strat-gameplay-webapp/backend/app/core/runner_advancement.py
Cal Corum 0b6076d5b8 CLAUDE: Implement Phase 3B - X-Check league config tables
Complete X-Check resolution table system for defensive play outcomes.

Components:
- Defense range tables (20×5) for infield, outfield, catcher
- Error charts for LF/RF and CF (ratings 0-25)
- Placeholder error charts for P, C, 1B, 2B, 3B, SS (awaiting data)
- get_fielders_holding_runners() - Complete implementation
- get_error_chart_for_position() - Maps all 9 positions
- 6 X-Check placeholder advancement functions (g1-g3, f1-f3)

League Config Integration:
- Both SbaConfig and PdConfig include X-Check tables
- Shared common tables via league_configs.py
- Attributes: x_check_defense_tables, x_check_error_charts, x_check_holding_runners

Testing:
- 36 tests for X-Check tables (all passing)
- 9 tests for X-Check placeholders (all passing)
- Total: 45/45 tests passing

Documentation:
- Updated backend/CLAUDE.md with Phase 3B section
- Updated app/config/CLAUDE.md with X-Check tables documentation
- Updated app/core/CLAUDE.md with X-Check placeholder functions
- Updated tests/CLAUDE.md with new test counts (519 unit tests)
- Updated phase-3b-league-config-tables.md (marked complete)
- Updated NEXT_SESSION.md with Phase 3B completion

What's Pending:
- 6 infield error charts need actual data (P, C, 1B, 2B, 3B, SS)
- Phase 3C will implement full X-Check resolution logic

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-01 19:50:55 -05:00

1652 lines
56 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
import random
from dataclasses import dataclass
from enum import IntEnum
from typing import Optional, List, Dict
from uuid import UUID
from app.models.game_models import GameState, DefensiveDecision
from app.config.result_charts import PlayOutcome
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."""
@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)"
elif self.to_base == 4:
return f"RunnerMovement(#{self.lineup_id}: {self.from_base}→HOME)"
else:
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: Optional[GroundballResultType]
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
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)
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
else: # 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
elif gb_letter == 'B':
return GroundballResultType.LEAD_HOLDS_TRAIL_ADVANCES # 9
else: # C
# Check for DECIDE opportunity
if hit_location in ['SS', 'P', 'C']:
return GroundballResultType.DECIDE_OPPORTUNITY # 12
else:
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
elif gb_letter == 'B':
return GroundballResultType.BATTER_OUT_RUNNERS_HOLD # 1
else: # 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
elif gb_letter == 'B':
return GroundballResultType.BATTER_OUT_RUNNERS_HOLD # 1
else: # C
# Check for DECIDE on certain hit locations
if hit_location in ['1B', '2B']:
return GroundballResultType.DECIDE_OPPORTUNITY # 12
elif hit_location == '3B':
return GroundballResultType.BATTER_OUT_RUNNERS_HOLD # 1
else: # 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
elif gb_letter == 'B':
return GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND # 4
else: # 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
else: # 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
else: # 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 _calculate_double_play_probability(
self,
state: GameState,
defensive_decision: DefensiveDecision,
hit_location: str
) -> float:
"""
Calculate probability of successfully turning a double play.
Factors:
- Base probability: 45%
- Positioning: DP depth +20%, infield in -15%
- Hit location: Up middle +10%, corners -10%
- Runner speed: Fast -15%, slow +10% (TODO: when ratings available)
Args:
state: Current game state
defensive_decision: Defensive positioning
hit_location: Where ball was hit
Returns:
Probability between 0.0 and 1.0
"""
probability = 0.45 # Base 45% chance
# Positioning modifiers
if defensive_decision.infield_depth == "infield_in":
probability -= 0.15 # 30% playing in (prioritizing out at plate)
# Note: "double_play" depth doesn't exist in DefensiveDecision validation
# Could add modifier for "normal" depth with certain alignments in the future
# Hit location modifiers
if hit_location in ['2B', 'SS']: # Up the middle
probability += 0.10
elif hit_location in ['1B', '3B', 'P', 'C']: # Corners
probability -= 0.10
# TODO: Runner speed modifiers when player ratings available
# runner_on_first = state.get_runner_at_base(1)
# if runner_on_first and hasattr(runner_on_first, 'speed'):
# if runner_on_first.speed >= 15: # Fast
# probability -= 0.15
# elif runner_on_first.speed <= 5: # Slow
# probability += 0.10
# Clamp between 0 and 1
return max(0.0, min(1.0, probability))
def _execute_result(
self,
result_type: GroundballResultType,
state: GameState,
hit_location: str,
defensive_decision: Optional[DefensiveDecision] = 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)
elif result_type == GroundballResultType.DOUBLE_PLAY_AT_SECOND:
return self._gb_result_2(state, defensive_decision, hit_location)
elif result_type == GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE:
return self._gb_result_3(state)
elif result_type == GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND:
return self._gb_result_4(state)
elif result_type == GroundballResultType.CONDITIONAL_ON_MIDDLE_INFIELD:
return self._gb_result_5(state, hit_location)
elif result_type == GroundballResultType.CONDITIONAL_ON_RIGHT_SIDE:
return self._gb_result_6(state, hit_location)
elif result_type in [
GroundballResultType.BATTER_OUT_FORCED_ONLY,
GroundballResultType.BATTER_OUT_FORCED_ONLY_ALT
]:
return self._gb_result_7(state)
elif result_type == GroundballResultType.LEAD_HOLDS_TRAIL_ADVANCES:
return self._gb_result_9(state)
elif result_type == GroundballResultType.DOUBLE_PLAY_HOME_TO_FIRST:
return self._gb_result_10(state, defensive_decision, hit_location)
elif result_type == GroundballResultType.BATTER_SAFE_LEAD_OUT:
return self._gb_result_11(state)
elif result_type == GroundballResultType.DECIDE_OPPORTUNITY:
return self._gb_result_12(state, hit_location)
elif result_type == GroundballResultType.CONDITIONAL_DOUBLE_PLAY:
return self._gb_result_13(state, defensive_decision, hit_location)
else:
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: Optional[DefensiveDecision],
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
Uses probability calculation for DP success based on positioning and hit location.
"""
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:
# Calculate DP probability
if defensive_decision:
dp_probability = self._calculate_double_play_probability(
state=state,
defensive_decision=defensive_decision,
hit_location=hit_location
)
else:
dp_probability = 0.45 # Default base probability
# Roll for DP
turns_dp = random.random() < dp_probability
if turns_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:
# Only force 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 safe at first
movements.append(RunnerMovement(
lineup_id=state.current_batter_lineup_id,
from_base=0,
to_base=1,
is_out=False
))
description = "Force out at 2nd, batter safe 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)
else:
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)
else:
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: Optional[DefensiveDecision],
hit_location: str
) -> AdvancementResult:
"""
Result 10: Double play attempt 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
Uses probability calculation for DP success.
"""
movements = []
outs = 0
runs = 0
# Check if double play is possible
can_turn_dp = state.outs < 2
if can_turn_dp:
# Calculate DP probability
if defensive_decision:
dp_probability = self._calculate_double_play_probability(
state=state,
defensive_decision=defensive_decision,
hit_location=hit_location
)
else:
dp_probability = 0.45 # Default base probability
# Roll for DP
turns_dp = random.random() < dp_probability
if turns_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:
# Only out at home, batter safe
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 safe at first
movements.append(RunnerMovement(
lineup_id=state.current_batter_lineup_id,
from_base=0,
to_base=1,
is_out=False
))
description = "Out at home, batter safe 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: Optional[DefensiveDecision],
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)
Uses probability calculation for DP success.
"""
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:
# Calculate DP probability
if defensive_decision:
dp_probability = self._calculate_double_play_probability(
state=state,
defensive_decision=defensive_decision,
hit_location=hit_location
)
else:
dp_probability = 0.45 # Default
# Roll for DP
turns_dp = random.random() < dp_probability
else:
turns_dp = False
if turns_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
)
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"
)
# ============================================================================
# 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
) -> AdvancementResult:
"""
Runner advancement for X-Check G1 result.
TODO: Implement full table lookups in Phase 3D
Args:
on_base_code: Current base situation code
defender_in: Is the defender playing in?
error_result: 'NO', 'E1', 'E2', 'E3', 'RP'
Returns:
AdvancementResult with runner movements
"""
# Placeholder
return AdvancementResult(
movements=[],
outs_recorded=0,
runs_scored=0,
result_type=None,
description="X-Check G1 (placeholder)"
)
def x_check_g2(on_base_code: int, defender_in: bool, error_result: str) -> AdvancementResult:
"""X-Check G2 advancement (TODO: Phase 3D)."""
return AdvancementResult(
movements=[],
outs_recorded=0,
runs_scored=0,
result_type=None,
description="X-Check G2 (placeholder)"
)
def x_check_g3(on_base_code: int, defender_in: bool, error_result: str) -> AdvancementResult:
"""X-Check G3 advancement (TODO: Phase 3D)."""
return AdvancementResult(
movements=[],
outs_recorded=0,
runs_scored=0,
result_type=None,
description="X-Check G3 (placeholder)"
)
def x_check_f1(on_base_code: int, error_result: str) -> AdvancementResult:
"""X-Check F1 advancement (TODO: Phase 3D)."""
return AdvancementResult(
movements=[],
outs_recorded=1,
runs_scored=0,
result_type=None,
description="X-Check F1 (placeholder)"
)
def x_check_f2(on_base_code: int, error_result: str) -> AdvancementResult:
"""X-Check F2 advancement (TODO: Phase 3D)."""
return AdvancementResult(
movements=[],
outs_recorded=1,
runs_scored=0,
result_type=None,
description="X-Check F2 (placeholder)"
)
def x_check_f3(on_base_code: int, error_result: str) -> AdvancementResult:
"""X-Check F3 advancement (TODO: Phase 3D)."""
return AdvancementResult(
movements=[],
outs_recorded=1,
runs_scored=0,
result_type=None,
description="X-Check F3 (placeholder)"
)
# Add more placeholders for SI1, SI2, DO2, DO3, TR3, FO, PO as needed