mantimon-tcg/backend/app/api/games.py
Cal Corum cc0254d5ab Implement REST endpoints for game management (API-001)
Add REST API endpoints for game lifecycle operations:
- POST /games - Create new game between two players
- GET /games/{game_id} - Get game info for reconnection
- GET /games/me/active - List user's active games
- POST /games/{game_id}/resign - Resign from game via HTTP

Includes proper reverse proxy support for WebSocket URL generation
(X-Forwarded-* headers -> settings.base_url -> Host header fallback).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 23:09:12 -06:00

352 lines
12 KiB
Python

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