feat: AI Dungeon Master engine + web frontend

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

No files matched your search

+111
View File
@@ -0,0 +1,111 @@
from __future__ import annotations
import json
import re
from src.engine.config import load_from_env
from src.engine.providers import LLMProvider, create_provider
DEFAULT_MODEL = "gemma2:9b"
_KNOWN_TOOLS = {
"roll_dice": {"die": "str", "count": "int (default 1)", "modifier": "int (default 0)"},
"dice_chain": {"die": "str", "steps": "int"},
"combat": {"character_id": "int", "target_ac": "int", "attack_bonus": "int (default 0)",
"damage_die": "str (default d8)", "damage_bonus": "int (default 0)",
"mighty_deed_die": "str (default '')", "luck_mod": "int (default 0)",
"armor_type": "str (none/light/medium/heavy)"},
"ability_check": {"character_id": "int", "dc": "int", "modifier": "int (default 0)"},
"saving_throw": {"character_id": "int", "save_type": "str (Tempra/Riflessi/Volontà)", "dc": "int", "modifier": "int (default 0)"},
"movement": {"distance_m": "int", "terrain": "str (normale/difficile/molto difficile)", "stealth": "bool (default false)"},
"spellcasting": {"character_id": "int", "spell_name": "str", "spell_check": "int", "dc": "int (default 12)"},
"update_inventory": {"character_id": "int", "item": "str", "action": "str (add/remove)"},
"award_xp": {"character_id": "int", "xp": "int"},
"dm_override": {"command": "str", "params": "dict (opzionale)"},
}
SYSTEM_PROMPT = f"""Sei il Dungeon Master di una partita di DCC RPG (Dungeon Crawl Classics).
Il giocatore scrive un'azione e TU decidi quale strumento chiamare.
Strumenti disponibili:
{json.dumps(_KNOWN_TOOLS, indent=2)}
Regole:
1. Scegli SEMPRE UNO strumento per rispondere all'azione del giocatore.
2. I parametri DEVONO essere realistici per il contesto.
3. Se il giocatore fa qualcosa di narrativo, usa dm_override con command="narrate".
4. Tira sempre i dadi quando il giocatore tenta un'azione rischiosa.
5. Rispondi SOLO con JSON in questo formato:
{{"tool": "nome_tool", "params": {{...}}, "narration": "breve descrizione di cosa succede"}}
"""
_provider: LLMProvider | None = None
def get_provider() -> LLMProvider:
global _provider
if _provider is None:
_provider = create_provider(load_from_env())
return _provider
def set_provider(provider: LLMProvider) -> None:
global _provider
_provider = provider
def ask(prompt: str, system: str = SYSTEM_PROMPT) -> str:
return get_provider().ask(prompt, system=system)
def _extract_json(text: str) -> str | None:
for pat in [
r'```json\s*([\s\S]*?)```',
r'```\s*([\s\S]*?)```',
r'(\{[\s\S]*\})',
]:
m = re.search(pat, text)
if m:
candidate = m.group(1).strip()
try:
json.loads(candidate)
return candidate
except json.JSONDecodeError:
continue
return None
def parse_tool_call(text: str) -> dict:
text = text.strip()
blob = _extract_json(text)
if blob:
try:
data = json.loads(blob)
if "tool" in data:
return data
except json.JSONDecodeError:
pass
tool_match = re.search(r'(?:tool|strumento|chiamo|uso)\s*[":]*\s*(\w+)', text, re.I)
if tool_match:
return {"tool": tool_match.group(1), "params": {}, "narration": text[:200]}
return {"tool": "dm_override", "params": {"command": "narrate", "params": {"text": text[:200]}}, "narration": ""}
if __name__ == "__main__":
test_actions = [
"Tiro un d20 per vedere se sento rumori",
"Attacco l'orco con la mia ascia!",
"Esploro il corridoio buio",
]
for a in test_actions:
print(f"\n>>> Giocatore: {a}")
try:
resp = ask(a)
print(f"LLM: {resp[:300]}")
parsed = parse_tool_call(resp)
print(f"Parser: {json.dumps(parsed, indent=2)}")
except Exception as e:
print(f"ERRORE: {e}")