LLM fixes: - _try_repair_json() — ripara JSON malformato (apici, virgole trailing) - _strip_json_from_text() — rimuove residui JSON dalla narration - parse_tool_call() — rimuove troncamento 200 caratteri dal fallback DM fixes: - Fallback euristico quando params vuoti (dm.py) - Estrazione narrazione da dm_override/narrate params.params.text Prompt (prompts.py): - Vietati termini tecnici/scientifici/anacronistici (HPLC, laser, ...) - Aggiunto vocabolario fantasy obbligatorio Server (server.py): - dm_cycle in ThreadPoolExecutor per non bloccare WS ping - WebSocketDisconnect gestiti - Rimosso import inesistente db_schema Frontend (index.html): - Nascosti tool name dalla chat - Personaggio -> PG, connessioni aree con nomi Infrastruttura: - Switch da openrouter/free a DeepSeek V4 Flash via OpenCode Go - pyproject.toml, Dockerfile, docker-compose.yml, start.sh - .env auto-load (start.sh) - Funnel Lv0 (funnel.py) — creazione PG livello 0 Docs: architecture, database, setup, game mechanics, codebase ref
131 lines
4.3 KiB
Python
131 lines
4.3 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from src.engine import db, tools
|
|
from src.engine.config import load_from_env
|
|
from src.engine.game_context import GameContext
|
|
from src.engine.heuristic_fallback import parse_action as fallback_parse
|
|
from src.engine.llm import ask as llm_ask, parse_tool_call
|
|
from src.engine.prompts import SYSTEM_PROMPT, build_prompt
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
def _llm_decision(ctx: GameContext, player_action: str, current_area_id: str = "") -> tuple[str, dict, str]:
|
|
prompt = build_prompt(ctx, player_action, current_area_id=current_area_id)
|
|
try:
|
|
raw = llm_ask(prompt, system=SYSTEM_PROMPT)
|
|
except Exception as e:
|
|
log.warning("LLM call failed (%s), fallback a heuristic parser", e)
|
|
tool, params = fallback_parse(player_action, ctx.characters)
|
|
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 = fallback_parse(player_action, ctx.characters)
|
|
|
|
# Se il JSON era malformato e params sono vuoti, usa l'euristico per i parametri
|
|
elif not params and tool not in ("dm_override",):
|
|
log.info("Tool %s senza params, integro con fallback euristico", tool)
|
|
_, fallback_params = fallback_parse(player_action, ctx.characters)
|
|
params = fallback_params
|
|
|
|
# dm_override/narrate: il LLM spesso mette il testo in params.params.text
|
|
# invece che nel campo narration JSON — lo estraiamo qui
|
|
if tool == "dm_override" and not narration:
|
|
inner = params.get("params", {}) if isinstance(params, dict) else {}
|
|
if isinstance(inner, dict):
|
|
text = inner.get("text", "")
|
|
if text:
|
|
narration = text
|
|
|
|
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, current_area_id=ctx.current_area_id)
|
|
|
|
# Inietta campaign_id per strumenti che necessitano persistenza
|
|
if tool_name == "combat":
|
|
tool_params.setdefault("campaign_id", campaign_id)
|
|
|
|
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)
|