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
@@ -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}")
|
||||
Reference in new issue
Block a user