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

+144
View File
@@ -0,0 +1,144 @@
from __future__ import annotations
import json
from typing import Any
from src.engine import db as db_module
from src.engine.state_machine import GameContext
_KNOWN_TOOLS: dict[str, dict[str, str]] = {
"roll_dice": {"die": "tipo dado (d3..d30, d%)", "count": "numero dadi (default 1)", "modifier": "modificatore (default 0)"},
"dice_chain": {"die": "dado iniziale", "steps": "passi nella catena dadi DCC"},
"combat": {
"character_id": "id pg",
"target_ac": "CA bersaglio",
"attack_bonus": "bonus attacco (default 0)",
"damage_die": "dado danno (default d8)",
"damage_bonus": "bonus danno (default 0)",
"mighty_deed_die": "dado deed guerriero (es. d3)",
"armor_type": "none/light/medium/heavy",
},
"ability_check": {"character_id": "id pg", "dc": "CD prova", "modifier": "bonus/penalità"},
"saving_throw": {"character_id": "id pg", "save_type": "Tempra|Riflessi|Volontà", "dc": "CD", "modifier": "bonus/penalità"},
"movement": {"distance_m": "metri", "terrain": "normale|difficile|molto difficile"},
"spellcasting": {"character_id": "id pg", "spell_name": "nome incantesimo", "spell_check": "valore tiro"},
"update_inventory": {"character_id": "id pg", "item": "nome oggetto", "action": "add|remove"},
"award_xp": {"character_id": "id pg", "xp": "quantità PE"},
"dm_override": {"command": "narrate|set|force", "params": "parametri opzionali"},
}
TOOL_EXAMPLES = """
Esempi:
- Attacco con spada → {"tool": "combat", "params": {"character_id": 1, "target_ac": 12, "attack_bonus": 0, "damage_die": "d8"}, "narration": "Sguaini la lama e colpisci il guardiano!"}
- Tiro d20 per sentire rumori → {"tool": "roll_dice", "params": {"die": "d20"}, "narration": "Tendi l'orecchio nel buio..."}
- Esploro la stanza → {"tool": "dm_override", "params": {"command": "narrate", "params": {"text": "La stanza è polverosa..."}}, "narration": "Scrutando nell'oscurità, noti..."}
- Prova di percezione CD 12 → {"tool": "ability_check", "params": {"character_id": 1, "dc": 12}, "narration": "Cerchi indizi nella stanza..."}
"""
SYSTEM_PROMPT = f"""Sei il Dungeon Master di DCC RPG (Dungeon Crawl Classics) — narrazione epico-oscura, tono sword & sorcery.
STILE:
- 2-3 frasi per scena. Non essere prolisso.
- Descrivi suoni, odori, sensazioni tattili.
- Usa ITALIANO.
- Dai sempre una scelta al giocatore alla fine.
- Mantieni il tono weird-fantasy di DCC: pericolo, meraviglia, corruzione.
MECCANICHE:
- CD base: 12 = facile, 15 = media, 18 = difficile
- Tiri salvezza: Tempra (veleno/malattia), Riflessi (trappole/esplosioni), Volontà (magia/paura)
- Combattimento: iniziativa (d20), attacco (d20 + bonus vs CA), danni (dado arma)
- Mighty Deed: i Guerrieri possono fare imprese leggendarie con d3/d4/d5 aggiuntivo
STRUMENTI DISPONIBILI:
{json.dumps(_KNOWN_TOOLS, indent=2, ensure_ascii=False)}
{TOOL_EXAMPLES}
FORMATO RISPOSTA — SOLO JSON, nient'altro:
{{"tool": "nome_strumento", "params": {{...}}, "narration": "descrizione narrativa"}}
Se l'azione è puramente narrativa (dialogo, esplorazione, interazione), usa dm_override con command="narrate".
"""
def _enrich_areas(ctx_areas: list[dict]) -> list[dict]:
area_map = {a["id"]: a for a in db_module.get_all_areas()}
enriched = []
for sa in ctx_areas:
aid = sa["area_id"]
full = area_map.get(aid, {})
enriched.append({
"area_id": aid,
"name": full.get("name", aid),
"description": full.get("description", ""),
"connections": full.get("connections", []),
"features": full.get("features", []),
"visited": sa.get("visited", 0),
"monsters": [m.get("ref_id", "") for m in full.get("monsters", [])],
})
return enriched
def format_context(ctx: GameContext) -> str:
lines = []
camp = ctx.campaign or {"name": "?", "description": ""}
lines.append(f"Campagna: {camp['name']}")
lines.append(f"Descrizione: {camp.get('description', '')}")
lines.append(f"Turno: {ctx.turn}")
lines.append("")
lines.append("PERSONAGGI:")
for c in ctx.characters:
bl = f" [{c['id']}] {c['name']} — Lv{c['level']} {c.get('char_class', '')} ({c.get('alignment', '?')})"
stat_str = f" For{c.get('str','?')} Agi{c.get('agi','?')} Sta{c.get('sta','?')} Per{c.get('per','?')} Int{c.get('int','?')} Luc{c.get('lck','?')}"
hp_str = f" PF {c['hp_current']}/{c['hp_max']} CA {c.get('ac', 10)} PE {c.get('xp', 0)}"
lines.extend([bl, stat_str, hp_str])
lines.append("")
if ctx.log:
lines.append("CRONOLOGIA (ultimi 5 turni):")
for entry in ctx.log[-5:]:
a = entry.get("action", "?")
r = entry.get("result", "")[:80]
lines.append(f" «{a}» → {r}")
lines.append("")
if ctx.quests:
lines.append("MISSIONI:")
for q in ctx.quests:
lines.append(f" [{q['status']}] {q['name']}")
lines.append("")
enriched = _enrich_areas(ctx.areas)
if enriched:
lines.append("LUOGHI DELL'AVVENTURA (tutte le aree):")
visited_ids = {a["area_id"] for a in ctx.areas if a.get("visited")}
for a in enriched:
status = "visitata" if a["area_id"] in visited_ids else "inesplorata"
lines.append(f" [{a['area_id']}] {a['name']} [{status}]")
if a["visited"] and a.get("description"):
lines.append(f" {a['description'][:120]}")
lines.append(f" Collegate: {', '.join(a['connections']) if a['connections'] else 'nessuna'}")
if a.get("features"):
lines.append(f" Elementi: {' | '.join(a['features'][:3])}")
if a.get("monsters"):
names = []
for mid in a["monsters"]:
m = db_module.get_monster_by_id(mid)
if m:
names.append(m["name"])
if names:
lines.append(f" Creature: {', '.join(names)}")
lines.append("")
return "\n".join(lines)
def build_prompt(ctx: GameContext, player_action: str, current_area_id: str = "") -> str:
area_block = ""
if current_area_id:
area_data = db_module.format_area_context(current_area_id)
if area_data:
area_block = f"\n\nAREA ATTUALE:\n{area_data}\n"
return f"""{format_context(ctx)}{area_block}---
AZIONE GIOCATORE: "{player_action}"
Rispondi con il JSON nel formato specificato (tool, params, narration)."""