feat(engine): persistenza enigmi, inventario strutturato, HP mostri
test / test (push) Has been cancelled

Fase 2 del remediation plan:
- set_puzzle_state / is_puzzle_solved per enigmi area
- update_inventory su character_items con add/remove/equip/list
- combat() legge HP da combat_state e persiste danno
- dm_cycle inietta campaign_id nei tool che ne hanno bisogno
- helpers db: dedup inventario, remove/equip per nome

Fase 4 del remediation plan:
- test integrazione dm_cycle con LLM mockato (5 test)
- test WebSocket handler con mock (4 test)
- CI minimo .gitea/workflows/test.yml
This commit is contained in:
truetype committed 2026-06-27 18:29:32 +02:00
1 parent cb45172717
commit 6b048dc0cc
7 files changed
+386 -18

No files matched your search

+67 -8
View File
@@ -415,7 +415,7 @@ def get_log(campaign_id: int, limit: int = 20) -> list[dict]:
return [dict(r) for r in rows]
def set_area_state(campaign_id: int, area_id: str, visited: bool = True, state: dict = {}) -> None:
def set_area_state(campaign_id: int, area_id: str, visited: bool = True, state: dict | None = None) -> None:
conn = get_conn()
conn.execute(
"""INSERT INTO adventure_state (campaign_id, area_id, visited, state)
@@ -423,11 +423,41 @@ def set_area_state(campaign_id: int, area_id: str, visited: bool = True, state:
ON CONFLICT(campaign_id, area_id) DO UPDATE SET
visited = excluded.visited,
state = excluded.state""",
(campaign_id, area_id, int(visited), json.dumps(state)),
(campaign_id, area_id, int(visited), json.dumps(state or {})),
)
conn.commit()
def set_puzzle_state(campaign_id: int, area_id: str, puzzle_id: str, solved: bool = True) -> None:
conn = get_conn()
row = conn.execute(
"SELECT state FROM adventure_state WHERE campaign_id = ? AND area_id = ?",
(campaign_id, area_id),
).fetchone()
state = json.loads(row["state"]) if row and row["state"] else {}
state[puzzle_id] = "solved" if solved else "unsolved"
conn.execute(
"""INSERT INTO adventure_state (campaign_id, area_id, visited, state)
VALUES (?, ?, 1, ?)
ON CONFLICT(campaign_id, area_id) DO UPDATE SET
state = excluded.state""",
(campaign_id, area_id, json.dumps(state)),
)
conn.commit()
def is_puzzle_solved(campaign_id: int, area_id: str, puzzle_id: str) -> bool:
conn = get_conn()
row = conn.execute(
"SELECT state FROM adventure_state WHERE campaign_id = ? AND area_id = ?",
(campaign_id, area_id),
).fetchone()
if not row or not row["state"]:
return False
state = json.loads(row["state"])
return state.get(puzzle_id) == "solved"
def get_area_state(campaign_id: int) -> list[dict]:
conn = get_conn()
rows = conn.execute(
@@ -533,6 +563,17 @@ def get_combat_state(campaign_id: int) -> list[dict]:
def add_character_item(character_id: int, name: str, item_def_id: str = "", quantity: int = 1) -> int:
conn = get_conn()
existing = conn.execute(
"SELECT id, quantity FROM character_items WHERE character_id = ? AND name = ?",
(character_id, name),
).fetchone()
if existing:
conn.execute(
"UPDATE character_items SET quantity = quantity + ? WHERE id = ?",
(quantity, existing["id"]),
)
conn.commit()
return existing["id"]
cur = conn.execute(
"INSERT INTO character_items (character_id, name, item_def_id, quantity) VALUES (?, ?, ?, ?)",
(character_id, name, item_def_id, quantity),
@@ -541,11 +582,23 @@ def add_character_item(character_id: int, name: str, item_def_id: str = "", quan
return cur.lastrowid or 0
def remove_character_item(item_id: int) -> bool:
def remove_character_item(character_id: int, name: str, quantity: int = 1) -> bool:
conn = get_conn()
conn.execute("DELETE FROM character_items WHERE id = ?", (item_id,))
existing = conn.execute(
"SELECT id, quantity FROM character_items WHERE character_id = ? AND name = ?",
(character_id, name),
).fetchone()
if not existing:
return False
if existing["quantity"] > quantity:
conn.execute(
"UPDATE character_items SET quantity = quantity - ? WHERE id = ?",
(quantity, existing["id"]),
)
else:
conn.execute("DELETE FROM character_items WHERE id = ?", (existing["id"],))
conn.commit()
return conn.total_changes > 0
return True
def get_character_items(character_id: int) -> list[dict]:
@@ -557,11 +610,17 @@ def get_character_items(character_id: int) -> list[dict]:
return [dict(r) for r in rows]
def equip_character_item(item_id: int, equipped: bool = True) -> bool:
def equip_character_item(character_id: int, name: str, equipped: bool = True) -> bool:
conn = get_conn()
conn.execute("UPDATE character_items SET equipped = ? WHERE id = ?", (int(equipped), item_id))
existing = conn.execute(
"SELECT id FROM character_items WHERE character_id = ? AND name = ?",
(character_id, name),
).fetchone()
if not existing:
return False
conn.execute("UPDATE character_items SET equipped = ? WHERE id = ?", (int(equipped), existing["id"]))
conn.commit()
return conn.total_changes > 0
return True
# ---------------------------------------------------------------------------
+4
View File
@@ -39,6 +39,10 @@ def dm_cycle(campaign_id: int, player_action: str) -> dict:
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", "")
+2 -1
View File
@@ -16,13 +16,14 @@ _KNOWN_TOOLS: dict[str, dict[str, str]] = {
"damage_die": "dado danno (default d8)",
"damage_bonus": "bonus danno (default 0)",
"mighty_deed_die": "dado deed guerriero (es. d3)",
"monster_id": "id mostro (per PF persistenti)",
"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"},
"update_inventory": {"character_id": "id pg", "item": "nome oggetto", "action": "add|remove|equip|unequip|list", "quantity": "quantità (default 1)"},
"award_xp": {"character_id": "id pg", "xp": "quantità PE"},
"dm_override": {"command": "narrate|set|force", "params": "parametri opzionali"},
}
+67 -9
View File
@@ -62,6 +62,8 @@ def combat(
mighty_deed_die: str = "",
luck_mod: int = 0,
armor_type: str = "none",
monster_id: str = "",
campaign_id: int = 0,
) -> ToolResult:
char = storage.get_character(character_id)
if not char:
@@ -71,7 +73,6 @@ def combat(
atk_roll = dice.roll("d20", modifier=attack_bonus)
raw_total = atk_roll["total"]
deed_roll = None
if mighty_deed_die:
deed_roll = dice.roll(mighty_deed_die)
@@ -81,6 +82,17 @@ def combat(
result_parts = []
crit_res = None
# HP persistenti: carica mostro da combat_state se disponibile
monster_name = ""
monster_hp_before = None
if monster_id and campaign_id:
state = storage.get_combat_state(campaign_id)
monster_state = next((m for m in state if m["monster_id"] == monster_id), None)
if monster_state:
monster_name = monster_state["monster_name"]
monster_hp_before = monster_state["current_hp"]
result_parts.append(f"{monster_name}: PF {monster_state['current_hp']}/{monster_state['max_hp']}")
if atk_roll["fumble"]:
fumble_die_name = storage.get_fumble_die(armor_type) or "d4"
fumble_roll = dice.roll(fumble_die_name)
@@ -101,6 +113,10 @@ def combat(
+ (crit_res["result"] if crit_res else "Nessun risultato trovato.")
)
# Calcolo danno e persistenza
total_dmg = 0
new_hp = None
monster_defeated = False
if hit and not atk_roll["fumble"]:
dmg = dice.roll(damage_die, modifier=damage_bonus)
crit_dmg_bonus = 0
@@ -108,6 +124,15 @@ def combat(
crit_dmg_bonus = _parse_extra_damage(crit_res.get("result", ""))
total_dmg = dmg["total"] + crit_dmg_bonus
result_parts.append(f"Colpito! Danni: {dmg['results']} + {crit_dmg_bonus} extra = {total_dmg}")
if monster_id and campaign_id and monster_hp_before is not None:
hp_result = storage.update_combat_hp(campaign_id, monster_id, total_dmg)
if hp_result:
new_hp = hp_result["new_hp"]
monster_defeated = hp_result["status"] == "defeated"
result_parts.append(f"PF {monster_name}: {monster_hp_before} → {new_hp}")
if monster_defeated:
result_parts.append(f"{monster_name} sconfitto!")
elif not atk_roll["fumble"]:
result_parts.append("Mancato!")
@@ -125,6 +150,9 @@ def combat(
"crit": atk_roll["crit"],
"fumble": atk_roll["fumble"],
"deed": deed_roll["total"] if deed_roll else None,
"monster_hp_before": monster_hp_before,
"monster_hp_after": new_hp,
"monster_defeated": monster_defeated,
"message": f"Iniziativa: {init_roll['total']} | Attacco d20+{attack_bonus}: {atk_roll['results'][0]}+{attack_bonus}={atk_roll['total']} vs CA {target_ac} | {'COLPI TO!' if hit else 'Mancato.'}{deed_msg} | " + " | ".join(result_parts),
}
@@ -273,25 +301,55 @@ def spellcasting(character_id: int, spell_name: str, spell_check: int, dc: int =
@tool
def update_inventory(character_id: int, item: str, action: str = "add") -> ToolResult:
def update_inventory(character_id: int, item: str = "", action: str = "add") -> ToolResult:
char = storage.get_character(character_id)
if not char:
return {"success": False, "tool": "update-inventory", "message": "Personaggio non trovato."}
inv = json.loads(char.get("equipment", "[]"))
if action == "add":
inv.append(item)
if not item:
return {"success": False, "tool": "update-inventory", "message": "Specificare un oggetto da aggiungere."}
storage.add_character_item(character_id, item)
elif action == "remove":
inv = [i for i in inv if i != item]
if not item:
return {"success": False, "tool": "update-inventory", "message": "Specificare un oggetto da rimuovere."}
if not storage.remove_character_item(character_id, item):
return {"success": False, "tool": "update-inventory", "message": f"Oggetto '{item}' non trovato."}
elif action == "equip":
if not item:
return {"success": False, "tool": "update-inventory", "message": "Specificare un oggetto da equipaggiare."}
if not storage.equip_character_item(character_id, item, True):
return {"success": False, "tool": "update-inventory", "message": f"Oggetto '{item}' non trovato."}
elif action == "unequip":
if not item:
return {"success": False, "tool": "update-inventory", "message": "Specificare un oggetto da rimuovere."}
if not storage.equip_character_item(character_id, item, False):
return {"success": False, "tool": "update-inventory", "message": f"Oggetto '{item}' non trovato."}
elif action == "list":
items = storage.get_character_items(character_id)
names = [f"{i['name']} x{i['quantity']}{' (eq.)' if i['equipped'] else ''}" for i in items]
return {
"success": True,
"tool": "update-inventory",
"action": action,
"inventory": [dict(i) for i in items],
"message": f"Inventario: {len(items)} oggetti — " + (", ".join(names) if names else "vuoto."),
}
else:
return {"success": False, "tool": "update-inventory", "message": f"Azione '{action}' non valida. Usa 'add' o 'remove'."}
storage.update_character(character_id, equipment=json.dumps(inv))
return {"success": False, "tool": "update-inventory", "message": f"Azione '{action}' non valida. Usa: add, remove, equip, unequip, list."}
# Sincronizza campo equipment legacy per backward compat
items = storage.get_character_items(character_id)
names = [i["name"] for i in items if i.get("quantity", 1) > 0]
storage.update_character(character_id, equipment=json.dumps(names))
return {
"success": True,
"tool": "update-inventory",
"action": action,
"item": item,
"inventory": inv,
"message": f"Inventario: {action} '{item}' → {len(inv)} oggetti.",
"inventory": [dict(i) for i in storage.get_character_items(character_id)],
"message": f"Inventario: {action} '{item}' → aggiornato.",
}