- Fase 0: env var allineate, SYSTEM_PROMPT deduplicato, .gitignore pulito - Fase 1: GameContext isolato, ciclo DM unificato, provider thread-safe (contextvars) - Fase 2: DB schema esteso (current_area_id, combat_state, character_items) - Fase 3: thread-local connection pool + WAL + busy_timeout - Fase 4: test suite (32 test, 100% pass) - heuristic_fallback come parser di riserva (non secondo pilota)
9.6 KiB
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
# 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
*.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 soloGameContext) - 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:
# 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_statenel 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:
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.
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:
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:
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:
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:
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 <token> 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():
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
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:
- Un giocatore entra in una campagna → vede la posizione corrente
- Si muove in una nuova area → il DB lo registra, il contesto LLM cambia
- Attacca un mostro → gli HP scalano, il mostro può morire permanentemente
- Si riconnette dopo crash → lo stato è identico
- Due giocatori possono connettersi simultaneamente senza crash
- I test passano in CI