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:
parent
c0051d2a65
commit
9245b4e008
@ -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)
|
||||
|
||||
@ -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
|
||||
# ============================================================================
|
||||
|
||||
470
backend/tests/unit/config/test_result_charts.py
Normal file
470
backend/tests/unit/config/test_result_charts.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user