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)}