""" 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 """ import logging from dataclasses import dataclass from typing import Optional, List from enum import Enum from app.core.dice import dice_system from app.core.roll_types import AbRoll from app.models.game_models import GameState, RunnerState, DefensiveDecision, OffensiveDecision logger = logging.getLogger(f'{__name__}.PlayResolver') class PlayOutcome(str, Enum): """Possible play outcomes""" # Outs STRIKEOUT = "strikeout" GROUNDOUT = "groundout" FLYOUT = "flyout" LINEOUT = "lineout" DOUBLE_PLAY = "double_play" # Hits SINGLE = "single" DOUBLE = "double" TRIPLE = "triple" HOMERUN = "homerun" # Other WALK = "walk" HIT_BY_PITCH = "hbp" ERROR = "error" WILD_PITCH = "wild_pitch" PASSED_BALL = "passed_ball" @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 check_d20 value for outcome determination. Checks for wild pitch/passed ball first. """ # Check for wild pitch/passed ball if ab_roll.check_wild_pitch: # check_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: # check_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 check_d20 roll = ab_roll.check_d20 if roll <= 5: return PlayOutcome.STRIKEOUT elif roll <= 10: return PlayOutcome.GROUNDOUT elif roll <= 13: return PlayOutcome.FLYOUT elif roll <= 15: return PlayOutcome.WALK elif roll <= 17: return PlayOutcome.SINGLE elif roll <= 18: return PlayOutcome.DOUBLE elif roll == 19: return PlayOutcome.TRIPLE 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""" 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 ) elif outcome == PlayOutcome.GROUNDOUT: # Simple groundout - runners don't advance return PlayResult( outcome=outcome, outs_recorded=1, runs_scored=0, batter_result=None, runners_advanced=[], description="Groundout to shortstop", ab_roll=ab_roll, is_out=True ) elif outcome == PlayOutcome.FLYOUT: 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.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 ) elif outcome == PlayOutcome.SINGLE: # Single - batter to first, runners advance 1-2 bases 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.DOUBLE: 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 right-center", ab_roll=ab_roll, is_hit=True ) elif outcome == PlayOutcome.TRIPLE: # All runners score runs_scored = len(state.runners) return PlayResult( outcome=outcome, outs_recorded=0, runs_scored=runs_scored, batter_result=3, runners_advanced=[(r.on_base, 4) for r in state.runners], description="Triple to right-center gap", ab_roll=ab_roll, is_hit=True ) elif outcome == PlayOutcome.HOMERUN: # Everyone scores runs_scored = len(state.runners) + 1 return PlayResult( outcome=outcome, outs_recorded=0, runs_scored=runs_scored, batter_result=4, runners_advanced=[(r.on_base, 4) for r in state.runners], description="Home run to left field", ab_roll=ab_roll, is_hit=True ) elif outcome == PlayOutcome.WILD_PITCH: # Runners advance one base runners_advanced = [(r.on_base, r.on_base + 1) for r in state.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 = [(r.on_base, r.on_base + 1) for r in state.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 any(r.on_base == 1 for r in state.runners): # First occupied - check second if any(r.on_base == 2 for r in state.runners): # Bases loaded scenario if any(r.on_base == 3 for r in state.runners): # 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 = [] for runner in state.runners: if runner.on_base == 3: # Runner on third scores advances.append((3, 4)) elif runner.on_base == 2: # Runner on second scores (simplified - usually would) advances.append((2, 4)) elif runner.on_base == 1: # 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 = [] for runner in state.runners: # All runners score on double (simplified) advances.append((runner.on_base, 4)) return advances # Singleton instance play_resolver = PlayResolver()