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