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}") # Try to get game from state manager first (in-memory) state = state_manager.get_state(game_uuid) if state: 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, } # Fallback: Load from database if not in memory logger.info(f"Game {game_id} not in memory, loading from database") 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).where(Game.id == game_uuid) ) game = result.scalar_one_or_none() if not game: raise HTTPException(status_code=404, detail=f"Game {game_id} not found") return { "game_id": game_id, "status": game.status, "home_team_id": game.home_team_id, "away_team_id": game.away_team_id, "league_id": game.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" ) # Fetch team display info from SBA API and build game_metadata # This persists team data so it's always available regardless of season teams_data = await sba_api_client.get_teams_by_ids( [request.home_team_id, request.away_team_id], season=request.season ) # Validate that we successfully fetched both teams missing_teams = [ tid for tid in [request.home_team_id, request.away_team_id] if tid not in teams_data ] if missing_teams: logger.error( f"Failed to fetch team data for IDs {missing_teams} in season {request.season}" ) raise HTTPException( status_code=400, detail=f"Could not fetch team data for team IDs: {missing_teams}. " f"Verify teams exist in season {request.season}." ) home_team_data = teams_data.get(request.home_team_id, {}) away_team_data = teams_data.get(request.away_team_id, {}) game_metadata = { "home_team": { "lname": home_team_data.get("lname"), "sname": home_team_data.get("sname"), "abbrev": home_team_data.get("abbrev"), "color": home_team_data.get("color"), "thumbnail": home_team_data.get("thumbnail"), }, "away_team": { "lname": away_team_data.get("lname"), "sname": away_team_data.get("sname"), "abbrev": away_team_data.get("abbrev"), "color": away_team_data.get("color"), "thumbnail": away_team_data.get("thumbnail"), }, } # 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, game_metadata=game_metadata, ) # 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", game_metadata=game_metadata, ) 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})" ) # Fetch team display info from SBA API and build game_metadata # This persists team data so it's always available regardless of season teams_data = await sba_api_client.get_teams_by_ids( [home_team_id, away_team_id], season=13 ) # Validate that we successfully fetched both teams missing_teams = [ tid for tid in [home_team_id, away_team_id] if tid not in teams_data ] if missing_teams: logger.error( f"Quick-create: Failed to fetch team data for IDs {missing_teams}" ) raise HTTPException( status_code=400, detail=f"Could not fetch team data for team IDs: {missing_teams}. " f"Verify teams exist in season 13." ) home_team_data = teams_data.get(home_team_id, {}) away_team_data = teams_data.get(away_team_id, {}) game_metadata = { "home_team": { "lname": home_team_data.get("lname"), "sname": home_team_data.get("sname"), "abbrev": home_team_data.get("abbrev"), "color": home_team_data.get("color"), "thumbnail": home_team_data.get("thumbnail"), }, "away_team": { "lname": away_team_data.get("lname"), "sname": away_team_data.get("sname"), "abbrev": away_team_data.get("abbrev"), "color": away_team_data.get("color"), "thumbnail": away_team_data.get("thumbnail"), }, } # 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, game_metadata=game_metadata, ) # 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, game_metadata=game_metadata, ) 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 recover from database state = state_manager.get_state(game_uuid) if not state: logger.info(f"Game {game_id} not in memory, recovering from database") # Use recover_game to properly load game state state = await state_manager.recover_game(game_uuid) if not state: raise HTTPException(status_code=404, detail=f"Game {game_id} not found") logger.info(f"Recovered game {game_id} 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)}" ) class BenchPlayerRequest(BaseModel): """Single bench player in lineup request""" player_id: int = Field(..., description="SBA player ID") class SubmitTeamLineupRequest(BaseModel): """Request model for submitting a single team's lineup""" team_id: int = Field(..., description="Team ID submitting the lineup") lineup: list[LineupPlayerRequest] = Field( ..., min_length=9, max_length=10, description="Team's starting lineup (9-10 players)" ) bench: list[BenchPlayerRequest] = Field( default_factory=list, description="Bench players (not in starting lineup)" ) @field_validator("lineup") @classmethod def validate_lineup(cls, v: list[LineupPlayerRequest]) -> list[LineupPlayerRequest]: """Validate lineup structure - same rules as SubmitLineupsRequest""" 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 SubmitTeamLineupResponse(BaseModel): """Response model for single team lineup submission""" game_id: str team_id: int message: str lineup_count: int game_ready: bool # True if both teams have submitted and game can start game_started: bool # True if game was auto-started class LineupPlayerInfo(BaseModel): """Individual player in a lineup""" player_id: int position: str batting_order: int | None class LineupStatusResponse(BaseModel): """Response model for lineup status check""" game_id: str home_team_id: int away_team_id: int home_lineup_submitted: bool away_lineup_submitted: bool home_lineup_count: int away_lineup_count: int game_status: str # Include actual lineup data for display home_lineup: list[LineupPlayerInfo] = [] away_lineup: list[LineupPlayerInfo] = [] @router.get("/{game_id}/lineup-status", response_model=LineupStatusResponse) async def get_lineup_status(game_id: str): """ Check lineup submission status for a game. Returns whether each team has submitted their lineup. Works for pending games (before game state is fully initialized). """ try: # Validate game_id format try: game_uuid = UUID(game_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid game_id format") # First check in-memory state (if game is active) state = state_manager.get_state(game_uuid) if state: # Game is in memory, use state_manager for lineup info home_lineup = state_manager.get_lineup(game_uuid, state.home_team_id) away_lineup = state_manager.get_lineup(game_uuid, state.away_team_id) home_count = len(home_lineup.players) if home_lineup else 0 away_count = len(away_lineup.players) if away_lineup else 0 # Convert to response format (card_id is player_id for SBA) home_lineup_data = [ LineupPlayerInfo( player_id=p.card_id, position=p.position, batting_order=p.batting_order ) for p in (home_lineup.players if home_lineup else []) ] away_lineup_data = [ LineupPlayerInfo( player_id=p.card_id, position=p.position, batting_order=p.batting_order ) for p in (away_lineup.players if away_lineup else []) ] # Safeguard: Auto-start game if both lineups ready but game still pending game_status = state.status if home_count >= 9 and away_count >= 9 and game_status == "pending": try: logger.info(f"Auto-starting game {game_id} (in-memory) - both lineups ready but game was pending") await game_engine.start_game(game_uuid) game_status = "active" except Exception as start_error: logger.warning(f"Failed to auto-start game {game_id}: {start_error}") return LineupStatusResponse( game_id=game_id, home_team_id=state.home_team_id, away_team_id=state.away_team_id, home_lineup_submitted=home_count >= 9, away_lineup_submitted=away_count >= 9, home_lineup_count=home_count, away_lineup_count=away_count, game_status=game_status, home_lineup=home_lineup_data, away_lineup=away_lineup_data, ) # Game not in memory - query database directly # This works for pending games where GameState can't be fully constructed db_ops = DatabaseOperations() game = await db_ops.get_game(game_uuid) if not game: raise HTTPException(status_code=404, detail=f"Game {game_id} not found") # Get lineup data from database home_lineups = await db_ops.get_active_lineup(game_uuid, game.home_team_id) away_lineups = await db_ops.get_active_lineup(game_uuid, game.away_team_id) home_count = len(home_lineups) away_count = len(away_lineups) # Convert to response format home_lineup_data = [ LineupPlayerInfo( player_id=lineup.player_id, position=lineup.position, batting_order=lineup.batting_order ) for lineup in home_lineups if lineup.player_id is not None # SBA uses player_id ] away_lineup_data = [ LineupPlayerInfo( player_id=lineup.player_id, position=lineup.position, batting_order=lineup.batting_order ) for lineup in away_lineups if lineup.player_id is not None ] # Safeguard: Auto-start game if both lineups are ready but game is still pending # This handles edge cases where lineups were submitted before the auto-start fix game_status = game.status if home_count >= 9 and away_count >= 9 and game_status == "pending": try: logger.info(f"Auto-starting game {game_id} - both lineups ready but game was pending") # First recover game into state manager (required before start_game) await state_manager.recover_game(game_uuid) await game_engine.start_game(game_uuid) game_status = "active" except Exception as start_error: logger.warning(f"Failed to auto-start game {game_id}: {start_error}") # Continue with pending status - user can retry return LineupStatusResponse( game_id=game_id, home_team_id=game.home_team_id, away_team_id=game.away_team_id, home_lineup_submitted=home_count >= 9, away_lineup_submitted=away_count >= 9, home_lineup_count=home_count, away_lineup_count=away_count, game_status=game_status, home_lineup=home_lineup_data, away_lineup=away_lineup_data, ) except HTTPException: raise except Exception as e: logger.exception(f"Failed to get lineup status for game {game_id}: {e}") raise HTTPException(status_code=500, detail=f"Failed to get lineup status: {str(e)}") @router.post("/{game_id}/lineup", response_model=SubmitTeamLineupResponse) async def submit_team_lineup(game_id: str, request: SubmitTeamLineupRequest): """ Submit lineup for a single team. Accepts lineup for one team at a time. Game auto-starts when both teams have submitted their lineups. Args: game_id: Game identifier request: Team ID and lineup data Returns: Confirmation with lineup count and game status Raises: 400: Invalid lineup structure, team not in game, or lineup already submitted 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 lineup for team {request.team_id} in game {game_id}") # Get game state from memory or recover from database state = state_manager.get_state(game_uuid) if not state: logger.info(f"Game {game_id} not in memory, recovering from database") # Use recover_game to properly load game state AND existing lineups state = await state_manager.recover_game(game_uuid) if not state: raise HTTPException(status_code=404, detail=f"Game {game_id} not found") logger.info(f"Recovered game {game_id} from database") # Validate team is part of this game if request.team_id not in [state.home_team_id, state.away_team_id]: raise HTTPException( status_code=400, detail=f"Team {request.team_id} is not part of game {game_id}" ) # Check if lineup already submitted for this team is_home = request.team_id == state.home_team_id existing_lineup = state_manager.get_lineup(game_uuid, request.team_id) if existing_lineup and len(existing_lineup.players) > 0: raise HTTPException( status_code=400, detail=f"Lineup already submitted for team {request.team_id}" ) # Step 1: Collect all player IDs (starters + bench) for batch API fetch all_player_ids = [p.player_id for p in request.lineup] all_player_ids.extend([p.player_id for p in request.bench]) # Step 2: Fetch all player data from SBA API to get positions player_data = {} if all_player_ids: try: player_data = await sba_api_client.get_players_batch(all_player_ids) logger.info(f"Fetched {len(player_data)}/{len(all_player_ids)} players from SBA API") except Exception as e: logger.warning(f"Failed to fetch player data from SBA API: {e}") # Continue - roster entries will have empty positions # Step 3: Add ALL players to RosterLink with their natural positions and cached data db_ops = DatabaseOperations() roster_count = 0 for player_id in all_player_ids: player_positions: list[str] = [] cached_player_data: dict | None = None if player_id in player_data: player = player_data[player_id] player_positions = player.get_positions() # Cache essential player data to avoid runtime API calls cached_player_data = { "name": player.name, "image": player.get_image_url(), "headshot": player.headshot or "", } await db_ops.add_sba_roster_player( game_id=game_uuid, player_id=player_id, team_id=request.team_id, player_positions=player_positions, player_data=cached_player_data, ) roster_count += 1 logger.info(f"Added {roster_count} players to roster for team {request.team_id}") # Step 4: Add only STARTERS to active Lineup table player_count = 0 for player in request.lineup: await lineup_service.add_sba_player_to_lineup( game_id=game_uuid, team_id=request.team_id, player_id=player.player_id, position=player.position, batting_order=player.batting_order, is_starter=True, ) player_count += 1 logger.info(f"Added {player_count} starters to team {request.team_id} lineup") # Note: Bench players are NOT added to Lineup - they're derived from # RosterLink players not in active Lineup via get_bench_players() # Load lineup from DB and cache in state_manager for subsequent checks team_lineup = await lineup_service.load_team_lineup_with_player_data( game_id=game_uuid, team_id=request.team_id, league_id=state.league_id, ) if team_lineup: state_manager.set_lineup(game_uuid, request.team_id, team_lineup) logger.info(f"Cached lineup for team {request.team_id} in state_manager") # Check if both teams now have lineups home_lineup = state_manager.get_lineup(game_uuid, state.home_team_id) away_lineup = state_manager.get_lineup(game_uuid, state.away_team_id) home_ready = home_lineup is not None and len(home_lineup.players) >= 9 away_ready = away_lineup is not None and len(away_lineup.players) >= 9 game_ready = home_ready and away_ready game_started = False if game_ready: # Auto-start the game from app.core.game_engine import game_engine try: await game_engine.start_game(game_uuid) game_started = True logger.info(f"Game {game_id} auto-started after both lineups submitted") except Exception as e: logger.warning(f"Failed to auto-start game {game_id}: {e}") team_type = "home" if is_home else "away" other_team = "away" if is_home else "home" if game_started: message = f"{team_type.capitalize()} lineup submitted. Game started!" elif game_ready: message = f"{team_type.capitalize()} lineup submitted. Both teams ready." else: message = f"{team_type.capitalize()} lineup submitted. Waiting for {other_team} team." return SubmitTeamLineupResponse( game_id=game_id, team_id=request.team_id, message=message, lineup_count=player_count, game_ready=game_ready, game_started=game_started, ) except HTTPException: raise except Exception as e: logger.exception(f"Failed to submit lineup for team {request.team_id} in game {game_id}: {e}") raise HTTPException( status_code=500, detail=f"Failed to submit lineup: {str(e)}" )