Let the DM reuse an existing character instead of re-creating it

Characters are already owned by the player, not the game, and were
meant to be reusable — but the DM had no way to actually retrieve one.
upsert_character_sheet is write-only, so telling the DM "I'll bring my
character Tillo Eichenherz" led it to create a second, blank
"Tillo Eichenherz" (empty stats, thinner ability text) instead of
reusing the real one, then ask for all six ability scores again as if
nothing existed.

Two fixes:

1. New link_existing_character tool: given a player name and a
   character name, looks it up among that player's own characters
   (ownership enforced — never someone else's) and links it to
   game_participants.character_id, returning the full sheet so the DM
   can continue with real data immediately. System prompt now tells
   the DM to call this the moment a player names a previously-played
   character, instead of asking for stats.

2. run_dm_turn now auto-injects the full sheet of every already-linked
   character into the system prompt every turn (name, stats, combat
   stats, HP, abilities, equipment, background), with an explicit
   "already exists, don't ask again" instruction — this is the actual
   root-cause fix: even a character linked some other way (the
   existing join-with-character_id flow, or a future UI) now stays
   visible to the DM turn after turn instead of only living in
   whatever's left of the truncated chat window.

Also manually repaired the user's real "Schwarze Segel" game, which
had already hit this bug — re-pointed its participant back to the
real Tillo Eichenherz instead of the accidental duplicate (left the
duplicate in place rather than deleting data; it's a normal delete
away via the character-delete feature if unwanted).

Verified live against x.ai: a fresh game where the player said "Ich
nehme meinen alten Charakter Tillo Eichenherz" linked to the real
character on the first message (no duplicate created — confirmed by
row count), and a follow-up question about AC/Strength got the exact
real values (RK 18, Stärke 15) pulled from the injected sheet, not
re-asked or invented.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thorsten
2026-09-03 14:48:22 +02:00
parent f5c62d27fd
commit cc680dc392
3 changed files with 131 additions and 2 deletions
+43 -1
View File
@@ -10,7 +10,8 @@ from app.config import settings
from app.llm.client import get_dm_system_prompt, get_llm_client from app.llm.client import get_dm_system_prompt, get_llm_client
from app.llm.context import build_context from app.llm.context import build_context
from app.llm.json_utils import fix_double_escaped_unicode from app.llm.json_utils import fix_double_escaped_unicode
from app.llm.tools import character_sheet, dice, end_game, monster, world_state from app.llm.tools import character_sheet, dice, end_game, link_existing_character, monster, world_state
from app.models.character import Character
from app.models.game import Game, GameParticipant from app.models.game import Game, GameParticipant
from app.models.message import Message from app.models.message import Message
from app.models.user import User from app.models.user import User
@@ -28,6 +29,7 @@ TOOLS = [
{"type": "function", "function": end_game.TOOL_SCHEMA}, {"type": "function", "function": end_game.TOOL_SCHEMA},
{"type": "function", "function": monster.TOOL_SCHEMA}, {"type": "function", "function": monster.TOOL_SCHEMA},
{"type": "function", "function": world_state.TOOL_SCHEMA}, {"type": "function", "function": world_state.TOOL_SCHEMA},
{"type": "function", "function": link_existing_character.TOOL_SCHEMA},
] ]
@@ -47,6 +49,8 @@ async def _execute_tool_call(
return await monster.update(session, game_id, tool_input) return await monster.update(session, game_id, tool_input)
if tool_name == "update_world_state": if tool_name == "update_world_state":
return await world_state.update(session, game_id, tool_input) return await world_state.update(session, game_id, tool_input)
if tool_name == "link_existing_character":
return await link_existing_character.link(session, game_id, tool_input)
return {"error": f"Unknown tool {tool_name!r}"} return {"error": f"Unknown tool {tool_name!r}"}
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning("Tool call %s failed: %s", tool_name, exc) logger.warning("Tool call %s failed: %s", tool_name, exc)
@@ -82,6 +86,29 @@ async def _request_player_roll(
} }
def _format_character_sheet(character: Character) -> str:
parts = [
f"- {character.name} ({character.race or '?'} {character.char_class or '?'}, Stufe {character.level})"
]
if character.stats:
parts.append(f" Werte: {character.stats}")
hp = None
if character.current_hp is not None or character.max_hp is not None:
hp = f"{character.current_hp if character.current_hp is not None else '?'}/{character.max_hp if character.max_hp is not None else '?'}"
if hp:
parts.append(f" TP: {hp}")
if character.combat_stats:
parts.append(f" Kampfwerte: {character.combat_stats}")
if character.abilities:
ability_names = ", ".join(a.get("name", "") for a in character.abilities if isinstance(a, dict))
parts.append(f" Fähigkeiten: {ability_names}")
if character.equipment:
parts.append(f" Ausrüstung: {', '.join(character.equipment)}")
if character.description:
parts.append(f" Hintergrund: {character.description}")
return "\n".join(parts)
async def _check_hp_game_over(session: AsyncSession, game_id: uuid.UUID, result: dict) -> str | None: async def _check_hp_game_over(session: AsyncSession, game_id: uuid.UUID, result: dict) -> str | None:
"""After an upsert_character_sheet call, auto-ends the game if the character it touched """After an upsert_character_sheet call, auto-ends the game if the character it touched
dropped to 0 HP or below. Returns the end reason if the game was just ended, else None.""" dropped to 0 HP or below. Returns the end reason if the game was just ended, else None."""
@@ -159,6 +186,21 @@ async def run_dm_turn(
"über den Einladungslink dazukommen; du erkennst sie automatisch am Namen, sobald sie schreiben." "über den Einladungslink dazukommen; du erkennst sie automatisch am Namen, sobald sie schreiben."
) )
linked_characters = (
await session.execute(
select(Character)
.join(GameParticipant, GameParticipant.character_id == Character.id)
.where(GameParticipant.game_id == game_id)
)
).scalars().all()
if linked_characters:
sheets = "\n".join(_format_character_sheet(c) for c in linked_characters)
system_prompt += (
"\n\nBekannte Charakterblätter (bereits vollständig vorhanden — NICHT erneut nach "
"Werten/Ausrüstung/Hintergrund fragen, nur bei tatsächlichen Änderungen über "
f"upsert_character_sheet aktualisieren):\n{sheets}"
)
await world_state.refresh_if_due(session, game_id, client, settings.dm_model) await world_state.refresh_if_due(session, game_id, client, settings.dm_model)
world_summary = await world_state.get_summary(session, game_id) world_summary = await world_state.get_summary(session, game_id)
if world_summary: if world_summary:
+2 -1
View File
@@ -4,7 +4,7 @@ Phase 1: Session 0 (Vorbereitung)
Sobald du in ein neues Spiel geholt wirst, meldest du dich SOFORT von dir aus mit einer Begrüßung — warte nie erst auf eine Spielernachricht. Lernst du deine Spieler aktiv kennen. Prüfe zuerst die Spiel-Rahmendaten weiter unten (Name und Beschreibung, die der Ersteller beim Anlegen des Spiels bereits festgelegt hat, sowie die Liste der bereits beigetretenen Spieler) – Punkte, die dort schon beantwortet sind (z. B. Genre/Welt, Umfang, ggf. Erfahrungsstand, wer dabei ist), fragst du NICHT erneut ab, sondern bestätigst sie höchstens knapp im Gespräch. Wer mitspielt, ergibt sich ausschließlich aus den beigetretenen Spielern (Login/Einladungslink) – frage nie aktiv nach Anzahl oder Namen der Mitspieler; die Gruppe kann auch im Lauf der Sitzung noch wachsen. Stelle nur zu den noch offenen Punkten gezielt Fragen, eine nach der anderen, nicht alle auf einmal, an die Spieler, die bereits da sind: Sobald du in ein neues Spiel geholt wirst, meldest du dich SOFORT von dir aus mit einer Begrüßung — warte nie erst auf eine Spielernachricht. Lernst du deine Spieler aktiv kennen. Prüfe zuerst die Spiel-Rahmendaten weiter unten (Name und Beschreibung, die der Ersteller beim Anlegen des Spiels bereits festgelegt hat, sowie die Liste der bereits beigetretenen Spieler) – Punkte, die dort schon beantwortet sind (z. B. Genre/Welt, Umfang, ggf. Erfahrungsstand, wer dabei ist), fragst du NICHT erneut ab, sondern bestätigst sie höchstens knapp im Gespräch. Wer mitspielt, ergibt sich ausschließlich aus den beigetretenen Spielern (Login/Einladungslink) – frage nie aktiv nach Anzahl oder Namen der Mitspieler; die Gruppe kann auch im Lauf der Sitzung noch wachsen. Stelle nur zu den noch offenen Punkten gezielt Fragen, eine nach der anderen, nicht alle auf einmal, an die Spieler, die bereits da sind:
1. Erfahrungsstand: Komplette Anfänger, Gelegenheitsspieler oder Regelkenner? Frage das pro Spieler ab, falls die Gruppe gemischt ist. 1. Erfahrungsstand: Komplette Anfänger, Gelegenheitsspieler oder Regelkenner? Frage das pro Spieler ab, falls die Gruppe gemischt ist.
2. Charaktere: Wollen sie eigene, fertige Charaktere mitbringen (Name, Klasse, Rasse, Stats) oder sollst du sie durch die Charaktererstellung führen? 2. Charaktere: Wollen sie einen Charakter aus einem früheren Spiel wiederverwenden, einen komplett neuen, fertigen Charakter mitbringen (Name, Klasse, Rasse, Stats), oder sollst du sie durch die Charaktererstellung führen? Nennt jemand einen Charakternamen, den er schon einmal gespielt hat, rufe SOFORT link_existing_character auf, statt nach Werten zu fragen — das Werkzeug liefert dir das komplette vorhandene Blatt zurück (oder meldet, dass es keinen Treffer gibt, dann fragt du nach oder legt neu an).
3. Regelkenntnis: Brauchen sie Erklärungen zu Grundmechaniken (Würfelwürfe, Angriffe, Rettungswürfe) oder reicht ein knapper Hinweis? 3. Regelkenntnis: Brauchen sie Erklärungen zu Grundmechaniken (Würfelwürfe, Angriffe, Rettungswürfe) oder reicht ein knapper Hinweis?
4. Setting/Welt: Vorgefertigte Welt (z. B. Forgotten Realms), eine von dir erfundene Welt, oder wünschen sie sich ein bestimmtes Thema/Genre (High Fantasy, düster, humorvoll, Horror, Piraten, ...)? 4. Setting/Welt: Vorgefertigte Welt (z. B. Forgotten Realms), eine von dir erfundene Welt, oder wünschen sie sich ein bestimmtes Thema/Genre (High Fantasy, düster, humorvoll, Horror, Piraten, ...)?
5. Umfang: Einzelnes One-Shot-Abenteuer (eine Sitzung) oder eine längere Kampagne über mehrere Sitzungen? Grobe Zeit, die sie investieren wollen? 5. Umfang: Einzelnes One-Shot-Abenteuer (eine Sitzung) oder eine längere Kampagne über mehrere Sitzungen? Grobe Zeit, die sie investieren wollen?
@@ -20,6 +20,7 @@ Phase 2: Das Spiel leiten
- Angriffswurf gegen die Rüstungsklasse eines Gegners: die RK ist geheimes DM-Wissen und wird nicht verraten, aber sag ausdrücklich, dass es ein Angriffswurf ist (z. B. „A) Angriff mit dem Streitkolben (Angriffswurf)"), damit klar ist, dass trotzdem gewürfelt wird. - Angriffswurf gegen die Rüstungsklasse eines Gegners: die RK ist geheimes DM-Wissen und wird nicht verraten, aber sag ausdrücklich, dass es ein Angriffswurf ist (z. B. „A) Angriff mit dem Streitkolben (Angriffswurf)"), damit klar ist, dass trotzdem gewürfelt wird.
- Keine Probe nötig (z. B. reine Bewegung, automatischer Erfolg): das darfst du auch explizit so sagen. - 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. Rufe dafür IMMER das roll_dice-Werkzeug auf, sofort, im selben Zug – erfinde nie selbst ein Ergebnis und frage nicht erst, ob du würfeln sollst. Manche Spieler würfeln lieber mit eigenen physischen Würfeln (per Schalter in der Oberfläche eingestellt, nicht von dir gesteuert) – das erkennst du automatisch am Ergebnis von roll_dice: liefert es eine Zahl, erkläre sie sofort wie gewohnt; meldet es stattdessen "awaiting_player_roll", folge exakt der Anweisung im "note"-Feld (nenne die Notation, warte auf die Zahl in einer künftigen Nachricht, würfle nicht selbst). Erkläre ein echtes 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. * Für Würfelproben: Nenne IMMER zuerst klar, was gewürfelt wird (Fähigkeit + SG, oder „Angriffswurf") – bevor gewürfelt wird. Rufe dafür IMMER das roll_dice-Werkzeug auf, sofort, im selben Zug – erfinde nie selbst ein Ergebnis und frage nicht erst, ob du würfeln sollst. Manche Spieler würfeln lieber mit eigenen physischen Würfeln (per Schalter in der Oberfläche eingestellt, nicht von dir gesteuert) – das erkennst du automatisch am Ergebnis von roll_dice: liefert es eine Zahl, erkläre sie sofort wie gewohnt; meldet es stattdessen "awaiting_player_roll", folge exakt der Anweisung im "note"-Feld (nenne die Notation, warte auf die Zahl in einer künftigen Nachricht, würfle nicht selbst). Erkläre ein echtes 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.
* Stehen unten "Bekannte Charakterblätter", sind diese Charaktere bereits vollständig vorhanden (z. B. aus einem früheren Spiel wiederverwendet) — frag NICHT erneut nach Werten, Ausrüstung oder Hintergrund, nutze die Daten direkt und aktualisiere nur bei echten Änderungen über upsert_character_sheet.
* 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. * 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). * 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. * 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.
@@ -0,0 +1,86 @@
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.character import Character
from app.models.game import GameParticipant
from app.models.user import User
TOOL_SCHEMA = {
"name": "link_existing_character",
"description": (
"Call this when a player wants to bring a character they already created in a previous "
"game, identified by name — instead of building a new one. Looks the character up among "
"that player's own characters and links it to this game. Returns the full existing sheet "
"(stats, combat stats, abilities, equipment, backstory) so you can continue with real data "
"immediately instead of asking for it again. Do NOT use this to create a new character — "
"use upsert_character_sheet for that."
),
"parameters": {
"type": "object",
"properties": {
"player_name": {
"type": "string",
"description": "The real first name of the player this character belongs to, exactly as they introduced themselves.",
},
"character_name": {
"type": "string",
"description": "The character's name, as the player stated it.",
},
},
"required": ["player_name", "character_name"],
},
}
async def link(session: AsyncSession, game_id: uuid.UUID, tool_input: dict) -> dict:
player_name = tool_input["player_name"]
character_name = tool_input["character_name"]
participant_row = (
await session.execute(
select(GameParticipant, User)
.join(User, User.id == GameParticipant.user_id)
.where(GameParticipant.game_id == game_id, User.name == player_name)
)
).first()
if participant_row is None:
raise ValueError(f"No participant named {player_name!r} found in this game")
participant, user = participant_row
# Only ever search the requesting player's own characters — never let the DM link a
# character belonging to someone else, even if the name happens to match.
candidates = (
await session.execute(select(Character).where(Character.owner_id == user.id))
).scalars().all()
match = next(
(c for c in candidates if c.name.strip().lower() == character_name.strip().lower()), None
)
if match is None:
return {
"found": False,
"message": (
f"No character named {character_name!r} found for {player_name}. Ask them to "
"double-check the name, or offer to create a new character instead."
),
}
participant.character_id = match.id
await session.commit()
return {
"found": True,
"character_id": str(match.id),
"name": match.name,
"race": match.race,
"char_class": match.char_class,
"level": match.level,
"stats": match.stats,
"combat_stats": match.combat_stats,
"current_hp": match.current_hp,
"max_hp": match.max_hp,
"abilities": match.abilities,
"equipment": match.equipment,
"description": match.description,
}