Compare commits

..
18 Commits
Author SHA1 Message Date
truetype 53fbb78905 fix: scrollbar su camp-list, pulite 155 campagne test
test / test (push) Has been cancelled
- camp-list con max-height:60vh + overflow-y:auto
- Rimosse 155 campagne test dal DB
- Rimossi 167 PG di test, 558 stati area
2026-07-01 11:49:08 +02:00
truetype 9b2fd0ee92 feat: rate limiting WS + cache risposte LLM
test / test (push) Has been cancelled
- Rate limiting: 1.5s tra azioni dello stesso client WS
- Cache LLM: SHA256 key, TTL 5 min, max 100 entry
- Cache riduce token spesi su azioni identiche ripetute
2026-07-01 11:29:05 +02:00
truetype 893823fa1d feat(ui): cronaca azioni nella sidebar
test / test (push) Has been cancelled
- Turn log mostra ultime 10 azioni con turno, azione e risultato
- Dati inclusi nello stato WS via ctx.log
- Ordinati dal piu recente
2026-07-01 11:25:51 +02:00
truetype e15951f362 feat(ui): anteprima area nella sidebar
test / test (push) Has been cancelled
- Aggiunta descrizione area corrente tra Missioni e Aree
- Mostra nome, descrizione e atmosfera dell'area
- Sezione visibile solo quando si e' in una area
- Dati arricchiti con description/atmosphere/features
2026-07-01 11:23:19 +02:00
truetype ea945a37f8 feat(ui): selettore PG nella chat
test / test (push) Has been cancelled
- Selettore personaggio sopra la barra input
- Solo PG vivi, nome abbreviato
- Click seleziona/deseleziona
- Nome preposto all azione: 'Druso: esploro...'
- Label azione mostra il nome del PG
2026-07-01 11:19:52 +02:00
truetype 90ba835868 feat: DeepSeek V4 Flash primario, Big Pickle fallback
test / test (push) Has been cancelled
- deepseek-v4-flash su opencode/go come modello primario
- big-pickle su opencode/zen come fallback (gratuito)
- Fallback automatico su censura "User Safety" o errore HTTP
- Aggiornati .env, config.py, start.sh, docker-compose.yml
2026-07-01 11:13:29 +02:00
truetype ffeb199ef5 fix: switch Big Pickle -> DeepSeek V4 Flash (censorship)
test / test (push) Has been cancelled
- Big Pickle (opencode/zen free) censura contenuti fantasy
  con "User Safety: safe" - blocca la narrazione del DM
- DeepSeek V4 Flash su opencode/go: $0.14/M token,
  nessuna censura, descrizioni fantasy corrette
- Aggiornati .env, start.sh, config.py
2026-07-01 10:13:41 +02:00
truetype 950b7c4672 fix: race condition su accessi concorrenti al DB
test / test (push) Has been cancelled
- Lock per-campagna in dm_cycle: serializza le richieste
  per la stessa campagna, evitando che due DM cycle
  leggano stato stale e si sovrascrivano
- begin_write()/end_write() in db.py per lock scritture
- _conn_lock finalmente usato (era definito ma mai acquisito)
- 72 test, tutti passano
2026-07-01 09:58:08 +02:00
truetype 08edeed8bb test: parser LLM + fix repair JSON
test / test (push) Has been cancelled
- test_llm_parser.py: 18 test per parse_tool_call, repair, strip
- Fix: repair raddoppiava i due punti nelle chiavi
- Fix: aggiunto passaggio per valori con apici singoli
- Copertura: JSON valido, apici singoli, misti, codeblock,
  virgole trailing, = invece di :, testo senza JSON
- 72 test totali, tutti passano
2026-07-01 09:44:49 +02:00
truetype f4cf649318 ✅ test: aggiunti test update_quest + integrità avventura
test / test (push) Has been cancelled
- test_tools.py: 4 test per update_quest (normale, status invalido,
  quest non trovata, rinomina)
- test_adventure.py: integrità dati (9 aree, connessioni bidirezionali,
  mostri con statblock, quest, items, features)
- Tutti 54 test passano in 0.69s
2026-07-01 09:41:35 +02:00
truetype ee0d5f8f7c 🐛 fix: CSS mobile rotto - rimosso CSS orfano fuori media query
test / test (push) Has been cancelled
- Rimosse 50 righe di CSS duplicate senza @media wrapper
- css rompeva layout desktop applicandosi a tutti gli schermi
- Chat msg piu larghe su mobile (95%)
- Touch target piu grandi (camp-btn 14px padding, input 44px)
- Stat grid piu compatto (font 11px)
- Welcome screen input/bottoni touch-friendly
2026-07-01 09:35:11 +02:00
truetype 634625dbfb feat(ui): supporto funnel - morte PG e rimpiazzo
test / test (push) Has been cancelled
- Card personaggi morti: grigia, teschio, 0 PF, filtro grayscale
- Bottone Nuovo PG quando ci sono caduti
- replaceFunnelDead() chiama POST /api/campaign/{id}/character
- Stato aggiornato dopo rimpiazzo
2026-06-30 17:25:43 +02:00
truetype e3edb87fac feat: backup DB automatico e logging strutturato
- backup_db() in db.py: copia in data/backups/*.db
- Backup a ogni avvio + ogni 5 turni
- Mantiene max 20 backup, rimuove i piu vecchi
- Logging strutturato: [LLM] latenza, raw_len
- Logging strutturato: [CYCLE] turn, tool, latenza, success
- Fix: import mancante threading in db.py
2026-06-29 19:07:33 +02:00
truetype 8b6620cd3d fix(mobile): input bar non visibile - usare 100dvh
- #app usa 100dvh su mobile (viewport dinamico)
- Input piu visibile anche se disabilitato (opacity 0.7)
- Touch target 44px per input e bottone
- flex-shrink:0 su btn-send
- safe-area padding aumentato
2026-06-29 18:47:37 +02:00
truetype 54c9acb0d6 fix(mobile): layout input visibile, hamburger menu
- Rimosso height:100dvh da #main (causava layout rotto)
- #main si affida a flex:1 per l'altezza
- hamburger ☰ sempre visibile nell'header su mobile
- Safe-area per notch iOS
2026-06-29 17:29:46 +02:00
truetype 33ba2a78b2 feat(ui): adattamento mobile responsive
- Hamburger button (☰) nell'header per aprire sidebar
- Sidebar come drawer overlay su mobile (<768px)
- Sidebar backdrop con chiusura al tap
- Swipe a sinistra per chiudere sidebar
- Safe-area per iPhone notch
- Font-size 16px su input (previene zoom iOS)
- dvh per altezza viewport mobile
- Auto-focus input solo su desktop
2026-06-29 16:38:52 +02:00
truetype 886df7429f feat(ui): pannello DM-Override nella sidebar
- Handler WS type:dm — esegue dm_override bypassando il LLM
- Pannello collassabile con selettore comandi e campi dinamici
- Comandi: narrate, set_hp, add_item, force_roll, skip_combat
- Stato aggiornato dopo DM override
- Implementato narrate con testo personalizzato
2026-06-29 16:28:34 +02:00
truetype e9c559c001 feat(ui): mappa aree nella sidebar
- Aggiunto current_area_id allo stato WS
- Mappa ordinata per ID area (1.1-1.9)
- Area corrente evidenziata con ✧ e bordo dorato
- Aree visitate con pallino pieno, non visitate vuoto
- Connessioni mostrate con nomi (non ID)
- CSS rinnovato per area-item
2026-06-28 23:35:36 +02:00
15 changed files with 1104 additions and 48 deletions

No files matched your search

+4 -2
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

+9 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}")
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
+4 -2
View File
@@ -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
+103
View File
@@ -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"
)
+140
View File
@@ -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(" ") == ""
+33
View File
@@ -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(