""" Rule Validators - Validate game actions and state transitions. Ensures all game actions follow baseball rules and state is valid. Author: Claude Date: 2025-10-24 """ import logging from uuid import UUID from app.models.game_models import GameState, DefensiveDecision, OffensiveDecision logger = logging.getLogger(f'{__name__}.GameValidator') class ValidationError(Exception): """Raised when validation fails""" pass class GameValidator: """Validates game actions and state""" @staticmethod def validate_game_active(state: GameState) -> None: """Ensure game is in active state""" if state.status != "active": raise ValidationError(f"Game is not active (status: {state.status})") @staticmethod def validate_outs(outs: int) -> None: """Ensure outs are valid""" if outs < 0 or outs > 2: raise ValidationError(f"Invalid outs: {outs} (must be 0-2)") @staticmethod def validate_inning(inning: int, half: str) -> None: """Ensure inning is valid""" if inning < 1: raise ValidationError(f"Invalid inning: {inning}") if half not in ["top", "bottom"]: raise ValidationError(f"Invalid half: {half}") @staticmethod def validate_defensive_decision(decision: DefensiveDecision, state: GameState) -> None: """ Validate defensive team decision against current game state. Args: decision: Defensive decision to validate state: Current game state Raises: ValidationError: If decision is invalid for current situation """ # # Validate alignment (already validated by Pydantic, but double-check) # valid_alignments = ["normal", "shifted_left", "shifted_right", "extreme_shift"] # if decision.alignment not in valid_alignments: # raise ValidationError(f"Invalid alignment: {decision.alignment}") # Validate depths (already validated by Pydantic, but double-check) valid_infield_depths = ["infield_in", "normal", "corners_in"] if decision.infield_depth not in valid_infield_depths: raise ValidationError(f"Invalid infield depth: {decision.infield_depth}") valid_outfield_depths = ["normal", "shallow"] if decision.outfield_depth not in valid_outfield_depths: raise ValidationError(f"Invalid outfield depth: {decision.outfield_depth}") # Validate hold runners - can't hold empty bases occupied_bases = state.bases_occupied() for base in decision.hold_runners: if base not in [1, 2, 3]: raise ValidationError(f"Invalid hold runner base: {base} (must be 1, 2, or 3)") if base not in occupied_bases: raise ValidationError(f"Cannot hold runner on base {base} - no runner present") # Validate corners_in/infield_in depth requirements (requires runner on third) if decision.infield_depth in ['corners_in', 'infield_in']: if not state.is_runner_on_third(): raise ValidationError(f"Cannot play {decision.infield_depth} without a runner on third") # Validate shallow outfield requires walk-off scenario if decision.outfield_depth == 'shallow': # Walk-off conditions: # 1. Home team batting (bottom of inning) # 2. Bottom of final inning or later # 3. Tied or trailing # 4. Runner on base is_home_batting = (state.half == 'bottom') is_late_inning = (state.inning >= state.regulation_innings) if is_home_batting: is_close_game = (state.home_score <= state.away_score) else: is_close_game = (state.away_score <= state.home_score) has_runners = len(occupied_bases) > 0 if not (is_home_batting and is_late_inning and is_close_game and has_runners): raise ValidationError( f"Shallow outfield only allowed in walk-off situations " f"(home team batting, bottom {state.regulation_innings}th+ inning, tied/trailing, runner on base)" ) logger.debug("Defensive decision validated") @staticmethod def validate_offensive_decision(decision: OffensiveDecision, state: GameState) -> None: """ Validate offensive team decision against current game state. Args: decision: Offensive decision to validate state: Current game state Raises: ValidationError: If decision is invalid for current situation Session 2 Update (2025-01-14): Added validation for action field. """ # Validate action field (already validated by Pydantic, but enforce situational rules) occupied_bases = state.bases_occupied() # Validate steal action - requires steal_attempts to be specified if decision.action == 'steal': if not decision.steal_attempts: raise ValidationError("Steal action requires steal_attempts to specify which bases to steal") # Validate squeeze_bunt - requires R3, not with 2 outs if decision.action == 'squeeze_bunt': if not state.is_runner_on_third(): raise ValidationError("Squeeze bunt requires a runner on third base") if state.outs >= 2: raise ValidationError("Squeeze bunt cannot be used with 2 outs") # Validate check_jump - requires runner on base (lead runner only OR both if 1st+3rd) if decision.action == 'check_jump': if len(occupied_bases) == 0: raise ValidationError("Check jump requires at least one runner on base") # Lead runner validation: can't check jump at 2nd if R3 exists if state.is_runner_on_second() and state.is_runner_on_third(): raise ValidationError("Check jump not allowed for trail runner (R2) when R3 is on base") # Validate sac_bunt - cannot be used with 2 outs if decision.action == 'sac_bunt': if state.outs >= 2: raise ValidationError("Sacrifice bunt cannot be used with 2 outs") # Validate hit_and_run action - requires runner on base if decision.action == 'hit_and_run': if len(occupied_bases) == 0: raise ValidationError("Hit and run action requires at least one runner on base") # Validate steal attempts (when provided) for base in decision.steal_attempts: # Validate steal base is valid (2, 3, or 4 for home) if base not in [2, 3, 4]: raise ValidationError(f"Invalid steal attempt to base {base} (must be 2, 3, or 4)") # Must have runner on base-1 to steal base stealing_from = base - 1 if stealing_from not in occupied_bases: raise ValidationError(f"Cannot steal base {base} - no runner on base {stealing_from}") logger.debug("Offensive decision validated") @staticmethod def validate_defensive_lineup_positions(lineup: list) -> None: """ Validate defensive lineup has exactly 1 active player per position. Args: lineup: List of LineupPlayerState objects Raises: ValidationError: If any position is missing or duplicated """ required_positions = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF'] # Count active players per position position_counts: dict[str, int] = {} for player in lineup: if player.is_active: pos = player.position position_counts[pos] = position_counts.get(pos, 0) + 1 # Check each required position has exactly 1 active player errors = [] for pos in required_positions: count = position_counts.get(pos, 0) if count == 0: errors.append(f"Missing active player at {pos}") elif count > 1: errors.append(f"Multiple active players at {pos} ({count} players)") if errors: raise ValidationError(f"Invalid defensive lineup: {'; '.join(errors)}") logger.debug("Defensive lineup positions validated") @staticmethod def can_continue_inning(state: GameState) -> bool: """Check if inning can continue using state's outs_per_inning""" return state.outs < state.outs_per_inning @staticmethod def is_game_over(state: GameState) -> bool: """Check if game is complete using state's regulation_innings""" reg = state.regulation_innings # Game over after regulation innings if score not tied if state.inning >= reg and state.half == "bottom": if state.home_score != state.away_score: return True # Home team wins if ahead in bottom of final inning if state.home_score > state.away_score: return True # Also check if we're in extras and bottom team is ahead if state.inning > reg and state.half == "bottom": if state.home_score > state.away_score: return True return False # Singleton instance game_validator = GameValidator()