Files
DungeonsDragons/backend/app/llm/tools/link_existing_character.py
Thorsten cc680dc392 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>
2026-09-03 14:48:22 +02:00

87 lines
3.1 KiB
Python

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,
}