fix: JSON repair, WS thread pool, switch to DeepSeek V4 Flash, funnel Lv0

LLM fixes:
- _try_repair_json() — ripara JSON malformato (apici, virgole trailing)
- _strip_json_from_text() — rimuove residui JSON dalla narration
- parse_tool_call() — rimuove troncamento 200 caratteri dal fallback

DM fixes:
- Fallback euristico quando params vuoti (dm.py)
- Estrazione narrazione da dm_override/narrate params.params.text

Prompt (prompts.py):
- Vietati termini tecnici/scientifici/anacronistici (HPLC, laser, ...)
- Aggiunto vocabolario fantasy obbligatorio

Server (server.py):
- dm_cycle in ThreadPoolExecutor per non bloccare WS ping
- WebSocketDisconnect gestiti
- Rimosso import inesistente db_schema

Frontend (index.html):
- Nascosti tool name dalla chat
- Personaggio -> PG, connessioni aree con nomi

Infrastruttura:
- Switch da openrouter/free a DeepSeek V4 Flash via OpenCode Go
- pyproject.toml, Dockerfile, docker-compose.yml, start.sh
- .env auto-load (start.sh)
- Funnel Lv0 (funnel.py) — creazione PG livello 0

Docs: architecture, database, setup, game mechanics, codebase ref
This commit is contained in:
truetype committed 2026-06-28 08:18:24 +02:00
1 parent 6b048dc0cc
commit eacc946868
23 files changed
+1786 -61

No files matched your search

+3 -3
View File
@@ -15,10 +15,10 @@ class ProviderConfig:
openrouter_model: str = "openrouter/free"
openrouter_key_env: str = "DCC_OPENROUTER_KEY"
openrouter_free_fallback: bool = True
local_base_url: str = "http://192.168.0.240/v1"
local_base_url: str = "https://opencode.ai/zen/go/v1"
local_api_key: str = ""
local_model: str = "big-pickle"
timeout: int = 60
local_model: str = "deepseek-v4-flash"
timeout: int = 120
def load_from_env() -> ProviderConfig:
+15
View File
@@ -31,6 +31,21 @@ def _llm_decision(ctx: GameContext, player_action: str, current_area_id: str = "
log.info("LLM non ha prodotto tool 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)
_, 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):
text = inner.get("text", "")
if text:
narration = text
return tool, params, narration
+242
View File
@@ -0,0 +1,242 @@
"""
Funnel DCC — Generazione personaggi livello 0.
Ogni giocatore crea 3-4 PG lv0 da portare nel funnel.
I sopravvissuti salgono a lv1 e scelgono la classe.
"""
from __future__ import annotations
import random
from typing import Any
# Tabella 1-3: Mestieri (d100)
# (tiro_min, tiro_max, nome, arma, bene)
OCCUPATIONS: list[tuple[int, int, str, str, str]] = [
(1, 1, "Alchimista", "Bastone", "Olio, 1 fiaschetta"),
(2, 2, "Addestratore di animali", "Mazza", "Pony"),
(3, 3, "Armaiolo", "Mazza (martello)", "Elmo di ferro"),
(4, 4, "Astrologo", "Pugnale", "Cannocchiale"),
(5, 5, "Barbiere", "Pugnale (rasoio)", "Forbici"),
(6, 6, "Sagrestano", "Bastone", "Simbolo sacro"),
(7, 7, "Apicoltore", "Bastone", "Vasetto di miele"),
(8, 8, "Fabbro", "Mazza (martello)", "Tenaglie d'acciaio"),
(9, 9, "Macellaio", "Ascia (mannaia)", "Mezzena di manzo"),
(10, 10, "Guardia di carovana", "Spada corta", "Lino, 1 iarda"),
(11, 11, "Casaro", "Bastone (randello)", "Formaggio puzzolente"),
(12, 12, "Calzolaio", "Pugnale (lesina)", "Calzascarpe"),
(13, 13, "Truffatore", "Pugnale", "Mantello di qualità"),
(14, 14, "Bottaio", "Mazza (piede di porco)", "Botte"),
(15, 15, "Fruttivendolo ambulante", "Pugnale (coltello)", "Frutta"),
(16, 16, "Tagliaborse", "Pugnale", "Piccolo forziere"),
(17, 17, "Sterratore", "Bastone (pala)", "Terra fine, 1 libbra"),
(18, 18, "Lavoratore portuale", "Bastone (palo)", "1 libro GdR in ritardo"),
(19, 19, "Speziale nano", "Bastone (randello)", "Fiala d'acciaio"),
(20, 20, "Fabbro nano", "Mazza (martello)", "Mithril, 1 oncia"),
(21, 21, "Cassettaio nano", "Pugnale (scalpello)", "Legno, 10 libbre"),
(22, 22, "Mandriano nano", "Bastone", "Scrofa"),
(23, 24, "Minatore nano", "Mazza (piccone)", "Lanterna"),
(25, 25, "Coltivatore di funghi nano", "Bastone (pala)", "Sacco"),
(26, 26, "Acchiappatopi nano", "Mazza", "Rete"),
(27, 28, "Scalpellino nano", "Martello", "Pietra pregiata, 10 libbre"),
(29, 29, "Artigiano elfico", "Bastone", "Argilla, 1 libbra"),
(30, 30, "Avvocato elfico", "Dardo (penna d'oca)", "Libro"),
(31, 31, "Candelaio elfico", "Pugnale (forbici)", "Candele, 20"),
(32, 32, "Falconiere elfico", "Pugnale", "Falco"),
(33, 34, "Forestale elfico", "Bastone", "Erbe, 1 libbra"),
(35, 35, "Vetraio elfico", "Mazza (martello)", "Perline di vetro"),
(36, 36, "Navigatore elfico", "Arco corto", "Cannocchiale"),
(37, 38, "Saggio elfico", "Pugnale", "Pergamena e penna d'oca"),
(39, 47, "Contadino", "Lancia (forcone)", "Gallina"),
(48, 48, "Indovino", "Pugnale", "Mazzo di tarocchi"),
(49, 49, "Giocatore d'azzardo", "Mazza", "Dadi"),
(50, 50, "Spurgatore di latrine", "Pugnale (cazzuola)", "Sacco di letame notturno"),
(51, 52, "Becchino", "Bastone (pala)", "Cazzuola"),
(53, 54, "Mendicante di gilda", "Fionda", "Stampelle"),
(55, 55, "Macellaio di polli halfling", "Ascia da una mano", "Carne di pollo, 5 libbre"),
(56, 57, "Tintore halfling", "Bastone", "Tessuto, 3 iarde"),
(58, 58, "Guantaio halfling", "Pugnale (lesina)", "Guanti, 4 paia"),
(59, 59, "Errante halfling", "Fionda", "Bambola maledetta"),
(60, 60, "Merciaio halfling", "Pugnale (forbici)", "Vestiti pregiati, 3 completi"),
(61, 61, "Marinaio halfling", "Pugnale (coltello)", "Tela da vele, 2 iarde"),
(62, 62, "Usuraio halfling", "Spada corta", "5 mo, 10 ma, 200 mr"),
(63, 63, "Mercante halfling", "Spada corta", "20 ma"),
(64, 64, "Vagabondo halfling", "Mazza", "Ciotola dell'elemosina"),
(65, 65, "Guaritore", "Mazza", "Acqua santa, 1 fiala"),
(66, 66, "Erborista", "Mazza", "Erbe, 1 libbra"),
(67, 67, "Mandriano", "Bastone", "Cane da pastore"),
(68, 69, "Cacciatore", "Arco corto", "Pelle di cervo"),
(70, 70, "Servo a contratto", "Bastone", "Medaglione"),
(71, 71, "Buffone", "Dardo", "Vestiti di seta"),
(72, 72, "Gioielliere", "Pugnale", "Gemma da 20 mo"),
(73, 73, "Fabbro di serrature", "Pugnale", "Attrezzi di qualità"),
(74, 74, "Frate mendicante", "Mazza", "Salsa di formaggio"),
(75, 75, "Mercenario", "Spada lunga", "Armatura di cuoio"),
(76, 76, "Mercante", "Pugnale", "4 mo, 14 ma, 27 mr"),
(77, 77, "Mugnaio/fornaio", "Mazza", "Farina, 1 libbra"),
(78, 78, "Menestrello", "Pugnale", "Ukulele"),
(79, 79, "Nobile", "Spada lunga", "Anello d'oro da 10 mo"),
(80, 80, "Orfano", "Mazza", "Bambola di pezza"),
(81, 81, "Stalliere", "Bastone", "Briglia"),
(82, 82, "Fuorilegge", "Spada corta", "Armatura di cuoio leggera"),
(83, 83, "Cordaio", "Pugnale (coltello)", "Corda, 100 piedi"),
(84, 84, "Scriba", "Dardo", "Pergamena, 10 fogli"),
(85, 85, "Sciamano", "Mazza ferrata", "Erbe, 1 libbra"),
(86, 86, "Schiavo", "Mazza", "Roccia dall'aspetto strano"),
(87, 87, "Contrabbandiere", "Fionda", "Sacco impermeabile"),
(88, 88, "Soldato", "Lancia", "Scudo"),
(89, 90, "Scudiero", "Spada lunga", "Elmo d'acciaio"),
(91, 91, "Esattore delle tasse", "Spada lunga", "100 mr"),
(92, 93, "Cacciatore di pelli", "Fionda", "Pelle di tasso"),
(94, 94, "Monello", "Mazza (bastoncino)", "Ciotola dell'elemosina"),
(95, 95, "Carraio", "Mazza", "Carriola"),
(96, 96, "Tessitore", "Pugnale", "Vestito pregiato"),
(97, 97, "Apprendista mago", "Pugnale", "Grimorio nero"),
(98, 100, "Boscaiolo", "Ascia da una mano", "Fascina di legna"),
]
# Mappa arma → dado danno per livello 0
WEAPON_DAMAGE: dict[str, str] = {
"Ascia da una mano": "d6",
"Ascia (mannaia)": "d6",
"Arco corto": "d6",
"Bastone": "d4",
"Bastone (pala)": "d4",
"Bastone (palo)": "d4",
"Bastone (randello)": "d4",
"Dardo": "d4",
"Dardo (penna d'oca)": "d4",
"Fionda": "d4",
"Lancia": "d6",
"Lancia (forcone)": "d6",
"Mazza": "d4",
"Mazza (martello)": "d4",
"Mazza (piede di porco)": "d4",
"Mazza (piccone)": "d4",
"Mazza (bastoncino)": "d4",
"Mazza ferrata": "d4",
"Martello": "d4",
"Pugnale": "d4",
"Pugnale (rasoio)": "d4",
"Pugnale (lesina)": "d4",
"Pugnale (coltello)": "d4",
"Pugnale (forbici)": "d4",
"Pugnale (cazzuola)": "d4",
"Pugnale (scalpello)": "d4",
"Spada corta": "d6",
"Spada lunga": "d8",
}
NOMI_MASCHILI = [
"Aldo", "Baldo", "Crispo", "Druso", "Elmo",
"Ferro", "Gallo", "Ivo", "Lupo", "Marco",
"Orso", "Pio", "Rufo", "Sergio", "Tito",
"Ugo", "Vito", "Zeno", "Bruno", "Claudio",
]
NOMI_FEMMINILI = [
"Ada", "Berta", "Clara", "Dora", "Elsa",
"Frida", "Greta", "Ida", "Leda", "Mara",
"Nora", "Olga", "Pia", "Rosa", "Sara",
"Tina", "Una", "Vera", "Zita", "Lena",
]
# Pool di nomi nobili/guerrieri per pg che sopravvivono al funnel
NOMI_EROICI = [
"Lancelet", "Gawain", "Aldric", "Thorne", "Rowan",
"Bran", "Finn", "Kael", "Dorn", "Soren",
]
def tiro_3d6() -> int:
return sum(random.randint(1, 6) for _ in range(3))
def tiro_statistiche() -> dict[str, int]:
return {s: tiro_3d6() for s in ("str", "agi", "sta", "per", "int", "lck")}
def cerca_mestiere(tiro: int) -> tuple[int, int, str, str, str]:
for entry in OCCUPATIONS:
if entry[0] <= tiro <= entry[1]:
return entry
return OCCUPATIONS[-1] # fallback
def genera_pg_lv0(nome: str = "") -> dict[str, Any]:
"""
Genera un personaggio livello 0 completo per il funnel DCC.
Returns:
dict con: nome, mestiere, statistiche, arma, dado_danno, bene, pf_max, pf
"""
tiro_mestiere = random.randint(1, 100)
_, _, mestiere, arma, bene = cerca_mestiere(tiro_mestiere)
if not nome:
nome = f"{random.choice(NOMI_MASCHILI + NOMI_FEMMINILI)} il {mestiere}"
stats = tiro_statistiche()
dado_danno = WEAPON_DAMAGE.get(arma, "d4")
# Lv0 PF = d4 (o come da descrizione mestiere)
pf_max = random.randint(1, 4)
# Un po' di fortuna iniziale = 3d6 come le altre stats
lck_mod = _modificatore_fortuna(stats["lck"])
return {
"name": nome,
"occupation": mestiere,
"level": 0,
"char_class": "",
"weapon": arma,
"weapon_damage": dado_danno,
"belonging": bene,
"str": stats["str"],
"agi": stats["agi"],
"sta": stats["sta"],
"per": stats["per"],
"int": stats["int"],
"lck": stats["lck"],
"luck_mod": lck_mod,
"hp_current": pf_max,
"hp_max": pf_max,
"ac": 10,
"armor_type": "none",
}
def genera_funnel(num_pg: int = 4) -> list[dict[str, Any]]:
"""Genera N personaggi livello 0 per il funnel."""
return [genera_pg_lv0() for _ in range(num_pg)]
def _modificatore_fortuna(lck: int) -> int:
"""Tabella 1-2: Modificatore Fortuna."""
if lck == 3:
return -3
elif lck <= 5:
return -2
elif lck <= 8:
return -1
elif lck <= 12:
return 0
elif lck <= 15:
return 1
elif lck <= 17:
return 2
else: # 18
return 3
if __name__ == "__main__":
print("=== FUNNEL DCC — 4 Personaggi Livello 0 ===\n")
for i, pg in enumerate(genera_funnel(4), 1):
print(f" {i}. {pg['name']}")
print(f" Mestiere: {pg['occupation']}")
print(f" Arma: {pg['weapon']} ({pg['weapon_damage']})")
print(f" Bene: {pg['belonging']}")
print(f" PF: {pg['hp_current']}")
print(f" For:{pg['str']} Agi:{pg['agi']} Sta:{pg['sta']} "
f"Per:{pg['per']} Int:{pg['int']} Luc:{pg['lck']}")
print(f" CA: {pg['ac']}\n")
+4 -2
View File
@@ -45,9 +45,11 @@ class GameContext:
if self.current_area_id:
lines.append(f"Area corrente: {self.current_area_name or self.current_area_id}")
for c in self.characters:
cls = c.get("char_class") or ""
lv = c.get("level", 0)
cls = c.get("occupation") or c.get("char_class") or ""
label = f"{cls} (Lv{lv})" if lv == 0 else f"{c.get('char_class', '')} Lv{lv}"
lines.append(
f" [{c['id']}] {c['name']} — Lv{c['level']} {cls} "
f" [{c['id']}] {c['name']} — {label} "
f"PF {c['hp_current']}/{c['hp_max']} PE {c.get('xp', 0)}"
)
if self.quests:
+49 -4
View File
@@ -27,6 +27,30 @@ def ask(prompt: str, system: str = "") -> str:
return get_provider().ask(prompt, system=system)
def _try_repair_json(text: str) -> str | None:
"""Tenta di riparare errori JSON comuni prodotti dagli LLM."""
m = re.search(r'(\{[\s\S]*\})', text)
if not m:
return None
blob = m.group(1)
# 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)
# Fix 2: qualsiasi altra chiave con apici singoli
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)
try:
json.loads(blob)
return blob
except json.JSONDecodeError:
return None
def _extract_json(text: str) -> str | None:
for pat in [
r'```json\s*([\s\S]*?)```',
@@ -40,8 +64,27 @@ def _extract_json(text: str) -> str | None:
json.loads(candidate)
return candidate
except json.JSONDecodeError:
# tenta riparazione
repaired = _try_repair_json(candidate)
if repaired:
return repaired
continue
return None
# try repair on full text too
return _try_repair_json(text)
def _strip_json_from_text(text: str) -> str:
"""Rimuove qualsiasi contenuto simile a JSON dal testo narrativo."""
# toglie blocchi {...} completi di tool call
cleaned = re.sub(r'\{"tool":[\s\S]*?"narration":\s*"[^"]*"\s*\}', '', text)
# toglie blocchi JSON parziali o incompleti
cleaned = re.sub(r'\{"tool":[\s\S]*?\}', '', cleaned)
cleaned = re.sub(r'\{\s*"[a-zA-Z_]+"\s*[:=][^}]*\}', '', cleaned)
# pulisce spaziature multiple
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
# toglie virgolette pendenti all'inizio/fine
cleaned = cleaned.strip('"\' ,.')
return cleaned
def parse_tool_call(text: str) -> dict:
@@ -55,10 +98,12 @@ def parse_tool_call(text: str) -> dict:
except json.JSONDecodeError:
pass
tool_match = re.search(r'(?:tool|strumento|chiamo|uso)\s*[":]*\s*(\w+)', text, re.I)
# Fallback: estrae tool name via regex, ma PULISCE la narration dal JSON
tool_match = re.search(r'\b(?:tool|strumento|chiamo|uso)\s*[":]*\s*(\w+)', text, re.I)
narration = _strip_json_from_text(text) if text else ""
if tool_match:
return {"tool": tool_match.group(1), "params": {}, "narration": text[:200]}
return {"tool": "dm_override", "params": {"command": "narrate", "params": {"text": text[:200]}}, "narration": ""}
return {"tool": tool_match.group(1), "params": {}, "narration": narration}
return {"tool": "dm_override", "params": {"command": "narrate", "params": {"text": narration or text[:500]}}, "narration": narration}
if __name__ == "__main__":
+6 -1
View File
@@ -44,6 +44,9 @@ STILE:
- Usa ITALIANO.
- Dai sempre una scelta al giocatore alla fine.
- Mantieni il tono weird-fantasy di DCC: pericolo, meraviglia, corruzione.
- Usa VOCABOLARIO FANTASY: pietra, tenebra, fiamma, sangue, ferro, ossa, ombra, polvere, ruggine, muschio, crepitio, sussurro, pulsare, vibrare, brillare, incrinato, logoro, antico, dimenticato.
- VIETATO usare: termini tecnici, scientifici, moderni, anacronistici (niente HPLC, laser, DNA, algoritmi, frequenze, ecc.).
- Niente acronimi, niente linguaggio da laboratorio o ingegneria.
MECCANICHE:
- CD base: 12 = facile, 15 = media, 18 = difficile
@@ -88,7 +91,9 @@ def format_context(ctx: GameContext) -> str:
lines.append("PERSONAGGI:")
for c in ctx.characters:
bl = f" [{c['id']}] {c['name']} — Lv{c['level']} {c.get('char_class', '')} ({c.get('alignment', '?')})"
lv = c.get("level", 0)
label = c.get("occupation", "") if lv == 0 else c.get("char_class", "")
bl = f" [{c['id']}] {c['name']} — Lv{lv} {label} ({c.get('alignment', '?')})"
stat_str = f" For{c.get('str','?')} Agi{c.get('agi','?')} Sta{c.get('sta','?')} Per{c.get('per','?')} Int{c.get('int','?')} Luc{c.get('lck','?')}"
hp_str = f" PF {c['hp_current']}/{c['hp_max']} CA {c.get('ac', 10)} PE {c.get('xp', 0)}"
lines.extend([bl, stat_str, hp_str])
+79 -27
View File
@@ -1,13 +1,15 @@
from __future__ import annotations
import json
import asyncio
import logging
import os
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
import uvicorn # noqa: F401 — needed by main()
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse
from src.engine import db
from src.engine.config import load_from_env
@@ -51,11 +53,16 @@ def get_campaign_state(campaign_id: int):
for sa in ctx.areas:
aid = sa["area_id"]
full = area_map.get(aid, {})
raw_conns = full.get("connections", [])
conn_names = []
for cid in raw_conns:
linked = area_map.get(cid)
conn_names.append(linked["name"] if linked else cid)
enriched.append({
"id": aid,
"name": full.get("name", aid),
"description": full.get("description", "")[:200],
"connections": full.get("connections", []),
"connections": conn_names,
"visited": bool(sa.get("visited")),
"features": full.get("features", [])[:3],
})
@@ -78,32 +85,51 @@ def create_campaign(name: str = "Nuova Avventura", adventure: str = ""):
areas = db.get_all_areas()
if areas:
db.set_area_state(camp_id, areas[0]["id"], visited=True)
return {"campaign_id": camp_id}
for pg in genera_funnel(4):
_create_lv0_character(camp_id, pg)
return {"campaign_id": camp_id, "funnel": True}
def _create_lv0_character(campaign_id: int, pg: dict) -> int:
char_id = db.create_character(
campaign_id,
name=pg["name"],
char_class=f"Lv0 ({pg['occupation']})",
occupation=pg["occupation"],
)
db.update_character(
char_id,
level=0,
str=pg["str"],
agi=pg["agi"],
sta=pg["sta"],
per=pg["per"],
int=pg["int"],
lck=pg["lck"],
hp_current=pg["hp_current"],
hp_max=pg["hp_max"],
ac=pg["ac"],
armor_type=pg["armor_type"],
weapon=pg["weapon"],
equipment=f'["{pg["weapon"]}", "{pg["belonging"]}"]',
)
return char_id
@app.post("/api/campaign/{campaign_id}/character")
def add_character(
campaign_id: int,
name: str = "Eroe",
name: str = "",
char_class: str = "",
occupation: str = "",
):
char_id = db.create_character(campaign_id, name, char_class, occupation)
# Default stats for a lv0/lv1 character
db.update_character(
char_id,
level=1,
str=12,
agi=11,
sta=12,
per=10,
int=10,
lck=10,
hp_current=8,
hp_max=8,
ac=12,
)
return {"character_id": char_id}
if name and char_class:
char_id = db.create_character(campaign_id, name, char_class, occupation)
db.update_character(char_id, level=1, str=12, agi=11, sta=12, per=10, int=10, lck=10, hp_current=8, hp_max=8, ac=12)
return {"character_id": char_id}
pg = genera_pg_lv0(name)
char_id = _create_lv0_character(campaign_id, pg)
return {"character_id": char_id, "level": 0, "name": pg["name"], "occupation": pg["occupation"]}
# ---------------------------------------------------------------------------
@@ -111,6 +137,9 @@ def add_character(
# ---------------------------------------------------------------------------
_executor = ThreadPoolExecutor(max_workers=4)
@app.websocket("/ws/{campaign_id}")
async def game_ws(ws: WebSocket, campaign_id: int):
await ws.accept()
@@ -126,7 +155,11 @@ async def game_ws(ws: WebSocket, campaign_id: int):
await ws.send_json({"type": "state", "campaign": _build_state(campaign_id)})
while True:
raw = await ws.receive_text()
try:
raw = await ws.receive_text()
except WebSocketDisconnect:
break
try:
msg = json.loads(raw)
except json.JSONDecodeError:
@@ -138,7 +171,12 @@ async def game_ws(ws: WebSocket, campaign_id: int):
continue
try:
out = dm_cycle(campaign_id, player_action)
# dm_cycle è bloccante (chiamata HTTP al LLM) —
# la eseguiamo in un thread worker per non bloccare
# il loop asyncio e tenere vivi i ping WebSocket
out = await asyncio.get_event_loop().run_in_executor(
_executor, dm_cycle, campaign_id, player_action
)
await ws.send_json({
"type": "response",
"turn": out["turn"],
@@ -150,13 +188,22 @@ async def game_ws(ws: WebSocket, campaign_id: int):
})
except Exception as e:
log.exception("DM cycle failed")
await ws.send_json({"type": "error", "message": str(e)})
try:
await ws.send_json({"type": "error", "message": str(e)})
except WebSocketDisconnect:
break
# Send updated state after action
await ws.send_json({"type": "state", "campaign": _build_state(campaign_id)})
try:
await ws.send_json({"type": "state", "campaign": _build_state(campaign_id)})
except WebSocketDisconnect:
break
elif msg.get("type") == "ping":
await ws.send_json({"type": "pong"})
try:
await ws.send_json({"type": "pong"})
except WebSocketDisconnect:
break
def _build_state(campaign_id: int) -> dict:
@@ -167,11 +214,16 @@ def _build_state(campaign_id: int) -> dict:
for sa in ctx.areas:
aid = sa["area_id"]
full = area_map.get(aid, {})
raw_conns = full.get("connections", [])
conn_names = []
for cid in raw_conns:
linked = area_map.get(cid)
conn_names.append(linked["name"] if linked else cid)
enriched.append({
"id": aid,
"name": full.get("name", aid),
"visited": bool(sa.get("visited")),
"connections": full.get("connections", []),
"connections": conn_names,
})
return {
"turn": ctx.turn,
+28 -24
View File
@@ -552,7 +552,7 @@ html, body {
<!-- SIDEBAR -->
<div id="sidebar">
<div class="sidebar-section" id="char-section">
<h2>Personaggio</h2>
<h2>PG</h2>
<div id="char-display"><div class="empty-state">Nessun PG</div></div>
</div>
<div class="sidebar-section" id="quest-section">
@@ -750,9 +750,7 @@ function handleMessage(msg) {
if (msg.mechanics) {
bubble += `<div class="msg-dm-mechanics">${escapeHtml(msg.mechanics)}</div>`;
}
if (msg.tool) {
bubble += `<div class="msg-dm-tool">${escapeHtml(msg.tool)}</div>`;
}
// Debug tool name — nascosto all'utente
bubble += '</div>';
div.innerHTML = bubble;
chat.appendChild(div);
@@ -788,26 +786,32 @@ function renderChar(chars) {
charDisplay.innerHTML = '<div class="empty-state">Nessun PG</div>';
return;
}
const c = chars[0];
const hpPct = Math.round((c.hp_current / c.hp_max) * 100);
charDisplay.innerHTML = `
<div class="char-name">${escapeHtml(c.name)}</div>
<div class="char-class">${c.char_class || 'Lv0'} · Livello ${c.level}</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>
<div class="stat-grid">
<div class="stat-row"><span class="stat-label">FOR</span><span class="stat-val">${c.str||'?'}</span></div>
<div class="stat-row"><span class="stat-label">AGI</span><span class="stat-val">${c.agi||'?'}</span></div>
<div class="stat-row"><span class="stat-label">STA</span><span class="stat-val">${c.sta||'?'}</span></div>
<div class="stat-row"><span class="stat-label">PER</span><span class="stat-val">${c.per||'?'}</span></div>
<div class="stat-row"><span class="stat-label">INT</span><span class="stat-val">${c.int||'?'}</span></div>
<div class="stat-row"><span class="stat-label">LCK</span><span class="stat-val">${c.lck||'?'}</span></div>
</div>
<div class="char-ac">Armatura: <span>${c.ac||'?'}</span></div>
<div class="char-weapon">Arma: <span>${c.weapon || 'nessuna'}</span></div>
`;
charDisplay.innerHTML = chars.map(c => {
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-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>
<div class="stat-grid">
<div class="stat-row"><span class="stat-label">FOR</span><span class="stat-val">${c.str||'?'}</span></div>
<div class="stat-row"><span class="stat-label">AGI</span><span class="stat-val">${c.agi||'?'}</span></div>
<div class="stat-row"><span class="stat-label">STA</span><span class="stat-val">${c.sta||'?'}</span></div>
<div class="stat-row"><span class="stat-label">PER</span><span class="stat-val">${c.per||'?'}</span></div>
<div class="stat-row"><span class="stat-label">INT</span><span class="stat-val">${c.int||'?'}</span></div>
<div class="stat-row"><span class="stat-label">LCK</span><span class="stat-val">${c.lck||'?'}</span></div>
</div>
<div class="char-ac">Arma: <span>${escapeHtml(c.weapon || 'nessuna')}</span> · CA <span>${c.ac||'?'}</span></div>
</div>
`;
}).join('');
}
function renderQuests(quests) {