Two related bug fixes for gameplay accuracy: **Backend - play_resolver.py**: - Fixed DOUBLE2 advancement: Runners now advance exactly 2 bases * 1st → 3rd, 2nd → home, 3rd → home * Was incorrectly advancing all runners to home - Fixed DOUBLE3 advancement: Runners now advance exactly 3 bases * All runners score (1st+3=4, 2nd+3=5→4, 3rd+3=6→4) * Updated docstrings for clarity **Frontend - ManualOutcomeEntry.vue**: - Fixed hit location requirement logic * Now requires hit location when runners on base (any outs) * Was incorrectly restricting to only when outs < 2 * Hit location determines runner advancement regardless of outs These fixes ensure accurate Strat-O-Matic gameplay simulation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1407 lines
50 KiB
Python
1407 lines
50 KiB
Python
"""
|
|
Play Resolver - Resolves play outcomes based on dice rolls.
|
|
|
|
Architecture: Outcome-first design where manual resolution is primary.
|
|
- resolve_outcome(): Core resolution logic (works for both manual and auto)
|
|
- resolve_manual_play(): Wrapper for manual submissions (most games)
|
|
- resolve_auto_play(): Wrapper for PD auto mode (rare)
|
|
|
|
Author: Claude
|
|
Date: 2025-10-24
|
|
Updated: 2025-10-31 - Week 7 Task 6: Integrated RunnerAdvancement and outcome-first architecture
|
|
Updated: 2025-11-02 - Phase 3C: Added X-Check resolution logic
|
|
"""
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from app.config import PlayOutcome, get_league_config
|
|
from app.config.common_x_check_tables import (
|
|
CATCHER_DEFENSE_TABLE,
|
|
INFIELD_DEFENSE_TABLE,
|
|
OUTFIELD_DEFENSE_TABLE,
|
|
get_error_chart_for_position,
|
|
get_fielders_holding_runners,
|
|
)
|
|
from app.config.result_charts import (
|
|
PdAutoResultChart,
|
|
)
|
|
from app.core.dice import dice_system
|
|
from app.core.roll_types import AbRoll
|
|
from app.core.runner_advancement import AdvancementResult, RunnerAdvancement
|
|
from app.models.game_models import (
|
|
DefensiveDecision,
|
|
GameState,
|
|
ManualOutcomeSubmission,
|
|
OffensiveDecision,
|
|
XCheckResult,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from app.models.player_models import PdPlayer
|
|
|
|
logger = logging.getLogger(f"{__name__}.PlayResolver")
|
|
|
|
|
|
@dataclass
|
|
class PlayResult:
|
|
"""Result of a resolved play"""
|
|
|
|
outcome: PlayOutcome
|
|
outs_recorded: int
|
|
runs_scored: int
|
|
batter_result: int | None # None = out, 1-4 = base reached
|
|
runners_advanced: list[tuple[int, int]] # [(from_base, to_base), ...]
|
|
description: str
|
|
ab_roll: AbRoll # Full at-bat roll for audit trail
|
|
hit_location: str | None = (
|
|
None # '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'P', 'C'
|
|
)
|
|
|
|
# Statistics
|
|
is_hit: bool = False
|
|
is_out: bool = False
|
|
is_walk: bool = False
|
|
|
|
# X-Check details (Phase 3C)
|
|
x_check_details: XCheckResult | None = None
|
|
|
|
|
|
class PlayResolver:
|
|
"""
|
|
Resolves play outcomes based on dice rolls and game state.
|
|
|
|
Architecture: Outcome-first design
|
|
- Manual mode (primary): Players submit outcomes after reading physical cards
|
|
- Auto mode (rare): System generates outcomes from digitized ratings (PD only)
|
|
|
|
Args:
|
|
league_id: 'sba' or 'pd'
|
|
auto_mode: If True, use result charts to auto-generate outcomes
|
|
Only supported for leagues with digitized card data
|
|
|
|
Raises:
|
|
ValueError: If auto_mode requested for league that doesn't support it
|
|
"""
|
|
|
|
def __init__(
|
|
self, league_id: str, auto_mode: bool = False, state_manager: Any | None = None
|
|
):
|
|
self.league_id = league_id
|
|
self.auto_mode = auto_mode
|
|
self.runner_advancement = RunnerAdvancement()
|
|
self.state_manager = state_manager # Phase 3E-Main: For X-Check defender lookup
|
|
|
|
# Get league config for validation
|
|
league_config = get_league_config(league_id)
|
|
|
|
# Validate auto mode support
|
|
if auto_mode and not league_config.supports_auto_mode():
|
|
raise ValueError(
|
|
f"Auto mode not supported for {league_id} league. "
|
|
f"This league does not have digitized card data."
|
|
)
|
|
|
|
# Initialize result chart for auto mode only
|
|
if auto_mode:
|
|
self.result_chart = PdAutoResultChart()
|
|
logger.info(f"PlayResolver initialized in AUTO mode for {league_id}")
|
|
else:
|
|
self.result_chart = None
|
|
logger.info(f"PlayResolver initialized in MANUAL mode for {league_id}")
|
|
|
|
# ========================================
|
|
# PUBLIC METHODS - Primary API
|
|
# ========================================
|
|
|
|
def resolve_manual_play(
|
|
self,
|
|
submission: ManualOutcomeSubmission,
|
|
state: GameState,
|
|
defensive_decision: DefensiveDecision,
|
|
offensive_decision: OffensiveDecision,
|
|
ab_roll: AbRoll,
|
|
) -> PlayResult:
|
|
"""
|
|
Resolve a manually submitted play (SBA + PD manual mode).
|
|
|
|
This is the PRIMARY method for most games. Players read physical cards
|
|
and submit the outcome they see via WebSocket.
|
|
|
|
Args:
|
|
submission: Player's submitted outcome + optional hit location
|
|
state: Current game state
|
|
defensive_decision: Defensive team's choices
|
|
offensive_decision: Offensive team's choices
|
|
ab_roll: Server-rolled dice for audit trail
|
|
|
|
Returns:
|
|
PlayResult with complete outcome
|
|
"""
|
|
logger.info(
|
|
f"Resolving manual play - {submission.outcome} at {submission.hit_location}"
|
|
)
|
|
|
|
# Convert string to PlayOutcome enum
|
|
outcome = PlayOutcome(submission.outcome)
|
|
|
|
# Delegate to core resolution
|
|
return self.resolve_outcome(
|
|
outcome=outcome,
|
|
hit_location=submission.hit_location,
|
|
state=state,
|
|
defensive_decision=defensive_decision,
|
|
offensive_decision=offensive_decision,
|
|
ab_roll=ab_roll,
|
|
)
|
|
|
|
def resolve_auto_play(
|
|
self,
|
|
state: GameState,
|
|
batter: "PdPlayer",
|
|
pitcher: "PdPlayer",
|
|
defensive_decision: DefensiveDecision,
|
|
offensive_decision: OffensiveDecision,
|
|
) -> PlayResult:
|
|
"""
|
|
Resolve an auto-generated play (PD auto mode only).
|
|
|
|
This is RARE - only used for PD games with auto mode enabled.
|
|
System generates outcome from digitized player ratings.
|
|
|
|
Args:
|
|
state: Current game state
|
|
batter: Batting player (PdPlayer with ratings)
|
|
pitcher: Pitching player (PdPlayer with ratings)
|
|
defensive_decision: Defensive team's choices
|
|
offensive_decision: Offensive team's choices
|
|
|
|
Returns:
|
|
PlayResult with complete outcome
|
|
|
|
Raises:
|
|
ValueError: If called when not in auto mode
|
|
"""
|
|
if not self.auto_mode:
|
|
raise ValueError("resolve_auto_play() can only be called in auto mode")
|
|
|
|
logger.info(f"Resolving auto play - {batter.name} vs {pitcher.name}")
|
|
|
|
# Roll dice
|
|
ab_roll = dice_system.roll_ab(league_id=state.league_id, game_id=state.game_id)
|
|
|
|
# Generate outcome from ratings
|
|
outcome, hit_location = self.result_chart.get_outcome( # type: ignore
|
|
roll=ab_roll, state=state, batter=batter, pitcher=pitcher
|
|
)
|
|
|
|
# Delegate to core resolution
|
|
return self.resolve_outcome(
|
|
outcome=outcome,
|
|
hit_location=hit_location,
|
|
state=state,
|
|
defensive_decision=defensive_decision,
|
|
offensive_decision=offensive_decision,
|
|
ab_roll=ab_roll,
|
|
)
|
|
|
|
def resolve_outcome(
|
|
self,
|
|
outcome: PlayOutcome,
|
|
hit_location: str | None,
|
|
state: GameState,
|
|
defensive_decision: DefensiveDecision,
|
|
offensive_decision: OffensiveDecision,
|
|
ab_roll: AbRoll,
|
|
forced_xcheck_result: str | None = None,
|
|
forced_xcheck_error: str | None = None,
|
|
) -> PlayResult:
|
|
"""
|
|
CORE resolution method - all play resolution logic lives here.
|
|
|
|
This method handles all outcome types and delegates to RunnerAdvancement
|
|
for groundball outcomes. Works for both manual and auto modes.
|
|
|
|
Args:
|
|
outcome: The play outcome (from card or auto-generated)
|
|
hit_location: Where ball was hit ('1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'P', 'C') or None
|
|
state: Current game state
|
|
defensive_decision: Defensive team's positioning/strategy
|
|
offensive_decision: Offensive team's strategy
|
|
ab_roll: Dice roll for audit trail
|
|
forced_xcheck_result: For testing - force X-Check converted result (G1, G2, SI2, DO2, etc.)
|
|
forced_xcheck_error: For testing - force X-Check error result (NO, E1, E2, E3, RP)
|
|
|
|
Returns:
|
|
PlayResult with complete outcome, runner movements, and statistics
|
|
"""
|
|
logger.info(
|
|
f"Resolving {outcome.value} - Inning {state.inning} {state.half}, {state.outs} outs"
|
|
)
|
|
|
|
# ==================== Strikeout ====================
|
|
if outcome == PlayOutcome.STRIKEOUT:
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=1,
|
|
runs_scored=0,
|
|
batter_result=None,
|
|
runners_advanced=[],
|
|
description="Strikeout looking",
|
|
ab_roll=ab_roll,
|
|
hit_location=None,
|
|
is_out=True,
|
|
)
|
|
|
|
# ==================== Popout ====================
|
|
if outcome == PlayOutcome.POPOUT:
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=1,
|
|
runs_scored=0,
|
|
batter_result=None,
|
|
runners_advanced=[],
|
|
description="Popout to infield",
|
|
ab_roll=ab_roll,
|
|
hit_location=hit_location,
|
|
is_out=True,
|
|
)
|
|
|
|
# ==================== Groundballs ====================
|
|
if outcome in [
|
|
PlayOutcome.GROUNDBALL_A,
|
|
PlayOutcome.GROUNDBALL_B,
|
|
PlayOutcome.GROUNDBALL_C,
|
|
]:
|
|
# Business rule: hit_location only matters when there are runners on base
|
|
# AND less than 2 outs (for fielding choices and runner advancement)
|
|
has_runners = (
|
|
state.on_first is not None
|
|
or state.on_second is not None
|
|
or state.on_third is not None
|
|
)
|
|
needs_hit_location = has_runners and state.outs < 2
|
|
|
|
if needs_hit_location and not hit_location:
|
|
raise ValueError(
|
|
f"Hit location required for {outcome.value} when runners are on base with less than 2 outs. "
|
|
f"Current situation: {state.outs} outs, runners on: "
|
|
f"{'1B ' if state.on_first else ''}"
|
|
f"{'2B ' if state.on_second else ''}"
|
|
f"{'3B' if state.on_third else ''}"
|
|
)
|
|
|
|
# Delegate to RunnerAdvancement for all groundball outcomes
|
|
advancement_result = self.runner_advancement.advance_runners(
|
|
outcome=outcome,
|
|
hit_location=hit_location
|
|
or "SS", # Default to SS if location not specified
|
|
state=state,
|
|
defensive_decision=defensive_decision,
|
|
)
|
|
|
|
# Convert RunnerMovement list to tuple format for PlayResult
|
|
runners_advanced = [
|
|
(movement.from_base, movement.to_base)
|
|
for movement in advancement_result.movements
|
|
if not movement.is_out
|
|
and movement.from_base > 0 # Exclude batter, include only runners
|
|
]
|
|
|
|
# Extract batter result from movements
|
|
batter_movement = next(
|
|
(m for m in advancement_result.movements if m.from_base == 0), None
|
|
)
|
|
batter_result = (
|
|
batter_movement.to_base
|
|
if batter_movement and not batter_movement.is_out
|
|
else None
|
|
)
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=advancement_result.outs_recorded,
|
|
runs_scored=advancement_result.runs_scored,
|
|
batter_result=batter_result,
|
|
runners_advanced=runners_advanced,
|
|
description=advancement_result.description,
|
|
ab_roll=ab_roll,
|
|
hit_location=hit_location,
|
|
is_out=(advancement_result.outs_recorded > 0),
|
|
)
|
|
|
|
# ==================== Flyouts ====================
|
|
if outcome in [
|
|
PlayOutcome.FLYOUT_A,
|
|
PlayOutcome.FLYOUT_B,
|
|
PlayOutcome.FLYOUT_BQ,
|
|
PlayOutcome.FLYOUT_C,
|
|
]:
|
|
# Business rule: hit_location only matters for FLYOUT_B and FLYOUT_BQ
|
|
# when there are runners on base AND less than 2 outs (for tag-up decisions)
|
|
if outcome in [PlayOutcome.FLYOUT_B, PlayOutcome.FLYOUT_BQ]:
|
|
has_runners = (
|
|
state.on_first is not None
|
|
or state.on_second is not None
|
|
or state.on_third is not None
|
|
)
|
|
needs_hit_location = has_runners and state.outs < 2
|
|
|
|
if needs_hit_location and not hit_location:
|
|
raise ValueError(
|
|
f"Hit location required for {outcome.value} when runners are on base with less than 2 outs. "
|
|
f"Current situation: {state.outs} outs, runners on: "
|
|
f"{'1B ' if state.on_first else ''}"
|
|
f"{'2B ' if state.on_second else ''}"
|
|
f"{'3B' if state.on_third else ''}"
|
|
)
|
|
|
|
# Delegate to RunnerAdvancement for all flyball outcomes
|
|
advancement_result = self.runner_advancement.advance_runners(
|
|
outcome=outcome,
|
|
hit_location=hit_location
|
|
or "CF", # Default to CF if location not specified
|
|
state=state,
|
|
defensive_decision=defensive_decision,
|
|
)
|
|
|
|
# Convert RunnerMovement list to tuple format for PlayResult
|
|
runners_advanced = [
|
|
(movement.from_base, movement.to_base)
|
|
for movement in advancement_result.movements
|
|
if not movement.is_out
|
|
and movement.from_base > 0 # Exclude batter, include only runners
|
|
]
|
|
|
|
# Extract batter result from movements (always out for flyouts)
|
|
batter_movement = next(
|
|
(m for m in advancement_result.movements if m.from_base == 0), None
|
|
)
|
|
batter_result = (
|
|
batter_movement.to_base
|
|
if batter_movement and not batter_movement.is_out
|
|
else None
|
|
)
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=advancement_result.outs_recorded,
|
|
runs_scored=advancement_result.runs_scored,
|
|
batter_result=batter_result,
|
|
runners_advanced=runners_advanced,
|
|
description=advancement_result.description,
|
|
ab_roll=ab_roll,
|
|
hit_location=hit_location,
|
|
is_out=(advancement_result.outs_recorded > 0),
|
|
)
|
|
|
|
# ==================== Lineout ====================
|
|
if outcome == PlayOutcome.LINEOUT:
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=1,
|
|
runs_scored=0,
|
|
batter_result=None,
|
|
runners_advanced=[],
|
|
description="Lineout",
|
|
ab_roll=ab_roll,
|
|
is_out=True,
|
|
)
|
|
|
|
if outcome == PlayOutcome.WALK:
|
|
# Walk - batter to first, runners advance if forced
|
|
runners_advanced = self._advance_on_walk(state)
|
|
runs_scored = sum(
|
|
1 for (from_base, to_base) in runners_advanced if to_base == 4
|
|
)
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=0,
|
|
runs_scored=runs_scored,
|
|
batter_result=1,
|
|
runners_advanced=runners_advanced,
|
|
description="Walk",
|
|
ab_roll=ab_roll,
|
|
is_walk=True,
|
|
)
|
|
|
|
# ==================== Singles ====================
|
|
if outcome == PlayOutcome.SINGLE_1:
|
|
# Single with standard advancement
|
|
runners_advanced = self._advance_on_single_1(state)
|
|
runs_scored = sum(
|
|
1 for (from_base, to_base) in runners_advanced if to_base == 4
|
|
)
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=0,
|
|
runs_scored=runs_scored,
|
|
batter_result=1,
|
|
runners_advanced=runners_advanced,
|
|
description="Single to left field",
|
|
ab_roll=ab_roll,
|
|
is_hit=True,
|
|
)
|
|
|
|
if outcome == PlayOutcome.SINGLE_2:
|
|
# Single with enhanced advancement (more aggressive)
|
|
runners_advanced = self._advance_on_single_2(state)
|
|
runs_scored = sum(
|
|
1 for (from_base, to_base) in runners_advanced if to_base == 4
|
|
)
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=0,
|
|
runs_scored=runs_scored,
|
|
batter_result=1,
|
|
runners_advanced=runners_advanced,
|
|
description="Single to right field",
|
|
ab_roll=ab_roll,
|
|
is_hit=True,
|
|
)
|
|
|
|
if outcome == PlayOutcome.SINGLE_UNCAPPED:
|
|
# Business rule: hit_location only matters when there is a runner on 1st, 2nd, or both
|
|
has_runner_on_scoring_bases = state.on_first is not None or state.on_second is not None
|
|
|
|
if has_runner_on_scoring_bases and not hit_location:
|
|
raise ValueError(
|
|
f"Hit location required for {outcome.value} when runner on 1st or 2nd. "
|
|
f"Current situation: runners on: "
|
|
f"{'1B ' if state.on_first else ''}"
|
|
f"{'2B ' if state.on_second else ''}"
|
|
f"{'3B' if state.on_third else ''}"
|
|
)
|
|
|
|
# TODO Phase 3: Implement uncapped hit decision tree
|
|
# For now, treat as SINGLE_1
|
|
runners_advanced = self._advance_on_single_1(state)
|
|
runs_scored = sum(
|
|
1 for (from_base, to_base) in runners_advanced if to_base == 4
|
|
)
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=0,
|
|
runs_scored=runs_scored,
|
|
batter_result=1,
|
|
runners_advanced=runners_advanced,
|
|
description="Single to center (uncapped)",
|
|
ab_roll=ab_roll,
|
|
is_hit=True,
|
|
)
|
|
|
|
# ==================== Doubles ====================
|
|
if outcome == PlayOutcome.DOUBLE_2:
|
|
# Double to 2nd base
|
|
runners_advanced = self._advance_on_double_2(state)
|
|
runs_scored = sum(
|
|
1 for (from_base, to_base) in runners_advanced if to_base == 4
|
|
)
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=0,
|
|
runs_scored=runs_scored,
|
|
batter_result=2,
|
|
runners_advanced=runners_advanced,
|
|
description="Double to left-center",
|
|
ab_roll=ab_roll,
|
|
is_hit=True,
|
|
)
|
|
|
|
if outcome == PlayOutcome.DOUBLE_3:
|
|
# Double with extra runner advancement (runners advance 3 bases)
|
|
runners_advanced = self._advance_on_double_3(state)
|
|
runs_scored = sum(
|
|
1 for (from_base, to_base) in runners_advanced if to_base == 4
|
|
)
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=0,
|
|
runs_scored=runs_scored,
|
|
batter_result=2, # Batter reaches 2B (it's a double)
|
|
runners_advanced=runners_advanced,
|
|
description="Double to right-center gap (runners advance 3 bases)",
|
|
ab_roll=ab_roll,
|
|
is_hit=True,
|
|
)
|
|
|
|
if outcome == PlayOutcome.DOUBLE_UNCAPPED:
|
|
# Business rule: hit_location only matters when there is a runner on 1st
|
|
has_runner_on_first = state.on_first is not None
|
|
|
|
if has_runner_on_first and not hit_location:
|
|
raise ValueError(
|
|
f"Hit location required for {outcome.value} when runner on 1st. "
|
|
f"Current situation: runners on: "
|
|
f"{'1B ' if state.on_first else ''}"
|
|
f"{'2B ' if state.on_second else ''}"
|
|
f"{'3B' if state.on_third else ''}"
|
|
)
|
|
|
|
# TODO Phase 3: Implement uncapped hit decision tree
|
|
# For now, treat as DOUBLE_2
|
|
runners_advanced = self._advance_on_double_2(state)
|
|
runs_scored = sum(
|
|
1 for (from_base, to_base) in runners_advanced if to_base == 4
|
|
)
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=0,
|
|
runs_scored=runs_scored,
|
|
batter_result=2,
|
|
runners_advanced=runners_advanced,
|
|
description="Double (uncapped)",
|
|
ab_roll=ab_roll,
|
|
is_hit=True,
|
|
)
|
|
|
|
if outcome == PlayOutcome.TRIPLE:
|
|
# All runners score
|
|
runners_advanced = [(base, 4) for base, _ in state.get_all_runners()]
|
|
runs_scored = len(runners_advanced)
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=0,
|
|
runs_scored=runs_scored,
|
|
batter_result=3,
|
|
runners_advanced=runners_advanced,
|
|
description="Triple to right-center gap",
|
|
ab_roll=ab_roll,
|
|
is_hit=True,
|
|
)
|
|
|
|
if outcome == PlayOutcome.HOMERUN:
|
|
# Everyone scores
|
|
runners_advanced = [(base, 4) for base, _ in state.get_all_runners()]
|
|
runs_scored = len(runners_advanced) + 1
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=0,
|
|
runs_scored=runs_scored,
|
|
batter_result=4,
|
|
runners_advanced=runners_advanced,
|
|
description="Home run to left field",
|
|
ab_roll=ab_roll,
|
|
is_hit=True,
|
|
)
|
|
|
|
if outcome == PlayOutcome.WILD_PITCH:
|
|
# Runners advance one base
|
|
runners_advanced = [(base, base + 1) for base, _ in state.get_all_runners()]
|
|
runs_scored = sum(
|
|
1 for (from_base, to_base) in runners_advanced if to_base == 4
|
|
)
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=0,
|
|
runs_scored=runs_scored,
|
|
batter_result=None, # Batter stays at plate
|
|
runners_advanced=runners_advanced,
|
|
description="Wild pitch - runners advance",
|
|
ab_roll=ab_roll,
|
|
)
|
|
|
|
if outcome == PlayOutcome.PASSED_BALL:
|
|
# Runners advance one base
|
|
runners_advanced = [(base, base + 1) for base, _ in state.get_all_runners()]
|
|
runs_scored = sum(
|
|
1 for (from_base, to_base) in runners_advanced if to_base == 4
|
|
)
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=0,
|
|
runs_scored=runs_scored,
|
|
batter_result=None, # Batter stays at plate
|
|
runners_advanced=runners_advanced,
|
|
description="Passed ball - runners advance",
|
|
ab_roll=ab_roll,
|
|
)
|
|
|
|
# ==================== X-Check ====================
|
|
if outcome == PlayOutcome.X_CHECK:
|
|
# X-Check requires position in hit_location
|
|
if not hit_location:
|
|
raise ValueError("X-Check outcome requires hit_location (position)")
|
|
|
|
# Resolve X-Check with defense table and error chart lookups
|
|
return self._resolve_x_check(
|
|
position=hit_location,
|
|
state=state,
|
|
defensive_decision=defensive_decision,
|
|
ab_roll=ab_roll,
|
|
forced_result=forced_xcheck_result,
|
|
forced_error=forced_xcheck_error,
|
|
)
|
|
|
|
raise ValueError(f"Unhandled outcome: {outcome}")
|
|
|
|
def _advance_on_walk(self, state: GameState) -> list[tuple[int, int]]:
|
|
"""Calculate runner advancement on walk"""
|
|
advances = []
|
|
|
|
# Only forced runners advance
|
|
if state.on_first:
|
|
# First occupied - check second
|
|
if state.on_second:
|
|
# Bases loaded scenario
|
|
if state.on_third:
|
|
# Bases loaded - force runner home
|
|
advances.append((3, 4))
|
|
advances.append((2, 3))
|
|
advances.append((1, 2))
|
|
|
|
return advances
|
|
|
|
def _advance_on_single_1(self, state: GameState) -> list[tuple[int, int]]:
|
|
"""Calculate runner advancement on single (simplified)"""
|
|
advances = []
|
|
|
|
if state.on_third:
|
|
# Runner on third scores
|
|
advances.append((3, 4))
|
|
if state.on_second:
|
|
advances.append((2, 3))
|
|
if state.on_first:
|
|
advances.append((1, 2))
|
|
|
|
return advances
|
|
|
|
def _advance_on_single_2(self, state: GameState) -> list[tuple[int, int]]:
|
|
"""Calculate runner advancement on single (simplified)"""
|
|
advances = []
|
|
|
|
if state.on_third:
|
|
# Runner on third scores
|
|
advances.append((3, 4))
|
|
if state.on_second:
|
|
# Runner on second scores
|
|
advances.append((2, 4))
|
|
if state.on_first:
|
|
# Runner on first to third
|
|
advances.append((1, 3))
|
|
|
|
return advances
|
|
|
|
def _advance_on_double_2(self, state: GameState) -> list[tuple[int, int]]:
|
|
"""Calculate runner advancement on DOUBLE2 - all runners advance exactly 2 bases"""
|
|
advances = []
|
|
|
|
# Runners advance 2 bases:
|
|
# 1st -> 3rd, 2nd -> home, 3rd -> home
|
|
for base, _ in state.get_all_runners():
|
|
final_base = min(base + 2, 4)
|
|
advances.append((base, final_base))
|
|
|
|
return advances
|
|
|
|
def _advance_on_double_3(self, state: GameState) -> list[tuple[int, int]]:
|
|
"""Calculate runner advancement on DOUBLE3 - all runners advance exactly 3 bases"""
|
|
advances = []
|
|
|
|
# Runners advance 3 bases (all score from any base)
|
|
# 1st -> home (1+3=4), 2nd -> home (2+3=5→4), 3rd -> home
|
|
for base, _ in state.get_all_runners():
|
|
final_base = min(base + 3, 4)
|
|
advances.append((base, final_base))
|
|
|
|
return advances
|
|
|
|
# ========================================================================
|
|
# X-CHECK RESOLUTION (Phase 3C - 2025-11-02)
|
|
# ========================================================================
|
|
|
|
def _resolve_x_check(
|
|
self,
|
|
position: str,
|
|
state: GameState,
|
|
defensive_decision: DefensiveDecision,
|
|
ab_roll: AbRoll,
|
|
forced_result: str | None = None,
|
|
forced_error: str | None = None,
|
|
) -> PlayResult:
|
|
"""
|
|
Resolve X-Check play with defense range and error tables.
|
|
|
|
Process:
|
|
1. Get defender and their ratings
|
|
2. Roll 1d20 + 3d6 (or use forced values)
|
|
3. Adjust range if playing in
|
|
4. Look up base result from defense table (or use forced_result)
|
|
5. Apply SPD test if needed
|
|
6. Apply G2#/G3# conversion if applicable
|
|
7. Look up error result from error chart (or use forced_error)
|
|
8. Determine final outcome
|
|
9. Get runner advancement
|
|
10. Create Play record
|
|
|
|
Args:
|
|
position: Position being checked (SS, LF, 3B, etc.)
|
|
state: Current game state
|
|
defensive_decision: Defensive positioning
|
|
ab_roll: Dice roll for audit trail
|
|
forced_result: For testing - force the converted result (G1, G2, SI2, DO2, etc.)
|
|
forced_error: For testing - force the error result (NO, E1, E2, E3, RP)
|
|
|
|
Returns:
|
|
PlayResult with x_check_details populated
|
|
|
|
Raises:
|
|
ValueError: If defender has no position rating
|
|
"""
|
|
logger.info(f"Resolving X-Check to {position}")
|
|
if forced_result:
|
|
logger.info(
|
|
f"🎯 Forcing X-Check result: {forced_result} + {forced_error or 'NO'}"
|
|
)
|
|
|
|
# Check league config
|
|
league_config = get_league_config(state.league_id)
|
|
supports_ratings = league_config.supports_position_ratings()
|
|
|
|
# Step 1: Get defender from lineup cache and use position ratings
|
|
defender = None
|
|
if self.state_manager:
|
|
defender = state.get_defender_for_position(position, self.state_manager)
|
|
|
|
if defender and supports_ratings and defender.position_rating:
|
|
# Use actual ratings from PD league player
|
|
defender_range = defender.position_rating.range
|
|
defender_error_rating = defender.position_rating.error
|
|
defender_id = defender.lineup_id
|
|
logger.debug(
|
|
f"Using defender {defender_id} (card {defender.card_id}) ratings: "
|
|
f"range={defender_range}, error={defender_error_rating}"
|
|
)
|
|
elif defender:
|
|
# Defender found but no ratings (SBA or missing data)
|
|
logger.info(
|
|
f"Defender found at {position} but no ratings available "
|
|
f"(league={state.league_id}, supports_ratings={supports_ratings})"
|
|
)
|
|
defender_range = 3 # Average range
|
|
defender_error_rating = 15 # Average error
|
|
defender_id = defender.lineup_id
|
|
else:
|
|
# No defender found (shouldn't happen in valid game)
|
|
logger.warning(f"No defender found at {position}, using defaults")
|
|
defender_range = 3
|
|
defender_error_rating = 15
|
|
defender_id = 0
|
|
|
|
# Step 2: Roll dice using proper fielding roll (includes audit trail)
|
|
fielding_roll = dice_system.roll_fielding(
|
|
position=position, league_id=state.league_id, game_id=state.game_id
|
|
)
|
|
d20_roll = fielding_roll.d20
|
|
d6_roll = fielding_roll.error_total
|
|
|
|
logger.debug(
|
|
f"X-Check rolls: d20={d20_roll}, 3d6={d6_roll} (roll_id={fielding_roll.roll_id})"
|
|
)
|
|
|
|
# Step 3: Adjust range if playing in
|
|
adjusted_range = self._adjust_range_for_defensive_position(
|
|
base_range=defender_range,
|
|
position=position,
|
|
defensive_decision=defensive_decision,
|
|
)
|
|
|
|
# Initialize SPD test variables (used in both forced and normal paths)
|
|
spd_test_roll = None
|
|
spd_test_target = None
|
|
spd_test_passed = None
|
|
|
|
# Step 4: Look up base result (or use forced)
|
|
if forced_result:
|
|
# Use forced result, skip table lookup
|
|
base_result = forced_result
|
|
converted_result = forced_result # Skip SPD test and G2#/G3# conversion
|
|
logger.debug(f"Using forced result: {forced_result}")
|
|
else:
|
|
# Normal flow: look up from defense table
|
|
base_result = self._lookup_defense_table(
|
|
position=position, d20_roll=d20_roll, defense_range=adjusted_range
|
|
)
|
|
|
|
logger.debug(f"Base result from defense table: {base_result}")
|
|
|
|
# Step 5: Apply SPD test if needed
|
|
converted_result = base_result
|
|
|
|
if base_result == "SPD":
|
|
# TODO: Need batter for SPD test - placeholder for now
|
|
converted_result = "G3" # Default to G3 if SPD test fails
|
|
logger.debug(f"SPD test defaulted to fail → {converted_result}")
|
|
|
|
# Step 6: Apply G2#/G3# conversion if applicable
|
|
if converted_result in ["G2#", "G3#"]:
|
|
converted_result = self._apply_hash_conversion(
|
|
result=converted_result,
|
|
position=position,
|
|
adjusted_range=adjusted_range,
|
|
base_range=defender_range,
|
|
state=state,
|
|
batter_hand="R", # Placeholder
|
|
)
|
|
|
|
# Step 7: Look up error result (or use forced)
|
|
if forced_error:
|
|
# Use forced error, skip chart lookup
|
|
error_result = forced_error
|
|
logger.debug(f"Using forced error: {forced_error}")
|
|
else:
|
|
# Normal flow: look up from error chart
|
|
error_result = self._lookup_error_chart(
|
|
position=position, error_rating=defender_error_rating, d6_roll=d6_roll
|
|
)
|
|
|
|
logger.debug(f"Error result: {error_result}")
|
|
|
|
# Step 8: Determine final outcome
|
|
final_outcome, hit_type = self._determine_final_x_check_outcome(
|
|
converted_result=converted_result, error_result=error_result
|
|
)
|
|
|
|
# Step 9: Create XCheckResult
|
|
x_check_details = XCheckResult(
|
|
position=position,
|
|
d20_roll=d20_roll,
|
|
d6_roll=d6_roll,
|
|
defender_range=adjusted_range,
|
|
defender_error_rating=defender_error_rating,
|
|
defender_id=defender_id,
|
|
base_result=base_result,
|
|
converted_result=converted_result,
|
|
error_result=error_result,
|
|
final_outcome=final_outcome,
|
|
hit_type=hit_type,
|
|
spd_test_roll=spd_test_roll,
|
|
spd_test_target=spd_test_target,
|
|
spd_test_passed=spd_test_passed,
|
|
)
|
|
|
|
# Step 10: Get runner advancement
|
|
defender_in = adjusted_range > defender_range
|
|
|
|
# Call appropriate x_check function based on converted_result
|
|
advancement = self._get_x_check_advancement(
|
|
converted_result=converted_result,
|
|
error_result=error_result,
|
|
state=state,
|
|
defender_in=defender_in,
|
|
hit_location=position,
|
|
defensive_decision=defensive_decision,
|
|
)
|
|
|
|
# Convert AdvancementResult to PlayResult format
|
|
runners_advanced = [
|
|
(movement.from_base, movement.to_base)
|
|
for movement in advancement.movements
|
|
if not movement.is_out
|
|
and movement.from_base > 0 # Exclude batter, include only runners
|
|
]
|
|
|
|
# Extract batter result from movements
|
|
batter_movement = next(
|
|
(m for m in advancement.movements if m.from_base == 0), None
|
|
)
|
|
batter_result = (
|
|
batter_movement.to_base
|
|
if batter_movement and not batter_movement.is_out
|
|
else None
|
|
)
|
|
|
|
runs_scored = advancement.runs_scored
|
|
outs_recorded = advancement.outs_recorded
|
|
|
|
# Step 11: Create PlayResult
|
|
return PlayResult(
|
|
outcome=final_outcome,
|
|
outs_recorded=outs_recorded,
|
|
runs_scored=runs_scored,
|
|
batter_result=batter_result,
|
|
runners_advanced=runners_advanced,
|
|
description=f"X-Check {position}: {base_result} → {converted_result} + {error_result} = {final_outcome.value}",
|
|
ab_roll=ab_roll,
|
|
hit_location=position,
|
|
is_hit=final_outcome.is_hit(),
|
|
is_out=final_outcome.is_out(),
|
|
x_check_details=x_check_details,
|
|
)
|
|
|
|
def _adjust_range_for_defensive_position(
|
|
self, base_range: int, position: str, defensive_decision: DefensiveDecision
|
|
) -> int:
|
|
"""
|
|
Adjust defense range for defensive positioning.
|
|
|
|
If defender is playing in, range increases by 1 (max 5).
|
|
|
|
Args:
|
|
base_range: Defender's base range (1-5)
|
|
position: Position code
|
|
defensive_decision: Current defensive positioning
|
|
|
|
Returns:
|
|
Adjusted range (1-5)
|
|
"""
|
|
playing_in = False
|
|
|
|
if (
|
|
defensive_decision.infield_depth == "corners_in"
|
|
and position in ["1B", "3B", "P", "C"]
|
|
or defensive_decision.infield_depth == "infield_in"
|
|
and position in ["1B", "2B", "3B", "SS", "P", "C"]
|
|
):
|
|
playing_in = True
|
|
|
|
if playing_in:
|
|
adjusted = min(base_range + 1, 5)
|
|
logger.debug(f"{position} playing in: range {base_range} → {adjusted}")
|
|
return adjusted
|
|
|
|
return base_range
|
|
|
|
def _lookup_defense_table(
|
|
self, position: str, d20_roll: int, defense_range: int
|
|
) -> str:
|
|
"""
|
|
Look up base result from defense table.
|
|
|
|
Args:
|
|
position: Position code (determines which table)
|
|
d20_roll: 1-20 (row selector)
|
|
defense_range: 1-5 (column selector)
|
|
|
|
Returns:
|
|
Base result code (G1, F2, SI2, SPD, etc.)
|
|
"""
|
|
# Determine which table to use
|
|
if position in ["P", "C", "1B", "2B", "3B", "SS"]:
|
|
if position == "C":
|
|
table = CATCHER_DEFENSE_TABLE
|
|
else:
|
|
table = INFIELD_DEFENSE_TABLE
|
|
else: # LF, CF, RF
|
|
table = OUTFIELD_DEFENSE_TABLE
|
|
|
|
# Lookup (0-indexed)
|
|
row = d20_roll - 1
|
|
col = defense_range - 1
|
|
|
|
result = table[row][col]
|
|
logger.debug(f"Defense table[{d20_roll}][{defense_range}] = {result}")
|
|
|
|
return result
|
|
|
|
def _apply_hash_conversion(
|
|
self,
|
|
result: str,
|
|
position: str,
|
|
adjusted_range: int,
|
|
base_range: int,
|
|
state: GameState,
|
|
batter_hand: str,
|
|
) -> str:
|
|
"""
|
|
Convert G2# or G3# to SI2 if conditions are met.
|
|
|
|
Conversion happens if:
|
|
a) Infielder is playing in (range was adjusted), OR
|
|
b) Infielder is responsible for holding a runner
|
|
|
|
Args:
|
|
result: 'G2#' or 'G3#'
|
|
position: Position code
|
|
adjusted_range: Range after playing-in adjustment
|
|
base_range: Original range
|
|
state: Current game state
|
|
batter_hand: 'L' or 'R'
|
|
|
|
Returns:
|
|
'SI2' if converted, otherwise original result without # ('G2' or 'G3')
|
|
"""
|
|
# Check condition (a): playing in
|
|
if adjusted_range > base_range:
|
|
logger.debug(f"{result} → SI2 (defender playing in)")
|
|
return "SI2"
|
|
|
|
# Check condition (b): holding runner
|
|
runner_bases = [base for base, _ in state.get_all_runners()]
|
|
|
|
holding_positions = get_fielders_holding_runners(runner_bases, batter_hand)
|
|
|
|
if position in holding_positions:
|
|
logger.debug(f"{result} → SI2 (defender holding runner)")
|
|
return "SI2"
|
|
|
|
# No conversion - remove # suffix
|
|
base_result = result.replace("#", "")
|
|
logger.debug(f"{result} → {base_result} (no conversion)")
|
|
return base_result
|
|
|
|
def _lookup_error_chart(
|
|
self, position: str, error_rating: int, d6_roll: int
|
|
) -> str:
|
|
"""
|
|
Look up error result from error chart.
|
|
|
|
Args:
|
|
position: Position code
|
|
error_rating: Defender's error rating (0-25 for outfield, varies for infield)
|
|
d6_roll: Sum of 3d6 (3-18)
|
|
|
|
Returns:
|
|
Error result: 'NO', 'E1', 'E2', 'E3', or 'RP'
|
|
"""
|
|
error_chart = get_error_chart_for_position(position)
|
|
|
|
# Get row for this error rating
|
|
if error_rating not in error_chart:
|
|
logger.warning(f"Error rating {error_rating} not in chart, using 0")
|
|
error_rating = 0
|
|
|
|
rating_row = error_chart[error_rating]
|
|
|
|
# Check each error type in priority order
|
|
for error_type in ["RP", "E3", "E2", "E1"]:
|
|
if d6_roll in rating_row[error_type]:
|
|
logger.debug(f"Error chart: 3d6={d6_roll} → {error_type}")
|
|
return error_type
|
|
|
|
# No error
|
|
logger.debug(f"Error chart: 3d6={d6_roll} → NO")
|
|
return "NO"
|
|
|
|
def _get_x_check_advancement(
|
|
self,
|
|
converted_result: str,
|
|
error_result: str,
|
|
state: "GameState",
|
|
defender_in: bool,
|
|
hit_location: str,
|
|
defensive_decision: "DefensiveDecision",
|
|
) -> "AdvancementResult":
|
|
"""
|
|
Get runner advancement for X-Check result.
|
|
|
|
Calls appropriate x_check function based on result type:
|
|
- G1, G2, G3: Groundball advancement (uses x_check tables)
|
|
- F1, F2, F3: Flyball advancement (uses x_check tables)
|
|
- SI1, SI2, DO2, DO3, TR3: Hit advancement (uses existing methods + error bonuses)
|
|
- FO, PO: Out advancement (error overrides out, so just error advancement)
|
|
|
|
Args:
|
|
converted_result: Result after SPD test and hash conversion
|
|
error_result: Error type (NO, E1, E2, E3, RP)
|
|
state: Current game state (for runner positions)
|
|
defender_in: Whether defender was playing in
|
|
hit_location: Position where ball was hit (fielder's position)
|
|
defensive_decision: Defensive positioning decision
|
|
|
|
Returns:
|
|
AdvancementResult with runner movements
|
|
|
|
Raises:
|
|
ValueError: If result type is not recognized
|
|
"""
|
|
from app.core.runner_advancement import (
|
|
x_check_f1,
|
|
x_check_f2,
|
|
x_check_f3,
|
|
x_check_g1,
|
|
x_check_g2,
|
|
x_check_g3,
|
|
)
|
|
|
|
on_base_code = state.current_on_base_code
|
|
|
|
# Groundball results
|
|
if converted_result == "G1":
|
|
return x_check_g1(
|
|
on_base_code,
|
|
defender_in,
|
|
error_result,
|
|
state,
|
|
hit_location,
|
|
defensive_decision,
|
|
)
|
|
if converted_result == "G2":
|
|
return x_check_g2(
|
|
on_base_code,
|
|
defender_in,
|
|
error_result,
|
|
state,
|
|
hit_location,
|
|
defensive_decision,
|
|
)
|
|
if converted_result == "G3":
|
|
return x_check_g3(
|
|
on_base_code,
|
|
defender_in,
|
|
error_result,
|
|
state,
|
|
hit_location,
|
|
defensive_decision,
|
|
)
|
|
|
|
# Flyball results
|
|
if converted_result == "F1":
|
|
return x_check_f1(on_base_code, error_result, state, hit_location)
|
|
if converted_result == "F2":
|
|
return x_check_f2(on_base_code, error_result, state, hit_location)
|
|
if converted_result == "F3":
|
|
return x_check_f3(on_base_code, error_result, state, hit_location)
|
|
|
|
# Hit results - use existing advancement methods + error bonuses
|
|
if converted_result in ["SI1", "SI2", "DO2", "DO3", "TR3"]:
|
|
return self._get_hit_advancement_with_error(
|
|
converted_result, error_result, state
|
|
)
|
|
|
|
# Out results - error overrides out, so just error advancement
|
|
if converted_result in ["FO", "PO"]:
|
|
return self._get_out_advancement_with_error(error_result, state)
|
|
|
|
raise ValueError(f"Unknown X-Check result type: {converted_result}")
|
|
|
|
def _get_hit_advancement_with_error(
|
|
self, hit_type: str, error_result: str, state: "GameState"
|
|
) -> "AdvancementResult":
|
|
"""
|
|
Get runner advancement for X-Check hit with error.
|
|
|
|
Uses existing advancement methods and adds error bonuses:
|
|
- NO: No bonus
|
|
- E1: +1 base
|
|
- E2: +2 bases
|
|
- E3: +3 bases
|
|
- RP: Treat as E3
|
|
|
|
Args:
|
|
hit_type: SI1, SI2, DO2, DO3, or TR3
|
|
error_result: Error type
|
|
state: Current game state (for runner positions)
|
|
|
|
Returns:
|
|
AdvancementResult with movements
|
|
"""
|
|
from app.core.runner_advancement import AdvancementResult, RunnerMovement
|
|
|
|
# Get base advancement (without error)
|
|
|
|
if hit_type == "SI1":
|
|
base_advances = self._advance_on_single_1(state)
|
|
batter_reaches = 1
|
|
elif hit_type == "SI2":
|
|
base_advances = self._advance_on_single_2(state)
|
|
batter_reaches = 1
|
|
elif hit_type == "DO2":
|
|
base_advances = self._advance_on_double_2(state)
|
|
batter_reaches = 2
|
|
elif hit_type == "DO3":
|
|
base_advances = self._advance_on_double_3(state)
|
|
batter_reaches = (
|
|
2 # DO = double (batter to 2B), 3 = runners advance 3 bases
|
|
)
|
|
elif hit_type == "TR3":
|
|
base_advances = self._advance_on_triple(state)
|
|
batter_reaches = 3
|
|
else:
|
|
raise ValueError(f"Unknown hit type: {hit_type}")
|
|
|
|
# Apply error bonus
|
|
error_bonus = {"NO": 0, "E1": 1, "E2": 2, "E3": 3, "RP": 3}.get(error_result, 0)
|
|
|
|
movements = []
|
|
runs_scored = 0
|
|
|
|
# Add batter movement (with error bonus)
|
|
batter_final = min(batter_reaches + error_bonus, 4)
|
|
if batter_final == 4:
|
|
runs_scored += 1
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=0, # Placeholder - will be set by game engine
|
|
from_base=0,
|
|
to_base=batter_final,
|
|
is_out=False,
|
|
)
|
|
)
|
|
|
|
# Add runner movements (with error bonus)
|
|
for from_base, to_base in base_advances:
|
|
final_base = min(to_base + error_bonus, 4)
|
|
if final_base == 4:
|
|
runs_scored += 1
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=0, # Placeholder
|
|
from_base=from_base,
|
|
to_base=final_base,
|
|
is_out=False,
|
|
)
|
|
)
|
|
|
|
return AdvancementResult(
|
|
movements=movements,
|
|
outs_recorded=0,
|
|
runs_scored=runs_scored,
|
|
result_type=None,
|
|
description=f"X-Check {hit_type} + {error_result}",
|
|
)
|
|
|
|
def _get_out_advancement_with_error(
|
|
self, error_result: str, state: "GameState"
|
|
) -> "AdvancementResult":
|
|
"""
|
|
Get runner advancement for X-Check out with error.
|
|
|
|
When an out has an error, the out is negated and it becomes an error play.
|
|
Runners advance based on error severity:
|
|
- E1: All advance 1 base
|
|
- E2: All advance 2 bases
|
|
- E3: All advance 3 bases
|
|
- RP: All advance 3 bases
|
|
|
|
Args:
|
|
error_result: Error type (should not be 'NO' for outs)
|
|
state: Current game state (for runner positions)
|
|
|
|
Returns:
|
|
AdvancementResult with movements
|
|
"""
|
|
from app.core.runner_advancement import AdvancementResult, RunnerMovement
|
|
|
|
if error_result == "NO":
|
|
# No error on out - just record out
|
|
return AdvancementResult(
|
|
movements=[
|
|
RunnerMovement(lineup_id=0, from_base=0, to_base=0, is_out=True)
|
|
],
|
|
outs_recorded=1,
|
|
runs_scored=0,
|
|
result_type=None,
|
|
description="X-Check out (no error)",
|
|
)
|
|
|
|
# Error prevents out - batter and runners advance
|
|
error_bonus = {"E1": 1, "E2": 2, "E3": 3, "RP": 3}[error_result]
|
|
movements = []
|
|
runs_scored = 0
|
|
|
|
# Batter reaches base based on error severity
|
|
batter_final = min(error_bonus, 4)
|
|
if batter_final == 4:
|
|
runs_scored += 1
|
|
movements.append(
|
|
RunnerMovement(lineup_id=0, from_base=0, to_base=batter_final, is_out=False)
|
|
)
|
|
|
|
# All runners advance by error bonus
|
|
for base, _ in state.get_all_runners():
|
|
final_base = min(base + error_bonus, 4)
|
|
if final_base == 4:
|
|
runs_scored += 1
|
|
movements.append(
|
|
RunnerMovement(
|
|
lineup_id=0, from_base=base, to_base=final_base, is_out=False
|
|
)
|
|
)
|
|
|
|
return AdvancementResult(
|
|
movements=movements,
|
|
outs_recorded=0,
|
|
runs_scored=runs_scored,
|
|
result_type=None,
|
|
description=f"X-Check out + {error_result} (error overrides out)",
|
|
)
|
|
|
|
def _advance_on_triple(self, state: "GameState") -> list[tuple[int, int]]:
|
|
"""Calculate runner advancement on triple (all runners score)."""
|
|
return [(base, 4) for base, _ in state.get_all_runners()]
|
|
|
|
def _determine_final_x_check_outcome(
|
|
self, converted_result: str, error_result: str
|
|
) -> tuple[PlayOutcome, str]:
|
|
"""
|
|
Determine final outcome and hit_type from converted result + error.
|
|
|
|
Logic:
|
|
- If Out + Error: outcome = ERROR, hit_type = '{result}_plus_error_{n}'
|
|
- If Hit + Error: outcome = hit type, hit_type = '{result}_plus_error_{n}'
|
|
- If No Error: outcome = base outcome, hit_type = '{result}_no_error'
|
|
- If Rare Play: hit_type includes '_rare_play'
|
|
|
|
Args:
|
|
converted_result: Result after SPD/# conversions (G1, F2, SI2, etc.)
|
|
error_result: 'NO', 'E1', 'E2', 'E3', 'RP'
|
|
|
|
Returns:
|
|
Tuple of (final_outcome, hit_type)
|
|
"""
|
|
# Map result codes to PlayOutcome
|
|
result_map = {
|
|
"SI1": PlayOutcome.SINGLE_1,
|
|
"SI2": PlayOutcome.SINGLE_2,
|
|
"DO2": PlayOutcome.DOUBLE_2,
|
|
"DO3": PlayOutcome.DOUBLE_3,
|
|
"TR3": PlayOutcome.TRIPLE,
|
|
"G1": PlayOutcome.GROUNDBALL_B,
|
|
"G2": PlayOutcome.GROUNDBALL_B,
|
|
"G3": PlayOutcome.GROUNDBALL_C,
|
|
"F1": PlayOutcome.FLYOUT_A,
|
|
"F2": PlayOutcome.FLYOUT_B,
|
|
"F3": PlayOutcome.FLYOUT_C,
|
|
"FO": PlayOutcome.LINEOUT,
|
|
"PO": PlayOutcome.POPOUT,
|
|
}
|
|
|
|
base_outcome = result_map.get(converted_result)
|
|
if not base_outcome:
|
|
raise ValueError(f"Unknown X-Check result: {converted_result}")
|
|
|
|
# Build hit_type string
|
|
result_lower = converted_result.lower()
|
|
|
|
if error_result == "NO":
|
|
# No error
|
|
hit_type = f"{result_lower}_no_error"
|
|
final_outcome = base_outcome
|
|
|
|
elif error_result == "RP":
|
|
# Rare play
|
|
hit_type = f"{result_lower}_rare_play"
|
|
# Rare plays are treated like errors for stats
|
|
final_outcome = PlayOutcome.ERROR
|
|
|
|
else:
|
|
# E1, E2, E3
|
|
error_num = error_result[1] # Extract '1', '2', or '3'
|
|
hit_type = f"{result_lower}_plus_error_{error_num}"
|
|
|
|
# If base was an out, error overrides to ERROR outcome
|
|
if base_outcome.is_out():
|
|
final_outcome = PlayOutcome.ERROR
|
|
else:
|
|
# Hit + error: keep hit outcome
|
|
final_outcome = base_outcome
|
|
|
|
logger.info(
|
|
f"Final: {converted_result} + {error_result} → {final_outcome.value} ({hit_type})"
|
|
)
|
|
|
|
return final_outcome, hit_type
|