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

+22
View File
@@ -0,0 +1,22 @@
name: test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install dependencies
run: |
python3 -m pip install --upgrade pip
pip install pytest pytest-asyncio httpx
- name: Run tests
run: |
PYTHONPATH=$(pwd)/src python3 -m pytest tests/ -v
- name: Verify DB init
run: |
PYTHONPATH=$(pwd)/src python3 -c "from src.engine import db; db.init_db()"
+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.",
}
+95
View File
@@ -0,0 +1,95 @@
"""Integration test: dm_cycle con LLM mockato."""
import json
import sys
sys.path.insert(0, "src")
from unittest.mock import patch
from src.engine import db, dm
MOCK_TOOL_RESP = json.dumps({
"tool": "roll_dice",
"params": {"die": "d20", "count": 1},
"narration": "Tendi l'orecchio nel buio...",
})
MOCK_COMBAT_RESP = json.dumps({
"tool": "combat",
"params": {
"character_id": 1,
"target_ac": 12,
"attack_bonus": 2,
"damage_die": "d8",
},
"narration": "Affronti il guardiano con la spada sguainata!",
})
def _setup_campaign():
conn = db.init_db()
camp_id = db.create_campaign("Test Integrazione", "Avventura")
char_id = db.create_character(camp_id, "Eroe", "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",
)
db.seed_adventure_state(camp_id)
return camp_id, char_id
@patch("src.engine.dm.llm_ask", return_value=MOCK_TOOL_RESP)
def test_dm_cycle_returns_tool_params_narration(mock_ask):
camp_id, _ = _setup_campaign()
out = dm.dm_cycle(camp_id, "Provo a sentire rumori")
assert "tool" in out
assert "params" in out
assert "narration" in out
assert out["tool"] == "roll_dice"
assert out["params"]["die"] == "d20"
assert "Tendi l'orecchio" in out["narration"]
@patch("src.engine.dm.llm_ask", return_value=MOCK_TOOL_RESP)
def test_dm_cycle_advances_turn(mock_ask):
camp_id, _ = _setup_campaign()
turn_before = db.get_campaign(camp_id)["turn"]
out = dm.dm_cycle(camp_id, "Provo a sentire rumori")
assert out["turn"] == turn_before + 1
assert db.get_campaign(camp_id)["turn"] == turn_before + 1
@patch("src.engine.dm.llm_ask", return_value=MOCK_TOOL_RESP)
def test_dm_cycle_writes_log(mock_ask):
camp_id, _ = _setup_campaign()
out = dm.dm_cycle(camp_id, "Provo a sentire rumori")
log = db.get_log(camp_id, limit=1)
assert len(log) >= 1
assert log[0]["action"] == "Provo a sentire rumori"
assert "Tendi l'orecchio" in log[0]["result"]
@patch("src.engine.dm.llm_ask", return_value=MOCK_COMBAT_RESP)
def test_dm_cycle_combat_injects_campaign_id(mock_ask):
camp_id, char_id = _setup_campaign()
# Replace character_id placeholder in mock with real ID
resp = json.loads(MOCK_COMBAT_RESP)
resp["params"]["character_id"] = char_id
with patch("src.engine.dm.llm_ask", return_value=json.dumps(resp)):
out = dm.dm_cycle(camp_id, "Attacco il guardiano")
assert out["tool"] == "combat"
assert out["params"].get("campaign_id") == camp_id
@patch("src.engine.dm.llm_ask", return_value="{}")
def test_dm_cycle_fallback_on_empty_json(mock_ask):
camp_id, _ = _setup_campaign()
out = dm.dm_cycle(camp_id, "saluto il guardiano")
# Fallback heuristic: should produce dm_override narrate
assert out["tool"] in ("combat", "dm_override")
assert out["full_output"]
+129
View File
@@ -0,0 +1,129 @@
"""WebSocket test: test handler logica via mock WS."""
import json
import sys
sys.path.insert(0, "src")
from unittest.mock import patch, AsyncMock
import pytest
from src.engine import db
MOCK_RESP = json.dumps({
"tool": "roll_dice",
"params": {"die": "d20", "count": 1},
"narration": "Tendi l'orecchio nel buio...",
})
def _setup_campaign():
conn = db.init_db()
camp_id = db.create_campaign("Test WS", "Avventura")
char_id = db.create_character(camp_id, "Eroe", "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",
)
db.seed_adventure_state(camp_id)
return camp_id
def _make_mock_ws():
ws = AsyncMock()
ws.send_json = AsyncMock()
ws.send_text = AsyncMock()
ws.close = AsyncMock()
ws.receive_text = AsyncMock()
return ws
@pytest.mark.asyncio
@patch("src.engine.dm.llm_ask", return_value=MOCK_RESP)
async def test_ws_handler_game_loop(mock_ask):
from src.web.server import game_ws
camp_id = _setup_campaign()
ws = _make_mock_ws()
ws.receive_text.side_effect = [
json.dumps({"type": "action", "payload": "Provo ad ascoltare"}),
Exception("WS chiuso"),
]
try:
await game_ws(ws, camp_id)
except Exception:
pass
ws.accept.assert_awaited_once()
calls = [c[0][0] for c in ws.send_json.call_args_list]
assert calls[0]["type"] == "state"
resp = calls[1]
assert resp["type"] == "response"
assert resp["tool"] == "roll_dice"
assert resp["turn"] == 1
assert calls[2]["type"] == "state"
assert calls[2]["campaign"]["turn"] == 1
@pytest.mark.asyncio
@patch("src.engine.dm.llm_ask", return_value=MOCK_RESP)
async def test_ws_handler_unknown_campaign(mock_ask):
from src.web.server import game_ws
ws = _make_mock_ws()
await game_ws(ws, 99999)
ws.accept.assert_awaited_once()
ws.send_json.assert_awaited_once_with(
{"type": "error", "message": "Campagna non trovata"}
)
ws.close.assert_awaited_once()
@pytest.mark.asyncio
@patch("src.engine.dm.llm_ask", return_value=MOCK_RESP)
async def test_ws_handler_plain_text(mock_ask):
from src.web.server import game_ws
camp_id = _setup_campaign()
ws = _make_mock_ws()
ws.receive_text.side_effect = [
"Attacco il guardiano!",
Exception("fine"),
]
try:
await game_ws(ws, camp_id)
except Exception:
pass
calls = [c[0][0] for c in ws.send_json.call_args_list]
assert calls[0]["type"] == "state"
assert calls[1]["type"] == "response"
@pytest.mark.asyncio
@patch("src.engine.dm.llm_ask", return_value=MOCK_RESP)
async def test_ws_handler_error_boundary(mock_ask):
from src.web.server import game_ws
camp_id = _setup_campaign()
ws = _make_mock_ws()
ws.receive_text.side_effect = [
json.dumps({"type": "action", "payload": "test"}),
Exception("fine"),
]
with patch("src.web.server.dm_cycle", side_effect=[ValueError("Test errore")]):
try:
await game_ws(ws, camp_id)
except Exception:
pass
calls = [c[0][0] for c in ws.send_json.call_args_list]
error_msgs = [c for c in calls if c.get("type") == "error"]
assert len(error_msgs) >= 1