Files
dcc-rpg/REMEDIATION_PLAN.md
truetype cb45172717 refactor: remediation plan Fase 0-4 completata
- 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)
2026-06-27 18:00:03 +02:00

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 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:

# 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:

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:

  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