2b2af058b8
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>
51 lines
1.9 KiB
Python
51 lines
1.9 KiB
Python
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}
|