Compare commits

...
11 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
13 changed files with 618 additions and 73 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: 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")),
)
+33
View File
@@ -43,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("""
-- ============================================================
+23
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
import threading
import time
from src.engine import db, tools
@@ -12,6 +13,19 @@ 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)
@@ -51,6 +65,15 @@ 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)
+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}")
+25
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
@@ -140,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):
@@ -171,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
@@ -249,6 +270,9 @@ 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,
@@ -257,6 +281,7 @@ def _build_state(campaign_id: int) -> dict:
"areas": enriched,
"current_area_id": ctx.current_area_id,
"current_area_name": ctx.current_area_name,
"log": ctx.log,
}
+178 -55
View File
@@ -274,6 +274,40 @@ html, body {
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);
@@ -476,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
============================================================ */
@@ -563,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);
@@ -714,6 +791,18 @@ html, body {
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));
@@ -735,68 +824,28 @@ html, body {
#welcome h1 { font-size: 28px; }
#welcome .subtitle { font-size: 14px; }
.camp-list { max-width: 100%; padding: 0 12px; }
#new-camp-form { width: 100%; max-width: 100%; padding: 0 12px; }
/* 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: 12px; }
.area-item { font-size: 12px; }
}
#sidebar.open { left: 0; }
#sidebar-backdrop.open { display: block; }
#app { flex-direction: column; }
#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;
.camp-btn {
font-size: 15px;
padding: 14px 16px;
}
#input-area {
padding: 8px 10px;
padding-bottom: max(8px, env(safe-area-inset-bottom));
}
#input-area input {
font-size: 16px;
padding: 10px 12px;
}
#btn-send {
width: 44px;
height: 44px;
font-size: 20px;
}
/* Welcome screen */
#welcome h1 { font-size: 28px; }
#welcome .subtitle { font-size: 14px; }
.camp-list { max-width: 100%; padding: 0 12px; }
#new-camp-form { width: 100%; max-width: 100%; padding: 0 12px; }
#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: 12px; }
.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) {
@@ -829,6 +878,15 @@ 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>
@@ -862,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>
@@ -880,6 +939,7 @@ let ws = null;
let turn = 0;
let reconnectTimer = null;
let currentAreaId = '';
let selectedCharId = null;
let state = { characters: [], quests: [], areas: [] };
const HOST = window.location.host;
@@ -1065,6 +1125,8 @@ function applyState(s) {
renderChar(s.characters);
renderQuests(s.quests);
renderAreas(s.areas);
renderAreaPreview(s.areas, s.current_area_id);
renderLog(s.log);
}
// ================================================================
@@ -1107,6 +1169,59 @@ function renderChar(chars) {
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() {
@@ -1275,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(