From 2b2af058b8f4707b0bb77b5acf4848c0f1faa4a3 Mon Sep 17 00:00:00 2001 From: Thorsten Date: Tue, 1 Sep 2026 17:50:39 +0200 Subject: [PATCH] Add monster HP tracking, separate from the game-ending logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../d4b2c8a09e51_add_game_monsters.py | 32 ++++++++++++ backend/app/llm/orchestrator.py | 5 +- backend/app/llm/prompts/dm_system_prompt.txt | 1 + backend/app/llm/tools/monster.py | 50 +++++++++++++++++++ backend/app/models/game.py | 5 +- 5 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 backend/app/alembic/versions/d4b2c8a09e51_add_game_monsters.py create mode 100644 backend/app/llm/tools/monster.py diff --git a/backend/app/alembic/versions/d4b2c8a09e51_add_game_monsters.py b/backend/app/alembic/versions/d4b2c8a09e51_add_game_monsters.py new file mode 100644 index 0000000..04d5c67 --- /dev/null +++ b/backend/app/alembic/versions/d4b2c8a09e51_add_game_monsters.py @@ -0,0 +1,32 @@ +"""add game monsters (DM-only HP tracking) + +Revision ID: d4b2c8a09e51 +Revises: c9a1d5e7f316 +Create Date: 2026-09-01 17:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'd4b2c8a09e51' +down_revision: Union[str, None] = 'c9a1d5e7f316' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + 'games', + sa.Column( + 'monsters', postgresql.JSONB(astext_type=sa.Text()), nullable=False, + server_default='[]', + ), + ) + + +def downgrade() -> None: + op.drop_column('games', 'monsters') diff --git a/backend/app/llm/orchestrator.py b/backend/app/llm/orchestrator.py index 0941fef..31a47e5 100644 --- a/backend/app/llm/orchestrator.py +++ b/backend/app/llm/orchestrator.py @@ -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) diff --git a/backend/app/llm/prompts/dm_system_prompt.txt b/backend/app/llm/prompts/dm_system_prompt.txt index 5a6310f..d6e2957 100644 --- a/backend/app/llm/prompts/dm_system_prompt.txt +++ b/backend/app/llm/prompts/dm_system_prompt.txt @@ -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. diff --git a/backend/app/llm/tools/monster.py b/backend/app/llm/tools/monster.py new file mode 100644 index 0000000..539da4d --- /dev/null +++ b/backend/app/llm/tools/monster.py @@ -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} diff --git a/backend/app/models/game.py b/backend/app/models/game.py index 57b4a9e..1e18f97 100644 --- a/backend/app/models/game.py +++ b/backend/app/models/game.py @@ -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 )