CLAUDE: Implement Week 6 league configuration and play outcome systems
Week 6 Progress: 75% Complete ## Components Implemented ### 1. League Configuration System ✅ - Created BaseGameConfig abstract class for league-agnostic rules - Implemented SbaConfig and PdConfig with league-specific settings - Immutable configs (frozen=True) with singleton registry - 28 unit tests, all passing Files: - backend/app/config/base_config.py - backend/app/config/league_configs.py - backend/tests/unit/config/test_league_configs.py ### 2. PlayOutcome Enum ✅ - Universal enum for all play outcomes (both SBA and PD) - Helper methods: is_hit(), is_out(), is_uncapped(), is_interrupt() - Supports standard hits, uncapped hits, interrupt plays, ballpark power - 30 unit tests, all passing Files: - backend/app/config/result_charts.py - backend/tests/unit/config/test_play_outcome.py ### 3. Player Model Refinements ✅ - Fixed PdPlayer.id field mapping (player_id → id) - Improved field docstrings for image types - Fixed position checking logic in SBA helper methods - Added safety checks for missing image data Files: - backend/app/models/player_models.py (updated) ### 4. Documentation ✅ - Updated backend/CLAUDE.md with Week 6 section - Documented card-based resolution mechanics - Detailed config system and PlayOutcome usage ## Architecture Decisions 1. **Card-Based Resolution**: Both SBA and PD use same mechanics - 1d6 (column) + 2d6 (row) + 1d20 (split resolution) - PD: Digitized cards with auto-resolution - SBA: Manual entry from physical cards 2. **Immutable Configs**: Prevent accidental modification using Pydantic frozen 3. **Universal PlayOutcome**: Single enum for both leagues reduces duplication ## Testing - Total: 58 tests, all passing - Config tests: 28 - PlayOutcome tests: 30 ## Remaining Work (25%) - Update dice system (check_d20 → chaos_d20) - Integrate PlayOutcome into PlayResolver - Add Play.metadata support for uncapped hits 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
a0146223c8
commit
5d5c13f2b8
@ -1702,15 +1702,213 @@ terminal_client/
|
||||
- ⏳ Result charts & PD integration (not started)
|
||||
- ⏳ API client (deferred)
|
||||
|
||||
---
|
||||
|
||||
## Week 6: League Configuration & Play Outcome System (2025-10-28)
|
||||
|
||||
**Status**: 75% Complete
|
||||
**Phase**: Phase 2 - Week 6 (League Features & Integration)
|
||||
|
||||
### Overview
|
||||
|
||||
Implemented foundational configuration and outcome systems for both SBA and PD leagues, establishing the framework for card-based play resolution.
|
||||
|
||||
### Components Implemented
|
||||
|
||||
#### 1. League Configuration System ✅
|
||||
|
||||
**Location**: `app/config/`
|
||||
|
||||
Provides immutable, league-specific configuration objects for game rules and API endpoints.
|
||||
|
||||
**Files Created**:
|
||||
- `app/config/base_config.py` - Abstract base configuration
|
||||
- `app/config/league_configs.py` - SBA and PD implementations
|
||||
- `app/config/__init__.py` - Public API
|
||||
- `tests/unit/config/test_league_configs.py` - 28 tests (all passing)
|
||||
|
||||
**Key Features**:
|
||||
```python
|
||||
from app.config import get_league_config
|
||||
|
||||
# Get league-specific config
|
||||
config = get_league_config("sba")
|
||||
api_url = config.get_api_base_url() # "https://api.sba.manticorum.com"
|
||||
chart = config.get_result_chart_name() # "sba_standard_v1"
|
||||
|
||||
# Configs are immutable (frozen=True)
|
||||
# config.innings = 7 # Raises ValidationError
|
||||
```
|
||||
|
||||
**League Differences**:
|
||||
- **SBA**: Manual result selection, simple player data
|
||||
- **PD**: Flexible selection (manual or auto), detailed scouting data, cardset validation, advanced analytics
|
||||
|
||||
**Config Registry**:
|
||||
```python
|
||||
LEAGUE_CONFIGS = {
|
||||
"sba": SbaConfig(),
|
||||
"pd": PdConfig()
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. PlayOutcome Enum ✅
|
||||
|
||||
**Location**: `app/config/result_charts.py`
|
||||
|
||||
Universal enum defining all possible play outcomes for both leagues.
|
||||
|
||||
**Files Created**:
|
||||
- `app/config/result_charts.py` - PlayOutcome enum with helpers
|
||||
- `tests/unit/config/test_play_outcome.py` - 30 tests (all passing)
|
||||
|
||||
**Outcome Categories**:
|
||||
```python
|
||||
from app.config import PlayOutcome
|
||||
|
||||
# Standard outcomes
|
||||
PlayOutcome.STRIKEOUT
|
||||
PlayOutcome.SINGLE
|
||||
PlayOutcome.HOMERUN
|
||||
|
||||
# Uncapped hits (pitching cards only)
|
||||
PlayOutcome.SINGLE_UNCAPPED # si(cf) - triggers advancement decisions
|
||||
PlayOutcome.DOUBLE_UNCAPPED # do(cf) - triggers advancement decisions
|
||||
|
||||
# Interrupt plays (logged with pa=0)
|
||||
PlayOutcome.WILD_PITCH # Play.wp = 1
|
||||
PlayOutcome.PASSED_BALL # Play.pb = 1
|
||||
PlayOutcome.STOLEN_BASE # Play.sb = 1
|
||||
PlayOutcome.CAUGHT_STEALING # Play.cs = 1
|
||||
|
||||
# Ballpark power (PD specific)
|
||||
PlayOutcome.BP_HOMERUN # Play.bphr = 1
|
||||
PlayOutcome.BP_SINGLE # Play.bp1b = 1
|
||||
```
|
||||
|
||||
**Helper Methods**:
|
||||
```python
|
||||
outcome = PlayOutcome.SINGLE_UNCAPPED
|
||||
|
||||
outcome.is_hit() # True
|
||||
outcome.is_out() # False
|
||||
outcome.is_uncapped() # True - requires decision tree
|
||||
outcome.is_interrupt() # False
|
||||
outcome.get_bases_advanced() # 1
|
||||
```
|
||||
|
||||
#### 3. Card-Based Resolution System
|
||||
|
||||
**Resolution Mechanics** (Both SBA and PD):
|
||||
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 (e.g., 1-16: HR, 17-20: 2B)
|
||||
4. Outcome from card = `PlayOutcome` enum value
|
||||
|
||||
**League Differences**:
|
||||
- **PD**: Card data digitized in `PdBattingRating`/`PdPitchingRating`
|
||||
- Can auto-resolve using probabilities
|
||||
- OR manual selection by players
|
||||
- **SBA**: Physical cards only (not digitized)
|
||||
- Players read physical cards and manually enter outcomes
|
||||
- System validates and records the human-selected outcome
|
||||
|
||||
### Testing
|
||||
|
||||
**Test Coverage**:
|
||||
- 28 config tests (league configs, registry, immutability)
|
||||
- 30 PlayOutcome tests (helpers, categorization, edge cases)
|
||||
- **Total: 58 tests, all passing**
|
||||
|
||||
**Test Files**:
|
||||
```
|
||||
tests/unit/config/
|
||||
├── test_league_configs.py # Config system tests
|
||||
└── test_play_outcome.py # Outcome enum tests
|
||||
```
|
||||
|
||||
### Architecture Decisions
|
||||
|
||||
**1. Immutable Configs**
|
||||
- Used Pydantic `frozen=True` to prevent accidental modification
|
||||
- Configs are singletons in registry
|
||||
- Type-safe with full validation
|
||||
|
||||
**2. Universal PlayOutcome Enum**
|
||||
- Single source of truth for all possible outcomes
|
||||
- Works for both SBA and PD leagues
|
||||
- Helper methods reduce duplicate logic in resolvers
|
||||
|
||||
**3. No Static Result Charts**
|
||||
- Originally planned d20 charts for SBA
|
||||
- Realized both leagues use same card-based mechanics
|
||||
- Charts come from player card data (PD) or manual entry (SBA)
|
||||
|
||||
### Integration Points
|
||||
|
||||
**With Game Engine**:
|
||||
```python
|
||||
from app.config import get_league_config, PlayOutcome
|
||||
|
||||
# Get league config
|
||||
config = get_league_config(state.league_id)
|
||||
|
||||
# Resolve outcome (from card or manual entry)
|
||||
outcome = PlayOutcome.SINGLE_UNCAPPED
|
||||
|
||||
# Handle based on outcome type
|
||||
if outcome.is_uncapped() and state.on_base_code > 0:
|
||||
# Trigger advancement decision tree
|
||||
present_advancement_options()
|
||||
elif outcome.is_interrupt():
|
||||
# Log interrupt play (pa=0)
|
||||
log_interrupt_play(outcome)
|
||||
else:
|
||||
# Standard play resolution
|
||||
resolve_standard_play(outcome)
|
||||
```
|
||||
|
||||
### Remaining Work (Week 6)
|
||||
|
||||
**1. Dice System Update** ⏳
|
||||
- Rename `check_d20` → `chaos_d20` in AbRoll
|
||||
- Update all references
|
||||
|
||||
**2. PlayResolver Integration** ⏳
|
||||
- Replace old `PlayOutcome` enum with new one
|
||||
- Use `PlayOutcome` throughout resolution logic
|
||||
- Handle uncapped hit decision trees
|
||||
|
||||
**3. Play.metadata Support** ⏳
|
||||
- Add JSON metadata field for uncapped hit tracking
|
||||
- Log `{"uncapped": true}` when applicable
|
||||
|
||||
### Key Files
|
||||
|
||||
```
|
||||
app/config/
|
||||
├── __init__.py # Public API
|
||||
├── base_config.py # Abstract base config
|
||||
├── league_configs.py # SBA/PD implementations
|
||||
└── result_charts.py # PlayOutcome enum
|
||||
|
||||
tests/unit/config/
|
||||
├── test_league_configs.py # 28 tests
|
||||
└── test_play_outcome.py # 30 tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Next Priorities**:
|
||||
1. League configuration system (BaseConfig, SbaConfig, PdConfig)
|
||||
2. Result charts & PD play resolution with ratings
|
||||
3. API client for live roster data (optional for now)
|
||||
1. Update dice system (chaos_d20)
|
||||
2. Integrate PlayOutcome into PlayResolver
|
||||
3. Add Play.metadata support for uncapped hits
|
||||
4. Complete week 6 remaining work
|
||||
|
||||
**Python Version**: 3.13.3
|
||||
**Database Server**: 10.10.0.42:5432
|
||||
|
||||
**Implementation Status**: See `../.claude/implementation/week6-status-assessment.md` for detailed Week 6 progress
|
||||
**Implementation Status**: Week 6 - 75% Complete (Config & PlayOutcome ✅, Integration pending)
|
||||
|
||||
## Database Model Updates (2025-10-21)
|
||||
|
||||
|
||||
35
backend/app/config/__init__.py
Normal file
35
backend/app/config/__init__.py
Normal file
@ -0,0 +1,35 @@
|
||||
"""
|
||||
League configuration system for game rules and settings.
|
||||
|
||||
Provides:
|
||||
- League configurations (BaseGameConfig, SbaConfig, PdConfig)
|
||||
- Play outcome definitions (PlayOutcome enum)
|
||||
|
||||
Usage:
|
||||
from app.config import get_league_config, PlayOutcome
|
||||
|
||||
# Get config for specific league
|
||||
config = get_league_config("sba")
|
||||
api_url = config.get_api_base_url()
|
||||
|
||||
# Use play outcomes
|
||||
if outcome == PlayOutcome.SINGLE_UNCAPPED:
|
||||
# Handle uncapped hit decision tree
|
||||
"""
|
||||
from app.config.base_config import BaseGameConfig
|
||||
from app.config.league_configs import (
|
||||
SbaConfig,
|
||||
PdConfig,
|
||||
LEAGUE_CONFIGS,
|
||||
get_league_config
|
||||
)
|
||||
from app.config.result_charts import PlayOutcome
|
||||
|
||||
__all__ = [
|
||||
'BaseGameConfig',
|
||||
'SbaConfig',
|
||||
'PdConfig',
|
||||
'LEAGUE_CONFIGS',
|
||||
'get_league_config',
|
||||
'PlayOutcome'
|
||||
]
|
||||
62
backend/app/config/base_config.py
Normal file
62
backend/app/config/base_config.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""
|
||||
Base configuration class for league-specific game rules.
|
||||
|
||||
Provides abstract interface that all league configs must implement.
|
||||
|
||||
Author: Claude
|
||||
Date: 2025-10-28
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class BaseGameConfig(BaseModel, ABC):
|
||||
"""
|
||||
Abstract base configuration for all leagues.
|
||||
|
||||
Defines common game rules and requires league-specific implementations
|
||||
to provide result chart names, API endpoints, and feature flags.
|
||||
"""
|
||||
|
||||
league_id: str = Field(..., description="League identifier ('sba' or 'pd')")
|
||||
version: str = Field(default="1.0.0", description="Config version for compatibility")
|
||||
|
||||
# Basic baseball rules (same across leagues)
|
||||
innings: int = Field(default=9, description="Standard innings per game")
|
||||
outs_per_inning: int = Field(default=3, description="Outs required per half-inning")
|
||||
strikes_for_out: int = Field(default=3, description="Strikes for strikeout") # TODO: remove - unneeded
|
||||
balls_for_walk: int = Field(default=4, description="Balls for walk") # TODO: remove - unneeded
|
||||
|
||||
@abstractmethod
|
||||
def get_result_chart_name(self) -> str:
|
||||
"""
|
||||
Get name of result chart to use for this league.
|
||||
|
||||
Returns:
|
||||
Chart name identifier (e.g., 'sba_standard_v1')
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def supports_manual_result_selection(self) -> bool: # TODO: consider refactor: manually selecting results is default behavior with PD allowing auto-results as an option
|
||||
"""
|
||||
Whether players manually select results after dice roll.
|
||||
|
||||
Returns:
|
||||
True if players pick from chart, False if auto-resolved
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_api_base_url(self) -> str:
|
||||
"""
|
||||
Get base URL for league's external API.
|
||||
|
||||
Returns:
|
||||
Base URL for roster/player data API
|
||||
"""
|
||||
pass
|
||||
|
||||
class Config:
|
||||
"""Pydantic configuration."""
|
||||
frozen = True # Immutable config - prevents accidental modification
|
||||
107
backend/app/config/league_configs.py
Normal file
107
backend/app/config/league_configs.py
Normal file
@ -0,0 +1,107 @@
|
||||
"""
|
||||
League-specific configuration implementations.
|
||||
|
||||
Provides concrete configs for SBA and PD leagues with their unique rules,
|
||||
API endpoints, and feature flags.
|
||||
|
||||
Author: Claude
|
||||
Date: 2025-10-28
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict
|
||||
from app.config.base_config import BaseGameConfig
|
||||
|
||||
logger = logging.getLogger(f'{__name__}.LeagueConfigs')
|
||||
|
||||
|
||||
class SbaConfig(BaseGameConfig):
|
||||
"""
|
||||
SBA League configuration.
|
||||
|
||||
Features:
|
||||
- Manual result selection after dice roll
|
||||
- Simple player data model
|
||||
- Standard baseball rules
|
||||
"""
|
||||
|
||||
league_id: str = "sba"
|
||||
|
||||
# SBA-specific features
|
||||
player_selection_mode: str = "manual" # Players manually select from chart
|
||||
|
||||
def get_result_chart_name(self) -> str:
|
||||
"""Use SBA standard result chart."""
|
||||
return "sba_standard_v1"
|
||||
|
||||
def supports_manual_result_selection(self) -> bool:
|
||||
"""SBA players manually pick results from chart."""
|
||||
return True
|
||||
|
||||
def get_api_base_url(self) -> str:
|
||||
"""SBA API base URL."""
|
||||
return "https://api.sba.manticorum.com"
|
||||
|
||||
|
||||
class PdConfig(BaseGameConfig):
|
||||
"""
|
||||
Paper Dynasty League configuration.
|
||||
|
||||
Features:
|
||||
- Flexible result selection (manual or auto via scouting)
|
||||
- Complex scouting data model
|
||||
- Cardset validation
|
||||
- Advanced analytics
|
||||
"""
|
||||
|
||||
league_id: str = "pd"
|
||||
|
||||
# PD-specific features
|
||||
player_selection_mode: str = "flexible" # Manual or auto via scouting model
|
||||
use_scouting_model: bool = True # Use detailed ratings for auto-resolution
|
||||
cardset_validation: bool = True # Validate cards against approved cardsets
|
||||
|
||||
# Advanced features
|
||||
detailed_analytics: bool = True # Track advanced stats (WPA, RE24, etc.)
|
||||
wpa_calculation: bool = True # Calculate win probability added
|
||||
|
||||
def get_result_chart_name(self) -> str:
|
||||
"""Use PD standard result chart."""
|
||||
return "pd_standard_v1"
|
||||
|
||||
def supports_manual_result_selection(self) -> bool:
|
||||
"""PD supports manual selection (though auto is also available)."""
|
||||
return True
|
||||
|
||||
def get_api_base_url(self) -> str:
|
||||
"""PD API base URL."""
|
||||
return "https://pd.manticorum.com"
|
||||
|
||||
|
||||
# ==================== Config Registry ====================
|
||||
|
||||
LEAGUE_CONFIGS: Dict[str, BaseGameConfig] = {
|
||||
"sba": SbaConfig(),
|
||||
"pd": PdConfig()
|
||||
}
|
||||
|
||||
|
||||
def get_league_config(league_id: str) -> BaseGameConfig:
|
||||
"""
|
||||
Get configuration for specified league.
|
||||
|
||||
Args:
|
||||
league_id: League identifier ('sba' or 'pd')
|
||||
|
||||
Returns:
|
||||
League-specific config instance
|
||||
|
||||
Raises:
|
||||
ValueError: If league_id is not recognized
|
||||
"""
|
||||
config = LEAGUE_CONFIGS.get(league_id)
|
||||
if not config:
|
||||
logger.error(f"Unknown league ID: {league_id}")
|
||||
raise ValueError(f"Unknown league: {league_id}. Valid leagues: {list(LEAGUE_CONFIGS.keys())}")
|
||||
|
||||
logger.debug(f"Retrieved config for league: {league_id}")
|
||||
return config
|
||||
151
backend/app/config/result_charts.py
Normal file
151
backend/app/config/result_charts.py
Normal file
@ -0,0 +1,151 @@
|
||||
"""
|
||||
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 the universal PlayOutcome enum used by both leagues.
|
||||
|
||||
Author: Claude
|
||||
Date: 2025-10-28
|
||||
"""
|
||||
import logging
|
||||
from enum import Enum
|
||||
|
||||
logger = logging.getLogger(f'{__name__}.PlayOutcome')
|
||||
|
||||
|
||||
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"
|
||||
GROUNDOUT = "groundout"
|
||||
FLYOUT = "flyout"
|
||||
LINEOUT = "lineout"
|
||||
POPOUT = "popout"
|
||||
DOUBLE_PLAY = "double_play"
|
||||
|
||||
# ==================== Hits ====================
|
||||
SINGLE = "single"
|
||||
DOUBLE = "double"
|
||||
TRIPLE = "triple"
|
||||
HOMERUN = "homerun"
|
||||
|
||||
# Uncapped hits (only on pitching cards)
|
||||
# Trigger decision tree for advancing runners when on_base_code > 0
|
||||
SINGLE_UNCAPPED = "single_uncapped" # si(cf) on card
|
||||
DOUBLE_UNCAPPED = "double_uncapped" # do(cf) on card
|
||||
|
||||
# ==================== 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" # Logged during steal attempt
|
||||
PICK_OFF = "pick_off" # Runner picked off
|
||||
|
||||
# ==================== Ballpark Power (PD specific) ====================
|
||||
# Special PD outcomes for ballpark factors
|
||||
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, self.DOUBLE, self.TRIPLE, self.HOMERUN,
|
||||
self.SINGLE_UNCAPPED, self.DOUBLE_UNCAPPED,
|
||||
self.BP_HOMERUN, self.BP_SINGLE
|
||||
}
|
||||
|
||||
def is_out(self) -> bool:
|
||||
"""Check if outcome records an out."""
|
||||
return self in {
|
||||
self.STRIKEOUT, self.GROUNDOUT, self.FLYOUT,
|
||||
self.LINEOUT, self.POPOUT, self.DOUBLE_PLAY,
|
||||
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, self.TRIPLE, self.HOMERUN,
|
||||
self.DOUBLE_UNCAPPED, 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, self.SINGLE_UNCAPPED, self.BP_SINGLE}:
|
||||
return 1
|
||||
elif self in {self.DOUBLE, self.DOUBLE_UNCAPPED}:
|
||||
return 2
|
||||
elif self == self.TRIPLE:
|
||||
return 3
|
||||
elif self in {self.HOMERUN, self.BP_HOMERUN}:
|
||||
return 4
|
||||
else:
|
||||
return 0
|
||||
@ -25,12 +25,20 @@ class BasePlayer(BaseModel, ABC):
|
||||
# Common fields across all leagues
|
||||
id: int = Field(..., description="Player ID (SBA) or Card ID (PD)")
|
||||
name: str = Field(..., description="Player display name")
|
||||
image: Optional[str] = Field(None, description="Primary card/player image URL")
|
||||
image: str = Field(..., description="PRIMARY CARD: Main playing card image URL")
|
||||
image2: Optional[str] = Field(None, description="ALT CARD: Secondary card for two-way players")
|
||||
headshot: Optional[str] = Field(None, description="DEFAULT: League-provided headshot fallback")
|
||||
vanity_card: Optional[str] = Field(None, description="CUSTOM: User-uploaded profile image")
|
||||
|
||||
@abstractmethod
|
||||
def get_image_url(self) -> str:
|
||||
"""Get player image URL (with fallback logic if needed)."""
|
||||
pass
|
||||
# Positions (up to 8 possible positions)
|
||||
pos_1: str = Field(..., description="Primary position")
|
||||
pos_2: Optional[str] = Field(None, description="Secondary position")
|
||||
pos_3: Optional[str] = Field(None, description="Tertiary position")
|
||||
pos_4: Optional[str] = Field(None, description="Fourth position")
|
||||
pos_5: Optional[str] = Field(None, description="Fifth position")
|
||||
pos_6: Optional[str] = Field(None, description="Sixth position")
|
||||
pos_7: Optional[str] = Field(None, description="Seventh position")
|
||||
pos_8: Optional[str] = Field(None, description="Eighth position")
|
||||
|
||||
@abstractmethod
|
||||
def get_positions(self) -> List[str]:
|
||||
@ -42,6 +50,10 @@ class BasePlayer(BaseModel, ABC):
|
||||
"""Get formatted display name for UI."""
|
||||
pass
|
||||
|
||||
def get_player_image_url(self) -> str:
|
||||
"""Get player profile image (prioritizes custom uploads over league defaults)."""
|
||||
return self.vanity_card or self.headshot or ""
|
||||
|
||||
class Config:
|
||||
"""Pydantic configuration."""
|
||||
# Allow extra fields for future extensibility
|
||||
@ -60,31 +72,30 @@ class SbaPlayer(BasePlayer):
|
||||
|
||||
# SBA-specific fields
|
||||
wara: float = Field(default=0.0, description="Wins Above Replacement Average")
|
||||
image2: Optional[str] = Field(None, description="Secondary image URL")
|
||||
team_id: Optional[int] = Field(None, description="Current team ID")
|
||||
team_name: Optional[str] = Field(None, description="Current team name")
|
||||
season: Optional[int] = Field(None, description="Season number")
|
||||
|
||||
# Positions (up to 8 possible positions)
|
||||
pos_1: Optional[str] = Field(None, description="Primary position")
|
||||
pos_2: Optional[str] = Field(None, description="Secondary position")
|
||||
pos_3: Optional[str] = Field(None, description="Tertiary position")
|
||||
pos_4: Optional[str] = Field(None, description="Fourth position")
|
||||
pos_5: Optional[str] = Field(None, description="Fifth position")
|
||||
pos_6: Optional[str] = Field(None, description="Sixth position")
|
||||
pos_7: Optional[str] = Field(None, description="Seventh position")
|
||||
pos_8: Optional[str] = Field(None, description="Eighth position")
|
||||
|
||||
# Additional info
|
||||
headshot: Optional[str] = Field(None, description="Player headshot URL")
|
||||
vanity_card: Optional[str] = Field(None, description="Vanity card URL")
|
||||
strat_code: Optional[str] = Field(None, description="Strat-O-Matic code")
|
||||
bbref_id: Optional[str] = Field(None, description="Baseball Reference ID")
|
||||
injury_rating: Optional[str] = Field(None, description="Injury rating")
|
||||
|
||||
def get_image_url(self) -> str:
|
||||
"""Get player image with fallback logic."""
|
||||
return self.image or self.image2 or self.headshot or ""
|
||||
def get_pitching_card_url(self) -> str:
|
||||
"""Get pitching card image"""
|
||||
if self.pos_1 in ['SP', 'RP']:
|
||||
return self.image
|
||||
elif self.image2 and ('P' in str(self.pos_2) or 'P' in str(self.pos_3) or 'P' in str(self.pos_4)):
|
||||
return self.image2
|
||||
raise ValueError(f'Pitching card not found for {self.get_display_name()}')
|
||||
|
||||
def get_batting_card_url(self) -> str:
|
||||
"""Get batting card image"""
|
||||
if 'P' not in self.pos_1:
|
||||
return self.image
|
||||
elif self.image2 and any('P' in str(pos) for pos in [self.pos_2, self.pos_3, self.pos_4, self.pos_5, self.pos_6, self.pos_7, self.pos_8] if pos):
|
||||
return self.image2
|
||||
raise ValueError(f'Batting card not found for {self.get_display_name()}')
|
||||
|
||||
def get_positions(self) -> List[str]:
|
||||
"""Get list of all positions player can play."""
|
||||
@ -117,13 +128,13 @@ class SbaPlayer(BasePlayer):
|
||||
return cls(
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
image=data.get("image"),
|
||||
image=data.get("image", ""),
|
||||
image2=data.get("image2"),
|
||||
wara=data.get("wara", 0.0),
|
||||
team_id=team_id,
|
||||
team_name=team_name,
|
||||
season=data.get("season"),
|
||||
pos_1=data.get("pos_1"),
|
||||
pos_1=data["pos_1"],
|
||||
pos_2=data.get("pos_2"),
|
||||
pos_3=data.get("pos_3"),
|
||||
pos_4=data.get("pos_4"),
|
||||
@ -283,10 +294,11 @@ class PdPlayer(BasePlayer):
|
||||
|
||||
Complex model with detailed scouting data for simulation.
|
||||
Matches API response from: {{baseUrl}}/api/v2/players/:player_id
|
||||
|
||||
Note: PD API returns 'player_id' which is mapped to 'id' field in from_api_response().
|
||||
"""
|
||||
|
||||
# Override id field to use player_id (more explicit for PD)
|
||||
player_id: int = Field(..., description="PD player card ID", alias="id")
|
||||
# PD-specific fields
|
||||
cost: int = Field(..., description="Card cost/value")
|
||||
|
||||
# Card metadata
|
||||
@ -298,21 +310,6 @@ class PdPlayer(BasePlayer):
|
||||
mlbclub: str = Field(..., description="MLB club name")
|
||||
franchise: str = Field(..., description="Franchise name")
|
||||
|
||||
# Images
|
||||
image2: Optional[str] = Field(None, description="Secondary image URL")
|
||||
headshot: Optional[str] = Field(None, description="Player headshot URL")
|
||||
vanity_card: Optional[str] = Field(None, description="Vanity card URL")
|
||||
|
||||
# Positions (up to 8 possible positions)
|
||||
pos_1: Optional[str] = Field(None, description="Primary position")
|
||||
pos_2: Optional[str] = Field(None, description="Secondary position")
|
||||
pos_3: Optional[str] = Field(None, description="Tertiary position")
|
||||
pos_4: Optional[str] = Field(None, description="Fourth position")
|
||||
pos_5: Optional[str] = Field(None, description="Fifth position")
|
||||
pos_6: Optional[str] = Field(None, description="Sixth position")
|
||||
pos_7: Optional[str] = Field(None, description="Seventh position")
|
||||
pos_8: Optional[str] = Field(None, description="Eighth position")
|
||||
|
||||
# Reference IDs
|
||||
strat_code: Optional[str] = Field(None, description="Strat-O-Matic code")
|
||||
bbref_id: Optional[str] = Field(None, description="Baseball Reference ID")
|
||||
@ -326,10 +323,6 @@ class PdPlayer(BasePlayer):
|
||||
batting_card: Optional[PdBattingCard] = Field(None, description="Batting card with ratings")
|
||||
pitching_card: Optional[PdPitchingCard] = Field(None, description="Pitching card with ratings")
|
||||
|
||||
def get_image_url(self) -> str:
|
||||
"""Get player image with fallback logic."""
|
||||
return self.image or self.image2 or self.headshot or ""
|
||||
|
||||
def get_positions(self) -> List[str]:
|
||||
"""Get list of all positions player can play."""
|
||||
positions = [
|
||||
@ -340,7 +333,7 @@ class PdPlayer(BasePlayer):
|
||||
|
||||
def get_display_name(self) -> str:
|
||||
"""Get formatted display name with description."""
|
||||
return f"{self.name} ({self.description})"
|
||||
return f"{self.description} {self.name}"
|
||||
|
||||
def get_batting_rating(self, vs_hand: str) -> Optional[PdBattingRating]:
|
||||
"""
|
||||
@ -438,14 +431,14 @@ class PdPlayer(BasePlayer):
|
||||
id=player_data["player_id"],
|
||||
name=player_data["p_name"],
|
||||
cost=player_data["cost"],
|
||||
image=player_data.get("image"),
|
||||
image=player_data.get('image', ''),
|
||||
image2=player_data.get("image2"),
|
||||
cardset=PdCardset(**player_data["cardset"]),
|
||||
set_num=player_data["set_num"],
|
||||
rarity=PdRarity(**player_data["rarity"]),
|
||||
mlbclub=player_data["mlbclub"],
|
||||
franchise=player_data["franchise"],
|
||||
pos_1=player_data.get("pos_1"),
|
||||
pos_1=player_data['pos_1'],
|
||||
pos_2=player_data.get("pos_2"),
|
||||
pos_3=player_data.get("pos_3"),
|
||||
pos_4=player_data.get("pos_4"),
|
||||
|
||||
210
backend/tests/unit/config/test_league_configs.py
Normal file
210
backend/tests/unit/config/test_league_configs.py
Normal file
@ -0,0 +1,210 @@
|
||||
"""
|
||||
Unit tests for league configuration system.
|
||||
|
||||
Tests:
|
||||
- Config loading and registry
|
||||
- Config immutability
|
||||
- Abstract method implementations
|
||||
- League-specific settings
|
||||
"""
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.config import (
|
||||
BaseGameConfig,
|
||||
SbaConfig,
|
||||
PdConfig,
|
||||
LEAGUE_CONFIGS,
|
||||
get_league_config
|
||||
)
|
||||
|
||||
|
||||
class TestBaseGameConfig:
|
||||
"""Tests for BaseGameConfig abstract class."""
|
||||
|
||||
def test_cannot_instantiate_abstract_base(self):
|
||||
"""BaseGameConfig cannot be instantiated directly."""
|
||||
with pytest.raises(TypeError):
|
||||
BaseGameConfig(league_id="test")
|
||||
|
||||
|
||||
class TestSbaConfig:
|
||||
"""Tests for SBA league configuration."""
|
||||
|
||||
def test_sba_config_creation(self):
|
||||
"""Can create SBA config instance."""
|
||||
config = SbaConfig()
|
||||
assert config.league_id == "sba"
|
||||
assert config.version == "1.0.0"
|
||||
|
||||
def test_sba_basic_rules(self):
|
||||
"""SBA uses standard baseball rules."""
|
||||
config = SbaConfig()
|
||||
assert config.innings == 9
|
||||
assert config.outs_per_inning == 3
|
||||
assert config.strikes_for_out == 3
|
||||
assert config.balls_for_walk == 4
|
||||
|
||||
def test_sba_result_chart_name(self):
|
||||
"""SBA uses sba_standard_v1 chart."""
|
||||
config = SbaConfig()
|
||||
assert config.get_result_chart_name() == "sba_standard_v1"
|
||||
|
||||
def test_sba_supports_manual_selection(self):
|
||||
"""SBA supports manual result selection."""
|
||||
config = SbaConfig()
|
||||
assert config.supports_manual_result_selection() is True
|
||||
|
||||
def test_sba_api_url(self):
|
||||
"""SBA API URL is correct."""
|
||||
config = SbaConfig()
|
||||
assert config.get_api_base_url() == "https://api.sba.manticorum.com"
|
||||
|
||||
def test_sba_player_selection_mode(self):
|
||||
"""SBA uses manual player selection."""
|
||||
config = SbaConfig()
|
||||
assert config.player_selection_mode == "manual"
|
||||
|
||||
def test_sba_config_is_immutable(self):
|
||||
"""Config cannot be modified after creation."""
|
||||
config = SbaConfig()
|
||||
with pytest.raises(ValidationError):
|
||||
config.innings = 7
|
||||
|
||||
|
||||
class TestPdConfig:
|
||||
"""Tests for PD league configuration."""
|
||||
|
||||
def test_pd_config_creation(self):
|
||||
"""Can create PD config instance."""
|
||||
config = PdConfig()
|
||||
assert config.league_id == "pd"
|
||||
assert config.version == "1.0.0"
|
||||
|
||||
def test_pd_basic_rules(self):
|
||||
"""PD uses standard baseball rules."""
|
||||
config = PdConfig()
|
||||
assert config.innings == 9
|
||||
assert config.outs_per_inning == 3
|
||||
assert config.strikes_for_out == 3
|
||||
assert config.balls_for_walk == 4
|
||||
|
||||
def test_pd_result_chart_name(self):
|
||||
"""PD uses pd_standard_v1 chart."""
|
||||
config = PdConfig()
|
||||
assert config.get_result_chart_name() == "pd_standard_v1"
|
||||
|
||||
def test_pd_supports_manual_selection(self):
|
||||
"""PD supports manual result selection (though auto is also available)."""
|
||||
config = PdConfig()
|
||||
assert config.supports_manual_result_selection() is True
|
||||
|
||||
def test_pd_api_url(self):
|
||||
"""PD API URL is correct."""
|
||||
config = PdConfig()
|
||||
assert config.get_api_base_url() == "https://pd.manticorum.com"
|
||||
|
||||
def test_pd_player_selection_mode(self):
|
||||
"""PD uses flexible player selection."""
|
||||
config = PdConfig()
|
||||
assert config.player_selection_mode == "flexible"
|
||||
|
||||
def test_pd_scouting_model_enabled(self):
|
||||
"""PD has scouting model enabled."""
|
||||
config = PdConfig()
|
||||
assert config.use_scouting_model is True
|
||||
|
||||
def test_pd_cardset_validation_enabled(self):
|
||||
"""PD has cardset validation enabled."""
|
||||
config = PdConfig()
|
||||
assert config.cardset_validation is True
|
||||
|
||||
def test_pd_advanced_analytics_enabled(self):
|
||||
"""PD has advanced analytics enabled."""
|
||||
config = PdConfig()
|
||||
assert config.detailed_analytics is True
|
||||
assert config.wpa_calculation is True
|
||||
|
||||
def test_pd_config_is_immutable(self):
|
||||
"""Config cannot be modified after creation."""
|
||||
config = PdConfig()
|
||||
with pytest.raises(ValidationError):
|
||||
config.use_scouting_model = False
|
||||
|
||||
|
||||
class TestLeagueRegistry:
|
||||
"""Tests for league config registry and getter."""
|
||||
|
||||
def test_registry_contains_both_leagues(self):
|
||||
"""Registry has both SBA and PD configs."""
|
||||
assert "sba" in LEAGUE_CONFIGS
|
||||
assert "pd" in LEAGUE_CONFIGS
|
||||
assert len(LEAGUE_CONFIGS) == 2
|
||||
|
||||
def test_registry_configs_are_correct_type(self):
|
||||
"""Registry contains correct config types."""
|
||||
assert isinstance(LEAGUE_CONFIGS["sba"], SbaConfig)
|
||||
assert isinstance(LEAGUE_CONFIGS["pd"], PdConfig)
|
||||
|
||||
def test_get_sba_config(self):
|
||||
"""Can get SBA config via getter."""
|
||||
config = get_league_config("sba")
|
||||
assert isinstance(config, SbaConfig)
|
||||
assert config.league_id == "sba"
|
||||
|
||||
def test_get_pd_config(self):
|
||||
"""Can get PD config via getter."""
|
||||
config = get_league_config("pd")
|
||||
assert isinstance(config, PdConfig)
|
||||
assert config.league_id == "pd"
|
||||
|
||||
def test_get_invalid_league_raises(self):
|
||||
"""Getting invalid league raises ValueError."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
get_league_config("invalid")
|
||||
assert "Unknown league: invalid" in str(exc_info.value)
|
||||
assert "sba" in str(exc_info.value)
|
||||
assert "pd" in str(exc_info.value)
|
||||
|
||||
def test_registry_returns_same_instance(self):
|
||||
"""Registry returns singleton instances."""
|
||||
config1 = get_league_config("sba")
|
||||
config2 = get_league_config("sba")
|
||||
assert config1 is config2
|
||||
|
||||
|
||||
class TestConfigDifferences:
|
||||
"""Tests for differences between league configs."""
|
||||
|
||||
def test_different_result_charts(self):
|
||||
"""SBA and PD use different result charts."""
|
||||
sba = get_league_config("sba")
|
||||
pd = get_league_config("pd")
|
||||
assert sba.get_result_chart_name() != pd.get_result_chart_name()
|
||||
|
||||
def test_different_api_urls(self):
|
||||
"""SBA and PD have different API URLs."""
|
||||
sba = get_league_config("sba")
|
||||
pd = get_league_config("pd")
|
||||
assert sba.get_api_base_url() != pd.get_api_base_url()
|
||||
|
||||
def test_different_player_selection_modes(self):
|
||||
"""SBA and PD have different selection modes."""
|
||||
sba = get_league_config("sba")
|
||||
pd = get_league_config("pd")
|
||||
assert sba.player_selection_mode == "manual"
|
||||
assert pd.player_selection_mode == "flexible"
|
||||
|
||||
def test_pd_has_extra_features(self):
|
||||
"""PD has features that SBA doesn't."""
|
||||
pd = get_league_config("pd")
|
||||
sba = get_league_config("sba")
|
||||
|
||||
# PD-specific attributes
|
||||
assert hasattr(pd, 'use_scouting_model')
|
||||
assert hasattr(pd, 'cardset_validation')
|
||||
assert hasattr(pd, 'detailed_analytics')
|
||||
|
||||
# SBA doesn't have these (or they're False/None)
|
||||
assert not hasattr(sba, 'use_scouting_model')
|
||||
assert not hasattr(sba, 'cardset_validation')
|
||||
215
backend/tests/unit/config/test_play_outcome.py
Normal file
215
backend/tests/unit/config/test_play_outcome.py
Normal file
@ -0,0 +1,215 @@
|
||||
"""
|
||||
Unit tests for PlayOutcome enum.
|
||||
|
||||
Tests helper methods and outcome categorization.
|
||||
"""
|
||||
import pytest
|
||||
from app.config import PlayOutcome
|
||||
|
||||
|
||||
class TestPlayOutcomeHelpers:
|
||||
"""Tests for PlayOutcome helper methods."""
|
||||
|
||||
def test_is_hit_for_singles(self):
|
||||
"""Single outcomes are hits."""
|
||||
assert PlayOutcome.SINGLE.is_hit() is True
|
||||
assert PlayOutcome.SINGLE_UNCAPPED.is_hit() is True
|
||||
assert PlayOutcome.BP_SINGLE.is_hit() is True
|
||||
|
||||
def test_is_hit_for_extra_bases(self):
|
||||
"""Extra base hits are hits."""
|
||||
assert PlayOutcome.DOUBLE.is_hit() is True
|
||||
assert PlayOutcome.DOUBLE_UNCAPPED.is_hit() is True
|
||||
assert PlayOutcome.TRIPLE.is_hit() is True
|
||||
assert PlayOutcome.HOMERUN.is_hit() is True
|
||||
assert PlayOutcome.BP_HOMERUN.is_hit() is True
|
||||
|
||||
def test_is_hit_false_for_outs(self):
|
||||
"""Outs are not hits."""
|
||||
assert PlayOutcome.STRIKEOUT.is_hit() is False
|
||||
assert PlayOutcome.GROUNDOUT.is_hit() is False
|
||||
assert PlayOutcome.FLYOUT.is_hit() is False
|
||||
assert PlayOutcome.BP_FLYOUT.is_hit() is False
|
||||
|
||||
def test_is_hit_false_for_walks(self):
|
||||
"""Walks are not hits."""
|
||||
assert PlayOutcome.WALK.is_hit() is False
|
||||
assert PlayOutcome.INTENTIONAL_WALK.is_hit() is False
|
||||
|
||||
def test_is_out_for_standard_outs(self):
|
||||
"""Standard outs are outs."""
|
||||
assert PlayOutcome.STRIKEOUT.is_out() is True
|
||||
assert PlayOutcome.GROUNDOUT.is_out() is True
|
||||
assert PlayOutcome.FLYOUT.is_out() is True
|
||||
assert PlayOutcome.LINEOUT.is_out() is True
|
||||
assert PlayOutcome.POPOUT.is_out() is True
|
||||
assert PlayOutcome.DOUBLE_PLAY.is_out() is True
|
||||
|
||||
def test_is_out_for_baserunning_outs(self):
|
||||
"""Baserunning outs are outs."""
|
||||
assert PlayOutcome.CAUGHT_STEALING.is_out() is True
|
||||
assert PlayOutcome.PICK_OFF.is_out() is True
|
||||
|
||||
def test_is_out_for_ballpark_outs(self):
|
||||
"""Ballpark outs are outs."""
|
||||
assert PlayOutcome.BP_FLYOUT.is_out() is True
|
||||
assert PlayOutcome.BP_LINEOUT.is_out() is True
|
||||
|
||||
def test_is_out_false_for_hits(self):
|
||||
"""Hits are not outs."""
|
||||
assert PlayOutcome.SINGLE.is_out() is False
|
||||
assert PlayOutcome.HOMERUN.is_out() is False
|
||||
|
||||
def test_is_walk(self):
|
||||
"""Walk outcomes are walks."""
|
||||
assert PlayOutcome.WALK.is_walk() is True
|
||||
assert PlayOutcome.INTENTIONAL_WALK.is_walk() is True
|
||||
|
||||
def test_is_walk_false_for_hits(self):
|
||||
"""Hits are not walks."""
|
||||
assert PlayOutcome.SINGLE.is_walk() is False
|
||||
assert PlayOutcome.HOMERUN.is_walk() is False
|
||||
|
||||
def test_is_walk_false_for_hbp(self):
|
||||
"""HBP is not a walk (different stat)."""
|
||||
assert PlayOutcome.HIT_BY_PITCH.is_walk() is False
|
||||
|
||||
def test_is_uncapped(self):
|
||||
"""Uncapped outcomes are uncapped."""
|
||||
assert PlayOutcome.SINGLE_UNCAPPED.is_uncapped() is True
|
||||
assert PlayOutcome.DOUBLE_UNCAPPED.is_uncapped() is True
|
||||
|
||||
def test_is_uncapped_false_for_normal_hits(self):
|
||||
"""Normal hits are not uncapped."""
|
||||
assert PlayOutcome.SINGLE.is_uncapped() is False
|
||||
assert PlayOutcome.DOUBLE.is_uncapped() is False
|
||||
assert PlayOutcome.TRIPLE.is_uncapped() is False
|
||||
|
||||
def test_is_interrupt(self):
|
||||
"""Interrupt plays are interrupts."""
|
||||
assert PlayOutcome.WILD_PITCH.is_interrupt() is True
|
||||
assert PlayOutcome.PASSED_BALL.is_interrupt() is True
|
||||
assert PlayOutcome.STOLEN_BASE.is_interrupt() is True
|
||||
assert PlayOutcome.CAUGHT_STEALING.is_interrupt() is True
|
||||
assert PlayOutcome.BALK.is_interrupt() is True
|
||||
assert PlayOutcome.PICK_OFF.is_interrupt() is True
|
||||
|
||||
def test_is_interrupt_false_for_normal_plays(self):
|
||||
"""Normal plays are not interrupts."""
|
||||
assert PlayOutcome.SINGLE.is_interrupt() is False
|
||||
assert PlayOutcome.STRIKEOUT.is_interrupt() is False
|
||||
assert PlayOutcome.WALK.is_interrupt() is False
|
||||
|
||||
def test_is_extra_base_hit(self):
|
||||
"""Extra base hits are identified correctly."""
|
||||
assert PlayOutcome.DOUBLE.is_extra_base_hit() is True
|
||||
assert PlayOutcome.DOUBLE_UNCAPPED.is_extra_base_hit() is True
|
||||
assert PlayOutcome.TRIPLE.is_extra_base_hit() is True
|
||||
assert PlayOutcome.HOMERUN.is_extra_base_hit() is True
|
||||
assert PlayOutcome.BP_HOMERUN.is_extra_base_hit() is True
|
||||
|
||||
def test_is_extra_base_hit_false_for_singles(self):
|
||||
"""Singles are not extra base hits."""
|
||||
assert PlayOutcome.SINGLE.is_extra_base_hit() is False
|
||||
assert PlayOutcome.SINGLE_UNCAPPED.is_extra_base_hit() is False
|
||||
assert PlayOutcome.BP_SINGLE.is_extra_base_hit() is False
|
||||
|
||||
|
||||
class TestGetBasesAdvanced:
|
||||
"""Tests for get_bases_advanced() method."""
|
||||
|
||||
def test_singles_advance_one_base(self):
|
||||
"""Singles advance one base."""
|
||||
assert PlayOutcome.SINGLE.get_bases_advanced() == 1
|
||||
assert PlayOutcome.SINGLE_UNCAPPED.get_bases_advanced() == 1
|
||||
assert PlayOutcome.BP_SINGLE.get_bases_advanced() == 1
|
||||
|
||||
def test_doubles_advance_two_bases(self):
|
||||
"""Doubles advance two bases."""
|
||||
assert PlayOutcome.DOUBLE.get_bases_advanced() == 2
|
||||
assert PlayOutcome.DOUBLE_UNCAPPED.get_bases_advanced() == 2
|
||||
|
||||
def test_triples_advance_three_bases(self):
|
||||
"""Triples advance three bases."""
|
||||
assert PlayOutcome.TRIPLE.get_bases_advanced() == 3
|
||||
|
||||
def test_homeruns_advance_four_bases(self):
|
||||
"""Home runs advance four bases."""
|
||||
assert PlayOutcome.HOMERUN.get_bases_advanced() == 4
|
||||
assert PlayOutcome.BP_HOMERUN.get_bases_advanced() == 4
|
||||
|
||||
def test_outs_advance_zero_bases(self):
|
||||
"""Outs advance zero bases."""
|
||||
assert PlayOutcome.STRIKEOUT.get_bases_advanced() == 0
|
||||
assert PlayOutcome.GROUNDOUT.get_bases_advanced() == 0
|
||||
assert PlayOutcome.FLYOUT.get_bases_advanced() == 0
|
||||
|
||||
def test_walks_advance_zero_bases(self):
|
||||
"""Walks advance zero bases (forced advancement handled separately)."""
|
||||
assert PlayOutcome.WALK.get_bases_advanced() == 0
|
||||
assert PlayOutcome.INTENTIONAL_WALK.get_bases_advanced() == 0
|
||||
|
||||
def test_interrupts_advance_zero_bases(self):
|
||||
"""Interrupts advance zero bases (advancement handled by interrupt logic)."""
|
||||
assert PlayOutcome.WILD_PITCH.get_bases_advanced() == 0
|
||||
assert PlayOutcome.STOLEN_BASE.get_bases_advanced() == 0
|
||||
|
||||
|
||||
class TestPlayOutcomeValues:
|
||||
"""Tests for PlayOutcome string values."""
|
||||
|
||||
def test_outcome_string_values(self):
|
||||
"""Outcome values match expected strings."""
|
||||
assert PlayOutcome.STRIKEOUT.value == "strikeout"
|
||||
assert PlayOutcome.SINGLE.value == "single"
|
||||
assert PlayOutcome.SINGLE_UNCAPPED.value == "single_uncapped"
|
||||
assert PlayOutcome.WILD_PITCH.value == "wild_pitch"
|
||||
assert PlayOutcome.BP_HOMERUN.value == "bp_homerun"
|
||||
|
||||
def test_can_create_from_string(self):
|
||||
"""Can create PlayOutcome from string value."""
|
||||
outcome = PlayOutcome("strikeout")
|
||||
assert outcome == PlayOutcome.STRIKEOUT
|
||||
|
||||
def test_uncapped_uses_code_friendly_names(self):
|
||||
"""Uncapped outcomes use underscores not parentheses."""
|
||||
assert "(" not in PlayOutcome.SINGLE_UNCAPPED.value
|
||||
assert "(" not in PlayOutcome.DOUBLE_UNCAPPED.value
|
||||
assert "_" in PlayOutcome.SINGLE_UNCAPPED.value
|
||||
|
||||
|
||||
class TestPlayOutcomeCompleteness:
|
||||
"""Tests to ensure all outcome categories are covered."""
|
||||
|
||||
def test_all_hits_categorized(self):
|
||||
"""All hit outcomes are properly categorized."""
|
||||
hit_outcomes = {
|
||||
PlayOutcome.SINGLE, PlayOutcome.DOUBLE, PlayOutcome.TRIPLE,
|
||||
PlayOutcome.HOMERUN, PlayOutcome.SINGLE_UNCAPPED,
|
||||
PlayOutcome.DOUBLE_UNCAPPED, PlayOutcome.BP_HOMERUN,
|
||||
PlayOutcome.BP_SINGLE
|
||||
}
|
||||
for outcome in hit_outcomes:
|
||||
assert outcome.is_hit() is True, f"{outcome} should be a hit"
|
||||
|
||||
def test_all_outs_categorized(self):
|
||||
"""All out outcomes are properly categorized."""
|
||||
out_outcomes = {
|
||||
PlayOutcome.STRIKEOUT, PlayOutcome.GROUNDOUT,
|
||||
PlayOutcome.FLYOUT, PlayOutcome.LINEOUT,
|
||||
PlayOutcome.POPOUT, PlayOutcome.DOUBLE_PLAY,
|
||||
PlayOutcome.CAUGHT_STEALING, PlayOutcome.PICK_OFF,
|
||||
PlayOutcome.BP_FLYOUT, PlayOutcome.BP_LINEOUT
|
||||
}
|
||||
for outcome in out_outcomes:
|
||||
assert outcome.is_out() is True, f"{outcome} should be an out"
|
||||
|
||||
def test_all_interrupts_categorized(self):
|
||||
"""All interrupt outcomes are properly categorized."""
|
||||
interrupt_outcomes = {
|
||||
PlayOutcome.WILD_PITCH, PlayOutcome.PASSED_BALL,
|
||||
PlayOutcome.STOLEN_BASE, PlayOutcome.CAUGHT_STEALING,
|
||||
PlayOutcome.BALK, PlayOutcome.PICK_OFF
|
||||
}
|
||||
for outcome in interrupt_outcomes:
|
||||
assert outcome.is_interrupt() is True, f"{outcome} should be an interrupt"
|
||||
Loading…
Reference in New Issue
Block a user