diff --git a/backend/app/core/play_resolver.py b/backend/app/core/play_resolver.py index 9b2ccf6..bc8e900 100644 --- a/backend/app/core/play_resolver.py +++ b/backend/app/core/play_resolver.py @@ -427,6 +427,24 @@ class PlayResolver: is_walk=True, ) + if outcome == PlayOutcome.HIT_BY_PITCH: + # HBP - identical to walk: batter to first, runners advance if forced + runners_advanced = self._advance_on_walk(state) + runs_scored = sum( + 1 for (from_base, to_base) in runners_advanced if to_base == 4 + ) + + return PlayResult( + outcome=outcome, + outs_recorded=0, + runs_scored=runs_scored, + batter_result=1, + runners_advanced=runners_advanced, + description="Hit by pitch", + ab_roll=ab_roll, + # Note: HBP is NOT classified as a walk for statistics purposes + ) + # ==================== Singles ==================== if outcome == PlayOutcome.SINGLE_1: # Single with standard advancement diff --git a/backend/tests/unit/core/test_play_resolver.py b/backend/tests/unit/core/test_play_resolver.py index b6f60ad..6456298 100644 --- a/backend/tests/unit/core/test_play_resolver.py +++ b/backend/tests/unit/core/test_play_resolver.py @@ -148,6 +148,71 @@ class TestResolveOutcome: assert result.runs_scored == 1 # Runner on 3rd forced home assert (3, 4) in result.runners_advanced + def test_hit_by_pitch(self): + """ + Test HBP (Hit By Pitch) resolution. + + HBP works identically to walk for runner advancement, but is NOT + classified as a walk for baseball statistics purposes. + """ + resolver = PlayResolver(league_id="sba", auto_mode=False) + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + current_batter=LineupPlayerState(lineup_id=1, card_id=100, position="CF", batting_order=1) + ) + ab_roll = create_mock_ab_roll(state.game_id) + + result = resolver.resolve_outcome( + outcome=PlayOutcome.HIT_BY_PITCH, + hit_location=None, + state=state, + defensive_decision=DefensiveDecision(), + offensive_decision=OffensiveDecision(), + ab_roll=ab_roll + ) + + assert result.outcome == PlayOutcome.HIT_BY_PITCH + assert result.outs_recorded == 0 + assert result.runs_scored == 0 + assert result.batter_result == 1 # Batter to first + assert result.is_walk is False # HBP is NOT classified as walk + assert result.is_hit is False + assert result.description == "Hit by pitch" + + def test_hit_by_pitch_bases_loaded(self): + """ + Test HBP with bases loaded forces run home. + + Same forced runner advancement logic as walk. + """ + resolver = PlayResolver(league_id="sba", auto_mode=False) + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + current_batter=LineupPlayerState(lineup_id=1, card_id=100, position="CF", batting_order=1), + on_first=LineupPlayerState(lineup_id=2, card_id=101, position="CF", batting_order=2), + on_second=LineupPlayerState(lineup_id=3, card_id=102, position="RF", batting_order=3), + on_third=LineupPlayerState(lineup_id=4, card_id=103, position="SS", batting_order=4) + ) + ab_roll = create_mock_ab_roll(state.game_id) + + result = resolver.resolve_outcome( + outcome=PlayOutcome.HIT_BY_PITCH, + hit_location=None, + state=state, + defensive_decision=DefensiveDecision(), + offensive_decision=OffensiveDecision(), + ab_roll=ab_roll + ) + + assert result.runs_scored == 1 # Runner on 3rd forced home + assert (3, 4) in result.runners_advanced + def test_groundball_uses_runner_advancement(self): """Test that groundballs delegate to RunnerAdvancement""" resolver = PlayResolver(league_id="sba", auto_mode=False)