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.
This commit is contained in:
Cal Corum 2025-10-30 12:42:41 -05:00
parent c0051d2a65
commit 9245b4e008
3 changed files with 947 additions and 3 deletions

View File

@ -13,15 +13,26 @@ League Differences:
- SBA: Physical cards only (not digitized)
Players read physical cards and manually enter outcomes
This module defines the universal PlayOutcome enum used by both leagues.
This module defines:
- PlayOutcome enum (universal outcome types)
- ResultChart abstract base class
- Helper functions for hit location calculation
Author: Claude
Date: 2025-10-28
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
logger = logging.getLogger(f'{__name__}.PlayOutcome')
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):
@ -162,3 +173,415 @@ class PlayOutcome(str, Enum):
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)

View File

@ -189,6 +189,57 @@ class OffensiveDecision(BaseModel):
return v
class ManualOutcomeSubmission(BaseModel):
"""
Model for human players submitting play outcomes in manual mode.
In manual mode (SBA + PD manual), players roll dice, read physical cards,
and submit the outcome they see. The system then validates and processes.
Usage:
- Players submit via WebSocket after reading their physical card
- Outcome is required (what the card shows)
- Hit location is optional (only needed for certain outcomes)
- System validates location is provided when required
Example:
ManualOutcomeSubmission(
outcome=PlayOutcome.GROUNDBALL_C,
hit_location='SS'
)
"""
outcome: str # PlayOutcome enum value (e.g., "groundball_c")
hit_location: Optional[str] = None # '1B', '2B', 'SS', '3B', 'LF', 'CF', 'RF', 'P', 'C'
@field_validator('outcome')
@classmethod
def validate_outcome(cls, v: str) -> str:
"""Validate outcome is a valid PlayOutcome."""
from app.config.result_charts import PlayOutcome
try:
# Try to convert to PlayOutcome enum
PlayOutcome(v)
return v
except ValueError:
valid_outcomes = [o.value for o in PlayOutcome]
raise ValueError(
f"outcome must be a valid PlayOutcome: {v} not in {valid_outcomes[:5]}..."
)
@field_validator('hit_location')
@classmethod
def validate_hit_location(cls, v: Optional[str]) -> Optional[str]:
"""Validate hit location is a valid position."""
if v is None:
return v
valid_locations = ['1B', '2B', 'SS', '3B', 'LF', 'CF', 'RF', 'P', 'C']
if v not in valid_locations:
raise ValueError(f"hit_location must be one of {valid_locations}")
return v
# ============================================================================
# GAME STATE
# ============================================================================

View File

@ -0,0 +1,470 @@
"""
Unit Tests for Result Charts
Tests result chart abstraction, hit location calculation, and PD auto mode.
"""
import pytest
from unittest.mock import Mock
from pydantic import ValidationError
from app.config.result_charts import (
PlayOutcome,
calculate_hit_location,
ResultChart,
ManualResultChart,
PdAutoResultChart,
)
from app.models.game_models import ManualOutcomeSubmission
# ============================================================================
# HELPER FUNCTION TESTS
# ============================================================================
class TestCalculateHitLocation:
"""Test calculate_hit_location() helper function"""
def test_groundball_returns_infield_position(self):
"""Test groundballs return infield positions"""
outcome = PlayOutcome.GROUNDBALL_C
# Run 100 times to test distribution
locations = set()
for _ in range(100):
location = calculate_hit_location(outcome, 'R')
locations.add(location)
# Should only get infield positions
valid_infield = {'1B', '2B', 'SS', '3B', 'P', 'C'}
assert locations.issubset(valid_infield)
def test_flyout_returns_outfield_position(self):
"""Test flyouts return outfield positions"""
outcome = PlayOutcome.FLYOUT_B
# Run 100 times to test distribution
locations = set()
for _ in range(100):
location = calculate_hit_location(outcome, 'R')
locations.add(location)
# Should only get outfield positions
valid_outfield = {'LF', 'CF', 'RF'}
assert locations.issubset(valid_outfield)
def test_returns_none_for_outcomes_not_requiring_location(self):
"""Test returns None for strikeouts, walks, etc."""
assert calculate_hit_location(PlayOutcome.STRIKEOUT, 'R') is None
assert calculate_hit_location(PlayOutcome.WALK, 'R') is None
assert calculate_hit_location(PlayOutcome.HOMERUN, 'R') is None
assert calculate_hit_location(PlayOutcome.SINGLE_1, 'R') is None
# ============================================================================
# PLAYOUTCOME HELPER TESTS
# ============================================================================
class TestPlayOutcomeRequiresHitLocation:
"""Test PlayOutcome.requires_hit_location() method"""
def test_groundballs_require_location(self):
"""Test all groundball variants require location"""
assert PlayOutcome.GROUNDBALL_A.requires_hit_location() is True
assert PlayOutcome.GROUNDBALL_B.requires_hit_location() is True
assert PlayOutcome.GROUNDBALL_C.requires_hit_location() is True
def test_flyouts_require_location(self):
"""Test all flyout variants require location"""
assert PlayOutcome.FLYOUT_A.requires_hit_location() is True
assert PlayOutcome.FLYOUT_B.requires_hit_location() is True
assert PlayOutcome.FLYOUT_C.requires_hit_location() is True
def test_other_outcomes_do_not_require_location(self):
"""Test strikes, walks, hits don't require location"""
assert PlayOutcome.STRIKEOUT.requires_hit_location() is False
assert PlayOutcome.WALK.requires_hit_location() is False
assert PlayOutcome.HOMERUN.requires_hit_location() is False
assert PlayOutcome.SINGLE_1.requires_hit_location() is False
assert PlayOutcome.DOUBLE_2.requires_hit_location() is False
# ============================================================================
# MANUAL RESULT CHART TESTS
# ============================================================================
class TestManualResultChart:
"""Test ManualResultChart (should not be used)"""
def test_raises_not_implemented_error(self):
"""Test ManualResultChart.get_outcome() raises NotImplementedError"""
chart = ManualResultChart()
with pytest.raises(NotImplementedError) as exc_info:
chart.get_outcome(
roll=Mock(),
state=Mock(),
batter=Mock(),
pitcher=Mock()
)
assert "manual mode" in str(exc_info.value).lower()
assert "websocket" in str(exc_info.value).lower()
# ============================================================================
# PD AUTO RESULT CHART TESTS
# ============================================================================
class TestPdAutoResultChart:
"""Test PdAutoResultChart auto mode implementation"""
def create_mock_player(self, name, card_type, ratings_data):
"""Helper to create mock player with card data"""
player = Mock()
player.name = name
if card_type == 'batting':
card = Mock()
card.hand = 'R'
card.ratings = {
'R': Mock(**ratings_data),
'L': Mock(**ratings_data)
}
player.batting_card = card
player.pitching_card = None
else: # pitching
card = Mock()
card.hand = 'R'
card.ratings = {
'R': Mock(**ratings_data),
'L': Mock(**ratings_data)
}
player.pitching_card = card
player.batting_card = Mock(hand='R')
return player
def test_select_card_coin_flip_distribution(self):
"""Test _select_card() has ~50/50 distribution"""
chart = PdAutoResultChart()
# Create mock players with valid cards
batter = self.create_mock_player('Batter', 'batting', {'homerun': 10.0})
pitcher = self.create_mock_player('Pitcher', 'pitching', {'homerun': 5.0})
# Run 1000 trials
batting_count = 0
for _ in range(1000):
_, is_batting = chart._select_card(batter, pitcher)
if is_batting:
batting_count += 1
# Should be approximately 50% (allow 10% variance)
assert 400 <= batting_count <= 600
def test_select_card_uses_correct_handedness(self):
"""Test _select_card() selects rating for correct handedness"""
chart = PdAutoResultChart()
# Create batter with batting card
batter = self.create_mock_player('Batter', 'batting', {'homerun': 10.0})
# Create pitcher with specific hand
pitcher = Mock()
pitcher.name = 'Pitcher'
pitcher.pitching_card = Mock(hand='L') # LHP
pitcher.batting_card = None
# Force batting card selection
rating, is_batting = chart._select_card(batter, pitcher)
# Should have selected rating vs LHP
assert rating is not None
def test_build_distribution_creates_cumulative_values(self):
"""Test _build_distribution() builds cumulative thresholds"""
chart = PdAutoResultChart()
# Create mock rating
rating = Mock(
homerun=5.0,
triple=2.0,
double_two=8.0,
single_one=15.0,
walk=10.0,
strikeout=20.0,
groundout_a=10.0,
groundout_b=15.0,
groundout_c=15.0,
# Zero out others
bp_homerun=0.0,
double_three=0.0,
single_two=0.0,
bp_single=0.0,
hbp=0.0,
lineout=0.0,
popout=0.0,
flyout_a=0.0,
flyout_bq=0.0,
flyout_lf_b=0.0,
flyout_rf_b=0.0,
double_pull=0.0,
single_center=0.0,
)
distribution = chart._build_distribution(rating, is_batting_card=True)
# Should have entries
assert len(distribution) > 0
# Cumulative values should be increasing
prev_threshold = 0
for threshold, outcome in distribution:
assert threshold > prev_threshold
prev_threshold = threshold
# Total should be ~100% (within rounding)
assert distribution[-1][0] >= 95.0 # Allow some missing outcomes
def test_select_outcome_uses_distribution(self):
"""Test _select_outcome() selects based on cumulative distribution"""
chart = PdAutoResultChart()
# Simple distribution: 0-50 = STRIKEOUT, 50-100 = WALK
distribution = [
(50.0, PlayOutcome.STRIKEOUT),
(100.0, PlayOutcome.WALK),
]
# Run 1000 trials
results = {'strikeout': 0, 'walk': 0}
for _ in range(1000):
outcome = chart._select_outcome(distribution)
if outcome == PlayOutcome.STRIKEOUT:
results['strikeout'] += 1
elif outcome == PlayOutcome.WALK:
results['walk'] += 1
# Should be approximately 50/50 (allow 10% variance)
assert 400 <= results['strikeout'] <= 600
assert 400 <= results['walk'] <= 600
def test_get_outcome_returns_tuple(self):
"""Test get_outcome() returns (outcome, location) tuple"""
chart = PdAutoResultChart()
batter = self.create_mock_player('Batter', 'batting', {
'homerun': 100.0, # Always HR
'triple': 0.0,
'double_two': 0.0,
'single_one': 0.0,
'walk': 0.0,
'strikeout': 0.0,
'groundout_a': 0.0,
'groundout_b': 0.0,
'groundout_c': 0.0,
'bp_homerun': 0.0,
'double_three': 0.0,
'single_two': 0.0,
'bp_single': 0.0,
'hbp': 0.0,
'lineout': 0.0,
'popout': 0.0,
'flyout_a': 0.0,
'flyout_bq': 0.0,
'flyout_lf_b': 0.0,
'flyout_rf_b': 0.0,
'double_pull': 0.0,
'single_center': 0.0,
})
pitcher = self.create_mock_player('Pitcher', 'pitching', {'homerun': 0.0})
outcome, location = chart.get_outcome(
roll=Mock(),
state=Mock(),
batter=batter,
pitcher=pitcher
)
assert isinstance(outcome, PlayOutcome)
assert outcome == PlayOutcome.HOMERUN
assert location is None # HRs don't need location
def test_get_outcome_calculates_location_for_groundballs(self):
"""Test get_outcome() calculates location for groundballs"""
chart = PdAutoResultChart()
batter = self.create_mock_player('Batter', 'batting', {
'groundout_c': 100.0, # Always groundball
'homerun': 0.0,
'triple': 0.0,
'double_two': 0.0,
'single_one': 0.0,
'walk': 0.0,
'strikeout': 0.0,
'groundout_a': 0.0,
'groundout_b': 0.0,
'bp_homerun': 0.0,
'double_three': 0.0,
'single_two': 0.0,
'bp_single': 0.0,
'hbp': 0.0,
'lineout': 0.0,
'popout': 0.0,
'flyout_a': 0.0,
'flyout_bq': 0.0,
'flyout_lf_b': 0.0,
'flyout_rf_b': 0.0,
'double_pull': 0.0,
'single_center': 0.0,
})
pitcher = self.create_mock_player('Pitcher', 'pitching', {'homerun': 0.0})
outcome, location = chart.get_outcome(
roll=Mock(),
state=Mock(),
batter=batter,
pitcher=pitcher
)
assert outcome == PlayOutcome.GROUNDBALL_C
assert location is not None
assert location in ['1B', '2B', 'SS', '3B', 'P', 'C']
def test_get_outcome_raises_error_with_no_cards(self):
"""Test get_outcome() raises error if both players missing cards"""
chart = PdAutoResultChart()
batter = Mock(name='Batter', batting_card=None, pitching_card=None)
pitcher = Mock(name='Pitcher', batting_card=None, pitching_card=None)
with pytest.raises(ValueError) as exc_info:
chart.get_outcome(
roll=Mock(),
state=Mock(),
batter=batter,
pitcher=pitcher
)
assert "cannot auto-resolve" in str(exc_info.value).lower()
assert "missing card data" in str(exc_info.value).lower()
def test_outcome_distribution_matches_ratings(self):
"""Test outcome distribution roughly matches rating percentages"""
chart = PdAutoResultChart()
# Create player with known distribution
batter = self.create_mock_player('Batter', 'batting', {
'strikeout': 50.0,
'walk': 50.0,
'homerun': 0.0,
'triple': 0.0,
'double_two': 0.0,
'single_one': 0.0,
'groundout_a': 0.0,
'groundout_b': 0.0,
'groundout_c': 0.0,
'bp_homerun': 0.0,
'double_three': 0.0,
'single_two': 0.0,
'bp_single': 0.0,
'hbp': 0.0,
'lineout': 0.0,
'popout': 0.0,
'flyout_a': 0.0,
'flyout_bq': 0.0,
'flyout_lf_b': 0.0,
'flyout_rf_b': 0.0,
'double_pull': 0.0,
'single_center': 0.0,
})
pitcher = self.create_mock_player('Pitcher', 'pitching', {'homerun': 0.0})
# Force batting card every time for consistent test
original_select = chart._select_card
chart._select_card = lambda b, p: (batter.batting_card.ratings['R'], True)
try:
# Run 1000 trials
outcomes = {'strikeout': 0, 'walk': 0}
for _ in range(1000):
outcome, _ = chart.get_outcome(
roll=Mock(),
state=Mock(),
batter=batter,
pitcher=pitcher
)
if outcome == PlayOutcome.STRIKEOUT:
outcomes['strikeout'] += 1
elif outcome == PlayOutcome.WALK:
outcomes['walk'] += 1
# Should be approximately 50/50 (allow 10% variance)
assert 400 <= outcomes['strikeout'] <= 600
assert 400 <= outcomes['walk'] <= 600
finally:
chart._select_card = original_select
# ============================================================================
# MANUAL OUTCOME SUBMISSION TESTS
# ============================================================================
class TestManualOutcomeSubmission:
"""Test ManualOutcomeSubmission model validation"""
def test_valid_outcome_with_location(self):
"""Test valid submission with outcome and location"""
submission = ManualOutcomeSubmission(
outcome='groundball_c',
hit_location='SS'
)
assert submission.outcome == 'groundball_c'
assert submission.hit_location == 'SS'
def test_valid_outcome_without_location(self):
"""Test valid submission without location"""
submission = ManualOutcomeSubmission(
outcome='strikeout'
)
assert submission.outcome == 'strikeout'
assert submission.hit_location is None
def test_invalid_outcome_raises_error(self):
"""Test invalid outcome value raises ValidationError"""
with pytest.raises(ValidationError) as exc_info:
ManualOutcomeSubmission(outcome='invalid_outcome')
assert 'outcome' in str(exc_info.value).lower()
def test_invalid_hit_location_raises_error(self):
"""Test invalid hit location raises ValidationError"""
with pytest.raises(ValidationError) as exc_info:
ManualOutcomeSubmission(
outcome='groundball_c',
hit_location='INVALID'
)
assert 'hit_location' in str(exc_info.value).lower()
def test_all_valid_hit_locations_accepted(self):
"""Test all valid hit locations are accepted"""
valid_locations = ['1B', '2B', 'SS', '3B', 'LF', 'CF', 'RF', 'P', 'C']
for location in valid_locations:
submission = ManualOutcomeSubmission(
outcome='groundball_c',
hit_location=location
)
assert submission.hit_location == location
def test_none_hit_location_is_valid(self):
"""Test None for hit_location is valid"""
submission = ManualOutcomeSubmission(
outcome='strikeout',
hit_location=None
)
assert submission.hit_location is None