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
@@ -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
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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)}")
|
||||
|
||||
@@ -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)"},
|
||||
|
||||
@@ -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
@@ -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
@@ -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__)
|
||||
|
||||
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user