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

+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,