From 1eda66a06c53d97610da9e106554a55393a8b961 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 10 Feb 2026 22:26:30 -0600 Subject: [PATCH] fix: preserve batter at plate when half-inning ends on caught stealing When a half-inning ended with a CS (or pickoff), the batter who was at the plate was incorrectly skipped in the next inning. The side-switch code unconditionally advanced the batting order by 1 without checking whether the last play was a plate appearance. Now checks opponent_play.pa before incrementing, matching the existing non-side-switch logic. Co-Authored-By: Claude Opus 4.6 --- VERSION | 2 +- command_logic/logic_gameplay.py | 2 +- tests/command_logic/test_logic_gameplay.py | 92 ++++++++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index f8e233b..9ab8337 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.9.0 +1.9.1 diff --git a/command_logic/logic_gameplay.py b/command_logic/logic_gameplay.py index b6a2ee0..0d947be 100644 --- a/command_logic/logic_gameplay.py +++ b/command_logic/logic_gameplay.py @@ -759,7 +759,7 @@ def complete_play(session: Session, this_play: Play): opponent_play = get_last_team_play( session, this_play.game, this_play.pitcher.team ) - nbo = opponent_play.batting_order + 1 + nbo = opponent_play.batting_order + 1 if opponent_play.pa == 1 else opponent_play.batting_order except PlayNotFoundException as e: logger.info( f"logic_gameplay - complete_play - No last play found for {this_play.pitcher.team.sname}, setting upcoming batting order to 1" diff --git a/tests/command_logic/test_logic_gameplay.py b/tests/command_logic/test_logic_gameplay.py index 9824138..6aa24c1 100644 --- a/tests/command_logic/test_logic_gameplay.py +++ b/tests/command_logic/test_logic_gameplay.py @@ -569,3 +569,95 @@ def test_pinch_runner_entry_and_scoring(session: Session): # Entry play should NOT be in this list (PA=0) assert entry_play not in pitcher_plays + + +async def test_cs_end_of_inning_preserves_batter(session: Session): + """ + Test that when a half-inning ends on a Caught Stealing, the batter who was + at the plate (but did not complete a plate appearance) leads off the next + time their team bats. + + Scenario: + Top 1 - Away team batting: + Batter 1 (bo=1): strikeout → 1 out + Batter 2 (bo=2): strikeout → 2 outs + Batter 3 (bo=3): single, reaches first + Batter 4 (bo=4): at the plate, runner caught stealing → 3 outs, side switch + + Bot 1 - Home team batting: + Batter 1 (bo=1): strikeout → 1 out + Batter 2 (bo=2): strikeout → 2 outs + Batter 3 (bo=3): strikeout → 3 outs, side switch + + Top 2 - Away team batting: + The next batter should be Batter 4 (bo=4), NOT Batter 5 (bo=5), + because Batter 4 never completed a plate appearance. + """ + this_game = session.get(Game, 3) + play = this_game.initialize_play(session) + + # --- TOP 1 --- + + # Batter 1 (bo=1): strikeout → 1 out + assert play.batting_order == 1 + assert play.inning_half == 'top' + play = await strikeouts(session, None, play) + play = complete_play(session, play) + + # Batter 2 (bo=2): strikeout → 2 outs + assert play.batting_order == 2 + assert play.starting_outs == 1 + play = await strikeouts(session, None, play) + play = complete_play(session, play) + + # Batter 3 (bo=3): single → runner on first + assert play.batting_order == 3 + assert play.starting_outs == 2 + play = await singles(session, None, play, '*') + play = complete_play(session, play) + + # Batter 4 (bo=4) is at the plate with runner on first + assert play.batting_order == 4 + assert play.starting_outs == 2 + assert play.on_first is not None + assert play.inning_half == 'top' + + # Runner on first is caught stealing → 3rd out, side switch + play = await steals(session, None, play, 'caught-stealing', to_base=2) + assert play.pa == 0 # CS is not a plate appearance + assert play.outs == 1 # CS records one out + play = complete_play(session, play) + + # Should now be bottom of inning 1 + assert play.inning_half == 'bot' + assert play.inning_num == 1 + assert play.is_new_inning is True + + # --- BOT 1 --- + + # Home batter 1 (bo=1): strikeout → 1 out + assert play.batting_order == 1 + play = await strikeouts(session, None, play) + play = complete_play(session, play) + + # Home batter 2 (bo=2): strikeout → 2 outs + assert play.batting_order == 2 + play = await strikeouts(session, None, play) + play = complete_play(session, play) + + # Home batter 3 (bo=3): strikeout → 3 outs, side switch + assert play.batting_order == 3 + play = await strikeouts(session, None, play) + play = complete_play(session, play) + + # --- TOP 2 --- + + # Should be top of inning 2, and batter 4 (bo=4) should lead off + # because they never completed a PA in the previous half-inning + assert play.inning_half == 'top' + assert play.inning_num == 2 + assert play.is_new_inning is True + assert play.batting_order == 4, ( + f"Expected batter 4 to lead off (never completed PA), " + f"but got batter {play.batting_order}" + )