""" Play Resolver - Resolves play outcomes based on dice rolls. Uses our advanced dice system with AbRoll for at-bat resolution. Simplified result charts for Phase 2 MVP. Author: Claude Date: 2025-10-24 Updated: 2025-10-29 - Integrated universal PlayOutcome enum """ import logging from dataclasses import dataclass from typing import Optional, List from app.core.dice import dice_system from app.core.roll_types import AbRoll from app.models.game_models import GameState, DefensiveDecision, OffensiveDecision from app.config import PlayOutcome 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 # Statistics is_hit: bool = False is_out: bool = False is_walk: bool = False class SimplifiedResultChart: """ Simplified SBA result chart for Phase 2 Real implementation will load from config files and consider: - Batter card stats - Pitcher card stats - Defensive alignment - Offensive approach This provides basic outcomes for MVP testing. """ @staticmethod def get_outcome(ab_roll: AbRoll) -> PlayOutcome: """ Map AbRoll to outcome (simplified) Uses the chaos_d20 value for outcome determination. Checks for wild pitch/passed ball first. """ # Check for wild pitch/passed ball if ab_roll.check_wild_pitch: # chaos_d20 == 1, use resolution_d20 to confirm if ab_roll.resolution_d20 <= 10: # 50% chance it actually happens return PlayOutcome.WILD_PITCH # Otherwise treat as ball/foul return PlayOutcome.STRIKEOUT # Simplified if ab_roll.check_passed_ball: # chaos_d20 == 2, use resolution_d20 to confirm if ab_roll.resolution_d20 <= 10: # 50% chance return PlayOutcome.PASSED_BALL # Otherwise treat as ball/foul return PlayOutcome.STRIKEOUT # Simplified # Normal at-bat resolution using chaos_d20 roll = ab_roll.chaos_d20 # Strikeouts if roll <= 5: return PlayOutcome.STRIKEOUT # Groundballs - distribute across 3 variants elif roll == 6: return PlayOutcome.GROUNDBALL_A # DP opportunity elif roll == 7: return PlayOutcome.GROUNDBALL_B elif roll == 8: return PlayOutcome.GROUNDBALL_C # Flyouts - distribute across 3 variants elif roll == 9: return PlayOutcome.FLYOUT_A elif roll == 10: return PlayOutcome.FLYOUT_B elif roll == 11: return PlayOutcome.FLYOUT_C # Walks elif roll in [12, 13]: return PlayOutcome.WALK # Singles - distribute between variants elif roll == 14: return PlayOutcome.SINGLE_1 elif roll == 15: return PlayOutcome.SINGLE_2 # Doubles elif roll == 16: return PlayOutcome.DOUBLE_2 elif roll == 17: return PlayOutcome.DOUBLE_3 # Lineout elif roll == 18: return PlayOutcome.LINEOUT # Triple elif roll == 19: return PlayOutcome.TRIPLE # Home run else: # 20 return PlayOutcome.HOMERUN class PlayResolver: """Resolves play outcomes based on dice rolls and game state""" def __init__(self): self.result_chart = SimplifiedResultChart() def resolve_play( self, state: GameState, defensive_decision: DefensiveDecision, offensive_decision: OffensiveDecision ) -> PlayResult: """ Resolve a complete play Args: state: Current game state defensive_decision: Defensive team's choices offensive_decision: Offensive team's choices Returns: PlayResult with complete outcome """ logger.info(f"Resolving play - Inning {state.inning} {state.half}, {state.outs} outs") # Roll dice using our advanced AbRoll system ab_roll = dice_system.roll_ab( league_id=state.league_id, game_id=state.game_id ) logger.info(f"AB Roll: {ab_roll}") # Get base outcome from chart outcome = self.result_chart.get_outcome(ab_roll) logger.info(f"Base outcome: {outcome}") # Apply decisions (simplified for Phase 2) # TODO: Implement full decision logic in Phase 3 # Resolve outcome details result = self._resolve_outcome(outcome, state, ab_roll) logger.info(f"Play result: {result.description}") return result def _resolve_outcome( self, outcome: PlayOutcome, state: GameState, ab_roll: AbRoll ) -> PlayResult: """Resolve specific outcome type""" # ==================== 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, is_out=True ) # ==================== Groundballs ==================== elif outcome == PlayOutcome.GROUNDBALL_A: # TODO Phase 3: Check for double play opportunity # For now, treat as groundout return PlayResult( outcome=outcome, outs_recorded=1, runs_scored=0, batter_result=None, runners_advanced=[], description="Groundball to shortstop (DP opportunity)", ab_roll=ab_roll, is_out=True ) elif outcome == PlayOutcome.GROUNDBALL_B: return PlayResult( outcome=outcome, outs_recorded=1, runs_scored=0, batter_result=None, runners_advanced=[], description="Groundball to second base", ab_roll=ab_roll, is_out=True ) elif outcome == PlayOutcome.GROUNDBALL_C: return PlayResult( outcome=outcome, outs_recorded=1, runs_scored=0, batter_result=None, runners_advanced=[], description="Groundball to third base", ab_roll=ab_roll, is_out=True ) # ==================== Flyouts ==================== elif outcome == PlayOutcome.FLYOUT_A: return PlayResult( outcome=outcome, outs_recorded=1, runs_scored=0, batter_result=None, runners_advanced=[], description="Flyout to left field", ab_roll=ab_roll, is_out=True ) elif outcome == PlayOutcome.FLYOUT_B: return PlayResult( outcome=outcome, outs_recorded=1, runs_scored=0, batter_result=None, runners_advanced=[], description="Flyout to center field", ab_roll=ab_roll, is_out=True ) elif outcome == PlayOutcome.FLYOUT_C: return PlayResult( outcome=outcome, outs_recorded=1, runs_scored=0, batter_result=None, runners_advanced=[], description="Flyout to right field", ab_roll=ab_roll, is_out=True ) # ==================== 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(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(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(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(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(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(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 ) 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(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 (simplified - usually would) advances.append((2, 4)) if state.on_first: # Runner on first to third (simplified) advances.append((1, 3)) return advances def _advance_on_double(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 # Singleton instance play_resolver = PlayResolver()