""" Substitution Rules - Baseball substitution validation logic. Enforces official baseball substitution rules: - Players can only enter game once (no re-entry) - Substitutes must be from active roster - Substitutes must not already be in game - Substitutions respect game flow (timing, positions) Author: Claude Date: 2025-11-03 """ import logging from dataclasses import dataclass from app.models.game_models import GameState, LineupPlayerState, TeamLineupState logger = logging.getLogger(f"{__name__}.SubstitutionRules") @dataclass class ValidationResult: """Result of a substitution validation check""" is_valid: bool error_message: str | None = None error_code: str | None = None class SubstitutionRules: """ Baseball substitution rules validation. Enforces official rules: - No re-entry (once removed, player cannot return) - Roster eligibility (must be on roster) - Active status (substitute must be inactive) - Game flow (timing and situational rules) """ @staticmethod def validate_pinch_hitter( state: GameState, player_out: LineupPlayerState, player_in_card_id: int, roster: TeamLineupState, ) -> ValidationResult: """ Validate pinch hitter substitution. Rules: 1. Substitute must be in roster 2. Substitute must be inactive (not already in game) 3. Player being replaced must be current batter 4. Substituted player cannot re-enter Args: state: Current game state player_out: Player being replaced (must be current batter) player_in_card_id: Card ID of incoming player roster: Team's complete roster Returns: ValidationResult with is_valid and optional error message """ # Check player_out is current batter if player_out.lineup_id != state.current_batter.lineup_id: return ValidationResult( is_valid=False, error_message=f"Can only pinch hit for current batter. Current batter lineup_id: {state.current_batter.lineup_id}", error_code="NOT_CURRENT_BATTER", ) # Check player_out is active if not player_out.is_active: return ValidationResult( is_valid=False, error_message="Cannot substitute for player who is already out of game", error_code="PLAYER_ALREADY_OUT", ) # Check substitute is in roster player_in = roster.get_player_by_card_id(player_in_card_id) if not player_in: return ValidationResult( is_valid=False, error_message=f"Player with card_id {player_in_card_id} not found in roster", error_code="NOT_IN_ROSTER", ) # Check substitute is not already active if player_in.is_active: return ValidationResult( is_valid=False, error_message=f"Player {player_in.card_id} is already in the game", error_code="ALREADY_ACTIVE", ) # All checks passed logger.info( f"Pinch hitter validation passed: " f"{player_out.card_id} (lineup_id {player_out.lineup_id}) → " f"{player_in.card_id} (card_id {player_in_card_id})" ) return ValidationResult(is_valid=True) @staticmethod def validate_defensive_replacement( state: GameState, player_out: LineupPlayerState, player_in_card_id: int, new_position: str, roster: TeamLineupState, allow_mid_inning: bool = False, ) -> ValidationResult: """ Validate defensive replacement. Rules: 1. Substitute must be in roster 2. Substitute must be inactive 3. Substituted player cannot re-enter 4. Generally only between half-innings (unless allow_mid_inning=True for injury) 5. New position must be valid baseball position Args: state: Current game state player_out: Player being replaced player_in_card_id: Card ID of incoming player new_position: Position for incoming player (usually same as player_out) roster: Team's complete roster allow_mid_inning: If True, allows mid-inning substitution (for injuries) Returns: ValidationResult with is_valid and optional error message """ # Check player_out is active if not player_out.is_active: return ValidationResult( is_valid=False, error_message="Cannot substitute for player who is already out of game", error_code="PLAYER_ALREADY_OUT", ) # Check timing (can substitute mid-play if injury, otherwise must wait for half-inning) # For MVP: We'll allow any time, but log if mid-inning if not allow_mid_inning and state.play_count > 0: # In real game, would check if we're between half-innings # For MVP, just log a warning logger.warning( f"Defensive replacement during active play (play {state.play_count}). " f"In production, verify this is between half-innings or an injury." ) # Validate new position valid_positions = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "DH"] if new_position not in valid_positions: return ValidationResult( is_valid=False, error_message=f"Invalid position: {new_position}. Must be one of {valid_positions}", error_code="INVALID_POSITION", ) # Check substitute is in roster player_in = roster.get_player_by_card_id(player_in_card_id) if not player_in: return ValidationResult( is_valid=False, error_message=f"Player with card_id {player_in_card_id} not found in roster", error_code="NOT_IN_ROSTER", ) # Check substitute is not already active if player_in.is_active: return ValidationResult( is_valid=False, error_message=f"Player {player_in.card_id} is already in the game", error_code="ALREADY_ACTIVE", ) # All checks passed logger.info( f"Defensive replacement validation passed: " f"{player_out.card_id} (lineup_id {player_out.lineup_id}, {player_out.position}) → " f"{player_in.card_id} (card_id {player_in_card_id}, {new_position})" ) return ValidationResult(is_valid=True) @staticmethod def validate_pitching_change( state: GameState, pitcher_out: LineupPlayerState, pitcher_in_card_id: int, roster: TeamLineupState, force_change: bool = False, ) -> ValidationResult: """ Validate pitching change. Rules: 1. Substitute must be in roster 2. Substitute must be inactive 3. Old pitcher must be active 4. Old pitcher must have faced at least 1 batter (unless force_change=True for injury) 5. Substituted pitcher cannot re-enter as pitcher Args: state: Current game state pitcher_out: Current pitcher being replaced pitcher_in_card_id: Card ID of incoming pitcher roster: Team's complete roster force_change: If True, allows immediate change (for injuries/emergencies) Returns: ValidationResult with is_valid and optional error message """ # Check pitcher_out is active if not pitcher_out.is_active: return ValidationResult( is_valid=False, error_message="Cannot substitute for pitcher who is already out of game", error_code="PLAYER_ALREADY_OUT", ) # Check pitcher_out is actually a pitcher if pitcher_out.position != "P": return ValidationResult( is_valid=False, error_message=f"Player being replaced is not a pitcher (position: {pitcher_out.position})", error_code="NOT_A_PITCHER", ) # Check minimum batters faced (unless force_change) if not force_change and state.play_count == 0: return ValidationResult( is_valid=False, error_message="Pitcher must face at least 1 batter before being replaced", error_code="MIN_BATTERS_NOT_MET", ) # Check substitute is in roster pitcher_in = roster.get_player_by_card_id(pitcher_in_card_id) if not pitcher_in: return ValidationResult( is_valid=False, error_message=f"Player with card_id {pitcher_in_card_id} not found in roster", error_code="NOT_IN_ROSTER", ) # Check substitute is not already active if pitcher_in.is_active: return ValidationResult( is_valid=False, error_message=f"Player {pitcher_in.card_id} is already in the game", error_code="ALREADY_ACTIVE", ) # Check new pitcher can play pitcher position (MVP: skip position eligibility check) # In post-MVP, would check if player has 'P' in their positions list # All checks passed logger.info( f"Pitching change validation passed: " f"{pitcher_out.card_id} (lineup_id {pitcher_out.lineup_id}) → " f"{pitcher_in.card_id} (card_id {pitcher_in_card_id})" ) return ValidationResult(is_valid=True) @staticmethod def validate_double_switch( state: GameState, player_out_1: LineupPlayerState, player_in_1_card_id: int, new_position_1: str, new_batting_order_1: int, player_out_2: LineupPlayerState, player_in_2_card_id: int, new_position_2: str, new_batting_order_2: int, roster: TeamLineupState, ) -> ValidationResult: """ Validate double switch (two simultaneous substitutions with batting order swap). Complex rule used in National League to optimize pitcher's spot in batting order. Rules: 1. Both substitutes must be in roster 2. Both substitutes must be inactive 3. Both players being replaced must be active 4. New positions must be valid 5. New batting orders must be different from each other 6. Generally done between half-innings Args: state: Current game state player_out_1: First player being replaced player_in_1_card_id: Card ID of first incoming player new_position_1: Position for first incoming player new_batting_order_1: Batting order for first incoming player player_out_2: Second player being replaced player_in_2_card_id: Card ID of second incoming player new_position_2: Position for second incoming player new_batting_order_2: Batting order for second incoming player roster: Team's complete roster Returns: ValidationResult with is_valid and optional error message """ # Validate first substitution result_1 = SubstitutionRules.validate_defensive_replacement( state=state, player_out=player_out_1, player_in_card_id=player_in_1_card_id, new_position=new_position_1, roster=roster, allow_mid_inning=False, ) if not result_1.is_valid: return ValidationResult( is_valid=False, error_message=f"First substitution invalid: {result_1.error_message}", error_code=f"FIRST_SUB_{result_1.error_code}", ) # Validate second substitution result_2 = SubstitutionRules.validate_defensive_replacement( state=state, player_out=player_out_2, player_in_card_id=player_in_2_card_id, new_position=new_position_2, roster=roster, allow_mid_inning=False, ) if not result_2.is_valid: return ValidationResult( is_valid=False, error_message=f"Second substitution invalid: {result_2.error_message}", error_code=f"SECOND_SUB_{result_2.error_code}", ) # Validate batting orders if new_batting_order_1 not in range(1, 10) or new_batting_order_2 not in range( 1, 10 ): return ValidationResult( is_valid=False, error_message="Batting orders must be between 1 and 9", error_code="INVALID_BATTING_ORDER", ) if new_batting_order_1 == new_batting_order_2: return ValidationResult( is_valid=False, error_message="Both players cannot have same batting order", error_code="DUPLICATE_BATTING_ORDER", ) # All checks passed logger.info( f"Double switch validation passed: " f"({player_out_1.card_id} → {player_in_1_card_id} at {new_position_1}, order {new_batting_order_1}) & " f"({player_out_2.card_id} → {player_in_2_card_id} at {new_position_2}, order {new_batting_order_2})" ) return ValidationResult(is_valid=True)