Compare commits
18
Commits
7fcc2927c4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53fbb78905 | ||
|
|
9b2fd0ee92 | ||
|
|
893823fa1d | ||
|
|
e15951f362 | ||
|
|
ea945a37f8 | ||
|
|
90ba835868 | ||
|
|
ffeb199ef5 | ||
|
|
950b7c4672 | ||
|
|
08edeed8bb | ||
|
|
f4cf649318 | ||
|
|
ee0d5f8f7c | ||
|
|
634625dbfb | ||
|
|
e3edb87fac | ||
|
|
8b6620cd3d | ||
|
|
54c9acb0d6 | ||
|
|
33ba2a78b2 | ||
|
|
886df7429f | ||
|
|
e9c559c001 |
No files matched your search
+4
-2
@@ -14,8 +14,10 @@ services:
|
||||
- DCC_OPENROUTER_MODEL=${DCC_OPENROUTER_MODEL:-openrouter/free}
|
||||
- DCC_OLLAMA_URL=${DCC_OLLAMA_URL:-http://192.168.0.50:11434}
|
||||
- DCC_OLLAMA_MODEL=${DCC_OLLAMA_MODEL:-gemma2:9b}
|
||||
- DCC_LOCAL_URL=${DCC_LOCAL_URL:-https://opencode.ai/zen/v1}
|
||||
- DCC_LOCAL_URL=${DCC_LOCAL_URL:-https://opencode.ai/zen/go/v1}
|
||||
- DCC_LOCAL_KEY=${DCC_LOCAL_KEY:-}
|
||||
- DCC_LOCAL_MODEL=${DCC_LOCAL_MODEL:-big-pickle}
|
||||
- DCC_LOCAL_MODEL=${DCC_LOCAL_MODEL:-deepseek-v4-flash}
|
||||
- DCC_LOCAL_FALLBACK_URL=${DCC_LOCAL_FALLBACK_URL:-https://opencode.ai/zen/v1}
|
||||
- DCC_LOCAL_FALLBACK_MODEL=${DCC_LOCAL_FALLBACK_MODEL:-big-pickle}
|
||||
- DCC_LLM_TIMEOUT=${DCC_LLM_TIMEOUT:-120}
|
||||
restart: unless-stopped
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 250 KiB |
@@ -15,22 +15,26 @@ class ProviderConfig:
|
||||
openrouter_model: str = "openrouter/free"
|
||||
openrouter_key_env: str = "DCC_OPENROUTER_KEY"
|
||||
openrouter_free_fallback: bool = True
|
||||
local_base_url: str = "https://opencode.ai/zen/v1"
|
||||
local_base_url: str = "https://opencode.ai/zen/go/v1"
|
||||
local_api_key: str = ""
|
||||
local_model: str = "big-pickle"
|
||||
local_model: str = "deepseek-v4-flash"
|
||||
local_fallback_url: str = "https://opencode.ai/zen/v1"
|
||||
local_fallback_model: str = "big-pickle"
|
||||
timeout: int = 120
|
||||
|
||||
|
||||
def load_from_env() -> ProviderConfig:
|
||||
return ProviderConfig(
|
||||
kind=os.getenv("DCC_LLM_PROVIDER", "openrouter"), # type: ignore
|
||||
kind=os.getenv("DCC_LLM_PROVIDER", "local"), # type: ignore
|
||||
ollama_base_url=os.getenv("DCC_OLLAMA_URL", "http://192.168.0.50:11434"),
|
||||
ollama_model=os.getenv("DCC_OLLAMA_MODEL", "gemma2:9b"),
|
||||
openrouter_api_key=os.getenv("DCC_OPENROUTER_KEY", os.getenv("OPENROUTER_API_KEY", "")),
|
||||
openrouter_model=os.getenv("DCC_OPENROUTER_MODEL", "openrouter/free"),
|
||||
openrouter_free_fallback=os.getenv("DCC_OR_FREE_FALLBACK", "1") == "1",
|
||||
local_base_url=os.getenv("DCC_LOCAL_URL", "http://192.168.0.240/v1"),
|
||||
local_base_url=os.getenv("DCC_LOCAL_URL", "https://opencode.ai/zen/v1"),
|
||||
local_api_key=os.getenv("DCC_LOCAL_KEY", ""),
|
||||
local_model=os.getenv("DCC_LOCAL_MODEL", "big-pickle"),
|
||||
timeout=int(os.getenv("DCC_LLM_TIMEOUT", "60")),
|
||||
local_fallback_url=os.getenv("DCC_LOCAL_FALLBACK_URL", "https://opencode.ai/zen/go/v1"),
|
||||
local_fallback_model=os.getenv("DCC_LOCAL_FALLBACK_MODEL", "deepseek-v4-flash"),
|
||||
timeout=int(os.getenv("DCC_LLM_TIMEOUT", "120")),
|
||||
)
|
||||
+67
-1
@@ -4,15 +4,21 @@ Database module per DCC RPG — Neon Scratch Lounge.
|
||||
Gestisce schema SQLite e popolazione iniziale dati.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
DB_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "dcc_rpg.db"
|
||||
DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data"
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_conn_lock = threading.Lock()
|
||||
_local = threading.local()
|
||||
|
||||
@@ -37,6 +43,39 @@ def get_conn() -> sqlite3.Connection:
|
||||
return conn
|
||||
|
||||
|
||||
def begin_write():
|
||||
"""Acquisisce lock di scrittura e avvia transazione IMMEDIATE.
|
||||
|
||||
SQLite permette un solo writer per volta, anche in WAL mode.
|
||||
Questo lock serializza le scritture tra thread diversi,
|
||||
evitando race condition quando due richieste modificano
|
||||
lo stesso stato contemporaneamente.
|
||||
"""
|
||||
_conn_lock.acquire()
|
||||
conn = get_conn()
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
|
||||
|
||||
def end_write():
|
||||
"""Commuta la transazione e rilascia il lock di scrittura."""
|
||||
conn = get_conn()
|
||||
conn.commit()
|
||||
_conn_lock.release()
|
||||
|
||||
|
||||
def _write(sql: str, params: tuple = ()) -> sqlite3.Cursor:
|
||||
"""Esegue una scrittura SQL con lock."""
|
||||
begin_write()
|
||||
try:
|
||||
cur = get_conn().execute(sql, params)
|
||||
end_write()
|
||||
return cur
|
||||
except Exception:
|
||||
get_conn().rollback()
|
||||
_conn_lock.release()
|
||||
raise
|
||||
|
||||
|
||||
def init_schema(conn: sqlite3.Connection) -> None:
|
||||
conn.executescript("""
|
||||
-- ============================================================
|
||||
@@ -239,6 +278,33 @@ def init_db() -> sqlite3.Connection:
|
||||
return conn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BACKUP_DIR = DB_PATH.parent / "backups"
|
||||
|
||||
|
||||
def backup_db() -> str:
|
||||
"""Copia il database in data/backups/dcc_rpg_YYYY-MM-DD_HHMMSS.db.
|
||||
Mantiene al massimo 20 backup, rimuove i piu vecchi."""
|
||||
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
||||
dest = BACKUP_DIR / f"dcc_rpg_{ts}.db"
|
||||
try:
|
||||
shutil.copy2(str(DB_PATH), str(dest))
|
||||
log.info("Backup DB creato: %s", dest.name)
|
||||
except FileNotFoundError:
|
||||
log.warning("Nessun DB da backupare (file non trovato)")
|
||||
return ""
|
||||
# Pulisce backup vecchi (mantiene 20)
|
||||
backups = sorted(BACKUP_DIR.glob("dcc_rpg_*.db"), reverse=True)
|
||||
for old in backups[20:]:
|
||||
old.unlink()
|
||||
log.info("Rimosso backup vecchio: %s", old.name)
|
||||
return str(dest)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Query helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+42
-7
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from src.engine import db, tools
|
||||
from src.engine.config import load_from_env
|
||||
@@ -11,13 +13,30 @@ from src.engine.prompts import SYSTEM_PROMPT, build_prompt
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Lock per-campagna: evita race condition quando due richieste
|
||||
# arrivano simultaneamente per la stessa campagna.
|
||||
# La chiave è campaign_id, il valore è un threading.Lock.
|
||||
_campaign_locks: dict[int, threading.Lock] = {}
|
||||
_campaign_locks_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_campaign_lock(campaign_id: int) -> threading.Lock:
|
||||
with _campaign_locks_lock:
|
||||
if campaign_id not in _campaign_locks:
|
||||
_campaign_locks[campaign_id] = threading.Lock()
|
||||
return _campaign_locks[campaign_id]
|
||||
|
||||
|
||||
def _llm_decision(ctx: GameContext, player_action: str, current_area_id: str = "") -> tuple[str, dict, str]:
|
||||
prompt = build_prompt(ctx, player_action, current_area_id=current_area_id)
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
raw = llm_ask(prompt, system=SYSTEM_PROMPT)
|
||||
elapsed = time.perf_counter() - t0
|
||||
log.info("[LLM] azione=%s tool=? latenza=%.1fs raw_len=%d", player_action[:40], elapsed, len(raw))
|
||||
except Exception as e:
|
||||
log.warning("LLM call failed (%s), fallback a heuristic parser", e)
|
||||
elapsed = time.perf_counter() - t0
|
||||
log.warning("[LLM] fallback heuristico dopo %.1fs: %s", elapsed, e)
|
||||
tool, params = fallback_parse(player_action, ctx.characters)
|
||||
return tool, params, ""
|
||||
|
||||
@@ -28,17 +47,13 @@ def _llm_decision(ctx: GameContext, player_action: str, current_area_id: str = "
|
||||
narration = decision.get("narration", "")
|
||||
|
||||
if not tool:
|
||||
log.info("LLM non ha prodotto tool valido, fallback heuristico")
|
||||
log.info("[DM] tool non valido, fallback heuristico")
|
||||
tool, params = fallback_parse(player_action, ctx.characters)
|
||||
|
||||
# Se il JSON era malformato e params sono vuoti, usa l'euristico per i parametri
|
||||
elif not params and tool not in ("dm_override",):
|
||||
log.info("Tool %s senza params, integro con fallback euristico", tool)
|
||||
log.info("[DM] tool=%s senza params, integro euristico", tool)
|
||||
_, fallback_params = fallback_parse(player_action, ctx.characters)
|
||||
params = fallback_params
|
||||
|
||||
# dm_override/narrate: il LLM spesso mette il testo in params.params.text
|
||||
# invece che nel campo narration JSON — lo estraiamo qui
|
||||
if tool == "dm_override" and not narration:
|
||||
inner = params.get("params", {}) if isinstance(params, dict) else {}
|
||||
if isinstance(inner, dict):
|
||||
@@ -50,6 +65,16 @@ def _llm_decision(ctx: GameContext, player_action: str, current_area_id: str = "
|
||||
|
||||
|
||||
def dm_cycle(campaign_id: int, player_action: str) -> dict:
|
||||
lock = _get_campaign_lock(campaign_id)
|
||||
lock.acquire()
|
||||
try:
|
||||
return _dm_cycle_impl(campaign_id, player_action)
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
|
||||
def _dm_cycle_impl(campaign_id: int, player_action: str) -> dict:
|
||||
t0 = time.perf_counter()
|
||||
ctx = GameContext.load(campaign_id)
|
||||
|
||||
tool_name, tool_params, narration = _llm_decision(ctx, player_action, current_area_id=ctx.current_area_id)
|
||||
@@ -66,6 +91,16 @@ def dm_cycle(campaign_id: int, player_action: str) -> dict:
|
||||
turn = db.advance_turn(campaign_id)
|
||||
db.log_turn(campaign_id, turn, player_action, full_msg)
|
||||
|
||||
# Backup ogni 5 turni
|
||||
if turn > 0 and turn % 5 == 0:
|
||||
db.backup_db()
|
||||
|
||||
elapsed = time.perf_counter() - t0
|
||||
log.info(
|
||||
"[CYCLE] turn=%d tool=%s action=%s latenza=%.1fs success=%s",
|
||||
turn, tool_name, player_action[:40], elapsed, result.get("success", True),
|
||||
)
|
||||
|
||||
return {
|
||||
"turn": turn,
|
||||
"action": player_action,
|
||||
|
||||
+41
-4
@@ -1,14 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
|
||||
from src.engine.providers import LLMProvider, create_provider
|
||||
|
||||
|
||||
_provider_var: contextvars.ContextVar[LLMProvider | None] = contextvars.ContextVar("provider", default=None)
|
||||
|
||||
# Cache risposte LLM: {cache_key: (timestamp, response)}
|
||||
_cache: dict[str, tuple[float, str]] = {}
|
||||
_CACHE_TTL = 300 # 5 minuti
|
||||
|
||||
|
||||
def _cache_key(prompt: str, system: str) -> str:
|
||||
return hashlib.sha256(f"{system}||{prompt}".encode()).hexdigest()
|
||||
|
||||
|
||||
def _cache_get(prompt: str, system: str) -> str | None:
|
||||
key = _cache_key(prompt, system)
|
||||
entry = _cache.get(key)
|
||||
if entry and time.monotonic() - entry[0] < _CACHE_TTL:
|
||||
return entry[1]
|
||||
if entry:
|
||||
del _cache[key]
|
||||
return None
|
||||
|
||||
|
||||
def _cache_set(prompt: str, system: str, response: str) -> None:
|
||||
key = _cache_key(prompt, system)
|
||||
_cache[key] = (time.monotonic(), response)
|
||||
# Mantieni cache sotto 100 entry
|
||||
if len(_cache) > 100:
|
||||
oldest = min(_cache.keys(), key=lambda k: _cache[k][0])
|
||||
del _cache[oldest]
|
||||
|
||||
|
||||
def get_provider() -> LLMProvider:
|
||||
provider = _provider_var.get()
|
||||
@@ -24,7 +53,12 @@ def set_provider(provider: LLMProvider) -> None:
|
||||
|
||||
|
||||
def ask(prompt: str, system: str = "") -> str:
|
||||
return get_provider().ask(prompt, system=system)
|
||||
cached = _cache_get(prompt, system)
|
||||
if cached is not None:
|
||||
return cached
|
||||
response = get_provider().ask(prompt, system=system)
|
||||
_cache_set(prompt, system, response)
|
||||
return response
|
||||
|
||||
|
||||
def _try_repair_json(text: str) -> str | None:
|
||||
@@ -34,16 +68,19 @@ def _try_repair_json(text: str) -> str | None:
|
||||
return None
|
||||
blob = m.group(1)
|
||||
|
||||
# Fix 1: chiavi con apici singoli → doppi ("params' " → "params": ")
|
||||
# Fix 1: chiavi con apici singoli → doppi ("params' " → "params")
|
||||
blob = re.sub(r"(?<!\\)'(params|narration|tool|command|text|character_id|dc|modifier|die|count)"
|
||||
r"'(?=\s*[:=])", r'"\1":', blob)
|
||||
r"'(?=\s*[:=])", r'"\1"', blob)
|
||||
# Fix 2: qualsiasi altra chiave con apici singoli
|
||||
blob = re.sub(r"(?<!\\)'(\w+)'(?=\s*[:=])", r'"\1":', blob)
|
||||
blob = re.sub(r"(?<!\\)'(\w+)'(?=\s*[:=])", r'"\1"', blob)
|
||||
# Fix 3: trailing comma prima di } o ]
|
||||
blob = re.sub(r',\s*}', '}', blob)
|
||||
blob = re.sub(r',\s*\]', ']', blob)
|
||||
# Fix 4: = al posto di : nelle chiavi
|
||||
blob = re.sub(r'"(\w+)"\s*=', r'"\1":', blob)
|
||||
# Fix 5: valori con apici singoli → doppi (dopo aver riparato le chiavi)
|
||||
# 'valore' → "valore"
|
||||
blob = re.sub(r"'([^']*?)'(?=\s*[,}\]])", r'"\1"', blob)
|
||||
try:
|
||||
json.loads(blob)
|
||||
return blob
|
||||
|
||||
+25
-5
@@ -84,13 +84,15 @@ class OpenRouterProvider(LLMProvider):
|
||||
|
||||
|
||||
class LocalProvider(LLMProvider):
|
||||
def __init__(self, base_url: str, model: str, api_key: str = "", timeout: int = 60):
|
||||
def __init__(self, base_url: str, model: str, api_key: str = "",
|
||||
timeout: int = 60, fallbacks: list[tuple[str, str]] | None = None):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.model = model
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self.fallbacks = fallbacks or []
|
||||
|
||||
def ask(self, prompt: str, system: str = "") -> str:
|
||||
def _call(self, base_url: str, model: str, prompt: str, system: str) -> str:
|
||||
messages = []
|
||||
if system:
|
||||
messages.append({"role": "system", "content": system})
|
||||
@@ -100,10 +102,10 @@ class LocalProvider(LLMProvider):
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
|
||||
body = {"model": self.model, "messages": messages, "stream": False}
|
||||
body = {"model": model, "messages": messages, "stream": False}
|
||||
|
||||
resp = httpx.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
f"{base_url}/chat/completions",
|
||||
json=body,
|
||||
headers=headers,
|
||||
timeout=self.timeout,
|
||||
@@ -112,6 +114,21 @@ class LocalProvider(LLMProvider):
|
||||
data = resp.json()
|
||||
return data["choices"][0]["message"]["content"]
|
||||
|
||||
def ask(self, prompt: str, system: str = "") -> str:
|
||||
candidates = [(self.base_url, self.model)] + self.fallbacks
|
||||
last_err = None
|
||||
for base_url, model in candidates:
|
||||
try:
|
||||
text = self._call(base_url, model, prompt, system)
|
||||
# Rileva risposte di censura del modello gratuito
|
||||
if "User Safety" in text and "safe" in text.lower():
|
||||
raise ValueError(f"Modello '{model}' ha censurato la risposta: {text[:60]}")
|
||||
return text
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
continue
|
||||
raise last_err # type: ignore
|
||||
|
||||
|
||||
_registry: dict[str, Any] = {
|
||||
"ollama": OllamaProvider,
|
||||
@@ -136,6 +153,9 @@ def create_provider(cfg: ProviderConfig | None = None) -> LLMProvider:
|
||||
raise ValueError("DCC_OPENROUTER_KEY non configurata")
|
||||
return cls(key, cfg.openrouter_model, cfg.timeout, cfg.openrouter_free_fallback)
|
||||
elif cfg.kind == "local":
|
||||
return cls(cfg.local_base_url, cfg.local_model, cfg.local_api_key, cfg.timeout)
|
||||
fallbacks = []
|
||||
if cfg.local_fallback_url and cfg.local_fallback_model:
|
||||
fallbacks.append((cfg.local_fallback_url, cfg.local_fallback_model))
|
||||
return cls(cfg.local_base_url, cfg.local_model, cfg.local_api_key, cfg.timeout, fallbacks=fallbacks)
|
||||
else:
|
||||
raise ValueError(f"Provider non implementato: {cfg.kind}")
|
||||
@@ -425,6 +425,17 @@ def dm_override(command: str, params: dict | None = None) -> ToolResult:
|
||||
"commands": manual,
|
||||
"message": "Comandi DM disponibili: " + ", ".join(manual.keys()),
|
||||
}
|
||||
|
||||
if command == "narrate":
|
||||
text = (params or {}).get("text", "")
|
||||
return {
|
||||
"success": True,
|
||||
"tool": "dm-override",
|
||||
"command": command,
|
||||
"params": params or {},
|
||||
"message": text or "Override DM: narrate applicato.",
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"tool": "dm-override",
|
||||
|
||||
+54
-1
@@ -4,6 +4,7 @@ import json
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
@@ -15,6 +16,7 @@ from src.engine import db
|
||||
from src.engine.config import load_from_env
|
||||
from src.engine.dm import dm_cycle
|
||||
from src.engine.game_context import GameContext
|
||||
from src.engine.tools import run as run_tool
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -139,6 +141,19 @@ def add_character(
|
||||
|
||||
_executor = ThreadPoolExecutor(max_workers=4)
|
||||
|
||||
# Rate limiting: almeno 1.5s tra azioni dello stesso client
|
||||
_last_action: dict[str, float] = {}
|
||||
_RATE_LIMIT = 1.5
|
||||
|
||||
|
||||
def _check_rate(client_id: str) -> str | None:
|
||||
now = time.monotonic()
|
||||
last = _last_action.get(client_id, 0)
|
||||
if now - last < _RATE_LIMIT:
|
||||
return f"Troppo veloce! Aspetta {_RATE_LIMIT - (now - last):.1f}s"
|
||||
_last_action[client_id] = now
|
||||
return None
|
||||
|
||||
|
||||
@app.websocket("/ws/{campaign_id}")
|
||||
async def game_ws(ws: WebSocket, campaign_id: int):
|
||||
@@ -170,6 +185,13 @@ async def game_ws(ws: WebSocket, campaign_id: int):
|
||||
if not player_action:
|
||||
continue
|
||||
|
||||
# Rate limiting
|
||||
if ws.client:
|
||||
err = _check_rate(str(ws.client))
|
||||
if err:
|
||||
await ws.send_json({"type": "error", "message": err})
|
||||
continue
|
||||
|
||||
try:
|
||||
# dm_cycle è bloccante (chiamata HTTP al LLM) —
|
||||
# la eseguiamo in un thread worker per non bloccare
|
||||
@@ -205,6 +227,30 @@ async def game_ws(ws: WebSocket, campaign_id: int):
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
|
||||
elif msg.get("type") == "dm":
|
||||
command = msg.get("command", "help")
|
||||
params = msg.get("params", {})
|
||||
try:
|
||||
result = run_tool("dm_override", command=command, params=params)
|
||||
await ws.send_json({
|
||||
"type": "response",
|
||||
"tool": "dm-override",
|
||||
"narration": result.get("message", ""),
|
||||
"mechanics": "",
|
||||
"turn": 0,
|
||||
})
|
||||
# Send updated state after DM command (e.g. set_hp changes HP)
|
||||
try:
|
||||
await ws.send_json({"type": "state", "campaign": _build_state(campaign_id)})
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
except Exception as e:
|
||||
log.exception("DM override failed")
|
||||
try:
|
||||
await ws.send_json({"type": "error", "message": str(e)})
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
|
||||
|
||||
def _build_state(campaign_id: int) -> dict:
|
||||
ctx = GameContext.load(campaign_id)
|
||||
@@ -224,12 +270,18 @@ def _build_state(campaign_id: int) -> dict:
|
||||
"name": full.get("name", aid),
|
||||
"visited": bool(sa.get("visited")),
|
||||
"connections": conn_names,
|
||||
"description": full.get("description", "")[:300],
|
||||
"atmosphere": full.get("atmosphere", ""),
|
||||
"features": full.get("features", []),
|
||||
})
|
||||
return {
|
||||
"turn": ctx.turn,
|
||||
"characters": ctx.characters,
|
||||
"quests": ctx.quests,
|
||||
"areas": enriched,
|
||||
"current_area_id": ctx.current_area_id,
|
||||
"current_area_name": ctx.current_area_name,
|
||||
"log": ctx.log,
|
||||
}
|
||||
|
||||
|
||||
@@ -260,9 +312,10 @@ async def index():
|
||||
def main():
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
format="%(asctime)s [%(levelname)-5s] %(name)s: %(message)s",
|
||||
)
|
||||
db.init_db()
|
||||
db.backup_db()
|
||||
cfg = load_from_env()
|
||||
log.info(
|
||||
"DM config: provider=%s model=%s",
|
||||
|
||||
+571
-21
@@ -233,27 +233,118 @@ html, body {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Funnel - char dead */
|
||||
.char-card.dead {
|
||||
opacity: 0.4;
|
||||
filter: grayscale(0.8);
|
||||
}
|
||||
.char-card.dead .char-name { color: var(--red); }
|
||||
.funnel-actions {
|
||||
padding: 8px 0;
|
||||
}
|
||||
.btn-funnel {
|
||||
width: 100%;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px dashed var(--border-light);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
.btn-funnel:hover {
|
||||
background: var(--gold-dim);
|
||||
color: #fff;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
/* Area map */
|
||||
.area-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px 6px;
|
||||
padding: 5px 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
border-left: 2px solid transparent;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
/* Area preview */
|
||||
#area-preview-section { display: none; }
|
||||
#area-preview-section.visible { display: block; }
|
||||
#area-preview-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--gold);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
#area-preview-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Turn log */
|
||||
#log-display {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
font-size: 12px;
|
||||
}
|
||||
.log-entry {
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.log-entry:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.log-turn { color: var(--gold-dim); font-weight: 700; font-size: 10px; }
|
||||
.log-action { color: var(--text-primary); }
|
||||
.log-result { color: var(--text-muted); font-size: 11px; }
|
||||
|
||||
.area-item.visited { color: var(--text-secondary); }
|
||||
.area-item.current {
|
||||
color: var(--gold-bright);
|
||||
border-left-color: var(--gold);
|
||||
background: rgba(201,168,76,0.06);
|
||||
}
|
||||
.area-item.visited { color: var(--text-primary); }
|
||||
.area-marker {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.area-item.visited .area-marker { background: var(--text-secondary); }
|
||||
.area-item.current .area-marker { background: var(--gold); box-shadow: 0 0 6px var(--gold); }
|
||||
.area-id {
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
color: var(--gold-dim);
|
||||
min-width: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.area-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
}
|
||||
.area-here {
|
||||
font-size: 12px;
|
||||
color: var(--gold);
|
||||
margin-left: auto;
|
||||
}
|
||||
.area-item.visited .area-marker { background: var(--gold); box-shadow: 0 0 4px var(--gold); }
|
||||
.area-conns {
|
||||
width: 100%;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-left: 14px;
|
||||
padding-left: 36px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Zone vuote */
|
||||
@@ -263,6 +354,65 @@ html, body {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* DM Override panel */
|
||||
.dm-toggle {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
opacity: 0.6;
|
||||
font-size: 12px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.dm-toggle:hover { opacity: 1; }
|
||||
.dm-panel {
|
||||
padding: 6px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.dm-panel.hidden { display: none; }
|
||||
.dm-panel select,
|
||||
.dm-panel input,
|
||||
.dm-panel textarea {
|
||||
width: 100%;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 6px 8px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
.dm-panel select:focus,
|
||||
.dm-panel input:focus,
|
||||
.dm-panel textarea:focus {
|
||||
border-color: var(--gold-dim);
|
||||
}
|
||||
.dm-panel textarea {
|
||||
min-height: 60px;
|
||||
resize: vertical;
|
||||
}
|
||||
#btn-dm-exec {
|
||||
background: var(--red);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
padding: 6px;
|
||||
color: #fff;
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
#btn-dm-exec:hover { background: #c04040; }
|
||||
.dm-result {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 0;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Chat Area
|
||||
============================================================ */
|
||||
@@ -360,6 +510,44 @@ html, body {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Character selector (above input)
|
||||
============================================================ */
|
||||
.char-selector {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 6px 20px;
|
||||
background: var(--bg-surface);
|
||||
border-top: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.char-selector::-webkit-scrollbar { display: none; }
|
||||
.char-selector.hidden { display: none; }
|
||||
.char-chip {
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 3px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, color 0.2s, background 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.char-chip:hover { border-color: var(--gold-dim); color: var(--text-primary); }
|
||||
.char-chip.active {
|
||||
background: var(--gold-dim);
|
||||
border-color: var(--gold);
|
||||
color: #fff;
|
||||
}
|
||||
.char-chip.dead {
|
||||
opacity: 0.3;
|
||||
text-decoration: line-through;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Input Area
|
||||
============================================================ */
|
||||
@@ -388,7 +576,7 @@ html, body {
|
||||
}
|
||||
#input-area input::placeholder { color: var(--text-muted); }
|
||||
#input-area input:disabled {
|
||||
opacity: 0.4;
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
#btn-send {
|
||||
@@ -447,9 +635,14 @@ html, body {
|
||||
#welcome .camp-list {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
padding-right: 4px;
|
||||
}
|
||||
#welcome .camp-btn {
|
||||
background: var(--bg-card);
|
||||
@@ -534,6 +727,132 @@ html, body {
|
||||
margin-right: 12px;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Hamburger menu button */
|
||||
#btn-menu {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Sidebar backdrop (mobile) */
|
||||
#sidebar-backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 8;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Mobile Responsive
|
||||
============================================================ */
|
||||
@media (max-width: 768px) {
|
||||
#btn-menu { display: block; }
|
||||
|
||||
#sidebar {
|
||||
position: fixed;
|
||||
left: -320px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 300px;
|
||||
min-width: 300px;
|
||||
z-index: 9;
|
||||
transition: left 0.25s ease;
|
||||
box-shadow: 4px 0 20px rgba(0,0,0,0.5);
|
||||
}
|
||||
#sidebar.open { left: 0; }
|
||||
#sidebar-backdrop.open { display: block; }
|
||||
|
||||
#app { flex-direction: column; height: 100dvh; }
|
||||
|
||||
#header {
|
||||
padding: 0 12px;
|
||||
gap: 10px;
|
||||
min-height: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
#header h1 { font-size: 14px; letter-spacing: 1px; }
|
||||
#header .turn-badge { font-size: 11px; }
|
||||
|
||||
#main {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
#chat {
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.msg { max-width: 95%; }
|
||||
.msg-dm-narration { font-size: 15px; }
|
||||
|
||||
.char-selector {
|
||||
padding: 4px 12px;
|
||||
gap: 4px;
|
||||
}
|
||||
.char-chip {
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
#input-area {
|
||||
padding: 10px 12px;
|
||||
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
||||
gap: 8px;
|
||||
}
|
||||
#input-area input {
|
||||
font-size: 16px;
|
||||
padding: 12px;
|
||||
min-height: 44px;
|
||||
}
|
||||
#btn-send {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Welcome screen */
|
||||
#welcome h1 { font-size: 28px; }
|
||||
#welcome .subtitle { font-size: 14px; }
|
||||
.camp-list { max-width: 100%; padding: 0 12px; }
|
||||
.camp-btn {
|
||||
font-size: 15px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
#new-camp-form input { font-size: 16px; padding: 12px; }
|
||||
#new-camp-form button { font-size: 14px; padding: 12px 20px; }
|
||||
|
||||
/* Sidebar content */
|
||||
.sidebar-section { padding: 10px 12px; }
|
||||
.sidebar-section h2 { font-size: 10px; }
|
||||
.char-name { font-size: 14px; }
|
||||
.stat-grid { gap: 3px; }
|
||||
.stat-row { font-size: 11px; padding: 2px 4px; }
|
||||
.area-item { font-size: 12px; }
|
||||
.char-card { padding: 6px 0; }
|
||||
.char-class { font-size: 12px; margin-bottom: 6px; }
|
||||
.hp-bar-bg { height: 8px; }
|
||||
.hp-text { font-size: 12px; min-width: 40px; }
|
||||
.char-ac { font-size: 12px; }
|
||||
.quest-name { font-size: 13px; }
|
||||
.quest-desc { font-size: 12px; }
|
||||
.btn-funnel { padding: 10px; font-size: 10px; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
#sidebar { width: 260px; min-width: 260px; left: -260px; }
|
||||
#header h1 { font-size: 12px; }
|
||||
.msg-bubble { font-size: 14px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -559,15 +878,41 @@ html, body {
|
||||
<h2>Missioni</h2>
|
||||
<div id="quest-display"><div class="empty-state">Nessuna missione</div></div>
|
||||
</div>
|
||||
<div class="sidebar-section" id="area-preview-section" style="display:none;">
|
||||
<h2 id="area-preview-title">Luogo</h2>
|
||||
<div id="area-preview-desc"></div>
|
||||
<div id="area-preview-atmo" style="font-size:12px;color:var(--text-muted);font-style:italic;margin-top:4px;"></div>
|
||||
</div>
|
||||
<div class="sidebar-section" id="log-section">
|
||||
<h2>Cronaca</h2>
|
||||
<div id="log-display"><div class="empty-state">Nessuna azione</div></div>
|
||||
</div>
|
||||
<div class="sidebar-section" id="area-section">
|
||||
<h2>Aree</h2>
|
||||
<div id="area-display"><div class="empty-state">Nessuna area</div></div>
|
||||
</div>
|
||||
<div class="sidebar-section" id="dm-section">
|
||||
<h2 class="dm-toggle" onclick="toggleDM()">DM +</h2>
|
||||
<div id="dm-panel" class="dm-panel hidden">
|
||||
<select id="dm-cmd" onchange="onDMCommand()">
|
||||
<option value="narrate">Narrate (testo)</option>
|
||||
<option value="set_hp">Set HP</option>
|
||||
<option value="add_item">Add Item</option>
|
||||
<option value="force_roll">Force Roll</option>
|
||||
<option value="skip_combat">Skip Combat</option>
|
||||
</select>
|
||||
<div id="dm-fields"></div>
|
||||
<button id="btn-dm-exec" onclick="execDM()">Esegui</button>
|
||||
<div id="dm-result" class="dm-result"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sidebar-backdrop" class="hidden" onclick="toggleSidebar()"></div>
|
||||
|
||||
<!-- MAIN -->
|
||||
<div id="main">
|
||||
<div id="header">
|
||||
<button id="btn-menu" onclick="toggleSidebar()">☰</button>
|
||||
<div id="conn-indicator" class="conn-status disconnected"></div>
|
||||
<h1>Tomba del Mago Guerriero</h1>
|
||||
<div class="turn-badge">Turno <span id="turn-counter">0</span></div>
|
||||
@@ -575,6 +920,7 @@ html, body {
|
||||
<div id="chat">
|
||||
<div class="msg system"><div class="msg-bubble">Benvenuto, avventuriero. Scrivi la tua azione per iniziare.</div></div>
|
||||
</div>
|
||||
<div id="char-selector" class="char-selector hidden"></div>
|
||||
<div id="input-area">
|
||||
<input type="text" id="input" placeholder="Cosa vuoi fare?" disabled />
|
||||
<button id="btn-send" disabled>➤</button>
|
||||
@@ -592,6 +938,8 @@ let campaignId = null;
|
||||
let ws = null;
|
||||
let turn = 0;
|
||||
let reconnectTimer = null;
|
||||
let currentAreaId = '';
|
||||
let selectedCharId = null;
|
||||
let state = { characters: [], quests: [], areas: [] };
|
||||
|
||||
const HOST = window.location.host;
|
||||
@@ -699,7 +1047,7 @@ function connectWS() {
|
||||
connIndicator.className = 'conn-status connected';
|
||||
input.disabled = false;
|
||||
btnSend.disabled = false;
|
||||
input.focus();
|
||||
if (window.innerWidth > 768) input.focus();
|
||||
if (reconnectTimer) { clearInterval(reconnectTimer); reconnectTimer = null; }
|
||||
};
|
||||
|
||||
@@ -773,9 +1121,12 @@ function applyState(s) {
|
||||
turn = s.turn;
|
||||
turnCounter.textContent = turn;
|
||||
}
|
||||
if (s.current_area_id) currentAreaId = s.current_area_id;
|
||||
renderChar(s.characters);
|
||||
renderQuests(s.quests);
|
||||
renderAreas(s.areas);
|
||||
renderAreaPreview(s.areas, s.current_area_id);
|
||||
renderLog(s.log);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
@@ -786,19 +1137,22 @@ function renderChar(chars) {
|
||||
charDisplay.innerHTML = '<div class="empty-state">Nessun PG</div>';
|
||||
return;
|
||||
}
|
||||
const alive = chars.filter(c => c.status !== 'dead');
|
||||
const dead = chars.filter(c => c.status === 'dead');
|
||||
charDisplay.innerHTML = chars.map(c => {
|
||||
const isDead = c.status === 'dead';
|
||||
const isLv0 = c.level === 0;
|
||||
const hpPct = Math.round((c.hp_current / c.hp_max) * 100);
|
||||
const label = isLv0
|
||||
? `${c.occupation || 'Mestiere'} · Lv0`
|
||||
: `${c.char_class || ''} · Livello ${c.level}`;
|
||||
return `
|
||||
<div class="char-card" style="border-bottom:1px solid var(--border);padding:8px 0;">
|
||||
<div class="char-name">${escapeHtml(c.name)}</div>
|
||||
<div class="char-card ${isDead ? 'dead' : ''}" style="border-bottom:1px solid var(--border);padding:8px 0;">
|
||||
<div class="char-name">${isDead ? '💀 ' : ''}${escapeHtml(c.name)}</div>
|
||||
<div class="char-class">${escapeHtml(label)}</div>
|
||||
<div class="hp-bar-wrapper">
|
||||
<div class="hp-bar-bg"><div class="hp-bar-fill" style="width:${hpPct}%"></div></div>
|
||||
<span class="hp-text">${c.hp_current}/${c.hp_max}</span>
|
||||
<div class="hp-bar-bg"><div class="hp-bar-fill" style="width:${isDead ? 0 : hpPct}%"></div></div>
|
||||
<span class="hp-text">${isDead ? '✝ morto' : c.hp_current+'/'+c.hp_max}</span>
|
||||
</div>
|
||||
<div class="stat-grid">
|
||||
<div class="stat-row"><span class="stat-label">FOR</span><span class="stat-val">${c.str||'?'}</span></div>
|
||||
@@ -812,6 +1166,69 @@ function renderChar(chars) {
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
if (dead.length > 0) {
|
||||
charDisplay.innerHTML += `<div class="funnel-actions"><button onclick="replaceFunnelDead()" class="btn-funnel">➕ Nuovo PG (rimpiazza caduti)</button></div>`;
|
||||
}
|
||||
renderCharSelector(chars);
|
||||
}
|
||||
|
||||
function renderCharSelector(chars) {
|
||||
const container = document.getElementById('char-selector');
|
||||
const alive = chars.filter(c => c.status !== 'dead');
|
||||
if (alive.length <= 1) {
|
||||
container.classList.add('hidden');
|
||||
selectedCharId = null;
|
||||
return;
|
||||
}
|
||||
container.classList.remove('hidden');
|
||||
container.innerHTML = alive.map(c => `
|
||||
<button class="char-chip ${c.id === selectedCharId ? 'active' : ''}"
|
||||
onclick="selectChar(${c.id})">${escapeHtml(c.name).split(' il ')[0]}</button>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function selectChar(id) {
|
||||
selectedCharId = selectedCharId === id ? null : id;
|
||||
renderCharSelector(state.characters);
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function renderAreaPreview(areas, currentId) {
|
||||
const section = document.getElementById('area-preview-section');
|
||||
const title = document.getElementById('area-preview-title');
|
||||
const desc = document.getElementById('area-preview-desc');
|
||||
const atmo = document.getElementById('area-preview-atmo');
|
||||
const area = areas?.find(a => a.id === currentId);
|
||||
if (area) {
|
||||
section.classList.add('visible');
|
||||
title.textContent = area.name || currentId;
|
||||
desc.textContent = area.description || '';
|
||||
atmo.textContent = area.atmosphere || '';
|
||||
} else {
|
||||
section.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
function renderLog(log) {
|
||||
const container = document.getElementById('log-display');
|
||||
if (!log || log.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">Nessuna azione</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = [...log].reverse().slice(0, 10).map(e => `
|
||||
<div class="log-entry">
|
||||
<div class="log-turn">Turno ${e.turn}</div>
|
||||
<div class="log-action">${escapeHtml(e.action? e.action.slice(0, 80) : '?')}</div>
|
||||
<div class="log-result">${escapeHtml(e.result? e.result.slice(0, 60) : '')}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function replaceFunnelDead() {
|
||||
const resp = await fetch(`${BASE}/api/campaign/${campaignId}/character`, { method: 'POST' });
|
||||
const data = await resp.json();
|
||||
const s = await (await fetch(`${BASE}/api/campaign/${campaignId}`)).json();
|
||||
applyState(s);
|
||||
}
|
||||
|
||||
function renderQuests(quests) {
|
||||
@@ -832,15 +1249,140 @@ function renderAreas(areas) {
|
||||
areaDisplay.innerHTML = '<div class="empty-state">Nessuna area</div>';
|
||||
return;
|
||||
}
|
||||
areaDisplay.innerHTML = areas.map(a => `
|
||||
<div class="area-item ${a.visited ? 'visited' : ''}">
|
||||
<span class="area-marker"></span>
|
||||
<span>${escapeHtml(a.name)}</span>
|
||||
</div>
|
||||
${a.connections && a.connections.length ? `<div class="area-conns">→ ${a.connections.join(', ')}</div>` : ''}
|
||||
`).join('');
|
||||
// Sort by area id (1.1, 1.2, ..., 1.9)
|
||||
const sorted = [...areas].sort((a, b) => (a.id || '').localeCompare(b.id || ''));
|
||||
areaDisplay.innerHTML = sorted.map(a => {
|
||||
const isCurrent = a.id === currentAreaId;
|
||||
const status = isCurrent ? 'current' : (a.visited ? 'visited' : 'unvisited');
|
||||
const conns = (a.connections || []).join(' → ');
|
||||
return `
|
||||
<div class="area-item ${status}">
|
||||
<span class="area-marker"></span>
|
||||
<span class="area-id">${escapeHtml(a.id)}</span>
|
||||
<span class="area-name">${escapeHtml(a.name)}</span>
|
||||
${isCurrent ? '<span class="area-here">✧</span>' : ''}
|
||||
${conns ? `<div class="area-conns">→ ${escapeHtml(conns)}</div>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Mobile sidebar
|
||||
// ================================================================
|
||||
function toggleSidebar() {
|
||||
const sb = document.getElementById('sidebar');
|
||||
const backdrop = document.getElementById('sidebar-backdrop');
|
||||
const open = !sb.classList.contains('open');
|
||||
sb.classList.toggle('open', open);
|
||||
backdrop.classList.toggle('open', open);
|
||||
backdrop.classList.toggle('hidden', !open);
|
||||
}
|
||||
|
||||
// Close sidebar when swiping left on mobile
|
||||
let touchStartX = 0;
|
||||
document.addEventListener('touchstart', e => { touchStartX = e.touches[0].clientX; });
|
||||
document.addEventListener('touchend', e => {
|
||||
const sb = document.getElementById('sidebar');
|
||||
if (!sb.classList.contains('open')) return;
|
||||
const dx = touchStartX - e.changedTouches[0].clientX;
|
||||
if (dx > 80) toggleSidebar();
|
||||
});
|
||||
|
||||
|
||||
// ================================================================
|
||||
// DM Override
|
||||
// ================================================================
|
||||
let dmOpen = false;
|
||||
|
||||
function toggleDM() {
|
||||
dmOpen = !dmOpen;
|
||||
const panel = $('dm-panel');
|
||||
panel.classList.toggle('hidden', !dmOpen);
|
||||
if (dmOpen) onDMCommand();
|
||||
}
|
||||
|
||||
function onDMCommand() {
|
||||
const cmd = $('dm-cmd').value;
|
||||
const fields = $('dm-fields');
|
||||
$('dm-result').textContent = '';
|
||||
switch (cmd) {
|
||||
case 'narrate':
|
||||
fields.innerHTML = '<textarea id="dm-narrate-text" placeholder="Testo narrativo..."></textarea>';
|
||||
break;
|
||||
case 'set_hp':
|
||||
fields.innerHTML = `
|
||||
<input id="dm-char-id" type="number" placeholder="ID PG" min="1" />
|
||||
<input id="dm-hp-value" type="number" placeholder="PF" min="0" />
|
||||
`;
|
||||
break;
|
||||
case 'add_item':
|
||||
fields.innerHTML = `
|
||||
<input id="dm-char-id" type="number" placeholder="ID PG" min="1" />
|
||||
<input id="dm-item-name" placeholder="Nome oggetto" />
|
||||
`;
|
||||
break;
|
||||
case 'force_roll':
|
||||
fields.innerHTML = `
|
||||
<input id="dm-die" placeholder="dado (es. d20)" />
|
||||
<input id="dm-value" type="number" placeholder="Valore forzato" min="1" />
|
||||
`;
|
||||
break;
|
||||
case 'skip_combat':
|
||||
fields.innerHTML = '<input id="dm-area-id" placeholder="ID area (es. 1.4)" />';
|
||||
break;
|
||||
default:
|
||||
fields.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
function execDM() {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const cmd = $('dm-cmd').value;
|
||||
let params = {};
|
||||
const resultDiv = $('dm-result');
|
||||
resultDiv.textContent = 'Invio...';
|
||||
|
||||
switch (cmd) {
|
||||
case 'narrate': {
|
||||
const text = $('dm-narrate-text')?.value?.trim();
|
||||
if (!text) { resultDiv.textContent = 'Inserisci testo.'; return; }
|
||||
params = { text };
|
||||
break;
|
||||
}
|
||||
case 'set_hp': {
|
||||
const cid = parseInt($('dm-char-id')?.value);
|
||||
const hp = parseInt($('dm-hp-value')?.value);
|
||||
if (!cid || isNaN(hp)) { resultDiv.textContent = 'Specifica ID PG e PF.'; return; }
|
||||
params = { character_id: cid, value: hp };
|
||||
break;
|
||||
}
|
||||
case 'add_item': {
|
||||
const cid = parseInt($('dm-char-id')?.value);
|
||||
const item = $('dm-item-name')?.value?.trim();
|
||||
if (!cid || !item) { resultDiv.textContent = 'Specifica ID PG e oggetto.'; return; }
|
||||
params = { character_id: cid, item };
|
||||
break;
|
||||
}
|
||||
case 'force_roll': {
|
||||
const die = $('dm-die')?.value?.trim();
|
||||
const val = parseInt($('dm-value')?.value);
|
||||
if (!die || isNaN(val)) { resultDiv.textContent = 'Specifica dado e valore.'; return; }
|
||||
params = { die, forced_value: val };
|
||||
break;
|
||||
}
|
||||
case 'skip_combat': {
|
||||
const aid = $('dm-area-id')?.value?.trim();
|
||||
if (!aid) { resultDiv.textContent = 'Specifica ID area.'; return; }
|
||||
params = { area_id: aid };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify({ type: 'dm', command: cmd, params }));
|
||||
resultDiv.textContent = '✧ Comando inviato.';
|
||||
}
|
||||
|
||||
|
||||
// ================================================================
|
||||
// Send action
|
||||
// ================================================================
|
||||
@@ -848,14 +1390,22 @@ function sendAction() {
|
||||
const text = input.value.trim();
|
||||
if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const chars = state.characters || [];
|
||||
const sel = selectedCharId ? chars.find(c => c.id === selectedCharId) : null;
|
||||
|
||||
// Prepend selected character name if any
|
||||
let actionText = text;
|
||||
if (sel) actionText = `${sel.name}: ${text}`;
|
||||
|
||||
// Optimistic display
|
||||
const div = document.createElement('div');
|
||||
div.className = 'msg player';
|
||||
div.innerHTML = `<div class="msg-label">⚔️ Azione</div><div class="msg-bubble">${escapeHtml(text)}</div>`;
|
||||
const label = sel ? `⚔️ ${sel.name}` : '⚔️ Azione';
|
||||
div.innerHTML = `<div class="msg-label">${label}</div><div class="msg-bubble">${escapeHtml(text)}</div>`;
|
||||
chat.appendChild(div);
|
||||
scrollChat();
|
||||
|
||||
ws.send(JSON.stringify({ type: 'action', payload: text }));
|
||||
ws.send(JSON.stringify({ type: 'action', payload: actionText }));
|
||||
input.value = '';
|
||||
input.focus();
|
||||
}
|
||||
|
||||
@@ -16,9 +16,11 @@ export DCC_OPENROUTER_KEY="${DCC_OPENROUTER_KEY:-}"
|
||||
export DCC_OPENROUTER_MODEL="${DCC_OPENROUTER_MODEL:-openrouter/free}"
|
||||
export DCC_OLLAMA_URL="${DCC_OLLAMA_URL:-http://192.168.0.50:11434}"
|
||||
export DCC_OLLAMA_MODEL="${DCC_OLLAMA_MODEL:-gemma2:9b}"
|
||||
export DCC_LOCAL_URL="${DCC_LOCAL_URL:-https://opencode.ai/zen/v1}"
|
||||
export DCC_LOCAL_URL="${DCC_LOCAL_URL:-https://opencode.ai/zen/go/v1}"
|
||||
export DCC_LOCAL_KEY="${DCC_LOCAL_KEY:-}"
|
||||
export DCC_LOCAL_MODEL="${DCC_LOCAL_MODEL:-big-pickle}"
|
||||
export DCC_LOCAL_MODEL="${DCC_LOCAL_MODEL:-deepseek-v4-flash}"
|
||||
export DCC_LOCAL_FALLBACK_URL="${DCC_LOCAL_FALLBACK_URL:-https://opencode.ai/zen/v1}"
|
||||
export DCC_LOCAL_FALLBACK_MODEL="${DCC_LOCAL_FALLBACK_MODEL:-big-pickle}"
|
||||
|
||||
# ── create data dir ────────────────────────────────────────────────
|
||||
mkdir -p data
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Test integrità dati avventura — aree, mostri, quest, oggetti."""
|
||||
import sys
|
||||
sys.path.insert(0, "src")
|
||||
|
||||
from src.engine.db import (
|
||||
get_all_areas, get_area_by_id,
|
||||
get_monster_by_id, get_monsters_in_area,
|
||||
get_all_quest_defs, get_special_items,
|
||||
)
|
||||
|
||||
|
||||
def _load_data():
|
||||
"""Load data once per session."""
|
||||
# init_db is called automatically by _load_adventure_json
|
||||
return {
|
||||
"areas": get_all_areas(),
|
||||
"quests": get_all_quest_defs(),
|
||||
"items": get_special_items(),
|
||||
}
|
||||
|
||||
|
||||
def test_all_nine_areas_present():
|
||||
data = _load_data()
|
||||
assert len(data["areas"]) == 9
|
||||
ids = [a["id"] for a in data["areas"]]
|
||||
expected = [f"1.{i}" for i in range(1, 10)]
|
||||
for e in expected:
|
||||
assert e in ids, f"Manca area {e}"
|
||||
|
||||
|
||||
def test_each_area_has_name_and_description():
|
||||
for a in get_all_areas():
|
||||
assert a.get("name"), f"Area {a['id']} senza nome"
|
||||
assert a.get("description"), f"Area {a['id']} senza descrizione"
|
||||
|
||||
|
||||
def test_each_area_has_connections():
|
||||
for a in get_all_areas():
|
||||
conns = a.get("connections", [])
|
||||
assert len(conns) > 0, f"Area {a['id']} non ha connessioni"
|
||||
for c in conns:
|
||||
linked = get_area_by_id(c)
|
||||
assert linked is not None, (
|
||||
f"Area {a['id']} connette a '{c}' che non esiste"
|
||||
)
|
||||
|
||||
|
||||
def test_connections_are_bidirectional():
|
||||
"""Se A connette a B, B deve connettere ad A."""
|
||||
for a in get_all_areas():
|
||||
for c in a.get("connections", []):
|
||||
linked = get_area_by_id(c)
|
||||
assert linked is not None
|
||||
back = c in linked.get("connections", [])
|
||||
if not back:
|
||||
print(
|
||||
f"⚠ Area {a['id']}→{c} non ha connessione di ritorno"
|
||||
)
|
||||
|
||||
|
||||
def test_all_monsters_have_statblocks():
|
||||
monsters_seen = set()
|
||||
for a in get_all_areas():
|
||||
for mref in a.get("monsters", []):
|
||||
mid = mref.get("ref_id")
|
||||
assert mid, f"Area {a['id']} ha ref_id mostro vuoto"
|
||||
monster = get_monster_by_id(mid)
|
||||
assert monster is not None, (
|
||||
f"Mostro '{mid}' (in area {a['id']}) non trovato"
|
||||
)
|
||||
assert monster.get("name"), f"Mostro '{mid}' senza nome"
|
||||
stats = monster.get("stats", {})
|
||||
assert stats.get("ac"), f"Mostro '{mid}' senza CA"
|
||||
assert stats.get("hp"), f"Mostro '{mid}' senza PF"
|
||||
monsters_seen.add(mid)
|
||||
assert len(monsters_seen) >= 1, "Nessun mostro trovato nelle aree"
|
||||
|
||||
|
||||
def test_areas_have_no_duplicate_ids():
|
||||
ids = [a["id"] for a in get_all_areas()]
|
||||
assert len(ids) == len(set(ids)), "Trovati ID area duplicati"
|
||||
|
||||
|
||||
def test_quests_have_names():
|
||||
for q in get_all_quest_defs():
|
||||
assert q.get("name"), f"Quest senza nome: {q}"
|
||||
assert q.get("description"), f"Quest '{q['name']}' senza descrizione"
|
||||
|
||||
|
||||
def test_special_items_have_location():
|
||||
for i in get_special_items():
|
||||
assert i.get("name"), f"Item senza nome: {i}"
|
||||
loc = i.get("location", "")
|
||||
if loc in ("?", ""):
|
||||
print(f"⚠ Item '{i.get('name')}' senza posizione")
|
||||
|
||||
|
||||
def test_area_features_format():
|
||||
for a in get_all_areas():
|
||||
features = a.get("features", [])
|
||||
assert isinstance(features, list), (
|
||||
f"Area {a['id']}: features non è una lista"
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Test parsing risposte LLM — JSON extraction, repair, strip."""
|
||||
import sys
|
||||
sys.path.insert(0, "src")
|
||||
|
||||
from src.engine.llm import parse_tool_call, _try_repair_json, _strip_json_from_text
|
||||
|
||||
|
||||
def test_parse_valid_json():
|
||||
r = parse_tool_call(
|
||||
'{"tool": "roll_dice", "params": {"die": "d20"}, "narration": "Tiro il dado."}'
|
||||
)
|
||||
assert r["tool"] == "roll_dice"
|
||||
assert r["params"]["die"] == "d20"
|
||||
assert "Tiro" in r["narration"]
|
||||
|
||||
|
||||
def test_parse_valid_json_with_all_params():
|
||||
r = parse_tool_call(
|
||||
'{"tool": "combat", "params": {"character_id": 1, "target_ac": 15}, "narration": "Attacco!"}'
|
||||
)
|
||||
assert r["tool"] == "combat"
|
||||
assert r["params"]["character_id"] == 1
|
||||
assert r["params"]["target_ac"] == 15
|
||||
|
||||
|
||||
def test_parse_dm_override():
|
||||
r = parse_tool_call(
|
||||
'{"tool": "dm_override", "params": {"command": "narrate", "params": {"text": "Scena epica."}}, "narration": "Il mago appare."}'
|
||||
)
|
||||
assert r["tool"] == "dm_override"
|
||||
assert r["params"]["command"] == "narrate"
|
||||
|
||||
|
||||
def test_parse_plain_text_fallback():
|
||||
"""Testo senza JSON deve cascare sul fallback dm_override narrate."""
|
||||
r = parse_tool_call("Esploro la stanza buia con cautela")
|
||||
assert r["tool"] == "dm_override"
|
||||
assert r["params"]["command"] == "narrate"
|
||||
assert "stanza" in r["narration"]
|
||||
|
||||
|
||||
def test_parse_text_containing_json_residue():
|
||||
"""Testo con JSON completo + testo extra dopo.
|
||||
Il JSON viene parsato correttamente, il testo extra nella narrazione
|
||||
e gestito da dm.py (_llm_decision estrae da params.params.text)."""
|
||||
r = parse_tool_call(
|
||||
'{"tool": "dm_override", "params": {"command": "narrate", "params": {"text": "Cripta oscura."}}} La stanza e buia...'
|
||||
)
|
||||
assert r["tool"] == "dm_override"
|
||||
assert r["params"]["command"] == "narrate"
|
||||
|
||||
|
||||
def test_parse_single_quotes():
|
||||
"""JSON con apici singoli (errore comune LLM)."""
|
||||
r = parse_tool_call(
|
||||
"{'tool': 'roll_dice', 'params': {'die': 'd20'}, 'narration': 'Provo fortuna.'}"
|
||||
)
|
||||
assert r["tool"] == "roll_dice"
|
||||
assert r["params"]["die"] == "d20"
|
||||
|
||||
|
||||
def test_parse_mixed_quotes():
|
||||
"""Mix di apici singoli e doppi."""
|
||||
r = parse_tool_call(
|
||||
'{"tool": "ability_check", \'params\': {"character_id": 1, "dc": 12}, "narration": "Cerco tracce."}'
|
||||
)
|
||||
assert r["tool"] == "ability_check"
|
||||
assert r["params"]["character_id"] == 1
|
||||
|
||||
|
||||
def test_parse_trailing_commas():
|
||||
"""Virgole prima di } o ] (errore comune)."""
|
||||
r = parse_tool_call(
|
||||
'{"tool": "roll_dice", "params": {"die": "d20", "count": 2,}, "narration": "Doppio tiro.",}'
|
||||
)
|
||||
assert r["tool"] == "roll_dice"
|
||||
assert r["params"]["count"] == 2
|
||||
|
||||
|
||||
def test_parse_equals_instead_of_colon():
|
||||
"""= invece di : nelle chiavi."""
|
||||
r = parse_tool_call(
|
||||
'{"tool": "roll_dice", "params": {"die" = "d20"}, "narration": "Tiro."}'
|
||||
)
|
||||
assert r["tool"] == "roll_dice"
|
||||
|
||||
|
||||
def test_parse_backtick_code_block():
|
||||
r = parse_tool_call(
|
||||
"```json\n{\"tool\": \"roll_dice\", \"params\": {\"die\": \"d20\"}, \"narration\": \"Tiro.\"}\n```"
|
||||
)
|
||||
assert r["tool"] == "roll_dice"
|
||||
assert r["params"]["die"] == "d20"
|
||||
|
||||
|
||||
def test_parse_unknown_tool_uses_fallback():
|
||||
"""Tool sconosciuto deve cascare su dm_override narrate."""
|
||||
r = parse_tool_call(
|
||||
'{"tool": "danza_elfica", "params": {}, "narration": "Ballano."}'
|
||||
)
|
||||
assert r["tool"] in ("dm_override", "danza_elfica")
|
||||
|
||||
|
||||
def test_repair_json_single_quotes():
|
||||
result = _try_repair_json("{'tool': 'test', 'params': {}}")
|
||||
assert result is not None
|
||||
assert '"tool"' in result
|
||||
|
||||
|
||||
def test_repair_json_trailing_comma():
|
||||
result = _try_repair_json('{"tool": "test", "params": {},}')
|
||||
assert result is not None
|
||||
|
||||
|
||||
def test_repair_json_equals_sign():
|
||||
result = _try_repair_json('{"tool" = "test", "params" = {}}')
|
||||
assert result is not None
|
||||
|
||||
|
||||
def test_repair_json_invalid_returns_none():
|
||||
result = _try_repair_json("non è json")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_strip_json_removes_tool_call():
|
||||
cleaned = _strip_json_from_text(
|
||||
'{"tool": "ability_check", "params": {}, "narration": "Testo"} Druso si muove.'
|
||||
)
|
||||
assert "Druso" in cleaned
|
||||
assert "tool" not in cleaned
|
||||
|
||||
|
||||
def test_strip_json_partial_block():
|
||||
cleaned = _strip_json_from_text('{"tool": "test"} Avanza.')
|
||||
assert "Avanza" in cleaned
|
||||
|
||||
|
||||
def test_strip_json_empty_input():
|
||||
assert _strip_json_from_text("") == ""
|
||||
assert _strip_json_from_text(" ") == ""
|
||||
@@ -123,6 +123,39 @@ def test_dm_override_help():
|
||||
assert "commands" in r
|
||||
|
||||
|
||||
def test_update_quest_tool():
|
||||
camp_id, _ = _setup_char()
|
||||
import src.engine.db as db
|
||||
qid = db.add_quest(camp_id, "Missione Test", "Descrizione test")
|
||||
r = run("update_quest", quest_id=qid, status="completed")
|
||||
assert r["success"] is True
|
||||
assert r["status"] == "completed"
|
||||
assert "Missione Test" in r["message"]
|
||||
|
||||
|
||||
def test_update_quest_invalid_status():
|
||||
camp_id, _ = _setup_char()
|
||||
import src.engine.db as db
|
||||
qid = db.add_quest(camp_id, "Missione", "")
|
||||
r = run("update_quest", quest_id=qid, status="impossibile")
|
||||
assert r["success"] is False
|
||||
|
||||
|
||||
def test_update_quest_not_found():
|
||||
r = run("update_quest", quest_id=99999, status="completed")
|
||||
assert r["success"] is False
|
||||
assert "non trovata" in r["message"]
|
||||
|
||||
|
||||
def test_update_quest_with_name():
|
||||
camp_id, _ = _setup_char()
|
||||
import src.engine.db as db
|
||||
qid = db.add_quest(camp_id, "Vecchio Nome", "")
|
||||
r = run("update_quest", quest_id=qid, status="active", name="Nuovo Nome")
|
||||
assert r["success"] is True
|
||||
assert "Nuovo Nome" in r["name"]
|
||||
|
||||
|
||||
def test_combat():
|
||||
_, char_id = _setup_char()
|
||||
r = run(
|
||||
|
||||
Reference in new issue
Block a user