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)
This commit is contained in:
truetype committed 2026-06-27 18:00:03 +02:00
1 parent 90f823fdb9
commit cb45172717
16 files changed
+906 -221

No files matched your search

+317
View File
@@ -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 <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()`:
```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