Add monster HP tracking, separate from the game-ending logic

Only the player character's HP was tracked before, so a monster's
death was never detected — only the DM's own (unenforced) narration
of it. Add a new update_monster_hp tool the DM calls whenever an
NPC/monster is introduced or takes damage, stored as DM-only
bookkeeping on Game (never exposed via GameRead, since HP/AC of NPCs
is meant to stay secret from players). The tool reports back
"defeated": true once HP drops to 0 so the DM can react to it — but
unlike a character dying, this does NOT auto-end the session; whether
a monster's death should end the game is a separate decision left to
the DM's own end_game call, or to a future rule.

Building an isolated test for this caught a real bug along the way:
the update mutated the matched monster's dict in place before
reassigning the list, which made SQLAlchemy's old-vs-new JSONB
comparison see identical content and silently skip writing the
change. Fixed by building fresh dicts instead of mutating shared ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thorsten
2026-09-01 17:50:39 +02:00
parent 8da834c5c1
commit 2b2af058b8
5 changed files with 91 additions and 2 deletions
+4 -1
View File
@@ -9,7 +9,7 @@ from app.config import settings
from app.llm.client import get_dm_system_prompt, get_llm_client
from app.llm.context import build_context
from app.llm.json_utils import fix_double_escaped_unicode
from app.llm.tools import character_sheet, dice, end_game
from app.llm.tools import character_sheet, dice, end_game, monster
from app.models.game import Game
from app.models.message import Message
from app.rag.retrieval import build_rag_block
@@ -23,6 +23,7 @@ TOOLS = [
{"type": "function", "function": dice.TOOL_SCHEMA},
{"type": "function", "function": character_sheet.TOOL_SCHEMA},
{"type": "function", "function": end_game.TOOL_SCHEMA},
{"type": "function", "function": monster.TOOL_SCHEMA},
]
@@ -38,6 +39,8 @@ async def _execute_tool_call(
return await character_sheet.upsert(session, game_id, tool_input)
if tool_name == "end_game":
return await end_game.end(session, game_id, tool_input)
if tool_name == "update_monster_hp":
return await monster.update(session, game_id, tool_input)
return {"error": f"Unknown tool {tool_name!r}"}
except Exception as exc: # noqa: BLE001
logger.warning("Tool call %s failed: %s", tool_name, exc)
@@ -22,6 +22,7 @@ Phase 2: Das Spiel leiten
- Keine Probe nötig (z. B. reine Bewegung, automatischer Erfolg): das darfst du auch explizit so sagen.
* Für Würfelproben: Nenne IMMER zuerst klar, was gewürfelt wird (Fähigkeit + SG, oder „Angriffswurf") – bevor gewürfelt wird. Würfle IMMER selbst über das roll_dice-Werkzeug, sofort, im selben Zug – fordere den Spieler NICHT auf, selbst zu würfeln oder dir ein Ergebnis zu nennen, und warte nicht auf eine Bestätigung. Das gilt ausnahmslos, außer ein Spieler hat ausdrücklich gesagt, dass er lieber mit eigenen physischen Würfeln spielt und dir die Ergebnisse selbst mitteilt (dann fragst du vorher, was er bevorzugt). Ohne eine solche Ansage würfelst du immer selbst, nie erfindest du ein Ergebnis. Erkläre das Ergebnis danach ausführlich in dieser Reihenfolge: (1) wogegen gewürfelt wurde (SG bei Proben; bei Angriffswürfen ohne die RK zu nennen), (2) gewürfeltes Ergebnis inklusive Bonus, (3) ob es reicht/trifft oder nicht – erst danach folgt die erzählerische Konsequenz.
* Trefferpunkte: Pflege current_hp/max_hp bei jeder Änderung (Schaden, Heilung, Erstellung) über upsert_character_sheet. Fällt ein Charakter auf 0 TP oder darunter, endet die Session automatisch serverseitig – erzähle das Ende dramaturgisch passend (Niederlage), aber erfinde danach keine Rettung oder Fortsetzung mehr.
* Monster/NPCs in einem Kampf: Pflege deren TP ebenso über update_monster_hp bei jeder Änderung (Auftauchen, Schaden). Das ist reines Hintergrund-Tracking, nie den Spielern zeigen (RK/TP bleiben geheim wie bisher). Meldet das Werkzeug "defeated": true, ist der Gegner besiegt – erzähle das entsprechend, das beendet aber nicht automatisch die Session (nutze dafür bei Bedarf end_game).
* Erreicht die Handlung einen echten Abschluss (Sieg, gelöste Haupthandlung eines One-Shots), rufe end_game mit einer kurzen Begründung auf. Nutze es nicht für bloße Szenenwechsel, sondern nur für ein echtes Ende der Geschichte.
* Behalte den Weltzustand im Blick: NPC-Namen, Orte, offene Handlungsstränge, Inventar und Fortschritt der Charaktere bleiben über die Sitzung(en) hinweg konsistent.
* Baue die Sitzung dramaturgisch auf: Einstieg, ansteigende Spannung, Höhepunkt, Auflösung – bei Kampagnen jede Sitzung mit einem Cliffhanger oder klaren Abschluss beenden.
+50
View File
@@ -0,0 +1,50 @@
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.game import Game
TOOL_SCHEMA = {
"name": "update_monster_hp",
"description": (
"Track an NPC's/monster's hit points during a fight — call this whenever one is introduced "
"or takes damage, so the server can detect when it's defeated. This is DM-only bookkeeping, "
"never shown to players; keep narrating combat and secrecy (e.g. AC) exactly as before."
),
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The monster's/NPC's name, used consistently across calls to identify it.",
},
"current_hp": {"type": "integer"},
"max_hp": {"type": "integer"},
},
"required": ["name", "current_hp", "max_hp"],
},
}
async def update(session: AsyncSession, game_id: uuid.UUID, tool_input: dict) -> dict:
game = await session.get(Game, game_id)
if game is None:
raise ValueError(f"Game {game_id} not found")
name = tool_input["name"]
current_hp = tool_input["current_hp"]
max_hp = tool_input["max_hp"]
# Build an entirely new list of new dicts rather than mutating existing ones in place:
# SQLAlchemy compares the old and new JSONB value by content to decide whether a flush is
# needed, and an in-place mutation makes the "old" value (still referenced by the attribute)
# look identical to the "new" one by the time of that comparison, so the UPDATE silently
# gets skipped.
updated_entry = {"name": name, "current_hp": current_hp, "max_hp": max_hp}
monsters = [dict(m) for m in (game.monsters or []) if m.get("name") != name]
monsters.append(updated_entry)
game.monsters = monsters
await session.commit()
return {"name": name, "current_hp": current_hp, "max_hp": max_hp, "defeated": current_hp <= 0}