CLAUDE: Implement Week 7 Tasks 4 & 5 - Runner advancement logic and double play mechanics

- Created runner_advancement.py with complete groundball advancement system
- Implemented GroundballResultType IntEnum with 13 rulebook-aligned results
- Built RunnerAdvancement class with chart lookup logic (Infield Back/In)
- Implemented all 13 result handlers (gb_result_1 through gb_result_13)
- Added DECIDE mechanic support for interactive runner advancement decisions
- Implemented double play probability calculation with positioning modifiers
- Created 30 comprehensive unit tests covering all scenarios (100% passing)

Key Features:
- Supports Infield Back and Infield In defensive positioning
- Handles Corners In hybrid positioning (applies In rules to corner fielders)
- Conditional results based on hit location (middle IF, right side, corners)
- Force play detection and advancement logic
- Double play mechanics with probability-based success (45% base rate)
- Result types match official rulebook exactly (1-13)

Architecture:
- IntEnum for result types (type-safe, self-documenting)
- Comprehensive hit location tracking (1B, 2B, SS, 3B, P, C, LF, CF, RF)
- Dataclasses for movements (RunnerMovement, AdvancementResult)
- Probability modifiers: Infield In (-15%), hit location (±10%)

Testing:
- 30 unit tests covering chart lookup, all result types, and edge cases
- Double play probability validation
- All on-base codes (0-7) tested
- All groundball types (A, B, C) verified

Status: Week 7 Tasks 4 & 5 complete (~87% of Week 7 finished)
Next: Task 6 (PlayResolver Integration)

Related: #task4 #task5 #runner-advancement #double-play #week7
This commit is contained in:
Cal Corum 2025-10-30 23:32:44 -05:00
parent 69782f54c9
commit 102cbb6081
2 changed files with 1935 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,705 @@
"""
Tests for runner advancement logic.
This module tests the complete runner advancement system including:
- Chart lookup (Infield Back vs Infield In)
- All 13 groundball result types
- Double play probability calculations
- Edge cases and special scenarios
"""
import pytest
from unittest.mock import Mock, patch
from app.core.runner_advancement import (
RunnerAdvancement,
GroundballResultType,
RunnerMovement,
AdvancementResult
)
from app.models.game_models import GameState, DefensiveDecision
from app.config.result_charts import PlayOutcome
# ========================================
# Fixtures
# ========================================
@pytest.fixture
def advancement():
"""Create RunnerAdvancement instance."""
return RunnerAdvancement()
@pytest.fixture
def base_state():
"""Create a base GameState mock for testing."""
state = Mock(spec=GameState)
state.outs = 0
state.current_on_base_code = 0
state.current_batter_lineup_id = 1
state.on_first = None
state.on_second = None
state.on_third = None
state.is_runner_on_first = Mock(return_value=False)
state.is_runner_on_second = Mock(return_value=False)
state.is_runner_on_third = Mock(return_value=False)
return state
@pytest.fixture
def normal_defense():
"""Create normal defensive decision."""
return DefensiveDecision(
alignment="normal",
infield_depth="normal",
outfield_depth="normal",
hold_runners=[]
)
@pytest.fixture
def infield_in_defense():
"""Create infield in defensive decision."""
return DefensiveDecision(
alignment="normal",
infield_depth="infield_in",
outfield_depth="normal",
hold_runners=[]
)
@pytest.fixture
def corners_in_defense():
"""Create corners in defensive decision."""
return DefensiveDecision(
alignment="normal",
infield_depth="corners_in",
outfield_depth="normal",
hold_runners=[]
)
# ========================================
# Chart Lookup Tests
# ========================================
class TestChartLookup:
"""Tests for determining which groundball result applies."""
def test_empty_bases_always_result_1(self, advancement, base_state, normal_defense):
"""Empty bases always results in batter out, runners hold."""
base_state.current_on_base_code = 0
base_state.outs = 0
result = advancement.advance_runners(
outcome=PlayOutcome.GROUNDBALL_A,
hit_location='SS',
state=base_state,
defensive_decision=normal_defense
)
assert result.result_type == GroundballResultType.BATTER_OUT_RUNNERS_HOLD
assert result.outs_recorded == 1
assert result.runs_scored == 0
def test_two_outs_always_result_1(self, advancement, base_state, normal_defense):
"""With 2 outs, always batter out regardless of situation."""
base_state.current_on_base_code = 7 # Bases loaded
base_state.outs = 2
base_state.on_first = 2
base_state.on_second = 3
base_state.on_third = 4
result = advancement.advance_runners(
outcome=PlayOutcome.GROUNDBALL_A,
hit_location='SS',
state=base_state,
defensive_decision=normal_defense
)
assert result.result_type == GroundballResultType.BATTER_OUT_RUNNERS_HOLD
assert result.outs_recorded == 1
def test_runner_on_first_gba_infield_back(self, advancement, base_state, normal_defense):
"""Runner on 1st, GBA, Infield Back → Result 2 (DP attempt)."""
base_state.current_on_base_code = 1
base_state.outs = 0
base_state.on_first = 2
base_state.is_runner_on_first = Mock(return_value=True)
result = advancement.advance_runners(
outcome=PlayOutcome.GROUNDBALL_A,
hit_location='SS',
state=base_state,
defensive_decision=normal_defense
)
assert result.result_type == GroundballResultType.DOUBLE_PLAY_AT_SECOND
def test_runner_on_third_infield_in(self, advancement, base_state, infield_in_defense):
"""Runner on 3rd, GBA, Infield In → Result 7 (forced advancement)."""
base_state.current_on_base_code = 3
base_state.outs = 0
base_state.on_third = 2
base_state.is_runner_on_third = Mock(return_value=True)
result = advancement.advance_runners(
outcome=PlayOutcome.GROUNDBALL_A,
hit_location='SS',
state=base_state,
defensive_decision=infield_in_defense
)
assert result.result_type == GroundballResultType.BATTER_OUT_FORCED_ONLY
def test_bases_loaded_infield_in(self, advancement, base_state, infield_in_defense):
"""Bases loaded, GBA, Infield In → Result 10 (DP home to first)."""
base_state.current_on_base_code = 7
base_state.outs = 0
base_state.on_first = 2
base_state.on_second = 3
base_state.on_third = 4
base_state.is_runner_on_first = Mock(return_value=True)
base_state.is_runner_on_second = Mock(return_value=True)
base_state.is_runner_on_third = Mock(return_value=True)
result = advancement.advance_runners(
outcome=PlayOutcome.GROUNDBALL_A,
hit_location='SS',
state=base_state,
defensive_decision=infield_in_defense
)
assert result.result_type == GroundballResultType.DOUBLE_PLAY_HOME_TO_FIRST
def test_corners_in_hit_to_corner(self, advancement, base_state, corners_in_defense):
"""Runner on 3rd, GBA, Corners In, hit to 3B → Uses Infield In rules."""
base_state.current_on_base_code = 3
base_state.outs = 0
base_state.on_third = 2
base_state.is_runner_on_third = Mock(return_value=True)
result = advancement.advance_runners(
outcome=PlayOutcome.GROUNDBALL_A,
hit_location='3B', # Corner
state=base_state,
defensive_decision=corners_in_defense
)
# Should use Infield In rules for corners
assert result.result_type == GroundballResultType.BATTER_OUT_FORCED_ONLY
def test_corners_in_hit_to_middle(self, advancement, base_state, corners_in_defense):
"""Runner on 3rd, GBA, Corners In, hit to SS → Uses Infield Back rules (Result 5 → Result 3)."""
base_state.current_on_base_code = 3
base_state.outs = 0
base_state.on_third = 2
base_state.is_runner_on_third = Mock(return_value=True)
result = advancement.advance_runners(
outcome=PlayOutcome.GROUNDBALL_A,
hit_location='SS', # Middle infield
state=base_state,
defensive_decision=corners_in_defense
)
# Should use Infield Back rules for middle IF (Result 5 hit to MIF = Result 3)
assert result.result_type == GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE
assert result.runs_scored == 1 # R3 scores
# ========================================
# Result Handler Tests
# ========================================
class TestResult1:
"""Tests for Result 1: Batter out, runners hold."""
def test_empty_bases(self, advancement, base_state, normal_defense):
"""Empty bases: only batter out."""
base_state.current_on_base_code = 0
base_state.outs = 0
result = advancement.advance_runners(
outcome=PlayOutcome.GROUNDBALL_B,
hit_location='SS',
state=base_state,
defensive_decision=normal_defense
)
assert len(result.movements) == 1
assert result.movements[0].from_base == 0
assert result.movements[0].is_out is True
assert result.outs_recorded == 1
assert result.runs_scored == 0
def test_runners_hold(self, advancement, base_state, normal_defense):
"""All runners stay put."""
base_state.current_on_base_code = 7 # Loaded
base_state.outs = 2 # Forces result 1
base_state.on_first = 2
base_state.on_second = 3
base_state.on_third = 4
base_state.is_runner_on_first = Mock(return_value=True)
base_state.is_runner_on_second = Mock(return_value=True)
base_state.is_runner_on_third = Mock(return_value=True)
result = advancement.advance_runners(
outcome=PlayOutcome.GROUNDBALL_A,
hit_location='SS',
state=base_state,
defensive_decision=normal_defense
)
# Should have batter + 3 runners
assert len(result.movements) == 4
# All runners should stay put
runner_movements = [m for m in result.movements if m.from_base > 0]
for movement in runner_movements:
assert movement.from_base == movement.to_base
assert movement.is_out is False
class TestResult2:
"""Tests for Result 2: Double play at 2nd and 1st."""
@patch('app.core.runner_advancement.random.random', return_value=0.3)
def test_successful_double_play(self, mock_random, advancement, base_state, normal_defense):
"""Successful DP: runner on 1st and batter both out."""
base_state.current_on_base_code = 4 # 1st and 2nd
base_state.outs = 0
base_state.on_first = 2
base_state.on_second = 3
base_state.is_runner_on_first = Mock(return_value=True)
base_state.is_runner_on_second = Mock(return_value=True)
result = advancement.advance_runners(
outcome=PlayOutcome.GROUNDBALL_A,
hit_location='SS', # Up the middle (45% base + 10% middle = 55%)
state=base_state,
defensive_decision=normal_defense
)
# Should have 2 outs
assert result.outs_recorded == 2
# Runner on 1st should be out
r1_movement = next(m for m in result.movements if m.lineup_id == 2)
assert r1_movement.is_out is True
# Batter should be out
batter_movement = next(m for m in result.movements if m.from_base == 0)
assert batter_movement.is_out is True
# Runner on 2nd should score
r2_movement = next(m for m in result.movements if m.lineup_id == 3)
assert r2_movement.to_base == 4
assert result.runs_scored == 1
@patch('app.core.runner_advancement.random.random', return_value=0.8)
def test_failed_double_play(self, mock_random, advancement, base_state, normal_defense):
"""Failed DP: only force out at 2nd, batter safe."""
base_state.current_on_base_code = 1 # Runner on 1st
base_state.outs = 0
base_state.on_first = 2
base_state.is_runner_on_first = Mock(return_value=True)
result = advancement.advance_runners(
outcome=PlayOutcome.GROUNDBALL_A,
hit_location='3B', # Corner (lower DP chance)
state=base_state,
defensive_decision=normal_defense
)
# Should only have 1 out
assert result.outs_recorded == 1
# Runner on 1st should be out
r1_movement = next(m for m in result.movements if m.lineup_id == 2)
assert r1_movement.is_out is True
# Batter should be safe at 1st
batter_movement = next(m for m in result.movements if m.from_base == 0)
assert batter_movement.to_base == 1
assert batter_movement.is_out is False
class TestResult3:
"""Tests for Result 3: Batter out, runners advance 1 base."""
def test_runner_on_first(self, advancement, base_state, normal_defense):
"""Runner on 1st advances to 2nd."""
base_state.current_on_base_code = 1
base_state.outs = 0
base_state.on_first = 2
base_state.is_runner_on_first = Mock(return_value=True)
result = advancement.advance_runners(
outcome=PlayOutcome.GROUNDBALL_C,
hit_location='SS',
state=base_state,
defensive_decision=normal_defense
)
assert result.outs_recorded == 1
assert result.runs_scored == 0
# Runner advances to 2nd
r1_movement = next(m for m in result.movements if m.lineup_id == 2)
assert r1_movement.from_base == 1
assert r1_movement.to_base == 2
def test_runner_scores_from_third(self, advancement, base_state, normal_defense):
"""Runner on 3rd scores."""
base_state.current_on_base_code = 3
base_state.outs = 0
base_state.on_third = 2
base_state.is_runner_on_third = Mock(return_value=True)
result = advancement.advance_runners(
outcome=PlayOutcome.GROUNDBALL_C,
hit_location='SS',
state=base_state,
defensive_decision=normal_defense
)
assert result.outs_recorded == 1
assert result.runs_scored == 1
# Runner scores
r3_movement = next(m for m in result.movements if m.lineup_id == 2)
assert r3_movement.from_base == 3
assert r3_movement.to_base == 4
class TestResult5:
"""Tests for Result 5: Conditional on middle infield."""
def test_hit_to_middle_infield(self, advancement, base_state, normal_defense):
"""Hit to 2B/SS: runners advance."""
base_state.current_on_base_code = 3
base_state.outs = 0
base_state.on_third = 2
base_state.is_runner_on_third = Mock(return_value=True)
result = advancement.advance_runners(
outcome=PlayOutcome.GROUNDBALL_A,
hit_location='2B', # Middle infield
state=base_state,
defensive_decision=normal_defense
)
# Should advance runners (Result 3)
assert result.runs_scored == 1
def test_hit_to_corner(self, advancement, base_state, normal_defense):
"""Hit to corner: runners hold."""
base_state.current_on_base_code = 3
base_state.outs = 0
base_state.on_third = 2
base_state.is_runner_on_third = Mock(return_value=True)
result = advancement.advance_runners(
outcome=PlayOutcome.GROUNDBALL_B,
hit_location='3B', # Corner
state=base_state,
defensive_decision=normal_defense
)
# Should hold runners (Result 1)
assert result.runs_scored == 0
r3_movement = next(m for m in result.movements if m.lineup_id == 2)
assert r3_movement.to_base == 3 # Holds
class TestResult7:
"""Tests for Result 7: Forced advancement only."""
def test_bases_loaded_forced_advancement(self, advancement, base_state, infield_in_defense):
"""Bases loaded, Infield In, GBC: Result 7/8 - forced advancement only."""
base_state.current_on_base_code = 7 # Bases loaded
base_state.outs = 0
base_state.on_first = 2
base_state.on_second = 3
base_state.on_third = 4
base_state.is_runner_on_first = Mock(return_value=True)
base_state.is_runner_on_second = Mock(return_value=True)
base_state.is_runner_on_third = Mock(return_value=True)
result = advancement.advance_runners(
outcome=PlayOutcome.GROUNDBALL_C, # GBC
hit_location='P', # Not 1B/2B (would be Result 12)
state=base_state,
defensive_decision=infield_in_defense
)
# Result 11: Batter safe, lead runner out
assert result.result_type == GroundballResultType.BATTER_SAFE_LEAD_OUT
assert result.outs_recorded == 1
def test_runner_on_third_only_holds(self, advancement, base_state, infield_in_defense):
"""Runner on 3rd only (not forced): holds."""
base_state.current_on_base_code = 3
base_state.outs = 0
base_state.on_third = 2
base_state.is_runner_on_third = Mock(return_value=True)
result = advancement.advance_runners(
outcome=PlayOutcome.GROUNDBALL_A,
hit_location='SS',
state=base_state,
defensive_decision=infield_in_defense
)
assert result.runs_scored == 0
# R3 should hold (not forced)
r3_movement = next(m for m in result.movements if m.lineup_id == 2)
assert r3_movement.to_base == 3
class TestResult10:
"""Tests for Result 10: Double play home to first."""
@patch('app.core.runner_advancement.random.random', return_value=0.3)
def test_successful_dp_home_to_first(self, mock_random, advancement, base_state, infield_in_defense):
"""Successful DP: R3 out at home, batter out."""
base_state.current_on_base_code = 7
base_state.outs = 0
base_state.on_first = 2
base_state.on_second = 3
base_state.on_third = 4
base_state.is_runner_on_first = Mock(return_value=True)
base_state.is_runner_on_second = Mock(return_value=True)
base_state.is_runner_on_third = Mock(return_value=True)
result = advancement.advance_runners(
outcome=PlayOutcome.GROUNDBALL_A,
hit_location='SS',
state=base_state,
defensive_decision=infield_in_defense
)
assert result.outs_recorded == 2
assert result.runs_scored == 0
# R3 should be out
r3_movement = next(m for m in result.movements if m.lineup_id == 4)
assert r3_movement.is_out is True
# Batter should be out
batter_movement = next(m for m in result.movements if m.from_base == 0)
assert batter_movement.is_out is True
# Other runners should advance
r2_movement = next(m for m in result.movements if m.lineup_id == 3)
assert r2_movement.to_base == 3
r1_movement = next(m for m in result.movements if m.lineup_id == 2)
assert r1_movement.to_base == 2
class TestResult12:
"""Tests for Result 12: DECIDE opportunity."""
def test_hit_to_1b_simple_advancement(self, advancement, base_state, infield_in_defense):
"""Hit to 1B: Simple advancement (Result 3)."""
base_state.current_on_base_code = 3
base_state.outs = 0
base_state.on_third = 2
base_state.is_runner_on_third = Mock(return_value=True)
result = advancement.advance_runners(
outcome=PlayOutcome.GROUNDBALL_C,
hit_location='1B',
state=base_state,
defensive_decision=infield_in_defense
)
# Should be Result 3 behavior
assert result.runs_scored == 1
def test_hit_to_3b_runners_hold(self, advancement, base_state, infield_in_defense):
"""Hit to 3B: Runners hold (Result 1)."""
base_state.current_on_base_code = 3
base_state.outs = 0
base_state.on_third = 2
base_state.is_runner_on_third = Mock(return_value=True)
result = advancement.advance_runners(
outcome=PlayOutcome.GROUNDBALL_C,
hit_location='3B',
state=base_state,
defensive_decision=infield_in_defense
)
# Should be Result 1 behavior
assert result.runs_scored == 0
def test_hit_to_ss_decide_opportunity(self, advancement, base_state, infield_in_defense):
"""Hit to SS/P/C: DECIDE opportunity (conservative default)."""
base_state.current_on_base_code = 3
base_state.outs = 0
base_state.on_third = 2
base_state.is_runner_on_third = Mock(return_value=True)
result = advancement.advance_runners(
outcome=PlayOutcome.GROUNDBALL_C,
hit_location='SS',
state=base_state,
defensive_decision=infield_in_defense
)
# Conservative default: runners hold
assert result.result_type == GroundballResultType.DECIDE_OPPORTUNITY
assert result.runs_scored == 0
# ========================================
# Double Play Probability Tests
# ========================================
class TestDoublePlayProbability:
"""Tests for DP probability calculation."""
def test_base_probability(self, advancement, base_state, normal_defense):
"""Base probability is 45%."""
probability = advancement._calculate_double_play_probability(
state=base_state,
defensive_decision=normal_defense,
hit_location='SS'
)
# Base 45% + SS middle infield bonus 10% = 55%
assert probability == pytest.approx(0.55)
def test_corner_penalty(self, advancement, base_state, normal_defense):
"""Corner locations reduce DP probability by 10%."""
probability = advancement._calculate_double_play_probability(
state=base_state,
defensive_decision=normal_defense,
hit_location='1B' # Corner
)
# Base 45% - corner penalty 10% = 35%
assert probability == pytest.approx(0.35)
def test_infield_in_penalty(self, advancement, base_state, infield_in_defense):
"""Infield in subtracts 15%."""
probability = advancement._calculate_double_play_probability(
state=base_state,
defensive_decision=infield_in_defense,
hit_location='SS' # Middle (bonus 10%)
)
# Base 45% - infield in 15% + middle bonus 10% = 40%
assert probability == pytest.approx(0.40)
def test_probability_clamped_to_zero(self, advancement, base_state, infield_in_defense):
"""Probability can't go below 0%."""
probability = advancement._calculate_double_play_probability(
state=base_state,
defensive_decision=infield_in_defense,
hit_location='3B' # Corner (penalty 10%)
)
# Base 45% - infield in 15% - corner 10% = 20%
assert probability == pytest.approx(0.20)
assert probability >= 0.0
def test_probability_bounds(self, advancement, base_state, normal_defense, infield_in_defense):
"""Probability is always clamped between 0 and 1."""
# Test upper bound with middle infield bonus
prob_high = advancement._calculate_double_play_probability(
state=base_state,
defensive_decision=normal_defense,
hit_location='SS' # Middle (bonus 10%)
)
# Base 45% + middle 10% = 55%
assert prob_high == pytest.approx(0.55)
assert prob_high <= 1.0
# Test lower bound
prob_low = advancement._calculate_double_play_probability(
state=base_state,
defensive_decision=infield_in_defense,
hit_location='3B'
)
assert prob_low >= 0.0
# ========================================
# Edge Cases
# ========================================
class TestEdgeCases:
"""Tests for edge cases and special scenarios."""
def test_invalid_outcome_raises_error(self, advancement, base_state, normal_defense):
"""Non-groundball outcomes raise ValueError."""
with pytest.raises(ValueError, match="only handles groundballs"):
advancement.advance_runners(
outcome=PlayOutcome.HOMERUN,
hit_location='CF',
state=base_state,
defensive_decision=normal_defense
)
def test_all_groundball_types_supported(self, advancement, base_state, normal_defense):
"""All three groundball types work."""
base_state.current_on_base_code = 0
base_state.outs = 0
for outcome in [PlayOutcome.GROUNDBALL_A, PlayOutcome.GROUNDBALL_B, PlayOutcome.GROUNDBALL_C]:
result = advancement.advance_runners(
outcome=outcome,
hit_location='SS',
state=base_state,
defensive_decision=normal_defense
)
assert result is not None
assert isinstance(result, AdvancementResult)
def test_all_hit_locations_supported(self, advancement, base_state, normal_defense):
"""All hit locations work."""
base_state.current_on_base_code = 0
base_state.outs = 0
locations = ['1B', '2B', 'SS', '3B', 'P', 'C']
for location in locations:
result = advancement.advance_runners(
outcome=PlayOutcome.GROUNDBALL_A,
hit_location=location,
state=base_state,
defensive_decision=normal_defense
)
assert result is not None
def test_all_on_base_codes_supported(self, advancement, base_state, normal_defense):
"""All on-base codes (0-7) work."""
for code in range(8):
base_state.current_on_base_code = code
base_state.outs = 0
# Set up runner mocks based on code
base_state.is_runner_on_first = Mock(return_value=code in [1, 4, 5, 7])
base_state.is_runner_on_second = Mock(return_value=code in [2, 4, 6, 7])
base_state.is_runner_on_third = Mock(return_value=code in [3, 5, 6, 7])
if code in [1, 4, 5, 7]:
base_state.on_first = 2
if code in [2, 4, 6, 7]:
base_state.on_second = 3
if code in [3, 5, 6, 7]:
base_state.on_third = 4
result = advancement.advance_runners(
outcome=PlayOutcome.GROUNDBALL_A,
hit_location='SS',
state=base_state,
defensive_decision=normal_defense
)
assert result is not None