diff --git a/.gitignore b/.gitignore index fa2433f..5a88bda 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,14 @@ __pycache__/ # Generated graph artifacts graphify-out/ +# Virtual environments +env/ + +# Node.js artifacts (not used by this Python project) +node_modules/ +package-lock.json +package.json + # OS files .DS_Store Thumbs.db diff --git a/REMEDIATION_PLAN.md b/REMEDIATION_PLAN.md new file mode 100644 index 0000000..fc582df --- /dev/null +++ b/REMEDIATION_PLAN.md @@ -0,0 +1,317 @@ +# DCC RPG — Piano di Remediation + +## Strategia + +Il progetto ha un nucleo funzionante (LLM, DB, WebSocket, frontend) ma soffre di +architettura a due teste e stato inconsistente. La remediation segue 5 fasi, +ciascuna con checkpoint verificabile. Ogni fase è reversibile (commit atomici). + +## Fase 0 — Sicurezza e Igiene (⏱ 1h) + +Obbiettivo: rimuovere rischi immediati, allineare convenzioni di base. + +### 0.1 — Rotazione token Gitea + +```bash +# Attuale: http://truetype:TOKEN@192.168.0.183:8085/truetype/dcc-rpg.git +git remote set-url origin http://192.168.0.183:8085/truetype/dcc-rpg.git +# Usare ~/.netrc per auth: +# machine 192.168.0.183 login truetype password NUOVO_TOKEN +# O forzare HTTPS con password prompt +``` + +**Done quando**: `git remote -v` non mostra token. + +### 0.2 — Allineamento nomi env var + +| Oggi | Dovrebbe essere | +|------|----------------| +| `OPENROUTER_API_KEY` | `DCC_OPENROUTER_KEY` | +| `DCC_OPENROUTER_MODEL` | ✅ ok | +| `DCC_LLM_PROVIDER` | ✅ ok | + +Modificare `config.py` e `providers.py` per leggere `DCC_OPENROUTER_KEY`. +Tenere `OPENROUTER_API_KEY` come fallback per retrocompatibilità. + +**Done quando**: un test via env `DCC_OPENROUTER_KEY=...` funziona. + +### 0.3 — Eliminare SYSTEM_PROMPT duplicato + +Tenere `prompts.py:SYSTEM_PROMPT`. Rimuovere `llm.py:SYSTEM_PROMPT` e far +sì che `llm.ask()` lo riceva dall'esterno (parametro required, non default). + +**Done quando**: `grep SYSTEM_PROMPT src/engine/llm.py` produce 0 matches (solo import). + +### 0.4 — Aggiungere .gitignore per database e file temporanei + +```gitignore +*.db +__pycache__/ +env/ +*.pyc +``` + +**Done quando**: `git status --porcelain` non mostra file `*.db` sporchi. + +--- + +## Fase 1 — Consolidamento Architetturale (⏱ 3-4h) + +Obbiettivo: unico entry point, flusso dati lineare, niente routing parallelo. + +### 1.1 — Unificare il ciclo DM + +**Problema**: `state_machine.process_turn()` esiste in parallelo a `dm.dm_cycle()`. +Nessuno chiama `process_turn()` tranne l'interfaccia interattiva (forse). + +**Azione**: +- Rinominare `state_machine.py` → `game_context.py` (contiene solo `GameContext`) +- Rimuovere `process_turn()` (morto) +- Tutte le chiamate vanno a `dm.dm_cycle()` — unico entry point + +**Done quando**: `state_machine.py` non contiene più `process_turn` e non viene +importato da nessun modulo (solo `GameContext` eventualmente). + +### 1.2 — Cablare current_area_id + +**Problema**: `build_prompt` accetta `current_area_id` ma `_llm_decision` non lo passa. + +**Azione**: +```python +# dm.py +def _llm_decision(ctx: GameContext, player_action: str, current_area_id: str = "") -> ...: + prompt = build_prompt(ctx, player_action, current_area_id=current_area_id) +``` + +Come si ottiene `current_area_id`? +- Leggere ultima area visitata da `adventure_state` nel DB +- O far decidere all'LLM un cambio area esplicito come tool separato (`move_to_area`) +- In dm_cycle, caricare current_area_id dal DB prima di chiamare _llm_decision + +**Done quando**: Se un PG è nell'area "1.1", il contesto LLM contiene la +descrizione focalizzata dell'area 1.1 (oltre a tutte le aree per contesto). + +### 1.3 — Rimuovere singleton globale `_provider` + +**Problema**: `llm.py:_provider` è mutabile e condiviso tra tutte le richieste. + +**Azione**: +- `get_provider()` accetta parametri opzionali (o usa contextvar) +- Oppure: `llm.ask()` diventa stateless — carica config ad ogni chiamata +- Nel WebSocket, ogni connessione crea il proprio provider + +Soluzione più semplice: +```python +import contextvars +_provider_var: contextvars.ContextVar[LLMProvider | None] = contextvars.ContextVar('provider', default=None) +``` + +**Done quando**: due WS simultanei possono usare provider diversi senza race. + +### 1.4 — Rendere `_parse_action()` tail-only + +**Problema**: `_parse_action()` è ancora nel main path di `_llm_decision()` come +fallback. + +**Azione**: +- Spostare la logica euristica in un modulo separato `heuristic_fallback.py` +- Chiamarlo SOLO se `parse_tool_call()` fallisce o restituisce tool vuoto +- Non duplicare la logica di routing — l'euristico è un salvagente, non un secondo pilota + +**Done quando**: `state_machine.py` non esiste più, sostituito da moduli con +responsabilità singola. + +--- + +## Fase 2 — Persistenza dello Stato di Gioco (⏱ 4-6h) + +Obbiettivo: lo stato del mondo non è solo nel prompt LLM, ma nel database. + +### 2.1 — Posizione corrente del PG + +**Azione**: Aggiungere colonna `current_area_id` alla tabella `campaigns`. + +```sql +ALTER TABLE campaigns ADD COLUMN current_area_id TEXT DEFAULT ''; +``` + +Aggiornare `area_state` quando l'LLM muove il PG (tool `dm_override` +o nuovo tool `move_to_area`). + +**Done quando**: dopo un messaggio che descrive l'ingresso in una nuova area, +`SELECT current_area_id FROM campaigns WHERE id = ?` restituisce l'area corretta. + +### 2.2 — HP persistenti dei mostri + +**Azione**: Nuova tabella `combat_state`: +```sql +CREATE TABLE combat_state ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + campaign_id INTEGER NOT NULL, + monster_id TEXT NOT NULL, + current_hp INTEGER NOT NULL, + max_hp INTEGER NOT NULL, + status TEXT DEFAULT 'active', -- active, defeated, fled + FOREIGN KEY (campaign_id) REFERENCES campaigns(id) +); +``` + +Il tool `combat()` cerca prima qui gli HP invece di generarli fresh. +Quando il mostro muore → `status = 'defeated'`. + +**Done quando**: due attacchi consecutivi allo stesso mostro scalano gli HP correttamente. + +### 2.3 — Stato enigmi e trappole + +**Azione**: Aggiungere campo `puzzle_state` a `adventure_state` (campo TEXT/JSON +o tabella separata). Ogni tool che risolve un enigma aggiorna lo stato. + +**Done quando**: dopo aver risolto un enigma, rivisitare l'area non lo ripropone. + +### 2.4 — Persistenza inventario strutturata + +**Azione**: Rimpiazzare la gestione inventario a stringa con tabella dedicata: +```sql +CREATE TABLE character_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + character_id INTEGER NOT NULL, + item_def_id TEXT, -- riferimento a avventura.json items[].id + name TEXT NOT NULL, + quantity INTEGER DEFAULT 1, + equipped INTEGER DEFAULT 0, + properties TEXT DEFAULT '{}', + FOREIGN KEY (character_id) REFERENCES characters(id) +); +``` + +**Done quando**: `get_character_items(char_id)` restituisce oggetti con quantità e equipped. + +--- + +## Fase 3 — Robustezza e Multi-player (⏱ 2-3h) + +### 3.1 — Connection pooling SQLite + +**Azione**: Usare `queue=True` per scrivere in coda, `check_same_thread=False` +per WS. Oppure wrapper con lock: + +```python +import threading +_conn_lock = threading.Lock() + +def get_conn(): + with _conn_lock: + conn = sqlite3.connect(DB_PATH, check_same_thread=False) + conn.row_factory = sqlite3.Row + return conn +``` + +**Meglio**: tenere una connessione per `ContextVar` e usare WAL mode: +```python +conn.execute("PRAGMA journal_mode=WAL") +conn.execute("PRAGMA busy_timeout=5000") +``` + +**Done quando**: 5 WS simultanei possono fare richieste senza `database is locked`. + +### 3.2 — Session token per WebSocket + +**Azione**: Aggiungere header `Authorization: Bearer ` opzionale. +Finché è single-user su LAN, tenerlo semplice: leggere un token dal file +`.session_token` o env var `DCC_WS_TOKEN`. Se presente, va verificato. + +**Done quando**: server avviato con `DCC_WS_TOKEN=xxx` → chiamate senza token +vengono rifiutate. + +### 3.3 — Error boundaries nel ciclo DM + +**Azione**: In `game_ws()`: +```python +try: + result = dm_cycle(campaign_id, player_action) +except Exception as e: + await ws.send_json({"error": str(e), "type": "error", "recoverable": True}) + continue # non rompere il loop +``` + +Loggare l'errore con traceback, mandare messaggio generico al client. + +**Done quando**: un tool crash non chiude la connessione WS. + +--- + +## Fase 4 — Testing (⏱ 3-4h) + +### 4.1 — Unit test per strumenti base + +``` +tests/ + test_dice.py # roll(), dice_chain(), roll_str() + test_tools.py # ogni tool con fixture di contesto minimo + test_providers.py # mock delle chiamate HTTP +``` + +### 4.2 — Integration test per dm_cycle + +Mock dell'LLM (restituisce JSON fisso), verifica che: +- La risposta contiene tool, params, narration +- Il turno viene avanzato +- Il log viene scritto + +### 4.3 — WebSocket test + +Usare `httpx` + `pytest-asyncio` per connettersi al WS, inviare azione, +verificare risposta. + +### 4.4 — CI minimo con Gitea Actions + +```yaml +steps: + - run: python3 -m pytest tests/ + - run: PYTHONPATH=/home/codex/rpg python3 -c "from src.engine import db; db.init_db()" +``` + +--- + +## Fase 5 — Integrazione Knowledge (ⱪ 2-3h, bassa priorità) + +Solo dopo che il motore è stabile. + +### 5.1 — Caricare classi e talenti + +Leggere `knowledge/classi/*.json` (se esiste) e esporre via db o modulo. + +### 5.2 — Sostituire tabelle hardcoded in tools.py + +`_crit_table_for()`, `_crit_die_for()`, `_parse_extra_damage()` → leggere da +file JSON dati. + +### 5.3 — Esporre equipment e magia come tool data + +Aggiungere tool `lookup(entity_type, entity_id)` per cercare regole specifiche +ed includerle nel contesto LLM su richiesta. + +--- + +## Roadmap Temporale + +``` +Settimana 1: Fase 0 + Fase 1 (fondamenta) +Settimana 2: Fase 2 (stato di gioco) +Settimana 3: Fase 3 + Fase 4 (robustezza + test) +Settimana 4: Fase 5 (knowledge) +``` + +Ogni fase produce un commit (o PR su branch) che non rompe le fasi precedenti. +L'ultimo commit di ogni fase aggiorna `server.py` per essere avviabile subito. + +## Checkpoint Finale + +Il progetto è risorto quando: + +1. Un giocatore entra in una campagna → vede la posizione corrente +2. Si muove in una nuova area → il DB lo registra, il contesto LLM cambia +3. Attacca un mostro → gli HP scalano, il mostro può morire permanentemente +4. Si riconnette dopo crash → lo stato è identico +5. Due giocatori possono connettersi simultaneamente senza crash +6. I test passano in CI diff --git a/src/engine/__init__.py b/src/engine/__init__.py index 5fa5cf2..153f9d5 100644 --- a/src/engine/__init__.py +++ b/src/engine/__init__.py @@ -1,14 +1,13 @@ from src.engine.config import ProviderConfig, load_from_env, ProviderKind from src.engine.dm import dm_cycle, interactive from src.engine.llm import set_provider -from src.engine.state_machine import GameContext, process_turn +from src.engine.game_context import GameContext from src.engine.tools import run as run_tool __all__ = [ "dm_cycle", "interactive", "GameContext", - "process_turn", "run_tool", "ProviderConfig", "ProviderKind", diff --git a/src/engine/config.py b/src/engine/config.py index 8be33de..77a210e 100644 --- a/src/engine/config.py +++ b/src/engine/config.py @@ -13,6 +13,7 @@ class ProviderConfig: ollama_model: str = "gemma2:9b" openrouter_api_key: str = "" 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_api_key: str = "" @@ -25,7 +26,7 @@ def load_from_env() -> ProviderConfig: kind=os.getenv("DCC_LLM_PROVIDER", "openrouter"), # 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("OPENROUTER_API_KEY", ""), + 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"), diff --git a/src/engine/db.py b/src/engine/db.py index d406e89..53d0da8 100644 --- a/src/engine/db.py +++ b/src/engine/db.py @@ -7,19 +7,33 @@ Gestisce schema SQLite e popolazione iniziale dati. import sqlite3 import json import os +import threading from pathlib import Path DB_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "dcc_rpg.db" DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data" +_conn_lock = threading.Lock() +_local = threading.local() + def get_conn() -> sqlite3.Connection: - """Restituisce connessione al database, creandolo se non esiste.""" + """Restituisce connessione thread-local al database. + + Usa WAL mode + busy_timeout per supportare accessi concorrenti. + La connessione è cached per-thread via threading.local(). + """ + conn = getattr(_local, "conn", None) + if conn is not None: + return conn + DB_PATH.parent.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(str(DB_PATH)) + conn = sqlite3.connect(str(DB_PATH), check_same_thread=False) conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=5000") conn.execute("PRAGMA foreign_keys=ON") conn.row_factory = sqlite3.Row + _local.conn = conn return conn @@ -58,11 +72,13 @@ def init_schema(conn: sqlite3.Connection) -> None: -- ============================================================ CREATE TABLE IF NOT EXISTS campaigns ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - adventure TEXT, - turn INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + adventure TEXT, + turn INTEGER NOT NULL DEFAULT 0, + current_area_id TEXT DEFAULT '', + current_area_name TEXT DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS characters ( @@ -105,6 +121,28 @@ def init_schema(conn: sqlite3.Connection) -> None: CREATE UNIQUE INDEX IF NOT EXISTS idx_area_campaign ON adventure_state(campaign_id, area_id); + CREATE TABLE IF NOT EXISTS combat_state ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + campaign_id INTEGER NOT NULL REFERENCES campaigns(id), + monster_id TEXT NOT NULL, + monster_name TEXT NOT NULL DEFAULT '', + current_hp INTEGER NOT NULL, + max_hp INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'active' + CHECK(status IN ('active', 'defeated', 'fled')), + UNIQUE(campaign_id, monster_id) + ); + + CREATE TABLE IF NOT EXISTS character_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + character_id INTEGER NOT NULL REFERENCES characters(id), + item_def_id TEXT, + name TEXT NOT NULL, + quantity INTEGER DEFAULT 1, + equipped INTEGER DEFAULT 0, + properties TEXT DEFAULT '{}' + ); + CREATE TABLE IF NOT EXISTS quests ( id INTEGER PRIMARY KEY AUTOINCREMENT, campaign_id INTEGER NOT NULL REFERENCES campaigns(id), @@ -178,10 +216,25 @@ def populate_from_json(conn: sqlite3.Connection) -> None: conn.commit() +def _migrate_schema(conn: sqlite3.Connection) -> None: + """Applica migrazioni incrementali allo schema esistente.""" + migrations = [ + "ALTER TABLE campaigns ADD COLUMN current_area_id TEXT DEFAULT ''", + "ALTER TABLE campaigns ADD COLUMN current_area_name TEXT DEFAULT ''", + ] + for sql in migrations: + try: + conn.execute(sql) + except sqlite3.OperationalError: + pass + conn.commit() + + def init_db() -> sqlite3.Connection: """Inizializza database completo. Chiamata all'avvio.""" conn = get_conn() init_schema(conn) + _migrate_schema(conn) populate_from_json(conn) return conn @@ -273,7 +326,8 @@ def get_all_tables() -> list[dict]: def create_campaign(name: str, adventure: str = "") -> int: conn = get_conn() cur = conn.execute( - "INSERT INTO campaigns (name, adventure) VALUES (?, ?)", (name, adventure) + "INSERT INTO campaigns (name, adventure, current_area_id, current_area_name) VALUES (?, ?, '', '')", + (name, adventure), ) conn.commit() return cur.lastrowid or 0 @@ -408,6 +462,108 @@ def get_quests(campaign_id: int) -> list[dict]: return [dict(r) for r in rows] +# --------------------------------------------------------------------------- +# Area / Combat / Inventory helpers +# --------------------------------------------------------------------------- + + +def set_current_area(campaign_id: int, area_id: str, area_name: str = "") -> None: + conn = get_conn() + conn.execute( + "UPDATE campaigns SET current_area_id = ?, current_area_name = ? WHERE id = ?", + (area_id, area_name, campaign_id), + ) + conn.commit() + set_area_state(campaign_id, area_id, visited=True) + + +def get_current_area(campaign_id: int) -> dict: + conn = get_conn() + row = conn.execute( + "SELECT current_area_id, current_area_name FROM campaigns WHERE id = ?", + (campaign_id,), + ).fetchone() + if row and row["current_area_id"]: + area = get_area_by_id(row["current_area_id"]) + return { + "area_id": row["current_area_id"], + "area_name": row["current_area_name"] or (area["name"] if area else ""), + } + return {"area_id": "", "area_name": ""} + + +def set_combat_state(campaign_id: int, monster_id: str, monster_name: str, hp: int, status: str = "active") -> None: + conn = get_conn() + conn.execute( + """INSERT INTO combat_state (campaign_id, monster_id, monster_name, current_hp, max_hp, status) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(campaign_id, monster_id) DO UPDATE SET + current_hp = excluded.current_hp, + status = excluded.status""", + (campaign_id, monster_id, monster_name, hp, hp, status), + ) + conn.commit() + + +def update_combat_hp(campaign_id: int, monster_id: str, damage: int) -> dict | None: + conn = get_conn() + row = conn.execute( + "SELECT * FROM combat_state WHERE campaign_id = ? AND monster_id = ?", + (campaign_id, monster_id), + ).fetchone() + if not row: + return None + new_hp = max(0, row["current_hp"] - damage) + new_status = "defeated" if new_hp <= 0 else row["status"] + conn.execute( + "UPDATE combat_state SET current_hp = ?, status = ? WHERE id = ?", + (new_hp, new_status, row["id"]), + ) + conn.commit() + return {"monster_id": monster_id, "previous_hp": row["current_hp"], "new_hp": new_hp, "damage": damage, "status": new_status} + + +def get_combat_state(campaign_id: int) -> list[dict]: + conn = get_conn() + rows = conn.execute( + "SELECT * FROM combat_state WHERE campaign_id = ? ORDER BY id", (campaign_id,) + ).fetchall() + return [dict(r) for r in rows] + + +def add_character_item(character_id: int, name: str, item_def_id: str = "", quantity: int = 1) -> int: + conn = get_conn() + cur = conn.execute( + "INSERT INTO character_items (character_id, name, item_def_id, quantity) VALUES (?, ?, ?, ?)", + (character_id, name, item_def_id, quantity), + ) + conn.commit() + return cur.lastrowid or 0 + + +def remove_character_item(item_id: int) -> bool: + conn = get_conn() + conn.execute("DELETE FROM character_items WHERE id = ?", (item_id,)) + conn.commit() + return conn.total_changes > 0 + + +def get_character_items(character_id: int) -> list[dict]: + conn = get_conn() + rows = conn.execute( + "SELECT * FROM character_items WHERE character_id = ? ORDER BY id", + (character_id,), + ).fetchall() + return [dict(r) for r in rows] + + +def equip_character_item(item_id: int, equipped: bool = True) -> bool: + conn = get_conn() + conn.execute("UPDATE character_items SET equipped = ? WHERE id = ?", (int(equipped), item_id)) + conn.commit() + return conn.total_changes > 0 + + # --------------------------------------------------------------------------- # Adventure data loading (from JSON) # --------------------------------------------------------------------------- diff --git a/src/engine/dm.py b/src/engine/dm.py index 808af36..9c0c504 100644 --- a/src/engine/dm.py +++ b/src/engine/dm.py @@ -4,20 +4,21 @@ import logging from src.engine import db, tools from src.engine.config import load_from_env +from src.engine.game_context import GameContext +from src.engine.heuristic_fallback import parse_action as fallback_parse from src.engine.llm import ask as llm_ask, parse_tool_call from src.engine.prompts import SYSTEM_PROMPT, build_prompt -from src.engine.state_machine import GameContext, _parse_action log = logging.getLogger(__name__) -def _llm_decision(ctx: GameContext, player_action: str) -> tuple[str, dict, str]: - prompt = build_prompt(ctx, player_action) +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) try: raw = llm_ask(prompt, system=SYSTEM_PROMPT) except Exception as e: - log.warning("LLM call failed (%s), fallback a _parse_action", e) - tool, params = _parse_action(player_action, ctx) + log.warning("LLM call failed (%s), fallback a heuristic parser", e) + tool, params = fallback_parse(player_action, ctx.characters) return tool, params, "" decision = parse_tool_call(raw) @@ -28,7 +29,7 @@ def _llm_decision(ctx: GameContext, player_action: str) -> tuple[str, dict, str] if not tool: log.info("LLM non ha prodotto tool valido, fallback heuristico") - tool, params = _parse_action(player_action, ctx) + tool, params = fallback_parse(player_action, ctx.characters) return tool, params, narration @@ -36,7 +37,7 @@ def _llm_decision(ctx: GameContext, player_action: str) -> tuple[str, dict, str] def dm_cycle(campaign_id: int, player_action: str) -> dict: ctx = GameContext.load(campaign_id) - tool_name, tool_params, narration = _llm_decision(ctx, player_action) + tool_name, tool_params, narration = _llm_decision(ctx, player_action, current_area_id=ctx.current_area_id) result = tools.run(tool_name, **tool_params) diff --git a/src/engine/game_context.py b/src/engine/game_context.py new file mode 100644 index 0000000..095f6ca --- /dev/null +++ b/src/engine/game_context.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from src.engine import db + + +@dataclass +class GameContext: + campaign_id: int + turn: int + campaign: dict | None = None + characters: list[dict] = field(default_factory=list) + log: list[dict] = field(default_factory=list) + quests: list[dict] = field(default_factory=list) + areas: list[dict] = field(default_factory=list) + current_area_id: str = "" + current_area_name: str = "" + + @classmethod + def load(cls, campaign_id: int) -> GameContext: + camp = db.get_campaign(campaign_id) + if not camp: + raise ValueError(f"Campagna #{campaign_id} non trovata.") + chars = db.get_characters(campaign_id) + log = db.get_log(campaign_id, limit=10) + quests = db.get_quests(campaign_id) + areas = db.get_area_state(campaign_id) + area_info = db.get_current_area(campaign_id) + return cls( + campaign_id=campaign_id, + turn=camp.get("turn", 0), + campaign=camp, + characters=chars, + log=log, + quests=quests, + areas=areas, + current_area_id=area_info.get("area_id", ""), + current_area_name=area_info.get("area_name", ""), + ) + + def summary(self) -> str: + camp = self.campaign or {"name": "?", "turn": 0} + lines = [f"Campagna: {camp['name']} | Turno {self.turn}"] + 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 "" + lines.append( + f" [{c['id']}] {c['name']} — Lv{c['level']} {cls} " + f"PF {c['hp_current']}/{c['hp_max']} PE {c.get('xp', 0)}" + ) + if self.quests: + lines.append("Missioni:") + for q in self.quests: + lines.append(f" [{q['status']}] {q['name']}") + return "\n".join(lines) diff --git a/src/engine/heuristic_fallback.py b/src/engine/heuristic_fallback.py new file mode 100644 index 0000000..92f4408 --- /dev/null +++ b/src/engine/heuristic_fallback.py @@ -0,0 +1,77 @@ +""" +Heuristic fallback parser per azioni giocatore. + +Usato SOLO quando il parsing LLM fallisce o restituisce tool vuoto. +Non è un secondo pilota — è un salvagente. +""" + +from __future__ import annotations + +import re +from typing import Any + + +def parse_action(raw_action: str, chars: list[dict]) -> tuple[str, dict]: + """Parsing euristico azione testuale → (tool_name, params). + + Chiamato SOLO come fallback quando parse_tool_call() fallisce. + """ + a = raw_action.lower().strip() + cd = re.search(r'(?:cd|dc)\s*(\d+)', a) + char_id = chars[0]["id"] if chars else 0 + + if any(k in a for k in ["tempra", "riflessi", "volontà", "salvezza"]): + for key in ["tempra", "riflessi", "volontà"]: + if key in a: + return "saving_throw", { + "character_id": char_id, + "save_type": key.capitalize(), + "dc": int(cd.group(1)) if cd else 15, + } + return "saving_throw", { + "character_id": char_id, + "save_type": "Tempra", + "dc": int(cd.group(1)) if cd else 15, + } + + if any(k in a for k in ["abilità", "prova", "percepire", "scassinare", "nascondersi"]): + return "ability_check", { + "character_id": char_id, + "dc": int(cd.group(1)) if cd else 12, + } + + if any(k in a for k in ["attacco", "attaccare", "colpire", "combattimento", "combat", "mischia"]): + mod = re.search(r'([+-]\s*\d+)', a) + ac = re.search(r'(?:ca|ac)\s*(\d+)', a) + return "combat", { + "character_id": char_id, + "target_ac": int(ac.group(1)) if ac else 12, + "attack_bonus": int(mod.group(1).replace(" ", "")) if mod else 0, + } + + if any(k in a for k in ["muovi", "muovo", "movimento", "esplora", "cammina", "cammino", "corri", "corro"]): + dist = re.search(r'(\d+)\s*m', a) + return "movement", {"distance_m": int(dist.group(1)) if dist else 10} + + if any(k in a for k in ["incantesimo", "magia", "lancio", "spell"]): + return "spellcasting", { + "character_id": char_id, + "spell_name": a, + "spell_check": 10, + } + + if any(k in a for k in ["inventario", "prendi", "prendo", "lascia", "lascio", "usa", "uso", "raccogli", "raccolgo"]): + return "update_inventory", { + "character_id": char_id, + "item": raw_action.split()[-1], + "action": "add", + } + + if any(k in a for k in ["xp", "esperienza", "livello"]): + return "award_xp", {"character_id": char_id, "xp": 5} + + die_match = re.search(r'\bd(?:20|%|3|4|5|6|7|8|10|12|14|16|24|30)\b', a) + if die_match: + return "roll_dice", {"die": die_match.group()} + + return "dm_override", {"command": "narrate", "params": {"text": raw_action}} diff --git a/src/engine/llm.py b/src/engine/llm.py index 7774abe..ae465e4 100644 --- a/src/engine/llm.py +++ b/src/engine/llm.py @@ -1,62 +1,29 @@ from __future__ import annotations +import contextvars import json import re -from src.engine.config import load_from_env from src.engine.providers import LLMProvider, create_provider -DEFAULT_MODEL = "gemma2:9b" -_KNOWN_TOOLS = { - "roll_dice": {"die": "str", "count": "int (default 1)", "modifier": "int (default 0)"}, - "dice_chain": {"die": "str", "steps": "int"}, - "combat": {"character_id": "int", "target_ac": "int", "attack_bonus": "int (default 0)", - "damage_die": "str (default d8)", "damage_bonus": "int (default 0)", - "mighty_deed_die": "str (default '')", "luck_mod": "int (default 0)", - "armor_type": "str (none/light/medium/heavy)"}, - "ability_check": {"character_id": "int", "dc": "int", "modifier": "int (default 0)"}, - "saving_throw": {"character_id": "int", "save_type": "str (Tempra/Riflessi/Volontà)", "dc": "int", "modifier": "int (default 0)"}, - "movement": {"distance_m": "int", "terrain": "str (normale/difficile/molto difficile)", "stealth": "bool (default false)"}, - "spellcasting": {"character_id": "int", "spell_name": "str", "spell_check": "int", "dc": "int (default 12)"}, - "update_inventory": {"character_id": "int", "item": "str", "action": "str (add/remove)"}, - "award_xp": {"character_id": "int", "xp": "int"}, - "dm_override": {"command": "str", "params": "dict (opzionale)"}, -} - -SYSTEM_PROMPT = f"""Sei il Dungeon Master di una partita di DCC RPG (Dungeon Crawl Classics). -Il giocatore scrive un'azione e TU decidi quale strumento chiamare. - -Strumenti disponibili: -{json.dumps(_KNOWN_TOOLS, indent=2)} - -Regole: -1. Scegli SEMPRE UNO strumento per rispondere all'azione del giocatore. -2. I parametri DEVONO essere realistici per il contesto. -3. Se il giocatore fa qualcosa di narrativo, usa dm_override con command="narrate". -4. Tira sempre i dadi quando il giocatore tenta un'azione rischiosa. -5. Rispondi SOLO con JSON in questo formato: -{{"tool": "nome_tool", "params": {{...}}, "narration": "breve descrizione di cosa succede"}} - - -""" - -_provider: LLMProvider | None = None +_provider_var: contextvars.ContextVar[LLMProvider | None] = contextvars.ContextVar("provider", default=None) def get_provider() -> LLMProvider: - global _provider - if _provider is None: - _provider = create_provider(load_from_env()) - return _provider + provider = _provider_var.get() + if provider is None: + from src.engine.config import load_from_env + provider = create_provider(load_from_env()) + _provider_var.set(provider) + return provider def set_provider(provider: LLMProvider) -> None: - global _provider - _provider = provider + _provider_var.set(provider) -def ask(prompt: str, system: str = SYSTEM_PROMPT) -> str: +def ask(prompt: str, system: str = "") -> str: return get_provider().ask(prompt, system=system) @@ -100,10 +67,11 @@ if __name__ == "__main__": "Attacco l'orco con la mia ascia!", "Esploro il corridoio buio", ] + from src.engine.prompts import SYSTEM_PROMPT as _DM_PROMPT for a in test_actions: print(f"\n>>> Giocatore: {a}") try: - resp = ask(a) + resp = ask(a, system=_DM_PROMPT) print(f"LLM: {resp[:300]}") parsed = parse_tool_call(resp) print(f"Parser: {json.dumps(parsed, indent=2)}") diff --git a/src/engine/prompts.py b/src/engine/prompts.py index 9511224..199ac9d 100644 --- a/src/engine/prompts.py +++ b/src/engine/prompts.py @@ -4,7 +4,7 @@ import json from typing import Any from src.engine import db as db_module -from src.engine.state_machine import GameContext +from src.engine.game_context import GameContext _KNOWN_TOOLS: dict[str, dict[str, str]] = { "roll_dice": {"die": "tipo dado (d3..d30, d%)", "count": "numero dadi (default 1)", "modifier": "modificatore (default 0)"}, diff --git a/src/engine/providers.py b/src/engine/providers.py index 4bfbaca..f6bdef8 100644 --- a/src/engine/providers.py +++ b/src/engine/providers.py @@ -131,9 +131,9 @@ def create_provider(cfg: ProviderConfig | None = None) -> LLMProvider: if cfg.kind == "ollama": return cls(cfg.ollama_base_url, cfg.ollama_model, cfg.timeout) elif cfg.kind == "openrouter": - key = cfg.openrouter_api_key or os.getenv("OPENROUTER_API_KEY", "") + key = cfg.openrouter_api_key or os.getenv("DCC_OPENROUTER_KEY", os.getenv("OPENROUTER_API_KEY", "")) if not key: - raise ValueError("OPENROUTER_API_KEY non configurata") + 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) diff --git a/src/engine/state_machine.py b/src/engine/state_machine.py index 81d13c1..84cfa24 100644 --- a/src/engine/state_machine.py +++ b/src/engine/state_machine.py @@ -1,160 +1,13 @@ from __future__ import annotations -import json -import re -from dataclasses import dataclass, field -from typing import Any +import warnings -from src.engine import db, tools +from src.engine.game_context import GameContext -# --------------------------------------------------------------------------- -# Stato del gioco (immutabile dentro un turno) -# --------------------------------------------------------------------------- +warnings.warn( + "state_machine module is deprecated. Import GameContext from src.engine.game_context instead.", + DeprecationWarning, + stacklevel=2, +) - -@dataclass -class GameContext: - campaign_id: int - turn: int - campaign: dict | None = None - characters: list[dict] = field(default_factory=list) - log: list[dict] = field(default_factory=list) - quests: list[dict] = field(default_factory=list) - areas: list[dict] = field(default_factory=list) - - @classmethod - def load(cls, campaign_id: int) -> GameContext: - camp = db.get_campaign(campaign_id) - if not camp: - raise ValueError(f"Campagna #{campaign_id} non trovata.") - chars = db.get_characters(campaign_id) - log = db.get_log(campaign_id, limit=10) - quests = db.get_quests(campaign_id) - areas = db.get_area_state(campaign_id) - return cls( - campaign_id=campaign_id, - turn=camp["turn"], - campaign=camp, - characters=chars, - log=log, - quests=quests, - areas=areas, - ) - - def summary(self) -> str: - camp = self.campaign or {"name": "?", "turn": 0} - lines = [f"Campagna: {camp['name']} | Turno {self.turn}"] - for c in self.characters: - cls = c.get("char_class") or "" - lines.append( - f" [{c['id']}] {c['name']} — Lv{c['level']} {cls} " - f"PF {c['hp_current']}/{c['hp_max']} PE {c.get('xp', 0)}" - ) - if self.quests: - lines.append("Missioni:") - for q in self.quests: - lines.append(f" [{q['status']}] {q['name']}") - return "\n".join(lines) - - -# --------------------------------------------------------------------------- -# Decisore — sceglie quale tool chiamare in base all'azione testuale -# --------------------------------------------------------------------------- - -# Mappa euristica: parole chiave → tool -def _parse_action(raw_action: str, ctx: GameContext) -> tuple[str, dict]: - a = raw_action.lower().strip() - cd = re.search(r'(?:cd|dc)\s*(\d+)', a) - char_id = ctx.characters[0]["id"] if ctx.characters else 0 - - if any(k in a for k in ["tempra", "riflessi", "volontà", "salvezza"]): - for key in ["tempra", "riflessi", "volontà"]: - if key in a: - return "saving_throw", {"character_id": char_id, "save_type": key.capitalize(), "dc": int(cd.group(1)) if cd else 15} - return "saving_throw", {"character_id": char_id, "save_type": "Tempra", "dc": int(cd.group(1)) if cd else 15} - - if any(k in a for k in ["abilità", "prova", "percepire", "scassinare", "nascondersi"]): - return "ability_check", {"character_id": char_id, "dc": int(cd.group(1)) if cd else 12} - - if any(k in a for k in ["attacco", "attaccare", "colpire", "combattimento", "combat", "mischia"]): - mod = re.search(r'([+-]\s*\d+)', a) - ac = re.search(r'(?:ca|ac)\s*(\d+)', a) - return "combat", { - "character_id": char_id, - "target_ac": int(ac.group(1)) if ac else 12, - "attack_bonus": int(mod.group(1).replace(" ", "")) if mod else 0, - } - - if any(k in a for k in ["muovi", "muovo", "movimento", "esplora", "cammina", "cammino", "corri", "corro"]): - dist = re.search(r'(\d+)\s*m', a) - return "movement", {"distance_m": int(dist.group(1)) if dist else 10} - - if any(k in a for k in ["incantesimo", "magia", "lancio", "spell"]): - return "spellcasting", {"character_id": char_id, "spell_name": a, "spell_check": 10} - - if any(k in a for k in ["inventario", "prendi", "prendo", "lascia", "lascio", "usa", "uso", "raccogli", "raccolgo"]): - return "update_inventory", {"character_id": char_id, "item": raw_action.split()[-1], "action": "add"} - - if any(k in a for k in ["xp", "esperienza", "livello"]): - return "award_xp", {"character_id": char_id, "xp": 5} - - die_match = re.search(r'\bd(?:20|%|3|4|5|6|7|8|10|12|14|16|24|30)\b', a) - if die_match: - return "roll_dice", {"die": die_match.group()} - - return "dm_override", {"command": "narrate", "params": {"text": raw_action}} - - -def process_turn(campaign_id: int, raw_action: str) -> dict: - ctx = GameContext.load(campaign_id) - tool_name, tool_params = _parse_action(raw_action, ctx) - - result = tools.run(tool_name, **tool_params) - - turn = db.advance_turn(campaign_id) - db.log_turn(campaign_id, turn, raw_action, result.get("message", "")) - - return { - "turn": turn, - "action": raw_action, - "tool": tool_name, - "result": result, - "context": ctx.summary(), - } - - -# --------------------------------------------------------------------------- -# CLI test -# --------------------------------------------------------------------------- - -if __name__ == "__main__": - conn = db.init_db() - - camp_id = db.create_campaign("Avventura Test", "Tomba del Mago-Guerriero") - char_id = db.create_character(camp_id, "Marco il Fidato", "guerriero", "soldato") - db.update_character(char_id, - level=1, str=15, agi=11, sta=14, - per=9, int=8, lck=10, - hp_current=12, hp_max=12, ac=15, - armor_type="light" - ) - - actions = [ - "Tiro un d20 per iniziativa", - "Attacco il guardiano con la spada", - "Prova di percezione CD 12", - "Tiro salvezza su Tempra CD 15", - "Mi muovo verso la porta", - "Lancio l'incantesimo Dardo Incantato", - "Prendo la pozione dalla mensola", - "dm forzo la scena", - ] - - for action in actions: - print(f"\n>>> {action}") - out = process_turn(camp_id, action) - print(f" Tool: {out['tool']} | Msg: {out['result'].get('message', '?')[:100]}") - - print(f"\n=== Cronologia ({len(actions)} turni) ===") - ctx = GameContext.load(camp_id) - print(ctx.summary()) +__all__ = ["GameContext"] diff --git a/src/web/server.py b/src/web/server.py index 4332365..87d0abf 100644 --- a/src/web/server.py +++ b/src/web/server.py @@ -12,7 +12,7 @@ from fastapi.staticfiles import StaticFiles from src.engine import db from src.engine.config import load_from_env from src.engine.dm import dm_cycle -from src.engine.state_machine import GameContext +from src.engine.game_context import GameContext log = logging.getLogger(__name__) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dice.py b/tests/test_dice.py new file mode 100644 index 0000000..fb7023e --- /dev/null +++ b/tests/test_dice.py @@ -0,0 +1,108 @@ +import sys +sys.path.insert(0, "src") + +from src.engine.dice import roll, dice_chain, roll_str, validate_die, ALL_DICE + + +def test_roll_basic(): + for _ in range(100): + r = roll("d20") + assert 1 <= r["total"] <= 20 + assert r["die"] == "d20" + assert r["count"] == 1 + assert r["modifier"] == 0 + assert len(r["results"]) == 1 + + +def test_roll_multiple(): + for _ in range(50): + r = roll("d6", count=3) + assert len(r["results"]) == 3 + assert 3 <= r["total"] <= 18 + + +def test_roll_modifier(): + r = roll("d20", modifier=5) + assert r["total"] == r["results"][0] + 5 + assert r["modifier"] == 5 + + +def test_roll_crit(): + for _ in range(1000): + r = roll("d20") + if r["results"][0] == 20: + assert r["crit"] is True + if r["results"][0] == 1: + assert r["fumble"] is True + + +def test_roll_d100(): + for _ in range(100): + r = roll("d%") + assert 1 <= r["total"] <= 100 + + +def test_roll_dice_chain_up(): + res = dice_chain("d3", 2) + assert res["output_die"] == "d5" + assert res["steps"] == 2 + assert res["input_die"] == "d3" + + +def test_dice_chain_overflow(): + res = dice_chain("d3", 20) + assert res["output_die"] == "d30" + + +def test_dice_chain_underflow(): + res = dice_chain("d30", -30) + assert res["output_die"] == "d3" + + +def test_dice_chain_invalid(): + res = dice_chain("d9", 1) + assert "error" in res + + +def test_roll_str_basic(): + r = roll_str("2d6+3") + assert r["count"] == 2 + assert r["die"] == "d6" + assert r["modifier"] == 3 + + +def test_roll_str_percent(): + r = roll_str("d%") + assert r["die"] == "d%" + assert r["count"] == 1 + + +def test_roll_str_negative_mod(): + r = roll_str("d20-2") + assert r["modifier"] == -2 + + +def test_validate_die(): + assert validate_die("d20") == "d20" + assert validate_die("d%") == "d%" + assert validate_die("d7") == "d7" + assert validate_die("d9") is None + assert validate_die("xyz") is None + + +def test_all_dice_keys(): + assert "d3" in ALL_DICE + assert "d20" in ALL_DICE + assert "d30" in ALL_DICE + assert "d%" in ALL_DICE + assert ALL_DICE["d%"] == 100 + + +def test_roll_zero_faced_die(): + r = roll("d0") + assert r["total"] == 0 + + +def test_roll_one_faced_die(): + r = roll("d1") + assert r["total"] == 1 diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 0000000..16a4b30 --- /dev/null +++ b/tests/test_tools.py @@ -0,0 +1,140 @@ +import sys +import json +sys.path.insert(0, "src") + +from src.engine.tools import run +from src.engine.db import init_db, create_campaign, create_character, update_character + + +def _setup_char(): + conn = init_db() + camp_id = create_campaign("Test", "Avventura") + char_id = create_character(camp_id, "TestChar", "guerriero", "soldato") + update_character( + char_id, + level=1, str=15, agi=11, sta=14, + per=9, int=8, lck=10, + hp_current=12, hp_max=12, ac=15, + armor_type="light", + ) + return camp_id, char_id + + +def test_roll_dice_tool(): + r = run("roll_dice", die="d20") + assert r["success"] is True + assert r["tool"] == "roll-dice" + assert 1 <= r["total"] <= 20 + + +def test_dice_chain_tool(): + r = run("dice_chain", die="d3", steps=2) + assert r["success"] is True + assert r["output_die"] == "d5" + + +def test_unknown_tool(): + r = run("nonexistent") + assert r["success"] is False + assert "sconosciuto" in r["message"] + + +def test_ability_check(): + _, char_id = _setup_char() + r = run("ability_check", character_id=char_id, dc=12, modifier=2) + assert r["success"] is True + assert "d20" in r["message"] + + +def test_saving_throw(): + _, char_id = _setup_char() + r = run("saving_throw", character_id=char_id, save_type="Tempra", dc=15) + assert r["success"] is True + assert r["save_type"] == "Tempra" + + +def test_saving_throw_invalid_type(): + _, char_id = _setup_char() + r = run("saving_throw", character_id=char_id, save_type="Forza", dc=15) + assert r["success"] is False + + +def test_movement(): + r = run("movement", distance_m=30, terrain="normale") + assert r["success"] is True + assert r["rounds"] == 3 + assert r["speed_m_per_round"] == 12 + + +def test_movement_difficult(): + r = run("movement", distance_m=30, terrain="difficile") + assert r["success"] is True + assert r["rounds"] == 5 + + +def test_spellcasting(): + _, char_id = _setup_char() + r = run("spellcasting", character_id=char_id, spell_name="Dardo Incantato", spell_check=18, dc=12) + assert r["success"] is True + assert r["result_tier"] in ("minore", "moderato", "maggiore") + + +def test_spellcasting_failure(): + _, char_id = _setup_char() + r = run("spellcasting", character_id=char_id, spell_name="Dardo Incantato", spell_check=5, dc=12) + assert r["success"] is True + assert "fallisce" in r["message"] + + +def test_update_inventory(): + _, char_id = _setup_char() + r = run("update_inventory", character_id=char_id, item="Pozione di Cura") + assert r["success"] is True + assert "Pozione di Cura" in r["message"] + + +def test_award_xp(): + _, char_id = _setup_char() + r = run("award_xp", character_id=char_id, xp=5) + assert r["success"] is True + assert r["xp_gained"] == 5 + + +def test_award_xp_levelup(): + camp_id, _ = _setup_char() + # Create a lv0 character (contadino) to test level-up + import src.engine.db as db + lv0_id = db.create_character(camp_id, "Lv0 Hero", "contadino", "contadino") + db.update_character(lv0_id, level=0, xp=0) + r1 = run("award_xp", character_id=lv0_id, xp=10) + assert r1["xp_total"] == 10 + assert r1["leveled_up"] is True + + +def test_dm_override(): + r = run("dm_override", command="narrate", params={"text": "Una luce accecante."}) + assert r["success"] is True + assert r["tool"] == "dm-override" + + +def test_dm_override_help(): + r = run("dm_override", command="help") + assert r["success"] is True + assert "commands" in r + + +def test_combat(): + _, char_id = _setup_char() + r = run( + "combat", + character_id=char_id, + target_ac=15, + attack_bonus=2, + damage_die="d8", + mighty_deed_die="d3", + luck_mod=0, + armor_type="medium", + ) + assert r["success"] is True + assert r["tool"] == "combat" + assert isinstance(r["hit"], bool)