# Phase 3C: X-Check Resolution Logic in PlayResolver **Status**: Not Started **Estimated Effort**: 4-5 hours **Dependencies**: Phase 3A (Data Models), Phase 3B (Config Tables) ## Overview Implement the core X-Check resolution logic in PlayResolver. This includes: - Dice rolling (1d20 + 3d6) - Defense table lookups - SPD test resolution - G2#/G3# conversion logic - Error chart lookups - Final outcome determination ## Tasks ### 1. Add X-Check Resolution to PlayResolver **File**: `backend/app/core/play_resolver.py` **Add import** at top: ```python from app.models.game_models import XCheckResult from app.config.common_x_check_tables import ( INFIELD_DEFENSE_TABLE, OUTFIELD_DEFENSE_TABLE, CATCHER_DEFENSE_TABLE, get_error_chart_for_position, get_fielders_holding_runners, ) ``` **Add to resolve_play method** (in the long conditional): ```python def resolve_play( self, outcome: PlayOutcome, state: GameState, batter: BasePlayer, pitcher: BasePlayer, hit_location: Optional[str] = None, # ... other params ) -> PlayResult: """Resolve a play outcome into game state changes.""" # ... existing code ... elif outcome == PlayOutcome.X_CHECK: # X-Check requires position in hit_location if not hit_location: raise ValueError("X-Check outcome requires hit_location (position)") return self._resolve_x_check( position=hit_location, state=state, batter=batter, pitcher=pitcher, ) # ... rest of conditionals ... ``` **Add _resolve_x_check method**: ```python def _resolve_x_check( self, position: str, state: GameState, batter: BasePlayer, pitcher: BasePlayer, ) -> PlayResult: """ Resolve X-Check play with defense range and error tables. Process: 1. Get defender and their ratings 2. Roll 1d20 + 3d6 3. Adjust range if playing in 4. Look up base result from defense table 5. Apply SPD test if needed 6. Apply G2#/G3# conversion if applicable 7. Look up error result from error chart 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 batter: Batting player pitcher: Pitching player Returns: PlayResult with x_check_details populated Raises: ValueError: If defender has no position rating """ logger.info(f"Resolving X-Check to {position}") # Step 1: Get defender defender = self._get_defender_at_position(state, position) if not defender.active_position_rating: raise ValueError( f"Defender at {position} ({defender.name}) has no position rating loaded" ) # Step 2: Roll dice d20_roll = self.dice.roll_d20() d6_roll = self.dice.roll_3d6() # Sum of 3d6 logger.debug(f"X-Check rolls: d20={d20_roll}, 3d6={d6_roll}") # Step 3: Adjust range if playing in base_range = defender.active_position_rating.range adjusted_range = self._adjust_range_for_defensive_position( base_range=base_range, position=position, state=state ) # Step 4: Look up base result 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 spd_test_roll = None spd_test_target = None spd_test_passed = None if base_result == 'SPD': converted_result, spd_test_roll, spd_test_target, spd_test_passed = \ self._resolve_spd_test(batter) logger.debug( f"SPD test: roll={spd_test_roll}, target={spd_test_target}, " f"passed={spd_test_passed}, result={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=base_range, state=state, batter=batter ) # Step 7: Look up error result error_result = self._lookup_error_chart( position=position, error_rating=defender.active_position_rating.error, 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.active_position_rating.error, 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 # Check if defender was playing in for advancement purposes defender_in = (adjusted_range > base_range) advancement = self._get_x_check_advancement( converted_result=converted_result, error_result=error_result, on_base_code=state.get_on_base_code(), defender_in=defender_in ) # Step 11: Create PlayResult return PlayResult( outcome=final_outcome, advancement=advancement, x_check_details=x_check_details, outs_recorded=1 if final_outcome.is_out() and error_result == 'NO' else 0, ) ``` ### 2. Add Helper Methods **Add these methods to PlayResolver class**: ```python def _get_defender_at_position( self, state: GameState, position: str ) -> BasePlayer: """ Get defender currently playing at position. Args: state: Current game state position: Position code (SS, LF, etc.) Returns: BasePlayer at that position Raises: ValueError: If no defender at position """ # Get defensive team's lineup defensive_lineup = ( state.away_lineup if state.is_bottom_inning else state.home_lineup ) # Find player at position for player in defensive_lineup.get_defensive_positions(): if player.current_position == position: return player raise ValueError(f"No defender found at position {position}") def _adjust_range_for_defensive_position( self, base_range: int, position: str, state: GameState ) -> 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 state: Current game state Returns: Adjusted range (1-5) """ # Check if position is playing in based on defensive decision decision = state.current_defensive_decision playing_in = False if decision.corners_in and position in ['1B', '3B', 'P', 'C']: playing_in = True elif decision.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 _resolve_spd_test( self, batter: BasePlayer ) -> Tuple[str, int, int, bool]: """ Resolve SPD (speed test) result. Roll 1d20 and compare to batter's speed rating. - If roll <= speed: SI1 - If roll > speed: G3 Args: batter: Batting player Returns: Tuple of (result, roll, target, passed) Raises: ValueError: If batter has no speed rating """ # Get speed rating speed = self._get_batter_speed(batter) # Roll d20 roll = self.dice.roll_d20() # Compare passed = (roll <= speed) result = 'SI1' if passed else 'G3' logger.info( f"SPD test: {batter.name} speed={speed}, roll={roll}, " f"{'PASSED' if passed else 'FAILED'} → {result}" ) return result, roll, speed, passed def _get_batter_speed(self, batter: BasePlayer) -> int: """ Get batter's speed rating for SPD test. Args: batter: Batting player Returns: Speed value (0-20) Raises: ValueError: If speed rating not available """ # PD players: speed from batting_card.running if hasattr(batter, 'batting_card') and batter.batting_card: return batter.batting_card.running # SBA players: TODO - need to add speed field or get from manual input raise ValueError(f"No speed rating available for {batter.name}") def _apply_hash_conversion( self, result: str, position: str, adjusted_range: int, base_range: int, state: GameState, batter: BasePlayer ) -> 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: Batting player 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 = state.get_runner_bases() batter_hand = self._get_batter_handedness(batter) 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 _get_batter_handedness(self, batter: BasePlayer) -> str: """ Get batter handedness (L or R). Args: batter: Batting player Returns: 'L' or 'R' """ # PD players if hasattr(batter, 'batting_card') and batter.batting_card: return batter.batting_card.hand # SBA players - TODO: add handedness field return 'R' # Default to right-handed 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) 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 for error_type in ['RP', 'E3', 'E2', 'E1']: # Check in priority order 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 _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, # Map to existing groundball 'G2': PlayOutcome.GROUNDBALL_B, 'G3': PlayOutcome.GROUNDBALL_C, 'F1': PlayOutcome.FLYOUT_A, # Map to existing flyout 'F2': PlayOutcome.FLYOUT_B, 'F3': PlayOutcome.FLYOUT_C, 'FO': PlayOutcome.LINEOUT, # Foul out '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 def _get_x_check_advancement( self, converted_result: str, error_result: str, on_base_code: int, defender_in: bool ) -> AdvancementResult: """ Get runner advancement for X-Check result. Calls appropriate advancement function based on result type. Args: converted_result: Base result after conversions (G1, F2, SI2, etc.) error_result: 'NO', 'E1', 'E2', 'E3', 'RP' on_base_code: Current base situation defender_in: Was defender playing in? Returns: AdvancementResult Note: Uses placeholder functions from Phase 3B. Full implementation in Phase 3D. """ from app.core.runner_advancement import ( x_check_g1, x_check_g2, x_check_g3, x_check_f1, x_check_f2, x_check_f3, ) # Map to advancement function advancement_funcs = { 'G1': x_check_g1, 'G2': x_check_g2, 'G3': x_check_g3, 'F1': x_check_f1, 'F2': x_check_f2, 'F3': x_check_f3, } if converted_result in advancement_funcs: # Groundball or flyball - needs special tables func = advancement_funcs[converted_result] if converted_result.startswith('G'): return func(on_base_code, defender_in, error_result) else: # Flyball return func(on_base_code, error_result) # For hits (SI1, SI2, DO2, DO3, TR3), use standard advancement # with error adding extra bases # TODO: May need custom advancement for hits + errors return AdvancementResult(movements=[], requires_decision=False) ``` ## Testing Requirements 1. **Unit Tests**: `tests/core/test_x_check_resolution.py` - Test _lookup_defense_table() for all position types - Test _resolve_spd_test() with various speeds - Test _apply_hash_conversion() with all conditions - Test _lookup_error_chart() for known values - Test _determine_final_x_check_outcome() for all error types - Test _adjust_range_for_defensive_position() 2. **Integration Tests**: `tests/integration/test_x_check_flow.py` - Test complete X-Check resolution (infield) - Test complete X-Check resolution (outfield) - Test complete X-Check resolution (catcher with SPD) - Test G2# conversion scenarios - Test error overriding outs ## Acceptance Criteria - [ ] _resolve_x_check() method implemented - [ ] All helper methods implemented - [ ] Defense table lookup working for all positions - [ ] SPD test resolution working - [ ] G2#/G3# conversion logic working - [ ] Error chart lookup working - [ ] Final outcome determination working - [ ] Integration with PlayResolver.resolve_play() - [ ] All unit tests pass - [ ] All integration tests pass - [ ] Logging at debug/info levels throughout ## Notes - SBA players need speed rating - may require manual input or model update - Advancement functions are placeholders - will be filled in Phase 3D - Error priority order: RP > E3 > E2 > E1 > NO - Playing in increases range by 1 (max 5) AND triggers # conversion - Holding runner triggers # conversion but doesn't change range ## Next Phase After completion, proceed to **Phase 3D: Runner Advancement Tables**