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>
352 lines
12 KiB
Python
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",
|
|
)
|