CLAUDE: Integrate flyball advancement with RunnerAdvancement system

Major Phase 2 refactoring to consolidate runner advancement logic:

**Flyball System Enhancement**:
- Add FLYOUT_BQ variant (medium-shallow depth)
- 4 flyball types with clear semantics: A (deep), B (medium), BQ (medium-shallow), C (shallow)
- Updated helper methods to include FLYOUT_BQ

**RunnerAdvancement Integration**:
- Extend runner_advancement.py to handle both groundballs AND flyballs
- advance_runners() routes to _advance_runners_groundball() or _advance_runners_flyball()
- Comprehensive flyball logic with proper DECIDE mechanics per flyball type
- No-op movements recorded for state recovery consistency

**PlayResolver Refactoring**:
- Consolidate all 4 flyball outcomes to delegate to RunnerAdvancement (DRY)
- Eliminate duplicate flyball resolution code
- Rename helpers for clarity: _advance_on_single_1/_advance_on_single_2 (was _advance_on_single)
- Fix single/double advancement logic for different hit types

**State Recovery Fix**:
- Fix state_manager.py game recovery to build LineupPlayerState objects properly
- Use get_lineup_player() helper to construct from lineup data
- Correctly track runners in on_first/on_second/on_third fields (matches Phase 2 model)

**Database Support**:
- Add runner tracking fields to play data for accurate recovery
- Include batter_id, on_first_id, on_second_id, on_third_id, and *_final fields

**Type Safety Improvements**:
- Fix lineup_id access throughout runner_advancement.py (was accessing on_first directly, now on_first.lineup_id)
- Make current_batter_lineup_id non-optional (always set by _prepare_next_play)
- Add type: ignore for known SQLAlchemy false positives

**Documentation**:
- Update CLAUDE.md with comprehensive flyball documentation
- Add flyball types table, usage examples, and test coverage notes
- Document differences between groundball and flyball mechanics

**Testing**:
- Add test_flyball_advancement.py with 21 flyball tests
- Coverage: all 4 types, DECIDE scenarios, no-op movements, edge cases

🚀 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-10-31 17:04:23 -05:00
parent 23a0a1db4e
commit a696473d0a
8 changed files with 1055 additions and 140 deletions

View File

@ -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,
}

View File

@ -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)

View File

@ -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 = []

View File

@ -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"
)

View File

@ -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")

View File

@ -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
]

View File

@ -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

View File

@ -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