From 7bbdee868af9910d539389e9420aabb129f8fc08 Mon Sep 17 00:00:00 2001 From: Thorsten Date: Wed, 2 Sep 2026 14:04:42 +0200 Subject: [PATCH] DM speaks first automatically; stop asking who's playing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues from live testing: (1) the DM only ever spoke after a player sent something, so starting a fresh game meant typing a throwaway message like "start" just to get it going; (2) Session 0 asked for player headcount and names even though that's already known — everyone who wants in has already logged in and joined via the invite link/code, tracked in game_participants. Fix 1: ws_game.py now runs a DM turn immediately on the first-ever WebSocket connection to a game with zero messages, before entering the normal receive loop — guarded by the game's existing lock plus a "does this game have messages yet" check, so two tabs opening around the same time can't produce two greetings. Extracted the run-turn-then-broadcast logic (typing/rolling/game_ended/message) into _run_and_broadcast_dm_turn, shared between the kickoff and the normal per-message path instead of duplicated. Fix 2: orchestrator.run_dm_turn() now queries game_participants and injects the actual joined-player names into the system prompt alongside the game's name/description. dm_system_prompt.txt drops the "ask for player count and names" question entirely and instructs the DM to greet whoever's already listed instead, since more players can join mid-session and get recognized automatically once they speak. Verified live: a freshly created game showed "Dungeon Master schreibt …" and then a real opening message addressed to "Thorsten" by name before any player message existed, skipped straight to the character question, and reloading the page didn't produce a duplicate greeting. Co-Authored-By: Claude Sonnet 5 --- backend/app/api/ws_game.py | 70 +++++++++++++------- backend/app/llm/orchestrator.py | 18 ++++- backend/app/llm/prompts/dm_system_prompt.txt | 15 ++--- 3 files changed, 70 insertions(+), 33 deletions(-) diff --git a/backend/app/api/ws_game.py b/backend/app/api/ws_game.py index 77bdaea..2b44a3b 100644 --- a/backend/app/api/ws_game.py +++ b/backend/app/api/ws_game.py @@ -4,6 +4,7 @@ import uuid from fastapi import APIRouter, WebSocket, WebSocketDisconnect from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession from app.db import async_session_maker from app.llm.orchestrator import run_dm_turn @@ -11,6 +12,8 @@ from app.models.game import GameParticipant from app.models.message import Message from app.ws_tickets import consume_ticket +KICKOFF_QUERY = "Beginne die Sitzung und begrüße die anwesenden Spieler." + logger = logging.getLogger("app.ws_game") router = APIRouter() @@ -70,6 +73,36 @@ async def _serialize_message(session, message: Message) -> dict: } +async def _run_and_broadcast_dm_turn( + session: AsyncSession, game_id: uuid.UUID, latest_player_message: str +) -> None: + await manager.broadcast(game_id, {"type": "typing"}) + + async def _broadcast_roll(notation: str) -> None: + await manager.broadcast(game_id, {"type": "rolling", "notation": notation}) + + async def _broadcast_game_ended(reason: str) -> None: + await manager.broadcast(game_id, {"type": "game_ended", "reason": reason}) + + try: + dm_message = await run_dm_turn( + session, + game_id, + latest_player_message=latest_player_message, + on_roll=_broadcast_roll, + on_game_ended=_broadcast_game_ended, + ) + except Exception: # noqa: BLE001 + logger.exception("DM turn failed for game %s", game_id) + await manager.broadcast(game_id, {"type": "error", "detail": "dm_turn_failed"}) + return + + await manager.broadcast( + game_id, + {"type": "message", "message": await _serialize_message(session, dm_message)}, + ) + + @router.websocket("/ws/games/{game_id}") async def game_websocket(websocket: WebSocket, game_id: uuid.UUID, ticket: str) -> None: user_id = consume_ticket(ticket, game_id) @@ -80,6 +113,18 @@ async def game_websocket(websocket: WebSocket, game_id: uuid.UUID, ticket: str) await websocket.accept() manager.connect(game_id, websocket) + # First-ever connection to a fresh game: the DM speaks first, unprompted, instead of + # waiting for a player to type something just to kick things off. Guarded by the game's + # lock + a "does this game have any messages yet" check so concurrent connections (e.g. two + # tabs opening around the same time) can't trigger this twice. + async with manager.get_lock(game_id): + async with async_session_maker() as session: + has_messages = ( + await session.execute(select(Message.id).where(Message.game_id == game_id).limit(1)) + ).first() is not None + if not has_messages: + await _run_and_broadcast_dm_turn(session, game_id, KICKOFF_QUERY) + try: while True: data = await websocket.receive_json() @@ -118,31 +163,8 @@ async def game_websocket(websocket: WebSocket, game_id: uuid.UUID, ticket: str) game_id, {"type": "message", "message": await _serialize_message(session, player_message)}, ) - await manager.broadcast(game_id, {"type": "typing"}) - async def _broadcast_roll(notation: str) -> None: - await manager.broadcast(game_id, {"type": "rolling", "notation": notation}) - - async def _broadcast_game_ended(reason: str) -> None: - await manager.broadcast(game_id, {"type": "game_ended", "reason": reason}) - - try: - dm_message = await run_dm_turn( - session, - game_id, - latest_player_message=content, - on_roll=_broadcast_roll, - on_game_ended=_broadcast_game_ended, - ) - except Exception: # noqa: BLE001 - logger.exception("DM turn failed for game %s", game_id) - await manager.broadcast(game_id, {"type": "error", "detail": "dm_turn_failed"}) - continue - - await manager.broadcast( - game_id, - {"type": "message", "message": await _serialize_message(session, dm_message)}, - ) + await _run_and_broadcast_dm_turn(session, game_id, content) except WebSocketDisconnect: pass finally: diff --git a/backend/app/llm/orchestrator.py b/backend/app/llm/orchestrator.py index 6bc4cbd..e099d8b 100644 --- a/backend/app/llm/orchestrator.py +++ b/backend/app/llm/orchestrator.py @@ -3,6 +3,7 @@ import logging import uuid from collections.abc import Awaitable, Callable +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -10,8 +11,9 @@ 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, monster, world_state -from app.models.game import Game +from app.models.game import Game, GameParticipant from app.models.message import Message +from app.models.user import User from app.rag.adventure_retrieval import build_adventure_rag_block from app.rag.retrieval import build_rag_block @@ -89,6 +91,20 @@ async def run_dm_turn( f"Name: {game.name}\nBeschreibung: {game.description}" ) + participant_names = ( + await session.execute( + select(User.name) + .join(GameParticipant, GameParticipant.user_id == User.id) + .where(GameParticipant.game_id == game_id) + ) + ).scalars().all() + if participant_names: + system_prompt += ( + "\n\nBereits beigetretene Spieler (über Login/Einladungslink — NICHT nach Anzahl " + "oder Namen fragen): " + ", ".join(participant_names) + ". Weitere können jederzeit " + "über den Einladungslink dazukommen; du erkennst sie automatisch am Namen, sobald sie schreiben." + ) + await world_state.refresh_if_due(session, game_id, client, settings.dm_model) world_summary = await world_state.get_summary(session, game_id) if world_summary: diff --git a/backend/app/llm/prompts/dm_system_prompt.txt b/backend/app/llm/prompts/dm_system_prompt.txt index c7a11ff..6189e63 100644 --- a/backend/app/llm/prompts/dm_system_prompt.txt +++ b/backend/app/llm/prompts/dm_system_prompt.txt @@ -1,15 +1,14 @@ Rolle Du bist ein erfahrener D&D Dungeon Master und Regelexperte (Standard: D&D 5e, sofern nichts anderes vereinbart wird). Du erschaffst lebendige Abenteuer, leitest die Spieler souverän durch die Sitzung und sorgst für ein rundes Spielerlebnis – von der ersten Frage bis zum Abspann der Session. Phase 1: Session 0 (Vorbereitung) -Bevor das Abenteuer beginnt, 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) – Punkte, die dort schon beantwortet sind (z. B. Genre/Welt, Umfang, ggf. Erfahrungsstand), fragst du NICHT erneut ab, sondern bestätigst sie höchstens knapp im Gespräch. Stelle nur zu den noch offenen Punkten gezielt Fragen, eine nach der anderen, nicht alle auf einmal: +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. Anzahl der Spieler und ihre Vornamen. -2. Erfahrungsstand: Komplette Anfänger, Gelegenheitsspieler oder Regelkenner? Frage das pro Spieler ab, falls die Gruppe gemischt ist. -3. Charaktere: Wollen sie eigene, fertige Charaktere mitbringen (Name, Klasse, Rasse, Stats) oder sollst du sie durch die Charaktererstellung führen? -4. Regelkenntnis: Brauchen sie Erklärungen zu Grundmechaniken (Würfelwürfe, Angriffe, Rettungswürfe) oder reicht ein knapper Hinweis? -5. 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, ...)? -6. Umfang: Einzelnes One-Shot-Abenteuer (eine Sitzung) oder eine längere Kampagne über mehrere Sitzungen? Grobe Zeit, die sie investieren wollen? -7. Inhaltliche Grenzen: Gibt es Themen, die sie im Spiel nicht wollen (Triggerwarnungen, Gewaltlevel, etc.)? +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? +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, ...)? +5. Umfang: Einzelnes One-Shot-Abenteuer (eine Sitzung) oder eine längere Kampagne über mehrere Sitzungen? Grobe Zeit, die sie investieren wollen? +6. Inhaltliche Grenzen: Gibt es Themen, die sie im Spiel nicht wollen (Triggerwarnungen, Gewaltlevel, etc.)? Passe deinen Hilfe-Level dynamisch an: Anfängern erklärst du Mechaniken proaktiv und schlägst Optionen vor ("Du kannst jetzt A, B oder C tun"). Erfahrenen Spielern gibst du nur auf Nachfrage Regeldetails und lässt ihnen mehr erzählerische Freiheit. Phase 2: Das Spiel leiten