# Phase 3D: X-Check Runner Advancement Tables **Status**: Not Started **Estimated Effort**: 6-8 hours (table-heavy) **Dependencies**: Phase 3C (Resolution Logic) ## Overview Implement complete runner advancement tables for all X-Check result types. Each combination of (base_result, error_result, on_base_code, defender_in) has specific advancement rules. This phase involves: - Groundball advancement (G1, G2, G3) with defender_in and error variations - Flyball advancement (F1, F2, F3) with error variations - Hit advancement (SI1, SI2, DO2, DO3, TR3) with error bonuses - Out advancement (FO, PO) with error overrides ## Tasks ### 1. Create X-Check Advancement Tables Module **File**: `backend/app/core/x_check_advancement_tables.py` (NEW FILE) ```python """ X-Check runner advancement tables. Each X-Check result type has specific advancement rules based on: - on_base_code: Current runner configuration - defender_in: Whether defender was playing in - error_result: NO, E1, E2, E3, RP Author: Claude Date: 2025-11-01 """ import logging from typing import List, Dict, Tuple from app.models.game_models import RunnerMovement, AdvancementResult from app.core.runner_advancement import GroundballResultType logger = logging.getLogger(f'{__name__}') # ============================================================================ # GROUNDBALL ADVANCEMENT TABLES # ============================================================================ # Structure: {on_base_code: {(defender_in, error_result): GroundballResultType}} # # These tables cross-reference: # - on_base_code (0-7) # - defender_in (True/False) # - error_result ('NO', 'E1', 'E2', 'E3', 'RP') # # Result is a GroundballResultType which feeds into existing groundball_X() functions # TODO: Fill these tables with actual data from rulebook # For now, placeholders with basic logic G1_ADVANCEMENT_TABLE: Dict[int, Dict[Tuple[bool, str], GroundballResultType]] = { # on_base_code 0 (bases empty) 0: { (False, 'NO'): GroundballResultType.GROUNDOUT_ROUTINE, (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, (False, 'RP'): GroundballResultType.RARE_PLAY, (True, 'NO'): GroundballResultType.GROUNDOUT_ROUTINE, (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, (True, 'RP'): GroundballResultType.RARE_PLAY, }, # on_base_code 1 (R1 only) 1: { (False, 'NO'): GroundballResultType.GROUNDOUT_DP_ATTEMPT, (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, (False, 'RP'): GroundballResultType.RARE_PLAY, (True, 'NO'): GroundballResultType.FORCE_AT_THIRD, # Infield in (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, (True, 'RP'): GroundballResultType.RARE_PLAY, }, # TODO: Add codes 2-7 } G2_ADVANCEMENT_TABLE: Dict[int, Dict[Tuple[bool, str], GroundballResultType]] = { # Similar structure to G1 # TODO: Fill with actual data } G3_ADVANCEMENT_TABLE: Dict[int, Dict[Tuple[bool, str], GroundballResultType]] = { # Similar structure to G1 # TODO: Fill with actual data } def get_groundball_advancement( result_type: str, # 'G1', 'G2', or 'G3' on_base_code: int, defender_in: bool, error_result: str ) -> GroundballResultType: """ Get GroundballResultType for X-Check groundball. Args: result_type: 'G1', 'G2', or 'G3' on_base_code: Current base situation (0-7) defender_in: Is defender playing in? error_result: 'NO', 'E1', 'E2', 'E3', 'RP' Returns: GroundballResultType to pass to existing groundball functions Raises: ValueError: If parameters invalid """ # Select table tables = { 'G1': G1_ADVANCEMENT_TABLE, 'G2': G2_ADVANCEMENT_TABLE, 'G3': G3_ADVANCEMENT_TABLE, } if result_type not in tables: raise ValueError(f"Unknown groundball type: {result_type}") table = tables[result_type] # Lookup key = (defender_in, error_result) if on_base_code not in table: raise ValueError(f"on_base_code {on_base_code} not in {result_type} table") if key not in table[on_base_code]: raise ValueError( f"Key {key} not in {result_type} table for on_base_code {on_base_code}" ) return table[on_base_code][key] # ============================================================================ # FLYBALL ADVANCEMENT TABLES # ============================================================================ # Flyballs are simpler - only cross-reference on_base_code and error_result # (No defender_in parameter) # Structure: {on_base_code: {error_result: List[RunnerMovement]}} F1_ADVANCEMENT_TABLE: Dict[int, Dict[str, List[RunnerMovement]]] = { # on_base_code 0 (bases empty) 0: { 'NO': [], # Out, no runners 'E1': [RunnerMovement(from_base=0, to_base=1, is_out=False)], # Batter to 1B 'E2': [RunnerMovement(from_base=0, to_base=2, is_out=False)], # Batter to 2B 'E3': [RunnerMovement(from_base=0, to_base=3, is_out=False)], # Batter to 3B 'RP': [], # Rare play - TODO: specific advancement }, # on_base_code 1 (R1 only) 1: { 'NO': [ # F1 = deep fly, R1 advances RunnerMovement(from_base=1, to_base=2, is_out=False) ], 'E1': [ RunnerMovement(from_base=1, to_base=2, is_out=False), RunnerMovement(from_base=0, to_base=1, is_out=False), ], 'E2': [ RunnerMovement(from_base=1, to_base=3, is_out=False), RunnerMovement(from_base=0, to_base=2, is_out=False), ], 'E3': [ RunnerMovement(from_base=1, to_base=4, is_out=False), # R1 scores RunnerMovement(from_base=0, to_base=3, is_out=False), ], 'RP': [], # TODO }, # TODO: Add codes 2-7 } F2_ADVANCEMENT_TABLE: Dict[int, Dict[str, List[RunnerMovement]]] = { # Similar structure # TODO: Fill with actual data } F3_ADVANCEMENT_TABLE: Dict[int, Dict[str, List[RunnerMovement]]] = { # Similar structure # TODO: Fill with actual data } def get_flyball_advancement( result_type: str, # 'F1', 'F2', or 'F3' on_base_code: int, error_result: str ) -> List[RunnerMovement]: """ Get runner movements for X-Check flyball. Args: result_type: 'F1', 'F2', or 'F3' on_base_code: Current base situation (0-7) error_result: 'NO', 'E1', 'E2', 'E3', 'RP' Returns: List of RunnerMovements Raises: ValueError: If parameters invalid """ # Select table tables = { 'F1': F1_ADVANCEMENT_TABLE, 'F2': F2_ADVANCEMENT_TABLE, 'F3': F3_ADVANCEMENT_TABLE, } if result_type not in tables: raise ValueError(f"Unknown flyball type: {result_type}") table = tables[result_type] # Lookup if on_base_code not in table: raise ValueError(f"on_base_code {on_base_code} not in {result_type} table") if error_result not in table[on_base_code]: raise ValueError( f"error_result {error_result} not in {result_type} table for on_base_code {on_base_code}" ) return table[on_base_code][error_result] # ============================================================================ # HIT ADVANCEMENT (SI1, SI2, DO2, DO3, TR3) # ============================================================================ # Hits with errors: base advancement + error bonus def get_hit_advancement( result_type: str, # 'SI1', 'SI2', 'DO2', 'DO3', 'TR3' on_base_code: int, error_result: str ) -> List[RunnerMovement]: """ Get runner movements for X-Check hit + error. For hits, we combine: - Base hit advancement (use existing single/double advancement) - Error bonus (all runners advance N additional bases) Args: result_type: Hit type on_base_code: Current base situation error_result: 'NO', 'E1', 'E2', 'E3', 'RP' Returns: List of RunnerMovements TODO: Implement proper hit advancement with error bonuses For now, placeholder """ movements = [] # Base advancement for hit type base_advances = { 'SI1': 1, 'SI2': 1, 'DO2': 2, 'DO3': 2, 'TR3': 3, } batter_advances = base_advances.get(result_type, 1) # Error bonus error_bonus = { 'NO': 0, 'E1': 1, 'E2': 2, 'E3': 3, 'RP': 0, # Rare play handled separately } bonus = error_bonus.get(error_result, 0) # Batter advancement batter_final = min(batter_advances + bonus, 4) movements.append(RunnerMovement(from_base=0, to_base=batter_final, is_out=False)) # TODO: Advance existing runners based on hit type + error # This requires knowing current runner positions return movements # ============================================================================ # OUT ADVANCEMENT (FO, PO) # ============================================================================ def get_out_advancement( result_type: str, # 'FO' or 'PO' on_base_code: int, error_result: str ) -> List[RunnerMovement]: """ Get runner movements for X-Check out (foul out or popout). If error: all runners advance N bases (error overrides out) If no error: batter out, runners hold (or tag if deep enough) Args: result_type: 'FO' or 'PO' on_base_code: Current base situation error_result: 'NO', 'E1', 'E2', 'E3', 'RP' Returns: List of RunnerMovements """ if error_result == 'NO': # Simple out, no advancement return [] # Error on out - all runners advance error_advances = { 'E1': 1, 'E2': 2, 'E3': 3, 'RP': 0, # Rare play - TODO } advances = error_advances.get(error_result, 0) movements = [ RunnerMovement(from_base=0, to_base=advances, is_out=False) ] # TODO: Advance existing runners # Need to know which bases are occupied return movements ``` ### 2. Update Runner Advancement Functions **File**: `backend/app/core/runner_advancement.py` **Replace placeholder functions** with full implementations: ```python from app.core.x_check_advancement_tables import ( get_groundball_advancement, get_flyball_advancement, get_hit_advancement, get_out_advancement, ) # ============================================================================ # X-CHECK RUNNER ADVANCEMENT # ============================================================================ def x_check_g1( on_base_code: int, defender_in: bool, error_result: str ) -> AdvancementResult: """ Runner advancement for X-Check G1 result. Uses G1 advancement table to get GroundballResultType, then calls appropriate groundball_X() function. Args: on_base_code: Current base situation code defender_in: Is the defender playing in? error_result: 'NO', 'E1', 'E2', 'E3', 'RP' Returns: AdvancementResult with runner movements """ gb_type = get_groundball_advancement('G1', on_base_code, defender_in, error_result) # Map GroundballResultType to existing function # These functions already exist: groundball_1 through groundball_13 gb_func_map = { GroundballResultType.GROUNDOUT_ROUTINE: groundball_1, GroundballResultType.GROUNDOUT_DP_ATTEMPT: groundball_2, GroundballResultType.FORCE_AT_THIRD: groundball_3, # ... add full mapping based on existing GroundballResultType enum } if gb_type in gb_func_map: return gb_func_map[gb_type](on_base_code) # Fallback logger.warning(f"Unknown GroundballResultType: {gb_type}, using groundball_1") return groundball_1(on_base_code) def x_check_g2(on_base_code: int, defender_in: bool, error_result: str) -> AdvancementResult: """Runner advancement for X-Check G2 result.""" gb_type = get_groundball_advancement('G2', on_base_code, defender_in, error_result) # Similar logic to x_check_g1 # TODO: Implement full mapping return AdvancementResult(movements=[], requires_decision=False) def x_check_g3(on_base_code: int, defender_in: bool, error_result: str) -> AdvancementResult: """Runner advancement for X-Check G3 result.""" gb_type = get_groundball_advancement('G3', on_base_code, defender_in, error_result) # Similar logic to x_check_g1 # TODO: Implement full mapping return AdvancementResult(movements=[], requires_decision=False) def x_check_f1(on_base_code: int, error_result: str) -> AdvancementResult: """Runner advancement for X-Check F1 result.""" movements = get_flyball_advancement('F1', on_base_code, error_result) return AdvancementResult(movements=movements, requires_decision=False) def x_check_f2(on_base_code: int, error_result: str) -> AdvancementResult: """Runner advancement for X-Check F2 result.""" movements = get_flyball_advancement('F2', on_base_code, error_result) return AdvancementResult(movements=movements, requires_decision=False) def x_check_f3(on_base_code: int, error_result: str) -> AdvancementResult: """Runner advancement for X-Check F3 result.""" movements = get_flyball_advancement('F3', on_base_code, error_result) return AdvancementResult(movements=movements, requires_decision=False) def x_check_si1(on_base_code: int, error_result: str) -> AdvancementResult: """Runner advancement for X-Check SI1 + error.""" movements = get_hit_advancement('SI1', on_base_code, error_result) return AdvancementResult(movements=movements, requires_decision=False) def x_check_si2(on_base_code: int, error_result: str) -> AdvancementResult: """Runner advancement for X-Check SI2 + error.""" movements = get_hit_advancement('SI2', on_base_code, error_result) return AdvancementResult(movements=movements, requires_decision=False) def x_check_do2(on_base_code: int, error_result: str) -> AdvancementResult: """Runner advancement for X-Check DO2 + error.""" movements = get_hit_advancement('DO2', on_base_code, error_result) return AdvancementResult(movements=movements, requires_decision=False) def x_check_do3(on_base_code: int, error_result: str) -> AdvancementResult: """Runner advancement for X-Check DO3 + error.""" movements = get_hit_advancement('DO3', on_base_code, error_result) return AdvancementResult(movements=movements, requires_decision=False) def x_check_tr3(on_base_code: int, error_result: str) -> AdvancementResult: """Runner advancement for X-Check TR3 + error.""" movements = get_hit_advancement('TR3', on_base_code, error_result) return AdvancementResult(movements=movements, requires_decision=False) def x_check_fo(on_base_code: int, error_result: str) -> AdvancementResult: """Runner advancement for X-Check FO (foul out).""" movements = get_out_advancement('FO', on_base_code, error_result) outs = 0 if error_result != 'NO' else 1 return AdvancementResult(movements=movements, requires_decision=False) def x_check_po(on_base_code: int, error_result: str) -> AdvancementResult: """Runner advancement for X-Check PO (popout).""" movements = get_out_advancement('PO', on_base_code, error_result) outs = 0 if error_result != 'NO' else 1 return AdvancementResult(movements=movements, requires_decision=False) ``` ### 3. Update PlayResolver to Call Correct Functions **File**: `backend/app/core/play_resolver.py` **Update _get_x_check_advancement** to handle all result types: ```python 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 """ from app.core.runner_advancement import ( x_check_g1, x_check_g2, x_check_g3, x_check_f1, x_check_f2, x_check_f3, x_check_si1, x_check_si2, x_check_do2, x_check_do3, x_check_tr3, x_check_fo, x_check_po, ) # Map result to function advancement_funcs = { # Groundballs (need defender_in) 'G1': lambda: x_check_g1(on_base_code, defender_in, error_result), 'G2': lambda: x_check_g2(on_base_code, defender_in, error_result), 'G3': lambda: x_check_g3(on_base_code, defender_in, error_result), # Flyballs (no defender_in) 'F1': lambda: x_check_f1(on_base_code, error_result), 'F2': lambda: x_check_f2(on_base_code, error_result), 'F3': lambda: x_check_f3(on_base_code, error_result), # Hits 'SI1': lambda: x_check_si1(on_base_code, error_result), 'SI2': lambda: x_check_si2(on_base_code, error_result), 'DO2': lambda: x_check_do2(on_base_code, error_result), 'DO3': lambda: x_check_do3(on_base_code, error_result), 'TR3': lambda: x_check_tr3(on_base_code, error_result), # Outs 'FO': lambda: x_check_fo(on_base_code, error_result), 'PO': lambda: x_check_po(on_base_code, error_result), } if converted_result in advancement_funcs: return advancement_funcs[converted_result]() # Fallback logger.warning(f"Unknown X-Check result: {converted_result}, no advancement") return AdvancementResult(movements=[], requires_decision=False) ``` ## Testing Requirements 1. **Unit Tests**: `tests/core/test_x_check_advancement_tables.py` - Test get_groundball_advancement() for all combinations - Test get_flyball_advancement() for all combinations - Test get_hit_advancement() with errors - Test get_out_advancement() with errors 2. **Integration Tests**: `tests/integration/test_x_check_advancement.py` - Test complete advancement for each result type - Test error bonuses applied correctly - Test defender_in affects groundball results ## Acceptance Criteria - [ ] x_check_advancement_tables.py created - [ ] All groundball tables complete (G1, G2, G3) - [ ] All flyball tables complete (F1, F2, F3) - [ ] Hit advancement with errors working - [ ] Out advancement with errors working - [ ] All x_check_* functions implemented in runner_advancement.py - [ ] PlayResolver._get_x_check_advancement() updated - [ ] All unit tests pass - [ ] All integration tests pass ## Notes - This phase requires rulebook data for all advancement tables - Tables marked TODO need actual values filled in - GroundballResultType enum may need new values for X-Check specific results - Error bonuses on hits need careful testing (batter advances + runners advance) - Rare Play (RP) advancement needs special handling per result type ## Next Phase After completion, proceed to **Phase 3E: WebSocket Events & UI Integration**