""" Substitution Manager - Orchestrates player substitutions with DB-first pattern. Follows the established pattern: 1. Validate (in-memory) 2. Update DATABASE FIRST 3. Update in-memory state SECOND 4. WebSocket broadcasts happen in handler Author: Claude Date: 2025-11-03 """ import logging from dataclasses import dataclass from typing import TYPE_CHECKING from uuid import UUID from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError from app.core.state_manager import state_manager from app.core.substitution_rules import SubstitutionRules from app.models.game_models import LineupPlayerState, TeamLineupState from app.services.lineup_service import lineup_service if TYPE_CHECKING: from app.database.operations import DatabaseOperations logger = logging.getLogger(f"{__name__}.SubstitutionManager") @dataclass class SubstitutionResult: """Result of a substitution operation""" success: bool new_lineup_id: int | None = None player_out_lineup_id: int | None = None player_in_card_id: int | None = None new_position: str | None = None new_batting_order: int | None = None updated_lineup: TeamLineupState | None = None error_message: str | None = None error_code: str | None = None class SubstitutionManager: """ Manages player substitutions with state + database sync. Pattern: DB-first → State-second 1. Validate using in-memory state 2. Update database FIRST 3. Update in-memory state SECOND 4. Return result (WebSocket broadcast happens in handler) """ def __init__(self, db_ops: "DatabaseOperations"): """ Initialize substitution manager. Args: db_ops: Database operations instance """ self.db_ops = db_ops self.rules = SubstitutionRules() async def pinch_hit( self, game_id: UUID, player_out_lineup_id: int, player_in_card_id: int, team_id: int, ) -> SubstitutionResult: """ Execute pinch hitter substitution. Flow: 1. Load game state + roster (in-memory) 2. Validate substitution (rules check) 3. Update database FIRST (create new lineup entry, mark old inactive) 4. Update in-memory state SECOND (update lineup cache) 5. Return result Args: game_id: Game identifier player_out_lineup_id: Lineup ID of player being replaced (must be current batter) player_in_card_id: Card ID of incoming player team_id: Team identifier Returns: SubstitutionResult with success status and details """ logger.info( f"Pinch hit request: game={game_id}, " f"out={player_out_lineup_id}, in={player_in_card_id}, team={team_id}" ) # STEP 1: Load game state and roster state = state_manager.get_state(game_id) if not state: return SubstitutionResult( success=False, error_message=f"Game {game_id} not found", error_code="GAME_NOT_FOUND", ) roster = state_manager.get_lineup(game_id, team_id) if not roster: return SubstitutionResult( success=False, error_message=f"Roster not found for team {team_id}", error_code="ROSTER_NOT_FOUND", ) player_out = roster.get_player_by_lineup_id(player_out_lineup_id) if not player_out: return SubstitutionResult( success=False, error_message=f"Player with lineup_id {player_out_lineup_id} not found", error_code="PLAYER_NOT_FOUND", ) # STEP 2: Validate substitution validation = self.rules.validate_pinch_hitter( state=state, player_out=player_out, player_in_card_id=player_in_card_id, roster=roster, ) if not validation.is_valid: return SubstitutionResult( success=False, error_message=validation.error_message, error_code=validation.error_code, ) # STEP 3: Update DATABASE FIRST try: new_lineup_id = await self.db_ops.create_substitution( game_id=game_id, team_id=team_id, player_out_lineup_id=player_out_lineup_id, player_in_card_id=player_in_card_id, position=player_out.position, # Pinch hitter takes same defensive position batting_order=player_out.batting_order, # Takes same spot in order inning=state.inning, play_number=state.play_count, ) except IntegrityError as e: logger.error(f"Integrity error during pinch hit substitution: {e}") return SubstitutionResult( success=False, error_message="Player substitution conflict - player may already be active", error_code="DB_INTEGRITY_ERROR", ) except OperationalError as e: logger.error(f"Database connection error during pinch hit: {e}") return SubstitutionResult( success=False, error_message="Database connection error - please retry", error_code="DB_CONNECTION_ERROR", ) except SQLAlchemyError as e: logger.error( f"Database error during pinch hit substitution: {e}", exc_info=True ) return SubstitutionResult( success=False, error_message="Database error occurred", error_code="DB_ERROR", ) # STEP 4: Update IN-MEMORY STATE SECOND try: # Mark old player inactive player_out.is_active = False # Fetch player data for SBA league player_name = f"Player #{player_in_card_id}" player_image = "" if state.league_id == "sba": player_name, player_image = await lineup_service.get_sba_player_data( player_in_card_id ) # Create new player entry new_player = LineupPlayerState( lineup_id=new_lineup_id, card_id=player_in_card_id, position=player_out.position, batting_order=player_out.batting_order, is_active=True, is_starter=False, player_name=player_name, player_image=player_image, ) # Add to roster roster.players.append(new_player) # Update cache state_manager.set_lineup(game_id, team_id, roster) # Update current_batter if this is the current batter if ( state.current_batter and state.current_batter.lineup_id == player_out_lineup_id ): state.current_batter = new_player # Update object reference state_manager.update_state(game_id, state) logger.info( f"Pinch hit successful: {player_out.card_id} (lineup {player_out_lineup_id}) → " f"{player_in_card_id} (new lineup {new_lineup_id})" ) return SubstitutionResult( success=True, new_lineup_id=new_lineup_id, player_out_lineup_id=player_out_lineup_id, player_in_card_id=player_in_card_id, new_position=player_out.position, new_batting_order=player_out.batting_order, updated_lineup=roster, ) except (KeyError, AttributeError) as e: # Missing expected data in state - indicates corrupted state logger.error( f"State corruption during pinch hit substitution: {e}", exc_info=True ) return SubstitutionResult( success=False, new_lineup_id=new_lineup_id, error_message="State corruption detected - game state may need recovery", error_code="STATE_CORRUPTION_ERROR", ) except ValueError as e: # Invalid value during state update logger.error(f"Invalid value during pinch hit state update: {e}") return SubstitutionResult( success=False, new_lineup_id=new_lineup_id, error_message=f"Invalid state update: {str(e)}", error_code="STATE_VALIDATION_ERROR", ) async def defensive_replace( self, game_id: UUID, player_out_lineup_id: int, player_in_card_id: int, new_position: str, team_id: int, new_batting_order: int | None = None, allow_mid_inning: bool = False, ) -> SubstitutionResult: """ Execute defensive replacement. Flow: Same as pinch_hit (validate → DB → state) Args: game_id: Game identifier player_out_lineup_id: Lineup ID of player being replaced player_in_card_id: Card ID of incoming player new_position: Position for incoming player (can differ from player_out) team_id: Team identifier new_batting_order: Optional - if provided, changes batting order spot allow_mid_inning: If True, allows mid-inning substitution (for injuries) Returns: SubstitutionResult with success status and details """ logger.info( f"Defensive replacement request: game={game_id}, " f"out={player_out_lineup_id}, in={player_in_card_id}, " f"position={new_position}, team={team_id}" ) # STEP 1: Load game state and roster state = state_manager.get_state(game_id) if not state: return SubstitutionResult( success=False, error_message=f"Game {game_id} not found", error_code="GAME_NOT_FOUND", ) roster = state_manager.get_lineup(game_id, team_id) if not roster: return SubstitutionResult( success=False, error_message=f"Roster not found for team {team_id}", error_code="ROSTER_NOT_FOUND", ) player_out = roster.get_player_by_lineup_id(player_out_lineup_id) if not player_out: return SubstitutionResult( success=False, error_message=f"Player with lineup_id {player_out_lineup_id} not found", error_code="PLAYER_NOT_FOUND", ) # STEP 2: Validate substitution validation = self.rules.validate_defensive_replacement( state=state, player_out=player_out, player_in_card_id=player_in_card_id, new_position=new_position, roster=roster, allow_mid_inning=allow_mid_inning, ) if not validation.is_valid: return SubstitutionResult( success=False, error_message=validation.error_message, error_code=validation.error_code, ) # Determine batting order (keep old if not specified) batting_order = ( new_batting_order if new_batting_order is not None else player_out.batting_order ) # STEP 3: Update DATABASE FIRST try: new_lineup_id = await self.db_ops.create_substitution( game_id=game_id, team_id=team_id, player_out_lineup_id=player_out_lineup_id, player_in_card_id=player_in_card_id, position=new_position, batting_order=batting_order, inning=state.inning, play_number=state.play_count, ) except IntegrityError as e: logger.error(f"Integrity error during defensive replacement: {e}") return SubstitutionResult( success=False, error_message="Player substitution conflict - player may already be active", error_code="DB_INTEGRITY_ERROR", ) except OperationalError as e: logger.error(f"Database connection error during defensive replacement: {e}") return SubstitutionResult( success=False, error_message="Database connection error - please retry", error_code="DB_CONNECTION_ERROR", ) except SQLAlchemyError as e: logger.error( f"Database error during defensive replacement: {e}", exc_info=True ) return SubstitutionResult( success=False, error_message="Database error occurred", error_code="DB_ERROR", ) # STEP 4: Update IN-MEMORY STATE SECOND try: # Mark old player inactive player_out.is_active = False # Fetch player data for SBA league player_name = f"Player #{player_in_card_id}" player_image = "" if state.league_id == "sba": player_name, player_image = await lineup_service.get_sba_player_data( player_in_card_id ) # Create new player entry new_player = LineupPlayerState( lineup_id=new_lineup_id, card_id=player_in_card_id, position=new_position, batting_order=batting_order, is_active=True, is_starter=False, player_name=player_name, player_image=player_image, ) # Add to roster roster.players.append(new_player) # Update cache state_manager.set_lineup(game_id, team_id, roster) # Update current pitcher/catcher if this affects them if ( player_out.position == "P" and state.current_pitcher and state.current_pitcher.lineup_id == player_out_lineup_id ): state.current_pitcher = new_player state_manager.update_state(game_id, state) elif ( player_out.position == "C" and state.current_catcher and state.current_catcher.lineup_id == player_out_lineup_id ): state.current_catcher = new_player state_manager.update_state(game_id, state) logger.info( f"Defensive replacement successful: {player_out.card_id} ({player_out.position}) → " f"{player_in_card_id} ({new_position})" ) return SubstitutionResult( success=True, new_lineup_id=new_lineup_id, player_out_lineup_id=player_out_lineup_id, player_in_card_id=player_in_card_id, new_position=new_position, new_batting_order=batting_order, updated_lineup=roster, ) except (KeyError, AttributeError) as e: # Missing expected data in state - indicates corrupted state logger.error( f"State corruption during defensive replacement: {e}", exc_info=True ) return SubstitutionResult( success=False, new_lineup_id=new_lineup_id, error_message="State corruption detected - game state may need recovery", error_code="STATE_CORRUPTION_ERROR", ) except ValueError as e: # Invalid value during state update logger.error(f"Invalid value during defensive replacement state update: {e}") return SubstitutionResult( success=False, new_lineup_id=new_lineup_id, error_message=f"Invalid state update: {str(e)}", error_code="STATE_VALIDATION_ERROR", ) async def change_pitcher( self, game_id: UUID, pitcher_out_lineup_id: int, pitcher_in_card_id: int, team_id: int, force_change: bool = False, ) -> SubstitutionResult: """ Execute pitching change. Special case of defensive replacement for pitchers. Always changes position 'P', maintains batting order. Flow: Same as pinch_hit (validate → DB → state) Args: game_id: Game identifier pitcher_out_lineup_id: Lineup ID of current pitcher pitcher_in_card_id: Card ID of incoming pitcher team_id: Team identifier force_change: If True, allows immediate change (for injuries) Returns: SubstitutionResult with success status and details """ logger.info( f"Pitching change request: game={game_id}, " f"out={pitcher_out_lineup_id}, in={pitcher_in_card_id}, team={team_id}" ) # STEP 1: Load game state and roster state = state_manager.get_state(game_id) if not state: return SubstitutionResult( success=False, error_message=f"Game {game_id} not found", error_code="GAME_NOT_FOUND", ) roster = state_manager.get_lineup(game_id, team_id) if not roster: return SubstitutionResult( success=False, error_message=f"Roster not found for team {team_id}", error_code="ROSTER_NOT_FOUND", ) pitcher_out = roster.get_player_by_lineup_id(pitcher_out_lineup_id) if not pitcher_out: return SubstitutionResult( success=False, error_message=f"Player with lineup_id {pitcher_out_lineup_id} not found", error_code="PLAYER_NOT_FOUND", ) # STEP 2: Validate substitution validation = self.rules.validate_pitching_change( state=state, pitcher_out=pitcher_out, pitcher_in_card_id=pitcher_in_card_id, roster=roster, force_change=force_change, ) if not validation.is_valid: return SubstitutionResult( success=False, error_message=validation.error_message, error_code=validation.error_code, ) # STEP 3: Update DATABASE FIRST try: new_lineup_id = await self.db_ops.create_substitution( game_id=game_id, team_id=team_id, player_out_lineup_id=pitcher_out_lineup_id, player_in_card_id=pitcher_in_card_id, position="P", # Always pitcher batting_order=pitcher_out.batting_order, # Maintains batting order inning=state.inning, play_number=state.play_count, ) except IntegrityError as e: logger.error(f"Integrity error during pitching change: {e}") return SubstitutionResult( success=False, error_message="Pitching change conflict - pitcher may already be active", error_code="DB_INTEGRITY_ERROR", ) except OperationalError as e: logger.error(f"Database connection error during pitching change: {e}") return SubstitutionResult( success=False, error_message="Database connection error - please retry", error_code="DB_CONNECTION_ERROR", ) except SQLAlchemyError as e: logger.error(f"Database error during pitching change: {e}", exc_info=True) return SubstitutionResult( success=False, error_message="Database error occurred", error_code="DB_ERROR", ) # STEP 4: Update IN-MEMORY STATE SECOND try: # Mark old pitcher inactive pitcher_out.is_active = False # Fetch player data for SBA league player_name = f"Player #{pitcher_in_card_id}" player_image = "" if state.league_id == "sba": player_name, player_image = await lineup_service.get_sba_player_data( pitcher_in_card_id ) # Create new pitcher entry new_pitcher = LineupPlayerState( lineup_id=new_lineup_id, card_id=pitcher_in_card_id, position="P", batting_order=pitcher_out.batting_order, is_active=True, is_starter=False, player_name=player_name, player_image=player_image, ) # Add to roster roster.players.append(new_pitcher) # Update cache state_manager.set_lineup(game_id, team_id, roster) # Update current pitcher in game state state.current_pitcher = new_pitcher state_manager.update_state(game_id, state) logger.info( f"Pitching change successful: {pitcher_out.card_id} (lineup {pitcher_out_lineup_id}) → " f"{pitcher_in_card_id} (new lineup {new_lineup_id})" ) return SubstitutionResult( success=True, new_lineup_id=new_lineup_id, player_out_lineup_id=pitcher_out_lineup_id, player_in_card_id=pitcher_in_card_id, new_position="P", new_batting_order=pitcher_out.batting_order, updated_lineup=roster, ) except (KeyError, AttributeError) as e: # Missing expected data in state - indicates corrupted state logger.error( f"State corruption during pitching change: {e}", exc_info=True ) return SubstitutionResult( success=False, new_lineup_id=new_lineup_id, error_message="State corruption detected - game state may need recovery", error_code="STATE_CORRUPTION_ERROR", ) except ValueError as e: # Invalid value during state update logger.error(f"Invalid value during pitching change state update: {e}") return SubstitutionResult( success=False, new_lineup_id=new_lineup_id, error_message=f"Invalid state update: {str(e)}", error_code="STATE_VALIDATION_ERROR", )