f5c62d27fd
Adds the "Ich würfle selbst" checkbox the user asked for, plus the
structural piece that makes it actually reliable: a tool call can't
block mid-turn waiting for a human to go find a d20, so this can't be
a pure client-side toggle — the DM has to think in two turns (ask,
then later recognize the answer), and it's easy for an LLM to lose
track of that across a real gap in the conversation.
game_participants gets self_rolls (the toggle) and pending_roll (what
roll is currently awaited, if any). When self_rolls is on, roll_dice
doesn't touch the RNG — it validates the notation, stores it as
pending_roll, and returns an "awaiting_player_roll" result that tells
the DM to ask for that exact roll and wait, never inventing a number.
At the top of the player's next turn, orchestrator.run_dm_turn reads
back any pending_roll, injects it as an explicit reminder into the
system prompt ("the player's message is probably answering this"),
and clears it — so the DM doesn't have to rely on remembering what it
asked for several messages ago.
Frontend: the checkbox lives in the game header (PATCH
/api/games/{id}/self-rolls, per-participant). A persistent "🎲 Du bist
am Zug" banner shows the pending notation/reason — persistent, not
transient like the typing/rolling indicators, since answering it means
physically finding a die and rolling, which takes real time. It's
seeded from GameRead.my_pending_roll on load so it survives a page
reload, and only clears when the player actually sends their next
message (not when the DM's message arrives, which would make it
disappear before there was time to read it).
Verified live end-to-end against x.ai in a real session: toggled the
setting, asked for something requiring a check, got "würfle bitte
1d20+1" instead of an auto-rolled result, confirmed the banner
survived a full page reload, replied "14", and the DM used that
number directly ("Du hast 14 gewürfelt — das reicht") without
re-rolling — with pending_roll correctly cleared in the DB afterward.
Also confirmed per-participant scoping (a second participant's flag
stayed off) and that toggling back off works cleanly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
319 lines
11 KiB
Python
319 lines
11 KiB
Python
import secrets
|
|
import string
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.auth.users import current_active_user
|
|
from app.db import get_async_session
|
|
from app.llm.game_setup import run_game_setup_turn
|
|
from app.models.character import Character
|
|
from app.models.game import Game, GameParticipant
|
|
from app.models.message import Message
|
|
from app.models.user import User
|
|
from app.rag.adventure_ingestion import ingest_adventure_text
|
|
from app.schemas.game import GameCreate, GameJoin, GameRead, SelfRollsUpdate
|
|
from app.schemas.game_setup import GameSetupChatRequest, GameSetupChatResponse
|
|
from app.schemas.message import MessageRead
|
|
from app.ws_tickets import issue_ticket
|
|
|
|
router = APIRouter(prefix="/api/games", tags=["games"])
|
|
|
|
CODE_ALPHABET = string.ascii_uppercase + string.digits
|
|
|
|
|
|
def _generate_participation_code() -> str:
|
|
return "".join(secrets.choice(CODE_ALPHABET) for _ in range(8))
|
|
|
|
|
|
async def _serialize_games(
|
|
session: AsyncSession, games: list[Game], viewer_id: uuid.UUID
|
|
) -> list[GameRead]:
|
|
if not games:
|
|
return []
|
|
|
|
game_ids = [g.id for g in games]
|
|
|
|
creators = (
|
|
await session.execute(select(User.id, User.name).where(User.id.in_([g.creator_id for g in games])))
|
|
).all()
|
|
creator_names = {row.id: row.name for row in creators}
|
|
|
|
participant_rows = (
|
|
await session.execute(
|
|
select(
|
|
GameParticipant.game_id,
|
|
GameParticipant.user_id,
|
|
GameParticipant.character_id,
|
|
GameParticipant.self_rolls,
|
|
GameParticipant.pending_roll,
|
|
User.name,
|
|
)
|
|
.join(User, User.id == GameParticipant.user_id)
|
|
.where(GameParticipant.game_id.in_(game_ids))
|
|
)
|
|
).all()
|
|
participants_by_game: dict[uuid.UUID, list[str]] = {}
|
|
viewer_participation: dict[uuid.UUID, uuid.UUID | None] = {}
|
|
viewer_self_rolls: dict[uuid.UUID, bool] = {}
|
|
viewer_pending_roll: dict[uuid.UUID, dict | None] = {}
|
|
for game_id, participant_user_id, character_id, self_rolls, pending_roll, name in participant_rows:
|
|
participants_by_game.setdefault(game_id, []).append(name)
|
|
if participant_user_id == viewer_id:
|
|
viewer_participation[game_id] = character_id
|
|
viewer_self_rolls[game_id] = self_rolls
|
|
viewer_pending_roll[game_id] = pending_roll
|
|
|
|
result = []
|
|
for game in games:
|
|
names = participants_by_game.get(game.id, [])
|
|
is_participant = game.id in viewer_participation
|
|
result.append(
|
|
GameRead(
|
|
id=game.id,
|
|
name=game.name,
|
|
description=game.description,
|
|
creator_id=game.creator_id,
|
|
creator_name=creator_names.get(game.creator_id, "?"),
|
|
player_count=len(names),
|
|
player_names=names,
|
|
participation_code=game.participation_code if game.creator_id == viewer_id else None,
|
|
created_at=game.created_at,
|
|
is_participant=is_participant,
|
|
my_character_id=viewer_participation.get(game.id),
|
|
status=game.status,
|
|
ended_reason=game.ended_reason,
|
|
has_adventure=bool(game.adventure_text.strip()),
|
|
my_self_rolls=viewer_self_rolls.get(game.id, False),
|
|
my_pending_roll=viewer_pending_roll.get(game.id),
|
|
)
|
|
)
|
|
return result
|
|
|
|
|
|
@router.get("", response_model=list[GameRead])
|
|
async def list_games(
|
|
session: AsyncSession = Depends(get_async_session),
|
|
user: User = Depends(current_active_user),
|
|
) -> list[GameRead]:
|
|
games = (
|
|
(await session.execute(select(Game).order_by(Game.created_at.desc()))).scalars().all()
|
|
)
|
|
return await _serialize_games(session, list(games), user.id)
|
|
|
|
|
|
@router.post("", response_model=GameRead)
|
|
async def create_game(
|
|
payload: GameCreate,
|
|
session: AsyncSession = Depends(get_async_session),
|
|
user: User = Depends(current_active_user),
|
|
) -> GameRead:
|
|
game = Game(
|
|
name=payload.name,
|
|
description=payload.description,
|
|
adventure_text=payload.adventure_text,
|
|
creator_id=user.id,
|
|
participation_code=_generate_participation_code(),
|
|
)
|
|
session.add(game)
|
|
await session.flush()
|
|
|
|
session.add(GameParticipant(game_id=game.id, user_id=user.id))
|
|
await session.commit()
|
|
await session.refresh(game)
|
|
|
|
if payload.adventure_text.strip():
|
|
await ingest_adventure_text(session, game.id, payload.adventure_text)
|
|
|
|
serialized = await _serialize_games(session, [game], user.id)
|
|
return serialized[0]
|
|
|
|
|
|
@router.post("/setup-chat", response_model=GameSetupChatResponse)
|
|
async def game_setup_chat(
|
|
payload: GameSetupChatRequest,
|
|
user: User = Depends(current_active_user),
|
|
) -> GameSetupChatResponse:
|
|
result = await run_game_setup_turn(payload.messages, payload.adventure_text)
|
|
return GameSetupChatResponse(**result)
|
|
|
|
|
|
@router.get("/{game_id}", response_model=GameRead)
|
|
async def get_game(
|
|
game_id: uuid.UUID,
|
|
session: AsyncSession = Depends(get_async_session),
|
|
user: User = Depends(current_active_user),
|
|
) -> GameRead:
|
|
game = await session.get(Game, game_id)
|
|
if game is None:
|
|
raise HTTPException(status_code=404, detail="Game not found")
|
|
serialized = await _serialize_games(session, [game], user.id)
|
|
return serialized[0]
|
|
|
|
|
|
@router.delete("/{game_id}", status_code=204)
|
|
async def delete_game(
|
|
game_id: uuid.UUID,
|
|
session: AsyncSession = Depends(get_async_session),
|
|
user: User = Depends(current_active_user),
|
|
) -> None:
|
|
game = await session.get(Game, game_id)
|
|
if game is None:
|
|
raise HTTPException(status_code=404, detail="Game not found")
|
|
if game.creator_id != user.id:
|
|
raise HTTPException(status_code=403, detail="Only the creator can delete this game")
|
|
|
|
# game_participants, messages, world_state, and adventure_chunks all cascade on games.id.
|
|
await session.delete(game)
|
|
await session.commit()
|
|
|
|
|
|
async def _serialize_messages(session: AsyncSession, messages: list[Message]) -> list[MessageRead]:
|
|
if not messages:
|
|
return []
|
|
|
|
user_ids = {m.user_id for m in messages if m.user_id is not None}
|
|
character_ids = {m.character_id for m in messages if m.character_id is not None}
|
|
|
|
names: dict[uuid.UUID, str] = {}
|
|
if user_ids:
|
|
rows = (await session.execute(select(User.id, User.name).where(User.id.in_(user_ids)))).all()
|
|
names.update({row.id: row.name for row in rows})
|
|
|
|
char_names: dict[uuid.UUID, str] = {}
|
|
if character_ids:
|
|
rows = (
|
|
await session.execute(select(Character.id, Character.name).where(Character.id.in_(character_ids)))
|
|
).all()
|
|
char_names.update({row.id: row.name for row in rows})
|
|
|
|
return [
|
|
MessageRead(
|
|
id=m.id,
|
|
sender_type=m.sender_type,
|
|
user_id=m.user_id,
|
|
player_name=names.get(m.user_id) if m.user_id else None,
|
|
character_id=m.character_id,
|
|
character_name=char_names.get(m.character_id) if m.character_id else None,
|
|
content=m.content,
|
|
created_at=m.created_at,
|
|
)
|
|
for m in messages
|
|
]
|
|
|
|
|
|
async def _require_participant(session: AsyncSession, game_id: uuid.UUID, user_id: uuid.UUID) -> GameParticipant:
|
|
participant = (
|
|
await session.execute(
|
|
select(GameParticipant).where(
|
|
GameParticipant.game_id == game_id, GameParticipant.user_id == user_id
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
if participant is None:
|
|
raise HTTPException(status_code=403, detail="You have not joined this game")
|
|
return participant
|
|
|
|
|
|
@router.get("/{game_id}/messages", response_model=list[MessageRead])
|
|
async def get_recent_messages(
|
|
game_id: uuid.UUID,
|
|
limit: int = 5,
|
|
session: AsyncSession = Depends(get_async_session),
|
|
user: User = Depends(current_active_user),
|
|
) -> list[MessageRead]:
|
|
await _require_participant(session, game_id, user.id)
|
|
rows = (
|
|
await session.execute(
|
|
select(Message)
|
|
.where(Message.game_id == game_id)
|
|
.order_by(Message.created_at.desc(), Message.id.desc())
|
|
.limit(limit)
|
|
)
|
|
).scalars().all()
|
|
ordered = list(reversed(rows))
|
|
return await _serialize_messages(session, ordered)
|
|
|
|
|
|
@router.get("/{game_id}/messages/full", response_model=list[MessageRead])
|
|
async def get_full_messages(
|
|
game_id: uuid.UUID,
|
|
before_id: int | None = None,
|
|
limit: int = 100,
|
|
session: AsyncSession = Depends(get_async_session),
|
|
user: User = Depends(current_active_user),
|
|
) -> list[MessageRead]:
|
|
await _require_participant(session, game_id, user.id)
|
|
query = select(Message).where(Message.game_id == game_id)
|
|
if before_id is not None:
|
|
query = query.where(Message.id < before_id)
|
|
query = query.order_by(Message.id.desc()).limit(limit)
|
|
|
|
rows = (await session.execute(query)).scalars().all()
|
|
ordered = list(reversed(rows))
|
|
return await _serialize_messages(session, ordered)
|
|
|
|
|
|
@router.post("/{game_id}/join", response_model=GameRead)
|
|
async def join_game(
|
|
game_id: uuid.UUID,
|
|
payload: GameJoin,
|
|
session: AsyncSession = Depends(get_async_session),
|
|
user: User = Depends(current_active_user),
|
|
) -> GameRead:
|
|
game = await session.get(Game, game_id)
|
|
if game is None:
|
|
raise HTTPException(status_code=404, detail="Game not found")
|
|
if not secrets.compare_digest(payload.participation_code, game.participation_code):
|
|
raise HTTPException(status_code=403, detail="Invalid participation code")
|
|
|
|
existing = (
|
|
await session.execute(
|
|
select(GameParticipant).where(
|
|
GameParticipant.game_id == game_id, GameParticipant.user_id == user.id
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
|
|
if existing is None:
|
|
session.add(
|
|
GameParticipant(game_id=game_id, user_id=user.id, character_id=payload.character_id)
|
|
)
|
|
elif payload.character_id is not None:
|
|
existing.character_id = payload.character_id
|
|
|
|
await session.commit()
|
|
|
|
serialized = await _serialize_games(session, [game], user.id)
|
|
return serialized[0]
|
|
|
|
|
|
@router.patch("/{game_id}/self-rolls", response_model=GameRead)
|
|
async def update_self_rolls(
|
|
game_id: uuid.UUID,
|
|
payload: SelfRollsUpdate,
|
|
session: AsyncSession = Depends(get_async_session),
|
|
user: User = Depends(current_active_user),
|
|
) -> GameRead:
|
|
participant = await _require_participant(session, game_id, user.id)
|
|
participant.self_rolls = payload.self_rolls
|
|
if not payload.self_rolls:
|
|
participant.pending_roll = None
|
|
await session.commit()
|
|
|
|
game = await session.get(Game, game_id)
|
|
serialized = await _serialize_games(session, [game], user.id)
|
|
return serialized[0]
|
|
|
|
|
|
@router.post("/{game_id}/ws-ticket")
|
|
async def create_ws_ticket(
|
|
game_id: uuid.UUID,
|
|
session: AsyncSession = Depends(get_async_session),
|
|
user: User = Depends(current_active_user),
|
|
) -> dict[str, str]:
|
|
await _require_participant(session, game_id, user.id)
|
|
return {"ticket": issue_ticket(game_id, user.id)}
|