strat-gameplay-webapp/backend/app/config/result_charts.py
Cal Corum 9245b4e008 CLAUDE: Implement Week 7 Task 3 - Result chart abstraction and PD auto mode
Core Implementation:
- Added ResultChart abstract base class with get_outcome() method
- Implemented calculate_hit_location() helper for hit distribution
  - 45% pull, 35% center, 20% opposite field
  - RHB pulls left, LHB pulls right
  - Groundballs → infield positions, flyouts → outfield positions
- Added PlayOutcome.requires_hit_location() helper method
  - Returns True for groundballs and flyouts only

Manual Mode Support:
- Added ManualResultChart (passthrough for interface completeness)
- Manual mode doesn't use result charts - players submit directly
- Added ManualOutcomeSubmission model for WebSocket submissions
  - Validates PlayOutcome enum values
  - Validates hit location positions (1B, 2B, SS, 3B, LF, CF, RF, P, C)

PD Auto Mode Implementation:
- Implemented PdAutoResultChart for automated outcome generation
  - Coin flip (50/50) to choose batting or pitching card
  - Gets rating for correct handedness matchup
  - Builds cumulative distribution from rating percentages
  - Rolls 1d100 to select outcome
  - Calculates hit location using handedness and pull rates
- Maps rating fields to PlayOutcome enum:
  - Common: homerun, triple, doubles, singles, walks, strikeouts
  - Batting-specific: lineouts, popouts, flyout variants, groundout variants
  - Pitching-specific: uncapped singles/doubles, flyouts by location
- Proper error handling when card data missing

Testing:
- Created 21 comprehensive unit tests (all passing)
- Helper function tests (calculate_hit_location)
- PlayOutcome helper tests (requires_hit_location)
- ManualResultChart tests (NotImplementedError)
- PdAutoResultChart tests:
  - Coin flip distribution (~50/50)
  - Handedness matchup selection
  - Cumulative distribution building
  - Outcome selection from probabilities
  - Hit location calculation
  - Error handling for missing cards
  - Statistical distribution verification (1000 trials)
- ManualOutcomeSubmission validation tests
  - Valid/invalid outcomes
  - Valid/invalid hit locations
  - Optional location handling

Deferred to Future Tasks:
- PlayResolver integration (Phase 6 - Week 7 Task 3B)
- Terminal client manual outcome command (Phase 8)
- WebSocket handlers for manual submissions (Week 7 Task 6)
- Runner advancement logic using hit locations (Week 7 Task 4)

Files Modified:
- app/config/result_charts.py: Added base class, auto mode, and helpers
- app/models/game_models.py: Added ManualOutcomeSubmission model
- tests/unit/config/test_result_charts.py: 21 comprehensive tests

All tests passing, no regressions.
2025-10-30 12:42:41 -05:00

588 lines
21 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 Optional, TYPE_CHECKING
if TYPE_CHECKING:
from app.models.game_models import GameState
from app.models.player_models import BasePlayer
from app.core.dice import AbRoll
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 - 3 variants for different trajectories/depths
FLYOUT_A = "flyout_a" # Flyout variant A
FLYOUT_B = "flyout_b" # Flyout variant B
FLYOUT_C = "flyout_c" # Flyout variant C
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"
# ==================== 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_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 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
elif self in {self.DOUBLE_2, self.DOUBLE_3, self.DOUBLE_UNCAPPED}:
return 2
elif self == self.TRIPLE:
return 3
elif self in {self.HOMERUN, self.BP_HOMERUN}:
return 4
else:
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_C,
}
# ============================================================================
# HIT LOCATION HELPER
# ============================================================================
def calculate_hit_location(
outcome: PlayOutcome,
batter_handedness: str,
) -> Optional[str]:
"""
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'])
else: # LHB
# LHB pulls right (1B, 2B)
return random.choice(['1B', '2B'])
elif roll < 0.80: # Center (45% + 35% = 80%)
# Up the middle (2B, SS, P, C)
return random.choice(['2B', 'SS', 'P', 'C'])
else: # Opposite field (20%)
if batter_handedness == 'R':
# RHB opposite is right (1B, 2B)
return random.choice(['1B', '2B'])
else: # LHB
# LHB opposite is left (3B, SS)
return random.choice(['3B', 'SS'])
else:
# Fly ball locations: LF, CF, RF
if roll < 0.45: # Pull side
if batter_handedness == 'R':
# RHB pulls left
return 'LF'
else: # LHB
# LHB pulls right
return 'RF'
elif roll < 0.80: # Center
return 'CF'
else: # Opposite field
if batter_handedness == 'R':
# RHB opposite is right
return 'RF'
else: # 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, Optional[str]]:
"""
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, Optional[str]]:
"""
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[Optional[dict], 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, Optional[str]]:
"""
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)