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