strat-gameplay-webapp/backend/app/core/play_resolver.py
Cal Corum 02e816a57f CLAUDE: Phase 3E-Main - Position Ratings Integration for X-Check Resolution
Complete integration of position ratings system enabling X-Check defensive plays
to use actual player ratings from PD API with intelligent fallbacks for SBA.

**Live API Testing Verified**: 
- Endpoint: GET https://pd.manticorum.com/api/v2/cardpositions?player_id=8807
- Response: 200 OK, 7 positions retrieved successfully
- Cache performance: 16,601x faster (API: 0.214s, Cache: 0.000s)
- Data quality: Real defensive ratings (range 1-5, error 0-88)

**Architecture Overview**:
- League-aware: PD league fetches ratings from API, SBA uses defaults
- StateManager integration: Defenders retrieved from lineup cache
- Self-contained GameState: All data needed for X-Check in memory
- Graceful degradation: Falls back to league averages if ratings unavailable

**Files Created**:

1. app/services/pd_api_client.py (NEW)
   - PdApiClient class for PD API integration
   - Endpoint: GET /api/v2/cardpositions?player_id={id}&position={pos}
   - Async HTTP client using httpx (already in requirements.txt)
   - Optional position filtering: get_position_ratings(8807, ['SS', '2B'])
   - Returns List[PositionRating] for all positions player can play
   - Handles both list and dict response formats
   - Comprehensive error handling with logging

2. app/services/position_rating_service.py (NEW)
   - PositionRatingService with in-memory caching
   - get_ratings_for_card(card_id, league_id) - All positions
   - get_rating_for_position(card_id, position, league_id) - Specific position
   - Cache performance: >16,000x faster on hits
   - Singleton pattern: position_rating_service instance
   - TODO Phase 3E-Final: Upgrade to Redis

3. app/services/__init__.py (NEW)
   - Package exports for clean imports

4. test_pd_api_live.py (NEW)
   - Live API integration test script
   - Tests with real PD player 8807 (7 positions)
   - Verifies caching, filtering, GameState integration
   - Run: `python test_pd_api_live.py`

5. test_pd_api_mock.py (NEW)
   - Mock integration test for CI/CD
   - Demonstrates flow without API dependency

6. tests/integration/test_position_ratings_api.py (NEW)
   - Pytest integration test suite
   - Real API tests with player 8807
   - Cache verification, SBA skip logic
   - Full end-to-end GameState flow

**Files Modified**:

1. app/models/game_models.py
   - LineupPlayerState: Added position_rating field (Optional[PositionRating])
   - GameState: Added get_defender_for_position(position, state_manager)
   - Uses StateManager's lineup cache to find active defender by position
   - Iterates through lineup.players to match position + is_active

2. app/config/league_configs.py
   - SbaConfig: Added supports_position_ratings() → False
   - PdConfig: Added supports_position_ratings() → True
   - Enables league-specific behavior without hardcoded conditionals

3. app/core/play_resolver.py
   - __init__: Added state_manager parameter for X-Check defender lookup
   - _resolve_x_check(): Replaced placeholder defender ratings with actual lookup
   - Uses league config to check if ratings supported
   - Fetches defender via state.get_defender_for_position()
   - Falls back to defaults (range=3, error=15) if ratings unavailable
   - Detailed logging for debugging rating lookups

4. app/core/game_engine.py
   - Added _load_position_ratings_for_lineup() method
   - Loads all position ratings at game start for PD league
   - Skips loading for SBA (league config check)
   - start_game(): Calls rating loader for both teams before marking active
   - PlayResolver instantiation: Now passes state_manager parameter
   - Logs: "Loaded X/9 position ratings for team Y"

**X-Check Resolution Flow**:
1. League check: config.supports_position_ratings()?
2. Get defender: state.get_defender_for_position(pos, state_manager)
3. If PD + defender.position_rating exists: Use actual range/error
4. Else if defender found: Use defaults (range=3, error=15)
5. Else: Log warning, use defaults

**Position Rating Loading (Game Start)**:
1. Check if league supports ratings (PD only)
2. Get lineup from StateManager cache
3. For each player:
   - Fetch rating from position_rating_service (with caching)
   - Set player.position_rating field
4. Cache API responses (16,000x faster on subsequent access)
5. Log success: "Loaded X/9 position ratings for team Y"

**Live Test Results (Player 8807)**:
```
Position   Range    Error    Innings
CF         3        2        372
2B         3        8        212
SS         4        12       159
RF         2        2        74
LF         3        2        62
1B         4        0        46
3B         3        65       34
```

**Testing**:
-  Live API: Player 8807 → 7 positions retrieved successfully
-  Caching: 16,601x performance improvement
-  League config: SBA=False, PD=True
-  GameState integration: Defender lookup working
-  Existing tests: 27/28 config tests passing (1 pre-existing URL failure)
-  Syntax validation: All files compile successfully

**Benefits**:
-  X-Check now uses real defensive ratings in PD league
-  SBA league continues working with manual entry (uses defaults)
-  No breaking changes to existing functionality
-  Graceful degradation if API unavailable
-  In-memory caching reduces API calls by >99%
-  League-agnostic design via config system
-  Production-ready with live API verification

**Phase 3E Status**: Main complete (85% → 90%)
**Next**: Phase 3E-Final (WebSocket events, Redis upgrade, full defensive lineup)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 21:00:37 -06:00

1232 lines
44 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 Optional, List, Tuple, TYPE_CHECKING
import pendulum
from app.core.dice import dice_system
from app.core.roll_types import AbRoll, RollType
from app.core.runner_advancement import AdvancementResult, RunnerAdvancement
from app.models.game_models import GameState, DefensiveDecision, OffensiveDecision, ManualOutcomeSubmission, XCheckResult
from app.config import PlayOutcome, get_league_config
from app.config.result_charts import calculate_hit_location, PdAutoResultChart, ManualResultChart
from app.config.common_x_check_tables import (
INFIELD_DEFENSE_TABLE,
OUTFIELD_DEFENSE_TABLE,
CATCHER_DEFENSE_TABLE,
get_error_chart_for_position,
get_fielders_holding_runners,
)
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: Optional[int] # 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: Optional[str] = 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: Optional[XCheckResult] = 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: Optional[any] = 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: Optional[str],
state: GameState,
defensive_decision: DefensiveDecision,
offensive_decision: OffensiveDecision,
ab_roll: AbRoll
) -> 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
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
)
# ==================== Groundballs ====================
elif outcome in [PlayOutcome.GROUNDBALL_A, PlayOutcome.GROUNDBALL_B, PlayOutcome.GROUNDBALL_C]:
# 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 ====================
elif outcome in [PlayOutcome.FLYOUT_A, PlayOutcome.FLYOUT_B, PlayOutcome.FLYOUT_BQ, PlayOutcome.FLYOUT_C]:
# 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 ====================
elif 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
)
elif 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 ====================
elif 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
)
elif 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
)
elif outcome == PlayOutcome.SINGLE_UNCAPPED:
# 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 ====================
elif 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
)
elif outcome == PlayOutcome.DOUBLE_3:
# Double with extra advancement (batter to 3rd)
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=3, # Batter goes to 3rd
runners_advanced=runners_advanced,
description="Double to right-center gap (batter to 3rd)",
ab_roll=ab_roll,
is_hit=True
)
elif outcome == PlayOutcome.DOUBLE_UNCAPPED:
# 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
)
elif 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
)
elif 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
)
elif 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
)
elif 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 ====================
elif 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
)
else:
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 double"""
advances = []
# All runners score on double (simplified)
for base, _ in state.get_all_runners():
advances.append((base, 4))
return advances
def _advance_on_double_3(self, state: GameState) -> List[tuple[int, int]]:
"""Calculate runner advancement on double"""
advances = []
# All runners score on double (simplified)
for base, _ in state.get_all_runners():
advances.append((base, 4))
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
) -> PlayResult:
"""
Resolve X-Check play with defense range and error tables.
Process:
1. Get defender and their ratings
2. Roll 1d20 + 3d6
3. Adjust range if playing in
4. Look up base result from defense table
5. Apply SPD test if needed
6. Apply G2#/G3# conversion if applicable
7. Look up error result from error chart
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
Returns:
PlayResult with x_check_details populated
Raises:
ValueError: If defender has no position rating
"""
logger.info(f"Resolving X-Check to {position}")
# 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
)
# Step 4: Look up base result
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
spd_test_roll = None
spd_test_target = None
spd_test_passed = None
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
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']:
playing_in = True
elif 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_g1, x_check_g2, x_check_g3,
x_check_f1, x_check_f2, x_check_f3,
AdvancementResult, RunnerMovement
)
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)
elif converted_result == 'G2':
return x_check_g2(on_base_code, defender_in, error_result, state, hit_location, defensive_decision)
elif converted_result == 'G3':
return x_check_g3(on_base_code, defender_in, error_result, state, hit_location, defensive_decision)
# Flyball results
elif converted_result == 'F1':
return x_check_f1(on_base_code, error_result, state, hit_location)
elif converted_result == 'F2':
return x_check_f2(on_base_code, error_result, state, hit_location)
elif converted_result == 'F3':
return x_check_f3(on_base_code, error_result, state, hit_location)
# Hit results - use existing advancement methods + error bonuses
elif 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
elif converted_result in ['FO', 'PO']:
return self._get_out_advancement_with_error(error_result, state)
else:
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 = 3
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}[error_result]
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