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:
1 parent
90f823fdb9
commit
cb45172717
16 files changed
+906
-221
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user