Files
mtgonline/backend/app/routers/games_router.py
T
akadmin e9f5377e0a Phase 5: Complete Core Game Engine implementation
- Added MTGOnlineGame, GamePlayer, GameHistory models
- Implemented full game CRUD endpoints (create, list, get, join, leave, start, end)
- Integrated WebSocket game server with database persistence
- Added Commander deck validation service (100-card min, color identity, ban list)
- Created Alembic migration 006 for new game tables
- All game endpoints now database-backed (no more stubs)
2026-08-27 05:29:38 +00:00

804 lines
24 KiB
Python

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