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:
parent
23a0a1db4e
commit
a696473d0a
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 = []
|
||||
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
466
backend/tests/unit/core/test_flyball_advancement.py
Normal file
466
backend/tests/unit/core/test_flyball_advancement.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user