strat-gameplay-webapp/backend/app/config/result_charts.py
Cal Corum a4b99ee53e CLAUDE: Replace black and flake8 with ruff for formatting and linting
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>
2025-11-20 15:33:21 -06:00

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)