feat: AI Dungeon Master engine + web frontend

- Engine: stato SQLite, state machine, 10 approved tools, parser azioni
- LLM: multi-provider (Ollama/OpenRouter/Local), system prompt DCC
- DM cycle: LLM decide tool → engine esegue → narrazione + meccanica
- Dati avventura: JSON machine-readable con 9 aree, mostri, enigmi, quest
- Web: FastAPI + WebSocket backend, SPA frontend dark fantasy
- Ref: architecture-patterns/neon-scratch-lounge (separazione reasoning/execution)
This commit is contained in:
truetype committed 2026-06-26 20:31:34 +02:00
1 parent ae73fe43d3
commit 90f823fdb9
17 files changed
+4082

No files matched your search

View File
Whitespace-only changes.
+17
View File
@@ -0,0 +1,17 @@
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.tools import run as run_tool
__all__ = [
"dm_cycle",
"interactive",
"GameContext",
"process_turn",
"run_tool",
"ProviderConfig",
"ProviderKind",
"load_from_env",
"set_provider",
]
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Literal
ProviderKind = Literal["ollama", "openrouter", "local"]
@dataclass
class ProviderConfig:
kind: ProviderKind = "openrouter"
ollama_base_url: str = "http://192.168.0.50:11434"
ollama_model: str = "gemma2:9b"
openrouter_api_key: str = ""
openrouter_model: str = "openrouter/free"
openrouter_free_fallback: bool = True
local_base_url: str = "http://192.168.0.240/v1"
local_api_key: str = ""
local_model: str = "big-pickle"
timeout: int = 60
def load_from_env() -> ProviderConfig:
return 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_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"),
local_api_key=os.getenv("DCC_LOCAL_KEY", ""),
local_model=os.getenv("DCC_LOCAL_MODEL", "big-pickle"),
timeout=int(os.getenv("DCC_LLM_TIMEOUT", "60")),
)
+557
View File
@@ -0,0 +1,557 @@
"""
Database module per DCC RPG — Neon Scratch Lounge.
Gestisce schema SQLite e popolazione iniziale dati.
"""
import sqlite3
import json
import os
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"
def get_conn() -> sqlite3.Connection:
"""Restituisce connessione al database, creandolo se non esiste."""
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.row_factory = sqlite3.Row
return conn
def init_schema(conn: sqlite3.Connection) -> None:
conn.executescript("""
-- ============================================================
-- Tabelle di sistema (critici / fumble)
-- ============================================================
CREATE TABLE IF NOT EXISTS crit_fumble_tables (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
table_type TEXT NOT NULL CHECK(table_type IN ('crit', 'fumble')),
die TEXT,
modifier TEXT
);
CREATE TABLE IF NOT EXISTS crit_fumble_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_id TEXT NOT NULL REFERENCES crit_fumble_tables(id),
roll_min INTEGER,
roll_max INTEGER,
result_text TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_entries_table
ON crit_fumble_entries(table_id);
CREATE TABLE IF NOT EXISTS fumble_armor_die (
armor_type TEXT PRIMARY KEY,
die TEXT NOT NULL
);
-- ============================================================
-- Tabelle di stato gioco
-- ============================================================
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'))
);
CREATE TABLE IF NOT EXISTS characters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
campaign_id INTEGER NOT NULL REFERENCES campaigns(id),
name TEXT NOT NULL,
level INTEGER NOT NULL DEFAULT 0,
char_class TEXT,
alignment TEXT CHECK(alignment IN ('Legge','Caos','Neutralità')),
occupation TEXT,
str INTEGER NOT NULL DEFAULT 10,
agi INTEGER NOT NULL DEFAULT 10,
sta INTEGER NOT NULL DEFAULT 10,
per INTEGER NOT NULL DEFAULT 10,
int INTEGER NOT NULL DEFAULT 10,
lck INTEGER NOT NULL DEFAULT 10,
hp_current INTEGER NOT NULL DEFAULT 1,
hp_max INTEGER NOT NULL DEFAULT 1,
ac INTEGER NOT NULL DEFAULT 10,
armor_type TEXT DEFAULT 'none',
luck_current INTEGER NOT NULL DEFAULT 10,
xp INTEGER NOT NULL DEFAULT 0,
weapon TEXT,
equipment TEXT DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'alive'
CHECK(status IN ('alive','unconscious','dead'))
);
CREATE INDEX IF NOT EXISTS idx_char_campaign
ON characters(campaign_id);
CREATE TABLE IF NOT EXISTS adventure_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
campaign_id INTEGER NOT NULL REFERENCES campaigns(id),
area_id TEXT NOT NULL,
visited INTEGER NOT NULL DEFAULT 0,
state TEXT DEFAULT '{}'
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_area_campaign
ON adventure_state(campaign_id, area_id);
CREATE TABLE IF NOT EXISTS quests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
campaign_id INTEGER NOT NULL REFERENCES campaigns(id),
name TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('active','completed','failed'))
);
CREATE TABLE IF NOT EXISTS turn_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
campaign_id INTEGER NOT NULL REFERENCES campaigns(id),
turn INTEGER NOT NULL,
character_id INTEGER REFERENCES characters(id),
action TEXT,
result TEXT,
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_turnlog_campaign
ON turn_log(campaign_id, turn);
""")
def populate_from_json(conn: sqlite3.Connection) -> None:
"""Carica i dati da crit_fumble_tables.json se le tabelle sono vuote."""
cursor = conn.execute("SELECT COUNT(*) FROM crit_fumble_tables")
if cursor.fetchone()[0] > 0:
return # già popolato
json_path = DATA_DIR / "crit_fumble_tables.json"
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
# Popola tabelle
table_map = {}
for table_id, table in data.items():
if table_id == "meta":
continue
conn.execute(
"INSERT INTO crit_fumble_tables (id, name, description, table_type, die, modifier) "
"VALUES (?, ?, ?, ?, ?, ?)",
(
table_id,
table["name"],
table.get("description", ""),
table_id if table_id == "fumble" else "crit",
table.get("die"),
table.get("modifier"),
),
)
table_map[table_id] = table_id
# Popola entries
for entry in table["entries"]:
conn.execute(
"INSERT INTO crit_fumble_entries (table_id, roll_min, roll_max, result_text) "
"VALUES (?, ?, ?, ?)",
(table_id, entry["min"], entry["max"], entry["result"]),
)
# Popola mappatura armatura→dado per fumble
if table_id == "fumble" and "die_by_armor" in table:
for armor, die in table["die_by_armor"].items():
conn.execute(
"INSERT OR REPLACE INTO fumble_armor_die (armor_type, die) VALUES (?, ?)",
(armor, die),
)
conn.commit()
def init_db() -> sqlite3.Connection:
"""Inizializza database completo. Chiamata all'avvio."""
conn = get_conn()
init_schema(conn)
populate_from_json(conn)
return conn
# ---------------------------------------------------------------------------
# Query helpers
# ---------------------------------------------------------------------------
def lookup_crit_fumble(table_id: str, roll: int, luck_mod: int = 0) -> dict | None:
"""Cerca il risultato appropriato per un tiro su una tabella crit/fumble.
Applica automaticamente il modificatore:
- crit: +luck_mod
- fumble: -luck_mod (opposto della Fortuna)
Args:
table_id: "fumble", "crit_I", "crit_II", "crit_III", "crit_IV", "crit_V"
roll: Valore tirato (prima del modificatore)
luck_mod: Modificatore di Fortuna del PG
Returns:
Dict con {table_id, table_name, roll_applied, result} oppure None
"""
conn = get_conn()
cursor = conn.execute(
"SELECT id, name, table_type, die, modifier FROM crit_fumble_tables WHERE id = ?",
(table_id,),
)
table = cursor.fetchone()
if not table:
return None
# Applica modificatore
if table["table_type"] == "fumble":
modified_roll = roll - luck_mod
else:
modified_roll = roll + luck_mod
# Cerca entry corrispondente
cursor = conn.execute(
"""SELECT result_text FROM crit_fumble_entries
WHERE table_id = ?
AND (roll_min IS NULL OR roll_min <= ?)
AND (roll_max IS NULL OR roll_max >= ?)
ORDER BY roll_min IS NOT NULL DESC, roll_min DESC
LIMIT 1""",
(table_id, modified_roll, modified_roll),
)
entry = cursor.fetchone()
if not entry:
return None
return {
"table_id": table["id"],
"table_name": table["name"],
"table_type": table["table_type"],
"die": table["die"],
"roll_raw": roll,
"luck_mod": luck_mod,
"roll_applied": modified_roll,
"result": entry["result_text"],
}
def get_fumble_die(armor_type: str) -> str | None:
"""Restituisce il dado fumble in base al tipo di armatura."""
conn = get_conn()
cursor = conn.execute(
"SELECT die FROM fumble_armor_die WHERE armor_type = ?", (armor_type,)
)
row = cursor.fetchone()
return row["die"] if row else None
def get_all_tables() -> list[dict]:
"""Restituisce metadati di tutte le tabelle crit/fumble."""
conn = get_conn()
cursor = conn.execute(
"SELECT id, name, description, table_type, die, modifier FROM crit_fumble_tables ORDER BY id"
)
return [dict(row) for row in cursor.fetchall()]
# ---------------------------------------------------------------------------
# CRUD — Game State
# ---------------------------------------------------------------------------
def create_campaign(name: str, adventure: str = "") -> int:
conn = get_conn()
cur = conn.execute(
"INSERT INTO campaigns (name, adventure) VALUES (?, ?)", (name, adventure)
)
conn.commit()
return cur.lastrowid or 0
def get_campaign(campaign_id: int) -> dict | None:
conn = get_conn()
row = conn.execute(
"SELECT * FROM campaigns WHERE id = ?", (campaign_id,)
).fetchone()
return dict(row) if row else None
def create_character(
campaign_id: int, name: str, char_class: str = "", occupation: str = ""
) -> int:
conn = get_conn()
cur = conn.execute(
"""INSERT INTO characters (campaign_id, name, char_class, occupation)
VALUES (?, ?, ?, ?)""",
(campaign_id, name, char_class, occupation),
)
conn.commit()
return cur.lastrowid or 0
def get_characters(campaign_id: int) -> list[dict]:
conn = get_conn()
rows = conn.execute(
"SELECT * FROM characters WHERE campaign_id = ? ORDER BY id",
(campaign_id,),
).fetchall()
return [dict(r) for r in rows]
def get_character(char_id: int) -> dict | None:
conn = get_conn()
row = conn.execute(
"SELECT * FROM characters WHERE id = ?", (char_id,)
).fetchone()
return dict(row) if row else None
def update_character(char_id: int, **kwargs) -> bool:
cols = ", ".join(f"{k} = ?" for k in kwargs)
vals = list(kwargs.values()) + [char_id]
conn = get_conn()
conn.execute(f"UPDATE characters SET {cols} WHERE id = ?", vals)
conn.commit()
return conn.total_changes > 0
def advance_turn(campaign_id: int) -> int:
conn = get_conn()
conn.execute(
"UPDATE campaigns SET turn = turn + 1 WHERE id = ?", (campaign_id,)
)
conn.commit()
row = conn.execute(
"SELECT turn FROM campaigns WHERE id = ?", (campaign_id,)
).fetchone()
return row["turn"] if row else 0
def log_turn(campaign_id: int, turn: int, action: str, result: str, character_id: int = 0) -> int:
conn = get_conn()
cur = conn.execute(
"INSERT INTO turn_log (campaign_id, turn, character_id, action, result) VALUES (?, ?, ?, ?, ?)",
(campaign_id, turn, character_id or None, action, result),
)
conn.commit()
return cur.lastrowid or 0
def get_log(campaign_id: int, limit: int = 20) -> list[dict]:
conn = get_conn()
rows = conn.execute(
"""SELECT tl.*, c.name AS char_name
FROM turn_log tl
LEFT JOIN characters c ON tl.character_id = c.id
WHERE tl.campaign_id = ?
ORDER BY tl.id DESC LIMIT ?""",
(campaign_id, limit),
).fetchall()
return [dict(r) for r in rows]
def set_area_state(campaign_id: int, area_id: str, visited: bool = True, state: dict = {}) -> None:
conn = get_conn()
conn.execute(
"""INSERT INTO adventure_state (campaign_id, area_id, visited, state)
VALUES (?, ?, ?, ?)
ON CONFLICT(campaign_id, area_id) DO UPDATE SET
visited = excluded.visited,
state = excluded.state""",
(campaign_id, area_id, int(visited), json.dumps(state)),
)
conn.commit()
def get_area_state(campaign_id: int) -> list[dict]:
conn = get_conn()
rows = conn.execute(
"SELECT * FROM adventure_state WHERE campaign_id = ? ORDER BY area_id",
(campaign_id,),
).fetchall()
return [dict(r) for r in rows]
def add_quest(campaign_id: int, name: str, description: str) -> int:
conn = get_conn()
cur = conn.execute(
"INSERT INTO quests (campaign_id, name, description) VALUES (?, ?, ?)",
(campaign_id, name, description),
)
conn.commit()
return cur.lastrowid or 0
def update_quest(quest_id: int, status: str) -> bool:
conn = get_conn()
conn.execute("UPDATE quests SET status = ? WHERE id = ?", (status, quest_id))
conn.commit()
return conn.total_changes > 0
def get_quests(campaign_id: int) -> list[dict]:
conn = get_conn()
rows = conn.execute(
"SELECT * FROM quests WHERE campaign_id = ? ORDER BY id", (campaign_id,)
).fetchall()
return [dict(r) for r in rows]
# ---------------------------------------------------------------------------
# Adventure data loading (from JSON)
# ---------------------------------------------------------------------------
ADVENTURE_DATA_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "adventure" / "avventura.json"
def _load_adventure_json() -> dict:
"""Carica il file JSON dei dati avventura."""
with open(ADVENTURE_DATA_PATH, "r", encoding="utf-8") as f:
return json.load(f)
def get_adventure_meta() -> dict:
"""Restituisce i metadati dell'avventura."""
data = _load_adventure_json()
return data.get("meta", {})
def get_all_areas() -> list[dict]:
"""Restituisce tutte le aree dell'avventura."""
data = _load_adventure_json()
return data.get("areas", [])
def get_area_by_id(area_id: str) -> dict | None:
"""Cerca un'area per ID (es. '1.1', '1.5')."""
for a in get_all_areas():
if a["id"] == area_id:
return a
return None
def get_monster_by_id(monster_id: str) -> dict | None:
"""Restituisce il blocco statistiche di un mostro per ID."""
data = _load_adventure_json()
for m in data.get("monsters", []):
if m["id"] == monster_id:
return m
return None
def get_monsters_in_area(area_id: str) -> list[dict]:
"""Restituisce i mostri presenti in un'area (con stat block completi)."""
area = get_area_by_id(area_id)
if not area:
return []
result = []
for ref in area.get("monsters", []):
monster = get_monster_by_id(ref["ref_id"])
if monster:
entry = dict(monster)
entry["count"] = ref.get("count", 1)
entry["tactics"] = ref.get("tactics", "")
result.append(entry)
return result
def get_all_quest_defs() -> list[dict]:
"""Restituisce le quest definite nell'avventura."""
data = _load_adventure_json()
return data.get("quests", [])
def get_special_items() -> list[dict]:
"""Restituisce gli oggetti speciali dell'avventura."""
data = _load_adventure_json()
return data.get("special_items", [])
def seed_adventure_state(campaign_id: int) -> None:
"""Inizializza adventure_state per tutte le aree note dalla definizione JSON."""
areas = get_all_areas()
if not areas:
return
for area in areas:
set_area_state(campaign_id, area["id"], visited=False, state={})
def seed_quests_from_adventure(campaign_id: int) -> None:
"""Inizializza le quest dalla definizione JSON."""
quests = get_all_quest_defs()
for q in quests:
add_quest(campaign_id, q["name"], q.get("description", ""))
def format_area_context(area_id: str) -> str:
"""Formatta descrizione area per contesto LLM. Necessario per prompt strutturare."""
area = get_area_by_id(area_id)
if not area:
return ""
lines = [f"=== {area['name']} (Area {area['id']}) ==="]
lines.append(area.get("description", ""))
if area.get("atmosphere"):
lines.append(f"Atmosfera: {area['atmosphere']}")
if area.get("features"):
lines.append(f"Elementi: {', '.join(area['features'])}")
monsters = get_monsters_in_area(area_id)
if monsters:
lines.append("Creature presenti:")
for m in monsters:
count_str = f"x{m['count']} " if m.get("count", 1) > 1 else ""
stats = m.get("stats", {})
lines.append(f" - {count_str}{m.get('name', '?')} (CA {stats.get('ac', '?')}, PF {stats.get('hp', '?')}, Att {stats.get('attack', '?')}, Dan {stats.get('damage', '?')})")
return "\n".join(lines)
def format_all_areas_context() -> str:
"""Formatta TUTTE le aree per il contesto iniziale LLM."""
parts = ["LUOGHI DELL'AVVENTURA:", ""]
for a in get_all_areas():
block = f"Area {a['id']} — {a['name']}"
block += f"\n {a.get('description', '')[:150]}"
block += f"\n Collegate a: {', '.join(a.get('connections', []))}"
mons = get_monsters_in_area(a["id"])
if mons:
names = [f"{m['name']}{' x'+str(m['count']) if m.get('count',1)>1 else ''}" for m in mons]
block += f"\n Creature: {', '.join(names)}"
parts.append(block)
return "\n\n".join(parts)
# ---------------------------------------------------------------------------
# CLI test
# ---------------------------------------------------------------------------
if __name__ == "__main__":
conn = init_db()
print("=== Tabelle caricate ===")
for t in get_all_tables():
print(f" {t['id']}: {t['name']} ({t['table_type']}, dado={t['die']})")
print("\n=== Test lookup fumble ===")
for roll_val in [-1, 1, 5, 10, 16, 20]:
r = lookup_crit_fumble("fumble", roll_val, luck_mod=1)
print(f" fumble roll={roll_val} luck=+1 → (applied={r['roll_applied']}) {r['result'][:60]}...")
print("\n=== Test lookup crit ===")
for table in ["crit_I", "crit_II", "crit_III", "crit_IV", "crit_V"]:
r = lookup_crit_fumble(table, 15, luck_mod=0)
print(f" {table} roll=15 → (applied={r['roll_applied']}) {r['result'][:60]}...")
print("\n=== Test fumble die by armor ===")
for armor in ["none", "light", "medium", "heavy"]:
die = get_fumble_die(armor)
print(f" {armor}: {die}")
+272
View File
@@ -0,0 +1,272 @@
"""
Modulo Dadi per DCC RPG — Neon Scratch Lounge.
2 tool separati:
- roll(die, count, modifier) → risultato tiro
- dice_chain(die, steps) → dado risolto nella catena DCC
La catena dadi DCC: d3 → d4 → d5 → d6 → d7 → d8 → d10 → d12 → d14 → d16 → d20 → d24 → d30
"""
from __future__ import annotations
import random
import re
from typing import TypedDict
# ---------------------------------------------------------------------------
# DCC Dice Chain
# ---------------------------------------------------------------------------
DICE_CHAIN: list[str] = [
"d3", "d4", "d5", "d6", "d7", "d8",
"d10", "d12", "d14", "d16", "d20", "d24", "d30",
]
DICE_INDEX: dict[str, int] = {die: i for i, die in enumerate(DICE_CHAIN)}
# Dadi speciali / extra DCC non nella chain lineare
SPECIAL_DICE: dict[str, int] = {
"d%": 100,
"d100": 100,
"d0": 0,
"d1": 1,
"d2": 2,
}
# Unione per lookup veloce
ALL_DICE: dict[str, int] = {die: int(die[1:]) for die in DICE_CHAIN}
ALL_DICE.update(SPECIAL_DICE)
# Regex per validazione input LLM
_DIE_PATTERN = re.compile(r"^d(\d+)$|^d%$")
# ---------------------------------------------------------------------------
# Tipi di ritorno
# ---------------------------------------------------------------------------
class RollResult(TypedDict):
die: str
count: int
modifier: int
results: list[int]
total: int
crit: bool
fumble: bool
raw: str
class DiceChainResult(TypedDict):
input_die: str
steps: int
output_die: str
chain: list[str]
class ValidationError(TypedDict):
error: str
valid_dice: list[str]
# ---------------------------------------------------------------------------
# Validazione
# ---------------------------------------------------------------------------
def validate_die(die: str) -> str | None:
"""Ritorna il dado normalizzato se valido, None altrimenti."""
die = die.strip().lower()
if die in ALL_DICE:
return die
return None
def normalise_die(die: str) -> str:
"""Normalizza alias → forma canonica."""
d = die.strip().lower()
return d
# ---------------------------------------------------------------------------
# Tool 1 — roll
# ---------------------------------------------------------------------------
def roll(
die: str,
count: int = 1,
modifier: int = 0,
) -> RollResult:
"""Tira un dado DCC.
Parametri
---------
die : str
Nome dado (es. "d20", "d%", "d3", "d14").
count : int
Numero di dadi da tirare (default 1).
modifier : int
Modificatore da sommare al totale (default 0).
Ritorna
-------
RollResult con results[], total, crit/fumble flags, raw string.
"""
die_n = normalise_die(die)
err = validate_die(die_n)
if err is None:
raise ValueError(
f"Dad '{die}' non valido. Usa: {', '.join(ALL_DICE.keys())}"
)
faces = ALL_DICE[die_n]
max_val = faces
results: list[int] = []
for _ in range(count):
if faces <= 0:
results.append(0)
elif faces == 1:
results.append(1)
else:
results.append(random.randint(1, faces))
total = sum(results) + modifier
# Crit/fumble: solo per d20 standard (regola DCC)
crit = False
fumble = False
if die_n == "d20":
for r in results:
if r == 20:
crit = True
if r == 1:
fumble = True
# Raw string per logging
raw_parts = []
if count > 1:
raw_parts.append(f"{count}")
raw_parts.append(die_n)
if modifier:
raw_parts.append(f"{modifier:+d}")
raw = "".join(raw_parts) if len(raw_parts) == 1 else f"{count}{die_n}{modifier:+d}" if modifier else f"{count}{die_n}"
return RollResult(
die=die_n,
count=count,
modifier=modifier,
results=results,
total=total,
crit=crit,
fumble=fumble,
raw=raw,
)
# ---------------------------------------------------------------------------
# Tool 2 — dice_chain
# ---------------------------------------------------------------------------
def dice_chain(
die: str,
steps: int,
) -> DiceChainResult | ValidationError:
"""Risolve la catena dadi DCC.
Prende un dado di partenza e lo sposta di N step lungo la DICE_CHAIN.
Step positivi = su (dado più grande), negativi = giù (dado più piccolo).
Parametri
---------
die : str
Dado di partenza (es. "d3", "d10").
steps : int
Numero di step. Positivo = sale, negativo = scende.
Ritorna
-------
DiceChainResult con dado output + chain completa.
Se il dado non è nella chain, ritorna ValidationError.
"""
die_n = normalise_die(die)
if die_n not in DICE_INDEX:
return ValidationError(
error=f"'{die}' non è nella catena dadi DCC. I dadi della chain sono: {', '.join(DICE_CHAIN)}",
valid_dice=list(DICE_CHAIN),
)
idx = DICE_INDEX[die_n]
new_idx = idx + steps
# Clamping ai limiti della chain
if new_idx < 0:
new_idx = 0
if new_idx >= len(DICE_CHAIN):
new_idx = len(DICE_CHAIN) - 1
output_die = DICE_CHAIN[new_idx]
return DiceChainResult(
input_die=die_n,
steps=steps,
output_die=output_die,
chain=DICE_CHAIN.copy(),
)
# ---------------------------------------------------------------------------
# Helper per testing / CLI
# ---------------------------------------------------------------------------
def roll_str(raw: str) -> RollResult:
"""Parser stile '2d20+3' → chiamata roll()."""
m = re.match(r"(\d+)?d(\d+|%)([+-]\d+)?$", raw.strip().lower())
if not m:
raise ValueError(f"Formato non valido: '{raw}'. Usa tipo '2d20+3'.")
count = int(m.group(1)) if m.group(1) else 1
die = f"d{m.group(2)}"
modifier = int(m.group(3)) if m.group(3) else 0
return roll(die, count, modifier)
# ---------------------------------------------------------------------------
# Fire-and-forget test rapido (python src/engine/dice.py)
# ---------------------------------------------------------------------------
if __name__ == "__main__":
print("=== Test roll ===")
for d in ["d20", "d%", "d3", "d7", "d14"]:
r = roll(d)
print(f" {r['raw']}: {r['results']} (tot {r['total']}) crit={r['crit']} fumble={r['fumble']}")
print("\n=== Test dice_chain ===")
test_cases = [
("d3", 2), # d3 +2 → d5
("d20", -3), # d20 -3 → d10
("d7", 0), # d7 → d7
("d3", 20), # overflow → d30
("d30", -30), # underflow → d3
]
for die, steps in test_cases:
res = dice_chain(die, steps)
if "error" in res:
print(f" dice_chain('{die}', {steps}) → ERRORE: {res['error']}")
else:
print(f" dice_chain('{die}', {steps}) → {res['output_die']}")
print("\n=== Test roll_str ===")
for raw in ["d20", "2d6+2", "d%", "3d10-1"]:
r = roll_str(raw)
print(f" '{raw}' → {r['results']} tot {r['total']}")
print("\n=== Validazione ===")
for bad in ["d9", "d15", "d21", "xyz"]:
res = dice_chain(bad, 1)
if isinstance(res, dict) and "error" in res:
print(f" dice_chain('{bad}', 1) → ERRORE: {res['error']}")
try:
roll(bad)
except ValueError as e:
print(f" roll('{bad}') → ERRORE: {e}")
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
import logging
from src.engine import db, tools
from src.engine.config import load_from_env
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)
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)
return tool, params, ""
decision = parse_tool_call(raw)
tool = decision.get("tool", "")
params = decision.get("params", {})
narration = decision.get("narration", "")
if not tool:
log.info("LLM non ha prodotto tool valido, fallback heuristico")
tool, params = _parse_action(player_action, ctx)
return tool, params, narration
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)
result = tools.run(tool_name, **tool_params)
mechanic_msg = result.get("message", "")
full_msg = f"{narration}\n{mechanic_msg}".strip()
turn = db.advance_turn(campaign_id)
db.log_turn(campaign_id, turn, player_action, full_msg)
return {
"turn": turn,
"action": player_action,
"tool": tool_name,
"params": tool_params,
"narration": narration,
"mechanics": mechanic_msg,
"full_output": full_msg,
}
def interactive(campaign_id: int):
cfg = load_from_env()
print("=" * 60)
print("DCC RPG — AI Dungeon Master")
print(f"Provider: {cfg.kind} | Modello: {getattr(cfg, f'{cfg.kind}_model', '?')}")
print("Scrivi 'esci' per uscire, 'stato' per vedere la scheda.")
print("=" * 60)
print()
ctx = GameContext.load(campaign_id)
print(ctx.summary())
print()
while True:
try:
action = input("⚔️ » ").strip()
except (EOFError, KeyboardInterrupt):
print("\nFine partita.")
break
if not action:
continue
if action.lower() in ("esci", "exit", "quit"):
print("Alla prossima avventura!")
break
if action.lower() in ("stato", "stat", "status"):
print(GameContext.load(campaign_id).summary())
continue
print("🎲 Elaborazione...")
out = dm_cycle(campaign_id, action)
print(f"\n📖 {out['narration'] or '(azione riconosciuta)'}")
if out['mechanics']:
print(f"⚙️ {out['mechanics']}")
print()
if __name__ == "__main__":
import sys
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",
)
interactive(camp_id)
+111
View File
@@ -0,0 +1,111 @@
from __future__ import annotations
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
def get_provider() -> LLMProvider:
global _provider
if _provider is None:
_provider = create_provider(load_from_env())
return _provider
def set_provider(provider: LLMProvider) -> None:
global _provider
_provider = provider
def ask(prompt: str, system: str = SYSTEM_PROMPT) -> str:
return get_provider().ask(prompt, system=system)
def _extract_json(text: str) -> str | None:
for pat in [
r'```json\s*([\s\S]*?)```',
r'```\s*([\s\S]*?)```',
r'(\{[\s\S]*\})',
]:
m = re.search(pat, text)
if m:
candidate = m.group(1).strip()
try:
json.loads(candidate)
return candidate
except json.JSONDecodeError:
continue
return None
def parse_tool_call(text: str) -> dict:
text = text.strip()
blob = _extract_json(text)
if blob:
try:
data = json.loads(blob)
if "tool" in data:
return data
except json.JSONDecodeError:
pass
tool_match = re.search(r'(?:tool|strumento|chiamo|uso)\s*[":]*\s*(\w+)', text, re.I)
if tool_match:
return {"tool": tool_match.group(1), "params": {}, "narration": text[:200]}
return {"tool": "dm_override", "params": {"command": "narrate", "params": {"text": text[:200]}}, "narration": ""}
if __name__ == "__main__":
test_actions = [
"Tiro un d20 per vedere se sento rumori",
"Attacco l'orco con la mia ascia!",
"Esploro il corridoio buio",
]
for a in test_actions:
print(f"\n>>> Giocatore: {a}")
try:
resp = ask(a)
print(f"LLM: {resp[:300]}")
parsed = parse_tool_call(resp)
print(f"Parser: {json.dumps(parsed, indent=2)}")
except Exception as e:
print(f"ERRORE: {e}")
+144
View File
@@ -0,0 +1,144 @@
from __future__ import annotations
import json
from typing import Any
from src.engine import db as db_module
from src.engine.state_machine 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)"},
"dice_chain": {"die": "dado iniziale", "steps": "passi nella catena dadi DCC"},
"combat": {
"character_id": "id pg",
"target_ac": "CA bersaglio",
"attack_bonus": "bonus attacco (default 0)",
"damage_die": "dado danno (default d8)",
"damage_bonus": "bonus danno (default 0)",
"mighty_deed_die": "dado deed guerriero (es. d3)",
"armor_type": "none/light/medium/heavy",
},
"ability_check": {"character_id": "id pg", "dc": "CD prova", "modifier": "bonus/penalità"},
"saving_throw": {"character_id": "id pg", "save_type": "Tempra|Riflessi|Volontà", "dc": "CD", "modifier": "bonus/penalità"},
"movement": {"distance_m": "metri", "terrain": "normale|difficile|molto difficile"},
"spellcasting": {"character_id": "id pg", "spell_name": "nome incantesimo", "spell_check": "valore tiro"},
"update_inventory": {"character_id": "id pg", "item": "nome oggetto", "action": "add|remove"},
"award_xp": {"character_id": "id pg", "xp": "quantità PE"},
"dm_override": {"command": "narrate|set|force", "params": "parametri opzionali"},
}
TOOL_EXAMPLES = """
Esempi:
- Attacco con spada → {"tool": "combat", "params": {"character_id": 1, "target_ac": 12, "attack_bonus": 0, "damage_die": "d8"}, "narration": "Sguaini la lama e colpisci il guardiano!"}
- Tiro d20 per sentire rumori → {"tool": "roll_dice", "params": {"die": "d20"}, "narration": "Tendi l'orecchio nel buio..."}
- Esploro la stanza → {"tool": "dm_override", "params": {"command": "narrate", "params": {"text": "La stanza è polverosa..."}}, "narration": "Scrutando nell'oscurità, noti..."}
- Prova di percezione CD 12 → {"tool": "ability_check", "params": {"character_id": 1, "dc": 12}, "narration": "Cerchi indizi nella stanza..."}
"""
SYSTEM_PROMPT = f"""Sei il Dungeon Master di DCC RPG (Dungeon Crawl Classics) — narrazione epico-oscura, tono sword & sorcery.
STILE:
- 2-3 frasi per scena. Non essere prolisso.
- Descrivi suoni, odori, sensazioni tattili.
- Usa ITALIANO.
- Dai sempre una scelta al giocatore alla fine.
- Mantieni il tono weird-fantasy di DCC: pericolo, meraviglia, corruzione.
MECCANICHE:
- CD base: 12 = facile, 15 = media, 18 = difficile
- Tiri salvezza: Tempra (veleno/malattia), Riflessi (trappole/esplosioni), Volontà (magia/paura)
- Combattimento: iniziativa (d20), attacco (d20 + bonus vs CA), danni (dado arma)
- Mighty Deed: i Guerrieri possono fare imprese leggendarie con d3/d4/d5 aggiuntivo
STRUMENTI DISPONIBILI:
{json.dumps(_KNOWN_TOOLS, indent=2, ensure_ascii=False)}
{TOOL_EXAMPLES}
FORMATO RISPOSTA — SOLO JSON, nient'altro:
{{"tool": "nome_strumento", "params": {{...}}, "narration": "descrizione narrativa"}}
Se l'azione è puramente narrativa (dialogo, esplorazione, interazione), usa dm_override con command="narrate".
"""
def _enrich_areas(ctx_areas: list[dict]) -> list[dict]:
area_map = {a["id"]: a for a in db_module.get_all_areas()}
enriched = []
for sa in ctx_areas:
aid = sa["area_id"]
full = area_map.get(aid, {})
enriched.append({
"area_id": aid,
"name": full.get("name", aid),
"description": full.get("description", ""),
"connections": full.get("connections", []),
"features": full.get("features", []),
"visited": sa.get("visited", 0),
"monsters": [m.get("ref_id", "") for m in full.get("monsters", [])],
})
return enriched
def format_context(ctx: GameContext) -> str:
lines = []
camp = ctx.campaign or {"name": "?", "description": ""}
lines.append(f"Campagna: {camp['name']}")
lines.append(f"Descrizione: {camp.get('description', '')}")
lines.append(f"Turno: {ctx.turn}")
lines.append("")
lines.append("PERSONAGGI:")
for c in ctx.characters:
bl = f" [{c['id']}] {c['name']} — Lv{c['level']} {c.get('char_class', '')} ({c.get('alignment', '?')})"
stat_str = f" For{c.get('str','?')} Agi{c.get('agi','?')} Sta{c.get('sta','?')} Per{c.get('per','?')} Int{c.get('int','?')} Luc{c.get('lck','?')}"
hp_str = f" PF {c['hp_current']}/{c['hp_max']} CA {c.get('ac', 10)} PE {c.get('xp', 0)}"
lines.extend([bl, stat_str, hp_str])
lines.append("")
if ctx.log:
lines.append("CRONOLOGIA (ultimi 5 turni):")
for entry in ctx.log[-5:]:
a = entry.get("action", "?")
r = entry.get("result", "")[:80]
lines.append(f" «{a}» → {r}")
lines.append("")
if ctx.quests:
lines.append("MISSIONI:")
for q in ctx.quests:
lines.append(f" [{q['status']}] {q['name']}")
lines.append("")
enriched = _enrich_areas(ctx.areas)
if enriched:
lines.append("LUOGHI DELL'AVVENTURA (tutte le aree):")
visited_ids = {a["area_id"] for a in ctx.areas if a.get("visited")}
for a in enriched:
status = "visitata" if a["area_id"] in visited_ids else "inesplorata"
lines.append(f" [{a['area_id']}] {a['name']} [{status}]")
if a["visited"] and a.get("description"):
lines.append(f" {a['description'][:120]}")
lines.append(f" Collegate: {', '.join(a['connections']) if a['connections'] else 'nessuna'}")
if a.get("features"):
lines.append(f" Elementi: {' | '.join(a['features'][:3])}")
if a.get("monsters"):
names = []
for mid in a["monsters"]:
m = db_module.get_monster_by_id(mid)
if m:
names.append(m["name"])
if names:
lines.append(f" Creature: {', '.join(names)}")
lines.append("")
return "\n".join(lines)
def build_prompt(ctx: GameContext, player_action: str, current_area_id: str = "") -> str:
area_block = ""
if current_area_id:
area_data = db_module.format_area_context(current_area_id)
if area_data:
area_block = f"\n\nAREA ATTUALE:\n{area_data}\n"
return f"""{format_context(ctx)}{area_block}---
AZIONE GIOCATORE: "{player_action}"
Rispondi con il JSON nel formato specificato (tool, params, narration)."""
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
import json
import os
from abc import ABC, abstractmethod
from typing import Any
import httpx
from src.engine.config import ProviderConfig, load_from_env
class LLMProvider(ABC):
@abstractmethod
def ask(self, prompt: str, system: str = "") -> str:
...
class OllamaProvider(LLMProvider):
def __init__(self, base_url: str, model: str, timeout: int = 60):
self.base_url = base_url.rstrip("/")
self.model = model
self.timeout = timeout
def ask(self, prompt: str, system: str = "") -> str:
body = {"model": self.model, "prompt": prompt, "stream": False}
if system:
body["system"] = system
resp = httpx.post(
f"{self.base_url}/api/generate",
json=body,
timeout=self.timeout,
)
resp.raise_for_status()
return resp.json().get("response", "")
class OpenRouterProvider(LLMProvider):
_FREE_FALLBACK = [
"openrouter/free",
"google/gemma-4-31b-it:free",
"qwen/qwen3-coder:free",
]
def __init__(self, api_key: str, model: str, timeout: int = 60, free_fallback: bool = True):
self.api_key = api_key
self.model = model
self.timeout = timeout
self.free_fallback = free_fallback
def _call(self, model: str, prompt: str, system: str) -> str:
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
body = {"model": model, "messages": messages, "stream": False}
resp = httpx.post(
"https://openrouter.ai/api/v1/chat/completions",
json=body,
headers=headers,
timeout=self.timeout,
)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
def ask(self, prompt: str, system: str = "") -> str:
models = [self.model]
if self.free_fallback:
models.extend(self._FREE_FALLBACK)
last_err = None
for m in models:
try:
return self._call(m, prompt, system)
except Exception as e:
last_err = e
continue
raise last_err # type: ignore
class LocalProvider(LLMProvider):
def __init__(self, base_url: str, model: str, api_key: str = "", timeout: int = 60):
self.base_url = base_url.rstrip("/")
self.model = model
self.api_key = api_key
self.timeout = timeout
def ask(self, prompt: str, system: str = "") -> str:
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
body = {"model": self.model, "messages": messages, "stream": False}
resp = httpx.post(
f"{self.base_url}/chat/completions",
json=body,
headers=headers,
timeout=self.timeout,
)
resp.raise_for_status()
data = resp.json()
return data["choices"][0]["message"]["content"]
_registry: dict[str, Any] = {
"ollama": OllamaProvider,
"openrouter": OpenRouterProvider,
"local": LocalProvider,
}
def create_provider(cfg: ProviderConfig | None = None) -> LLMProvider:
if cfg is None:
cfg = load_from_env()
cls = _registry.get(cfg.kind)
if not cls:
raise ValueError(f"Provider sconosciuto: {cfg.kind} (disponibili: {list(_registry.keys())})")
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", "")
if not key:
raise ValueError("OPENROUTER_API_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)
else:
raise ValueError(f"Provider non implementato: {cfg.kind}")
+160
View File
@@ -0,0 +1,160 @@
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from typing import Any
from src.engine import db, tools
# ---------------------------------------------------------------------------
# Stato del gioco (immutabile dentro un turno)
# ---------------------------------------------------------------------------
@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())
+421
View File
@@ -0,0 +1,421 @@
from __future__ import annotations
import json
import re
from typing import Any
from src.engine import dice
from src.engine import db as storage
ToolResult = dict[str, Any]
TOOL_REGISTRY: dict[str, type] = {}
def tool(fn):
TOOL_REGISTRY[fn.__name__] = fn
return fn
@tool
def roll_dice(die: str, count: int = 1, modifier: int = 0) -> ToolResult:
try:
r = dice.roll(die, count, modifier)
return {
"success": True,
"tool": "roll-dice",
"die": r["die"],
"count": r["count"],
"modifier": r["modifier"],
"results": r["results"],
"total": r["total"],
"crit": r["crit"],
"fumble": r["fumble"],
"message": f"Tiro: {r['raw']} → {r['results']} (totale: {r['total']})",
}
except ValueError as e:
return {"success": False, "tool": "roll-dice", "message": str(e)}
@tool
def dice_chain(die: str, steps: int) -> ToolResult:
res = dice.dice_chain(die, steps)
if "error" in res:
return {"success": False, "tool": "dice-chain", "message": res["error"]}
return {
"success": True,
"tool": "dice-chain",
"input_die": res["input_die"],
"steps": res["steps"],
"output_die": res["output_die"],
"message": f"Catena dadi: {die} {'+' if steps >= 0 else ''}{steps} → {res['output_die']}",
}
@tool
def combat(
character_id: int,
target_ac: int,
attack_bonus: int = 0,
damage_die: str = "d8",
damage_bonus: int = 0,
mighty_deed_die: str = "",
luck_mod: int = 0,
armor_type: str = "none",
) -> ToolResult:
char = storage.get_character(character_id)
if not char:
return {"success": False, "tool": "combat", "message": "Personaggio non trovato."}
init_roll = dice.roll("d20")
atk_roll = dice.roll("d20", modifier=attack_bonus)
raw_total = atk_roll["total"]
deed_roll = None
if mighty_deed_die:
deed_roll = dice.roll(mighty_deed_die)
raw_total += deed_roll["total"]
hit = raw_total >= target_ac
result_parts = []
crit_res = None
if atk_roll["fumble"]:
fumble_die_name = storage.get_fumble_die(armor_type) or "d4"
fumble_roll = dice.roll(fumble_die_name)
fumble_res = storage.lookup_crit_fumble("fumble", fumble_roll["total"], luck_mod)
crit_res = fumble_res
result_parts.append(
f"Fallimento critico! Tiro fumble (d{armor_type}): {fumble_roll['total']} → "
+ (fumble_res["result"] if fumble_res else "Nessun risultato trovato.")
)
if atk_roll["crit"]:
crit_table_id = _crit_table_for(char)
crit_die_name = _crit_die_for(char)
crit_roll = dice.roll(crit_die_name)
crit_res = storage.lookup_crit_fumble(crit_table_id, crit_roll["total"], luck_mod)
result_parts.append(
f"Colpo critico! Tiro crit ({crit_die_name}): {crit_roll['total']} → "
+ (crit_res["result"] if crit_res else "Nessun risultato trovato.")
)
if hit and not atk_roll["fumble"]:
dmg = dice.roll(damage_die, modifier=damage_bonus)
crit_dmg_bonus = 0
if atk_roll["crit"] and crit_res:
crit_dmg_bonus = _parse_extra_damage(crit_res.get("result", ""))
total_dmg = dmg["total"] + crit_dmg_bonus
result_parts.append(f"Colpito! Danni: {dmg['results']} + {crit_dmg_bonus} extra = {total_dmg}")
elif not atk_roll["fumble"]:
result_parts.append("Mancato!")
deed_msg = f" Deed: {deed_roll['total']}" if deed_roll else ""
return {
"success": True,
"tool": "combat",
"initiative": init_roll["total"],
"attack_raw": atk_roll["results"][0],
"attack_bonus": attack_bonus,
"attack_total": raw_total,
"target_ac": target_ac,
"hit": hit,
"crit": atk_roll["crit"],
"fumble": atk_roll["fumble"],
"deed": deed_roll["total"] if deed_roll else None,
"message": f"Iniziativa: {init_roll['total']} | Attacco d20+{attack_bonus}: {atk_roll['results'][0]}+{attack_bonus}={atk_roll['total']} vs CA {target_ac} | {'COLPI TO!' if hit else 'Mancato.'}{deed_msg} | " + " | ".join(result_parts),
}
def _crit_table_for(char: dict) -> str:
cls = (char.get("char_class") or "").lower()
lv = char.get("level", 0)
if cls in ("guerriero",):
return "crit_V" if lv >= 5 else "crit_IV" if lv >= 3 else "crit_III"
if cls in ("nano",):
return "crit_V" if lv >= 6 else "crit_IV" if lv >= 4 else "crit_III"
if cls in ("ladro", "elfo"):
return "crit_II"
if cls in ("chierico", "halfling"):
return "crit_III"
if cls in ("mago",) or lv == 0:
return "crit_I"
return "crit_I"
def _crit_die_for(char: dict) -> str:
cls = (char.get("char_class") or "").lower()
lv = char.get("level", 0)
if cls in ("guerriero",):
return "d12" if lv >= 5 else "d10" if lv >= 3 else "d8"
if cls in ("nano",):
return "d12" if lv >= 6 else "d10" if lv >= 4 else "d8"
if cls in ("ladro", "elfo"):
return "d6"
if cls in ("chierico", "halfling"):
return "d8"
if cls in ("mago",) or lv == 0:
return "d4"
return "d4"
def _parse_extra_damage(result_text: str) -> int:
matches = re.findall(r'(\+?)(\d+)d\d+', result_text)
total = 0
for sign, num in matches:
if sign == "+":
total += int(num)
else:
total += int(num)
return total
@tool
def ability_check(character_id: int, dc: int, modifier: int = 0) -> ToolResult:
char = storage.get_character(character_id)
if not char:
return {"success": False, "tool": "ability-check", "message": "Personaggio non trovato."}
atk = dice.roll("d20", modifier=modifier)
passed = atk["total"] >= dc
return {
"success": True,
"tool": "ability-check",
"roll": atk["results"][0],
"modifier": modifier,
"total": atk["total"],
"dc": dc,
"passed": passed,
"crit": atk["crit"],
"fumble": atk["fumble"],
"message": f"Prova abilità: d20+{modifier} → {atk['results'][0]}+{modifier}={atk['total']} vs CD {dc} → {'Successo!' if passed else 'Fallimento.'}",
}
@tool
def saving_throw(character_id: int, save_type: str, dc: int, modifier: int = 0) -> ToolResult:
char = storage.get_character(character_id)
if not char:
return {"success": False, "tool": "saving-throw", "message": "Personaggio non trovato."}
valid = ("Tempra", "Riflessi", "Volontà")
if save_type not in valid:
return {"success": False, "tool": "saving-throw", "message": f"Tipo non valido: {save_type}. Usa: {', '.join(valid)}"}
atk = dice.roll("d20", modifier=modifier)
passed = atk["total"] >= dc
return {
"success": True,
"tool": "saving-throw",
"save_type": save_type,
"roll": atk["results"][0],
"modifier": modifier,
"total": atk["total"],
"dc": dc,
"passed": passed,
"crit": atk["crit"],
"fumble": atk["fumble"],
"message": f"Tiro Salvezza su {save_type}: d20+{modifier} → {atk['results'][0]}+{modifier}={atk['total']} vs CD {dc} → {'Superato!' if passed else 'Fallito.'}",
}
@tool
def movement(distance_m: int, terrain: str = "normale", stealth: bool = False) -> ToolResult:
speeds = {"normale": 12, "difficile": 6, "molto difficile": 3}
speed = speeds.get(terrain, 12)
rounds = max(1, -(-distance_m // speed))
stealth_msg = " (in furtivo)" if stealth else ""
return {
"success": True,
"tool": "movement",
"distance_m": distance_m,
"terrain": terrain,
"speed_m_per_round": speed,
"rounds": rounds,
"stealth": stealth,
"message": f"Movimento: {distance_m}m su terreno {terrain}{stealth_msg} → {rounds} round ({speed}m/round).",
}
@tool
def spellcasting(character_id: int, spell_name: str, spell_check: int, dc: int = 12) -> ToolResult:
char = storage.get_character(character_id)
if not char:
return {"success": False, "tool": "spellcasting", "message": "Personaggio non trovato."}
result_tier = "fallimento"
outcome = ""
if spell_check == 1:
outcome = "Fallimento critico! L'incantesimo si ritorce."
elif spell_check < dc:
outcome = f"L'incantesimo fallisce ({spell_check} < CD {dc})."
elif spell_check <= 11:
result_tier = "minore"
outcome = f"Successo minore ({spell_check}). Effetto base."
elif spell_check <= 17:
result_tier = "moderato"
outcome = f"Successo moderato ({spell_check}). Effetto migliorato."
elif spell_check <= 19:
result_tier = "maggiore"
outcome = f"Successo maggiore ({spell_check}). Effetto potente."
else:
result_tier = "massimo"
outcome = f"Successo massimo! ({spell_check}). Effetto devastante."
corruption_check = spell_check == 1 or (spell_check >= 12 and dice.roll("d20")["total"] <= 1)
return {
"success": True,
"tool": "spellcasting",
"spell": spell_name,
"spell_check": spell_check,
"dc": dc,
"result_tier": result_tier,
"corruption": corruption_check,
"message": f"Incantesimo: {spell_name} → Tiro {spell_check} vs CD {dc} → {outcome}" + (" (Corruzione!)" if corruption_check else ""),
}
@tool
def update_inventory(character_id: int, item: str, action: str = "add") -> ToolResult:
char = storage.get_character(character_id)
if not char:
return {"success": False, "tool": "update-inventory", "message": "Personaggio non trovato."}
inv = json.loads(char.get("equipment", "[]"))
if action == "add":
inv.append(item)
elif action == "remove":
inv = [i for i in inv if i != item]
else:
return {"success": False, "tool": "update-inventory", "message": f"Azione '{action}' non valida. Usa 'add' o 'remove'."}
storage.update_character(character_id, equipment=json.dumps(inv))
return {
"success": True,
"tool": "update-inventory",
"action": action,
"item": item,
"inventory": inv,
"message": f"Inventario: {action} '{item}' → {len(inv)} oggetti.",
}
@tool
def award_xp(character_id: int, xp: int) -> ToolResult:
char = storage.get_character(character_id)
if not char:
return {"success": False, "tool": "award-xp", "message": "Personaggio non trovato."}
new_xp = (char.get("xp", 0) or 0) + xp
storage.update_character(character_id, xp=new_xp)
messages = [f"{char['name']} guadagna {xp} PE (totale: {new_xp})."]
leveled = False
if new_xp >= 10 and char.get("level", 0) == 0:
storage.update_character(character_id, level=1)
messages.append(f"{char['name']} sale a livello 1!")
leveled = True
return {
"success": True,
"tool": "award-xp",
"xp_gained": xp,
"xp_total": new_xp,
"leveled_up": leveled,
"message": " ".join(messages),
}
@tool
def dm_override(command: str, params: dict | None = None) -> ToolResult:
manual = {
"set_hp": "Imposta PF: {character_id, value}",
"force_roll": "Forza risultato dado: {die, forced_value}",
"skip_combat": "Salta un combattimento: {area_id}",
"add_item": "Aggiunge oggetto: {character_id, item}",
"narrate": "Testo narrativo forzato: {text}",
}
if command == "help":
return {
"success": True,
"tool": "dm-override",
"commands": manual,
"message": "Comandi DM disponibili: " + ", ".join(manual.keys()),
}
return {
"success": True,
"tool": "dm-override",
"command": command,
"params": params or {},
"message": f"Override DM: {command} applicato.",
}
def run(tool_name: str, **kwargs) -> ToolResult:
if tool_name not in TOOL_REGISTRY:
return {
"success": False,
"tool": tool_name,
"message": f"Tool '{tool_name}' sconosciuto. Disponibili: {', '.join(sorted(TOOL_REGISTRY))}",
}
try:
return TOOL_REGISTRY[tool_name](**kwargs)
except Exception as e:
return {"success": False, "tool": tool_name, "message": f"Errore: {e}"}
if __name__ == "__main__":
print("=== Tool Registry ===")
for name in sorted(TOOL_REGISTRY):
print(f" - {name}")
print("\n=== Test roll-dice ===")
r = run("roll_dice", die="d20")
print(f" {r['message']}")
print("\n=== Test dice-chain ===")
r = run("dice_chain", die="d3", steps=2)
print(f" {r['message']}")
print("\n=== Test unknown tool ===")
r = run("non_existent")
print(f" {r['message']}")
print("\n=== Init DB and setup ===")
conn = storage.init_db()
camp_id = storage.create_campaign("Test", "Avventura di Prova")
char_id = storage.create_character(camp_id, "Bob il Contadino", "guerriero", "contadino")
storage.update_character(char_id,
level=3, str=14, agi=12, sta=13,
per=10, int=8, lck=9,
hp_current=18, hp_max=18,
ac=16, armor_type="medium"
)
print(f" Creata campagna #{camp_id} con personaggio #{char_id}")
print("\n=== Test combat ===")
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")
print(f" {r['message']}")
print("\n=== Test ability-check ===")
r = run("ability_check", character_id=char_id, dc=15, modifier=2)
print(f" {r['message']}")
print("\n=== Test saving-throw ===")
r = run("saving_throw", character_id=char_id, save_type="Tempra", dc=15, modifier=1)
print(f" {r['message']}")
print("\n=== Test movement ===")
r = run("movement", distance_m=30, terrain="difficile")
print(f" {r['message']}")
print("\n=== Test spellcasting ===")
r = run("spellcasting", character_id=char_id, spell_name="Dardo Incantato", spell_check=18, dc=12)
print(f" {r['message']}")
print("\n=== Test update-inventory ===")
r = run("update_inventory", character_id=char_id, item="Pozione di Cura")
print(f" {r['message']}")
print("\n=== Test award-xp ===")
r = run("award_xp", character_id=char_id, xp=5)
print(f" {r['message']}")
print("\n=== Test dm-override ===")
r = run("dm_override", command="narrate", params={"text": "Una luce accecante riempie la stanza."})
print(f" {r['message']}")
View File
Whitespace-only changes.
+227
View File
@@ -0,0 +1,227 @@
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse
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
log = logging.getLogger(__name__)
STATIC_DIR = Path(__file__).resolve().parent / "static"
app = FastAPI(title="DCC RPG — AI Dungeon Master")
# ---------------------------------------------------------------------------
# REST endpoints
# ---------------------------------------------------------------------------
@app.get("/api/campaigns")
def list_campaigns():
conn = db.get_conn()
rows = conn.execute(
"SELECT id, name, adventure, turn, created_at FROM campaigns ORDER BY id DESC"
).fetchall()
return [dict(r) for r in rows]
@app.get("/api/campaign/{campaign_id}")
def get_campaign_state(campaign_id: int):
try:
ctx = GameContext.load(campaign_id)
except ValueError:
return JSONResponse({"error": "Campagna non trovata"}, status_code=404)
# Full area details from adventure data
all_areas = db.get_all_areas()
area_map = {a["id"]: a for a in all_areas}
visited_ids = {a["area_id"] for a in ctx.areas if a.get("visited")}
enriched = []
for sa in ctx.areas:
aid = sa["area_id"]
full = area_map.get(aid, {})
enriched.append({
"id": aid,
"name": full.get("name", aid),
"description": full.get("description", "")[:200],
"connections": full.get("connections", []),
"visited": bool(sa.get("visited")),
"features": full.get("features", [])[:3],
})
return {
"campaign": dict(ctx.campaign) if ctx.campaign else {},
"characters": ctx.characters,
"quests": ctx.quests,
"areas": enriched,
"turn": ctx.turn,
}
@app.post("/api/campaign")
def create_campaign(name: str = "Nuova Avventura", adventure: str = ""):
camp_id = db.create_campaign(name, adventure)
db.seed_adventure_state(camp_id)
db.seed_quests_from_adventure(camp_id)
# Mark first area as visited
areas = db.get_all_areas()
if areas:
db.set_area_state(camp_id, areas[0]["id"], visited=True)
return {"campaign_id": camp_id}
@app.post("/api/campaign/{campaign_id}/character")
def add_character(
campaign_id: int,
name: str = "Eroe",
char_class: str = "",
occupation: str = "",
):
char_id = db.create_character(campaign_id, name, char_class, occupation)
# Default stats for a lv0/lv1 character
db.update_character(
char_id,
level=1,
str=12,
agi=11,
sta=12,
per=10,
int=10,
lck=10,
hp_current=8,
hp_max=8,
ac=12,
)
return {"character_id": char_id}
# ---------------------------------------------------------------------------
# WebSocket — real-time game loop
# ---------------------------------------------------------------------------
@app.websocket("/ws/{campaign_id}")
async def game_ws(ws: WebSocket, campaign_id: int):
await ws.accept()
try:
ctx = GameContext.load(campaign_id)
except ValueError:
await ws.send_json({"type": "error", "message": "Campagna non trovata"})
await ws.close()
return
# Send initial state
await ws.send_json({"type": "state", "campaign": _build_state(campaign_id)})
while True:
raw = await ws.receive_text()
try:
msg = json.loads(raw)
except json.JSONDecodeError:
msg = {"type": "action", "payload": raw}
if msg.get("type") == "action":
player_action = msg.get("payload", "").strip()
if not player_action:
continue
try:
out = dm_cycle(campaign_id, player_action)
await ws.send_json({
"type": "response",
"turn": out["turn"],
"tool": out["tool"],
"params": out["params"],
"narration": out["narration"],
"mechanics": out["mechanics"],
"full_output": out["full_output"],
})
except Exception as e:
log.exception("DM cycle failed")
await ws.send_json({"type": "error", "message": str(e)})
# Send updated state after action
await ws.send_json({"type": "state", "campaign": _build_state(campaign_id)})
elif msg.get("type") == "ping":
await ws.send_json({"type": "pong"})
def _build_state(campaign_id: int) -> dict:
ctx = GameContext.load(campaign_id)
all_areas = db.get_all_areas()
area_map = {a["id"]: a for a in all_areas}
enriched = []
for sa in ctx.areas:
aid = sa["area_id"]
full = area_map.get(aid, {})
enriched.append({
"id": aid,
"name": full.get("name", aid),
"visited": bool(sa.get("visited")),
"connections": full.get("connections", []),
})
return {
"turn": ctx.turn,
"characters": ctx.characters,
"quests": ctx.quests,
"areas": enriched,
}
# ---------------------------------------------------------------------------
# Serve frontend
# ---------------------------------------------------------------------------
@app.get("/health")
def health():
return {"status": "ok", "provider": load_from_env().kind}
@app.get("/")
async def index():
html_path = STATIC_DIR / "index.html"
if html_path.exists():
content = html_path.read_text(encoding="utf-8")
return HTMLResponse(content)
return HTMLResponse("<h1>Frontend non trovato</h1>", status_code=404)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
db.init_db()
cfg = load_from_env()
log.info(
"DM config: provider=%s model=%s",
cfg.kind,
getattr(cfg, f"{cfg.kind}_model", "?"),
)
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8300, log_level="info")
if __name__ == "__main__":
main()
+888
View File
@@ -0,0 +1,888 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DCC RPG — AI Dungeon Master</title>
<style>
/* ============================================================
CSS Variables & Reset
============================================================ */
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&display=swap');
:root {
--bg-deep: #0a0806;
--bg-surface: #13100c;
--bg-card: #1a1612;
--bg-elevated: #221e18;
--border: #2d271f;
--border-light: #3d352a;
--text-primary: #e8dcc8;
--text-secondary: #9a8c78;
--text-muted: #5e5446;
--gold: #c9a84c;
--gold-bright: #e8c85a;
--gold-dim: #8a7230;
--red: #a83030;
--green: #3a7a3a;
--blue-accent: #4a6080;
--hp-bar: #7a3030;
--hp-bar-bg: #2a1818;
--sidebar-w: 320px;
--header-h: 52px;
--radius: 4px;
--font-display: 'Cinzel', serif;
--font-body: 'Cormorant Garamond', serif;
--shadow: 0 2px 8px rgba(0,0,0,0.5);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: var(--bg-deep);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 17px;
line-height: 1.5;
overflow: hidden;
}
/* ============================================================
Layout
============================================================ */
#app {
display: flex;
height: 100vh;
}
/* Sidebar */
#sidebar {
width: var(--sidebar-w);
min-width: var(--sidebar-w);
background: var(--bg-surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
#sidebar::-webkit-scrollbar { width: 4px; }
#sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
/* Main area */
#main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
/* Header */
#header {
height: var(--header-h);
min-height: var(--header-h);
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 20px;
gap: 16px;
}
#header h1 {
font-family: var(--font-display);
font-size: 16px;
font-weight: 700;
color: var(--gold);
letter-spacing: 2px;
text-transform: uppercase;
flex: 1;
}
#header .turn-badge {
font-family: var(--font-display);
font-size: 12px;
color: var(--text-muted);
letter-spacing: 1px;
}
#header .turn-badge span { color: var(--gold); font-weight: 700; }
#header .conn-status {
width: 8px; height: 8px;
border-radius: 50%;
display: inline-block;
}
#header .conn-status.connected { background: var(--green); box-shadow: 0 0 6px var(--green); }
#header .conn-status.disconnected { background: var(--red); box-shadow: 0 0 6px var(--red); }
#header .conn-status.connecting { background: var(--gold-dim); animation: pulse 1s infinite; }
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
/* ============================================================
Sidebar Components
============================================================ */
.sidebar-section {
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.sidebar-section h2 {
font-family: var(--font-display);
font-size: 11px;
font-weight: 700;
color: var(--gold-dim);
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 10px;
}
/* Character card */
.char-name {
font-family: var(--font-display);
font-size: 18px;
font-weight: 700;
color: var(--gold-bright);
}
.char-class {
font-size: 14px;
color: var(--text-secondary);
font-style: italic;
margin-bottom: 10px;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px 8px;
margin-bottom: 8px;
}
.stat-row {
display: flex;
justify-content: space-between;
font-size: 13px;
padding: 2px 6px;
background: var(--bg-elevated);
border-radius: var(--radius);
}
.stat-row .stat-label {
color: var(--text-muted);
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 1px;
}
.stat-row .stat-val { color: var(--text-primary); font-weight: 600; }
.hp-bar-wrapper {
display: flex;
align-items: center;
gap: 8px;
margin: 8px 0 4px;
}
.hp-bar-bg {
flex: 1;
height: 10px;
background: var(--hp-bar-bg);
border-radius: 5px;
overflow: hidden;
border: 1px solid var(--border);
}
.hp-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--hp-bar), #a04020);
border-radius: 5px;
transition: width 0.4s;
}
.hp-text {
font-size: 13px;
font-weight: 600;
min-width: 50px;
text-align: right;
}
.char-ac, .char-weapon {
font-size: 13px;
color: var(--text-secondary);
}
.char-ac span, .char-weapon span { color: var(--text-primary); }
/* Quest log */
.quest-item {
padding: 6px 0;
border-bottom: 1px solid var(--border);
}
.quest-item:last-child { border-bottom: none; }
.quest-name {
font-size: 14px;
font-weight: 600;
}
.quest-status {
display: inline-block;
font-size: 10px;
font-family: var(--font-display);
letter-spacing: 1px;
padding: 1px 6px;
border-radius: 2px;
margin-left: 6px;
text-transform: uppercase;
}
.quest-status.active { background: var(--gold-dim); color: #fff; }
.quest-status.completed { background: var(--green); color: #fff; }
.quest-status.failed { background: var(--red); color: #fff; }
.quest-desc {
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
}
/* Area map */
.area-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 13px;
color: var(--text-muted);
}
.area-item.visited { color: var(--text-primary); }
.area-marker {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--text-muted);
flex-shrink: 0;
}
.area-item.visited .area-marker { background: var(--gold); box-shadow: 0 0 4px var(--gold); }
.area-conns {
font-size: 11px;
color: var(--text-muted);
margin-left: 14px;
}
/* Zone vuote */
.empty-state {
font-size: 13px;
color: var(--text-muted);
font-style: italic;
}
/* ============================================================
Chat Area
============================================================ */
#chat {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
#chat::-webkit-scrollbar { width: 4px; }
#chat::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.msg {
max-width: 85%;
animation: fadeIn 0.3s;
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.msg.player {
align-self: flex-end;
}
.msg.player .msg-bubble {
background: var(--bg-elevated);
border: 1px solid var(--border-light);
border-radius: 12px 12px 4px 12px;
padding: 10px 14px;
}
.msg.player .msg-label {
text-align: right;
font-size: 11px;
color: var(--text-muted);
margin-bottom: 4px;
font-family: var(--font-display);
letter-spacing: 1px;
}
.msg.dm {
align-self: flex-start;
}
.msg.dm .msg-bubble {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px 12px 12px 4px;
padding: 12px 16px;
}
.msg.dm .msg-label {
font-size: 11px;
color: var(--gold-dim);
margin-bottom: 4px;
font-family: var(--font-display);
letter-spacing: 1px;
}
.msg-dm-narration {
font-size: 17px;
line-height: 1.6;
margin-bottom: 6px;
}
.msg-dm-mechanics {
font-size: 14px;
color: var(--blue-accent);
font-style: italic;
padding: 6px 10px;
background: rgba(74, 96, 128, 0.1);
border-radius: var(--radius);
border-left: 2px solid var(--blue-accent);
}
.msg-dm-tool {
display: inline-block;
font-size: 10px;
font-family: var(--font-display);
letter-spacing: 1px;
color: var(--gold-dim);
background: rgba(201, 168, 76, 0.1);
padding: 2px 8px;
border-radius: 2px;
margin-top: 4px;
text-transform: uppercase;
}
.msg.system {
align-self: center;
}
.msg.system .msg-bubble {
font-size: 13px;
color: var(--text-muted);
font-style: italic;
text-align: center;
}
.msg.error .msg-bubble {
color: var(--red);
text-align: center;
}
/* ============================================================
Input Area
============================================================ */
#input-area {
padding: 12px 20px;
background: var(--bg-surface);
border-top: 1px solid var(--border);
display: flex;
gap: 10px;
align-items: center;
}
#input-area input {
flex: 1;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 14px;
color: var(--text-primary);
font-family: var(--font-body);
font-size: 16px;
outline: none;
transition: border-color 0.2s;
}
#input-area input:focus {
border-color: var(--gold-dim);
}
#input-area input::placeholder { color: var(--text-muted); }
#input-area input:disabled {
opacity: 0.4;
cursor: not-allowed;
}
#btn-send {
background: var(--gold-dim);
border: none;
border-radius: var(--radius);
color: #fff;
width: 40px;
height: 40px;
font-size: 18px;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
display: flex;
align-items: center;
justify-content: center;
}
#btn-send:hover { background: var(--gold); }
#btn-send:active { transform: scale(0.95); }
#btn-send:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
/* ============================================================
Welcome / Hero Screen
============================================================ */
#welcome {
position: absolute;
inset: 0;
background: var(--bg-deep);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 10;
padding: 20px;
}
#welcome.hidden { display: none; }
#welcome h1 {
font-family: var(--font-display);
font-size: 36px;
font-weight: 900;
color: var(--gold);
text-align: center;
letter-spacing: 4px;
text-transform: uppercase;
margin-bottom: 8px;
}
#welcome .subtitle {
font-size: 18px;
color: var(--text-secondary);
font-style: italic;
margin-bottom: 32px;
}
#welcome .camp-list {
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
gap: 8px;
}
#welcome .camp-btn {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 18px;
color: var(--text-primary);
font-family: var(--font-body);
font-size: 16px;
text-align: left;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
#welcome .camp-btn:hover {
border-color: var(--gold-dim);
background: var(--bg-elevated);
}
#welcome .camp-btn .camp-name {
font-weight: 600;
display: block;
}
#welcome .camp-btn .camp-meta {
font-size: 13px;
color: var(--text-muted);
}
#new-camp-form {
margin-top: 16px;
display: flex;
gap: 8px;
width: 100%;
max-width: 420px;
}
#new-camp-form input {
flex: 1;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 14px;
color: var(--text-primary);
font-family: var(--font-body);
font-size: 15px;
outline: none;
}
#new-camp-form input:focus { border-color: var(--gold-dim); }
#new-camp-form input::placeholder { color: var(--text-muted); }
#new-camp-form button {
background: var(--gold-dim);
border: none;
border-radius: var(--radius);
color: #fff;
padding: 10px 20px;
font-family: var(--font-display);
font-size: 12px;
letter-spacing: 1px;
text-transform: uppercase;
cursor: pointer;
transition: background 0.2s;
}
#new-camp-form button:hover { background: var(--gold); }
/* Loading overlay */
#loading {
display: none;
position: absolute;
inset: 0;
background: rgba(10,8,6,0.8);
z-index: 5;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-size: 14px;
color: var(--gold-dim);
letter-spacing: 2px;
}
#loading.active { display: flex; }
#loading .spinner {
width: 20px; height: 20px;
border: 2px solid var(--border);
border-top-color: var(--gold);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 12px;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div id="app">
<!-- WELCOME SCREEN -->
<div id="welcome">
<h1>DCC RPG</h1>
<p class="subtitle">AI Dungeon Master — La Tomba del Mago Guerriero</p>
<div class="camp-list" id="camp-list"></div>
<div id="new-camp-form">
<input type="text" id="new-camp-name" placeholder="Nuova avventura..." />
<button id="btn-new-camp">Crea</button>
</div>
</div>
<!-- SIDEBAR -->
<div id="sidebar">
<div class="sidebar-section" id="char-section">
<h2>Personaggio</h2>
<div id="char-display"><div class="empty-state">Nessun PG</div></div>
</div>
<div class="sidebar-section" id="quest-section">
<h2>Missioni</h2>
<div id="quest-display"><div class="empty-state">Nessuna missione</div></div>
</div>
<div class="sidebar-section" id="area-section">
<h2>Aree</h2>
<div id="area-display"><div class="empty-state">Nessuna area</div></div>
</div>
</div>
<!-- MAIN -->
<div id="main">
<div id="header">
<div id="conn-indicator" class="conn-status disconnected"></div>
<h1>Tomba del Mago Guerriero</h1>
<div class="turn-badge">Turno <span id="turn-counter">0</span></div>
</div>
<div id="chat">
<div class="msg system"><div class="msg-bubble">Benvenuto, avventuriero. Scrivi la tua azione per iniziare.</div></div>
</div>
<div id="input-area">
<input type="text" id="input" placeholder="Cosa vuoi fare?" disabled />
<button id="btn-send" disabled>➤</button>
</div>
</div>
</div>
<div id="loading" class="active"><div class="spinner"></div> Caricamento...</div>
<script>
// ================================================================
// State
// ================================================================
let campaignId = null;
let ws = null;
let turn = 0;
let reconnectTimer = null;
let state = { characters: [], quests: [], areas: [] };
const HOST = window.location.host;
const BASE = `${window.location.protocol}//${HOST}`;
const WS_BASE = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${HOST}`;
// ================================================================
// DOM refs
// ================================================================
const $ = id => document.getElementById(id);
const welcome = $('welcome');
const campList = $('camp-list');
const newCampName = $('new-camp-name');
const btnNewCamp = $('btn-new-camp');
const charDisplay = $('char-display');
const questDisplay = $('quest-display');
const areaDisplay = $('area-display');
const chat = $('chat');
const input = $('input');
const btnSend = $('btn-send');
const turnCounter = $('turn-counter');
const connIndicator = $('conn-indicator');
const loading = $('loading');
// ================================================================
// Welcome screen
// ================================================================
async function loadCampaignList() {
try {
const res = await fetch(`${BASE}/api/campaigns`);
const camps = await res.json();
campList.innerHTML = '';
if (camps.length === 0) {
campList.innerHTML = '<div class="empty-state" style="text-align:center;padding:16px;">Nessuna campagna. Creane una nuova.</div>';
return;
}
camps.forEach(c => {
const btn = document.createElement('button');
btn.className = 'camp-btn';
btn.innerHTML = `<span class="camp-name">${c.name}</span><span class="camp-meta">Turno ${c.turn} — ${c.created_at}</span>`;
btn.onclick = () => joinCampaign(c.id);
campList.appendChild(btn);
});
} catch(e) {
campList.innerHTML = '<div class="empty-state" style="text-align:center;padding:16px;color:var(--red);">Errore di connessione al server</div>';
}
}
btnNewCamp.onclick = async () => {
const name = newCampName.value.trim() || 'Nuova Avventura';
try {
const res = await fetch(`${BASE}/api/campaign?name=${encodeURIComponent(name)}&adventure=Tomba+del+Mago+Guerriero`, { method: 'POST' });
const data = await res.json();
joinCampaign(data.campaign_id);
} catch(e) {
alert('Errore nella creazione della campagna');
}
};
newCampName.addEventListener('keydown', e => { if (e.key === 'Enter') btnNewCamp.click(); });
// ================================================================
// Join campaign
// ================================================================
async function joinCampaign(id) {
campaignId = id;
loading.classList.add('active');
welcome.classList.add('hidden');
// Load initial state via REST
try {
const res = await fetch(`${BASE}/api/campaign/${id}`);
if (!res.ok) throw new Error('Not found');
const data = await res.json();
applyState(data);
if (data.campaign && data.campaign.name) {
document.querySelector('#header h1').textContent = data.campaign.name;
}
// If no characters, create one
if (data.characters.length === 0) {
await fetch(`${BASE}/api/campaign/${id}/character?name=Eroe&char_class=guerriero`, { method: 'POST' });
const res2 = await fetch(`${BASE}/api/campaign/${id}`);
applyState(await res2.json());
}
} catch(e) {
chat.innerHTML = `<div class="msg error"><div class="msg-bubble">Errore: campagna #${id} non trovata</div></div>`;
loading.classList.remove('active');
return;
}
loading.classList.remove('active');
connectWS();
}
// ================================================================
// WebSocket
// ================================================================
function connectWS() {
if (ws) { ws.close(); ws = null; }
connIndicator.className = 'conn-status connecting';
ws = new WebSocket(`${WS_BASE}/ws/${campaignId}`);
ws.onopen = () => {
connIndicator.className = 'conn-status connected';
input.disabled = false;
btnSend.disabled = false;
input.focus();
if (reconnectTimer) { clearInterval(reconnectTimer); reconnectTimer = null; }
};
ws.onclose = () => {
connIndicator.className = 'conn-status disconnected';
input.disabled = true;
btnSend.disabled = true;
if (!reconnectTimer) {
reconnectTimer = setInterval(() => {
if (!ws || ws.readyState > 1) connectWS();
}, 3000);
}
};
ws.onerror = () => { ws.close(); };
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
handleMessage(msg);
} catch(e) { /* ignore malformed */ }
};
}
// ================================================================
// Message handler
// ================================================================
function handleMessage(msg) {
if (msg.type === 'pong') return;
if (msg.type === 'state') {
applyState(msg.campaign);
return;
}
if (msg.type === 'response') {
turn = msg.turn;
turnCounter.textContent = turn;
// Player action bubble (already shown optimistically)
// DM response
const div = document.createElement('div');
div.className = 'msg dm';
let bubble = `<div class="msg-label">🎲 DM · Turno ${turn}</div><div class="msg-bubble">`;
if (msg.narration) {
bubble += `<div class="msg-dm-narration">${escapeHtml(msg.narration)}</div>`;
}
if (msg.mechanics) {
bubble += `<div class="msg-dm-mechanics">${escapeHtml(msg.mechanics)}</div>`;
}
if (msg.tool) {
bubble += `<div class="msg-dm-tool">${escapeHtml(msg.tool)}</div>`;
}
bubble += '</div>';
div.innerHTML = bubble;
chat.appendChild(div);
scrollChat();
return;
}
if (msg.type === 'error') {
const div = document.createElement('div');
div.className = 'msg error';
div.innerHTML = `<div class="msg-bubble">${escapeHtml(msg.message)}</div>`;
chat.appendChild(div);
scrollChat();
}
}
function applyState(s) {
state = s;
if (s.turn !== undefined) {
turn = s.turn;
turnCounter.textContent = turn;
}
renderChar(s.characters);
renderQuests(s.quests);
renderAreas(s.areas);
}
// ================================================================
// Rendering
// ================================================================
function renderChar(chars) {
if (!chars || chars.length === 0) {
charDisplay.innerHTML = '<div class="empty-state">Nessun PG</div>';
return;
}
const c = chars[0];
const hpPct = Math.round((c.hp_current / c.hp_max) * 100);
charDisplay.innerHTML = `
<div class="char-name">${escapeHtml(c.name)}</div>
<div class="char-class">${c.char_class || 'Lv0'} · Livello ${c.level}</div>
<div class="hp-bar-wrapper">
<div class="hp-bar-bg"><div class="hp-bar-fill" style="width:${hpPct}%"></div></div>
<span class="hp-text">${c.hp_current}/${c.hp_max}</span>
</div>
<div class="stat-grid">
<div class="stat-row"><span class="stat-label">FOR</span><span class="stat-val">${c.str||'?'}</span></div>
<div class="stat-row"><span class="stat-label">AGI</span><span class="stat-val">${c.agi||'?'}</span></div>
<div class="stat-row"><span class="stat-label">STA</span><span class="stat-val">${c.sta||'?'}</span></div>
<div class="stat-row"><span class="stat-label">PER</span><span class="stat-val">${c.per||'?'}</span></div>
<div class="stat-row"><span class="stat-label">INT</span><span class="stat-val">${c.int||'?'}</span></div>
<div class="stat-row"><span class="stat-label">LCK</span><span class="stat-val">${c.lck||'?'}</span></div>
</div>
<div class="char-ac">Armatura: <span>${c.ac||'?'}</span></div>
<div class="char-weapon">Arma: <span>${c.weapon || 'nessuna'}</span></div>
`;
}
function renderQuests(quests) {
if (!quests || quests.length === 0) {
questDisplay.innerHTML = '<div class="empty-state">Nessuna missione</div>';
return;
}
questDisplay.innerHTML = quests.map(q => `
<div class="quest-item">
<div class="quest-name">${escapeHtml(q.name)} <span class="quest-status ${q.status}">${q.status}</span></div>
<div class="quest-desc">${escapeHtml(q.description || '')}</div>
</div>
`).join('');
}
function renderAreas(areas) {
if (!areas || areas.length === 0) {
areaDisplay.innerHTML = '<div class="empty-state">Nessuna area</div>';
return;
}
areaDisplay.innerHTML = areas.map(a => `
<div class="area-item ${a.visited ? 'visited' : ''}">
<span class="area-marker"></span>
<span>${escapeHtml(a.name)}</span>
</div>
${a.connections && a.connections.length ? `<div class="area-conns">→ ${a.connections.join(', ')}</div>` : ''}
`).join('');
}
// ================================================================
// Send action
// ================================================================
function sendAction() {
const text = input.value.trim();
if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
// Optimistic display
const div = document.createElement('div');
div.className = 'msg player';
div.innerHTML = `<div class="msg-label">⚔️ Azione</div><div class="msg-bubble">${escapeHtml(text)}</div>`;
chat.appendChild(div);
scrollChat();
ws.send(JSON.stringify({ type: 'action', payload: text }));
input.value = '';
input.focus();
}
btnSend.onclick = sendAction;
input.addEventListener('keydown', e => { if (e.key === 'Enter') sendAction(); });
// ================================================================
// Utils
// ================================================================
function escapeHtml(str) {
if (!str) return '';
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
function scrollChat() {
requestAnimationFrame(() => { chat.scrollTop = chat.scrollHeight; });
}
// ================================================================
// Init
// ================================================================
loadCampaignList();
// Check URL for ?campaign=X
const params = new URLSearchParams(window.location.search);
if (params.get('campaign')) {
joinCampaign(parseInt(params.get('campaign')));
}
</script>
</body>
</html>