Files
dcc-rpg/src/web/server.py
T
truetype eacc946868 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
2026-06-28 08:18:24 +02:00

280 lines
8.3 KiB
Python

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
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
log = logging.getLogger(__name__)
STATIC_DIR = Path(__file__).resolve().parent / "static"
app = FastAPI(title="DCC RPG — AI Dungeon Master")
# ---------------------------------------------------------------------------
# REST endpoints
# ---------------------------------------------------------------------------
@app.get("/api/campaigns")
def list_campaigns():
conn = db.get_conn()
rows = conn.execute(
"SELECT id, name, adventure, turn, created_at FROM campaigns ORDER BY id DESC"
).fetchall()
return [dict(r) for r in rows]
@app.get("/api/campaign/{campaign_id}")
def get_campaign_state(campaign_id: int):
try:
ctx = GameContext.load(campaign_id)
except ValueError:
return JSONResponse({"error": "Campagna non trovata"}, status_code=404)
# Full area details from adventure data
all_areas = db.get_all_areas()
area_map = {a["id"]: a for a in all_areas}
visited_ids = {a["area_id"] for a in ctx.areas if a.get("visited")}
enriched = []
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": conn_names,
"visited": bool(sa.get("visited")),
"features": full.get("features", [])[:3],
})
return {
"campaign": dict(ctx.campaign) if ctx.campaign else {},
"characters": ctx.characters,
"quests": ctx.quests,
"areas": enriched,
"turn": ctx.turn,
}
@app.post("/api/campaign")
def create_campaign(name: str = "Nuova Avventura", adventure: str = ""):
camp_id = db.create_campaign(name, adventure)
db.seed_adventure_state(camp_id)
db.seed_quests_from_adventure(camp_id)
# Mark first area as visited
areas = db.get_all_areas()
if areas:
db.set_area_state(camp_id, areas[0]["id"], visited=True)
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 = "",
char_class: str = "",
occupation: str = "",
):
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"]}
# ---------------------------------------------------------------------------
# WebSocket — real-time game loop
# ---------------------------------------------------------------------------
_executor = ThreadPoolExecutor(max_workers=4)
@app.websocket("/ws/{campaign_id}")
async def game_ws(ws: WebSocket, campaign_id: int):
await ws.accept()
try:
ctx = GameContext.load(campaign_id)
except ValueError:
await ws.send_json({"type": "error", "message": "Campagna non trovata"})
await ws.close()
return
# Send initial state
await ws.send_json({"type": "state", "campaign": _build_state(campaign_id)})
while True:
try:
raw = await ws.receive_text()
except WebSocketDisconnect:
break
try:
msg = json.loads(raw)
except json.JSONDecodeError:
msg = {"type": "action", "payload": raw}
if msg.get("type") == "action":
player_action = msg.get("payload", "").strip()
if not player_action:
continue
try:
# 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"],
"tool": out["tool"],
"params": out["params"],
"narration": out["narration"],
"mechanics": out["mechanics"],
"full_output": out["full_output"],
})
except Exception as e:
log.exception("DM cycle failed")
try:
await ws.send_json({"type": "error", "message": str(e)})
except WebSocketDisconnect:
break
# Send updated state after action
try:
await ws.send_json({"type": "state", "campaign": _build_state(campaign_id)})
except WebSocketDisconnect:
break
elif msg.get("type") == "ping":
try:
await ws.send_json({"type": "pong"})
except WebSocketDisconnect:
break
def _build_state(campaign_id: int) -> dict:
ctx = GameContext.load(campaign_id)
all_areas = db.get_all_areas()
area_map = {a["id"]: a for a in all_areas}
enriched = []
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": conn_names,
})
return {
"turn": ctx.turn,
"characters": ctx.characters,
"quests": ctx.quests,
"areas": enriched,
}
# ---------------------------------------------------------------------------
# Serve frontend
# ---------------------------------------------------------------------------
@app.get("/health")
def health():
return {"status": "ok", "provider": load_from_env().kind}
@app.get("/")
async def index():
html_path = STATIC_DIR / "index.html"
if html_path.exists():
content = html_path.read_text(encoding="utf-8")
return HTMLResponse(content)
return HTMLResponse("<h1>Frontend non trovato</h1>", status_code=404)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
db.init_db()
cfg = load_from_env()
log.info(
"DM config: provider=%s model=%s",
cfg.kind,
getattr(cfg, f"{cfg.kind}_model", "?"),
)
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8300, log_level="info")
if __name__ == "__main__":
main()