8da834c5c1
Games now have a status/ended_reason pair. The DM can call the new end_game tool when the story reaches a real conclusion (victory, defeat, or a resolved one-shot), and the backend independently ends the game whenever a character's HP drops to 0 or below, regardless of whether the DM narrates it. The frontend shows a banner and a "Beendet" badge once a game ends. HP moves from the freeform combat_stats bag into dedicated current_hp/max_hp columns on Character, since reliably detecting 0 HP requires a real integer rather than parsing strings like "3/10" out of an LLM-authored key/value dict. Also fixed combat_stats to fully replace on each upsert instead of merging, matching its documented contract — the merge was leaving stale keys (old HP/TP text) behind after the model stopped sending them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
265 lines
9.0 KiB
Python
265 lines
9.0 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.schemas.game import GameCreate, GameJoin, GameRead
|
|
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, 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] = {}
|
|
for game_id, participant_user_id, character_id, name in participant_rows:
|
|
participants_by_game.setdefault(game_id, []).append(name)
|
|
if participant_user_id == viewer_id:
|
|
viewer_participation[game_id] = character_id
|
|
|
|
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,
|
|
)
|
|
)
|
|
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,
|
|
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)
|
|
|
|
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)
|
|
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]
|
|
|
|
|
|
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.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)}
|