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
@@ -2,7 +2,7 @@ import uuid
from datetime import datetime
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
@@ -24,6 +24,9 @@ class Game(Base):
)
status: Mapped[str] = mapped_column(String(length=20), nullable=False, default="active")
ended_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
# DM-only bookkeeping (name/current_hp/max_hp per monster) — never serialized to any
# API response, since HP/AC of NPCs is meant to stay secret from players.
monsters: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
created_at: Mapped[datetime] = mapped_column(
server_default=func.now(), nullable=False, index=True
)