""" 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 Optional, TYPE_CHECKING from uuid import UUID from app.core.substitution_rules import SubstitutionRules, ValidationResult from app.core.state_manager import state_manager from app.models.game_models import LineupPlayerState, TeamLineupState 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: Optional[int] = None player_out_lineup_id: Optional[int] = None player_in_card_id: Optional[int] = None new_position: Optional[str] = None new_batting_order: Optional[int] = None updated_lineup: Optional[TeamLineupState] = None error_message: Optional[str] = None error_code: Optional[str] = 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 Exception as e: logger.error(f"Database error during pinch hit substitution: {e}", exc_info=True) return SubstitutionResult( success=False, error_message=f"Database error: {str(e)}", error_code="DB_ERROR" ) # STEP 4: Update IN-MEMORY STATE SECOND try: # Mark old player inactive player_out.is_active = False # 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 ) # 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_lineup_id == player_out_lineup_id: state.current_batter_lineup_id = new_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 Exception as e: logger.error( f"State update error during pinch hit substitution: {e}", exc_info=True ) # Database is already updated - this is a state sync issue # Log error but return partial success (DB is source of truth) return SubstitutionResult( success=False, new_lineup_id=new_lineup_id, error_message=f"State sync error: {str(e)}", error_code="STATE_SYNC_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: Optional[int] = 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 Exception as e: logger.error(f"Database error during defensive replacement: {e}", exc_info=True) return SubstitutionResult( success=False, error_message=f"Database error: {str(e)}", error_code="DB_ERROR" ) # STEP 4: Update IN-MEMORY STATE SECOND try: # Mark old player inactive player_out.is_active = False # 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 ) # 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_lineup_id == player_out_lineup_id: state.current_pitcher_lineup_id = new_lineup_id state.current_pitcher = new_player state_manager.update_state(game_id, state) elif player_out.position == 'C' and state.current_catcher_lineup_id == player_out_lineup_id: state.current_catcher_lineup_id = new_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 Exception as e: logger.error( f"State update error during defensive replacement: {e}", exc_info=True ) return SubstitutionResult( success=False, new_lineup_id=new_lineup_id, error_message=f"State sync error: {str(e)}", error_code="STATE_SYNC_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 Exception as e: logger.error(f"Database error during pitching change: {e}", exc_info=True) return SubstitutionResult( success=False, error_message=f"Database error: {str(e)}", error_code="DB_ERROR" ) # STEP 4: Update IN-MEMORY STATE SECOND try: # Mark old pitcher inactive pitcher_out.is_active = False # 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 ) # 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_lineup_id = new_lineup_id 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 Exception as e: logger.error( f"State update error during pitching change: {e}", exc_info=True ) return SubstitutionResult( success=False, new_lineup_id=new_lineup_id, error_message=f"State sync error: {str(e)}", error_code="STATE_SYNC_ERROR" )