""" Play outcome definitions for card-based resolution system. Both SBA and PD leagues use the same resolution mechanics: 1. Roll 1d6 → determines column (1-3: batter card, 4-6: pitcher card) 2. Roll 2d6 → selects row 2-12 on that card 3. Roll 1d20 → resolves split results if needed 4. Outcome from card is a PlayOutcome enum value League Differences: - PD: Card data digitized in PdBattingRating/PdPitchingRating Can auto-resolve using probabilities OR manual selection - SBA: Physical cards only (not digitized) Players read physical cards and manually enter outcomes This module defines: - PlayOutcome enum (universal outcome types) - ResultChart abstract base class - Helper functions for hit location calculation Author: Claude Date: 2025-10-28, Updated 2025-10-30 """ import logging import random from abc import ABC, abstractmethod from enum import Enum from typing import TYPE_CHECKING if TYPE_CHECKING: from app.core.dice import AbRoll from app.models.game_models import GameState from app.models.player_models import BasePlayer logger = logging.getLogger(f"{__name__}") class PlayOutcome(str, Enum): """ Universal play outcome types for both SBA and PD leagues. These represent all possible results that can appear on player cards. Each outcome triggers specific game logic in the PlayResolver. Usage: - PD: Outcomes determined from PdBattingRating/PdPitchingRating data - SBA: Outcomes manually entered by players reading physical cards """ # ==================== Outs ==================== STRIKEOUT = "strikeout" # Groundballs - 3 variants for different defensive outcomes GROUNDBALL_A = "groundball_a" # Double play if possible, else groundout GROUNDBALL_B = "groundball_b" # Standard groundout GROUNDBALL_C = "groundball_c" # Standard groundout # Flyouts - 4 variants for different trajectories/depths FLYOUT_A = "flyout_a" # Deep - all runners advance FLYOUT_B = "flyout_b" # Medium - R3 scores, R2 DECIDE, R1 holds FLYOUT_BQ = "flyout_bq" # Medium-shallow (fly(b)?) - R3 DECIDE, R2 holds, R1 holds FLYOUT_C = "flyout_c" # Shallow - all runners hold LINEOUT = "lineout" POPOUT = "popout" # ==================== Hits ==================== # Singles - variants for different advancement rules SINGLE_1 = "single_1" # Single with standard advancement SINGLE_2 = "single_2" # Single with enhanced advancement SINGLE_UNCAPPED = "single_uncapped" # si(cf) - pitching card, decision tree # Doubles - variants for batter advancement DOUBLE_2 = "double_2" # Double to 2nd base DOUBLE_3 = "double_3" # Double to 3rd base (extra advancement) DOUBLE_UNCAPPED = "double_uncapped" # do(cf) - pitching card, decision tree TRIPLE = "triple" HOMERUN = "homerun" # ==================== Walks/HBP ==================== WALK = "walk" HIT_BY_PITCH = "hbp" INTENTIONAL_WALK = "intentional_walk" # ==================== Errors ==================== ERROR = "error" # ==================== X-Check Plays ==================== # X-Check: Defense-dependent plays requiring range/error rolls # Resolution determines actual outcome (hit/out/error) X_CHECK = "x_check" # Play.check_pos contains position, resolve via tables # ==================== Interrupt Plays ==================== # These are logged as separate plays with Play.pa = 0 WILD_PITCH = "wild_pitch" # Play.wp = 1 PASSED_BALL = "passed_ball" # Play.pb = 1 STOLEN_BASE = "stolen_base" # Play.sb = 1 CAUGHT_STEALING = "caught_stealing" # Play.cs = 1 BALK = "balk" # Play.balk = 1 / Logged during steal attempt PICK_OFF = "pick_off" # Play.pick_off = 1 / Runner picked off # ==================== Ballpark Power ==================== BP_HOMERUN = "bp_homerun" # Ballpark HR (Play.bphr = 1) BP_SINGLE = "bp_single" # Ballpark single (Play.bp1b = 1) BP_FLYOUT = "bp_flyout" # Ballpark flyout (Play.bpfo = 1) BP_LINEOUT = "bp_lineout" # Ballpark lineout (Play.bplo = 1) # ==================== Helper Methods ==================== def is_hit(self) -> bool: """Check if outcome is a hit (counts toward batting average).""" return self in { self.SINGLE_1, self.SINGLE_2, self.SINGLE_UNCAPPED, self.DOUBLE_2, self.DOUBLE_3, self.DOUBLE_UNCAPPED, self.TRIPLE, self.HOMERUN, self.BP_HOMERUN, self.BP_SINGLE, } def is_out(self) -> bool: """Check if outcome records an out.""" return self in { self.STRIKEOUT, self.GROUNDBALL_A, self.GROUNDBALL_B, self.GROUNDBALL_C, self.FLYOUT_A, self.FLYOUT_B, self.FLYOUT_BQ, self.FLYOUT_C, self.LINEOUT, self.POPOUT, self.CAUGHT_STEALING, self.PICK_OFF, self.BP_FLYOUT, self.BP_LINEOUT, } def is_walk(self) -> bool: """Check if outcome is a walk.""" return self in {self.WALK, self.INTENTIONAL_WALK} def is_uncapped(self) -> bool: """ Check if outcome is uncapped (requires advancement decision). Uncapped hits only trigger decisions when on_base_code > 0. """ return self in {self.SINGLE_UNCAPPED, self.DOUBLE_UNCAPPED} def is_interrupt(self) -> bool: """ Check if outcome is an interrupt play (logged with pa=0). Interrupt plays don't change the batter, only advance runners. """ return self in { self.WILD_PITCH, self.PASSED_BALL, self.STOLEN_BASE, self.CAUGHT_STEALING, self.BALK, self.PICK_OFF, } def is_extra_base_hit(self) -> bool: """Check if outcome is an extra-base hit (2B, 3B, HR).""" return self in { self.DOUBLE_2, self.DOUBLE_3, self.DOUBLE_UNCAPPED, self.TRIPLE, self.HOMERUN, self.BP_HOMERUN, } def is_x_check(self) -> bool: """Check if outcome requires x-check resolution.""" return self == self.X_CHECK def get_bases_advanced(self) -> int: """ Get number of bases batter advances (for standard outcomes). Returns: 0 for outs/walks, 1-4 for hits Note: Uncapped hits return base value; actual advancement determined by decision tree. """ if self in {self.SINGLE_1, self.SINGLE_2, self.SINGLE_UNCAPPED, self.BP_SINGLE}: return 1 if self in {self.DOUBLE_2, self.DOUBLE_3, self.DOUBLE_UNCAPPED}: return 2 if self == self.TRIPLE: return 3 if self in {self.HOMERUN, self.BP_HOMERUN}: return 4 return 0 def requires_hit_location(self) -> bool: """ Check if this outcome requires hit location for runner advancement. Based on advancement charts (Infield In, Infield Back, etc.), only certain outcomes care about specific hit location for determining runner advancement. Returns: True if location matters for advancement logic, False otherwise """ return self in { # Groundballs - location determines which fielder makes play self.GROUNDBALL_A, self.GROUNDBALL_B, self.GROUNDBALL_C, # Flyouts - location affects tag-up opportunities self.FLYOUT_A, self.FLYOUT_B, self.FLYOUT_BQ, self.FLYOUT_C, # Uncapped hits - location determines defender used in interactive play self.SINGLE_UNCAPPED, self.DOUBLE_UNCAPPED, } # ============================================================================ # HIT LOCATION HELPER # ============================================================================ def calculate_hit_location( outcome: PlayOutcome, batter_handedness: str, ) -> str | None: """ Calculate hit location based on outcome and batter handedness. Uses pull rates and handedness to determine which fielder makes the play. Location is critical for runner advancement logic (Task 4). Args: outcome: The play outcome batter_handedness: 'L' or 'R' Returns: Position string ('1B', '2B', 'SS', '3B', 'LF', 'CF', 'RF', 'P', 'C') or None Pull Rate Distribution: - 45% pull side (RHB left, LHB right) - 35% center - 20% opposite field Groundball Locations: P, C, 1B, 2B, SS, 3B (infield) Fly Ball Locations: LF, CF, RF (outfield) """ # Check if location matters for this outcome if not outcome.requires_hit_location(): return None # Determine if groundball or flyball is_groundball = outcome in { PlayOutcome.GROUNDBALL_A, PlayOutcome.GROUNDBALL_B, PlayOutcome.GROUNDBALL_C, } # Roll for distribution roll = random.random() # 0.0 to 1.0 if is_groundball: # Infield locations: 1B, 2B, SS, 3B, P, C if roll < 0.45: # Pull side if batter_handedness == "R": # RHB pulls left (3B, SS) return random.choice(["3B", "SS"]) # LHB # LHB pulls right (1B, 2B) return random.choice(["1B", "2B"]) if roll < 0.80: # Center (45% + 35% = 80%) # Up the middle (2B, SS, P, C) return random.choice(["2B", "SS", "P", "C"]) # Opposite field (20%) if batter_handedness == "R": # RHB opposite is right (1B, 2B) return random.choice(["1B", "2B"]) # LHB # LHB opposite is left (3B, SS) return random.choice(["3B", "SS"]) # Fly ball locations: LF, CF, RF if roll < 0.45: # Pull side if batter_handedness == "R": # RHB pulls left return "LF" # LHB # LHB pulls right return "RF" if roll < 0.80: # Center return "CF" # Opposite field if batter_handedness == "R": # RHB opposite is right return "RF" # LHB # LHB opposite is left return "LF" # ============================================================================ # RESULT CHART ABSTRACTION # ============================================================================ class ResultChart(ABC): """ Abstract base class for result chart implementations. Result charts determine play outcomes and hit locations. Different implementations exist for manual mode (humans read cards) and auto mode (system uses digitized ratings). Manual Mode (SBA + PD manual): - Humans roll dice and read physical cards - Players submit PlayOutcome + optional location - ResultChart not used (direct submission) Auto Mode (PD only): - System uses PdBattingRating/PdPitchingRating - Flips coin to choose batting or pitching card - Rolls against cumulative distribution - Returns outcome + calculated location """ @abstractmethod def get_outcome( self, roll: "AbRoll", state: "GameState", batter: "BasePlayer", pitcher: "BasePlayer", ) -> tuple[PlayOutcome, str | None]: """ Determine play outcome and hit location. Args: roll: Dice roll (column_d6, row_2d6, chaos_d20) state: Current game state batter: Batting player pitcher: Pitching player Returns: tuple: (outcome, hit_location) - outcome: PlayOutcome enum value - hit_location: Position string or None """ pass class ManualResultChart(ResultChart): """ Result chart for manual mode (SBA + PD manual). In manual mode, humans read physical cards and submit outcomes directly via WebSocket. This class exists for interface completeness but should not be called - manual mode doesn't use result charts. Manual Flow: 1. System rolls dice and presents to players 2. Players read physical cards 3. Players submit ManualOutcomeSubmission via WebSocket 4. System validates and processes Note: This class is not actually used. Manual outcomes come through WebSocket handlers, not through ResultChart.get_outcome(). """ def get_outcome( self, roll: "AbRoll", state: "GameState", batter: "BasePlayer", pitcher: "BasePlayer", ) -> tuple[PlayOutcome, str | None]: """ Not implemented for manual mode. Manual mode receives outcomes from humans via WebSocket, not from result chart calculations. Raises: NotImplementedError: Always - manual mode doesn't use this method """ raise NotImplementedError( "Manual mode receives outcomes from humans via WebSocket. " "This method should not be called. Use ManualOutcomeSubmission instead." ) class PdAutoResultChart(ResultChart): """ Auto-resolution result chart for PD league. Uses digitized PdBattingRating and PdPitchingRating data to automatically determine outcomes. This enables AI vs AI games and faster play. Process: 1. Flip coin (50/50) to choose batting or pitching card 2. Get rating for correct handedness matchup 3. Build cumulative distribution from rating percentages 4. Roll 1d100 to select outcome 5. Calculate hit location based on handedness Note: Manual PD games don't use this - they follow the SBA manual flow. """ def _select_card( self, batter: "BasePlayer", pitcher: "BasePlayer" ) -> tuple[dict | None, bool]: """ Flip coin to choose batting or pitching card. Args: batter: Batting player (must be PdPlayer with batting_card) pitcher: Pitching player (must be PdPlayer with pitching_card) Returns: tuple: (rating_dict, is_batting_card) - rating_dict: Rating data or None if unavailable - is_batting_card: True if batting card selected, False if pitching """ # Flip coin coin_flip = random.randint(1, 2) # 1 or 2 (50/50) use_batting_card = coin_flip == 1 if use_batting_card: # Use batting card - need pitcher handedness batting_card = getattr(batter, "batting_card", None) if not batting_card or not batting_card.ratings: logger.warning( f"Batter {batter.name} missing batting card, falling back to pitching" ) use_batting_card = False else: pitcher_hand = getattr( getattr(pitcher, "pitching_card", None), "hand", "R" ) rating = batting_card.ratings.get( pitcher_hand, batting_card.ratings.get("R") ) logger.debug(f"Selected batting card vs {pitcher_hand}HP") return (rating, True) # Use pitching card - need batter handedness pitching_card = getattr(pitcher, "pitching_card", None) if not pitching_card or not pitching_card.ratings: logger.warning(f"Pitcher {pitcher.name} missing pitching card") return (None, False) batter_hand = getattr(getattr(batter, "batting_card", None), "hand", "R") rating = pitching_card.ratings.get(batter_hand, pitching_card.ratings.get("R")) logger.debug(f"Selected pitching card vs {batter_hand}HB") return (rating, False) def _build_distribution( self, rating: dict, is_batting_card: bool ) -> list[tuple[float, PlayOutcome]]: """ Build cumulative distribution from rating percentages. Args: rating: Rating dict (PdBattingRating or PdPitchingRating) is_batting_card: True if batting rating, False if pitching Returns: List of (cumulative_threshold, outcome) tuples sorted by threshold """ distribution = [] cumulative = 0.0 # Common outcomes for both cards common_outcomes = [ ("homerun", PlayOutcome.HOMERUN), ("bp_homerun", PlayOutcome.BP_HOMERUN), ("triple", PlayOutcome.TRIPLE), ("double_three", PlayOutcome.DOUBLE_3), ("double_two", PlayOutcome.DOUBLE_2), ("single_two", PlayOutcome.SINGLE_2), ("single_one", PlayOutcome.SINGLE_1), ("bp_single", PlayOutcome.BP_SINGLE), ("hbp", PlayOutcome.HIT_BY_PITCH), ("walk", PlayOutcome.WALK), ("strikeout", PlayOutcome.STRIKEOUT), ] # Add common outcomes for field, outcome in common_outcomes: percentage = getattr(rating, field, 0.0) if percentage > 0: cumulative += percentage distribution.append((cumulative, outcome)) # Card-specific outcomes if is_batting_card: # Batting card specific batting_specific = [ ("double_pull", PlayOutcome.DOUBLE_2), # Map pull to double_2 ("single_center", PlayOutcome.SINGLE_1), # Map center to single_1 ("lineout", PlayOutcome.LINEOUT), ("popout", PlayOutcome.POPOUT), ("flyout_a", PlayOutcome.FLYOUT_A), ("flyout_bq", PlayOutcome.FLYOUT_B), ("flyout_lf_b", PlayOutcome.FLYOUT_B), ("flyout_rf_b", PlayOutcome.FLYOUT_B), ("groundout_a", PlayOutcome.GROUNDBALL_A), ("groundout_b", PlayOutcome.GROUNDBALL_B), ("groundout_c", PlayOutcome.GROUNDBALL_C), ] for field, outcome in batting_specific: percentage = getattr(rating, field, 0.0) if percentage > 0: cumulative += percentage distribution.append((cumulative, outcome)) else: # Pitching card specific pitching_specific = [ ( "double_cf", PlayOutcome.DOUBLE_UNCAPPED, ), # Pitching has uncapped double ( "single_center", PlayOutcome.SINGLE_UNCAPPED, ), # Pitching has uncapped single ("flyout_lf_b", PlayOutcome.FLYOUT_B), ("flyout_cf_b", PlayOutcome.FLYOUT_B), ("flyout_rf_b", PlayOutcome.FLYOUT_B), ("groundout_a", PlayOutcome.GROUNDBALL_A), ("groundout_b", PlayOutcome.GROUNDBALL_B), ] for field, outcome in pitching_specific: percentage = getattr(rating, field, 0.0) if percentage > 0: cumulative += percentage distribution.append((cumulative, outcome)) # Sort by cumulative value (should already be sorted, but ensure it) distribution.sort(key=lambda x: x[0]) logger.debug(f"Built distribution with cumulative total: {cumulative:.1f}%") return distribution def _select_outcome( self, distribution: list[tuple[float, PlayOutcome]] ) -> PlayOutcome: """ Roll 1d100 and select outcome from cumulative distribution. Args: distribution: List of (cumulative_threshold, outcome) tuples Returns: Selected PlayOutcome """ if not distribution: logger.error("Empty distribution, defaulting to GROUNDBALL_B") return PlayOutcome.GROUNDBALL_B # Roll 0.0 to 100.0 roll = random.uniform(0, 100) # Find first threshold >= roll for threshold, outcome in distribution: if roll <= threshold: logger.debug(f"Roll {roll:.1f} selected {outcome.value}") return outcome # Fallback (should only happen if distribution doesn't sum to 100%) logger.warning(f"Roll {roll:.1f} exceeded distribution, using last outcome") return distribution[-1][1] def get_outcome( self, roll: "AbRoll", state: "GameState", batter: "BasePlayer", pitcher: "BasePlayer", ) -> tuple[PlayOutcome, str | None]: """ Auto-generate outcome from PD card ratings. Args: roll: Dice roll (not used in auto mode - we use probabilities) state: Current game state batter: Batting player (PdPlayer with batting_card) pitcher: Pitching player (PdPlayer with pitching_card) Returns: tuple: (outcome, hit_location) Raises: ValueError: If neither player has valid card data """ # Select which card to use (coin flip) rating, is_batting_card = self._select_card(batter, pitcher) if rating is None: raise ValueError( f"Cannot auto-resolve: {batter.name} and {pitcher.name} both missing card data" ) # Build cumulative distribution distribution = self._build_distribution(rating, is_batting_card) # Select outcome outcome = self._select_outcome(distribution) # Calculate hit location if needed batter_hand = getattr(getattr(batter, "batting_card", None), "hand", "R") location = calculate_hit_location(outcome, batter_hand) logger.info( f"{batter.name} vs {pitcher.name}: {outcome.value}" + (f" to {location}" if location else "") ) return (outcome, location)