This commit fixes two critical bugs in the game engine and updates tests
to match current webhook behavior:
1. State Recovery - Batter Advancement (operations.py:545-546)
- Added missing batting_order and outs_recorded fields to plays dictionary
- These fields exist in database but weren't loaded by load_game_state()
- Root cause: Batter index was correctly recovered but current_batter remained
at placeholder (batting_order=1) because recovery logic couldn't find the
actual batting_order from last play
- Fix enables proper batter advancement after backend restarts
2. Flyball Descriptions - 2 Outs Logic (runner_advancement.py)
- Made flyball descriptions dynamic based on outs and actual base runners
- FLYOUT_A (Deep): Lines 1352-1363
- FLYOUT_B (Medium): Lines 1438-1449
- FLYOUT_BQ (Medium-shallow): Lines 1521-1530
- With 2 outs: "3rd out, inning over" (no advancement possible)
- With 0-1 outs: Dynamic based on runners ("R3 scores" only if R3 exists)
- Game logic was already correct (runs_scored=0), only descriptions were wrong
- Fixed method signatures to include hit_location parameter for all flyball methods
- Updated X-check function calls to pass hit_location parameter
3. Test Updates
- Fixed test expectation in test_flyball_advancement.py to match corrected behavior
- Descriptions now only show runners that actually exist (no phantom "R2 DECIDE")
- Auto-fixed import ordering with Ruff
- Updated websocket tests to match current webhook behavior:
* test_submit_manual_outcome_success now expects 2 broadcasts (play_resolved + game_state_update)
* test_submit_manual_outcome_missing_required_location updated to reflect hit_location now optional
Testing:
- All 739 unit tests passing (100%)
- Verified batter advances correctly after state recovery
- Verified flyball with 2 outs shows correct description
- Verified dynamic descriptions only mention actual runners on base
- Verified websocket handler broadcasts work correctly
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
1837 lines
62 KiB
Python
1837 lines
62 KiB
Python
"""
|
|
Runner advancement logic for groundball and flyball outcomes.
|
|
|
|
This module implements the complete runner advancement system based on:
|
|
|
|
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
|
|
|
|
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
|
|
from dataclasses import dataclass
|
|
from enum import IntEnum
|
|
|
|
from app.config.result_charts import PlayOutcome
|
|
from app.models.game_models import DefensiveDecision, GameState
|
|
|
|
logger = logging.getLogger(f"{__name__}.RunnerAdvancement")
|
|
|
|
# pyright: reportOptionalMemberAccess=false
|
|
|
|
|
|
class GroundballResultType(IntEnum):
|
|
"""
|
|
Groundball advancement results matching rulebook chart.
|
|
|
|
These numbered results correspond exactly to the official rulebook
|
|
advancement charts (Infield Back and Infield In).
|
|
"""
|
|
|
|
BATTER_OUT_RUNNERS_HOLD = 1
|
|
"""Result 1: Batter out, all runners hold."""
|
|
|
|
DOUBLE_PLAY_AT_SECOND = 2
|
|
"""Result 2: Batter out, runner on 1st out at 2nd (double play on empty/1 out). Other runners advance 1 base."""
|
|
|
|
BATTER_OUT_RUNNERS_ADVANCE = 3
|
|
"""Result 3: Batter out, all runners advance 1 base."""
|
|
|
|
BATTER_SAFE_FORCE_OUT_AT_SECOND = 4
|
|
"""Result 4: Batter safe at 1st, runner on 1st forced out at 2nd. Other runners advance 1 base."""
|
|
|
|
CONDITIONAL_ON_MIDDLE_INFIELD = 5
|
|
"""Result 5: Hit to 2B/SS = batter out, runners advance 1 base. Hit anywhere else = batter out, runners hold."""
|
|
|
|
CONDITIONAL_ON_RIGHT_SIDE = 6
|
|
"""Result 6: Hit to 1B/2B = batter out, runners advance 1 base. Hit anywhere else = batter out, runners hold."""
|
|
|
|
BATTER_OUT_FORCED_ONLY = 7
|
|
"""Result 7: Batter out, runners advance 1 base (only if forced)."""
|
|
|
|
BATTER_OUT_FORCED_ONLY_ALT = 8
|
|
"""Result 8: Same as Result 7 (batter out, forced advancement only)."""
|
|
|
|
LEAD_HOLDS_TRAIL_ADVANCES = 9
|
|
"""Result 9: Batter out, runner on 3rd holds, runner on 1st advances to 2nd (bases loaded scenario)."""
|
|
|
|
DOUBLE_PLAY_HOME_TO_FIRST = 10
|
|
"""Result 10: Double play at home and 1st (runner on 3rd out at home, batter out). Otherwise batter out, runners advance."""
|
|
|
|
BATTER_SAFE_LEAD_OUT = 11
|
|
"""Result 11: Batter safe at 1st, lead runner is out. Other runners advance 1 base."""
|
|
|
|
DECIDE_OPPORTUNITY = 12
|
|
"""Result 12: DECIDE - Lead runner can attempt to advance. Offense chooses, defense responds."""
|
|
|
|
CONDITIONAL_DOUBLE_PLAY = 13
|
|
"""Result 13: Hit to C/3B = double play at 3rd and 2nd (batter safe). Hit anywhere else = same as Result 2."""
|
|
|
|
SAFE_ALL_ADVANCE_ONE = 14
|
|
"""Result 14: Batter safe at 1st, all runners advance 1 base (error E1)."""
|
|
|
|
SAFE_ALL_ADVANCE_TWO = 15
|
|
"""Result 15: Batter safe at 2nd, all runners advance 2 bases (error E2)."""
|
|
|
|
SAFE_ALL_ADVANCE_THREE = 16
|
|
"""Result 16: Batter safe at 3rd, all runners advance 3 bases (error E3)."""
|
|
|
|
|
|
@dataclass
|
|
class RunnerMovement:
|
|
"""
|
|
Represents the movement of a single runner during a play.
|
|
|
|
Attributes:
|
|
lineup_id: The lineup position ID of the runner (1-9)
|
|
from_base: Starting base (0=batter, 1-3=bases)
|
|
to_base: Ending base (0=out, 1-3=bases, 4=scored)
|
|
is_out: Whether the runner was thrown out
|
|
"""
|
|
|
|
lineup_id: int
|
|
from_base: int
|
|
to_base: int
|
|
is_out: bool = False
|
|
|
|
def __repr__(self) -> str:
|
|
if self.is_out:
|
|
return f"RunnerMovement(#{self.lineup_id}: {self.from_base}→OUT)"
|
|
if self.to_base == 4:
|
|
return f"RunnerMovement(#{self.lineup_id}: {self.from_base}→HOME)"
|
|
return f"RunnerMovement(#{self.lineup_id}: {self.from_base}→{self.to_base})"
|
|
|
|
|
|
@dataclass
|
|
class AdvancementResult:
|
|
"""
|
|
Complete result of a runner advancement resolution.
|
|
|
|
Attributes:
|
|
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 (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 | None
|
|
description: str
|
|
|
|
|
|
class RunnerAdvancement:
|
|
"""
|
|
Handles runner advancement logic for groundball and flyball outcomes.
|
|
|
|
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):
|
|
self.logger = logging.getLogger(f"{__name__}.RunnerAdvancement")
|
|
|
|
def advance_runners(
|
|
self,
|
|
outcome: PlayOutcome,
|
|
hit_location: str,
|
|
state: GameState,
|
|
defensive_decision: DefensiveDecision,
|
|
) -> AdvancementResult:
|
|
"""
|
|
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
|
|
if 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
|
|
)
|
|
|
|
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)
|
|
hit_location: Where the ball was hit (1B, 2B, SS, 3B, P, C)
|
|
state: Current game state
|
|
defensive_decision: Defensive team's positioning decisions
|
|
|
|
Returns:
|
|
AdvancementResult with all runner movements and outs
|
|
"""
|
|
|
|
# Determine which result to apply
|
|
result_type = self._determine_groundball_result(
|
|
outcome=outcome,
|
|
on_base_code=state.current_on_base_code,
|
|
starting_outs=state.outs,
|
|
defensive_decision=defensive_decision,
|
|
hit_location=hit_location,
|
|
)
|
|
|
|
self.logger.info(
|
|
f"Groundball {outcome.name} with bases {state.current_on_base_code}, "
|
|
f"{state.outs} outs → Result {result_type}"
|
|
)
|
|
|
|
# Execute the result
|
|
return self._execute_result(
|
|
result_type=result_type,
|
|
state=state,
|
|
hit_location=hit_location,
|
|
defensive_decision=defensive_decision,
|
|
)
|
|
|
|
def _determine_groundball_result(
|
|
self,
|
|
outcome: PlayOutcome,
|
|
on_base_code: int,
|
|
starting_outs: int,
|
|
defensive_decision: DefensiveDecision,
|
|
hit_location: str,
|
|
) -> GroundballResultType:
|
|
"""
|
|
Determine which groundball result applies based on situation.
|
|
|
|
This implements the logic from both Infield Back and Infield In charts.
|
|
|
|
Args:
|
|
outcome: GROUNDBALL_A, B, or C
|
|
on_base_code: 0-7 representing base runners (0=empty, 7=loaded)
|
|
starting_outs: Number of outs before play (0-2)
|
|
defensive_decision: Defensive positioning
|
|
hit_location: Where ball was hit
|
|
|
|
Returns:
|
|
GroundballResultType (1-13)
|
|
"""
|
|
# Special case: 2 outs always results in batter out, runners hold
|
|
if starting_outs == 2:
|
|
return GroundballResultType.BATTER_OUT_RUNNERS_HOLD
|
|
|
|
# Determine if playing in or corners in
|
|
infield_in = defensive_decision.infield_depth == "infield_in"
|
|
corners_in = defensive_decision.infield_depth == "corners_in"
|
|
|
|
# Map hit location to position groups
|
|
hit_to_mif = hit_location in ["2B", "SS"] # Middle infield
|
|
hit_to_cif = hit_location in ["1B", "3B", "P", "C"] # Corner infield
|
|
hit_to_right = hit_location in ["1B", "2B"] # Right side
|
|
|
|
# Convert outcome to letter
|
|
gb_letter = outcome.name.split("_")[1] # 'GROUNDBALL_A' → 'A'
|
|
|
|
# ========================================
|
|
# INFIELD IN CHART (Runner on 3rd scenarios)
|
|
# ========================================
|
|
if infield_in and on_base_code in [3, 5, 6, 7]:
|
|
return self._apply_infield_in_chart(
|
|
gb_letter=gb_letter,
|
|
on_base_code=on_base_code,
|
|
hit_location=hit_location,
|
|
hit_to_mif=hit_to_mif,
|
|
hit_to_cif=hit_to_cif,
|
|
)
|
|
|
|
# ========================================
|
|
# CORNERS IN (Hybrid approach)
|
|
# ========================================
|
|
if corners_in and on_base_code in [3, 5, 6, 7] and hit_to_cif:
|
|
# Apply Infield In rules for corners
|
|
return self._apply_infield_in_chart(
|
|
gb_letter=gb_letter,
|
|
on_base_code=on_base_code,
|
|
hit_location=hit_location,
|
|
hit_to_mif=hit_to_mif,
|
|
hit_to_cif=hit_to_cif,
|
|
)
|
|
|
|
# ========================================
|
|
# INFIELD BACK CHART (Default positioning)
|
|
# ========================================
|
|
return self._apply_infield_back_chart(
|
|
gb_letter=gb_letter,
|
|
on_base_code=on_base_code,
|
|
hit_location=hit_location,
|
|
hit_to_mif=hit_to_mif,
|
|
hit_to_right=hit_to_right,
|
|
)
|
|
|
|
def _apply_infield_in_chart(
|
|
self,
|
|
gb_letter: str,
|
|
on_base_code: int,
|
|
hit_location: str,
|
|
hit_to_mif: bool,
|
|
hit_to_cif: bool,
|
|
) -> GroundballResultType:
|
|
"""
|
|
Apply Infield In chart logic.
|
|
|
|
Chart reference:
|
|
- 3rd only: GBA=7, GBB=1, GBC=8
|
|
- 1st & 3rd: GBA=7, GBB=9, GBC=8, with DECIDE for GBC hit to SS/P/C
|
|
- 2nd & 3rd: GBA=7, GBB=1, GBC=8
|
|
- Loaded: GBA=10, GBB=11, GBC=11
|
|
"""
|
|
# Bases loaded (on_base_code == 7)
|
|
if on_base_code == 7:
|
|
if gb_letter == "A":
|
|
return GroundballResultType.DOUBLE_PLAY_HOME_TO_FIRST # 10
|
|
# B or C
|
|
return GroundballResultType.BATTER_SAFE_LEAD_OUT # 11
|
|
|
|
# 1st & 3rd (on_base_code == 5)
|
|
if on_base_code == 5:
|
|
if gb_letter == "A":
|
|
return GroundballResultType.BATTER_OUT_FORCED_ONLY # 7
|
|
if gb_letter == "B":
|
|
return GroundballResultType.LEAD_HOLDS_TRAIL_ADVANCES # 9
|
|
# C
|
|
# Check for DECIDE opportunity
|
|
if hit_location in ["SS", "P", "C"]:
|
|
return GroundballResultType.DECIDE_OPPORTUNITY # 12
|
|
return GroundballResultType.BATTER_OUT_FORCED_ONLY_ALT # 8
|
|
|
|
# 2nd & 3rd (on_base_code == 6)
|
|
if on_base_code == 6:
|
|
if gb_letter == "A":
|
|
return GroundballResultType.BATTER_OUT_FORCED_ONLY # 7
|
|
if gb_letter == "B":
|
|
return GroundballResultType.BATTER_OUT_RUNNERS_HOLD # 1
|
|
# C
|
|
return GroundballResultType.BATTER_OUT_FORCED_ONLY_ALT # 8
|
|
|
|
# 3rd only (on_base_code == 3)
|
|
if on_base_code == 3:
|
|
if gb_letter == "A":
|
|
return GroundballResultType.BATTER_OUT_FORCED_ONLY # 7
|
|
if gb_letter == "B":
|
|
return GroundballResultType.BATTER_OUT_RUNNERS_HOLD # 1
|
|
# C
|
|
# Check for DECIDE on certain hit locations
|
|
if hit_location in ["1B", "2B"]:
|
|
return GroundballResultType.DECIDE_OPPORTUNITY # 12
|
|
if hit_location == "3B":
|
|
return GroundballResultType.BATTER_OUT_RUNNERS_HOLD # 1
|
|
# SS, P, C
|
|
return GroundballResultType.DECIDE_OPPORTUNITY # 12
|
|
|
|
# Fallback (shouldn't reach here)
|
|
self.logger.warning(
|
|
f"Unexpected Infield In scenario: bases={on_base_code}, letter={gb_letter}"
|
|
)
|
|
return GroundballResultType.BATTER_OUT_RUNNERS_HOLD
|
|
|
|
def _apply_infield_back_chart(
|
|
self,
|
|
gb_letter: str,
|
|
on_base_code: int,
|
|
hit_location: str,
|
|
hit_to_mif: bool,
|
|
hit_to_right: bool,
|
|
) -> GroundballResultType:
|
|
"""
|
|
Apply Infield Back chart logic (default positioning).
|
|
|
|
Chart reference:
|
|
- Empty: GBA=1, GBB=1, GBC=1 (all batter out)
|
|
- 1st: GBA=2, GBB=4, GBC=3
|
|
- 2nd: GBA=6, GBB=6, GBC=3 (conditional on right side)
|
|
- 3rd: GBA=5, GBB=5, GBC=3 (conditional on middle infield)
|
|
- 1st & 2nd: GBA=2, GBB=4, GBC=3
|
|
- 1st & 3rd: GBA=2, GBB=4, GBC=3
|
|
- 2nd & 3rd: GBA=5, GBB=5, GBC=3
|
|
- Loaded: GBA=2, GBB=4, GBC=3
|
|
"""
|
|
# Empty bases (on_base_code == 0)
|
|
if on_base_code == 0:
|
|
return GroundballResultType.BATTER_OUT_RUNNERS_HOLD # 1
|
|
|
|
# Runner on 1st (includes 1, 4, 5, 7 - any scenario with runner on 1st)
|
|
if on_base_code in [1, 4, 5, 7]:
|
|
if gb_letter == "A":
|
|
return GroundballResultType.DOUBLE_PLAY_AT_SECOND # 2
|
|
if gb_letter == "B":
|
|
return GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND # 4
|
|
# C
|
|
return GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE # 3
|
|
|
|
# Runner on 2nd only (on_base_code == 2)
|
|
if on_base_code == 2:
|
|
if gb_letter in ["A", "B"]:
|
|
return GroundballResultType.CONDITIONAL_ON_RIGHT_SIDE # 6
|
|
# C
|
|
return GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE # 3
|
|
|
|
# Runner on 3rd (includes 3, 6 - scenarios with runner on 3rd but not 1st)
|
|
if on_base_code in [3, 6]:
|
|
if gb_letter in ["A", "B"]:
|
|
return GroundballResultType.CONDITIONAL_ON_MIDDLE_INFIELD # 5
|
|
# C
|
|
return GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE # 3
|
|
|
|
# Fallback
|
|
self.logger.warning(
|
|
f"Unexpected Infield Back scenario: bases={on_base_code}, letter={gb_letter}"
|
|
)
|
|
return GroundballResultType.BATTER_OUT_RUNNERS_HOLD
|
|
|
|
def _execute_result(
|
|
self,
|
|
result_type: GroundballResultType,
|
|
state: GameState,
|
|
hit_location: str,
|
|
defensive_decision: DefensiveDecision | None = None,
|
|
) -> AdvancementResult:
|
|
"""
|
|
Execute a specific groundball result and return movements.
|
|
|
|
Args:
|
|
result_type: The result type (1-13)
|
|
state: Current game state
|
|
hit_location: Where ball was hit
|
|
defensive_decision: Defensive positioning (for DP probability)
|
|
|
|
Returns:
|
|
AdvancementResult with all movements
|
|
"""
|
|
# Dispatch to appropriate handler
|
|
if result_type == GroundballResultType.BATTER_OUT_RUNNERS_HOLD:
|
|
return self._gb_result_1(state)
|
|
if result_type == GroundballResultType.DOUBLE_PLAY_AT_SECOND:
|
|
return self._gb_result_2(state, defensive_decision, hit_location)
|
|
if result_type == GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE:
|
|
return self._gb_result_3(state)
|
|
if result_type == GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND:
|
|
return self._gb_result_4(state)
|
|
if result_type == GroundballResultType.CONDITIONAL_ON_MIDDLE_INFIELD:
|
|
return self._gb_result_5(state, hit_location)
|
|
if result_type == GroundballResultType.CONDITIONAL_ON_RIGHT_SIDE:
|
|
return self._gb_result_6(state, hit_location)
|
|
if result_type in [
|
|
GroundballResultType.BATTER_OUT_FORCED_ONLY,
|
|
GroundballResultType.BATTER_OUT_FORCED_ONLY_ALT,
|
|
]:
|
|
return self._gb_result_7(state)
|
|
if result_type == GroundballResultType.LEAD_HOLDS_TRAIL_ADVANCES:
|
|
return self._gb_result_9(state)
|
|
if result_type == GroundballResultType.DOUBLE_PLAY_HOME_TO_FIRST:
|
|
return self._gb_result_10(state, defensive_decision, hit_location)
|
|
if result_type == GroundballResultType.BATTER_SAFE_LEAD_OUT:
|
|
return self._gb_result_11(state)
|
|
if result_type == GroundballResultType.DECIDE_OPPORTUNITY:
|
|
return self._gb_result_12(state, hit_location)
|
|
if result_type == GroundballResultType.CONDITIONAL_DOUBLE_PLAY:
|
|
return self._gb_result_13(state, defensive_decision, hit_location)
|
|
raise ValueError(f"Unknown result type: {result_type}")
|
|
|
|
# ========================================
|
|
# Result Handlers (1-13)
|
|
# ========================================
|
|
|
|
def _gb_result_1(self, state: GameState) -> AdvancementResult:
|
|
"""
|
|
Result 1: Batter out, all runners hold.
|
|
"""
|
|
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 stay put
|
|
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=GroundballResultType.BATTER_OUT_RUNNERS_HOLD,
|
|
description="Batter out, runners hold",
|
|
)
|
|
|
|
def _gb_result_2(
|
|
self,
|
|
state: GameState,
|
|
defensive_decision: DefensiveDecision | None,
|
|
hit_location: str,
|
|
) -> AdvancementResult:
|
|
"""
|
|
Result 2: Double play at 2nd and 1st (when possible).
|
|
|
|
- With 0 or 1 out: Runner on 1st out at 2nd, batter out (DP)
|
|
- With 2 outs: Only batter out
|
|
- Other runners advance 1 base
|
|
"""
|
|
movements = []
|
|
outs = 0
|
|
runs = 0
|
|
|
|
# Check if double play is possible
|
|
can_turn_dp = state.outs < 2 and state.is_runner_on_first()
|
|
|
|
if can_turn_dp:
|
|
# Runner on first out at second
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.on_first.lineup_id,
|
|
from_base=1,
|
|
to_base=0,
|
|
is_out=True,
|
|
)
|
|
)
|
|
outs += 1
|
|
|
|
# Batter out at first
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.current_batter.lineup_id,
|
|
from_base=0,
|
|
to_base=0,
|
|
is_out=True,
|
|
)
|
|
)
|
|
outs += 1
|
|
|
|
description = "Double play: Runner out at 2nd, batter out at 1st"
|
|
else:
|
|
# Can't turn DP, just batter out
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.current_batter.lineup_id,
|
|
from_base=0,
|
|
to_base=0,
|
|
is_out=True,
|
|
)
|
|
)
|
|
outs += 1
|
|
description = "Batter out"
|
|
|
|
# Other runners advance if play doesn't end inning
|
|
if state.outs + outs < 3:
|
|
if state.is_runner_on_second():
|
|
# Runner scores from second
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.on_second.lineup_id,
|
|
from_base=2,
|
|
to_base=4,
|
|
is_out=False,
|
|
)
|
|
)
|
|
runs += 1
|
|
|
|
if state.is_runner_on_third():
|
|
# Runner scores from third
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.on_third.lineup_id,
|
|
from_base=3,
|
|
to_base=4,
|
|
is_out=False,
|
|
)
|
|
)
|
|
runs += 1
|
|
|
|
return AdvancementResult(
|
|
movements=movements,
|
|
outs_recorded=outs,
|
|
runs_scored=runs,
|
|
result_type=GroundballResultType.DOUBLE_PLAY_AT_SECOND,
|
|
description=description,
|
|
)
|
|
|
|
def _gb_result_3(self, state: GameState) -> AdvancementResult:
|
|
"""
|
|
Result 3: Batter out, all runners advance 1 base.
|
|
"""
|
|
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 advance 1 base (if less than 2 outs after play)
|
|
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,
|
|
from_base=1,
|
|
to_base=2,
|
|
is_out=False,
|
|
)
|
|
)
|
|
|
|
if state.is_runner_on_second():
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.on_second.lineup_id, # type: ignore
|
|
from_base=2,
|
|
to_base=3,
|
|
is_out=False,
|
|
)
|
|
)
|
|
|
|
if state.is_runner_on_third():
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.on_third.lineup_id,
|
|
from_base=3,
|
|
to_base=4,
|
|
is_out=False,
|
|
)
|
|
)
|
|
runs += 1
|
|
|
|
return AdvancementResult(
|
|
movements=movements,
|
|
outs_recorded=1,
|
|
runs_scored=runs,
|
|
result_type=GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE,
|
|
description="Batter out, runners advance 1 base",
|
|
)
|
|
|
|
def _gb_result_4(self, state: GameState) -> AdvancementResult:
|
|
"""
|
|
Result 4: Batter safe at 1st, runner on 1st forced out at 2nd.
|
|
Other runners advance 1 base.
|
|
"""
|
|
movements = []
|
|
runs = 0
|
|
|
|
# Runner on first forced out at second
|
|
if state.is_runner_on_first():
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.on_first.lineup_id,
|
|
from_base=1,
|
|
to_base=0,
|
|
is_out=True,
|
|
)
|
|
)
|
|
|
|
# Batter safe at first
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.current_batter.lineup_id,
|
|
from_base=0,
|
|
to_base=1,
|
|
is_out=False,
|
|
)
|
|
)
|
|
|
|
# Other runners advance if play doesn't end inning
|
|
if state.outs < 2:
|
|
if state.is_runner_on_second():
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.on_second.lineup_id,
|
|
from_base=2,
|
|
to_base=3,
|
|
is_out=False,
|
|
)
|
|
)
|
|
|
|
if state.is_runner_on_third():
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.on_third.lineup_id,
|
|
from_base=3,
|
|
to_base=4,
|
|
is_out=False,
|
|
)
|
|
)
|
|
runs += 1
|
|
|
|
return AdvancementResult(
|
|
movements=movements,
|
|
outs_recorded=1,
|
|
runs_scored=runs,
|
|
result_type=GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND,
|
|
description="Batter safe, force out at 2nd, other runners advance",
|
|
)
|
|
|
|
def _gb_result_5(self, state: GameState, hit_location: str) -> AdvancementResult:
|
|
"""
|
|
Result 5: Conditional on middle infield (2B/SS).
|
|
|
|
- Hit to 2B/SS: Batter out, runners advance 1 base (Result 3)
|
|
- Hit anywhere else: Batter out, runners hold (Result 1)
|
|
"""
|
|
hit_to_mif = hit_location in ["2B", "SS"]
|
|
|
|
if hit_to_mif:
|
|
return self._gb_result_3(state)
|
|
return self._gb_result_1(state)
|
|
|
|
def _gb_result_6(self, state: GameState, hit_location: str) -> AdvancementResult:
|
|
"""
|
|
Result 6: Conditional on right side (1B/2B).
|
|
|
|
- Hit to 1B/2B: Batter out, runners advance 1 base (Result 3)
|
|
- Hit anywhere else: Batter out, runners hold (Result 1)
|
|
"""
|
|
hit_to_right = hit_location in ["1B", "2B"]
|
|
|
|
if hit_to_right:
|
|
return self._gb_result_3(state)
|
|
return self._gb_result_1(state)
|
|
|
|
def _gb_result_7(self, state: GameState) -> AdvancementResult:
|
|
"""
|
|
Result 7/8: Batter out, runners advance only if forced.
|
|
"""
|
|
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,
|
|
)
|
|
)
|
|
|
|
# Check forced runners (only if play doesn't end inning)
|
|
if state.outs < 2:
|
|
# Runner on 3rd advances only if forced (bases loaded)
|
|
if state.is_runner_on_third():
|
|
forced = state.is_runner_on_first() and state.is_runner_on_second()
|
|
if forced:
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.on_third.lineup_id,
|
|
from_base=3,
|
|
to_base=4,
|
|
is_out=False,
|
|
)
|
|
)
|
|
runs += 1
|
|
else:
|
|
# Holds
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.on_third.lineup_id,
|
|
from_base=3,
|
|
to_base=3,
|
|
is_out=False,
|
|
)
|
|
)
|
|
|
|
# Runner on 2nd advances only if forced (1st and 2nd occupied)
|
|
if state.is_runner_on_second():
|
|
forced = state.is_runner_on_first()
|
|
if forced:
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.on_second.lineup_id,
|
|
from_base=2,
|
|
to_base=3,
|
|
is_out=False,
|
|
)
|
|
)
|
|
else:
|
|
# Holds
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.on_second.lineup_id,
|
|
from_base=2,
|
|
to_base=2,
|
|
is_out=False,
|
|
)
|
|
)
|
|
|
|
# Runner on 1st always forced
|
|
if state.is_runner_on_first():
|
|
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=GroundballResultType.BATTER_OUT_FORCED_ONLY,
|
|
description="Batter out, forced runners advance",
|
|
)
|
|
|
|
def _gb_result_9(self, state: GameState) -> AdvancementResult:
|
|
"""
|
|
Result 9: Batter out, runner on 3rd holds, runner on 1st advances to 2nd.
|
|
|
|
Specific to 1st & 3rd situation with Infield In positioning.
|
|
"""
|
|
movements = []
|
|
|
|
# Batter is out
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.current_batter.lineup_id,
|
|
from_base=0,
|
|
to_base=0,
|
|
is_out=True,
|
|
)
|
|
)
|
|
|
|
# Runner on 3rd holds
|
|
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,
|
|
)
|
|
)
|
|
|
|
# Runner on 1st advances to 2nd
|
|
if state.is_runner_on_first():
|
|
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=0,
|
|
result_type=GroundballResultType.LEAD_HOLDS_TRAIL_ADVANCES,
|
|
description="Batter out, R3 holds, R1 to 2nd",
|
|
)
|
|
|
|
def _gb_result_10(
|
|
self,
|
|
state: GameState,
|
|
defensive_decision: DefensiveDecision | None,
|
|
hit_location: str,
|
|
) -> AdvancementResult:
|
|
"""
|
|
Result 10: Double play at home and 1st (bases loaded).
|
|
|
|
- With 0 or 1 out: Runner on 3rd out at home, batter out (DP)
|
|
- With 2 outs: Only batter out
|
|
- Runners on 2nd and 1st advance
|
|
"""
|
|
movements = []
|
|
outs = 0
|
|
runs = 0
|
|
|
|
# Check if double play is possible
|
|
can_turn_dp = state.outs < 2
|
|
|
|
if can_turn_dp:
|
|
# Runner on third out at home
|
|
if state.is_runner_on_third():
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.on_third.lineup_id,
|
|
from_base=3,
|
|
to_base=0,
|
|
is_out=True,
|
|
)
|
|
)
|
|
outs += 1
|
|
|
|
# Batter out at first
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.current_batter.lineup_id,
|
|
from_base=0,
|
|
to_base=0,
|
|
is_out=True,
|
|
)
|
|
)
|
|
outs += 1
|
|
|
|
description = "Double play: Runner out at home, batter out at 1st"
|
|
else:
|
|
# Can't turn DP, just batter out
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.current_batter.lineup_id,
|
|
from_base=0,
|
|
to_base=0,
|
|
is_out=True,
|
|
)
|
|
)
|
|
outs += 1
|
|
description = "Batter out"
|
|
|
|
# Other runners advance if play doesn't end inning
|
|
if state.outs + outs < 3:
|
|
if state.is_runner_on_second():
|
|
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():
|
|
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=outs,
|
|
runs_scored=runs,
|
|
result_type=GroundballResultType.DOUBLE_PLAY_HOME_TO_FIRST,
|
|
description=description,
|
|
)
|
|
|
|
def _gb_result_11(self, state: GameState) -> AdvancementResult:
|
|
"""
|
|
Result 11: Batter safe at 1st, lead runner is out.
|
|
Other runners advance 1 base.
|
|
|
|
Used for bases loaded scenarios with Infield In.
|
|
"""
|
|
movements = []
|
|
runs = 0
|
|
|
|
# Lead runner is out (highest base)
|
|
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,
|
|
from_base=3,
|
|
to_base=0,
|
|
is_out=True,
|
|
)
|
|
)
|
|
elif state.is_runner_on_second():
|
|
# Runner on 2nd is lead runner
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.on_second.lineup_id,
|
|
from_base=2,
|
|
to_base=0,
|
|
is_out=True,
|
|
)
|
|
)
|
|
elif state.is_runner_on_first():
|
|
# Runner on 1st is lead runner
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.on_first.lineup_id,
|
|
from_base=1,
|
|
to_base=0,
|
|
is_out=True,
|
|
)
|
|
)
|
|
|
|
# Batter safe at first
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.current_batter.lineup_id,
|
|
from_base=0,
|
|
to_base=1,
|
|
is_out=False,
|
|
)
|
|
)
|
|
|
|
# Other runners advance if play doesn't end inning
|
|
if state.outs < 2:
|
|
# 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,
|
|
from_base=2,
|
|
to_base=3,
|
|
is_out=False,
|
|
)
|
|
)
|
|
|
|
# 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,
|
|
from_base=1,
|
|
to_base=2,
|
|
is_out=False,
|
|
)
|
|
)
|
|
|
|
return AdvancementResult(
|
|
movements=movements,
|
|
outs_recorded=1,
|
|
runs_scored=runs,
|
|
result_type=GroundballResultType.BATTER_SAFE_LEAD_OUT,
|
|
description="Batter safe, lead runner out, others advance",
|
|
)
|
|
|
|
def _gb_result_12(self, state: GameState, hit_location: str) -> AdvancementResult:
|
|
"""
|
|
Result 12: DECIDE opportunity.
|
|
|
|
Lead runner can attempt to advance a base. Offense chooses whether to attempt.
|
|
If they attempt, defense can take sure out at 1st OR try to throw lead runner out.
|
|
|
|
NOTE: This is a simplified implementation. Full DECIDE mechanic requires
|
|
interactive decision-making from offense/defense, which will be handled
|
|
by the game engine / WebSocket layer.
|
|
|
|
For now, this returns a result indicating DECIDE opportunity exists,
|
|
and the actual decision-making will be handled at a higher level.
|
|
"""
|
|
movements = []
|
|
|
|
# Hit to 1B/2B: Simple advancement
|
|
if hit_location in ["1B", "2B"]:
|
|
return self._gb_result_3(state)
|
|
|
|
# Hit to 3B: Runners hold
|
|
if hit_location == "3B":
|
|
return self._gb_result_1(state)
|
|
|
|
# Hit to SS/P/C: DECIDE opportunity
|
|
# For now, default to conservative play (batter out, runners hold)
|
|
# TODO: This needs to be interactive in the game engine
|
|
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.current_batter.lineup_id,
|
|
from_base=0,
|
|
to_base=0,
|
|
is_out=True,
|
|
)
|
|
)
|
|
|
|
# Hold all runners by default
|
|
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=GroundballResultType.DECIDE_OPPORTUNITY,
|
|
description="DECIDE opportunity (conservative: batter out, runners hold)",
|
|
)
|
|
|
|
def _gb_result_13(
|
|
self,
|
|
state: GameState,
|
|
defensive_decision: DefensiveDecision | None,
|
|
hit_location: str,
|
|
) -> AdvancementResult:
|
|
"""
|
|
Result 13: Conditional double play.
|
|
|
|
- Hit to C/3B: Double play at 3rd and 2nd base, batter safe
|
|
- Hit anywhere else: Same as Result 2 (double play at 2nd and 1st)
|
|
"""
|
|
hit_to_c_or_3b = hit_location in ["C", "3B"]
|
|
|
|
if hit_to_c_or_3b:
|
|
movements = []
|
|
outs = 0
|
|
|
|
# Check if DP is possible
|
|
can_turn_dp = state.outs < 2
|
|
|
|
if can_turn_dp:
|
|
# Runner on 3rd out
|
|
if state.is_runner_on_third():
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.on_third.lineup_id,
|
|
from_base=3,
|
|
to_base=0,
|
|
is_out=True,
|
|
)
|
|
)
|
|
outs += 1
|
|
|
|
# Runner on 2nd out
|
|
if state.is_runner_on_second():
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.on_second.lineup_id,
|
|
from_base=2,
|
|
to_base=0,
|
|
is_out=True,
|
|
)
|
|
)
|
|
outs += 1
|
|
|
|
# Batter safe at first
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.current_batter.lineup_id,
|
|
from_base=0,
|
|
to_base=1,
|
|
is_out=False,
|
|
)
|
|
)
|
|
|
|
description = "Double play at 3rd and 2nd, batter safe"
|
|
else:
|
|
# Can't turn DP
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=state.current_batter.lineup_id,
|
|
from_base=0,
|
|
to_base=0,
|
|
is_out=True,
|
|
)
|
|
)
|
|
outs += 1
|
|
description = "Batter out"
|
|
|
|
return AdvancementResult(
|
|
movements=movements,
|
|
outs_recorded=outs,
|
|
runs_scored=0,
|
|
result_type=GroundballResultType.CONDITIONAL_DOUBLE_PLAY,
|
|
description=description,
|
|
)
|
|
# 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, hit_location)
|
|
if outcome == PlayOutcome.FLYOUT_B:
|
|
return self._fb_result_medium(state, hit_location)
|
|
if outcome == PlayOutcome.FLYOUT_BQ:
|
|
return self._fb_result_bq(state, hit_location)
|
|
if outcome == PlayOutcome.FLYOUT_C:
|
|
return self._fb_result_shallow(state, hit_location)
|
|
raise ValueError(f"Unknown flyball outcome: {outcome}")
|
|
|
|
# ========================================
|
|
# Flyball Result Handlers
|
|
# ========================================
|
|
|
|
def _fb_result_deep(self, state: GameState, hit_location: str) -> 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,
|
|
)
|
|
)
|
|
|
|
# Build dynamic description based on outs and actual results
|
|
if state.outs >= 2:
|
|
desc = f"Deep flyball to {hit_location} - 3rd out, inning over"
|
|
else:
|
|
parts = [f"Deep flyball to {hit_location}"]
|
|
if runs > 0:
|
|
parts.append("R3 scores")
|
|
if state.is_runner_on_second():
|
|
parts.append("R2→3B")
|
|
if state.is_runner_on_first():
|
|
parts.append("R1→2B")
|
|
desc = " - ".join(parts) if len(parts) > 1 else parts[0] + " - all runners tag"
|
|
|
|
return AdvancementResult(
|
|
movements=movements,
|
|
outs_recorded=1,
|
|
runs_scored=runs,
|
|
result_type=None, # Flyballs don't use result types
|
|
description=desc,
|
|
)
|
|
|
|
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,
|
|
)
|
|
)
|
|
|
|
# Build dynamic description based on outs and actual results
|
|
if state.outs >= 2:
|
|
desc = f"Medium flyball to {hit_location} - 3rd out, inning over"
|
|
else:
|
|
parts = [f"Medium flyball to {hit_location}"]
|
|
if runs > 0:
|
|
parts.append("R3 scores")
|
|
if state.is_runner_on_second():
|
|
parts.append("R2 DECIDE (held)")
|
|
if state.is_runner_on_first():
|
|
parts.append("R1 holds")
|
|
desc = " - ".join(parts) if len(parts) > 1 else parts[0]
|
|
|
|
return AdvancementResult(
|
|
movements=movements,
|
|
outs_recorded=1,
|
|
runs_scored=runs,
|
|
result_type=None, # Flyballs don't use result types
|
|
description=desc,
|
|
)
|
|
|
|
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,
|
|
)
|
|
)
|
|
|
|
# Build dynamic description based on outs and actual results
|
|
if state.outs >= 2:
|
|
desc = f"Medium-shallow flyball to {hit_location} - 3rd out, inning over"
|
|
else:
|
|
parts = [f"Medium-shallow flyball to {hit_location}"]
|
|
if state.is_runner_on_third():
|
|
parts.append("R3 DECIDE (held)")
|
|
if state.is_runner_on_second() or state.is_runner_on_first():
|
|
parts.append("all others hold")
|
|
desc = " - ".join(parts) if len(parts) > 1 else parts[0]
|
|
|
|
return AdvancementResult(
|
|
movements=movements,
|
|
outs_recorded=1,
|
|
runs_scored=runs,
|
|
result_type=None, # Flyballs don't use result types
|
|
description=desc,
|
|
)
|
|
|
|
def _fb_result_shallow(self, state: GameState, hit_location: str) -> 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",
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# X-CHECK RUNNER ADVANCEMENT (Placeholders - to be implemented in Phase 3D)
|
|
# ============================================================================
|
|
|
|
|
|
def x_check_g1(
|
|
on_base_code: int,
|
|
defender_in: bool,
|
|
error_result: str,
|
|
state: GameState,
|
|
hit_location: str,
|
|
defensive_decision: DefensiveDecision,
|
|
) -> AdvancementResult:
|
|
"""
|
|
Runner advancement for X-Check G1 result.
|
|
|
|
Uses G1 advancement table to get GroundballResultType based on
|
|
base situation, defensive positioning, and error result.
|
|
|
|
Args:
|
|
on_base_code: Current base situation code (0-7 bit field)
|
|
defender_in: Is the defender playing in?
|
|
error_result: 'NO', 'E1', 'E2', 'E3', 'RP'
|
|
state: Current game state (for lineup IDs, outs, etc.)
|
|
hit_location: Where the ball was hit (1B, 2B, SS, 3B, P, C)
|
|
defensive_decision: Defensive positioning decision
|
|
|
|
Returns:
|
|
AdvancementResult with runner movements
|
|
"""
|
|
from app.core.x_check_advancement_tables import (
|
|
build_advancement_from_code,
|
|
get_groundball_advancement,
|
|
)
|
|
|
|
# Lookup groundball result type from table
|
|
gb_type = get_groundball_advancement("G1", on_base_code, defender_in, error_result)
|
|
|
|
# If error result: use simple error advancement (doesn't need GameState details)
|
|
if error_result in ["E1", "E2", "E3", "RP"]:
|
|
return build_advancement_from_code(on_base_code, gb_type, result_name="G1")
|
|
|
|
# If no error: delegate to existing result handler (needs full GameState)
|
|
runner_adv = RunnerAdvancement()
|
|
return runner_adv._execute_result(
|
|
result_type=gb_type,
|
|
state=state,
|
|
hit_location=hit_location,
|
|
defensive_decision=defensive_decision,
|
|
)
|
|
|
|
|
|
def x_check_g2(
|
|
on_base_code: int,
|
|
defender_in: bool,
|
|
error_result: str,
|
|
state: GameState,
|
|
hit_location: str,
|
|
defensive_decision: DefensiveDecision,
|
|
) -> AdvancementResult:
|
|
"""
|
|
Runner advancement for X-Check G2 result.
|
|
|
|
Uses G2 advancement table to get GroundballResultType based on
|
|
base situation, defensive positioning, and error result.
|
|
|
|
Args:
|
|
on_base_code: Current base situation code (0-7 bit field)
|
|
defender_in: Is the defender playing in?
|
|
error_result: 'NO', 'E1', 'E2', 'E3', 'RP'
|
|
state: Current game state (for lineup IDs, outs, etc.)
|
|
hit_location: Where the ball was hit (1B, 2B, SS, 3B, P, C)
|
|
defensive_decision: Defensive positioning decision
|
|
|
|
Returns:
|
|
AdvancementResult with runner movements
|
|
"""
|
|
from app.core.x_check_advancement_tables import (
|
|
build_advancement_from_code,
|
|
get_groundball_advancement,
|
|
)
|
|
|
|
gb_type = get_groundball_advancement("G2", on_base_code, defender_in, error_result)
|
|
|
|
# If error result: use simple error advancement (doesn't need GameState details)
|
|
if error_result in ["E1", "E2", "E3", "RP"]:
|
|
return build_advancement_from_code(on_base_code, gb_type, result_name="G2")
|
|
|
|
# If no error: delegate to existing result handler (needs full GameState)
|
|
runner_adv = RunnerAdvancement()
|
|
return runner_adv._execute_result(
|
|
result_type=gb_type,
|
|
state=state,
|
|
hit_location=hit_location,
|
|
defensive_decision=defensive_decision,
|
|
)
|
|
|
|
|
|
def x_check_g3(
|
|
on_base_code: int,
|
|
defender_in: bool,
|
|
error_result: str,
|
|
state: GameState,
|
|
hit_location: str,
|
|
defensive_decision: DefensiveDecision,
|
|
) -> AdvancementResult:
|
|
"""
|
|
Runner advancement for X-Check G3 result.
|
|
|
|
Uses G3 advancement table to get GroundballResultType based on
|
|
base situation, defensive positioning, and error result.
|
|
|
|
Args:
|
|
on_base_code: Current base situation code (0-7 bit field)
|
|
defender_in: Is the defender playing in?
|
|
error_result: 'NO', 'E1', 'E2', 'E3', 'RP'
|
|
state: Current game state (for lineup IDs, outs, etc.)
|
|
hit_location: Where the ball was hit (1B, 2B, SS, 3B, P, C)
|
|
defensive_decision: Defensive positioning decision
|
|
|
|
Returns:
|
|
AdvancementResult with runner movements
|
|
"""
|
|
from app.core.x_check_advancement_tables import (
|
|
build_advancement_from_code,
|
|
get_groundball_advancement,
|
|
)
|
|
|
|
gb_type = get_groundball_advancement("G3", on_base_code, defender_in, error_result)
|
|
|
|
# If error result: use simple error advancement (doesn't need GameState details)
|
|
if error_result in ["E1", "E2", "E3", "RP"]:
|
|
return build_advancement_from_code(on_base_code, gb_type, result_name="G3")
|
|
|
|
# If no error: delegate to existing result handler (needs full GameState)
|
|
runner_adv = RunnerAdvancement()
|
|
return runner_adv._execute_result(
|
|
result_type=gb_type,
|
|
state=state,
|
|
hit_location=hit_location,
|
|
defensive_decision=defensive_decision,
|
|
)
|
|
|
|
|
|
def x_check_f1(
|
|
on_base_code: int, error_result: str, state: GameState, hit_location: str
|
|
) -> AdvancementResult:
|
|
"""
|
|
Runner advancement for X-Check F1 (deep flyball) result.
|
|
|
|
F1 maps to FLYOUT_A behavior:
|
|
- If error: all runners advance E# bases (out negated)
|
|
- If no error: delegate to existing FLYOUT_A logic
|
|
|
|
Args:
|
|
on_base_code: Current base situation code (0-7 bit field)
|
|
error_result: 'NO', 'E1', 'E2', 'E3', 'RP'
|
|
state: Current game state (for lineup IDs, outs, etc.)
|
|
hit_location: Where the ball was hit (LF, CF, RF)
|
|
|
|
Returns:
|
|
AdvancementResult with runner movements
|
|
"""
|
|
from app.core.x_check_advancement_tables import build_flyball_advancement_with_error
|
|
|
|
# If error result: use simple error advancement (doesn't need GameState details)
|
|
if error_result != "NO":
|
|
return build_flyball_advancement_with_error(
|
|
on_base_code, error_result, flyball_type="F1"
|
|
)
|
|
|
|
# If no error: delegate to existing FLYOUT_A logic
|
|
runner_adv = RunnerAdvancement()
|
|
return runner_adv._fb_result_deep(state, hit_location)
|
|
|
|
|
|
def x_check_f2(
|
|
on_base_code: int, error_result: str, state: GameState, hit_location: str
|
|
) -> AdvancementResult:
|
|
"""
|
|
Runner advancement for X-Check F2 (medium flyball) result.
|
|
|
|
F2 maps to FLYOUT_B behavior:
|
|
- If error: all runners advance E# bases (out negated)
|
|
- If no error: delegate to existing FLYOUT_B logic
|
|
|
|
Args:
|
|
on_base_code: Current base situation code (0-7 bit field)
|
|
error_result: 'NO', 'E1', 'E2', 'E3', 'RP'
|
|
state: Current game state (for lineup IDs, outs, etc.)
|
|
hit_location: Where the ball was hit (LF, CF, RF)
|
|
|
|
Returns:
|
|
AdvancementResult with runner movements
|
|
"""
|
|
from app.core.x_check_advancement_tables import build_flyball_advancement_with_error
|
|
|
|
# If error result: use simple error advancement (doesn't need GameState details)
|
|
if error_result != "NO":
|
|
return build_flyball_advancement_with_error(
|
|
on_base_code, error_result, flyball_type="F2"
|
|
)
|
|
|
|
# If no error: delegate to existing FLYOUT_B logic
|
|
runner_adv = RunnerAdvancement()
|
|
return runner_adv._fb_result_medium(state, hit_location)
|
|
|
|
|
|
def x_check_f3(
|
|
on_base_code: int, error_result: str, state: GameState, hit_location: str
|
|
) -> AdvancementResult:
|
|
"""
|
|
Runner advancement for X-Check F3 (shallow flyball) result.
|
|
|
|
F3 maps to FLYOUT_C behavior:
|
|
- If error: all runners advance E# bases (out negated)
|
|
- If no error: delegate to existing FLYOUT_C logic (batter out, no advancement)
|
|
|
|
Args:
|
|
on_base_code: Current base situation code (0-7 bit field)
|
|
error_result: 'NO', 'E1', 'E2', 'E3', 'RP'
|
|
state: Current game state (for lineup IDs, outs, etc.)
|
|
hit_location: Where the ball was hit (LF, CF, RF)
|
|
|
|
Returns:
|
|
AdvancementResult with runner movements
|
|
"""
|
|
from app.core.x_check_advancement_tables import build_flyball_advancement_with_error
|
|
|
|
# If error result: use simple error advancement (doesn't need GameState details)
|
|
if error_result != "NO":
|
|
return build_flyball_advancement_with_error(
|
|
on_base_code, error_result, flyball_type="F3"
|
|
)
|
|
|
|
# If no error: delegate to existing FLYOUT_C logic
|
|
runner_adv = RunnerAdvancement()
|
|
return runner_adv._fb_result_shallow(state, hit_location)
|