From 1f5e290d8b2710c8ae54d4197e1014f124845c3d Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 16 Jan 2026 14:08:39 -0600 Subject: [PATCH] CLAUDE: Add game page tabs with lineup persistence and per-team submission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactor game page into tab container with Game, Lineups, Stats tabs - Extract GamePlay, LineupBuilder, GameStats components from page - Add manager-aware default tab logic (pending + manager → Lineups tab) - Implement per-team lineup submission (each team submits independently) - Add lineup-status endpoint with actual lineup data for recovery - Fix lineup persistence across tab switches and page refreshes - Query database directly for pending games (avoids GameState validation) - Add userTeamIds to auth store for manager detection Co-Authored-By: Claude Opus 4.5 --- backend/app/api/routes/games.py | 395 +++++++- frontend-sba/components/Game/GamePlay.vue | 817 ++++++++++++++++ frontend-sba/components/Game/GameStats.vue | 39 + .../Game/LineupBuilder.vue} | 321 +++++- frontend-sba/pages/games/[id].vue | 919 +++--------------- frontend-sba/store/auth.ts | 2 + 6 files changed, 1609 insertions(+), 884 deletions(-) create mode 100644 frontend-sba/components/Game/GamePlay.vue create mode 100644 frontend-sba/components/Game/GameStats.vue rename frontend-sba/{pages/games/lineup/[id].vue => components/Game/LineupBuilder.vue} (79%) diff --git a/backend/app/api/routes/games.py b/backend/app/api/routes/games.py index 18275ce..32936e9 100644 --- a/backend/app/api/routes/games.py +++ b/backend/app/api/routes/games.py @@ -258,19 +258,39 @@ async def get_game(game_id: str): 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") + # 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, + } - state = state_manager._states[game_uuid] + # 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 - 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, - } + 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 @@ -628,31 +648,18 @@ async def submit_lineups(game_id: str, request: SubmitLineupsRequest): logger.info(f"Submitting lineups for game {game_id}") - # Get game state from memory or load basic info from database + # 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, loading from database") + logger.info(f"Game {game_id} not in memory, recovering from database") - # Load basic game info from database - db_ops = DatabaseOperations() - game_data = await db_ops.load_game_state(game_uuid) + # Use recover_game to properly load game state + state = await state_manager.recover_game(game_uuid) - if not game_data: + if not state: 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") + logger.info(f"Recovered game {game_id} from database") # Process home team lineup home_count = 0 @@ -708,3 +715,331 @@ async def submit_lineups(game_id: str, request: SubmitLineupsRequest): raise HTTPException( status_code=500, detail=f"Failed to submit lineups: {str(e)}" ) + + +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)" + ) + + @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 []) + ] + + 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=state.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 + ] + + 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}" + ) + + # Process lineup + 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} players to team {request.team_id} lineup") + + # 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)}" + ) diff --git a/frontend-sba/components/Game/GamePlay.vue b/frontend-sba/components/Game/GamePlay.vue new file mode 100644 index 0000000..3bc4bdb --- /dev/null +++ b/frontend-sba/components/Game/GamePlay.vue @@ -0,0 +1,817 @@ + + + + + diff --git a/frontend-sba/components/Game/GameStats.vue b/frontend-sba/components/Game/GameStats.vue new file mode 100644 index 0000000..302dfc8 --- /dev/null +++ b/frontend-sba/components/Game/GameStats.vue @@ -0,0 +1,39 @@ + + + diff --git a/frontend-sba/pages/games/lineup/[id].vue b/frontend-sba/components/Game/LineupBuilder.vue similarity index 79% rename from frontend-sba/pages/games/lineup/[id].vue rename to frontend-sba/components/Game/LineupBuilder.vue index d358e16..3ebf2b8 100644 --- a/frontend-sba/pages/games/lineup/[id].vue +++ b/frontend-sba/components/Game/LineupBuilder.vue @@ -1,18 +1,18 @@