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
+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}