diff --git a/backend/app/config/result_charts.py b/backend/app/config/result_charts.py index 462f64e..b1920a5 100644 --- a/backend/app/config/result_charts.py +++ b/backend/app/config/result_charts.py @@ -55,10 +55,11 @@ class PlayOutcome(str, Enum): GROUNDBALL_B = "groundball_b" # Standard groundout GROUNDBALL_C = "groundball_c" # Standard groundout - # Flyouts - 3 variants for different trajectories/depths - FLYOUT_A = "flyout_a" # Flyout variant A - FLYOUT_B = "flyout_b" # Flyout variant B - FLYOUT_C = "flyout_c" # Flyout variant C + # Flyouts - 4 variants for different trajectories/depths + FLYOUT_A = "flyout_a" # Deep - all runners advance + FLYOUT_B = "flyout_b" # Medium - R3 scores, R2 DECIDE, R1 holds + FLYOUT_BQ = "flyout_bq" # Medium-shallow (fly(b)?) - R3 DECIDE, R2 holds, R1 holds + FLYOUT_C = "flyout_c" # Shallow - all runners hold LINEOUT = "lineout" POPOUT = "popout" @@ -116,7 +117,7 @@ class PlayOutcome(str, Enum): return self in { self.STRIKEOUT, self.GROUNDBALL_A, self.GROUNDBALL_B, self.GROUNDBALL_C, - self.FLYOUT_A, self.FLYOUT_B, self.FLYOUT_C, + self.FLYOUT_A, self.FLYOUT_B, self.FLYOUT_BQ, self.FLYOUT_C, self.LINEOUT, self.POPOUT, self.CAUGHT_STEALING, self.PICK_OFF, self.BP_FLYOUT, self.BP_LINEOUT @@ -192,6 +193,7 @@ class PlayOutcome(str, Enum): # Flyouts - location affects tag-up opportunities self.FLYOUT_A, self.FLYOUT_B, + self.FLYOUT_BQ, self.FLYOUT_C, } diff --git a/backend/app/core/CLAUDE.md b/backend/app/core/CLAUDE.md index b61295c..bf510de 100644 --- a/backend/app/core/CLAUDE.md +++ b/backend/app/core/CLAUDE.md @@ -271,15 +271,16 @@ class PlayResult: is_walk: bool ``` -**Groundball Integration**: +**Runner Advancement Integration**: - Delegates all groundball outcomes to RunnerAdvancement +- Delegates all flyball outcomes to RunnerAdvancement - Converts AdvancementResult to PlayResult format - Extracts batter movement from RunnerMovement list **Supported Outcomes**: - Strikeouts - Groundballs (A, B, C) → delegates to RunnerAdvancement -- Flyouts (A, B, C) +- Flyouts (A, B, BQ, C) → delegates to RunnerAdvancement - Lineouts - Walks - Singles (1, 2, uncapped) @@ -292,15 +293,15 @@ class PlayResult: ### 4. runner_advancement.py -**Purpose**: Implements complete groundball runner advancement system +**Purpose**: Implements complete runner advancement system for groundballs and flyballs **Key Classes**: -- `RunnerAdvancement`: Main logic handler -- `GroundballResultType`: Enum of 13 result types (matches rulebook) +- `RunnerAdvancement`: Main logic handler (handles both groundballs and flyballs) +- `GroundballResultType`: Enum of 13 result types (matches rulebook charts) - `RunnerMovement`: Single runner's movement - `AdvancementResult`: Complete result with all movements -**Result Types** (1-13): +**Groundball Result Types** (1-13): ```python 1: BATTER_OUT_RUNNERS_HOLD 2: DOUBLE_PLAY_AT_SECOND @@ -370,6 +371,57 @@ Infield Back (default): --- +**Flyball Types** (Direct Mapping): + +Flyballs use direct outcome-to-behavior mapping (no chart needed): + +| Outcome | Depth | R3 | R2 | R1 | Notes | +|---------|-------|----|----|-----|-------| +| **FLYOUT_A** | Deep | Advances (scores) | Advances to 3rd | Advances to 2nd | All runners tag up | +| **FLYOUT_B** | Medium | **Scores** | DECIDE (defaults hold) | Holds | Sac fly + DECIDE | +| **FLYOUT_BQ** | Medium-shallow | DECIDE (defaults hold) | Holds | Holds | fly(b)? from cards | +| **FLYOUT_C** | Shallow | Holds | Holds | Holds | Too shallow to tag | + +**Usage**: +```python +runner_advancement = RunnerAdvancement() + +# Flyball advancement (same interface as groundballs) +result = runner_advancement.advance_runners( + outcome=PlayOutcome.FLYOUT_B, + hit_location='RF', # LF, CF, or RF + state=state, + defensive_decision=defensive_decision +) + +# Result contains: +result.movements # List[RunnerMovement] +result.outs_recorded # Always 1 for flyouts +result.runs_scored # 0-3 depending on runners +result.result_type # None (flyballs don't use result types) +result.description # e.g., "Medium flyball to RF - R3 scores, R2 DECIDE (held), R1 holds" +``` + +**Key Differences from Groundballs**: +1. **No Chart Lookup**: Direct mapping from outcome to behavior +2. **No Result Type**: `result_type` is `None` for flyballs (groundballs use 1-13) +3. **DECIDE Mechanics**: + - FLYOUT_B: R2 may attempt to tag to 3rd (currently defaults to hold) + - FLYOUT_BQ: R3 may attempt to score (currently defaults to hold) + - TODO: Interactive DECIDE with probability calculations (arm strength, runner speed) +4. **No-op Movements**: Hold movements are recorded for state recovery (same as groundballs) + +**Special Cases**: +- **2 outs**: No runner advancement recorded (inning ends) +- **Empty bases**: Only batter movement (out at plate) +- **Hit location**: Used in description and future DECIDE probability calculations + +**Test Coverage**: +- 21 flyball tests in `tests/unit/core/test_flyball_advancement.py` +- Coverage: All 4 types, DECIDE scenarios, no-op movements, edge cases + +--- + ### 5. dice.py **Purpose**: Cryptographically secure dice rolling system @@ -1086,7 +1138,8 @@ pytest tests/unit/core/ -v # Specific module pytest tests/unit/core/test_game_engine.py -v pytest tests/unit/core/test_play_resolver.py -v -pytest tests/unit/core/test_runner_advancement.py -v +pytest tests/unit/core/test_runner_advancement.py -v # Groundball tests (30 tests) +pytest tests/unit/core/test_flyball_advancement.py -v # Flyball tests (21 tests) pytest tests/unit/core/test_state_manager.py -v # With coverage @@ -1270,7 +1323,7 @@ The `core` directory is the beating heart of the baseball simulation engine. It - `state_manager.py` - O(1) state lookups, lineup caching, recovery - `game_engine.py` - Orchestration, workflow, persistence - `play_resolver.py` - Outcome-first resolution (manual + auto) -- `runner_advancement.py` - Groundball advancement (13 result types) +- `runner_advancement.py` - Groundball (13 result types) & flyball (4 types) advancement - `dice.py` - Cryptographic dice rolling system - `validators.py` - Rule enforcement - `ai_opponent.py` - AI decision-making (stub) diff --git a/backend/app/core/play_resolver.py b/backend/app/core/play_resolver.py index a4b01f0..7676733 100644 --- a/backend/app/core/play_resolver.py +++ b/backend/app/core/play_resolver.py @@ -165,7 +165,7 @@ class PlayResolver: ab_roll = dice_system.roll_ab(league_id=state.league_id, game_id=state.game_id) # Generate outcome from ratings - outcome, hit_location = self.result_chart.get_outcome( + outcome, hit_location = self.result_chart.get_outcome( #type: ignore roll=ab_roll, state=state, batter=batter, @@ -261,40 +261,39 @@ class PlayResolver: ) # ==================== Flyouts ==================== - elif outcome == PlayOutcome.FLYOUT_A: - return PlayResult( + elif outcome in [PlayOutcome.FLYOUT_A, PlayOutcome.FLYOUT_B, PlayOutcome.FLYOUT_BQ, PlayOutcome.FLYOUT_C]: + # Delegate to RunnerAdvancement for all flyball outcomes + advancement_result = self.runner_advancement.advance_runners( outcome=outcome, - outs_recorded=1, - runs_scored=0, - batter_result=None, - runners_advanced=[], - description="Flyout to left field", - ab_roll=ab_roll, - is_out=True + hit_location=hit_location or 'CF', # Default to CF if location not specified + state=state, + defensive_decision=defensive_decision ) - elif outcome == PlayOutcome.FLYOUT_B: - return PlayResult( - outcome=outcome, - outs_recorded=1, - runs_scored=0, - batter_result=None, - runners_advanced=[], - description="Flyout to center field", - ab_roll=ab_roll, - is_out=True - ) + # Convert RunnerMovement list to tuple format for PlayResult + runners_advanced = [ + (movement.from_base, movement.to_base) + for movement in advancement_result.movements + if not movement.is_out and 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 - elif outcome == PlayOutcome.FLYOUT_C: return PlayResult( outcome=outcome, - outs_recorded=1, - runs_scored=0, - batter_result=None, - runners_advanced=[], - description="Flyout to right field", + 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, - is_out=True + hit_location=hit_location, + is_out=(advancement_result.outs_recorded > 0) ) # ==================== Lineout ==================== @@ -329,7 +328,7 @@ class PlayResolver: # ==================== Singles ==================== elif outcome == PlayOutcome.SINGLE_1: # Single with standard advancement - runners_advanced = self._advance_on_single(state) + runners_advanced = self._advance_on_single_1(state) runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4) return PlayResult( @@ -345,7 +344,7 @@ class PlayResolver: elif outcome == PlayOutcome.SINGLE_2: # Single with enhanced advancement (more aggressive) - runners_advanced = self._advance_on_single(state) + runners_advanced = self._advance_on_single_2(state) runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4) return PlayResult( @@ -362,7 +361,7 @@ class PlayResolver: elif outcome == PlayOutcome.SINGLE_UNCAPPED: # TODO Phase 3: Implement uncapped hit decision tree # For now, treat as SINGLE_1 - runners_advanced = self._advance_on_single(state) + runners_advanced = self._advance_on_single_1(state) runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4) return PlayResult( @@ -379,7 +378,7 @@ class PlayResolver: # ==================== Doubles ==================== elif outcome == PlayOutcome.DOUBLE_2: # Double to 2nd base - runners_advanced = self._advance_on_double(state) + runners_advanced = self._advance_on_double_2(state) runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4) return PlayResult( @@ -395,7 +394,7 @@ class PlayResolver: elif outcome == PlayOutcome.DOUBLE_3: # Double with extra advancement (batter to 3rd) - runners_advanced = self._advance_on_double(state) + runners_advanced = self._advance_on_double_3(state) runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4) return PlayResult( @@ -412,7 +411,7 @@ class PlayResolver: elif outcome == PlayOutcome.DOUBLE_UNCAPPED: # TODO Phase 3: Implement uncapped hit decision tree # For now, treat as DOUBLE_2 - runners_advanced = self._advance_on_double(state) + runners_advanced = self._advance_on_double_2(state) runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4) return PlayResult( @@ -508,7 +507,7 @@ class PlayResolver: return advances - def _advance_on_single(self, state: GameState) -> List[tuple[int, int]]: + def _advance_on_single_1(self, state: GameState) -> List[tuple[int, int]]: """Calculate runner advancement on single (simplified)""" advances = [] @@ -516,15 +515,39 @@ class PlayResolver: # Runner on third scores advances.append((3, 4)) if state.on_second: - # Runner on second scores (simplified - usually would) + advances.append((2, 3)) + if state.on_first: + advances.append((1, 2)) + + return advances + + def _advance_on_single_2(self, state: GameState) -> List[tuple[int, int]]: + """Calculate runner advancement on single (simplified)""" + advances = [] + + if state.on_third: + # Runner on third scores + advances.append((3, 4)) + if state.on_second: + # Runner on second scores advances.append((2, 4)) if state.on_first: - # Runner on first to third (simplified) + # Runner on first to third advances.append((1, 3)) return advances - def _advance_on_double(self, state: GameState) -> List[tuple[int, int]]: + def _advance_on_double_2(self, state: GameState) -> List[tuple[int, int]]: + """Calculate runner advancement on double""" + advances = [] + + # All runners score on double (simplified) + for base, _ in state.get_all_runners(): + advances.append((base, 4)) + + return advances + + def _advance_on_double_3(self, state: GameState) -> List[tuple[int, int]]: """Calculate runner advancement on double""" advances = [] diff --git a/backend/app/core/runner_advancement.py b/backend/app/core/runner_advancement.py index 6b66d5d..21ad40e 100644 --- a/backend/app/core/runner_advancement.py +++ b/backend/app/core/runner_advancement.py @@ -1,13 +1,20 @@ """ -Runner advancement logic for groundball outcomes. +Runner advancement logic for groundball and flyball outcomes. This module implements the complete runner advancement system based on: -- Hit outcome (GROUNDBALL_A, GROUNDBALL_B, GROUNDBALL_C) + +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 -The result numbers (1-13) match the 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 @@ -22,6 +29,8 @@ from app.config.result_charts import PlayOutcome logger = logging.getLogger(f'{__name__}.RunnerAdvancement') +# pyright: reportOptionalMemberAccess=false + class GroundballResultType(IntEnum): """ @@ -105,26 +114,32 @@ class AdvancementResult: movements: List of all runner movements (including batter) outs_recorded: Number of outs on the play (0-3) runs_scored: Number of runs scored on the play - result_type: The groundball result type used (1-13) + 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 + result_type: Optional[GroundballResultType] description: str class RunnerAdvancement: """ - Handles runner advancement logic for groundball outcomes. + Handles runner advancement logic for groundball and flyball outcomes. - This class implements the complete advancement system including: + 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): @@ -138,7 +153,49 @@ class RunnerAdvancement: defensive_decision: DefensiveDecision ) -> AdvancementResult: """ - Calculate runner advancement for a groundball outcome. + 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 + elif 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) + + else: + 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) @@ -148,17 +205,7 @@ class RunnerAdvancement: Returns: AdvancementResult with all runner movements and outs - - Raises: - ValueError: If outcome is not a groundball type """ - # Validate outcome is a groundball - if outcome not in [ - PlayOutcome.GROUNDBALL_A, - PlayOutcome.GROUNDBALL_B, - PlayOutcome.GROUNDBALL_C - ]: - raise ValueError(f"advance_runners only handles groundballs, got {outcome}") # Determine which result to apply result_type = self._determine_groundball_result( @@ -493,7 +540,7 @@ class RunnerAdvancement: # All runners stay put if state.is_runner_on_first(): movements.append(RunnerMovement( - lineup_id=state.on_first, + lineup_id=state.on_first.lineup_id, from_base=1, to_base=1, is_out=False @@ -501,7 +548,7 @@ class RunnerAdvancement: if state.is_runner_on_second(): movements.append(RunnerMovement( - lineup_id=state.on_second, + lineup_id=state.on_second.lineup_id, from_base=2, to_base=2, is_out=False @@ -509,7 +556,7 @@ class RunnerAdvancement: if state.is_runner_on_third(): movements.append(RunnerMovement( - lineup_id=state.on_third, + lineup_id=state.on_third.lineup_id, from_base=3, to_base=3, is_out=False @@ -562,7 +609,7 @@ class RunnerAdvancement: if turns_dp: # Runner on first out at second movements.append(RunnerMovement( - lineup_id=state.on_first, + lineup_id=state.on_first.lineup_id, from_base=1, to_base=0, is_out=True @@ -582,7 +629,7 @@ class RunnerAdvancement: else: # Only force out at second movements.append(RunnerMovement( - lineup_id=state.on_first, + lineup_id=state.on_first.lineup_id, from_base=1, to_base=0, is_out=True @@ -614,7 +661,7 @@ class RunnerAdvancement: if state.is_runner_on_second(): # Runner scores from second movements.append(RunnerMovement( - lineup_id=state.on_second, + lineup_id=state.on_second.lineup_id, from_base=2, to_base=4, is_out=False @@ -624,7 +671,7 @@ class RunnerAdvancement: if state.is_runner_on_third(): # Runner scores from third movements.append(RunnerMovement( - lineup_id=state.on_third, + lineup_id=state.on_third.lineup_id, from_base=3, to_base=4, is_out=False @@ -658,7 +705,7 @@ class RunnerAdvancement: 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=state.on_first.lineup_id, from_base=1, to_base=2, is_out=False @@ -666,7 +713,7 @@ class RunnerAdvancement: if state.is_runner_on_second(): movements.append(RunnerMovement( - lineup_id=state.on_second, + lineup_id=state.on_second.lineup_id, # type: ignore from_base=2, to_base=3, is_out=False @@ -674,7 +721,7 @@ class RunnerAdvancement: if state.is_runner_on_third(): movements.append(RunnerMovement( - lineup_id=state.on_third, + lineup_id=state.on_third.lineup_id, from_base=3, to_base=4, is_out=False @@ -700,7 +747,7 @@ class RunnerAdvancement: # Runner on first forced out at second if state.is_runner_on_first(): movements.append(RunnerMovement( - lineup_id=state.on_first, + lineup_id=state.on_first.lineup_id, from_base=1, to_base=0, is_out=True @@ -718,7 +765,7 @@ class RunnerAdvancement: if state.outs < 2: if state.is_runner_on_second(): movements.append(RunnerMovement( - lineup_id=state.on_second, + lineup_id=state.on_second.lineup_id, from_base=2, to_base=3, is_out=False @@ -726,7 +773,7 @@ class RunnerAdvancement: if state.is_runner_on_third(): movements.append(RunnerMovement( - lineup_id=state.on_third, + lineup_id=state.on_third.lineup_id, from_base=3, to_base=4, is_out=False @@ -791,7 +838,7 @@ class RunnerAdvancement: forced = state.is_runner_on_first() and state.is_runner_on_second() if forced: movements.append(RunnerMovement( - lineup_id=state.on_third, + lineup_id=state.on_third.lineup_id, from_base=3, to_base=4, is_out=False @@ -800,7 +847,7 @@ class RunnerAdvancement: else: # Holds movements.append(RunnerMovement( - lineup_id=state.on_third, + lineup_id=state.on_third.lineup_id, from_base=3, to_base=3, is_out=False @@ -811,7 +858,7 @@ class RunnerAdvancement: forced = state.is_runner_on_first() if forced: movements.append(RunnerMovement( - lineup_id=state.on_second, + lineup_id=state.on_second.lineup_id, from_base=2, to_base=3, is_out=False @@ -819,7 +866,7 @@ class RunnerAdvancement: else: # Holds movements.append(RunnerMovement( - lineup_id=state.on_second, + lineup_id=state.on_second.lineup_id, from_base=2, to_base=2, is_out=False @@ -828,7 +875,7 @@ class RunnerAdvancement: # Runner on 1st always forced if state.is_runner_on_first(): movements.append(RunnerMovement( - lineup_id=state.on_first, + lineup_id=state.on_first.lineup_id, from_base=1, to_base=2, is_out=False @@ -861,7 +908,7 @@ class RunnerAdvancement: # Runner on 3rd holds if state.is_runner_on_third(): movements.append(RunnerMovement( - lineup_id=state.on_third, + lineup_id=state.on_third.lineup_id, from_base=3, to_base=3, is_out=False @@ -870,7 +917,7 @@ class RunnerAdvancement: # Runner on 1st advances to 2nd if state.is_runner_on_first(): movements.append(RunnerMovement( - lineup_id=state.on_first, + lineup_id=state.on_first.lineup_id, from_base=1, to_base=2, is_out=False @@ -924,7 +971,7 @@ class RunnerAdvancement: # Runner on third out at home if state.is_runner_on_third(): movements.append(RunnerMovement( - lineup_id=state.on_third, + lineup_id=state.on_third.lineup_id, from_base=3, to_base=0, is_out=True @@ -945,7 +992,7 @@ class RunnerAdvancement: # Only out at home, batter safe if state.is_runner_on_third(): movements.append(RunnerMovement( - lineup_id=state.on_third, + lineup_id=state.on_third.lineup_id, from_base=3, to_base=0, is_out=True @@ -976,7 +1023,7 @@ class RunnerAdvancement: if state.outs + outs < 3: if state.is_runner_on_second(): movements.append(RunnerMovement( - lineup_id=state.on_second, + lineup_id=state.on_second.lineup_id, from_base=2, to_base=3, is_out=False @@ -984,7 +1031,7 @@ class RunnerAdvancement: if state.is_runner_on_first(): movements.append(RunnerMovement( - lineup_id=state.on_first, + lineup_id=state.on_first.lineup_id, from_base=1, to_base=2, is_out=False @@ -1012,7 +1059,7 @@ class RunnerAdvancement: 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=state.on_third.lineup_id, from_base=3, to_base=0, is_out=True @@ -1020,7 +1067,7 @@ class RunnerAdvancement: elif state.is_runner_on_second(): # Runner on 2nd is lead runner movements.append(RunnerMovement( - lineup_id=state.on_second, + lineup_id=state.on_second.lineup_id, from_base=2, to_base=0, is_out=True @@ -1028,7 +1075,7 @@ class RunnerAdvancement: elif state.is_runner_on_first(): # Runner on 1st is lead runner movements.append(RunnerMovement( - lineup_id=state.on_first, + lineup_id=state.on_first.lineup_id, from_base=1, to_base=0, is_out=True @@ -1047,7 +1094,7 @@ class RunnerAdvancement: # 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=state.on_second.lineup_id, from_base=2, to_base=3, is_out=False @@ -1056,7 +1103,7 @@ class RunnerAdvancement: # 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=state.on_first.lineup_id, from_base=1, to_base=2, is_out=False @@ -1108,7 +1155,7 @@ class RunnerAdvancement: # Hold all runners by default if state.is_runner_on_first(): movements.append(RunnerMovement( - lineup_id=state.on_first, + lineup_id=state.on_first.lineup_id, from_base=1, to_base=1, is_out=False @@ -1116,7 +1163,7 @@ class RunnerAdvancement: if state.is_runner_on_second(): movements.append(RunnerMovement( - lineup_id=state.on_second, + lineup_id=state.on_second.lineup_id, from_base=2, to_base=2, is_out=False @@ -1124,7 +1171,7 @@ class RunnerAdvancement: if state.is_runner_on_third(): movements.append(RunnerMovement( - lineup_id=state.on_third, + lineup_id=state.on_third.lineup_id, from_base=3, to_base=3, is_out=False @@ -1181,7 +1228,7 @@ class RunnerAdvancement: # Runner on 3rd out if state.is_runner_on_third(): movements.append(RunnerMovement( - lineup_id=state.on_third, + lineup_id=state.on_third.lineup_id, from_base=3, to_base=0, is_out=True @@ -1191,7 +1238,7 @@ class RunnerAdvancement: # Runner on 2nd out if state.is_runner_on_second(): movements.append(RunnerMovement( - lineup_id=state.on_second, + lineup_id=state.on_second.lineup_id, from_base=2, to_base=0, is_out=True @@ -1228,3 +1275,286 @@ class RunnerAdvancement: else: # 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) + elif outcome == PlayOutcome.FLYOUT_B: + return self._fb_result_medium(state, hit_location) + elif outcome == PlayOutcome.FLYOUT_BQ: + return self._fb_result_bq(state, hit_location) + elif outcome == PlayOutcome.FLYOUT_C: + return self._fb_result_shallow(state) + else: + raise ValueError(f"Unknown flyball outcome: {outcome}") + + # ======================================== + # Flyball Result Handlers + # ======================================== + + def _fb_result_deep(self, state: GameState) -> 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 + )) + + return AdvancementResult( + movements=movements, + outs_recorded=1, + runs_scored=runs, + result_type=None, # Flyballs don't use result types + description="Deep flyball - all runners tag up and advance" + ) + + 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 + )) + + return AdvancementResult( + movements=movements, + outs_recorded=1, + runs_scored=runs, + result_type=None, # Flyballs don't use result types + description=f"Medium flyball to {hit_location} - R3 scores, R2 DECIDE (held), R1 holds" + ) + + 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 + )) + + return AdvancementResult( + movements=movements, + outs_recorded=1, + runs_scored=runs, + result_type=None, # Flyballs don't use result types + description=f"Medium-shallow flyball to {hit_location} - R3 DECIDE (held), all others hold" + ) + + def _fb_result_shallow(self, state: GameState) -> 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" + ) diff --git a/backend/app/core/state_manager.py b/backend/app/core/state_manager.py index 9266c62..e49d68c 100644 --- a/backend/app/core/state_manager.py +++ b/backend/app/core/state_manager.py @@ -16,7 +16,7 @@ from typing import Dict, Optional, Union from uuid import UUID import pendulum -from app.models.game_models import GameState, TeamLineupState, DefensiveDecision, OffensiveDecision +from app.models.game_models import GameState, TeamLineupState, LineupPlayerState, DefensiveDecision, OffensiveDecision from app.database.operations import DatabaseOperations logger = logging.getLogger(f'{__name__}.StateManager') @@ -89,7 +89,8 @@ class StateManager: away_team_id=away_team_id, home_team_is_ai=home_team_is_ai, away_team_is_ai=away_team_is_ai, - auto_mode=auto_mode + auto_mode=auto_mode, + current_batter_lineup_id=0 # Will be set by _prepare_next_play() when game starts ) self._states[game_id] = state @@ -268,50 +269,80 @@ class StateManager: if completed_plays: last_play = max(completed_plays, key=lambda p: p['play_number']) - # Recover runner state from final positions - from app.models.game_models import RunnerState + # Build lineup lookup dict for quick access + lineups = game_data.get('lineups', []) + lineup_dict = {l['id']: l for l in lineups} - runners = [] - # Check each base for a runner (using *_final fields) - for base_num, final_field in [(1, 'on_first_final'), (2, 'on_second_final'), (3, 'on_third_final')]: - final_base = last_play.get(final_field) - if final_base == base_num: # Runner ended on this base - # Get lineup_id from corresponding on_X_id field - lineup_id = last_play.get(f'on_{["", "first", "second", "third"][base_num]}_id') - if lineup_id: - runners.append(RunnerState( - lineup_id=lineup_id, - card_id=0, # Will be populated when needed - on_base=base_num - )) + # Helper function to create LineupPlayerState from lineup_id + def get_lineup_player(lineup_id: int) -> Optional[LineupPlayerState]: + if not lineup_id or lineup_id not in lineup_dict: + return None + lineup = lineup_dict[lineup_id] + return LineupPlayerState( + lineup_id=lineup['id'], + card_id=lineup['card_id'] or 0, # Handle nullable + position=lineup['position'], + batting_order=lineup.get('batting_order'), + is_active=lineup.get('is_active', True) + ) - # Check if batter reached base + # Recover runners from *_final fields (where they ended up after last play) + # Check each base - if a runner ended on that base, place them there + runner_count = 0 + + # Check if on_first_id runner ended on first (on_first_final == 1) + if last_play.get('on_first_final') == 1: + state.on_first = get_lineup_player(last_play.get('on_first_id')) + if state.on_first: + runner_count += 1 + + # Check if on_second_id runner ended on second OR if on_first_id runner advanced to second + if last_play.get('on_second_final') == 2: + state.on_second = get_lineup_player(last_play.get('on_second_id')) + if state.on_second: + runner_count += 1 + elif last_play.get('on_first_final') == 2: + state.on_second = get_lineup_player(last_play.get('on_first_id')) + if state.on_second: + runner_count += 1 + + # Check if any runner ended on third + if last_play.get('on_third_final') == 3: + state.on_third = get_lineup_player(last_play.get('on_third_id')) + if state.on_third: + runner_count += 1 + elif last_play.get('on_second_final') == 3: + state.on_third = get_lineup_player(last_play.get('on_second_id')) + if state.on_third: + runner_count += 1 + elif last_play.get('on_first_final') == 3: + state.on_third = get_lineup_player(last_play.get('on_first_id')) + if state.on_third: + runner_count += 1 + + # Check if batter reached base (and didn't score) batter_final = last_play.get('batter_final') - if batter_final and 1 <= batter_final <= 3: - batter_id = last_play.get('batter_id') - if batter_id: - runners.append(RunnerState( - lineup_id=batter_id, - card_id=0, - on_base=batter_final - )) - - state.runners = runners + if batter_final == 1: + state.on_first = get_lineup_player(last_play.get('batter_id')) + if state.on_first: + runner_count += 1 + elif batter_final == 2: + state.on_second = get_lineup_player(last_play.get('batter_id')) + if state.on_second: + runner_count += 1 + elif batter_final == 3: + state.on_third = get_lineup_player(last_play.get('batter_id')) + if state.on_third: + runner_count += 1 # Recover batter indices from lineups - # We need to find where each team is in their batting order - home_lineup = [l for l in game_data.get('lineups', []) if l['team_id'] == state.home_team_id] - away_lineup = [l for l in game_data.get('lineups', []) if l['team_id'] == state.away_team_id] - - # For now, we'll need to be called with _prepare_next_play() after recovery - # to set the proper batter indices and snapshot # Initialize to 0 - will be corrected by _prepare_next_play() state.away_team_batter_idx = 0 state.home_team_batter_idx = 0 logger.debug( f"Recovered state from play {last_play['play_number']}: " - f"{len(runners)} runners on base" + f"{runner_count} runners on base" ) else: logger.debug("No completed plays found - initializing fresh state") diff --git a/backend/app/database/operations.py b/backend/app/database/operations.py index 4310ba8..133f951 100644 --- a/backend/app/database/operations.py +++ b/backend/app/database/operations.py @@ -407,7 +407,17 @@ class DatabaseOperations: 'inning': p.inning, 'half': p.half, 'outs_before': p.outs_before, - 'result_description': p.result_description + 'result_description': p.result_description, + 'complete': p.complete, + # Runner tracking for state recovery + 'batter_id': p.batter_id, + 'on_first_id': p.on_first_id, + 'on_second_id': p.on_second_id, + 'on_third_id': p.on_third_id, + 'batter_final': p.batter_final, + 'on_first_final': p.on_first_final, + 'on_second_final': p.on_second_final, + 'on_third_final': p.on_third_final } for p in plays ] diff --git a/backend/app/models/game_models.py b/backend/app/models/game_models.py index 6f2433f..770c1a4 100644 --- a/backend/app/models/game_models.py +++ b/backend/app/models/game_models.py @@ -294,7 +294,7 @@ class GameState(BaseModel): # Current play snapshot (set by _prepare_next_play) # These capture the state BEFORE each play for accurate record-keeping - current_batter_lineup_id: Optional[int] = None + current_batter_lineup_id: int current_pitcher_lineup_id: Optional[int] = None current_catcher_lineup_id: Optional[int] = None current_on_base_code: int = Field(default=0, ge=0) # Bit field: 1=1st, 2=2nd, 4=3rd, 7=loaded diff --git a/backend/tests/unit/core/test_flyball_advancement.py b/backend/tests/unit/core/test_flyball_advancement.py new file mode 100644 index 0000000..6bf3bf4 --- /dev/null +++ b/backend/tests/unit/core/test_flyball_advancement.py @@ -0,0 +1,466 @@ +""" +Tests for flyball runner advancement logic. + +Tests all 4 flyball types: +- FLYOUT_A (Deep): All runners advance +- FLYOUT_B (Medium): R3 scores, R2 DECIDE, R1 holds +- FLYOUT_BQ (Medium-shallow): R3 DECIDE, R2 holds, R1 holds +- FLYOUT_C (Shallow): All runners hold +""" + +import pytest +from uuid import uuid4 + +from app.core.runner_advancement import RunnerAdvancement, AdvancementResult +from app.models.game_models import GameState, DefensiveDecision, LineupPlayerState +from app.config import PlayOutcome + + +@pytest.fixture +def runner_advancement(): + """Create RunnerAdvancement instance.""" + return RunnerAdvancement() + + +@pytest.fixture +def base_state(): + """Create base GameState for testing.""" + return GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + inning=3, + half="top", + outs=1, + current_batter_lineup_id=1 # Required field + ) + + +@pytest.fixture +def defensive_decision(): + """Create standard defensive decision.""" + return DefensiveDecision( + alignment="normal", + infield_depth="normal", + outfield_depth="normal" + ) + + +class TestFlyoutA: + """Tests for FLYOUT_A (Deep flyball - all runners advance).""" + + def test_runner_on_third_scores(self, runner_advancement, base_state, defensive_decision): + """Runner on third scores on deep flyball.""" + # Setup: Runner on 3rd + base_state.on_third = LineupPlayerState(lineup_id=3, card_id=103, position="CF") + base_state.current_batter_lineup_id = 4 + + result = runner_advancement.advance_runners( + outcome=PlayOutcome.FLYOUT_A, + hit_location="CF", + state=base_state, + defensive_decision=defensive_decision + ) + + assert result.outs_recorded == 1 + assert result.runs_scored == 1 + assert result.result_type is None # Flyballs don't use result types + + # Verify movements + movements = {m.from_base: m for m in result.movements} + assert movements[0].is_out # Batter out + assert movements[3].to_base == 4 # Runner scored + assert not movements[3].is_out + + def test_all_bases_loaded_all_advance(self, runner_advancement, base_state, defensive_decision): + """All runners advance on deep flyball with bases loaded.""" + # Setup: Bases loaded + base_state.on_first = LineupPlayerState(lineup_id=1, card_id=101, position="RF") + base_state.on_second = LineupPlayerState(lineup_id=2, card_id=102, position="SS") + base_state.on_third = LineupPlayerState(lineup_id=3, card_id=103, position="CF") + base_state.current_batter_lineup_id = 4 + + result = runner_advancement.advance_runners( + outcome=PlayOutcome.FLYOUT_A, + hit_location="LF", + state=base_state, + defensive_decision=defensive_decision + ) + + assert result.outs_recorded == 1 + assert result.runs_scored == 1 # Only R3 scores + + # Verify all runners advanced + movements = {m.from_base: m for m in result.movements} + assert movements[3].to_base == 4 # R3 scores + assert movements[2].to_base == 3 # R2 to 3rd + assert movements[1].to_base == 2 # R1 to 2nd + + def test_two_outs_no_advancement(self, runner_advancement, base_state, defensive_decision): + """With 2 outs, runners don't advance (inning over).""" + base_state.outs = 2 + base_state.on_third = LineupPlayerState(lineup_id=3, card_id=103, position="CF") + base_state.current_batter_lineup_id = 4 + + result = runner_advancement.advance_runners( + outcome=PlayOutcome.FLYOUT_A, + hit_location="RF", + state=base_state, + defensive_decision=defensive_decision + ) + + assert result.outs_recorded == 1 + assert result.runs_scored == 0 # No runs (3rd out ends inning) + + # Only batter movement + assert len(result.movements) == 1 + assert result.movements[0].is_out + + def test_empty_bases(self, runner_advancement, base_state, defensive_decision): + """Deep flyball with no runners on base.""" + base_state.current_batter_lineup_id = 1 + + result = runner_advancement.advance_runners( + outcome=PlayOutcome.FLYOUT_A, + hit_location="CF", + state=base_state, + defensive_decision=defensive_decision + ) + + assert result.outs_recorded == 1 + assert result.runs_scored == 0 + assert len(result.movements) == 1 # Only batter + assert result.movements[0].is_out + + +class TestFlyoutB: + """Tests for FLYOUT_B (Medium flyball - R3 scores, R2 DECIDE).""" + + def test_runner_on_third_scores(self, runner_advancement, base_state, defensive_decision): + """Runner on third always scores on FLYOUT_B.""" + base_state.on_third = LineupPlayerState(lineup_id=3, card_id=103, position="CF") + base_state.current_batter_lineup_id = 4 + + result = runner_advancement.advance_runners( + outcome=PlayOutcome.FLYOUT_B, + hit_location="RF", + state=base_state, + defensive_decision=defensive_decision + ) + + assert result.outs_recorded == 1 + assert result.runs_scored == 1 + + movements = {m.from_base: m for m in result.movements} + assert movements[3].to_base == 4 # R3 scores + assert not movements[3].is_out + + def test_runner_on_second_holds_by_default(self, runner_advancement, base_state, defensive_decision): + """Runner on second holds (DECIDE defaults to conservative).""" + base_state.on_second = LineupPlayerState(lineup_id=2, card_id=102, position="SS") + base_state.current_batter_lineup_id = 3 + + result = runner_advancement.advance_runners( + outcome=PlayOutcome.FLYOUT_B, + hit_location="LF", + state=base_state, + defensive_decision=defensive_decision + ) + + assert result.outs_recorded == 1 + assert result.runs_scored == 0 + + # R2 holds (no-op movement for state recovery) + movements = {m.from_base: m for m in result.movements} + assert movements[2].to_base == 2 # Holds + assert not movements[2].is_out + + def test_bases_loaded_r3_scores_others_hold(self, runner_advancement, base_state, defensive_decision): + """With bases loaded, R3 scores, R2 DECIDE (holds), R1 holds.""" + base_state.on_first = LineupPlayerState(lineup_id=1, card_id=101, position="RF") + base_state.on_second = LineupPlayerState(lineup_id=2, card_id=102, position="SS") + base_state.on_third = LineupPlayerState(lineup_id=3, card_id=103, position="CF") + base_state.current_batter_lineup_id = 4 + + result = runner_advancement.advance_runners( + outcome=PlayOutcome.FLYOUT_B, + hit_location="CF", + state=base_state, + defensive_decision=defensive_decision + ) + + assert result.outs_recorded == 1 + assert result.runs_scored == 1 # Only R3 scores + + movements = {m.from_base: m for m in result.movements} + assert movements[3].to_base == 4 # R3 scores + assert movements[2].to_base == 2 # R2 holds (DECIDE defaults to hold) + assert movements[1].to_base == 1 # R1 holds + + def test_description_includes_location(self, runner_advancement, base_state, defensive_decision): + """Description includes hit location.""" + base_state.on_third = LineupPlayerState(lineup_id=3, card_id=103, position="CF") + base_state.current_batter_lineup_id = 4 + + result = runner_advancement.advance_runners( + outcome=PlayOutcome.FLYOUT_B, + hit_location="RF", + state=base_state, + defensive_decision=defensive_decision + ) + + assert "RF" in result.description + assert "DECIDE" in result.description + + +class TestFlyoutBQ: + """Tests for FLYOUT_BQ (Medium-shallow flyball - R3 DECIDE).""" + + def test_runner_on_third_holds_by_default(self, runner_advancement, base_state, defensive_decision): + """Runner on third holds (DECIDE defaults to conservative).""" + base_state.on_third = LineupPlayerState(lineup_id=3, card_id=103, position="CF") + base_state.current_batter_lineup_id = 4 + + result = runner_advancement.advance_runners( + outcome=PlayOutcome.FLYOUT_BQ, + hit_location="LF", + state=base_state, + defensive_decision=defensive_decision + ) + + assert result.outs_recorded == 1 + assert result.runs_scored == 0 # R3 doesn't score (holds by default) + + # R3 holds (no-op movement for state recovery) + movements = {m.from_base: m for m in result.movements} + assert movements[3].to_base == 3 # Holds + assert not movements[3].is_out + + def test_all_runners_hold(self, runner_advancement, base_state, defensive_decision): + """All runners hold on FLYOUT_BQ.""" + base_state.on_first = LineupPlayerState(lineup_id=1, card_id=101, position="RF") + base_state.on_second = LineupPlayerState(lineup_id=2, card_id=102, position="SS") + base_state.on_third = LineupPlayerState(lineup_id=3, card_id=103, position="CF") + base_state.current_batter_lineup_id = 4 + + result = runner_advancement.advance_runners( + outcome=PlayOutcome.FLYOUT_BQ, + hit_location="CF", + state=base_state, + defensive_decision=defensive_decision + ) + + assert result.outs_recorded == 1 + assert result.runs_scored == 0 + + # All runners hold + movements = {m.from_base: m for m in result.movements} + assert movements[3].to_base == 3 # R3 holds (DECIDE defaults to hold) + assert movements[2].to_base == 2 # R2 holds + assert movements[1].to_base == 1 # R1 holds + + def test_two_outs_no_movements(self, runner_advancement, base_state, defensive_decision): + """With 2 outs, no runner movements recorded.""" + base_state.outs = 2 + base_state.on_third = LineupPlayerState(lineup_id=3, card_id=103, position="CF") + base_state.current_batter_lineup_id = 4 + + result = runner_advancement.advance_runners( + outcome=PlayOutcome.FLYOUT_BQ, + hit_location="RF", + state=base_state, + defensive_decision=defensive_decision + ) + + assert result.outs_recorded == 1 + assert result.runs_scored == 0 + + # Only batter movement (inning over) + assert len(result.movements) == 1 + assert result.movements[0].is_out + + def test_description_includes_decide(self, runner_advancement, base_state, defensive_decision): + """Description mentions DECIDE opportunity.""" + base_state.on_third = LineupPlayerState(lineup_id=3, card_id=103, position="CF") + base_state.current_batter_lineup_id = 4 + + result = runner_advancement.advance_runners( + outcome=PlayOutcome.FLYOUT_BQ, + hit_location="LF", + state=base_state, + defensive_decision=defensive_decision + ) + + assert "DECIDE" in result.description + + +class TestFlyoutC: + """Tests for FLYOUT_C (Shallow flyball - all runners hold).""" + + def test_all_runners_hold(self, runner_advancement, base_state, defensive_decision): + """All runners hold on shallow flyball.""" + base_state.on_first = LineupPlayerState(lineup_id=1, card_id=101, position="RF") + base_state.on_second = LineupPlayerState(lineup_id=2, card_id=102, position="SS") + base_state.on_third = LineupPlayerState(lineup_id=3, card_id=103, position="CF") + base_state.current_batter_lineup_id = 4 + + result = runner_advancement.advance_runners( + outcome=PlayOutcome.FLYOUT_C, + hit_location="CF", + state=base_state, + defensive_decision=defensive_decision + ) + + assert result.outs_recorded == 1 + assert result.runs_scored == 0 + + # All runners hold (no-op movements for state recovery) + movements = {m.from_base: m for m in result.movements} + assert movements[3].to_base == 3 + assert movements[2].to_base == 2 + assert movements[1].to_base == 1 + assert not any(m.is_out for m in result.movements if m.from_base > 0) + + def test_empty_bases(self, runner_advancement, base_state, defensive_decision): + """Shallow flyball with no runners.""" + base_state.current_batter_lineup_id = 1 + + result = runner_advancement.advance_runners( + outcome=PlayOutcome.FLYOUT_C, + hit_location="LF", + state=base_state, + defensive_decision=defensive_decision + ) + + assert result.outs_recorded == 1 + assert result.runs_scored == 0 + assert len(result.movements) == 1 # Only batter + assert result.movements[0].is_out + + def test_runner_on_third_does_not_score(self, runner_advancement, base_state, defensive_decision): + """Runner on third does not score on shallow flyball.""" + base_state.on_third = LineupPlayerState(lineup_id=3, card_id=103, position="CF") + base_state.current_batter_lineup_id = 4 + + result = runner_advancement.advance_runners( + outcome=PlayOutcome.FLYOUT_C, + hit_location="RF", + state=base_state, + defensive_decision=defensive_decision + ) + + assert result.outs_recorded == 1 + assert result.runs_scored == 0 + + movements = {m.from_base: m for m in result.movements} + assert movements[3].to_base == 3 # Holds + assert not movements[3].is_out + + +class TestFlyballEdgeCases: + """Edge case tests for flyball advancement.""" + + def test_invalid_flyball_raises_error(self, runner_advancement, base_state, defensive_decision): + """Non-flyball outcome raises ValueError.""" + base_state.current_batter_lineup_id = 1 + + with pytest.raises(ValueError, match="only handles groundballs and flyballs"): + runner_advancement.advance_runners( + outcome=PlayOutcome.STRIKEOUT, + hit_location="CF", + state=base_state, + defensive_decision=defensive_decision + ) + + def test_all_flyball_types_supported(self, runner_advancement, base_state, defensive_decision): + """All 4 flyball types are supported.""" + base_state.current_batter_lineup_id = 1 + + for outcome in [PlayOutcome.FLYOUT_A, PlayOutcome.FLYOUT_B, PlayOutcome.FLYOUT_BQ, PlayOutcome.FLYOUT_C]: + result = runner_advancement.advance_runners( + outcome=outcome, + hit_location="CF", + state=base_state, + defensive_decision=defensive_decision + ) + assert result.outs_recorded == 1 + assert result.result_type is None # Flyballs don't use result types + + def test_all_outfield_locations_supported(self, runner_advancement, base_state, defensive_decision): + """All outfield locations (LF, CF, RF) are supported.""" + base_state.on_third = LineupPlayerState(lineup_id=3, card_id=103, position="CF") + base_state.current_batter_lineup_id = 4 + + for location in ["LF", "CF", "RF"]: + result = runner_advancement.advance_runners( + outcome=PlayOutcome.FLYOUT_B, + hit_location=location, + state=base_state, + defensive_decision=defensive_decision + ) + assert location in result.description + + +class TestNoOpMovements: + """Tests verifying no-op movements for state recovery.""" + + def test_flyout_c_records_hold_movements(self, runner_advancement, base_state, defensive_decision): + """FLYOUT_C records hold movements (critical for state recovery).""" + base_state.on_first = LineupPlayerState(lineup_id=1, card_id=101, position="RF") + base_state.on_third = LineupPlayerState(lineup_id=3, card_id=103, position="CF") + base_state.current_batter_lineup_id = 4 + + result = runner_advancement.advance_runners( + outcome=PlayOutcome.FLYOUT_C, + hit_location="LF", + state=base_state, + defensive_decision=defensive_decision + ) + + # Should have batter movement + 2 runner hold movements + assert len(result.movements) == 3 + + movements = {m.from_base: m for m in result.movements} + # R1 holds: from_base=1, to_base=1 + assert movements[1].from_base == 1 + assert movements[1].to_base == 1 + # R3 holds: from_base=3, to_base=3 + assert movements[3].from_base == 3 + assert movements[3].to_base == 3 + + def test_flyout_bq_r3_hold_recorded(self, runner_advancement, base_state, defensive_decision): + """FLYOUT_BQ records R3 hold movement (DECIDE defaults to hold).""" + base_state.on_third = LineupPlayerState(lineup_id=3, card_id=103, position="CF") + base_state.current_batter_lineup_id = 4 + + result = runner_advancement.advance_runners( + outcome=PlayOutcome.FLYOUT_BQ, + hit_location="CF", + state=base_state, + defensive_decision=defensive_decision + ) + + movements = {m.from_base: m for m in result.movements} + # R3 DECIDE defaults to hold - must be recorded + assert movements[3].from_base == 3 + assert movements[3].to_base == 3 + assert not movements[3].is_out + + def test_flyout_b_r2_hold_recorded(self, runner_advancement, base_state, defensive_decision): + """FLYOUT_B records R2 hold movement (DECIDE defaults to hold).""" + base_state.on_second = LineupPlayerState(lineup_id=2, card_id=102, position="SS") + base_state.current_batter_lineup_id = 3 + + result = runner_advancement.advance_runners( + outcome=PlayOutcome.FLYOUT_B, + hit_location="RF", + state=base_state, + defensive_decision=defensive_decision + ) + + movements = {m.from_base: m for m in result.movements} + # R2 DECIDE defaults to hold - must be recorded + assert movements[2].from_base == 2 + assert movements[2].to_base == 2 + assert not movements[2].is_out