""" Runner advancement logic for groundball outcomes. This module implements the complete runner advancement system based on: - Hit outcome (GROUNDBALL_A, GROUNDBALL_B, GROUNDBALL_C) - Base situation (0-7 on-base code) - Defensive positioning (infield_in, corners_in, normal) - Hit location (1B, 2B, SS, 3B, P, C) The result numbers (1-13) match the official rulebook charts exactly. """ import logging import random from dataclasses import dataclass from enum import IntEnum from typing import Optional, List, Dict from uuid import UUID from app.models.game_models import GameState, DefensiveDecision from app.config.result_charts import PlayOutcome logger = logging.getLogger(f'{__name__}.RunnerAdvancement') class GroundballResultType(IntEnum): """ Groundball advancement results matching rulebook chart. These numbered results correspond exactly to the official rulebook advancement charts (Infield Back and Infield In). """ BATTER_OUT_RUNNERS_HOLD = 1 """Result 1: Batter out, all runners hold.""" DOUBLE_PLAY_AT_SECOND = 2 """Result 2: Batter out, runner on 1st out at 2nd (double play on empty/1 out). Other runners advance 1 base.""" BATTER_OUT_RUNNERS_ADVANCE = 3 """Result 3: Batter out, all runners advance 1 base.""" BATTER_SAFE_FORCE_OUT_AT_SECOND = 4 """Result 4: Batter safe at 1st, runner on 1st forced out at 2nd. Other runners advance 1 base.""" CONDITIONAL_ON_MIDDLE_INFIELD = 5 """Result 5: Hit to 2B/SS = batter out, runners advance 1 base. Hit anywhere else = batter out, runners hold.""" CONDITIONAL_ON_RIGHT_SIDE = 6 """Result 6: Hit to 1B/2B = batter out, runners advance 1 base. Hit anywhere else = batter out, runners hold.""" BATTER_OUT_FORCED_ONLY = 7 """Result 7: Batter out, runners advance 1 base (only if forced).""" BATTER_OUT_FORCED_ONLY_ALT = 8 """Result 8: Same as Result 7 (batter out, forced advancement only).""" LEAD_HOLDS_TRAIL_ADVANCES = 9 """Result 9: Batter out, runner on 3rd holds, runner on 1st advances to 2nd (bases loaded scenario).""" DOUBLE_PLAY_HOME_TO_FIRST = 10 """Result 10: Double play at home and 1st (runner on 3rd out at home, batter out). Otherwise batter out, runners advance.""" BATTER_SAFE_LEAD_OUT = 11 """Result 11: Batter safe at 1st, lead runner is out. Other runners advance 1 base.""" DECIDE_OPPORTUNITY = 12 """Result 12: DECIDE - Lead runner can attempt to advance. Offense chooses, defense responds.""" CONDITIONAL_DOUBLE_PLAY = 13 """Result 13: Hit to C/3B = double play at 3rd and 2nd (batter safe). Hit anywhere else = same as Result 2.""" @dataclass class RunnerMovement: """ Represents the movement of a single runner during a play. Attributes: lineup_id: The lineup position ID of the runner (1-9) from_base: Starting base (0=batter, 1-3=bases) to_base: Ending base (0=out, 1-3=bases, 4=scored) is_out: Whether the runner was thrown out """ lineup_id: int from_base: int to_base: int is_out: bool = False def __repr__(self) -> str: if self.is_out: return f"RunnerMovement(#{self.lineup_id}: {self.from_base}→OUT)" elif self.to_base == 4: return f"RunnerMovement(#{self.lineup_id}: {self.from_base}→HOME)" else: return f"RunnerMovement(#{self.lineup_id}: {self.from_base}→{self.to_base})" @dataclass class AdvancementResult: """ Complete result of a runner advancement resolution. Attributes: movements: List of all runner movements (including batter) outs_recorded: Number of outs on the play (0-3) runs_scored: Number of runs scored on the play result_type: The groundball result type used (1-13) description: Human-readable description of the result """ movements: List[RunnerMovement] outs_recorded: int runs_scored: int result_type: GroundballResultType description: str class RunnerAdvancement: """ Handles runner advancement logic for groundball outcomes. This class implements the complete advancement system including: - Infield Back chart (normal defensive positioning) - Infield In chart (runners on 3rd, defense playing in) - Corners In positioning (hybrid approach) - Double play mechanics - DECIDE mechanic (lead runner advancement attempts) """ def __init__(self): self.logger = logging.getLogger(f'{__name__}.RunnerAdvancement') def advance_runners( self, outcome: PlayOutcome, hit_location: str, state: GameState, defensive_decision: DefensiveDecision ) -> AdvancementResult: """ Calculate runner advancement for a groundball outcome. Args: outcome: The play outcome (GROUNDBALL_A, B, or C) hit_location: Where the ball was hit (1B, 2B, SS, 3B, P, C) state: Current game state defensive_decision: Defensive team's positioning decisions Returns: AdvancementResult with all runner movements and outs Raises: ValueError: If outcome is not a groundball type """ # Validate outcome is a groundball if outcome not in [ PlayOutcome.GROUNDBALL_A, PlayOutcome.GROUNDBALL_B, PlayOutcome.GROUNDBALL_C ]: raise ValueError(f"advance_runners only handles groundballs, got {outcome}") # Determine which result to apply result_type = self._determine_groundball_result( outcome=outcome, on_base_code=state.current_on_base_code, starting_outs=state.outs, defensive_decision=defensive_decision, hit_location=hit_location ) self.logger.info( f"Groundball {outcome.name} with bases {state.current_on_base_code}, " f"{state.outs} outs → Result {result_type}" ) # Execute the result return self._execute_result( result_type=result_type, state=state, hit_location=hit_location, defensive_decision=defensive_decision ) def _determine_groundball_result( self, outcome: PlayOutcome, on_base_code: int, starting_outs: int, defensive_decision: DefensiveDecision, hit_location: str ) -> GroundballResultType: """ Determine which groundball result applies based on situation. This implements the logic from both Infield Back and Infield In charts. Args: outcome: GROUNDBALL_A, B, or C on_base_code: 0-7 representing base runners (0=empty, 7=loaded) starting_outs: Number of outs before play (0-2) defensive_decision: Defensive positioning hit_location: Where ball was hit Returns: GroundballResultType (1-13) """ # Special case: 2 outs always results in batter out, runners hold if starting_outs == 2: return GroundballResultType.BATTER_OUT_RUNNERS_HOLD # Determine if playing in or corners in infield_in = defensive_decision.infield_depth == "infield_in" corners_in = defensive_decision.infield_depth == "corners_in" # Map hit location to position groups hit_to_mif = hit_location in ['2B', 'SS'] # Middle infield hit_to_cif = hit_location in ['1B', '3B', 'P', 'C'] # Corner infield hit_to_right = hit_location in ['1B', '2B'] # Right side # Convert outcome to letter gb_letter = outcome.name.split('_')[1] # 'GROUNDBALL_A' → 'A' # ======================================== # INFIELD IN CHART (Runner on 3rd scenarios) # ======================================== if infield_in and on_base_code in [3, 5, 6, 7]: return self._apply_infield_in_chart( gb_letter=gb_letter, on_base_code=on_base_code, hit_location=hit_location, hit_to_mif=hit_to_mif, hit_to_cif=hit_to_cif ) # ======================================== # CORNERS IN (Hybrid approach) # ======================================== if corners_in and on_base_code in [3, 5, 6, 7] and hit_to_cif: # Apply Infield In rules for corners return self._apply_infield_in_chart( gb_letter=gb_letter, on_base_code=on_base_code, hit_location=hit_location, hit_to_mif=hit_to_mif, hit_to_cif=hit_to_cif ) # ======================================== # INFIELD BACK CHART (Default positioning) # ======================================== return self._apply_infield_back_chart( gb_letter=gb_letter, on_base_code=on_base_code, hit_location=hit_location, hit_to_mif=hit_to_mif, hit_to_right=hit_to_right ) def _apply_infield_in_chart( self, gb_letter: str, on_base_code: int, hit_location: str, hit_to_mif: bool, hit_to_cif: bool ) -> GroundballResultType: """ Apply Infield In chart logic. Chart reference: - 3rd only: GBA=7, GBB=1, GBC=8 - 1st & 3rd: GBA=7, GBB=9, GBC=8, with DECIDE for GBC hit to SS/P/C - 2nd & 3rd: GBA=7, GBB=1, GBC=8 - Loaded: GBA=10, GBB=11, GBC=11 """ # Bases loaded (on_base_code == 7) if on_base_code == 7: if gb_letter == 'A': return GroundballResultType.DOUBLE_PLAY_HOME_TO_FIRST # 10 else: # B or C return GroundballResultType.BATTER_SAFE_LEAD_OUT # 11 # 1st & 3rd (on_base_code == 5) if on_base_code == 5: if gb_letter == 'A': return GroundballResultType.BATTER_OUT_FORCED_ONLY # 7 elif gb_letter == 'B': return GroundballResultType.LEAD_HOLDS_TRAIL_ADVANCES # 9 else: # C # Check for DECIDE opportunity if hit_location in ['SS', 'P', 'C']: return GroundballResultType.DECIDE_OPPORTUNITY # 12 else: return GroundballResultType.BATTER_OUT_FORCED_ONLY_ALT # 8 # 2nd & 3rd (on_base_code == 6) if on_base_code == 6: if gb_letter == 'A': return GroundballResultType.BATTER_OUT_FORCED_ONLY # 7 elif gb_letter == 'B': return GroundballResultType.BATTER_OUT_RUNNERS_HOLD # 1 else: # C return GroundballResultType.BATTER_OUT_FORCED_ONLY_ALT # 8 # 3rd only (on_base_code == 3) if on_base_code == 3: if gb_letter == 'A': return GroundballResultType.BATTER_OUT_FORCED_ONLY # 7 elif gb_letter == 'B': return GroundballResultType.BATTER_OUT_RUNNERS_HOLD # 1 else: # C # Check for DECIDE on certain hit locations if hit_location in ['1B', '2B']: return GroundballResultType.DECIDE_OPPORTUNITY # 12 elif hit_location == '3B': return GroundballResultType.BATTER_OUT_RUNNERS_HOLD # 1 else: # SS, P, C return GroundballResultType.DECIDE_OPPORTUNITY # 12 # Fallback (shouldn't reach here) self.logger.warning(f"Unexpected Infield In scenario: bases={on_base_code}, letter={gb_letter}") return GroundballResultType.BATTER_OUT_RUNNERS_HOLD def _apply_infield_back_chart( self, gb_letter: str, on_base_code: int, hit_location: str, hit_to_mif: bool, hit_to_right: bool ) -> GroundballResultType: """ Apply Infield Back chart logic (default positioning). Chart reference: - Empty: GBA=1, GBB=1, GBC=1 (all batter out) - 1st: GBA=2, GBB=4, GBC=3 - 2nd: GBA=6, GBB=6, GBC=3 (conditional on right side) - 3rd: GBA=5, GBB=5, GBC=3 (conditional on middle infield) - 1st & 2nd: GBA=2, GBB=4, GBC=3 - 1st & 3rd: GBA=2, GBB=4, GBC=3 - 2nd & 3rd: GBA=5, GBB=5, GBC=3 - Loaded: GBA=2, GBB=4, GBC=3 """ # Empty bases (on_base_code == 0) if on_base_code == 0: return GroundballResultType.BATTER_OUT_RUNNERS_HOLD # 1 # Runner on 1st (includes 1, 4, 5, 7 - any scenario with runner on 1st) if on_base_code in [1, 4, 5, 7]: if gb_letter == 'A': return GroundballResultType.DOUBLE_PLAY_AT_SECOND # 2 elif gb_letter == 'B': return GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND # 4 else: # C return GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE # 3 # Runner on 2nd only (on_base_code == 2) if on_base_code == 2: if gb_letter in ['A', 'B']: return GroundballResultType.CONDITIONAL_ON_RIGHT_SIDE # 6 else: # C return GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE # 3 # Runner on 3rd (includes 3, 6 - scenarios with runner on 3rd but not 1st) if on_base_code in [3, 6]: if gb_letter in ['A', 'B']: return GroundballResultType.CONDITIONAL_ON_MIDDLE_INFIELD # 5 else: # C return GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE # 3 # Fallback self.logger.warning(f"Unexpected Infield Back scenario: bases={on_base_code}, letter={gb_letter}") return GroundballResultType.BATTER_OUT_RUNNERS_HOLD def _calculate_double_play_probability( self, state: GameState, defensive_decision: DefensiveDecision, hit_location: str ) -> float: """ Calculate probability of successfully turning a double play. Factors: - Base probability: 45% - Positioning: DP depth +20%, infield in -15% - Hit location: Up middle +10%, corners -10% - Runner speed: Fast -15%, slow +10% (TODO: when ratings available) Args: state: Current game state defensive_decision: Defensive positioning hit_location: Where ball was hit Returns: Probability between 0.0 and 1.0 """ probability = 0.45 # Base 45% chance # Positioning modifiers if defensive_decision.infield_depth == "infield_in": probability -= 0.15 # 30% playing in (prioritizing out at plate) # Note: "double_play" depth doesn't exist in DefensiveDecision validation # Could add modifier for "normal" depth with certain alignments in the future # Hit location modifiers if hit_location in ['2B', 'SS']: # Up the middle probability += 0.10 elif hit_location in ['1B', '3B', 'P', 'C']: # Corners probability -= 0.10 # TODO: Runner speed modifiers when player ratings available # runner_on_first = state.get_runner_at_base(1) # if runner_on_first and hasattr(runner_on_first, 'speed'): # if runner_on_first.speed >= 15: # Fast # probability -= 0.15 # elif runner_on_first.speed <= 5: # Slow # probability += 0.10 # Clamp between 0 and 1 return max(0.0, min(1.0, probability)) def _execute_result( self, result_type: GroundballResultType, state: GameState, hit_location: str, defensive_decision: Optional[DefensiveDecision] = None ) -> AdvancementResult: """ Execute a specific groundball result and return movements. Args: result_type: The result type (1-13) state: Current game state hit_location: Where ball was hit defensive_decision: Defensive positioning (for DP probability) Returns: AdvancementResult with all movements """ # Dispatch to appropriate handler if result_type == GroundballResultType.BATTER_OUT_RUNNERS_HOLD: return self._gb_result_1(state) elif result_type == GroundballResultType.DOUBLE_PLAY_AT_SECOND: return self._gb_result_2(state, defensive_decision, hit_location) elif result_type == GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE: return self._gb_result_3(state) elif result_type == GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND: return self._gb_result_4(state) elif result_type == GroundballResultType.CONDITIONAL_ON_MIDDLE_INFIELD: return self._gb_result_5(state, hit_location) elif result_type == GroundballResultType.CONDITIONAL_ON_RIGHT_SIDE: return self._gb_result_6(state, hit_location) elif result_type in [ GroundballResultType.BATTER_OUT_FORCED_ONLY, GroundballResultType.BATTER_OUT_FORCED_ONLY_ALT ]: return self._gb_result_7(state) elif result_type == GroundballResultType.LEAD_HOLDS_TRAIL_ADVANCES: return self._gb_result_9(state) elif result_type == GroundballResultType.DOUBLE_PLAY_HOME_TO_FIRST: return self._gb_result_10(state, defensive_decision, hit_location) elif result_type == GroundballResultType.BATTER_SAFE_LEAD_OUT: return self._gb_result_11(state) elif result_type == GroundballResultType.DECIDE_OPPORTUNITY: return self._gb_result_12(state, hit_location) elif result_type == GroundballResultType.CONDITIONAL_DOUBLE_PLAY: return self._gb_result_13(state, defensive_decision, hit_location) else: raise ValueError(f"Unknown result type: {result_type}") # ======================================== # Result Handlers (1-13) # ======================================== def _gb_result_1(self, state: GameState) -> AdvancementResult: """ Result 1: Batter out, all runners hold. """ movements = [] # Batter is out movements.append(RunnerMovement( lineup_id=state.current_batter_lineup_id, from_base=0, to_base=0, is_out=True )) # All runners stay put if state.is_runner_on_first(): movements.append(RunnerMovement( lineup_id=state.on_first, from_base=1, to_base=1, is_out=False )) if state.is_runner_on_second(): movements.append(RunnerMovement( lineup_id=state.on_second, from_base=2, to_base=2, is_out=False )) if state.is_runner_on_third(): movements.append(RunnerMovement( lineup_id=state.on_third, from_base=3, to_base=3, is_out=False )) return AdvancementResult( movements=movements, outs_recorded=1, runs_scored=0, result_type=GroundballResultType.BATTER_OUT_RUNNERS_HOLD, description="Batter out, runners hold" ) def _gb_result_2( self, state: GameState, defensive_decision: Optional[DefensiveDecision], hit_location: str ) -> AdvancementResult: """ Result 2: Double play at 2nd and 1st (when possible). - With 0 or 1 out: Runner on 1st out at 2nd, batter out (DP) - With 2 outs: Only batter out - Other runners advance 1 base Uses probability calculation for DP success based on positioning and hit location. """ movements = [] outs = 0 runs = 0 # Check if double play is possible can_turn_dp = state.outs < 2 and state.is_runner_on_first() if can_turn_dp: # Calculate DP probability if defensive_decision: dp_probability = self._calculate_double_play_probability( state=state, defensive_decision=defensive_decision, hit_location=hit_location ) else: dp_probability = 0.45 # Default base probability # Roll for DP turns_dp = random.random() < dp_probability if turns_dp: # Runner on first out at second movements.append(RunnerMovement( lineup_id=state.on_first, from_base=1, to_base=0, is_out=True )) outs += 1 # Batter out at first movements.append(RunnerMovement( lineup_id=state.current_batter_lineup_id, from_base=0, to_base=0, is_out=True )) outs += 1 description = "Double play: Runner out at 2nd, batter out at 1st" else: # Only force out at second movements.append(RunnerMovement( lineup_id=state.on_first, from_base=1, to_base=0, is_out=True )) outs += 1 # Batter safe at first movements.append(RunnerMovement( lineup_id=state.current_batter_lineup_id, from_base=0, to_base=1, is_out=False )) description = "Force out at 2nd, batter safe at 1st" else: # Can't turn DP, just batter out movements.append(RunnerMovement( lineup_id=state.current_batter_lineup_id, from_base=0, to_base=0, is_out=True )) outs += 1 description = "Batter out" # Other runners advance if play doesn't end inning if state.outs + outs < 3: if state.is_runner_on_second(): # Runner scores from second movements.append(RunnerMovement( lineup_id=state.on_second, from_base=2, to_base=4, is_out=False )) runs += 1 if state.is_runner_on_third(): # Runner scores from third movements.append(RunnerMovement( lineup_id=state.on_third, from_base=3, to_base=4, is_out=False )) runs += 1 return AdvancementResult( movements=movements, outs_recorded=outs, runs_scored=runs, result_type=GroundballResultType.DOUBLE_PLAY_AT_SECOND, description=description ) def _gb_result_3(self, state: GameState) -> AdvancementResult: """ Result 3: Batter out, all runners advance 1 base. """ movements = [] runs = 0 # Batter is out movements.append(RunnerMovement( lineup_id=state.current_batter_lineup_id, from_base=0, to_base=0, is_out=True )) # All runners advance 1 base (if less than 2 outs after play) if state.outs < 2: # Play doesn't end inning if state.is_runner_on_first(): movements.append(RunnerMovement( lineup_id=state.on_first, from_base=1, to_base=2, is_out=False )) if state.is_runner_on_second(): movements.append(RunnerMovement( lineup_id=state.on_second, from_base=2, to_base=3, is_out=False )) if state.is_runner_on_third(): movements.append(RunnerMovement( lineup_id=state.on_third, from_base=3, to_base=4, is_out=False )) runs += 1 return AdvancementResult( movements=movements, outs_recorded=1, runs_scored=runs, result_type=GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, description="Batter out, runners advance 1 base" ) def _gb_result_4(self, state: GameState) -> AdvancementResult: """ Result 4: Batter safe at 1st, runner on 1st forced out at 2nd. Other runners advance 1 base. """ movements = [] runs = 0 # Runner on first forced out at second if state.is_runner_on_first(): movements.append(RunnerMovement( lineup_id=state.on_first, from_base=1, to_base=0, is_out=True )) # Batter safe at first movements.append(RunnerMovement( lineup_id=state.current_batter_lineup_id, from_base=0, to_base=1, is_out=False )) # Other runners advance if play doesn't end inning if state.outs < 2: if state.is_runner_on_second(): movements.append(RunnerMovement( lineup_id=state.on_second, from_base=2, to_base=3, is_out=False )) if state.is_runner_on_third(): movements.append(RunnerMovement( lineup_id=state.on_third, from_base=3, to_base=4, is_out=False )) runs += 1 return AdvancementResult( movements=movements, outs_recorded=1, runs_scored=runs, result_type=GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND, description="Batter safe, force out at 2nd, other runners advance" ) def _gb_result_5(self, state: GameState, hit_location: str) -> AdvancementResult: """ Result 5: Conditional on middle infield (2B/SS). - Hit to 2B/SS: Batter out, runners advance 1 base (Result 3) - Hit anywhere else: Batter out, runners hold (Result 1) """ hit_to_mif = hit_location in ['2B', 'SS'] if hit_to_mif: return self._gb_result_3(state) else: return self._gb_result_1(state) def _gb_result_6(self, state: GameState, hit_location: str) -> AdvancementResult: """ Result 6: Conditional on right side (1B/2B). - Hit to 1B/2B: Batter out, runners advance 1 base (Result 3) - Hit anywhere else: Batter out, runners hold (Result 1) """ hit_to_right = hit_location in ['1B', '2B'] if hit_to_right: return self._gb_result_3(state) else: return self._gb_result_1(state) def _gb_result_7(self, state: GameState) -> AdvancementResult: """ Result 7/8: Batter out, runners advance only if forced. """ movements = [] runs = 0 # Batter is out movements.append(RunnerMovement( lineup_id=state.current_batter_lineup_id, from_base=0, to_base=0, is_out=True )) # Check forced runners (only if play doesn't end inning) if state.outs < 2: # Runner on 3rd advances only if forced (bases loaded) if state.is_runner_on_third(): forced = state.is_runner_on_first() and state.is_runner_on_second() if forced: movements.append(RunnerMovement( lineup_id=state.on_third, from_base=3, to_base=4, is_out=False )) runs += 1 else: # Holds movements.append(RunnerMovement( lineup_id=state.on_third, from_base=3, to_base=3, is_out=False )) # Runner on 2nd advances only if forced (1st and 2nd occupied) if state.is_runner_on_second(): forced = state.is_runner_on_first() if forced: movements.append(RunnerMovement( lineup_id=state.on_second, from_base=2, to_base=3, is_out=False )) else: # Holds movements.append(RunnerMovement( lineup_id=state.on_second, from_base=2, to_base=2, is_out=False )) # Runner on 1st always forced if state.is_runner_on_first(): movements.append(RunnerMovement( lineup_id=state.on_first, from_base=1, to_base=2, is_out=False )) return AdvancementResult( movements=movements, outs_recorded=1, runs_scored=runs, result_type=GroundballResultType.BATTER_OUT_FORCED_ONLY, description="Batter out, forced runners advance" ) def _gb_result_9(self, state: GameState) -> AdvancementResult: """ Result 9: Batter out, runner on 3rd holds, runner on 1st advances to 2nd. Specific to 1st & 3rd situation with Infield In positioning. """ movements = [] # Batter is out movements.append(RunnerMovement( lineup_id=state.current_batter_lineup_id, from_base=0, to_base=0, is_out=True )) # Runner on 3rd holds if state.is_runner_on_third(): movements.append(RunnerMovement( lineup_id=state.on_third, from_base=3, to_base=3, is_out=False )) # Runner on 1st advances to 2nd if state.is_runner_on_first(): movements.append(RunnerMovement( lineup_id=state.on_first, from_base=1, to_base=2, is_out=False )) return AdvancementResult( movements=movements, outs_recorded=1, runs_scored=0, result_type=GroundballResultType.LEAD_HOLDS_TRAIL_ADVANCES, description="Batter out, R3 holds, R1 to 2nd" ) def _gb_result_10( self, state: GameState, defensive_decision: Optional[DefensiveDecision], hit_location: str ) -> AdvancementResult: """ Result 10: Double play attempt at home and 1st (bases loaded). - With 0 or 1 out: Runner on 3rd out at home, batter out (DP) - With 2 outs: Only batter out - Runners on 2nd and 1st advance Uses probability calculation for DP success. """ movements = [] outs = 0 runs = 0 # Check if double play is possible can_turn_dp = state.outs < 2 if can_turn_dp: # Calculate DP probability if defensive_decision: dp_probability = self._calculate_double_play_probability( state=state, defensive_decision=defensive_decision, hit_location=hit_location ) else: dp_probability = 0.45 # Default base probability # Roll for DP turns_dp = random.random() < dp_probability if turns_dp: # Runner on third out at home if state.is_runner_on_third(): movements.append(RunnerMovement( lineup_id=state.on_third, from_base=3, to_base=0, is_out=True )) outs += 1 # Batter out at first movements.append(RunnerMovement( lineup_id=state.current_batter_lineup_id, from_base=0, to_base=0, is_out=True )) outs += 1 description = "Double play: Runner out at home, batter out at 1st" else: # Only out at home, batter safe if state.is_runner_on_third(): movements.append(RunnerMovement( lineup_id=state.on_third, from_base=3, to_base=0, is_out=True )) outs += 1 # Batter safe at first movements.append(RunnerMovement( lineup_id=state.current_batter_lineup_id, from_base=0, to_base=1, is_out=False )) description = "Out at home, batter safe at 1st" else: # Can't turn DP, just batter out movements.append(RunnerMovement( lineup_id=state.current_batter_lineup_id, from_base=0, to_base=0, is_out=True )) outs += 1 description = "Batter out" # Other runners advance if play doesn't end inning if state.outs + outs < 3: if state.is_runner_on_second(): movements.append(RunnerMovement( lineup_id=state.on_second, from_base=2, to_base=3, is_out=False )) if state.is_runner_on_first(): movements.append(RunnerMovement( lineup_id=state.on_first, from_base=1, to_base=2, is_out=False )) return AdvancementResult( movements=movements, outs_recorded=outs, runs_scored=runs, result_type=GroundballResultType.DOUBLE_PLAY_HOME_TO_FIRST, description=description ) def _gb_result_11(self, state: GameState) -> AdvancementResult: """ Result 11: Batter safe at 1st, lead runner is out. Other runners advance 1 base. Used for bases loaded scenarios with Infield In. """ movements = [] runs = 0 # Lead runner is out (highest base) if state.is_runner_on_third(): # Runner on 3rd is lead runner - out at home or 3rd movements.append(RunnerMovement( lineup_id=state.on_third, from_base=3, to_base=0, is_out=True )) elif state.is_runner_on_second(): # Runner on 2nd is lead runner movements.append(RunnerMovement( lineup_id=state.on_second, from_base=2, to_base=0, is_out=True )) elif state.is_runner_on_first(): # Runner on 1st is lead runner movements.append(RunnerMovement( lineup_id=state.on_first, from_base=1, to_base=0, is_out=True )) # Batter safe at first movements.append(RunnerMovement( lineup_id=state.current_batter_lineup_id, from_base=0, to_base=1, is_out=False )) # Other runners advance if play doesn't end inning if state.outs < 2: # If runner on 2nd exists and wasn't the lead runner if state.is_runner_on_second() and state.is_runner_on_third(): movements.append(RunnerMovement( lineup_id=state.on_second, from_base=2, to_base=3, is_out=False )) # If runner on 1st exists and wasn't the lead runner if state.is_runner_on_first() and (state.is_runner_on_second() or state.is_runner_on_third()): movements.append(RunnerMovement( lineup_id=state.on_first, from_base=1, to_base=2, is_out=False )) return AdvancementResult( movements=movements, outs_recorded=1, runs_scored=runs, result_type=GroundballResultType.BATTER_SAFE_LEAD_OUT, description="Batter safe, lead runner out, others advance" ) def _gb_result_12(self, state: GameState, hit_location: str) -> AdvancementResult: """ Result 12: DECIDE opportunity. Lead runner can attempt to advance a base. Offense chooses whether to attempt. If they attempt, defense can take sure out at 1st OR try to throw lead runner out. NOTE: This is a simplified implementation. Full DECIDE mechanic requires interactive decision-making from offense/defense, which will be handled by the game engine / WebSocket layer. For now, this returns a result indicating DECIDE opportunity exists, and the actual decision-making will be handled at a higher level. """ movements = [] # Hit to 1B/2B: Simple advancement if hit_location in ['1B', '2B']: return self._gb_result_3(state) # Hit to 3B: Runners hold if hit_location == '3B': return self._gb_result_1(state) # Hit to SS/P/C: DECIDE opportunity # For now, default to conservative play (batter out, runners hold) # TODO: This needs to be interactive in the game engine movements.append(RunnerMovement( lineup_id=state.current_batter_lineup_id, from_base=0, to_base=0, is_out=True )) # Hold all runners by default if state.is_runner_on_first(): movements.append(RunnerMovement( lineup_id=state.on_first, from_base=1, to_base=1, is_out=False )) if state.is_runner_on_second(): movements.append(RunnerMovement( lineup_id=state.on_second, from_base=2, to_base=2, is_out=False )) if state.is_runner_on_third(): movements.append(RunnerMovement( lineup_id=state.on_third, from_base=3, to_base=3, is_out=False )) return AdvancementResult( movements=movements, outs_recorded=1, runs_scored=0, result_type=GroundballResultType.DECIDE_OPPORTUNITY, description="DECIDE opportunity (conservative: batter out, runners hold)" ) def _gb_result_13( self, state: GameState, defensive_decision: Optional[DefensiveDecision], hit_location: str ) -> AdvancementResult: """ Result 13: Conditional double play. - Hit to C/3B: Double play at 3rd and 2nd base, batter safe - Hit anywhere else: Same as Result 2 (double play at 2nd and 1st) Uses probability calculation for DP success. """ hit_to_c_or_3b = hit_location in ['C', '3B'] if hit_to_c_or_3b: movements = [] outs = 0 # Check if DP is possible can_turn_dp = state.outs < 2 if can_turn_dp: # Calculate DP probability if defensive_decision: dp_probability = self._calculate_double_play_probability( state=state, defensive_decision=defensive_decision, hit_location=hit_location ) else: dp_probability = 0.45 # Default # Roll for DP turns_dp = random.random() < dp_probability else: turns_dp = False if turns_dp: # Runner on 3rd out if state.is_runner_on_third(): movements.append(RunnerMovement( lineup_id=state.on_third, from_base=3, to_base=0, is_out=True )) outs += 1 # Runner on 2nd out if state.is_runner_on_second(): movements.append(RunnerMovement( lineup_id=state.on_second, from_base=2, to_base=0, is_out=True )) outs += 1 # Batter safe at first movements.append(RunnerMovement( lineup_id=state.current_batter_lineup_id, from_base=0, to_base=1, is_out=False )) description = "Double play at 3rd and 2nd, batter safe" else: # Can't turn DP movements.append(RunnerMovement( lineup_id=state.current_batter_lineup_id, from_base=0, to_base=0, is_out=True )) outs += 1 description = "Batter out" return AdvancementResult( movements=movements, outs_recorded=outs, runs_scored=0, result_type=GroundballResultType.CONDITIONAL_DOUBLE_PLAY, description=description ) else: # Same as Result 2 return self._gb_result_2(state, defensive_decision, hit_location)