diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..cabd1c7 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -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()" diff --git a/src/engine/db.py b/src/engine/db.py index 53d0da8..246cd55 100644 --- a/src/engine/db.py +++ b/src/engine/db.py @@ -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 # --------------------------------------------------------------------------- diff --git a/src/engine/dm.py b/src/engine/dm.py index 9c0c504..f0b09db 100644 --- a/src/engine/dm.py +++ b/src/engine/dm.py @@ -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", "") diff --git a/src/engine/prompts.py b/src/engine/prompts.py index 199ac9d..132ef45 100644 --- a/src/engine/prompts.py +++ b/src/engine/prompts.py @@ -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"}, } diff --git a/src/engine/tools.py b/src/engine/tools.py index 3cda7f3..fb051c3 100644 --- a/src/engine/tools.py +++ b/src/engine/tools.py @@ -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.", } diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..9a4f180 --- /dev/null +++ b/tests/test_integration.py @@ -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"] diff --git a/tests/test_websocket.py b/tests/test_websocket.py new file mode 100644 index 0000000..639d305 --- /dev/null +++ b/tests/test_websocket.py @@ -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