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:
1 parent
ae73fe43d3
commit
90f823fdb9
17 files changed
+4082
No files matched your search
Whitespace-only changes.
@@ -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",
|
||||
]
|
||||
@@ -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")),
|
||||
)
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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)."""
|
||||
@@ -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}")
|
||||
@@ -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())
|
||||
@@ -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']}")
|
||||
Whitespace-only changes.
@@ -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()
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user