""" Play Resolver - Resolves play outcomes based on dice rolls. Architecture: Outcome-first design where manual resolution is primary. - resolve_outcome(): Core resolution logic (works for both manual and auto) - resolve_manual_play(): Wrapper for manual submissions (most games) - resolve_auto_play(): Wrapper for PD auto mode (rare) Author: Claude Date: 2025-10-24 Updated: 2025-10-31 - Week 7 Task 6: Integrated RunnerAdvancement and outcome-first architecture Updated: 2025-11-02 - Phase 3C: Added X-Check resolution logic """ import logging from dataclasses import dataclass from typing import TYPE_CHECKING, Any from app.config import PlayOutcome, get_league_config from app.config.common_x_check_tables import ( CATCHER_DEFENSE_TABLE, INFIELD_DEFENSE_TABLE, OUTFIELD_DEFENSE_TABLE, get_error_chart_for_position, get_fielders_holding_runners, ) from app.config.result_charts import ( PdAutoResultChart, ) from app.core.dice import dice_system from app.core.roll_types import AbRoll from app.core.runner_advancement import AdvancementResult, RunnerAdvancement from app.models.game_models import ( DefensiveDecision, GameState, ManualOutcomeSubmission, OffensiveDecision, XCheckResult, ) if TYPE_CHECKING: from app.models.player_models import PdPlayer logger = logging.getLogger(f"{__name__}.PlayResolver") @dataclass class RunnerAdvancementData: """Enhanced runner advancement data with player identification for play-by-play display.""" from_base: int # 0=batter, 1-3=bases to_base: int # 1-4=bases (4=home/scored), 0=out lineup_id: int # Player's lineup ID for name lookup is_out: bool = False @dataclass class PlayResult: """Result of a resolved play""" outcome: PlayOutcome outs_recorded: int runs_scored: int batter_result: int | None # None = out, 1-4 = base reached runners_advanced: list[RunnerAdvancementData] # Enhanced with lineup_id description: str ab_roll: AbRoll # Full at-bat roll for audit trail hit_location: str | None = ( None # '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'P', 'C' ) # Statistics is_hit: bool = False is_out: bool = False is_walk: bool = False # X-Check details (Phase 3C) x_check_details: XCheckResult | None = None class PlayResolver: """ Resolves play outcomes based on dice rolls and game state. Architecture: Outcome-first design - Manual mode (primary): Players submit outcomes after reading physical cards - Auto mode (rare): System generates outcomes from digitized ratings (PD only) Args: league_id: 'sba' or 'pd' auto_mode: If True, use result charts to auto-generate outcomes Only supported for leagues with digitized card data Raises: ValueError: If auto_mode requested for league that doesn't support it """ def __init__( self, league_id: str, auto_mode: bool = False, state_manager: Any | None = None ): self.league_id = league_id self.auto_mode = auto_mode self.runner_advancement = RunnerAdvancement() self.state_manager = state_manager # Phase 3E-Main: For X-Check defender lookup # Get league config for validation league_config = get_league_config(league_id) # Validate auto mode support if auto_mode and not league_config.supports_auto_mode(): raise ValueError( f"Auto mode not supported for {league_id} league. " f"This league does not have digitized card data." ) # Initialize result chart for auto mode only if auto_mode: self.result_chart = PdAutoResultChart() logger.info(f"PlayResolver initialized in AUTO mode for {league_id}") else: self.result_chart = None logger.info(f"PlayResolver initialized in MANUAL mode for {league_id}") # ======================================== # PUBLIC METHODS - Primary API # ======================================== def resolve_manual_play( self, submission: ManualOutcomeSubmission, state: GameState, defensive_decision: DefensiveDecision, offensive_decision: OffensiveDecision, ab_roll: AbRoll, ) -> PlayResult: """ Resolve a manually submitted play (SBA + PD manual mode). This is the PRIMARY method for most games. Players read physical cards and submit the outcome they see via WebSocket. Args: submission: Player's submitted outcome + optional hit location state: Current game state defensive_decision: Defensive team's choices offensive_decision: Offensive team's choices ab_roll: Server-rolled dice for audit trail Returns: PlayResult with complete outcome """ logger.info( f"Resolving manual play - {submission.outcome} at {submission.hit_location}" ) # Convert string to PlayOutcome enum outcome = PlayOutcome(submission.outcome) # Delegate to core resolution return self.resolve_outcome( outcome=outcome, hit_location=submission.hit_location, state=state, defensive_decision=defensive_decision, offensive_decision=offensive_decision, ab_roll=ab_roll, ) def resolve_auto_play( self, state: GameState, batter: "PdPlayer", pitcher: "PdPlayer", defensive_decision: DefensiveDecision, offensive_decision: OffensiveDecision, ) -> PlayResult: """ Resolve an auto-generated play (PD auto mode only). This is RARE - only used for PD games with auto mode enabled. System generates outcome from digitized player ratings. Args: state: Current game state batter: Batting player (PdPlayer with ratings) pitcher: Pitching player (PdPlayer with ratings) defensive_decision: Defensive team's choices offensive_decision: Offensive team's choices Returns: PlayResult with complete outcome Raises: ValueError: If called when not in auto mode """ if not self.auto_mode: raise ValueError("resolve_auto_play() can only be called in auto mode") logger.info(f"Resolving auto play - {batter.name} vs {pitcher.name}") # Check if there are runners on base (affects chaos check) runners_on_base = bool(state.on_first or state.on_second or state.on_third) # Roll dice ab_roll = dice_system.roll_ab( league_id=state.league_id, game_id=state.game_id, runners_on_base=runners_on_base, ) # Generate outcome from ratings outcome, hit_location = self.result_chart.get_outcome( # type: ignore roll=ab_roll, state=state, batter=batter, pitcher=pitcher ) # Delegate to core resolution return self.resolve_outcome( outcome=outcome, hit_location=hit_location, state=state, defensive_decision=defensive_decision, offensive_decision=offensive_decision, ab_roll=ab_roll, ) def resolve_outcome( self, outcome: PlayOutcome, hit_location: str | None, state: GameState, defensive_decision: DefensiveDecision, offensive_decision: OffensiveDecision, ab_roll: AbRoll, forced_xcheck_result: str | None = None, forced_xcheck_error: str | None = None, ) -> PlayResult: """ CORE resolution method - all play resolution logic lives here. This method handles all outcome types and delegates to RunnerAdvancement for groundball outcomes. Works for both manual and auto modes. Args: outcome: The play outcome (from card or auto-generated) hit_location: Where ball was hit ('1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'P', 'C') or None state: Current game state defensive_decision: Defensive team's positioning/strategy offensive_decision: Offensive team's strategy ab_roll: Dice roll for audit trail forced_xcheck_result: For testing - force X-Check converted result (G1, G2, SI2, DO2, etc.) forced_xcheck_error: For testing - force X-Check error result (NO, E1, E2, E3, RP) Returns: PlayResult with complete outcome, runner movements, and statistics """ logger.info( f"Resolving {outcome.value} - Inning {state.inning} {state.half}, {state.outs} outs" ) # ==================== Strikeout ==================== if outcome == PlayOutcome.STRIKEOUT: return PlayResult( outcome=outcome, outs_recorded=1, runs_scored=0, batter_result=None, runners_advanced=[], description="Strikeout looking", ab_roll=ab_roll, hit_location=None, is_out=True, ) # ==================== Popout ==================== if outcome == PlayOutcome.POPOUT: return PlayResult( outcome=outcome, outs_recorded=1, runs_scored=0, batter_result=None, runners_advanced=[], description="Popout to infield", ab_roll=ab_roll, hit_location=hit_location, is_out=True, ) # ==================== Groundballs ==================== if outcome in [ PlayOutcome.GROUNDBALL_A, PlayOutcome.GROUNDBALL_B, PlayOutcome.GROUNDBALL_C, ]: # Business rule: hit_location only matters when there are runners on base # AND less than 2 outs (for fielding choices and runner advancement) has_runners = ( state.on_first is not None or state.on_second is not None or state.on_third is not None ) needs_hit_location = has_runners and state.outs < 2 if needs_hit_location and not hit_location: raise ValueError( f"Hit location required for {outcome.value} when runners are on base with less than 2 outs. " f"Current situation: {state.outs} outs, runners on: " f"{'1B ' if state.on_first else ''}" f"{'2B ' if state.on_second else ''}" f"{'3B' if state.on_third else ''}" ) # Delegate to RunnerAdvancement for all groundball outcomes advancement_result = self.runner_advancement.advance_runners( outcome=outcome, hit_location=hit_location or "SS", # Default to SS if location not specified state=state, defensive_decision=defensive_decision, ) # Convert RunnerMovement list to RunnerAdvancementData for PlayResult runners_advanced = [ RunnerAdvancementData( from_base=movement.from_base, to_base=movement.to_base, lineup_id=movement.lineup_id, is_out=movement.is_out, ) for movement in advancement_result.movements if movement.from_base > 0 # Exclude batter, include only runners ] # Extract batter result from movements batter_movement = next( (m for m in advancement_result.movements if m.from_base == 0), None ) batter_result = ( batter_movement.to_base if batter_movement and not batter_movement.is_out else None ) return PlayResult( outcome=outcome, outs_recorded=advancement_result.outs_recorded, runs_scored=advancement_result.runs_scored, batter_result=batter_result, runners_advanced=runners_advanced, description=advancement_result.description, ab_roll=ab_roll, hit_location=hit_location, is_out=(advancement_result.outs_recorded > 0), ) # ==================== Flyouts ==================== if outcome in [ PlayOutcome.FLYOUT_A, PlayOutcome.FLYOUT_B, PlayOutcome.FLYOUT_BQ, PlayOutcome.FLYOUT_C, ]: # Business rule: hit_location only matters for FLYOUT_B and FLYOUT_BQ # when there are runners on base AND less than 2 outs (for tag-up decisions) if outcome in [PlayOutcome.FLYOUT_B, PlayOutcome.FLYOUT_BQ]: has_runners = ( state.on_first is not None or state.on_second is not None or state.on_third is not None ) needs_hit_location = has_runners and state.outs < 2 if needs_hit_location and not hit_location: raise ValueError( f"Hit location required for {outcome.value} when runners are on base with less than 2 outs. " f"Current situation: {state.outs} outs, runners on: " f"{'1B ' if state.on_first else ''}" f"{'2B ' if state.on_second else ''}" f"{'3B' if state.on_third else ''}" ) # Delegate to RunnerAdvancement for all flyball outcomes advancement_result = self.runner_advancement.advance_runners( outcome=outcome, hit_location=hit_location or "CF", # Default to CF if location not specified state=state, defensive_decision=defensive_decision, ) # Convert RunnerMovement list to RunnerAdvancementData for PlayResult runners_advanced = [ RunnerAdvancementData( from_base=movement.from_base, to_base=movement.to_base, lineup_id=movement.lineup_id, is_out=movement.is_out, ) for movement in advancement_result.movements if movement.from_base > 0 # Exclude batter, include only runners ] # Extract batter result from movements (always out for flyouts) batter_movement = next( (m for m in advancement_result.movements if m.from_base == 0), None ) batter_result = ( batter_movement.to_base if batter_movement and not batter_movement.is_out else None ) return PlayResult( outcome=outcome, outs_recorded=advancement_result.outs_recorded, runs_scored=advancement_result.runs_scored, batter_result=batter_result, runners_advanced=runners_advanced, description=advancement_result.description, ab_roll=ab_roll, hit_location=hit_location, is_out=(advancement_result.outs_recorded > 0), ) # ==================== Lineout ==================== if outcome == PlayOutcome.LINEOUT: return PlayResult( outcome=outcome, outs_recorded=1, runs_scored=0, batter_result=None, runners_advanced=[], description="Lineout", ab_roll=ab_roll, is_out=True, ) if outcome == PlayOutcome.WALK: # Walk - batter to first, runners advance if forced runners_advanced = self._advance_on_walk(state) runs_scored = sum( 1 for adv in runners_advanced if adv.to_base == 4 ) return PlayResult( outcome=outcome, outs_recorded=0, runs_scored=runs_scored, batter_result=1, runners_advanced=runners_advanced, description="Walk", ab_roll=ab_roll, is_walk=True, ) if outcome == PlayOutcome.HIT_BY_PITCH: # HBP - identical to walk: batter to first, runners advance if forced runners_advanced = self._advance_on_walk(state) runs_scored = sum( 1 for adv in runners_advanced if adv.to_base == 4 ) return PlayResult( outcome=outcome, outs_recorded=0, runs_scored=runs_scored, batter_result=1, runners_advanced=runners_advanced, description="Hit by pitch", ab_roll=ab_roll, # Note: HBP is NOT classified as a walk for statistics purposes ) # ==================== Singles ==================== if outcome == PlayOutcome.SINGLE_1: # Single with standard advancement runners_advanced = self._advance_on_single_1(state) runs_scored = sum( 1 for adv in runners_advanced if adv.to_base == 4 ) return PlayResult( outcome=outcome, outs_recorded=0, runs_scored=runs_scored, batter_result=1, runners_advanced=runners_advanced, description="Single to left field", ab_roll=ab_roll, is_hit=True, ) if outcome == PlayOutcome.SINGLE_2: # Single with enhanced advancement (more aggressive) runners_advanced = self._advance_on_single_2(state) runs_scored = sum( 1 for adv in runners_advanced if adv.to_base == 4 ) return PlayResult( outcome=outcome, outs_recorded=0, runs_scored=runs_scored, batter_result=1, runners_advanced=runners_advanced, description="Single to right field", ab_roll=ab_roll, is_hit=True, ) if outcome == PlayOutcome.SINGLE_UNCAPPED: # Business rule: hit_location only matters when there is a runner on 1st, 2nd, or both has_runner_on_scoring_bases = state.on_first is not None or state.on_second is not None if has_runner_on_scoring_bases and not hit_location: raise ValueError( f"Hit location required for {outcome.value} when runner on 1st or 2nd. " f"Current situation: runners on: " f"{'1B ' if state.on_first else ''}" f"{'2B ' if state.on_second else ''}" f"{'3B' if state.on_third else ''}" ) # TODO Phase 3: Implement uncapped hit decision tree # For now, treat as SINGLE_1 runners_advanced = self._advance_on_single_1(state) runs_scored = sum( 1 for adv in runners_advanced if adv.to_base == 4 ) return PlayResult( outcome=outcome, outs_recorded=0, runs_scored=runs_scored, batter_result=1, runners_advanced=runners_advanced, description="Single to center (uncapped)", ab_roll=ab_roll, is_hit=True, ) # ==================== Doubles ==================== if outcome == PlayOutcome.DOUBLE_2: # Double to 2nd base runners_advanced = self._advance_on_double_2(state) runs_scored = sum( 1 for adv in runners_advanced if adv.to_base == 4 ) return PlayResult( outcome=outcome, outs_recorded=0, runs_scored=runs_scored, batter_result=2, runners_advanced=runners_advanced, description="Double to left-center", ab_roll=ab_roll, is_hit=True, ) if outcome == PlayOutcome.DOUBLE_3: # Double with extra runner advancement (runners advance 3 bases) runners_advanced = self._advance_on_double_3(state) runs_scored = sum( 1 for adv in runners_advanced if adv.to_base == 4 ) return PlayResult( outcome=outcome, outs_recorded=0, runs_scored=runs_scored, batter_result=2, # Batter reaches 2B (it's a double) runners_advanced=runners_advanced, description="Double to right-center gap (runners advance 3 bases)", ab_roll=ab_roll, is_hit=True, ) if outcome == PlayOutcome.DOUBLE_UNCAPPED: # Business rule: hit_location only matters when there is a runner on 1st has_runner_on_first = state.on_first is not None if has_runner_on_first and not hit_location: raise ValueError( f"Hit location required for {outcome.value} when runner on 1st. " f"Current situation: runners on: " f"{'1B ' if state.on_first else ''}" f"{'2B ' if state.on_second else ''}" f"{'3B' if state.on_third else ''}" ) # TODO Phase 3: Implement uncapped hit decision tree # For now, treat as DOUBLE_2 runners_advanced = self._advance_on_double_2(state) runs_scored = sum( 1 for adv in runners_advanced if adv.to_base == 4 ) return PlayResult( outcome=outcome, outs_recorded=0, runs_scored=runs_scored, batter_result=2, runners_advanced=runners_advanced, description="Double (uncapped)", ab_roll=ab_roll, is_hit=True, ) if outcome == PlayOutcome.TRIPLE: # All runners score runners_advanced = [ RunnerAdvancementData(from_base=base, to_base=4, lineup_id=runner.lineup_id) for base, runner in state.get_all_runners() ] runs_scored = len(runners_advanced) return PlayResult( outcome=outcome, outs_recorded=0, runs_scored=runs_scored, batter_result=3, runners_advanced=runners_advanced, description="Triple to right-center gap", ab_roll=ab_roll, is_hit=True, ) if outcome == PlayOutcome.HOMERUN: # Everyone scores runners_advanced = [ RunnerAdvancementData(from_base=base, to_base=4, lineup_id=runner.lineup_id) for base, runner in state.get_all_runners() ] runs_scored = len(runners_advanced) + 1 return PlayResult( outcome=outcome, outs_recorded=0, runs_scored=runs_scored, batter_result=4, runners_advanced=runners_advanced, description="Home run to left field", ab_roll=ab_roll, is_hit=True, ) if outcome == PlayOutcome.WILD_PITCH: # Runners advance one base runners_advanced = [ RunnerAdvancementData( from_base=base, to_base=min(base + 1, 4), lineup_id=runner.lineup_id ) for base, runner in state.get_all_runners() ] runs_scored = sum( 1 for adv in runners_advanced if adv.to_base == 4 ) return PlayResult( outcome=outcome, outs_recorded=0, runs_scored=runs_scored, batter_result=None, # Batter stays at plate runners_advanced=runners_advanced, description="Wild pitch - runners advance", ab_roll=ab_roll, ) if outcome == PlayOutcome.PASSED_BALL: # Runners advance one base runners_advanced = [ RunnerAdvancementData( from_base=base, to_base=min(base + 1, 4), lineup_id=runner.lineup_id ) for base, runner in state.get_all_runners() ] runs_scored = sum( 1 for adv in runners_advanced if adv.to_base == 4 ) return PlayResult( outcome=outcome, outs_recorded=0, runs_scored=runs_scored, batter_result=None, # Batter stays at plate runners_advanced=runners_advanced, description="Passed ball - runners advance", ab_roll=ab_roll, ) # ==================== X-Check ==================== if outcome == PlayOutcome.X_CHECK: # X-Check requires position in hit_location if not hit_location: raise ValueError("X-Check outcome requires hit_location (position)") # Resolve X-Check with defense table and error chart lookups return self._resolve_x_check( position=hit_location, state=state, defensive_decision=defensive_decision, ab_roll=ab_roll, forced_result=forced_xcheck_result, forced_error=forced_xcheck_error, ) raise ValueError(f"Unhandled outcome: {outcome}") def _advance_on_walk(self, state: GameState) -> list[RunnerAdvancementData]: """Calculate runner advancement on walk""" advances: list[RunnerAdvancementData] = [] # Only forced runners advance if state.on_first: # First occupied - check second if state.on_second: # Bases loaded scenario if state.on_third: # Bases loaded - force runner home advances.append(RunnerAdvancementData( from_base=3, to_base=4, lineup_id=state.on_third.lineup_id )) advances.append(RunnerAdvancementData( from_base=2, to_base=3, lineup_id=state.on_second.lineup_id )) advances.append(RunnerAdvancementData( from_base=1, to_base=2, lineup_id=state.on_first.lineup_id )) return advances def _advance_on_single_1(self, state: GameState) -> list[RunnerAdvancementData]: """Calculate runner advancement on single (simplified)""" advances: list[RunnerAdvancementData] = [] if state.on_third: # Runner on third scores advances.append(RunnerAdvancementData( from_base=3, to_base=4, lineup_id=state.on_third.lineup_id )) if state.on_second: advances.append(RunnerAdvancementData( from_base=2, to_base=3, lineup_id=state.on_second.lineup_id )) if state.on_first: advances.append(RunnerAdvancementData( from_base=1, to_base=2, lineup_id=state.on_first.lineup_id )) return advances def _advance_on_single_2(self, state: GameState) -> list[RunnerAdvancementData]: """Calculate runner advancement on single (simplified)""" advances: list[RunnerAdvancementData] = [] if state.on_third: # Runner on third scores advances.append(RunnerAdvancementData( from_base=3, to_base=4, lineup_id=state.on_third.lineup_id )) if state.on_second: # Runner on second scores advances.append(RunnerAdvancementData( from_base=2, to_base=4, lineup_id=state.on_second.lineup_id )) if state.on_first: # Runner on first to third advances.append(RunnerAdvancementData( from_base=1, to_base=3, lineup_id=state.on_first.lineup_id )) return advances def _advance_on_double_2(self, state: GameState) -> list[RunnerAdvancementData]: """Calculate runner advancement on DOUBLE2 - all runners advance exactly 2 bases""" advances: list[RunnerAdvancementData] = [] # Runners advance 2 bases: # 1st -> 3rd, 2nd -> home, 3rd -> home for base, runner in state.get_all_runners(): final_base = min(base + 2, 4) advances.append(RunnerAdvancementData( from_base=base, to_base=final_base, lineup_id=runner.lineup_id )) return advances def _advance_on_double_3(self, state: GameState) -> list[RunnerAdvancementData]: """Calculate runner advancement on DOUBLE3 - all runners advance exactly 3 bases""" advances: list[RunnerAdvancementData] = [] # Runners advance 3 bases (all score from any base) # 1st -> home (1+3=4), 2nd -> home (2+3=5→4), 3rd -> home for base, runner in state.get_all_runners(): final_base = min(base + 3, 4) advances.append(RunnerAdvancementData( from_base=base, to_base=final_base, lineup_id=runner.lineup_id )) return advances # ======================================================================== # X-CHECK RESOLUTION (Phase 3C - 2025-11-02) # ======================================================================== def _resolve_x_check( self, position: str, state: GameState, defensive_decision: DefensiveDecision, ab_roll: AbRoll, forced_result: str | None = None, forced_error: str | None = None, ) -> PlayResult: """ Resolve X-Check play with defense range and error tables. Process: 1. Get defender and their ratings 2. Roll 1d20 + 3d6 (or use forced values) 3. Adjust range if playing in 4. Look up base result from defense table (or use forced_result) 5. Apply SPD test if needed 6. Apply G2#/G3# conversion if applicable 7. Look up error result from error chart (or use forced_error) 8. Determine final outcome 9. Get runner advancement 10. Create Play record Args: position: Position being checked (SS, LF, 3B, etc.) state: Current game state defensive_decision: Defensive positioning ab_roll: Dice roll for audit trail forced_result: For testing - force the converted result (G1, G2, SI2, DO2, etc.) forced_error: For testing - force the error result (NO, E1, E2, E3, RP) Returns: PlayResult with x_check_details populated Raises: ValueError: If defender has no position rating """ logger.info(f"Resolving X-Check to {position}") if forced_result: logger.info( f"🎯 Forcing X-Check result: {forced_result} + {forced_error or 'NO'}" ) # Check league config league_config = get_league_config(state.league_id) supports_ratings = league_config.supports_position_ratings() # Step 1: Get defender from lineup cache and use position ratings defender = None if self.state_manager: defender = state.get_defender_for_position(position, self.state_manager) if defender and supports_ratings and defender.position_rating: # Use actual ratings from PD league player defender_range = defender.position_rating.range defender_error_rating = defender.position_rating.error defender_id = defender.lineup_id logger.debug( f"Using defender {defender_id} (card {defender.card_id}) ratings: " f"range={defender_range}, error={defender_error_rating}" ) elif defender: # Defender found but no ratings (SBA or missing data) logger.info( f"Defender found at {position} but no ratings available " f"(league={state.league_id}, supports_ratings={supports_ratings})" ) defender_range = 3 # Average range defender_error_rating = 15 # Average error defender_id = defender.lineup_id else: # No defender found (shouldn't happen in valid game) logger.warning(f"No defender found at {position}, using defaults") defender_range = 3 defender_error_rating = 15 defender_id = 0 # Step 2: Roll dice using proper fielding roll (includes audit trail) fielding_roll = dice_system.roll_fielding( position=position, league_id=state.league_id, game_id=state.game_id ) d20_roll = fielding_roll.d20 d6_roll = fielding_roll.error_total logger.debug( f"X-Check rolls: d20={d20_roll}, 3d6={d6_roll} (roll_id={fielding_roll.roll_id})" ) # Step 3: Adjust range if playing in adjusted_range = self._adjust_range_for_defensive_position( base_range=defender_range, position=position, defensive_decision=defensive_decision, ) # Initialize SPD test variables (used in both forced and normal paths) spd_test_roll = None spd_test_target = None spd_test_passed = None # Step 4: Look up base result (or use forced) if forced_result: # Use forced result, skip table lookup base_result = forced_result converted_result = forced_result # Skip SPD test and G2#/G3# conversion logger.debug(f"Using forced result: {forced_result}") else: # Normal flow: look up from defense table base_result = self._lookup_defense_table( position=position, d20_roll=d20_roll, defense_range=adjusted_range ) logger.debug(f"Base result from defense table: {base_result}") # Step 5: Apply SPD test if needed converted_result = base_result if base_result == "SPD": # TODO: Need batter for SPD test - placeholder for now converted_result = "G3" # Default to G3 if SPD test fails logger.debug(f"SPD test defaulted to fail → {converted_result}") # Step 6: Apply G2#/G3# conversion if applicable if converted_result in ["G2#", "G3#"]: converted_result = self._apply_hash_conversion( result=converted_result, position=position, adjusted_range=adjusted_range, base_range=defender_range, state=state, batter_hand="R", # Placeholder ) # Step 7: Look up error result (or use forced) if forced_error: # Use forced error, skip chart lookup error_result = forced_error logger.debug(f"Using forced error: {forced_error}") else: # Normal flow: look up from error chart error_result = self._lookup_error_chart( position=position, error_rating=defender_error_rating, d6_roll=d6_roll ) logger.debug(f"Error result: {error_result}") # Step 8: Determine final outcome final_outcome, hit_type = self._determine_final_x_check_outcome( converted_result=converted_result, error_result=error_result ) # Step 9: Create XCheckResult x_check_details = XCheckResult( position=position, d20_roll=d20_roll, d6_roll=d6_roll, defender_range=adjusted_range, defender_error_rating=defender_error_rating, defender_id=defender_id, base_result=base_result, converted_result=converted_result, error_result=error_result, final_outcome=final_outcome, hit_type=hit_type, spd_test_roll=spd_test_roll, spd_test_target=spd_test_target, spd_test_passed=spd_test_passed, ) # Step 10: Get runner advancement defender_in = adjusted_range > defender_range # Call appropriate x_check function based on converted_result advancement = self._get_x_check_advancement( converted_result=converted_result, error_result=error_result, state=state, defender_in=defender_in, hit_location=position, defensive_decision=defensive_decision, ) # Convert AdvancementResult to RunnerAdvancementData for PlayResult runners_advanced = [ RunnerAdvancementData( from_base=movement.from_base, to_base=movement.to_base, lineup_id=movement.lineup_id, is_out=movement.is_out, ) for movement in advancement.movements if movement.from_base > 0 # Exclude batter, include only runners ] # Extract batter result from movements batter_movement = next( (m for m in advancement.movements if m.from_base == 0), None ) batter_result = ( batter_movement.to_base if batter_movement and not batter_movement.is_out else None ) runs_scored = advancement.runs_scored outs_recorded = advancement.outs_recorded # Step 11: Create PlayResult return PlayResult( outcome=final_outcome, outs_recorded=outs_recorded, runs_scored=runs_scored, batter_result=batter_result, runners_advanced=runners_advanced, description=f"X-Check {position}: {base_result} → {converted_result} + {error_result} = {final_outcome.value}", ab_roll=ab_roll, hit_location=position, is_hit=final_outcome.is_hit(), is_out=final_outcome.is_out(), x_check_details=x_check_details, ) def _adjust_range_for_defensive_position( self, base_range: int, position: str, defensive_decision: DefensiveDecision ) -> int: """ Adjust defense range for defensive positioning. If defender is playing in, range increases by 1 (max 5). Args: base_range: Defender's base range (1-5) position: Position code defensive_decision: Current defensive positioning Returns: Adjusted range (1-5) """ playing_in = False if ( defensive_decision.infield_depth == "corners_in" and position in ["1B", "3B", "P", "C"] or defensive_decision.infield_depth == "infield_in" and position in ["1B", "2B", "3B", "SS", "P", "C"] ): playing_in = True if playing_in: adjusted = min(base_range + 1, 5) logger.debug(f"{position} playing in: range {base_range} → {adjusted}") return adjusted return base_range def _lookup_defense_table( self, position: str, d20_roll: int, defense_range: int ) -> str: """ Look up base result from defense table. Args: position: Position code (determines which table) d20_roll: 1-20 (row selector) defense_range: 1-5 (column selector) Returns: Base result code (G1, F2, SI2, SPD, etc.) """ # Determine which table to use if position in ["P", "C", "1B", "2B", "3B", "SS"]: if position == "C": table = CATCHER_DEFENSE_TABLE else: table = INFIELD_DEFENSE_TABLE else: # LF, CF, RF table = OUTFIELD_DEFENSE_TABLE # Lookup (0-indexed) row = d20_roll - 1 col = defense_range - 1 result = table[row][col] logger.debug(f"Defense table[{d20_roll}][{defense_range}] = {result}") return result def _apply_hash_conversion( self, result: str, position: str, adjusted_range: int, base_range: int, state: GameState, batter_hand: str, ) -> str: """ Convert G2# or G3# to SI2 if conditions are met. Conversion happens if: a) Infielder is playing in (range was adjusted), OR b) Infielder is responsible for holding a runner Args: result: 'G2#' or 'G3#' position: Position code adjusted_range: Range after playing-in adjustment base_range: Original range state: Current game state batter_hand: 'L' or 'R' Returns: 'SI2' if converted, otherwise original result without # ('G2' or 'G3') """ # Check condition (a): playing in if adjusted_range > base_range: logger.debug(f"{result} → SI2 (defender playing in)") return "SI2" # Check condition (b): holding runner runner_bases = [base for base, _ in state.get_all_runners()] holding_positions = get_fielders_holding_runners(runner_bases, batter_hand) if position in holding_positions: logger.debug(f"{result} → SI2 (defender holding runner)") return "SI2" # No conversion - remove # suffix base_result = result.replace("#", "") logger.debug(f"{result} → {base_result} (no conversion)") return base_result def _lookup_error_chart( self, position: str, error_rating: int, d6_roll: int ) -> str: """ Look up error result from error chart. Args: position: Position code error_rating: Defender's error rating (0-25 for outfield, varies for infield) d6_roll: Sum of 3d6 (3-18) Returns: Error result: 'NO', 'E1', 'E2', 'E3', or 'RP' """ error_chart = get_error_chart_for_position(position) # Get row for this error rating if error_rating not in error_chart: logger.warning(f"Error rating {error_rating} not in chart, using 0") error_rating = 0 rating_row = error_chart[error_rating] # Check each error type in priority order for error_type in ["RP", "E3", "E2", "E1"]: if d6_roll in rating_row[error_type]: logger.debug(f"Error chart: 3d6={d6_roll} → {error_type}") return error_type # No error logger.debug(f"Error chart: 3d6={d6_roll} → NO") return "NO" def _get_x_check_advancement( self, converted_result: str, error_result: str, state: "GameState", defender_in: bool, hit_location: str, defensive_decision: "DefensiveDecision", ) -> "AdvancementResult": """ Get runner advancement for X-Check result. Calls appropriate x_check function based on result type: - G1, G2, G3: Groundball advancement (uses x_check tables) - F1, F2, F3: Flyball advancement (uses x_check tables) - SI1, SI2, DO2, DO3, TR3: Hit advancement (uses existing methods + error bonuses) - FO, PO: Out advancement (error overrides out, so just error advancement) Args: converted_result: Result after SPD test and hash conversion error_result: Error type (NO, E1, E2, E3, RP) state: Current game state (for runner positions) defender_in: Whether defender was playing in hit_location: Position where ball was hit (fielder's position) defensive_decision: Defensive positioning decision Returns: AdvancementResult with runner movements Raises: ValueError: If result type is not recognized """ from app.core.runner_advancement import ( x_check_f1, x_check_f2, x_check_f3, x_check_g1, x_check_g2, x_check_g3, ) on_base_code = state.current_on_base_code # Groundball results if converted_result == "G1": return x_check_g1( on_base_code, defender_in, error_result, state, hit_location, defensive_decision, ) if converted_result == "G2": return x_check_g2( on_base_code, defender_in, error_result, state, hit_location, defensive_decision, ) if converted_result == "G3": return x_check_g3( on_base_code, defender_in, error_result, state, hit_location, defensive_decision, ) # Flyball results if converted_result == "F1": return x_check_f1(on_base_code, error_result, state, hit_location) if converted_result == "F2": return x_check_f2(on_base_code, error_result, state, hit_location) if converted_result == "F3": return x_check_f3(on_base_code, error_result, state, hit_location) # Hit results - use existing advancement methods + error bonuses if converted_result in ["SI1", "SI2", "DO2", "DO3", "TR3"]: return self._get_hit_advancement_with_error( converted_result, error_result, state ) # Out results - error overrides out, so just error advancement if converted_result in ["FO", "PO"]: return self._get_out_advancement_with_error(error_result, state) raise ValueError(f"Unknown X-Check result type: {converted_result}") def _get_hit_advancement_with_error( self, hit_type: str, error_result: str, state: "GameState" ) -> "AdvancementResult": """ Get runner advancement for X-Check hit with error. Uses existing advancement methods and adds error bonuses: - NO: No bonus - E1: +1 base - E2: +2 bases - E3: +3 bases - RP: Treat as E3 Args: hit_type: SI1, SI2, DO2, DO3, or TR3 error_result: Error type state: Current game state (for runner positions) Returns: AdvancementResult with movements """ from app.core.runner_advancement import AdvancementResult, RunnerMovement # Get base advancement (without error) if hit_type == "SI1": base_advances = self._advance_on_single_1(state) batter_reaches = 1 elif hit_type == "SI2": base_advances = self._advance_on_single_2(state) batter_reaches = 1 elif hit_type == "DO2": base_advances = self._advance_on_double_2(state) batter_reaches = 2 elif hit_type == "DO3": base_advances = self._advance_on_double_3(state) batter_reaches = ( 2 # DO = double (batter to 2B), 3 = runners advance 3 bases ) elif hit_type == "TR3": base_advances = self._advance_on_triple(state) batter_reaches = 3 else: raise ValueError(f"Unknown hit type: {hit_type}") # Apply error bonus error_bonus = {"NO": 0, "E1": 1, "E2": 2, "E3": 3, "RP": 3}.get(error_result, 0) movements = [] runs_scored = 0 # Add batter movement (with error bonus) batter_final = min(batter_reaches + error_bonus, 4) if batter_final == 4: runs_scored += 1 movements.append( RunnerMovement( lineup_id=0, # Placeholder - will be set by game engine from_base=0, to_base=batter_final, is_out=False, ) ) # Add runner movements (with error bonus) for from_base, to_base in base_advances: final_base = min(to_base + error_bonus, 4) if final_base == 4: runs_scored += 1 movements.append( RunnerMovement( lineup_id=0, # Placeholder from_base=from_base, to_base=final_base, is_out=False, ) ) return AdvancementResult( movements=movements, outs_recorded=0, runs_scored=runs_scored, result_type=None, description=f"X-Check {hit_type} + {error_result}", ) def _get_out_advancement_with_error( self, error_result: str, state: "GameState" ) -> "AdvancementResult": """ Get runner advancement for X-Check out with error. When an out has an error, the out is negated and it becomes an error play. Runners advance based on error severity: - E1: All advance 1 base - E2: All advance 2 bases - E3: All advance 3 bases - RP: All advance 3 bases Args: error_result: Error type (should not be 'NO' for outs) state: Current game state (for runner positions) Returns: AdvancementResult with movements """ from app.core.runner_advancement import AdvancementResult, RunnerMovement if error_result == "NO": # No error on out - just record out return AdvancementResult( movements=[ RunnerMovement(lineup_id=0, from_base=0, to_base=0, is_out=True) ], outs_recorded=1, runs_scored=0, result_type=None, description="X-Check out (no error)", ) # Error prevents out - batter and runners advance error_bonus = {"E1": 1, "E2": 2, "E3": 3, "RP": 3}[error_result] movements = [] runs_scored = 0 # Batter reaches base based on error severity batter_final = min(error_bonus, 4) if batter_final == 4: runs_scored += 1 movements.append( RunnerMovement(lineup_id=0, from_base=0, to_base=batter_final, is_out=False) ) # All runners advance by error bonus for base, runner in state.get_all_runners(): final_base = min(base + error_bonus, 4) if final_base == 4: runs_scored += 1 movements.append( RunnerMovement( lineup_id=runner.lineup_id, from_base=base, to_base=final_base, is_out=False ) ) return AdvancementResult( movements=movements, outs_recorded=0, runs_scored=runs_scored, result_type=None, description=f"X-Check out + {error_result} (error overrides out)", ) def _advance_on_triple(self, state: "GameState") -> list[RunnerAdvancementData]: """Calculate runner advancement on triple (all runners score).""" return [ RunnerAdvancementData(from_base=base, to_base=4, lineup_id=runner.lineup_id) for base, runner in state.get_all_runners() ] def _determine_final_x_check_outcome( self, converted_result: str, error_result: str ) -> tuple[PlayOutcome, str]: """ Determine final outcome and hit_type from converted result + error. Logic: - If Out + Error: outcome = ERROR, hit_type = '{result}_plus_error_{n}' - If Hit + Error: outcome = hit type, hit_type = '{result}_plus_error_{n}' - If No Error: outcome = base outcome, hit_type = '{result}_no_error' - If Rare Play: hit_type includes '_rare_play' Args: converted_result: Result after SPD/# conversions (G1, F2, SI2, etc.) error_result: 'NO', 'E1', 'E2', 'E3', 'RP' Returns: Tuple of (final_outcome, hit_type) """ # Map result codes to PlayOutcome result_map = { "SI1": PlayOutcome.SINGLE_1, "SI2": PlayOutcome.SINGLE_2, "DO2": PlayOutcome.DOUBLE_2, "DO3": PlayOutcome.DOUBLE_3, "TR3": PlayOutcome.TRIPLE, "G1": PlayOutcome.GROUNDBALL_B, "G2": PlayOutcome.GROUNDBALL_B, "G3": PlayOutcome.GROUNDBALL_C, "F1": PlayOutcome.FLYOUT_A, "F2": PlayOutcome.FLYOUT_B, "F3": PlayOutcome.FLYOUT_C, "FO": PlayOutcome.LINEOUT, "PO": PlayOutcome.POPOUT, } base_outcome = result_map.get(converted_result) if not base_outcome: raise ValueError(f"Unknown X-Check result: {converted_result}") # Build hit_type string result_lower = converted_result.lower() if error_result == "NO": # No error hit_type = f"{result_lower}_no_error" final_outcome = base_outcome elif error_result == "RP": # Rare play hit_type = f"{result_lower}_rare_play" # Rare plays are treated like errors for stats final_outcome = PlayOutcome.ERROR else: # E1, E2, E3 error_num = error_result[1] # Extract '1', '2', or '3' hit_type = f"{result_lower}_plus_error_{error_num}" # If base was an out, error overrides to ERROR outcome if base_outcome.is_out(): final_outcome = PlayOutcome.ERROR else: # Hit + error: keep hit outcome final_outcome = base_outcome logger.info( f"Final: {converted_result} + {error_result} → {final_outcome.value} ({hit_type})" ) return final_outcome, hit_type