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

+8
View File
@@ -18,6 +18,14 @@ __pycache__/
# Generated graph artifacts
graphify-out/
# Virtual environments
env/
# Node.js artifacts (not used by this Python project)
node_modules/
package-lock.json
package.json
# OS files
.DS_Store
Thumbs.db
+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
+1 -2
View File
@@ -1,14 +1,13 @@
from src.engine.config import ProviderConfig, load_from_env, ProviderKind
from src.engine.dm import dm_cycle, interactive
from src.engine.llm import set_provider
from src.engine.state_machine import GameContext, process_turn
from src.engine.game_context import GameContext
from src.engine.tools import run as run_tool
__all__ = [
"dm_cycle",
"interactive",
"GameContext",
"process_turn",
"run_tool",
"ProviderConfig",
"ProviderKind",
+2 -1
View File
@@ -13,6 +13,7 @@ class ProviderConfig:
ollama_model: str = "gemma2:9b"
openrouter_api_key: str = ""
openrouter_model: str = "openrouter/free"
openrouter_key_env: str = "DCC_OPENROUTER_KEY"
openrouter_free_fallback: bool = True
local_base_url: str = "http://192.168.0.240/v1"
local_api_key: str = ""
@@ -25,7 +26,7 @@ def load_from_env() -> ProviderConfig:
kind=os.getenv("DCC_LLM_PROVIDER", "openrouter"), # type: ignore
ollama_base_url=os.getenv("DCC_OLLAMA_URL", "http://192.168.0.50:11434"),
ollama_model=os.getenv("DCC_OLLAMA_MODEL", "gemma2:9b"),
openrouter_api_key=os.getenv("OPENROUTER_API_KEY", ""),
openrouter_api_key=os.getenv("DCC_OPENROUTER_KEY", os.getenv("OPENROUTER_API_KEY", "")),
openrouter_model=os.getenv("DCC_OPENROUTER_MODEL", "openrouter/free"),
openrouter_free_fallback=os.getenv("DCC_OR_FREE_FALLBACK", "1") == "1",
local_base_url=os.getenv("DCC_LOCAL_URL", "http://192.168.0.240/v1"),
+164 -8
View File
@@ -7,19 +7,33 @@ Gestisce schema SQLite e popolazione iniziale dati.
import sqlite3
import json
import os
import threading
from pathlib import Path
DB_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "dcc_rpg.db"
DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data"
_conn_lock = threading.Lock()
_local = threading.local()
def get_conn() -> sqlite3.Connection:
"""Restituisce connessione al database, creandolo se non esiste."""
"""Restituisce connessione thread-local al database.
Usa WAL mode + busy_timeout per supportare accessi concorrenti.
La connessione è cached per-thread via threading.local().
"""
conn = getattr(_local, "conn", None)
if conn is not None:
return conn
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
conn = sqlite3.connect(str(DB_PATH), check_same_thread=False)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("PRAGMA foreign_keys=ON")
conn.row_factory = sqlite3.Row
_local.conn = conn
return conn
@@ -58,11 +72,13 @@ def init_schema(conn: sqlite3.Connection) -> None:
-- ============================================================
CREATE TABLE IF NOT EXISTS campaigns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
adventure TEXT,
turn INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
adventure TEXT,
turn INTEGER NOT NULL DEFAULT 0,
current_area_id TEXT DEFAULT '',
current_area_name TEXT DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS characters (
@@ -105,6 +121,28 @@ def init_schema(conn: sqlite3.Connection) -> None:
CREATE UNIQUE INDEX IF NOT EXISTS idx_area_campaign
ON adventure_state(campaign_id, area_id);
CREATE TABLE IF NOT EXISTS combat_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
campaign_id INTEGER NOT NULL REFERENCES campaigns(id),
monster_id TEXT NOT NULL,
monster_name TEXT NOT NULL DEFAULT '',
current_hp INTEGER NOT NULL,
max_hp INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('active', 'defeated', 'fled')),
UNIQUE(campaign_id, monster_id)
);
CREATE TABLE IF NOT EXISTS character_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
character_id INTEGER NOT NULL REFERENCES characters(id),
item_def_id TEXT,
name TEXT NOT NULL,
quantity INTEGER DEFAULT 1,
equipped INTEGER DEFAULT 0,
properties TEXT DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS quests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
campaign_id INTEGER NOT NULL REFERENCES campaigns(id),
@@ -178,10 +216,25 @@ def populate_from_json(conn: sqlite3.Connection) -> None:
conn.commit()
def _migrate_schema(conn: sqlite3.Connection) -> None:
"""Applica migrazioni incrementali allo schema esistente."""
migrations = [
"ALTER TABLE campaigns ADD COLUMN current_area_id TEXT DEFAULT ''",
"ALTER TABLE campaigns ADD COLUMN current_area_name TEXT DEFAULT ''",
]
for sql in migrations:
try:
conn.execute(sql)
except sqlite3.OperationalError:
pass
conn.commit()
def init_db() -> sqlite3.Connection:
"""Inizializza database completo. Chiamata all'avvio."""
conn = get_conn()
init_schema(conn)
_migrate_schema(conn)
populate_from_json(conn)
return conn
@@ -273,7 +326,8 @@ def get_all_tables() -> list[dict]:
def create_campaign(name: str, adventure: str = "") -> int:
conn = get_conn()
cur = conn.execute(
"INSERT INTO campaigns (name, adventure) VALUES (?, ?)", (name, adventure)
"INSERT INTO campaigns (name, adventure, current_area_id, current_area_name) VALUES (?, ?, '', '')",
(name, adventure),
)
conn.commit()
return cur.lastrowid or 0
@@ -408,6 +462,108 @@ def get_quests(campaign_id: int) -> list[dict]:
return [dict(r) for r in rows]
# ---------------------------------------------------------------------------
# Area / Combat / Inventory helpers
# ---------------------------------------------------------------------------
def set_current_area(campaign_id: int, area_id: str, area_name: str = "") -> None:
conn = get_conn()
conn.execute(
"UPDATE campaigns SET current_area_id = ?, current_area_name = ? WHERE id = ?",
(area_id, area_name, campaign_id),
)
conn.commit()
set_area_state(campaign_id, area_id, visited=True)
def get_current_area(campaign_id: int) -> dict:
conn = get_conn()
row = conn.execute(
"SELECT current_area_id, current_area_name FROM campaigns WHERE id = ?",
(campaign_id,),
).fetchone()
if row and row["current_area_id"]:
area = get_area_by_id(row["current_area_id"])
return {
"area_id": row["current_area_id"],
"area_name": row["current_area_name"] or (area["name"] if area else ""),
}
return {"area_id": "", "area_name": ""}
def set_combat_state(campaign_id: int, monster_id: str, monster_name: str, hp: int, status: str = "active") -> None:
conn = get_conn()
conn.execute(
"""INSERT INTO combat_state (campaign_id, monster_id, monster_name, current_hp, max_hp, status)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(campaign_id, monster_id) DO UPDATE SET
current_hp = excluded.current_hp,
status = excluded.status""",
(campaign_id, monster_id, monster_name, hp, hp, status),
)
conn.commit()
def update_combat_hp(campaign_id: int, monster_id: str, damage: int) -> dict | None:
conn = get_conn()
row = conn.execute(
"SELECT * FROM combat_state WHERE campaign_id = ? AND monster_id = ?",
(campaign_id, monster_id),
).fetchone()
if not row:
return None
new_hp = max(0, row["current_hp"] - damage)
new_status = "defeated" if new_hp <= 0 else row["status"]
conn.execute(
"UPDATE combat_state SET current_hp = ?, status = ? WHERE id = ?",
(new_hp, new_status, row["id"]),
)
conn.commit()
return {"monster_id": monster_id, "previous_hp": row["current_hp"], "new_hp": new_hp, "damage": damage, "status": new_status}
def get_combat_state(campaign_id: int) -> list[dict]:
conn = get_conn()
rows = conn.execute(
"SELECT * FROM combat_state WHERE campaign_id = ? ORDER BY id", (campaign_id,)
).fetchall()
return [dict(r) for r in rows]
def add_character_item(character_id: int, name: str, item_def_id: str = "", quantity: int = 1) -> int:
conn = get_conn()
cur = conn.execute(
"INSERT INTO character_items (character_id, name, item_def_id, quantity) VALUES (?, ?, ?, ?)",
(character_id, name, item_def_id, quantity),
)
conn.commit()
return cur.lastrowid or 0
def remove_character_item(item_id: int) -> bool:
conn = get_conn()
conn.execute("DELETE FROM character_items WHERE id = ?", (item_id,))
conn.commit()
return conn.total_changes > 0
def get_character_items(character_id: int) -> list[dict]:
conn = get_conn()
rows = conn.execute(
"SELECT * FROM character_items WHERE character_id = ? ORDER BY id",
(character_id,),
).fetchall()
return [dict(r) for r in rows]
def equip_character_item(item_id: int, equipped: bool = True) -> bool:
conn = get_conn()
conn.execute("UPDATE character_items SET equipped = ? WHERE id = ?", (int(equipped), item_id))
conn.commit()
return conn.total_changes > 0
# ---------------------------------------------------------------------------
# Adventure data loading (from JSON)
# ---------------------------------------------------------------------------
+8 -7
View File
@@ -4,20 +4,21 @@ import logging
from src.engine import db, tools
from src.engine.config import load_from_env
from src.engine.game_context import GameContext
from src.engine.heuristic_fallback import parse_action as fallback_parse
from src.engine.llm import ask as llm_ask, parse_tool_call
from src.engine.prompts import SYSTEM_PROMPT, build_prompt
from src.engine.state_machine import GameContext, _parse_action
log = logging.getLogger(__name__)
def _llm_decision(ctx: GameContext, player_action: str) -> tuple[str, dict, str]:
prompt = build_prompt(ctx, player_action)
def _llm_decision(ctx: GameContext, player_action: str, current_area_id: str = "") -> tuple[str, dict, str]:
prompt = build_prompt(ctx, player_action, current_area_id=current_area_id)
try:
raw = llm_ask(prompt, system=SYSTEM_PROMPT)
except Exception as e:
log.warning("LLM call failed (%s), fallback a _parse_action", e)
tool, params = _parse_action(player_action, ctx)
log.warning("LLM call failed (%s), fallback a heuristic parser", e)
tool, params = fallback_parse(player_action, ctx.characters)
return tool, params, ""
decision = parse_tool_call(raw)
@@ -28,7 +29,7 @@ def _llm_decision(ctx: GameContext, player_action: str) -> tuple[str, dict, str]
if not tool:
log.info("LLM non ha prodotto tool valido, fallback heuristico")
tool, params = _parse_action(player_action, ctx)
tool, params = fallback_parse(player_action, ctx.characters)
return tool, params, narration
@@ -36,7 +37,7 @@ def _llm_decision(ctx: GameContext, player_action: str) -> tuple[str, dict, str]
def dm_cycle(campaign_id: int, player_action: str) -> dict:
ctx = GameContext.load(campaign_id)
tool_name, tool_params, narration = _llm_decision(ctx, player_action)
tool_name, tool_params, narration = _llm_decision(ctx, player_action, current_area_id=ctx.current_area_id)
result = tools.run(tool_name, **tool_params)
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from dataclasses import dataclass, field
from src.engine import db
@dataclass
class GameContext:
campaign_id: int
turn: int
campaign: dict | None = None
characters: list[dict] = field(default_factory=list)
log: list[dict] = field(default_factory=list)
quests: list[dict] = field(default_factory=list)
areas: list[dict] = field(default_factory=list)
current_area_id: str = ""
current_area_name: str = ""
@classmethod
def load(cls, campaign_id: int) -> GameContext:
camp = db.get_campaign(campaign_id)
if not camp:
raise ValueError(f"Campagna #{campaign_id} non trovata.")
chars = db.get_characters(campaign_id)
log = db.get_log(campaign_id, limit=10)
quests = db.get_quests(campaign_id)
areas = db.get_area_state(campaign_id)
area_info = db.get_current_area(campaign_id)
return cls(
campaign_id=campaign_id,
turn=camp.get("turn", 0),
campaign=camp,
characters=chars,
log=log,
quests=quests,
areas=areas,
current_area_id=area_info.get("area_id", ""),
current_area_name=area_info.get("area_name", ""),
)
def summary(self) -> str:
camp = self.campaign or {"name": "?", "turn": 0}
lines = [f"Campagna: {camp['name']} | Turno {self.turn}"]
if self.current_area_id:
lines.append(f"Area corrente: {self.current_area_name or self.current_area_id}")
for c in self.characters:
cls = c.get("char_class") or ""
lines.append(
f" [{c['id']}] {c['name']} — Lv{c['level']} {cls} "
f"PF {c['hp_current']}/{c['hp_max']} PE {c.get('xp', 0)}"
)
if self.quests:
lines.append("Missioni:")
for q in self.quests:
lines.append(f" [{q['status']}] {q['name']}")
return "\n".join(lines)
+77
View File
@@ -0,0 +1,77 @@
"""
Heuristic fallback parser per azioni giocatore.
Usato SOLO quando il parsing LLM fallisce o restituisce tool vuoto.
Non è un secondo pilota — è un salvagente.
"""
from __future__ import annotations
import re
from typing import Any
def parse_action(raw_action: str, chars: list[dict]) -> tuple[str, dict]:
"""Parsing euristico azione testuale → (tool_name, params).
Chiamato SOLO come fallback quando parse_tool_call() fallisce.
"""
a = raw_action.lower().strip()
cd = re.search(r'(?:cd|dc)\s*(\d+)', a)
char_id = chars[0]["id"] if chars else 0
if any(k in a for k in ["tempra", "riflessi", "volontà", "salvezza"]):
for key in ["tempra", "riflessi", "volontà"]:
if key in a:
return "saving_throw", {
"character_id": char_id,
"save_type": key.capitalize(),
"dc": int(cd.group(1)) if cd else 15,
}
return "saving_throw", {
"character_id": char_id,
"save_type": "Tempra",
"dc": int(cd.group(1)) if cd else 15,
}
if any(k in a for k in ["abilità", "prova", "percepire", "scassinare", "nascondersi"]):
return "ability_check", {
"character_id": char_id,
"dc": int(cd.group(1)) if cd else 12,
}
if any(k in a for k in ["attacco", "attaccare", "colpire", "combattimento", "combat", "mischia"]):
mod = re.search(r'([+-]\s*\d+)', a)
ac = re.search(r'(?:ca|ac)\s*(\d+)', a)
return "combat", {
"character_id": char_id,
"target_ac": int(ac.group(1)) if ac else 12,
"attack_bonus": int(mod.group(1).replace(" ", "")) if mod else 0,
}
if any(k in a for k in ["muovi", "muovo", "movimento", "esplora", "cammina", "cammino", "corri", "corro"]):
dist = re.search(r'(\d+)\s*m', a)
return "movement", {"distance_m": int(dist.group(1)) if dist else 10}
if any(k in a for k in ["incantesimo", "magia", "lancio", "spell"]):
return "spellcasting", {
"character_id": char_id,
"spell_name": a,
"spell_check": 10,
}
if any(k in a for k in ["inventario", "prendi", "prendo", "lascia", "lascio", "usa", "uso", "raccogli", "raccolgo"]):
return "update_inventory", {
"character_id": char_id,
"item": raw_action.split()[-1],
"action": "add",
}
if any(k in a for k in ["xp", "esperienza", "livello"]):
return "award_xp", {"character_id": char_id, "xp": 5}
die_match = re.search(r'\bd(?:20|%|3|4|5|6|7|8|10|12|14|16|24|30)\b', a)
if die_match:
return "roll_dice", {"die": die_match.group()}
return "dm_override", {"command": "narrate", "params": {"text": raw_action}}
+12 -44
View File
@@ -1,62 +1,29 @@
from __future__ import annotations
import contextvars
import json
import re
from src.engine.config import load_from_env
from src.engine.providers import LLMProvider, create_provider
DEFAULT_MODEL = "gemma2:9b"
_KNOWN_TOOLS = {
"roll_dice": {"die": "str", "count": "int (default 1)", "modifier": "int (default 0)"},
"dice_chain": {"die": "str", "steps": "int"},
"combat": {"character_id": "int", "target_ac": "int", "attack_bonus": "int (default 0)",
"damage_die": "str (default d8)", "damage_bonus": "int (default 0)",
"mighty_deed_die": "str (default '')", "luck_mod": "int (default 0)",
"armor_type": "str (none/light/medium/heavy)"},
"ability_check": {"character_id": "int", "dc": "int", "modifier": "int (default 0)"},
"saving_throw": {"character_id": "int", "save_type": "str (Tempra/Riflessi/Volontà)", "dc": "int", "modifier": "int (default 0)"},
"movement": {"distance_m": "int", "terrain": "str (normale/difficile/molto difficile)", "stealth": "bool (default false)"},
"spellcasting": {"character_id": "int", "spell_name": "str", "spell_check": "int", "dc": "int (default 12)"},
"update_inventory": {"character_id": "int", "item": "str", "action": "str (add/remove)"},
"award_xp": {"character_id": "int", "xp": "int"},
"dm_override": {"command": "str", "params": "dict (opzionale)"},
}
SYSTEM_PROMPT = f"""Sei il Dungeon Master di una partita di DCC RPG (Dungeon Crawl Classics).
Il giocatore scrive un'azione e TU decidi quale strumento chiamare.
Strumenti disponibili:
{json.dumps(_KNOWN_TOOLS, indent=2)}
Regole:
1. Scegli SEMPRE UNO strumento per rispondere all'azione del giocatore.
2. I parametri DEVONO essere realistici per il contesto.
3. Se il giocatore fa qualcosa di narrativo, usa dm_override con command="narrate".
4. Tira sempre i dadi quando il giocatore tenta un'azione rischiosa.
5. Rispondi SOLO con JSON in questo formato:
{{"tool": "nome_tool", "params": {{...}}, "narration": "breve descrizione di cosa succede"}}
"""
_provider: LLMProvider | None = None
_provider_var: contextvars.ContextVar[LLMProvider | None] = contextvars.ContextVar("provider", default=None)
def get_provider() -> LLMProvider:
global _provider
if _provider is None:
_provider = create_provider(load_from_env())
return _provider
provider = _provider_var.get()
if provider is None:
from src.engine.config import load_from_env
provider = create_provider(load_from_env())
_provider_var.set(provider)
return provider
def set_provider(provider: LLMProvider) -> None:
global _provider
_provider = provider
_provider_var.set(provider)
def ask(prompt: str, system: str = SYSTEM_PROMPT) -> str:
def ask(prompt: str, system: str = "") -> str:
return get_provider().ask(prompt, system=system)
@@ -100,10 +67,11 @@ if __name__ == "__main__":
"Attacco l'orco con la mia ascia!",
"Esploro il corridoio buio",
]
from src.engine.prompts import SYSTEM_PROMPT as _DM_PROMPT
for a in test_actions:
print(f"\n>>> Giocatore: {a}")
try:
resp = ask(a)
resp = ask(a, system=_DM_PROMPT)
print(f"LLM: {resp[:300]}")
parsed = parse_tool_call(resp)
print(f"Parser: {json.dumps(parsed, indent=2)}")
+1 -1
View File
@@ -4,7 +4,7 @@ import json
from typing import Any
from src.engine import db as db_module
from src.engine.state_machine import GameContext
from src.engine.game_context import GameContext
_KNOWN_TOOLS: dict[str, dict[str, str]] = {
"roll_dice": {"die": "tipo dado (d3..d30, d%)", "count": "numero dadi (default 1)", "modifier": "modificatore (default 0)"},
+2 -2
View File
@@ -131,9 +131,9 @@ def create_provider(cfg: ProviderConfig | None = None) -> LLMProvider:
if cfg.kind == "ollama":
return cls(cfg.ollama_base_url, cfg.ollama_model, cfg.timeout)
elif cfg.kind == "openrouter":
key = cfg.openrouter_api_key or os.getenv("OPENROUTER_API_KEY", "")
key = cfg.openrouter_api_key or os.getenv("DCC_OPENROUTER_KEY", os.getenv("OPENROUTER_API_KEY", ""))
if not key:
raise ValueError("OPENROUTER_API_KEY non configurata")
raise ValueError("DCC_OPENROUTER_KEY non configurata")
return cls(key, cfg.openrouter_model, cfg.timeout, cfg.openrouter_free_fallback)
elif cfg.kind == "local":
return cls(cfg.local_base_url, cfg.local_model, cfg.local_api_key, cfg.timeout)
+8 -155
View File
@@ -1,160 +1,13 @@
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from typing import Any
import warnings
from src.engine import db, tools
from src.engine.game_context import GameContext
# ---------------------------------------------------------------------------
# Stato del gioco (immutabile dentro un turno)
# ---------------------------------------------------------------------------
warnings.warn(
"state_machine module is deprecated. Import GameContext from src.engine.game_context instead.",
DeprecationWarning,
stacklevel=2,
)
@dataclass
class GameContext:
campaign_id: int
turn: int
campaign: dict | None = None
characters: list[dict] = field(default_factory=list)
log: list[dict] = field(default_factory=list)
quests: list[dict] = field(default_factory=list)
areas: list[dict] = field(default_factory=list)
@classmethod
def load(cls, campaign_id: int) -> GameContext:
camp = db.get_campaign(campaign_id)
if not camp:
raise ValueError(f"Campagna #{campaign_id} non trovata.")
chars = db.get_characters(campaign_id)
log = db.get_log(campaign_id, limit=10)
quests = db.get_quests(campaign_id)
areas = db.get_area_state(campaign_id)
return cls(
campaign_id=campaign_id,
turn=camp["turn"],
campaign=camp,
characters=chars,
log=log,
quests=quests,
areas=areas,
)
def summary(self) -> str:
camp = self.campaign or {"name": "?", "turn": 0}
lines = [f"Campagna: {camp['name']} | Turno {self.turn}"]
for c in self.characters:
cls = c.get("char_class") or ""
lines.append(
f" [{c['id']}] {c['name']} — Lv{c['level']} {cls} "
f"PF {c['hp_current']}/{c['hp_max']} PE {c.get('xp', 0)}"
)
if self.quests:
lines.append("Missioni:")
for q in self.quests:
lines.append(f" [{q['status']}] {q['name']}")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Decisore — sceglie quale tool chiamare in base all'azione testuale
# ---------------------------------------------------------------------------
# Mappa euristica: parole chiave → tool
def _parse_action(raw_action: str, ctx: GameContext) -> tuple[str, dict]:
a = raw_action.lower().strip()
cd = re.search(r'(?:cd|dc)\s*(\d+)', a)
char_id = ctx.characters[0]["id"] if ctx.characters else 0
if any(k in a for k in ["tempra", "riflessi", "volontà", "salvezza"]):
for key in ["tempra", "riflessi", "volontà"]:
if key in a:
return "saving_throw", {"character_id": char_id, "save_type": key.capitalize(), "dc": int(cd.group(1)) if cd else 15}
return "saving_throw", {"character_id": char_id, "save_type": "Tempra", "dc": int(cd.group(1)) if cd else 15}
if any(k in a for k in ["abilità", "prova", "percepire", "scassinare", "nascondersi"]):
return "ability_check", {"character_id": char_id, "dc": int(cd.group(1)) if cd else 12}
if any(k in a for k in ["attacco", "attaccare", "colpire", "combattimento", "combat", "mischia"]):
mod = re.search(r'([+-]\s*\d+)', a)
ac = re.search(r'(?:ca|ac)\s*(\d+)', a)
return "combat", {
"character_id": char_id,
"target_ac": int(ac.group(1)) if ac else 12,
"attack_bonus": int(mod.group(1).replace(" ", "")) if mod else 0,
}
if any(k in a for k in ["muovi", "muovo", "movimento", "esplora", "cammina", "cammino", "corri", "corro"]):
dist = re.search(r'(\d+)\s*m', a)
return "movement", {"distance_m": int(dist.group(1)) if dist else 10}
if any(k in a for k in ["incantesimo", "magia", "lancio", "spell"]):
return "spellcasting", {"character_id": char_id, "spell_name": a, "spell_check": 10}
if any(k in a for k in ["inventario", "prendi", "prendo", "lascia", "lascio", "usa", "uso", "raccogli", "raccolgo"]):
return "update_inventory", {"character_id": char_id, "item": raw_action.split()[-1], "action": "add"}
if any(k in a for k in ["xp", "esperienza", "livello"]):
return "award_xp", {"character_id": char_id, "xp": 5}
die_match = re.search(r'\bd(?:20|%|3|4|5|6|7|8|10|12|14|16|24|30)\b', a)
if die_match:
return "roll_dice", {"die": die_match.group()}
return "dm_override", {"command": "narrate", "params": {"text": raw_action}}
def process_turn(campaign_id: int, raw_action: str) -> dict:
ctx = GameContext.load(campaign_id)
tool_name, tool_params = _parse_action(raw_action, ctx)
result = tools.run(tool_name, **tool_params)
turn = db.advance_turn(campaign_id)
db.log_turn(campaign_id, turn, raw_action, result.get("message", ""))
return {
"turn": turn,
"action": raw_action,
"tool": tool_name,
"result": result,
"context": ctx.summary(),
}
# ---------------------------------------------------------------------------
# CLI test
# ---------------------------------------------------------------------------
if __name__ == "__main__":
conn = db.init_db()
camp_id = db.create_campaign("Avventura Test", "Tomba del Mago-Guerriero")
char_id = db.create_character(camp_id, "Marco il Fidato", "guerriero", "soldato")
db.update_character(char_id,
level=1, str=15, agi=11, sta=14,
per=9, int=8, lck=10,
hp_current=12, hp_max=12, ac=15,
armor_type="light"
)
actions = [
"Tiro un d20 per iniziativa",
"Attacco il guardiano con la spada",
"Prova di percezione CD 12",
"Tiro salvezza su Tempra CD 15",
"Mi muovo verso la porta",
"Lancio l'incantesimo Dardo Incantato",
"Prendo la pozione dalla mensola",
"dm forzo la scena",
]
for action in actions:
print(f"\n>>> {action}")
out = process_turn(camp_id, action)
print(f" Tool: {out['tool']} | Msg: {out['result'].get('message', '?')[:100]}")
print(f"\n=== Cronologia ({len(actions)} turni) ===")
ctx = GameContext.load(camp_id)
print(ctx.summary())
__all__ = ["GameContext"]
+1 -1
View File
@@ -12,7 +12,7 @@ from fastapi.staticfiles import StaticFiles
from src.engine import db
from src.engine.config import load_from_env
from src.engine.dm import dm_cycle
from src.engine.state_machine import GameContext
from src.engine.game_context import GameContext
log = logging.getLogger(__name__)
View File
Whitespace-only changes.
+108
View File
@@ -0,0 +1,108 @@
import sys
sys.path.insert(0, "src")
from src.engine.dice import roll, dice_chain, roll_str, validate_die, ALL_DICE
def test_roll_basic():
for _ in range(100):
r = roll("d20")
assert 1 <= r["total"] <= 20
assert r["die"] == "d20"
assert r["count"] == 1
assert r["modifier"] == 0
assert len(r["results"]) == 1
def test_roll_multiple():
for _ in range(50):
r = roll("d6", count=3)
assert len(r["results"]) == 3
assert 3 <= r["total"] <= 18
def test_roll_modifier():
r = roll("d20", modifier=5)
assert r["total"] == r["results"][0] + 5
assert r["modifier"] == 5
def test_roll_crit():
for _ in range(1000):
r = roll("d20")
if r["results"][0] == 20:
assert r["crit"] is True
if r["results"][0] == 1:
assert r["fumble"] is True
def test_roll_d100():
for _ in range(100):
r = roll("d%")
assert 1 <= r["total"] <= 100
def test_roll_dice_chain_up():
res = dice_chain("d3", 2)
assert res["output_die"] == "d5"
assert res["steps"] == 2
assert res["input_die"] == "d3"
def test_dice_chain_overflow():
res = dice_chain("d3", 20)
assert res["output_die"] == "d30"
def test_dice_chain_underflow():
res = dice_chain("d30", -30)
assert res["output_die"] == "d3"
def test_dice_chain_invalid():
res = dice_chain("d9", 1)
assert "error" in res
def test_roll_str_basic():
r = roll_str("2d6+3")
assert r["count"] == 2
assert r["die"] == "d6"
assert r["modifier"] == 3
def test_roll_str_percent():
r = roll_str("d%")
assert r["die"] == "d%"
assert r["count"] == 1
def test_roll_str_negative_mod():
r = roll_str("d20-2")
assert r["modifier"] == -2
def test_validate_die():
assert validate_die("d20") == "d20"
assert validate_die("d%") == "d%"
assert validate_die("d7") == "d7"
assert validate_die("d9") is None
assert validate_die("xyz") is None
def test_all_dice_keys():
assert "d3" in ALL_DICE
assert "d20" in ALL_DICE
assert "d30" in ALL_DICE
assert "d%" in ALL_DICE
assert ALL_DICE["d%"] == 100
def test_roll_zero_faced_die():
r = roll("d0")
assert r["total"] == 0
def test_roll_one_faced_die():
r = roll("d1")
assert r["total"] == 1
+140
View File
@@ -0,0 +1,140 @@
import sys
import json
sys.path.insert(0, "src")
from src.engine.tools import run
from src.engine.db import init_db, create_campaign, create_character, update_character
def _setup_char():
conn = init_db()
camp_id = create_campaign("Test", "Avventura")
char_id = create_character(camp_id, "TestChar", "guerriero", "soldato")
update_character(
char_id,
level=1, str=15, agi=11, sta=14,
per=9, int=8, lck=10,
hp_current=12, hp_max=12, ac=15,
armor_type="light",
)
return camp_id, char_id
def test_roll_dice_tool():
r = run("roll_dice", die="d20")
assert r["success"] is True
assert r["tool"] == "roll-dice"
assert 1 <= r["total"] <= 20
def test_dice_chain_tool():
r = run("dice_chain", die="d3", steps=2)
assert r["success"] is True
assert r["output_die"] == "d5"
def test_unknown_tool():
r = run("nonexistent")
assert r["success"] is False
assert "sconosciuto" in r["message"]
def test_ability_check():
_, char_id = _setup_char()
r = run("ability_check", character_id=char_id, dc=12, modifier=2)
assert r["success"] is True
assert "d20" in r["message"]
def test_saving_throw():
_, char_id = _setup_char()
r = run("saving_throw", character_id=char_id, save_type="Tempra", dc=15)
assert r["success"] is True
assert r["save_type"] == "Tempra"
def test_saving_throw_invalid_type():
_, char_id = _setup_char()
r = run("saving_throw", character_id=char_id, save_type="Forza", dc=15)
assert r["success"] is False
def test_movement():
r = run("movement", distance_m=30, terrain="normale")
assert r["success"] is True
assert r["rounds"] == 3
assert r["speed_m_per_round"] == 12
def test_movement_difficult():
r = run("movement", distance_m=30, terrain="difficile")
assert r["success"] is True
assert r["rounds"] == 5
def test_spellcasting():
_, char_id = _setup_char()
r = run("spellcasting", character_id=char_id, spell_name="Dardo Incantato", spell_check=18, dc=12)
assert r["success"] is True
assert r["result_tier"] in ("minore", "moderato", "maggiore")
def test_spellcasting_failure():
_, char_id = _setup_char()
r = run("spellcasting", character_id=char_id, spell_name="Dardo Incantato", spell_check=5, dc=12)
assert r["success"] is True
assert "fallisce" in r["message"]
def test_update_inventory():
_, char_id = _setup_char()
r = run("update_inventory", character_id=char_id, item="Pozione di Cura")
assert r["success"] is True
assert "Pozione di Cura" in r["message"]
def test_award_xp():
_, char_id = _setup_char()
r = run("award_xp", character_id=char_id, xp=5)
assert r["success"] is True
assert r["xp_gained"] == 5
def test_award_xp_levelup():
camp_id, _ = _setup_char()
# Create a lv0 character (contadino) to test level-up
import src.engine.db as db
lv0_id = db.create_character(camp_id, "Lv0 Hero", "contadino", "contadino")
db.update_character(lv0_id, level=0, xp=0)
r1 = run("award_xp", character_id=lv0_id, xp=10)
assert r1["xp_total"] == 10
assert r1["leveled_up"] is True
def test_dm_override():
r = run("dm_override", command="narrate", params={"text": "Una luce accecante."})
assert r["success"] is True
assert r["tool"] == "dm-override"
def test_dm_override_help():
r = run("dm_override", command="help")
assert r["success"] is True
assert "commands" in r
def test_combat():
_, char_id = _setup_char()
r = run(
"combat",
character_id=char_id,
target_ac=15,
attack_bonus=2,
damage_die="d8",
mighty_deed_die="d3",
luck_mod=0,
armor_type="medium",
)
assert r["success"] is True
assert r["tool"] == "combat"
assert isinstance(r["hit"], bool)