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:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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