# 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