""" Runner advancement logic for groundball and flyball outcomes. This module implements the complete runner advancement system based on: GROUNDBALLS (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) - Result numbers (1-13) match official rulebook charts exactly FLYBALLS (FLYOUT_A, FLYOUT_B, FLYOUT_BQ, FLYOUT_C): - FLYOUT_A (Deep): All runners tag up and advance one base - FLYOUT_B (Medium): R3 scores, R2 may attempt tag-up (DECIDE), R1 holds - FLYOUT_BQ (Medium-shallow fly(b)?): R3 may attempt to score (DECIDE), R2 holds, R1 holds - FLYOUT_C (Shallow): No advancement, all runners hold - Defender arm strength factors into tag-up decisions (for DECIDE plays) """ import logging from dataclasses import dataclass from enum import IntEnum from app.config.result_charts import PlayOutcome from app.models.game_models import DefensiveDecision, GameState logger = logging.getLogger(f"{__name__}.RunnerAdvancement") # pyright: reportOptionalMemberAccess=false 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.""" SAFE_ALL_ADVANCE_ONE = 14 """Result 14: Batter safe at 1st, all runners advance 1 base (error E1).""" SAFE_ALL_ADVANCE_TWO = 15 """Result 15: Batter safe at 2nd, all runners advance 2 bases (error E2).""" SAFE_ALL_ADVANCE_THREE = 16 """Result 16: Batter safe at 3rd, all runners advance 3 bases (error E3).""" @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)" if self.to_base == 4: return f"RunnerMovement(#{self.lineup_id}: {self.from_base}→HOME)" 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 (1-13), or None for flyballs description: Human-readable description of the result """ movements: list[RunnerMovement] outs_recorded: int runs_scored: int result_type: GroundballResultType | None description: str class RunnerAdvancement: """ Handles runner advancement logic for groundball and flyball outcomes. GROUNDBALLS: - 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) FLYBALLS: - Deep (FLYOUT_A): All runners tag up and advance one base - Medium (FLYOUT_B): R3 scores, R2 may attempt tag (DECIDE), R1 holds - Medium-shallow (FLYOUT_BQ): R3 may attempt to score (DECIDE), R2 holds, R1 holds - Shallow (FLYOUT_C): No advancement """ 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 groundball or flyball outcome. Args: outcome: The play outcome (GROUNDBALL_A/B/C or FLYOUT_A/B/BQ/C) hit_location: Where the ball was hit (1B, 2B, SS, 3B, P, C for GB; LF, CF, RF for FB) 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 or flyball type """ # Check if groundball if outcome in [ PlayOutcome.GROUNDBALL_A, PlayOutcome.GROUNDBALL_B, PlayOutcome.GROUNDBALL_C, ]: return self._advance_runners_groundball( outcome, hit_location, state, defensive_decision ) # Check if flyball if outcome in [ PlayOutcome.FLYOUT_A, PlayOutcome.FLYOUT_B, PlayOutcome.FLYOUT_BQ, PlayOutcome.FLYOUT_C, ]: return self._advance_runners_flyball( outcome, hit_location, state, defensive_decision ) raise ValueError( f"advance_runners only handles groundballs and flyballs, got {outcome}" ) def _advance_runners_groundball( self, outcome: PlayOutcome, hit_location: str, state: GameState, defensive_decision: DefensiveDecision, ) -> AdvancementResult: """ Calculate runner advancement for 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 """ # 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 # 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 if gb_letter == "B": return GroundballResultType.LEAD_HOLDS_TRAIL_ADVANCES # 9 # C # Check for DECIDE opportunity if hit_location in ["SS", "P", "C"]: return GroundballResultType.DECIDE_OPPORTUNITY # 12 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 if gb_letter == "B": return GroundballResultType.BATTER_OUT_RUNNERS_HOLD # 1 # 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 if gb_letter == "B": return GroundballResultType.BATTER_OUT_RUNNERS_HOLD # 1 # C # Check for DECIDE on certain hit locations if hit_location in ["1B", "2B"]: return GroundballResultType.DECIDE_OPPORTUNITY # 12 if hit_location == "3B": return GroundballResultType.BATTER_OUT_RUNNERS_HOLD # 1 # 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 if gb_letter == "B": return GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND # 4 # 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 # 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 # 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 _execute_result( self, result_type: GroundballResultType, state: GameState, hit_location: str, defensive_decision: DefensiveDecision | None = 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) if result_type == GroundballResultType.DOUBLE_PLAY_AT_SECOND: return self._gb_result_2(state, defensive_decision, hit_location) if result_type == GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE: return self._gb_result_3(state) if result_type == GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND: return self._gb_result_4(state) if result_type == GroundballResultType.CONDITIONAL_ON_MIDDLE_INFIELD: return self._gb_result_5(state, hit_location) if result_type == GroundballResultType.CONDITIONAL_ON_RIGHT_SIDE: return self._gb_result_6(state, hit_location) if result_type in [ GroundballResultType.BATTER_OUT_FORCED_ONLY, GroundballResultType.BATTER_OUT_FORCED_ONLY_ALT, ]: return self._gb_result_7(state) if result_type == GroundballResultType.LEAD_HOLDS_TRAIL_ADVANCES: return self._gb_result_9(state) if result_type == GroundballResultType.DOUBLE_PLAY_HOME_TO_FIRST: return self._gb_result_10(state, defensive_decision, hit_location) if result_type == GroundballResultType.BATTER_SAFE_LEAD_OUT: return self._gb_result_11(state) if result_type == GroundballResultType.DECIDE_OPPORTUNITY: return self._gb_result_12(state, hit_location) if result_type == GroundballResultType.CONDITIONAL_DOUBLE_PLAY: return self._gb_result_13(state, defensive_decision, hit_location) 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.lineup_id, from_base=1, to_base=1, is_out=False, ) ) if state.is_runner_on_second(): movements.append( RunnerMovement( lineup_id=state.on_second.lineup_id, from_base=2, to_base=2, is_out=False, ) ) if state.is_runner_on_third(): movements.append( RunnerMovement( lineup_id=state.on_third.lineup_id, 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: DefensiveDecision | None, 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 """ 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: # Runner on first out at second movements.append( RunnerMovement( lineup_id=state.on_first.lineup_id, 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: # 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.lineup_id, 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.lineup_id, 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.lineup_id, from_base=1, to_base=2, is_out=False, ) ) if state.is_runner_on_second(): movements.append( RunnerMovement( lineup_id=state.on_second.lineup_id, # type: ignore from_base=2, to_base=3, is_out=False, ) ) if state.is_runner_on_third(): movements.append( RunnerMovement( lineup_id=state.on_third.lineup_id, 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.lineup_id, 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.lineup_id, from_base=2, to_base=3, is_out=False, ) ) if state.is_runner_on_third(): movements.append( RunnerMovement( lineup_id=state.on_third.lineup_id, 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) 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) 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.lineup_id, from_base=3, to_base=4, is_out=False, ) ) runs += 1 else: # Holds movements.append( RunnerMovement( lineup_id=state.on_third.lineup_id, 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.lineup_id, from_base=2, to_base=3, is_out=False, ) ) else: # Holds movements.append( RunnerMovement( lineup_id=state.on_second.lineup_id, 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.lineup_id, 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.lineup_id, 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.lineup_id, 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: DefensiveDecision | None, hit_location: str, ) -> AdvancementResult: """ Result 10: Double play 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 """ movements = [] outs = 0 runs = 0 # Check if double play is possible can_turn_dp = state.outs < 2 if can_turn_dp: # Runner on third out at home if state.is_runner_on_third(): movements.append( RunnerMovement( lineup_id=state.on_third.lineup_id, 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: # 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.lineup_id, from_base=2, to_base=3, is_out=False, ) ) if state.is_runner_on_first(): movements.append( RunnerMovement( lineup_id=state.on_first.lineup_id, 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.lineup_id, 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.lineup_id, 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.lineup_id, 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.lineup_id, 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.lineup_id, 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.lineup_id, from_base=1, to_base=1, is_out=False, ) ) if state.is_runner_on_second(): movements.append( RunnerMovement( lineup_id=state.on_second.lineup_id, from_base=2, to_base=2, is_out=False, ) ) if state.is_runner_on_third(): movements.append( RunnerMovement( lineup_id=state.on_third.lineup_id, 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: DefensiveDecision | None, 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) """ 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: # Runner on 3rd out if state.is_runner_on_third(): movements.append( RunnerMovement( lineup_id=state.on_third.lineup_id, 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.lineup_id, 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, ) # Same as Result 2 return self._gb_result_2(state, defensive_decision, hit_location) # ======================================== # FLYBALL METHODS # ======================================== def _advance_runners_flyball( self, outcome: PlayOutcome, hit_location: str, state: GameState, defensive_decision: DefensiveDecision, ) -> AdvancementResult: """ Calculate runner advancement for flyball outcome. Direct mapping (no chart needed): - FLYOUT_A: Deep - all runners tag up and advance one base - FLYOUT_B: Medium - R3 scores, R2 may attempt (DECIDE), R1 holds - FLYOUT_BQ: Medium-shallow - R3 may attempt to score (DECIDE), R2 holds, R1 holds - FLYOUT_C: Shallow - no advancement Args: outcome: The play outcome (FLYOUT_A, B, BQ, or C) hit_location: Where the ball was hit (LF, CF, RF) state: Current game state defensive_decision: Defensive team's positioning decisions Returns: AdvancementResult with all runner movements and outs """ self.logger.info( f"Flyball {outcome.name} to {hit_location}, bases {state.current_on_base_code}, " f"{state.outs} outs" ) # Dispatch directly based on outcome if outcome == PlayOutcome.FLYOUT_A: return self._fb_result_deep(state, hit_location) if outcome == PlayOutcome.FLYOUT_B: return self._fb_result_medium(state, hit_location) if outcome == PlayOutcome.FLYOUT_BQ: return self._fb_result_bq(state, hit_location) if outcome == PlayOutcome.FLYOUT_C: return self._fb_result_shallow(state, hit_location) raise ValueError(f"Unknown flyball outcome: {outcome}") # ======================================== # Flyball Result Handlers # ======================================== def _fb_result_deep(self, state: GameState, hit_location: str) -> AdvancementResult: """ FLYOUT_A: Deep flyball - all runners tag up and advance one base. - Runner on 3rd scores (sacrifice fly) - Runner on 2nd advances to 3rd - Runner on 1st advances to 2nd - Batter is out """ 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 tag up and advance one base (if less than 3 outs) if state.outs < 2: # Play doesn't end inning if state.is_runner_on_third(): # Runner scores (sacrifice fly) movements.append( RunnerMovement( lineup_id=state.on_third.lineup_id, from_base=3, to_base=4, is_out=False, ) ) runs += 1 if state.is_runner_on_second(): # Runner advances to third movements.append( RunnerMovement( lineup_id=state.on_second.lineup_id, from_base=2, to_base=3, is_out=False, ) ) if state.is_runner_on_first(): # Runner advances to second movements.append( RunnerMovement( lineup_id=state.on_first.lineup_id, from_base=1, to_base=2, is_out=False, ) ) # Build dynamic description based on outs and actual results if state.outs >= 2: desc = f"Deep flyball to {hit_location} - 3rd out, inning over" else: parts = [f"Deep flyball to {hit_location}"] if runs > 0: parts.append("R3 scores") if state.is_runner_on_second(): parts.append("R2→3B") if state.is_runner_on_first(): parts.append("R1→2B") desc = " - ".join(parts) if len(parts) > 1 else parts[0] + " - all runners tag" return AdvancementResult( movements=movements, outs_recorded=1, runs_scored=runs, result_type=None, # Flyballs don't use result types description=desc, ) def _fb_result_medium( self, state: GameState, hit_location: str ) -> AdvancementResult: """ FLYOUT_B: Medium flyball - interactive tag-up situation. - Runner on 3rd scores (always) - Runner on 2nd may attempt to tag to 3rd (DECIDE opportunity) - Runner on 1st holds (too risky) - Batter is out NOTE: DECIDE mechanic will be handled by game engine/WebSocket layer. For now, this returns conservative default (R2 holds). """ 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, ) ) # Runner advancement (if less than 3 outs) if state.outs < 2: if state.is_runner_on_third(): # Runner on 3rd always scores movements.append( RunnerMovement( lineup_id=state.on_third.lineup_id, from_base=3, to_base=4, is_out=False, ) ) runs += 1 if state.is_runner_on_second(): # DECIDE opportunity - R2 may attempt to tag to 3rd # TODO: Interactive decision-making via game engine # For now: conservative default (holds) movements.append( RunnerMovement( lineup_id=state.on_second.lineup_id, from_base=2, to_base=2, # Holds by default is_out=False, ) ) if state.is_runner_on_first(): # Runner on 1st always holds (too risky) movements.append( RunnerMovement( lineup_id=state.on_first.lineup_id, from_base=1, to_base=1, is_out=False, ) ) # Build dynamic description based on outs and actual results if state.outs >= 2: desc = f"Medium flyball to {hit_location} - 3rd out, inning over" else: parts = [f"Medium flyball to {hit_location}"] if runs > 0: parts.append("R3 scores") if state.is_runner_on_second(): parts.append("R2 DECIDE (held)") if state.is_runner_on_first(): parts.append("R1 holds") desc = " - ".join(parts) if len(parts) > 1 else parts[0] return AdvancementResult( movements=movements, outs_recorded=1, runs_scored=runs, result_type=None, # Flyballs don't use result types description=desc, ) def _fb_result_bq(self, state: GameState, hit_location: str) -> AdvancementResult: """ FLYOUT_BQ: Medium-shallow flyball (fly(b)?) - interactive tag-up situation. - Runner on 3rd may attempt to score (DECIDE opportunity) - Runner on 2nd holds (too risky) - Runner on 1st holds (too risky) - Batter is out NOTE: DECIDE mechanic will be handled by game engine/WebSocket layer. For now, this returns conservative default (R3 holds). """ 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, ) ) # Runner advancement (if less than 3 outs) if state.outs < 2: if state.is_runner_on_third(): # DECIDE opportunity - R3 may attempt to score # TODO: Interactive decision-making via game engine # For now: conservative default (holds) movements.append( RunnerMovement( lineup_id=state.on_third.lineup_id, from_base=3, to_base=3, # Holds by default is_out=False, ) ) if state.is_runner_on_second(): # Runner on 2nd holds (too risky) movements.append( RunnerMovement( lineup_id=state.on_second.lineup_id, from_base=2, to_base=2, is_out=False, ) ) if state.is_runner_on_first(): # Runner on 1st holds (too risky) movements.append( RunnerMovement( lineup_id=state.on_first.lineup_id, from_base=1, to_base=1, is_out=False, ) ) # Build dynamic description based on outs and actual results if state.outs >= 2: desc = f"Medium-shallow flyball to {hit_location} - 3rd out, inning over" else: parts = [f"Medium-shallow flyball to {hit_location}"] if state.is_runner_on_third(): parts.append("R3 DECIDE (held)") if state.is_runner_on_second() or state.is_runner_on_first(): parts.append("all others hold") desc = " - ".join(parts) if len(parts) > 1 else parts[0] return AdvancementResult( movements=movements, outs_recorded=1, runs_scored=runs, result_type=None, # Flyballs don't use result types description=desc, ) def _fb_result_shallow(self, state: GameState, hit_location: str) -> AdvancementResult: """ FLYOUT_C: Shallow flyball - no runners advance. - Batter is out - All runners hold (too shallow to tag) """ 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 hold if state.is_runner_on_first(): movements.append( RunnerMovement( lineup_id=state.on_first.lineup_id, from_base=1, to_base=1, is_out=False, ) ) if state.is_runner_on_second(): movements.append( RunnerMovement( lineup_id=state.on_second.lineup_id, from_base=2, to_base=2, is_out=False, ) ) if state.is_runner_on_third(): movements.append( RunnerMovement( lineup_id=state.on_third.lineup_id, from_base=3, to_base=3, is_out=False, ) ) return AdvancementResult( movements=movements, outs_recorded=1, runs_scored=0, result_type=None, # Flyballs don't use result types description="Shallow flyball - all runners hold", ) # ============================================================================ # X-CHECK RUNNER ADVANCEMENT (Placeholders - to be implemented in Phase 3D) # ============================================================================ def x_check_g1( on_base_code: int, defender_in: bool, error_result: str, state: GameState, hit_location: str, defensive_decision: DefensiveDecision, ) -> AdvancementResult: """ Runner advancement for X-Check G1 result. Uses G1 advancement table to get GroundballResultType based on base situation, defensive positioning, and error result. Args: on_base_code: Current base situation code (0-7 bit field) defender_in: Is the defender playing in? error_result: 'NO', 'E1', 'E2', 'E3', 'RP' state: Current game state (for lineup IDs, outs, etc.) hit_location: Where the ball was hit (1B, 2B, SS, 3B, P, C) defensive_decision: Defensive positioning decision Returns: AdvancementResult with runner movements """ from app.core.x_check_advancement_tables import ( build_advancement_from_code, get_groundball_advancement, ) # Lookup groundball result type from table gb_type = get_groundball_advancement("G1", on_base_code, defender_in, error_result) # If error result: use simple error advancement (doesn't need GameState details) if error_result in ["E1", "E2", "E3", "RP"]: return build_advancement_from_code(on_base_code, gb_type, result_name="G1") # If no error: delegate to existing result handler (needs full GameState) runner_adv = RunnerAdvancement() return runner_adv._execute_result( result_type=gb_type, state=state, hit_location=hit_location, defensive_decision=defensive_decision, ) def x_check_g2( on_base_code: int, defender_in: bool, error_result: str, state: GameState, hit_location: str, defensive_decision: DefensiveDecision, ) -> AdvancementResult: """ Runner advancement for X-Check G2 result. Uses G2 advancement table to get GroundballResultType based on base situation, defensive positioning, and error result. Args: on_base_code: Current base situation code (0-7 bit field) defender_in: Is the defender playing in? error_result: 'NO', 'E1', 'E2', 'E3', 'RP' state: Current game state (for lineup IDs, outs, etc.) hit_location: Where the ball was hit (1B, 2B, SS, 3B, P, C) defensive_decision: Defensive positioning decision Returns: AdvancementResult with runner movements """ from app.core.x_check_advancement_tables import ( build_advancement_from_code, get_groundball_advancement, ) gb_type = get_groundball_advancement("G2", on_base_code, defender_in, error_result) # If error result: use simple error advancement (doesn't need GameState details) if error_result in ["E1", "E2", "E3", "RP"]: return build_advancement_from_code(on_base_code, gb_type, result_name="G2") # If no error: delegate to existing result handler (needs full GameState) runner_adv = RunnerAdvancement() return runner_adv._execute_result( result_type=gb_type, state=state, hit_location=hit_location, defensive_decision=defensive_decision, ) def x_check_g3( on_base_code: int, defender_in: bool, error_result: str, state: GameState, hit_location: str, defensive_decision: DefensiveDecision, ) -> AdvancementResult: """ Runner advancement for X-Check G3 result. Uses G3 advancement table to get GroundballResultType based on base situation, defensive positioning, and error result. Args: on_base_code: Current base situation code (0-7 bit field) defender_in: Is the defender playing in? error_result: 'NO', 'E1', 'E2', 'E3', 'RP' state: Current game state (for lineup IDs, outs, etc.) hit_location: Where the ball was hit (1B, 2B, SS, 3B, P, C) defensive_decision: Defensive positioning decision Returns: AdvancementResult with runner movements """ from app.core.x_check_advancement_tables import ( build_advancement_from_code, get_groundball_advancement, ) gb_type = get_groundball_advancement("G3", on_base_code, defender_in, error_result) # If error result: use simple error advancement (doesn't need GameState details) if error_result in ["E1", "E2", "E3", "RP"]: return build_advancement_from_code(on_base_code, gb_type, result_name="G3") # If no error: delegate to existing result handler (needs full GameState) runner_adv = RunnerAdvancement() return runner_adv._execute_result( result_type=gb_type, state=state, hit_location=hit_location, defensive_decision=defensive_decision, ) def x_check_f1( on_base_code: int, error_result: str, state: GameState, hit_location: str ) -> AdvancementResult: """ Runner advancement for X-Check F1 (deep flyball) result. F1 maps to FLYOUT_A behavior: - If error: all runners advance E# bases (out negated) - If no error: delegate to existing FLYOUT_A logic Args: on_base_code: Current base situation code (0-7 bit field) error_result: 'NO', 'E1', 'E2', 'E3', 'RP' state: Current game state (for lineup IDs, outs, etc.) hit_location: Where the ball was hit (LF, CF, RF) Returns: AdvancementResult with runner movements """ from app.core.x_check_advancement_tables import build_flyball_advancement_with_error # If error result: use simple error advancement (doesn't need GameState details) if error_result != "NO": return build_flyball_advancement_with_error( on_base_code, error_result, flyball_type="F1" ) # If no error: delegate to existing FLYOUT_A logic runner_adv = RunnerAdvancement() return runner_adv._fb_result_deep(state, hit_location) def x_check_f2( on_base_code: int, error_result: str, state: GameState, hit_location: str ) -> AdvancementResult: """ Runner advancement for X-Check F2 (medium flyball) result. F2 maps to FLYOUT_B behavior: - If error: all runners advance E# bases (out negated) - If no error: delegate to existing FLYOUT_B logic Args: on_base_code: Current base situation code (0-7 bit field) error_result: 'NO', 'E1', 'E2', 'E3', 'RP' state: Current game state (for lineup IDs, outs, etc.) hit_location: Where the ball was hit (LF, CF, RF) Returns: AdvancementResult with runner movements """ from app.core.x_check_advancement_tables import build_flyball_advancement_with_error # If error result: use simple error advancement (doesn't need GameState details) if error_result != "NO": return build_flyball_advancement_with_error( on_base_code, error_result, flyball_type="F2" ) # If no error: delegate to existing FLYOUT_B logic runner_adv = RunnerAdvancement() return runner_adv._fb_result_medium(state, hit_location) def x_check_f3( on_base_code: int, error_result: str, state: GameState, hit_location: str ) -> AdvancementResult: """ Runner advancement for X-Check F3 (shallow flyball) result. F3 maps to FLYOUT_C behavior: - If error: all runners advance E# bases (out negated) - If no error: delegate to existing FLYOUT_C logic (batter out, no advancement) Args: on_base_code: Current base situation code (0-7 bit field) error_result: 'NO', 'E1', 'E2', 'E3', 'RP' state: Current game state (for lineup IDs, outs, etc.) hit_location: Where the ball was hit (LF, CF, RF) Returns: AdvancementResult with runner movements """ from app.core.x_check_advancement_tables import build_flyball_advancement_with_error # If error result: use simple error advancement (doesn't need GameState details) if error_result != "NO": return build_flyball_advancement_with_error( on_base_code, error_result, flyball_type="F3" ) # If no error: delegate to existing FLYOUT_C logic runner_adv = RunnerAdvancement() return runner_adv._fb_result_shallow(state, hit_location)