""" Games Router - Consolidated This module consolidates room management, game operations, MTGJSON data refresh, and WebSocket connections into a unified router structure. Endpoints: /rooms/* - Room CRUD operations (Admin/Judge only for write ops) /games/* - Game management endpoints /mtgjson/* - MTGJSON dataset refresh and verification (Admin only) /ws/game/{game_id} - WebSocket connection for game sessions Architecture: - Uses FastAPI sub-routers with different prefixes for logical grouping - WebSocket endpoint for real-time game communication - All endpoints require authentication via get_current_user dependency - Admin/Judge privilege checks on sensitive operations """ import logging from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, WebSocket, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, insert, update, delete from typing import Optional, List from datetime import datetime from app.core.database import get_db from app.core.security import get_current_user from app.models.models import ( Room, User, MTGOnlineGame, GamePlayer, GameHistory ) from app.schemas.schemas import ( RoomResponse, GameCreate, GameResponse, GameUpdate, GamePlayerResponse, GameHistoryResponse, GameJoinResponse ) from app.services.mtgjson_manager import MTGJSONManager from app.services.game_server import game_websocket_endpoint logger = logging.getLogger(__name__) # Valid game types VALID_GAME_TYPES = ["Commander", "Standard", "Modern", "Pauper"] # Valid game states VALID_GAME_STATES = ["WAITING", "IN_PROGRESS", "FINISHED", "ABORTED"] # Main router router = APIRouter() # ============================================================================= # Room Management Sub-Router (prefix: /rooms) # ============================================================================= rooms_router = APIRouter(prefix="/rooms", tags=["Rooms"]) @rooms_router.get("/", response_model=List[RoomResponse]) async def list_rooms( db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ) -> List[RoomResponse]: """List all available rooms. Returns a list of all rooms in the system, ordered by ID. Requires authentication. """ stmt = select(Room).order_by(Room.id) result = await db.execute(stmt) rooms = result.scalars().all() return [RoomResponse.model_validate(room) for room in rooms] @rooms_router.get("/{room_id}", response_model=RoomResponse) async def get_room( room_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ) -> RoomResponse: """Get room by ID. Returns detailed information about a specific room. Raises 404 if room not found. """ stmt = select(Room).where(Room.id == room_id) result = await db.execute(stmt) room = result.scalar_one_or_none() if not room: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Room not found", ) return RoomResponse.model_validate(room) @rooms_router.post("/", status_code=status.HTTP_201_CREATED) async def create_room( name: str, description: Optional[str] = None, is_password_protected: bool = False, password_hash: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ) -> dict: """Create a new room (Admin/Judge only). Creates a new game room with the specified configuration. Only users with Admin or Judge privileges can create rooms. Raises 409 if room name already exists. """ # Check if current user is admin if current_user.get("privlevel") not in ["Admin", "Judge"]: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required", ) # Check if room name already exists stmt = select(Room).where(Room.name == name) result = await db.execute(stmt) if result.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Room name already exists", ) new_room = Room( name=name, description=description, is_password_protected=is_password_protected, password_hash=password_hash, ) db.add(new_room) await db.flush() return {"message": f"Room '{name}' created successfully", "room_id": new_room.id} @rooms_router.patch("/{room_id}") async def update_room( room_id: int, name: Optional[str] = None, description: Optional[str] = None, is_password_protected: Optional[bool] = None, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ) -> dict: """Update room (Admin/Judge only). Updates the specified fields of a room. Only users with Admin or Judge privileges can update rooms. """ # Check if current user is admin if current_user.get("privlevel") not in ["Admin", "Judge"]: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required", ) stmt = select(Room).where(Room.id == room_id) result = await db.execute(stmt) room = result.scalar_one_or_none() if not room: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Room not found", ) # Update fields update_data = {} if name: update_data["name"] = name if description is not None: update_data["description"] = description if is_password_protected is not None: update_data["is_password_protected"] = is_password_protected stmt = ( update(Room) .where(Room.id == room_id) .values(**update_data) ) await db.execute(stmt) return {"message": f"Room '{name or room.name}' updated successfully"} @rooms_router.delete("/{room_id}") async def delete_room( room_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ) -> dict: """Delete room (Admin/Judge only). Permanently deletes a room from the system. Only users with Admin or Judge privileges can delete rooms. """ # Check if current user is admin if current_user.get("privlevel") not in ["Admin", "Judge"]: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required", ) stmt = select(Room).where(Room.id == room_id) result = await db.execute(stmt) room = result.scalar_one_or_none() if not room: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Room not found", ) await db.execute(delete(Room).where(Room.id == room_id)) return {"message": f"Room '{room.name}' deleted successfully"} # ============================================================================= # Game Management Sub-Router (prefix: /games) # ============================================================================= games_router = APIRouter(prefix="/games", tags=["Games"]) @games_router.get("/", response_model=List[GameResponse]) async def list_games( room_id: Optional[int] = None, game_type: Optional[str] = None, state: Optional[str] = None, page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=100), db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ) -> List[GameResponse]: """List active games. Returns a paginated list of games with optional filtering by room_id, game_type, and state. Defaults to filtering by WAITING state. """ # Build query with filters stmt = select(MTGOnlineGame) if room_id: stmt = stmt.where(MTGOnlineGame.room_id == room_id) if game_type: if game_type not in VALID_GAME_TYPES: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid game_type. Must be one of: {VALID_GAME_TYPES}", ) stmt = stmt.where(MTGOnlineGame.game_type == game_type) if state: if state not in VALID_GAME_STATES: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid state. Must be one of: {VALID_GAME_STATES}", ) stmt = stmt.where(MTGOnlineGame.state == state) else: # Default to WAITING games stmt = stmt.where(MTGOnlineGame.state == "WAITING") # Add pagination offset = (page - 1) * page_size stmt = stmt.offset(offset).limit(page_size).order_by(MTGOnlineGame.created_at.desc()) result = await db.execute(stmt) games = result.scalars().all() # Fetch players for each game game_responses = [] for game in games: player_stmt = select(GamePlayer).where(GamePlayer.game_id == game.id) player_result = await db.execute(player_stmt) players = player_result.scalars().all() game.players = [GamePlayerResponse.model_validate(p) for p in players] game_responses.append(GameResponse.model_validate(game)) return game_responses @games_router.post("/", response_model=GameResponse, status_code=status.HTTP_201_CREATED) async def create_game( request: GameCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ) -> GameResponse: """Create a new game. Creates a new game session with the current user as host. Validates room exists and game_type is valid. """ user_id = int(current_user["user_id"]) # Validate game_type if request.game_type not in VALID_GAME_TYPES: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid game_type. Must be one of: {VALID_GAME_TYPES}", ) # Check if room exists stmt = select(Room).where(Room.id == request.room_id) result = await db.execute(stmt) room = result.scalar_one_or_none() if not room: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Room not found", ) # Create game new_game = MTGOnlineGame( room_id=request.room_id, host_id=user_id, game_type=request.game_type, state="WAITING", is_password_protected=request.is_password_protected, max_players=request.max_players, current_players=1, ) db.add(new_game) await db.flush() # Add host as first player host_player = GamePlayer( game_id=new_game.id, user_id=user_id, seat_number=1, life_total=20, is_ready=False, is_host=True, ) db.add(host_player) await db.flush() # Fetch players for response player_stmt = select(GamePlayer).where(GamePlayer.game_id == new_game.id) player_result = await db.execute(player_result) players = player_result.scalars().all() new_game.players = [GamePlayerResponse.model_validate(p) for p in players] logger.info(f"Game {new_game.id} created by user {user_id} in room {request.room_id}") return GameResponse.model_validate(new_game) @games_router.get("/{game_id}", response_model=GameResponse) async def get_game( game_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ) -> GameResponse: """Get game by ID. Returns detailed information about a specific game including player list. Raises 404 if game not found. """ # Get game stmt = select(MTGOnlineGame).where(MTGOnlineGame.id == game_id) result = await db.execute(stmt) game = result.scalar_one_or_none() if not game: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Game not found", ) # Get players player_stmt = select(GamePlayer).where(GamePlayer.game_id == game_id) player_result = await db.execute(player_stmt) players = player_result.scalars().all() game.players = [GamePlayerResponse.model_validate(p) for p in players] return GameResponse.model_validate(game) @games_router.post("/{game_id}/join", response_model=GameJoinResponse) async def join_game( game_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ) -> GameJoinResponse: """Join a game. Adds the current user to a waiting game. Validates game exists, is in WAITING state, and has space. """ user_id = int(current_user["user_id"]) # Get game stmt = select(MTGOnlineGame).where(MTGOnlineGame.id == game_id) result = await db.execute(stmt) game = result.scalar_one_or_none() if not game: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Game not found", ) # Check game state if game.state != "WAITING": raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Game is not in WAITING state", ) # Check if game is full if game.current_players >= game.max_players: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Game is full", ) # Check if user is already in game player_stmt = select(GamePlayer).where( GamePlayer.game_id == game_id, GamePlayer.user_id == user_id, ) player_result = await db.execute(player_stmt) existing_player = player_result.scalar_one_or_none() if existing_player: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="User is already in this game", ) # Get next available seat number seat_stmt = select(GamePlayer.seat_number).where( GamePlayer.game_id == game_id ).order_by(GamePlayer.seat_number.desc()).limit(1) seat_result = await db.execute(seat_stmt) max_seat = seat_result.scalar_one_or_none() next_seat = (max_seat or 0) + 1 # Add player new_player = GamePlayer( game_id=game_id, user_id=user_id, seat_number=next_seat, life_total=20, is_ready=False, is_host=False, ) db.add(new_player) # Update game player count game.current_players += 1 await db.flush() logger.info(f"User {user_id} joined game {game_id} as seat {next_seat}") return GameJoinResponse( message=f"Successfully joined game {game_id}", game_id=game_id, player_id=user_id, current_players=game.current_players, max_players=game.max_players, ) @games_router.post("/{game_id}/leave", response_model=GameJoinResponse) async def leave_game( game_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ) -> GameJoinResponse: """Leave a game. Removes the current user from a game. If host leaves, transfers host to another player or closes game. """ user_id = int(current_user["user_id"]) # Get game stmt = select(MTGOnlineGame).where(MTGOnlineGame.id == game_id) result = await db.execute(stmt) game = result.scalar_one_or_none() if not game: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Game not found", ) # Get player player_stmt = select(GamePlayer).where( GamePlayer.game_id == game_id, GamePlayer.user_id == user_id, ) player_result = await db.execute(player_stmt) player = player_result.scalar_one_or_none() if not player: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="User is not in this game", ) # If host is leaving, transfer host or close game if player.is_host: # Get other players other_players_stmt = select(GamePlayer).where( GamePlayer.game_id == game_id, GamePlayer.user_id != user_id, ) other_players_result = await db.execute(other_players_stmt) other_players = other_players_result.scalars().all() if other_players: # Transfer host to first other player new_host = other_players[0] new_host.is_host = True player.is_host = False logger.info(f"Host transferred from user {user_id} to user {new_host.user_id} in game {game_id}") else: # No other players, close game game.state = "FINISHED" game.finished_at = datetime.now() logger.info(f"Game {game_id} closed - host left with no other players") # Remove player await db.delete(player) # Update game player count game.current_players -= 1 await db.flush() logger.info(f"User {user_id} left game {game_id}") return GameJoinResponse( message=f"Successfully left game {game_id}", game_id=game_id, player_id=user_id, current_players=game.current_players, max_players=game.max_players, ) @games_router.post("/{game_id}/start", response_model=GameJoinResponse) async def start_game( game_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ) -> GameJoinResponse: """Start a game (host only). Initiates a game session. Validates that the current user is the host. """ user_id = int(current_user["user_id"]) # Get game stmt = select(MTGOnlineGame).where(MTGOnlineGame.id == game_id) result = await db.execute(stmt) game = result.scalar_one_or_none() if not game: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Game not found", ) # Check game state if game.state != "WAITING": raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Game is not in WAITING state", ) # Check if user is host player_stmt = select(GamePlayer).where( GamePlayer.game_id == game_id, GamePlayer.user_id == user_id, ) player_result = await db.execute(player_stmt) player = player_result.scalar_one_or_none() if not player or not player.is_host: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only the host can start the game", ) # Start game game.state = "IN_PROGRESS" game.started_at = datetime.now() await db.flush() logger.info(f"Game {game_id} started by host user {user_id}") return GameJoinResponse( message=f"Game {game_id} started", game_id=game_id, player_id=user_id, current_players=game.current_players, max_players=game.max_players, ) @games_router.post("/{game_id}/end", response_model=GameJoinResponse) async def end_game( game_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ) -> GameJoinResponse: """End a game. Terminates a game session and records it in GameHistory. """ user_id = int(current_user["user_id"]) # Get game stmt = select(MTGOnlineGame).where(MTGOnlineGame.id == game_id) result = await db.execute(stmt) game = result.scalar_one_or_none() if not game: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Game not found", ) # Check game state if game.state not in ["WAITING", "IN_PROGRESS"]: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Game is already finished or aborted", ) # Calculate duration duration_seconds = None if game.started_at: end_time = datetime.now() duration_seconds = int((end_time - game.started_at).total_seconds()) # Determine winner (for now, host wins by default) winner_id = game.host_id result_type = "victory" # Create game history record game_history = GameHistory( game_id=game.id, room_id=game.room_id, game_type=game.game_type, winner_id=winner_id, duration_seconds=duration_seconds, result_type=result_type, ) db.add(game_history) # Update game state game.state = "FINISHED" game.finished_at = datetime.now() await db.flush() logger.info(f"Game {game_id} ended. Winner: user {winner_id}, Duration: {duration_seconds}s") return GameJoinResponse( message=f"Game {game_id} ended", game_id=game_id, player_id=user_id, current_players=0, max_players=game.max_players, ) # ============================================================================= # MTGJSON Data Refresh Sub-Router (prefix: /mtgjson) # ============================================================================= mtgjson_router = APIRouter(prefix="/mtgjson", tags=["MTGJSON Data"]) @mtgjson_router.post("/refresh") async def trigger_refresh( background_tasks: BackgroundTasks, current_user: dict = Depends(get_current_user), ) -> dict: """Trigger a refresh of MTGJSON datasets (Admin only). Downloads files from MTGJSON API and upserts data into PostgreSQL. The refresh runs in the background and may take several minutes. Only Admin users can trigger refreshes. """ # Check if current user is admin if current_user.get("privlevel") != "Admin": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required", ) # Create manager and trigger refresh in background manager = MTGJSONManager() background_tasks.add_task(manager.download_and_refresh, force=False) return { "message": "Refresh initiated in background", "status": "started", } @mtgjson_router.get("/status") async def get_refresh_status( current_user: dict = Depends(get_current_user), ) -> dict: """Get current refresh status and data health (Admin only). Returns information about: - Last successful refresh timestamp - Current data counts - File status - Required files status Only Admin users can access this endpoint. """ # Check if current user is admin if current_user.get("privlevel") != "Admin": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required", ) manager = MTGJSONManager() return await manager.get_health_status() @mtgjson_router.post("/verify") async def verify_files( current_user: dict = Depends(get_current_user), ) -> dict: """Verify the integrity of downloaded MTGJSON files (Admin only). Checks that all required files exist and are valid. Only Admin users can verify files. """ # Check if current user is admin if current_user.get("privlevel") != "Admin": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required", ) manager = MTGJSONManager() all_valid, errors = await manager.verify_files() if not all_valid: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={ "valid": False, "errors": errors, "message": "Some files failed verification", }, ) return { "valid": True, "message": "All files verified successfully", } # ============================================================================= # WebSocket Endpoint # ============================================================================= @router.websocket("/ws/game/{game_id}") async def game_websocket( websocket: WebSocket, game_id: int, player_id: Optional[int] = Query(None), ) -> None: """WebSocket endpoint for game connections. Handles real-time game state updates for connected players. Establishes a persistent connection for live game communication. """ await websocket.accept() await game_websocket_endpoint(websocket, game_id) # ============================================================================= # Include Sub-Routers # ============================================================================= router.include_router(rooms_router) router.include_router(games_router) router.include_router(mtgjson_router)