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:
@@ -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}
|
||||
Reference in New Issue
Block a user