"""Games API router for Mantimon TCG. This module provides REST endpoints for game management including creating, retrieving, listing, and resigning from games. WebSocket is the primary channel for real-time gameplay. These REST endpoints handle game lifecycle operations that don't require real-time communication. Endpoints: POST /games - Create a new game between two players GET /games/{game_id} - Get game info (for reconnection checks) GET /games/me/active - List user's active games POST /games/{game_id}/resign - Resign from a game via HTTP """ import logging from fastapi import APIRouter, HTTPException, Request, status from app.api.deps import ( CurrentUser, DbSession, DeckServiceDep, GameServiceDep, GameStateManagerDep, ) from app.config import settings from app.core.models.game_state import GameState from app.schemas.game import ( ActiveGameListResponse, ActiveGameSummary, GameCreateRequest, GameCreateResponse, GameInfoResponse, GameResignResponse, ) from app.services.game_service import ( GameAlreadyEndedError, GameNotFoundError, PlayerNotInGameError, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/games", tags=["games"]) def _build_ws_url(request: Request) -> str: """Build WebSocket URL based on request. Constructs the WebSocket URL for game connections. Handles reverse proxy scenarios by checking X-Forwarded-* headers, falling back to settings.base_url in production, or the raw request headers in development. Header priority: 1. X-Forwarded-Host + X-Forwarded-Proto (behind reverse proxy) 2. settings.base_url (configured server URL) 3. Request Host header (direct connection, development) Args: request: The incoming HTTP request. Returns: WebSocket URL for game connections (ws:// or wss://). """ # Check for reverse proxy headers first (most reliable in production) forwarded_host = request.headers.get("x-forwarded-host") forwarded_proto = request.headers.get("x-forwarded-proto", "https") if forwarded_host: # Behind a reverse proxy - trust forwarded headers scheme = "wss" if forwarded_proto == "https" else "ws" return f"{scheme}://{forwarded_host}/socket.io/" # Fall back to configured base_url (recommended for production) if settings.base_url and not settings.is_development: base = settings.base_url.rstrip("/") # Convert http(s) to ws(s) if base.startswith("https://"): return base.replace("https://", "wss://") + "/socket.io/" elif base.startswith("http://"): return base.replace("http://", "ws://") + "/socket.io/" # Development fallback - use request headers directly scheme = "wss" if request.url.scheme == "https" else "ws" host = request.headers.get("host", request.url.netloc) return f"{scheme}://{host}/socket.io/" @router.post("", response_model=GameCreateResponse, status_code=status.HTTP_201_CREATED) async def create_game( game_in: GameCreateRequest, request: Request, current_user: CurrentUser, game_service: GameServiceDep, deck_service: DeckServiceDep, ) -> GameCreateResponse: """Create a new game between two players. Creates a game using the specified decks and rules. Both players should then connect via WebSocket to play. Currently supports direct opponent specification for testing. Future phases will add matchmaking and invite systems. Args: game_in: Game creation parameters (decks, opponent, rules). request: HTTP request for building WebSocket URL. current_user: Authenticated user creating the game. game_service: GameService for game creation. deck_service: DeckService for deck loading. Returns: GameCreateResponse with game_id and WebSocket URL. Raises: 400: If deck validation fails or game creation error. 404: If decks not found. """ try: result = await game_service.create_game( player1_id=current_user.id, player2_id=game_in.opponent_id, deck1_id=game_in.deck_id, deck2_id=game_in.opponent_deck_id, rules_config=game_in.rules_config, deck_service=deck_service, game_type=game_in.game_type, ) except Exception as e: logger.error(f"Failed to create game: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Failed to create game: {str(e)}", ) from e if not result.success or result.game_id is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=result.message or "Failed to create game", ) ws_url = _build_ws_url(request) logger.info( f"Game created: {result.game_id}, " f"players={current_user.id} vs {game_in.opponent_id}" ) return GameCreateResponse( game_id=result.game_id, ws_url=ws_url, starting_player_id=result.starting_player_id or "", message="Game created successfully", ) @router.get("/me/active", response_model=ActiveGameListResponse) async def list_active_games( current_user: CurrentUser, state_manager: GameStateManagerDep, db: DbSession, ) -> ActiveGameListResponse: """List the current user's active games. Returns a summary of all ongoing games where the user is a participant. Useful for the game lobby or reconnection UI. Args: current_user: Authenticated user. state_manager: GameStateManager for querying active games. db: Database session. Returns: ActiveGameListResponse with list of active game summaries. """ active_games = await state_manager.get_player_active_games(current_user.id, session=db) game_summaries: list[ActiveGameSummary] = [] for game in active_games: # Determine opponent name if game.npc_id: opponent_name = game.npc_id # TODO: Look up NPC display name in Phase 5 elif game.player2_id: # Get opponent user for display name if game.player1_id == current_user.id and game.player2: opponent_name = game.player2.display_name or "Opponent" elif game.player1: opponent_name = game.player1.display_name or "Opponent" else: opponent_name = "Unknown" else: opponent_name = "Unknown" # Determine if it's user's turn by loading game state from cache is_your_turn = False turn_number = game.turn_number try: cached_state = await state_manager.load_from_cache(str(game.id)) if cached_state: is_your_turn = cached_state.current_player_id == str(current_user.id) turn_number = cached_state.turn_number except Exception as e: # Fall back to DB turn info - log for visibility logger.debug(f"Cache lookup failed for game {game.id}, using DB values: {e}") game_summaries.append( ActiveGameSummary( game_id=str(game.id), game_type=game.game_type, opponent_name=opponent_name, is_your_turn=is_your_turn, turn_number=turn_number, started_at=game.started_at, last_action_at=game.last_action_at, ) ) return ActiveGameListResponse( games=game_summaries, total=len(game_summaries), ) @router.get("/{game_id}", response_model=GameInfoResponse) async def get_game_info( game_id: str, current_user: CurrentUser, game_service: GameServiceDep, state_manager: GameStateManagerDep, db: DbSession, ) -> GameInfoResponse: """Get information about a specific game. Returns game metadata without full state (use WebSocket for live state). Useful for reconnection checks and game status display. Args: game_id: Unique game identifier. current_user: Authenticated user. game_service: GameService for state access. state_manager: GameStateManager for active game lookup. db: Database session. Returns: GameInfoResponse with game metadata. Raises: 404: If game not found. 403: If user is not a participant. """ # Try to get game state (checks cache first, then DB) try: state: GameState = await game_service.get_game_state(game_id) except GameNotFoundError: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Game not found", ) from None # Verify user is a participant user_id_str = str(current_user.id) if user_id_str not in state.players: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You are not a participant in this game", ) # Get additional metadata from ActiveGame record (required for timestamps/game_type) active_games = await state_manager.get_player_active_games(current_user.id, session=db) active_game = next((g for g in active_games if str(g.id) == game_id), None) if active_game is None: # Game exists in cache/state but not in ActiveGame table # This shouldn't happen in normal operation, but handle gracefully raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Game not found in active games", ) return GameInfoResponse( game_id=state.game_id, game_type=active_game.game_type, player1_id=active_game.player1_id, player2_id=active_game.player2_id, npc_id=active_game.npc_id, current_player_id=state.current_player_id, turn_number=state.turn_number, phase=state.phase, is_your_turn=state.current_player_id == user_id_str, is_game_over=state.winner_id is not None or state.end_reason is not None, winner_id=state.winner_id, end_reason=state.end_reason, started_at=active_game.started_at, last_action_at=active_game.last_action_at, ) @router.post("/{game_id}/resign", response_model=GameResignResponse) async def resign_game( game_id: str, current_user: CurrentUser, game_service: GameServiceDep, ) -> GameResignResponse: """Resign from a game via HTTP. This is a backup for WebSocket resignation. Preferred method is via WebSocket game:resign event for immediate opponent notification. Args: game_id: Unique game identifier. current_user: Authenticated user resigning. game_service: GameService for resignation. Returns: GameResignResponse with success status. Raises: 404: If game not found. 403: If user is not a participant. 400: If game has already ended. """ user_id_str = str(current_user.id) try: result = await game_service.resign_game( game_id=game_id, player_id=user_id_str, ) except GameNotFoundError: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Game not found", ) from None except PlayerNotInGameError: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You are not a participant in this game", ) from None except GameAlreadyEndedError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Game has already ended", ) from None logger.info(f"Player {current_user.id} resigned from game {game_id} via HTTP") return GameResignResponse( success=result.success, game_id=game_id, message="You have resigned from the game", )