Migrated to ruff for faster, modern code formatting and linting: Configuration changes: - pyproject.toml: Added ruff 0.8.6, removed black/flake8 - Configured ruff with black-compatible formatting (88 chars) - Enabled comprehensive linting rules (pycodestyle, pyflakes, isort, pyupgrade, bugbear, comprehensions, simplify, return) - Updated CLAUDE.md: Changed code quality commands to use ruff Code improvements (490 auto-fixes): - Modernized type hints: List[T] → list[T], Dict[K,V] → dict[K,V], Optional[T] → T | None - Sorted all imports (isort integration) - Removed unused imports - Fixed whitespace issues - Reformatted 38 files for consistency Bug fixes: - app/core/play_resolver.py: Fixed type hint bug (any → Any) - tests/unit/core/test_runner_advancement.py: Removed obsolete random mock Testing: - All 739 unit tests passing (100%) - No regressions introduced 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
634 lines
22 KiB
Python
634 lines
22 KiB
Python
"""
|
|
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)
|