import logging from uuid import UUID, uuid4 from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, Field, field_validator from app.api.dependencies import get_current_user_optional from app.core.game_engine import game_engine from app.core.state_manager import state_manager from app.database.operations import DatabaseOperations from app.services.lineup_service import lineup_service from app.services.sba_api_client import sba_api_client logger = logging.getLogger(f"{__name__}.games") router = APIRouter() class GameListItem(BaseModel): """Game list item model with enriched team and game state info""" game_id: str league_id: str status: str home_team_id: int away_team_id: int # Enriched fields home_team_name: str | None = None away_team_name: str | None = None home_team_abbrev: str | None = None away_team_abbrev: str | None = None home_score: int = 0 away_score: int = 0 inning: int | None = None half: str | None = None # 'top' or 'bottom' # External schedule reference (for linking to SBA/PD schedule systems) schedule_game_id: int | None = None class CreateGameRequest(BaseModel): """Request model for creating a new game""" name: str = Field(..., description="Game name") home_team_id: int = Field(..., description="Home team ID") away_team_id: int = Field(..., description="Away team ID") is_ai_opponent: bool = Field(default=False, description="Is AI opponent") season: int = Field(default=3, description="Season number") league_id: str = Field(default="sba", description="League ID (sba or pd)") class CreateGameResponse(BaseModel): """Response model for game creation""" game_id: str message: str status: str class QuickCreateRequest(BaseModel): """Optional request model for quick-create with custom teams""" home_team_id: int | None = Field(None, description="Home team ID (uses default if not provided)") away_team_id: int | None = Field(None, description="Away team ID (uses default if not provided)") schedule_game_id: int | None = Field(None, description="External schedule game ID for linking") class LineupPlayerRequest(BaseModel): """Single player in lineup request""" player_id: int = Field(..., description="SBA player ID") position: str = Field(..., description="Defensive position (P, C, 1B, etc.)") batting_order: int | None = Field( None, ge=1, le=9, description="Batting order (1-9), null for pitcher in DH lineup" ) @field_validator("position") @classmethod def validate_position(cls, v: str) -> str: """Ensure position is valid""" valid = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "DH"] if v not in valid: raise ValueError(f"Position must be one of {valid}") return v class SubmitLineupsRequest(BaseModel): """ Request model for submitting lineups for both teams. Supports both 9-player (pitcher bats) and 10-player (DH) configurations: - 9 players: All have batting orders 1-9 - 10 players: Pitcher has batting_order=null, others have 1-9 """ home_lineup: list[LineupPlayerRequest] = Field( ..., min_length=9, max_length=10, description="Home team starting lineup (9-10 players)" ) away_lineup: list[LineupPlayerRequest] = Field( ..., min_length=9, max_length=10, description="Away team starting lineup (9-10 players)" ) @field_validator("home_lineup", "away_lineup") @classmethod def validate_lineup(cls, v: list[LineupPlayerRequest]) -> list[LineupPlayerRequest]: """ Validate lineup structure for 9-player or 10-player (DH) configurations. Rules: - 9 players: All must have batting orders 1-9 - 10 players: Exactly 9 have batting orders 1-9, pitcher has null """ lineup_size = len(v) # Check batting orders batters = [p for p in v if p.batting_order is not None] batting_orders = [p.batting_order for p in batters] if len(batting_orders) != 9: raise ValueError(f"Must have exactly 9 batters with batting orders, got {len(batting_orders)}") if set(batting_orders) != {1, 2, 3, 4, 5, 6, 7, 8, 9}: raise ValueError("Batting orders must be exactly 1-9 with no duplicates") # Check positions positions = [p.position for p in v] required_positions = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF"] for req_pos in required_positions: if req_pos not in positions: raise ValueError(f"Missing required position: {req_pos}") # For 10-player lineup, must have DH and pitcher must not bat if lineup_size == 10: if "DH" not in positions: raise ValueError("10-player lineup must include DH position") pitchers = [p for p in v if p.position == "P"] if len(pitchers) != 1: raise ValueError("Must have exactly 1 pitcher") if pitchers[0].batting_order is not None: raise ValueError("Pitcher cannot have batting order in DH lineup") # For 9-player lineup, pitcher must bat (no DH) elif lineup_size == 9: if "DH" in positions: raise ValueError("9-player lineup cannot include DH") pitchers = [p for p in v if p.position == "P"] if len(pitchers) != 1: raise ValueError("Must have exactly 1 pitcher") if pitchers[0].batting_order is None: raise ValueError("Pitcher must have batting order when no DH") # Check player uniqueness player_ids = [p.player_id for p in v] if len(set(player_ids)) != len(player_ids): raise ValueError("Players cannot be duplicated in lineup") return v class SubmitLineupsResponse(BaseModel): """Response model for lineup submission""" game_id: str message: str home_lineup_count: int away_lineup_count: int @router.get("/", response_model=list[GameListItem]) async def list_games(): """ List all games from the database with enriched team and game state info. Returns game information including team names, scores, and current inning. TODO: Add user filtering, pagination, and more sophisticated queries """ try: logger.info("Fetching games list from database") # Get all games from database (for now - later we can add filters) from app.database.session import AsyncSessionLocal from app.models.db_models import Game async with AsyncSessionLocal() as session: from sqlalchemy import select result = await session.execute( select(Game).order_by(Game.created_at.desc()) ) games = result.scalars().all() # Collect unique team IDs for batch lookup team_ids = set() for game in games: team_ids.add(game.home_team_id) team_ids.add(game.away_team_id) # Fetch team data (uses cache) teams_data = await sba_api_client.get_teams_by_ids(list(team_ids)) # Convert to response model with enriched data game_list = [] for game in games: home_team = teams_data.get(game.home_team_id, {}) away_team = teams_data.get(game.away_team_id, {}) game_list.append( GameListItem( game_id=str(game.id), league_id=game.league_id, status=game.status, home_team_id=game.home_team_id, away_team_id=game.away_team_id, home_team_name=home_team.get("lname"), away_team_name=away_team.get("lname"), home_team_abbrev=home_team.get("abbrev"), away_team_abbrev=away_team.get("abbrev"), home_score=game.home_score or 0, away_score=game.away_score or 0, inning=game.current_inning, half=game.current_half, schedule_game_id=game.schedule_game_id, ) ) logger.info(f"Retrieved {len(game_list)} games from database") return game_list except Exception as e: logger.exception(f"Failed to list games: {e}") raise HTTPException(status_code=500, detail=f"Failed to list games: {str(e)}") @router.get("/{game_id}") async def get_game(game_id: str): """ Get game details including team IDs and status. Args: game_id: Game identifier Returns: Game information including home/away team IDs Raises: 400: Invalid game_id format 404: Game not found """ try: # Validate game_id format try: game_uuid = UUID(game_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid game_id format") logger.info(f"Fetching game details for {game_id}") # Get game from state manager if game_uuid not in state_manager._states: raise HTTPException(status_code=404, detail=f"Game {game_id} not found") state = state_manager._states[game_uuid] return { "game_id": game_id, "status": state.status, "home_team_id": state.home_team_id, "away_team_id": state.away_team_id, "league_id": state.league_id, } except HTTPException: raise except Exception as e: logger.exception(f"Failed to fetch game {game_id}: {e}") raise HTTPException(status_code=500, detail=f"Failed to fetch game: {str(e)}") @router.post("/", response_model=CreateGameResponse) async def create_game(request: CreateGameRequest): """ Create new game Creates a new game in the state manager and database. Note: Lineups must be added separately before the game can be started. """ try: # Generate game ID game_id = uuid4() logger.info( f"Creating game {game_id}: {request.home_team_id} vs {request.away_team_id}" ) # Validate teams are different if request.home_team_id == request.away_team_id: raise HTTPException( status_code=400, detail="Home and away teams must be different" ) # Create game in state manager (in-memory) state = await state_manager.create_game( game_id=game_id, league_id=request.league_id, home_team_id=request.home_team_id, away_team_id=request.away_team_id, ) # Save to database db_ops = DatabaseOperations() await db_ops.create_game( game_id=game_id, league_id=request.league_id, home_team_id=request.home_team_id, away_team_id=request.away_team_id, game_mode="friendly" if not request.is_ai_opponent else "ai", visibility="public", ) logger.info( f"Game {game_id} created successfully - status: {state.status}" ) return CreateGameResponse( game_id=str(game_id), message=f"Game '{request.name}' created successfully. Add lineups to start the game.", status=state.status, ) except HTTPException: raise except Exception as e: logger.exception(f"Failed to create game: {e}") raise HTTPException(status_code=500, detail=f"Failed to create game: {str(e)}") @router.post("/quick-create", response_model=CreateGameResponse) async def quick_create_game( request: QuickCreateRequest | None = None, user: dict | None = Depends(get_current_user_optional), ): """ Quick-create endpoint for testing - creates a game with auto-generated lineups. If home_team_id and away_team_id are provided, fetches rosters from SBA API and auto-generates lineups. Otherwise uses default teams (35 vs 38) with pre-configured lineups. The authenticated user's discord_id is stored as creator_discord_id, allowing them to control teams regardless of actual team ownership. Args: request: Optional request body with custom team IDs Returns: CreateGameResponse with game_id """ try: # Generate game ID game_id = uuid4() league_id = "sba" # Determine team IDs and schedule link use_custom_teams = ( request is not None and request.home_team_id is not None and request.away_team_id is not None ) if use_custom_teams: home_team_id = request.home_team_id away_team_id = request.away_team_id schedule_game_id = request.schedule_game_id else: # Default demo teams home_team_id = 35 away_team_id = 38 schedule_game_id = None # Get creator's discord_id from authenticated user creator_discord_id = user.get("discord_id") if user else None logger.info( f"Quick-creating game {game_id}: {home_team_id} vs {away_team_id} " f"(creator: {creator_discord_id}, custom_teams: {use_custom_teams})" ) # Create game in state manager state = await state_manager.create_game( game_id=game_id, league_id=league_id, home_team_id=home_team_id, away_team_id=away_team_id, creator_discord_id=creator_discord_id, ) # Save to database db_ops = DatabaseOperations() await db_ops.create_game( game_id=game_id, league_id=league_id, home_team_id=home_team_id, away_team_id=away_team_id, game_mode="friendly", visibility="public", schedule_game_id=schedule_game_id, ) if use_custom_teams: # Fetch rosters from SBA API and build lineups dynamically await _build_lineup_from_roster(game_id, home_team_id, season=13) await _build_lineup_from_roster(game_id, away_team_id, season=13) else: # Use pre-configured demo lineups # Submit home lineup (Team 35) home_lineup_data = [ {"player_id": 1417, "position": "C", "batting_order": 1}, {"player_id": 1186, "position": "1B", "batting_order": 2}, {"player_id": 1381, "position": "2B", "batting_order": 3}, {"player_id": 1576, "position": "3B", "batting_order": 4}, {"player_id": 1242, "position": "SS", "batting_order": 5}, {"player_id": 1600, "position": "LF", "batting_order": 6}, {"player_id": 1675, "position": "CF", "batting_order": 7}, {"player_id": 1700, "position": "RF", "batting_order": 8}, {"player_id": 1759, "position": "DH", "batting_order": 9}, {"player_id": 1948, "position": "P", "batting_order": None}, ] for player in home_lineup_data: await lineup_service.add_sba_player_to_lineup( game_id=game_id, team_id=home_team_id, player_id=player["player_id"], position=player["position"], batting_order=player["batting_order"], is_starter=True, ) # Submit away lineup (Team 38) away_lineup_data = [ {"player_id": 1080, "position": "C", "batting_order": 1}, {"player_id": 1148, "position": "1B", "batting_order": 2}, {"player_id": 1166, "position": "2B", "batting_order": 3}, {"player_id": 1513, "position": "3B", "batting_order": 4}, {"player_id": 1209, "position": "SS", "batting_order": 5}, {"player_id": 1735, "position": "LF", "batting_order": 6}, {"player_id": 1665, "position": "CF", "batting_order": 7}, {"player_id": 1961, "position": "RF", "batting_order": 8}, {"player_id": 1980, "position": "DH", "batting_order": 9}, {"player_id": 2005, "position": "P", "batting_order": None}, ] for player in away_lineup_data: await lineup_service.add_sba_player_to_lineup( game_id=game_id, team_id=away_team_id, player_id=player["player_id"], position=player["position"], batting_order=player["batting_order"], is_starter=True, ) # Start the game await game_engine.start_game(game_id) logger.info(f"Quick-created game {game_id} and started successfully") return CreateGameResponse( game_id=str(game_id), message=f"Game quick-created with Team {home_team_id} vs Team {away_team_id}. Ready to play!", status="active", ) except Exception as e: logger.exception(f"Failed to quick-create game: {e}") raise HTTPException(status_code=500, detail=f"Failed to quick-create game: {str(e)}") async def _build_lineup_from_roster(game_id: UUID, team_id: int, season: int = 13) -> None: """ Build a lineup from SBA API roster data. Fetches the team roster and assigns players to positions based on their listed positions. Uses a simple algorithm to fill all positions: - First player at each position gets assigned - DH filled with extra player if available - Batting order assigned by position priority Args: game_id: Game UUID team_id: Team ID to fetch roster for season: Season number (default 13) """ # Fetch roster from SBA API roster = await sba_api_client.get_roster(team_id=team_id, season=season) if not roster: raise HTTPException( status_code=400, detail=f"No roster found for team {team_id} in season {season}", ) # Position priority for lineup building # Standard DH lineup: 9 fielders + DH, pitcher doesn't bat positions_needed = ["C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "P"] lineup_positions: dict[str, dict] = {} used_player_ids: set[int] = set() # First pass: assign players to their primary positions for player in roster: player_id = player.get("id") player_positions = player.get("position", "").split("/") if not player_id or player_id in used_player_ids: continue # Try to assign to first matching needed position for pos in player_positions: pos = pos.strip().upper() if pos in positions_needed and pos not in lineup_positions: lineup_positions[pos] = { "player_id": player_id, "position": pos, } used_player_ids.add(player_id) break # Check we have all required positions missing = [p for p in positions_needed if p not in lineup_positions] if missing: # Second pass: try to fill missing positions with any available player for pos in missing: for player in roster: player_id = player.get("id") if player_id and player_id not in used_player_ids: lineup_positions[pos] = { "player_id": player_id, "position": pos, } used_player_ids.add(player_id) break # Still missing? Raise error still_missing = [p for p in positions_needed if p not in lineup_positions] if still_missing: raise HTTPException( status_code=400, detail=f"Could not fill positions {still_missing} for team {team_id}", ) # Find DH - use next available player not in lineup dh_player = None for player in roster: player_id = player.get("id") if player_id and player_id not in used_player_ids: dh_player = {"player_id": player_id, "position": "DH"} used_player_ids.add(player_id) break if not dh_player: raise HTTPException( status_code=400, detail=f"Not enough players for DH on team {team_id}", ) # Build final lineup with batting order # Batting order: C, 1B, 2B, 3B, SS, LF, CF, RF, DH batting_order_positions = ["C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "DH"] for order, pos in enumerate(batting_order_positions, start=1): if pos == "DH": player_data = dh_player else: player_data = lineup_positions[pos] await lineup_service.add_sba_player_to_lineup( game_id=game_id, team_id=team_id, player_id=player_data["player_id"], position=player_data["position"], batting_order=order, is_starter=True, ) # Add pitcher (no batting order in DH lineup) pitcher_data = lineup_positions["P"] await lineup_service.add_sba_player_to_lineup( game_id=game_id, team_id=team_id, player_id=pitcher_data["player_id"], position="P", batting_order=None, is_starter=True, ) logger.info(f"Built lineup for team {team_id} with {len(batting_order_positions) + 1} players") @router.post("/{game_id}/lineups", response_model=SubmitLineupsResponse) async def submit_lineups(game_id: str, request: SubmitLineupsRequest): """ Submit lineups for both teams. Accepts complete lineups for home and away teams. Supports both: - 9-player lineups (pitcher bats, no DH) - 10-player lineups (universal DH, pitcher doesn't bat) Args: game_id: Game identifier request: Lineup data for both teams Returns: Confirmation with lineup counts Raises: 400: Invalid lineup structure or validation error 404: Game not found 500: Database or API error """ try: # Validate game_id format try: game_uuid = UUID(game_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid game_id format") logger.info(f"Submitting lineups for game {game_id}") # Get game state from memory or load basic info from database state = state_manager.get_state(game_uuid) if not state: logger.info(f"Game {game_id} not in memory, loading from database") # Load basic game info from database db_ops = DatabaseOperations() game_data = await db_ops.load_game_state(game_uuid) if not game_data: raise HTTPException(status_code=404, detail=f"Game {game_id} not found") game_info = game_data['game'] # Recreate game state in memory (without lineups - we're about to add them) state = await state_manager.create_game( game_id=game_uuid, league_id=game_info['league_id'], home_team_id=game_info['home_team_id'], away_team_id=game_info['away_team_id'], home_team_is_ai=game_info.get('home_team_is_ai', False), away_team_is_ai=game_info.get('away_team_is_ai', False), auto_mode=game_info.get('auto_mode', False) ) logger.info(f"Recreated game {game_id} in memory from database") # Process home team lineup home_count = 0 for player in request.home_lineup: await lineup_service.add_sba_player_to_lineup( game_id=game_uuid, team_id=state.home_team_id, player_id=player.player_id, position=player.position, batting_order=player.batting_order, is_starter=True, ) home_count += 1 logger.info(f"Added {home_count} players to home team lineup") # Process away team lineup away_count = 0 for player in request.away_lineup: await lineup_service.add_sba_player_to_lineup( game_id=game_uuid, team_id=state.away_team_id, player_id=player.player_id, position=player.position, batting_order=player.batting_order, is_starter=True, ) away_count += 1 logger.info(f"Added {away_count} players to away team lineup") # Automatically start the game after lineups are submitted from app.core.game_engine import game_engine try: await game_engine.start_game(game_uuid) logger.info(f"Game {game_id} started successfully after lineup submission") except Exception as e: logger.warning(f"Failed to auto-start game {game_id}: {e}") # Don't fail the lineup submission if game start fails # User can manually start if needed return SubmitLineupsResponse( game_id=game_id, message=f"Lineups submitted successfully. Home: {home_count} players, Away: {away_count} players.", home_lineup_count=home_count, away_lineup_count=away_count, ) except HTTPException: raise except Exception as e: logger.exception(f"Failed to submit lineups for game {game_id}: {e}") raise HTTPException( status_code=500, detail=f"Failed to submit lineups: {str(e)}" )