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])