DM speaks first automatically; stop asking who's playing

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 <noreply@anthropic.com>
This commit is contained in:
Thorsten
2026-09-02 14:04:42 +02:00
parent 07665595a2
commit 7bbdee868a
3 changed files with 70 additions and 33 deletions
+46 -24
View File
@@ -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: