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:
Cal Corum 2025-10-28 22:46:12 -05:00
parent a0146223c8
commit 5d5c13f2b8
8 changed files with 1022 additions and 51 deletions

View File

@ -1702,15 +1702,213 @@ terminal_client/
- ⏳ Result charts & PD integration (not started) - ⏳ Result charts & PD integration (not started)
- ⏳ API client (deferred) - ⏳ 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**: **Next Priorities**:
1. League configuration system (BaseConfig, SbaConfig, PdConfig) 1. Update dice system (chaos_d20)
2. Result charts & PD play resolution with ratings 2. Integrate PlayOutcome into PlayResolver
3. API client for live roster data (optional for now) 3. Add Play.metadata support for uncapped hits
4. Complete week 6 remaining work
**Python Version**: 3.13.3 **Python Version**: 3.13.3
**Database Server**: 10.10.0.42:5432 **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) ## Database Model Updates (2025-10-21)

View 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'
]

View 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

View 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

View 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

View File

@ -25,12 +25,20 @@ class BasePlayer(BaseModel, ABC):
# Common fields across all leagues # Common fields across all leagues
id: int = Field(..., description="Player ID (SBA) or Card ID (PD)") id: int = Field(..., description="Player ID (SBA) or Card ID (PD)")
name: str = Field(..., description="Player display name") 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 # Positions (up to 8 possible positions)
def get_image_url(self) -> str: pos_1: str = Field(..., description="Primary position")
"""Get player image URL (with fallback logic if needed).""" pos_2: Optional[str] = Field(None, description="Secondary position")
pass 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 @abstractmethod
def get_positions(self) -> List[str]: def get_positions(self) -> List[str]:
@ -42,6 +50,10 @@ class BasePlayer(BaseModel, ABC):
"""Get formatted display name for UI.""" """Get formatted display name for UI."""
pass 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: class Config:
"""Pydantic configuration.""" """Pydantic configuration."""
# Allow extra fields for future extensibility # Allow extra fields for future extensibility
@ -60,31 +72,30 @@ class SbaPlayer(BasePlayer):
# SBA-specific fields # SBA-specific fields
wara: float = Field(default=0.0, description="Wins Above Replacement Average") 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_id: Optional[int] = Field(None, description="Current team ID")
team_name: Optional[str] = Field(None, description="Current team name") team_name: Optional[str] = Field(None, description="Current team name")
season: Optional[int] = Field(None, description="Season number") 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 # 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") strat_code: Optional[str] = Field(None, description="Strat-O-Matic code")
bbref_id: Optional[str] = Field(None, description="Baseball Reference ID") bbref_id: Optional[str] = Field(None, description="Baseball Reference ID")
injury_rating: Optional[str] = Field(None, description="Injury rating") injury_rating: Optional[str] = Field(None, description="Injury rating")
def get_image_url(self) -> str: def get_pitching_card_url(self) -> str:
"""Get player image with fallback logic.""" """Get pitching card image"""
return self.image or self.image2 or self.headshot or "" 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]: def get_positions(self) -> List[str]:
"""Get list of all positions player can play.""" """Get list of all positions player can play."""
@ -117,13 +128,13 @@ class SbaPlayer(BasePlayer):
return cls( return cls(
id=data["id"], id=data["id"],
name=data["name"], name=data["name"],
image=data.get("image"), image=data.get("image", ""),
image2=data.get("image2"), image2=data.get("image2"),
wara=data.get("wara", 0.0), wara=data.get("wara", 0.0),
team_id=team_id, team_id=team_id,
team_name=team_name, team_name=team_name,
season=data.get("season"), season=data.get("season"),
pos_1=data.get("pos_1"), pos_1=data["pos_1"],
pos_2=data.get("pos_2"), pos_2=data.get("pos_2"),
pos_3=data.get("pos_3"), pos_3=data.get("pos_3"),
pos_4=data.get("pos_4"), pos_4=data.get("pos_4"),
@ -283,10 +294,11 @@ class PdPlayer(BasePlayer):
Complex model with detailed scouting data for simulation. Complex model with detailed scouting data for simulation.
Matches API response from: {{baseUrl}}/api/v2/players/:player_id 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) # PD-specific fields
player_id: int = Field(..., description="PD player card ID", alias="id")
cost: int = Field(..., description="Card cost/value") cost: int = Field(..., description="Card cost/value")
# Card metadata # Card metadata
@ -298,21 +310,6 @@ class PdPlayer(BasePlayer):
mlbclub: str = Field(..., description="MLB club name") mlbclub: str = Field(..., description="MLB club name")
franchise: str = Field(..., description="Franchise 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 # Reference IDs
strat_code: Optional[str] = Field(None, description="Strat-O-Matic code") strat_code: Optional[str] = Field(None, description="Strat-O-Matic code")
bbref_id: Optional[str] = Field(None, description="Baseball Reference ID") 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") batting_card: Optional[PdBattingCard] = Field(None, description="Batting card with ratings")
pitching_card: Optional[PdPitchingCard] = Field(None, description="Pitching 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]: def get_positions(self) -> List[str]:
"""Get list of all positions player can play.""" """Get list of all positions player can play."""
positions = [ positions = [
@ -340,7 +333,7 @@ class PdPlayer(BasePlayer):
def get_display_name(self) -> str: def get_display_name(self) -> str:
"""Get formatted display name with description.""" """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]: def get_batting_rating(self, vs_hand: str) -> Optional[PdBattingRating]:
""" """
@ -438,14 +431,14 @@ class PdPlayer(BasePlayer):
id=player_data["player_id"], id=player_data["player_id"],
name=player_data["p_name"], name=player_data["p_name"],
cost=player_data["cost"], cost=player_data["cost"],
image=player_data.get("image"), image=player_data.get('image', ''),
image2=player_data.get("image2"), image2=player_data.get("image2"),
cardset=PdCardset(**player_data["cardset"]), cardset=PdCardset(**player_data["cardset"]),
set_num=player_data["set_num"], set_num=player_data["set_num"],
rarity=PdRarity(**player_data["rarity"]), rarity=PdRarity(**player_data["rarity"]),
mlbclub=player_data["mlbclub"], mlbclub=player_data["mlbclub"],
franchise=player_data["franchise"], 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_2=player_data.get("pos_2"),
pos_3=player_data.get("pos_3"), pos_3=player_data.get("pos_3"),
pos_4=player_data.get("pos_4"), pos_4=player_data.get("pos_4"),

View 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')

View 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"