Complete X-Check resolution table system for defensive play outcomes. Components: - Defense range tables (20×5) for infield, outfield, catcher - Error charts for LF/RF and CF (ratings 0-25) - Placeholder error charts for P, C, 1B, 2B, 3B, SS (awaiting data) - get_fielders_holding_runners() - Complete implementation - get_error_chart_for_position() - Maps all 9 positions - 6 X-Check placeholder advancement functions (g1-g3, f1-f3) League Config Integration: - Both SbaConfig and PdConfig include X-Check tables - Shared common tables via league_configs.py - Attributes: x_check_defense_tables, x_check_error_charts, x_check_holding_runners Testing: - 36 tests for X-Check tables (all passing) - 9 tests for X-Check placeholders (all passing) - Total: 45/45 tests passing Documentation: - Updated backend/CLAUDE.md with Phase 3B section - Updated app/config/CLAUDE.md with X-Check tables documentation - Updated app/core/CLAUDE.md with X-Check placeholder functions - Updated tests/CLAUDE.md with new test counts (519 unit tests) - Updated phase-3b-league-config-tables.md (marked complete) - Updated NEXT_SESSION.md with Phase 3B completion What's Pending: - 6 infield error charts need actual data (P, C, 1B, 2B, 3B, SS) - Phase 3C will implement full X-Check resolution logic 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
987 lines
27 KiB
Markdown
987 lines
27 KiB
Markdown
# Configuration System - League Rules & Play Outcomes
|
||
|
||
## Purpose
|
||
|
||
The configuration system provides immutable, league-specific game rules and play outcome definitions for the Paper Dynasty game engine. It serves as the single source of truth for:
|
||
|
||
- League-specific game rules (innings, outs, feature flags)
|
||
- API endpoint configuration for external data sources
|
||
- Universal play outcome definitions (hits, outs, walks, etc.)
|
||
- Card-based resolution mechanics for both manual and auto modes
|
||
- Hit location calculation for runner advancement logic
|
||
|
||
This system enables a **league-agnostic game engine** that adapts to SBA and PD league differences through configuration rather than conditional logic.
|
||
|
||
## Architecture Overview
|
||
|
||
```
|
||
app/config/
|
||
├── __init__.py # Public API exports
|
||
├── base_config.py # Abstract base configuration
|
||
├── league_configs.py # Concrete SBA/PD implementations
|
||
└── result_charts.py # PlayOutcome enum + result chart abstractions
|
||
```
|
||
|
||
### Design Principles
|
||
|
||
1. **Immutability**: Configs are frozen Pydantic models (cannot be modified after creation)
|
||
2. **Registry Pattern**: Pre-instantiated singletons in `LEAGUE_CONFIGS` dict
|
||
3. **Type Safety**: Full Pydantic validation with abstract base class enforcement
|
||
4. **League Agnostic**: Game engine uses `BaseGameConfig` interface, never concrete types
|
||
|
||
## Key Components
|
||
|
||
### 1. BaseGameConfig (Abstract Base Class)
|
||
|
||
**Location**: `base_config.py:13-77`
|
||
|
||
Defines the interface all league configs must implement.
|
||
|
||
**Common Fields**:
|
||
- `league_id` (str): League identifier ('sba' or 'pd')
|
||
- `version` (str): Config version for compatibility tracking
|
||
- `innings` (int): Standard innings per game (default 9)
|
||
- `outs_per_inning` (int): Outs required per half-inning (default 3)
|
||
|
||
**Abstract Methods** (must be implemented by subclasses):
|
||
```python
|
||
@abstractmethod
|
||
def get_result_chart_name(self) -> str:
|
||
"""Get name of result chart to use for this league."""
|
||
|
||
@abstractmethod
|
||
def supports_manual_result_selection(self) -> bool:
|
||
"""Whether players manually select results after dice roll."""
|
||
|
||
@abstractmethod
|
||
def supports_auto_mode(self) -> bool:
|
||
"""Whether this league supports auto-resolution of outcomes."""
|
||
|
||
@abstractmethod
|
||
def get_api_base_url(self) -> str:
|
||
"""Get base URL for league's external API."""
|
||
```
|
||
|
||
**Configuration**:
|
||
```python
|
||
class Config:
|
||
frozen = True # Immutable - prevents accidental modification
|
||
```
|
||
|
||
### 2. League-Specific Configs
|
||
|
||
#### SbaConfig
|
||
|
||
**Location**: `league_configs.py:17-46`
|
||
|
||
Configuration for SBA League with manual result selection.
|
||
|
||
**Features**:
|
||
- Manual result selection only (physical cards, not digitized)
|
||
- Simple player data model
|
||
- Standard baseball rules
|
||
|
||
**Unique Fields**:
|
||
- `player_selection_mode`: "manual" (always manual selection)
|
||
|
||
**Methods**:
|
||
- `get_result_chart_name()` → "sba_standard_v1"
|
||
- `supports_manual_result_selection()` → True
|
||
- `supports_auto_mode()` → False (cards not digitized)
|
||
- `get_api_base_url()` → "https://api.sba.manticorum.com"
|
||
|
||
#### PdConfig
|
||
|
||
**Location**: `league_configs.py:49-86`
|
||
|
||
Configuration for Paper Dynasty League with flexible resolution modes.
|
||
|
||
**Features**:
|
||
- Flexible result selection (manual OR auto via scouting)
|
||
- Complex scouting data model (PdBattingRating/PdPitchingRating)
|
||
- Cardset validation
|
||
- Advanced analytics (WPA, RE24)
|
||
|
||
**Unique Fields**:
|
||
- `player_selection_mode`: "flexible" (manual or auto)
|
||
- `use_scouting_model`: True (use detailed ratings for auto)
|
||
- `cardset_validation`: True (validate cards against approved sets)
|
||
- `detailed_analytics`: True (track advanced stats)
|
||
- `wpa_calculation`: True (calculate win probability added)
|
||
|
||
**Methods**:
|
||
- `get_result_chart_name()` → "pd_standard_v1"
|
||
- `supports_manual_result_selection()` → True (though auto is also available)
|
||
- `supports_auto_mode()` → True (via digitized scouting data)
|
||
- `get_api_base_url()` → "https://pd.manticorum.com"
|
||
|
||
### 3. Config Registry
|
||
|
||
**Location**: `league_configs.py:88-115`
|
||
|
||
Pre-instantiated singletons for O(1) lookup.
|
||
|
||
```python
|
||
LEAGUE_CONFIGS: Dict[str, BaseGameConfig] = {
|
||
"sba": SbaConfig(),
|
||
"pd": PdConfig()
|
||
}
|
||
|
||
def get_league_config(league_id: str) -> BaseGameConfig:
|
||
"""Get configuration for specified league."""
|
||
config = LEAGUE_CONFIGS.get(league_id)
|
||
if not config:
|
||
raise ValueError(f"Unknown league: {league_id}")
|
||
return config
|
||
```
|
||
|
||
### 4. PlayOutcome Enum
|
||
|
||
**Location**: `result_charts.py:38-197`
|
||
|
||
Universal enum defining all possible play outcomes for both leagues.
|
||
|
||
**Outcome Categories**:
|
||
|
||
1. **Outs** (9 types):
|
||
- `STRIKEOUT`
|
||
- `GROUNDBALL_A` / `GROUNDBALL_B` / `GROUNDBALL_C` (double play vs groundout)
|
||
- `FLYOUT_A` / `FLYOUT_B` / `FLYOUT_C` (different trajectories/depths)
|
||
- `LINEOUT`
|
||
- `POPOUT`
|
||
|
||
2. **Hits** (8 types):
|
||
- `SINGLE_1` / `SINGLE_2` / `SINGLE_UNCAPPED` (standard vs enhanced vs decision tree)
|
||
- `DOUBLE_2` / `DOUBLE_3` / `DOUBLE_UNCAPPED` (2nd base vs 3rd base vs decision tree)
|
||
- `TRIPLE`
|
||
- `HOMERUN`
|
||
|
||
3. **Walks/HBP** (3 types):
|
||
- `WALK`
|
||
- `HIT_BY_PITCH`
|
||
- `INTENTIONAL_WALK`
|
||
|
||
4. **Errors** (1 type):
|
||
- `ERROR`
|
||
|
||
5. **Interrupt Plays** (6 types) - logged with `pa=0`:
|
||
- `WILD_PITCH` (Play.wp = 1)
|
||
- `PASSED_BALL` (Play.pb = 1)
|
||
- `STOLEN_BASE` (Play.sb = 1)
|
||
- `CAUGHT_STEALING` (Play.cs = 1)
|
||
- `BALK` (Play.balk = 1)
|
||
- `PICK_OFF` (Play.pick_off = 1)
|
||
|
||
6. **Ballpark Power** (4 types) - PD league specific:
|
||
- `BP_HOMERUN` (Play.bphr = 1)
|
||
- `BP_SINGLE` (Play.bp1b = 1)
|
||
- `BP_FLYOUT` (Play.bpfo = 1)
|
||
- `BP_LINEOUT` (Play.bplo = 1)
|
||
|
||
**Helper Methods**:
|
||
```python
|
||
outcome = PlayOutcome.SINGLE_UNCAPPED
|
||
|
||
# Categorization helpers
|
||
outcome.is_hit() # True
|
||
outcome.is_out() # False
|
||
outcome.is_walk() # False
|
||
outcome.is_uncapped() # True - requires advancement decision
|
||
outcome.is_interrupt() # False
|
||
outcome.is_extra_base_hit() # False
|
||
|
||
# Advancement logic
|
||
outcome.get_bases_advanced() # 1
|
||
outcome.requires_hit_location() # False (only groundballs/flyouts)
|
||
```
|
||
|
||
### 5. Hit Location Calculation
|
||
|
||
**Location**: `result_charts.py:206-279`
|
||
|
||
Calculates fielder positions for groundballs and flyouts based on batter handedness.
|
||
|
||
**Function**:
|
||
```python
|
||
def calculate_hit_location(
|
||
outcome: PlayOutcome,
|
||
batter_handedness: str
|
||
) -> Optional[str]:
|
||
"""
|
||
Calculate hit location based on outcome and batter handedness.
|
||
|
||
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)
|
||
"""
|
||
```
|
||
|
||
**Usage**:
|
||
```python
|
||
from app.config import calculate_hit_location, PlayOutcome
|
||
|
||
# Calculate location for groundball
|
||
location = calculate_hit_location(PlayOutcome.GROUNDBALL_A, 'R') # '3B', 'SS', etc.
|
||
|
||
# Only works for groundballs/flyouts
|
||
location = calculate_hit_location(PlayOutcome.HOMERUN, 'R') # None
|
||
```
|
||
|
||
### 6. ResultChart Abstraction (Future)
|
||
|
||
**Location**: `result_charts.py:285-588`
|
||
|
||
Abstract base class for result chart implementations. Currently defines interface for future auto-mode implementation.
|
||
|
||
**Classes**:
|
||
- `ResultChart` (ABC): Abstract interface
|
||
- `ManualResultChart`: Placeholder (not used - manual outcomes come via WebSocket)
|
||
- `PdAutoResultChart`: Auto-resolution for PD league using digitized card data
|
||
|
||
**Note**: Manual mode doesn't use result charts - outcomes come directly from WebSocket handlers.
|
||
|
||
### 7. X-Check Tables (Phase 3B)
|
||
|
||
**Location**: `common_x_check_tables.py`
|
||
|
||
**Status**: ✅ Complete (2025-11-01)
|
||
|
||
X-Check resolution tables convert dice rolls into defensive play outcomes. These tables are shared across both SBA and PD leagues.
|
||
|
||
**Components**:
|
||
|
||
1. **Defense Range Tables** (20×5 each)
|
||
- `INFIELD_DEFENSE_TABLE`: Maps d20 roll × defense range (1-5) → result code
|
||
- Result codes: G1, G2, G2#, G3, G3#, SI1, SI2
|
||
- G2# and G3# convert to SI2 when fielder is holding runner
|
||
- `OUTFIELD_DEFENSE_TABLE`: Outfield defensive results
|
||
- Result codes: F1, F2, F3, SI2, DO2, DO3, TR3
|
||
- `CATCHER_DEFENSE_TABLE`: Catcher-specific results
|
||
- Result codes: G1, G2, G3, SI1, SPD, FO, PO
|
||
|
||
2. **Error Charts** (3d6 by error rating 0-25)
|
||
- `LF_RF_ERROR_CHART`: Corner outfield error rates (COMPLETE)
|
||
- `CF_ERROR_CHART`: Center field error rates (COMPLETE)
|
||
- Infield charts: `PITCHER_ERROR_CHART`, `CATCHER_ERROR_CHART`, `FIRST_BASE_ERROR_CHART`, `SECOND_BASE_ERROR_CHART`, `THIRD_BASE_ERROR_CHART`, `SHORTSTOP_ERROR_CHART` (PLACEHOLDERS - awaiting data)
|
||
|
||
**Error Types**:
|
||
- `RP`: Replay (runner returns, batter re-rolls)
|
||
- `E1`: Minor error (batter safe, runners advance 1 base)
|
||
- `E2`: Moderate error (batter safe, runners advance 2 bases)
|
||
- `E3`: Major error (batter safe, runners advance 3 bases)
|
||
- `NO`: No error (default if 3d6 roll not in any list)
|
||
|
||
3. **Helper Functions**
|
||
- `get_fielders_holding_runners(runner_bases, batter_handedness)` → List[str]
|
||
- Returns positions holding runners (e.g., `['1B', '2B', '3B']`)
|
||
- R1: 1B + middle infielder (2B for RHB, SS for LHB)
|
||
- R2: Middle infielder (if not already added)
|
||
- R3: 3B
|
||
- `get_error_chart_for_position(position)` → error chart dict
|
||
- Maps position code ('P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF') to appropriate error chart
|
||
|
||
**Integration**: Both `SbaConfig` and `PdConfig` include:
|
||
```python
|
||
x_check_defense_tables: Dict[str, List[List[str]]] = {
|
||
'infield': INFIELD_DEFENSE_TABLE,
|
||
'outfield': OUTFIELD_DEFENSE_TABLE,
|
||
'catcher': CATCHER_DEFENSE_TABLE,
|
||
}
|
||
x_check_error_charts: Callable = get_error_chart_for_position
|
||
x_check_holding_runners: Callable = get_fielders_holding_runners
|
||
```
|
||
|
||
**Usage Example**:
|
||
```python
|
||
from app.config import get_league_config
|
||
|
||
config = get_league_config('sba')
|
||
|
||
# Look up defense result
|
||
d20_roll = 15
|
||
defense_range = 3 # Average range
|
||
result = config.x_check_defense_tables['infield'][d20_roll - 1][defense_range - 1]
|
||
# Returns: 'G1'
|
||
|
||
# Check error
|
||
position = 'LF'
|
||
error_rating = 10
|
||
error_chart = config.x_check_error_charts(position)
|
||
error_chances = error_chart[error_rating]
|
||
|
||
# Determine fielders holding runners
|
||
runner_bases = [1, 3] # R1 and R3
|
||
batter_hand = 'R'
|
||
holding = config.x_check_holding_runners(runner_bases, batter_hand)
|
||
# Returns: ['1B', '2B', '3B']
|
||
```
|
||
|
||
**Test Coverage**: 36 tests in `tests/unit/config/test_x_check_tables.py`
|
||
|
||
**Next Phase**: Phase 3C will implement full X-Check resolution logic using these tables.
|
||
|
||
## Patterns & Conventions
|
||
|
||
### 1. Immutable Configuration
|
||
|
||
All configs are frozen after instantiation to prevent accidental modification.
|
||
|
||
```python
|
||
# ✅ CORRECT - Read-only access
|
||
config = get_league_config("sba")
|
||
api_url = config.get_api_base_url()
|
||
chart_name = config.get_result_chart_name()
|
||
|
||
# ❌ WRONG - Raises ValidationError
|
||
config.innings = 7 # ValidationError: "Game" object is immutable
|
||
```
|
||
|
||
### 2. Registry Pattern
|
||
|
||
Configs are pre-instantiated singletons in the registry, not created per-request.
|
||
|
||
```python
|
||
# ✅ CORRECT - Use registry
|
||
from app.config import get_league_config
|
||
config = get_league_config(league_id)
|
||
|
||
# ❌ WRONG - Don't instantiate directly
|
||
from app.config import SbaConfig
|
||
config = SbaConfig() # Creates unnecessary instance
|
||
```
|
||
|
||
### 3. League-Agnostic Code
|
||
|
||
Game engine uses `BaseGameConfig` interface, never concrete types.
|
||
|
||
```python
|
||
# ✅ CORRECT - Works for any league
|
||
def resolve_play(state: GameState, config: BaseGameConfig):
|
||
if config.supports_auto_mode():
|
||
# Auto-resolve
|
||
pass
|
||
else:
|
||
# Wait for manual input
|
||
pass
|
||
|
||
# ❌ WRONG - Hard-coded league logic
|
||
def resolve_play(state: GameState):
|
||
if state.league_id == "sba":
|
||
# SBA-specific logic
|
||
pass
|
||
elif state.league_id == "pd":
|
||
# PD-specific logic
|
||
pass
|
||
```
|
||
|
||
### 4. Enum Helper Methods
|
||
|
||
Use PlayOutcome helper methods instead of duplicate logic.
|
||
|
||
```python
|
||
# ✅ CORRECT - Use helper methods
|
||
if outcome.is_hit():
|
||
record_hit()
|
||
elif outcome.is_walk():
|
||
record_walk()
|
||
elif outcome.is_interrupt():
|
||
log_interrupt_play()
|
||
|
||
# ❌ WRONG - Duplicate categorization logic
|
||
if outcome in {PlayOutcome.SINGLE_1, PlayOutcome.SINGLE_2, PlayOutcome.HOMERUN, ...}:
|
||
record_hit()
|
||
```
|
||
|
||
### 5. Type Safety
|
||
|
||
Always use type hints with `BaseGameConfig` for league-agnostic code.
|
||
|
||
```python
|
||
# ✅ CORRECT - Type-safe
|
||
from app.config import BaseGameConfig
|
||
|
||
def process_game(config: BaseGameConfig) -> None:
|
||
# Works for SBA or PD
|
||
pass
|
||
|
||
# ❌ WRONG - No type safety
|
||
def process_game(config) -> None:
|
||
# Could be anything
|
||
pass
|
||
```
|
||
|
||
## Integration Points
|
||
|
||
### With Game Engine
|
||
|
||
```python
|
||
from app.config import get_league_config, PlayOutcome
|
||
from app.models import GameState
|
||
|
||
async def resolve_play(state: GameState, outcome: PlayOutcome):
|
||
# Get league-specific config
|
||
config = get_league_config(state.league_id)
|
||
|
||
# Handle based on outcome type
|
||
if outcome.is_uncapped() and state.on_base_code > 0:
|
||
# Uncapped hit with runners - need advancement decision
|
||
await request_advancement_decision(state)
|
||
elif outcome.is_interrupt():
|
||
# Interrupt play - logged with pa=0
|
||
await log_interrupt_play(state, outcome)
|
||
elif outcome.is_hit():
|
||
# Standard hit - advance runners
|
||
bases = outcome.get_bases_advanced()
|
||
await advance_batter(state, bases)
|
||
elif outcome.is_out():
|
||
# Record out
|
||
state.outs += 1
|
||
```
|
||
|
||
### With Database Models
|
||
|
||
```python
|
||
from app.config import PlayOutcome
|
||
from app.models import Play
|
||
|
||
async def save_play(outcome: PlayOutcome, state: GameState):
|
||
play = Play(
|
||
game_id=state.game_id,
|
||
outcome=outcome.value, # Store enum value as string
|
||
pa=0 if outcome.is_interrupt() else 1,
|
||
ab=1 if not outcome.is_walk() and not outcome.is_interrupt() else 0,
|
||
hit=1 if outcome.is_hit() else 0,
|
||
# ... other fields
|
||
)
|
||
await db_ops.save_play(play)
|
||
```
|
||
|
||
### With WebSocket Handlers
|
||
|
||
```python
|
||
from app.config import get_league_config, PlayOutcome
|
||
|
||
@sio.event
|
||
async def submit_manual_outcome(sid: str, data: dict):
|
||
"""Handle manual outcome submission from player."""
|
||
# Validate league supports manual mode
|
||
config = get_league_config(data['league_id'])
|
||
if not config.supports_manual_result_selection():
|
||
raise ValueError("Manual selection not supported for this league")
|
||
|
||
# Parse outcome
|
||
outcome = PlayOutcome(data['outcome'])
|
||
|
||
# Process play
|
||
await process_play_outcome(data['game_id'], outcome)
|
||
```
|
||
|
||
### With Player Models
|
||
|
||
```python
|
||
from app.config import calculate_hit_location, PlayOutcome
|
||
from app.models import PdPlayer
|
||
|
||
def resolve_groundball(batter: PdPlayer, outcome: PlayOutcome):
|
||
# Get batter handedness
|
||
handedness = batter.batting_card.hand if batter.batting_card else 'R'
|
||
|
||
# Calculate hit location
|
||
location = calculate_hit_location(outcome, handedness)
|
||
|
||
# Use location for advancement logic
|
||
if location in ['1B', '2B']:
|
||
# Right side groundball - slower to turn double play
|
||
pass
|
||
elif location in ['SS', '3B']:
|
||
# Left side groundball - easier double play
|
||
pass
|
||
```
|
||
|
||
## Common Tasks
|
||
|
||
### Adding a New League Config
|
||
|
||
1. **Create config class** in `league_configs.py`:
|
||
|
||
```python
|
||
class NewLeagueConfig(BaseGameConfig):
|
||
"""Configuration for New League."""
|
||
|
||
league_id: str = "new_league"
|
||
|
||
# New league-specific features
|
||
custom_feature: bool = True
|
||
|
||
def get_result_chart_name(self) -> str:
|
||
return "new_league_standard_v1"
|
||
|
||
def supports_manual_result_selection(self) -> bool:
|
||
return True
|
||
|
||
def supports_auto_mode(self) -> bool:
|
||
return False
|
||
|
||
def get_api_base_url(self) -> str:
|
||
return "https://api.newleague.com"
|
||
```
|
||
|
||
2. **Register in LEAGUE_CONFIGS**:
|
||
|
||
```python
|
||
LEAGUE_CONFIGS: Dict[str, BaseGameConfig] = {
|
||
"sba": SbaConfig(),
|
||
"pd": PdConfig(),
|
||
"new_league": NewLeagueConfig() # Add here
|
||
}
|
||
```
|
||
|
||
3. **Write tests** in `tests/unit/config/test_league_configs.py`:
|
||
|
||
```python
|
||
def test_new_league_config():
|
||
config = get_league_config("new_league")
|
||
assert config.league_id == "new_league"
|
||
assert config.get_result_chart_name() == "new_league_standard_v1"
|
||
assert config.supports_manual_result_selection() is True
|
||
assert config.supports_auto_mode() is False
|
||
```
|
||
|
||
### Adding a New PlayOutcome
|
||
|
||
1. **Add to enum** in `result_charts.py`:
|
||
|
||
```python
|
||
class PlayOutcome(str, Enum):
|
||
# ... existing outcomes
|
||
|
||
# New outcome
|
||
BUNT_SINGLE = "bunt_single" # New bunt result
|
||
```
|
||
|
||
2. **Update helper methods** if needed:
|
||
|
||
```python
|
||
def is_hit(self) -> bool:
|
||
return self in {
|
||
self.SINGLE_1, self.SINGLE_2, self.SINGLE_UNCAPPED,
|
||
# ... existing hits
|
||
self.BUNT_SINGLE # Add to hit category
|
||
}
|
||
```
|
||
|
||
3. **Write tests** in `tests/unit/config/test_play_outcome.py`:
|
||
|
||
```python
|
||
def test_bunt_single_categorization():
|
||
outcome = PlayOutcome.BUNT_SINGLE
|
||
assert outcome.is_hit()
|
||
assert not outcome.is_out()
|
||
assert outcome.get_bases_advanced() == 1
|
||
```
|
||
|
||
### Modifying Existing Config
|
||
|
||
**DON'T**: Configs are immutable by design.
|
||
|
||
**DO**: Create new version if rules change:
|
||
|
||
```python
|
||
# Old version (keep for compatibility)
|
||
class SbaConfigV1(BaseGameConfig):
|
||
league_id: str = "sba"
|
||
version: str = "1.0.0"
|
||
innings: int = 9
|
||
|
||
# New version (different rules)
|
||
class SbaConfigV2(BaseGameConfig):
|
||
league_id: str = "sba"
|
||
version: str = "2.0.0"
|
||
innings: int = 7 # New: 7-inning games
|
||
|
||
# Registry supports versioning
|
||
LEAGUE_CONFIGS = {
|
||
"sba:v1": SbaConfigV1(),
|
||
"sba:v2": SbaConfigV2(),
|
||
"sba": SbaConfigV2() # Default to latest
|
||
}
|
||
```
|
||
|
||
### Checking League Capabilities
|
||
|
||
```python
|
||
from app.config import get_league_config
|
||
|
||
def can_use_auto_mode(league_id: str) -> bool:
|
||
"""Check if league supports auto-resolution."""
|
||
config = get_league_config(league_id)
|
||
return config.supports_auto_mode()
|
||
|
||
def requires_cardset_validation(league_id: str) -> bool:
|
||
"""Check if league requires cardset validation."""
|
||
config = get_league_config(league_id)
|
||
# PD-specific check
|
||
return hasattr(config, 'cardset_validation') and config.cardset_validation
|
||
```
|
||
|
||
## Troubleshooting
|
||
|
||
### Problem: "Unknown league" error
|
||
|
||
**Symptom**:
|
||
```
|
||
ValueError: Unknown league: xyz. Valid leagues: ['sba', 'pd']
|
||
```
|
||
|
||
**Cause**: League ID not in registry
|
||
|
||
**Solution**:
|
||
```python
|
||
# Check valid leagues
|
||
from app.config import LEAGUE_CONFIGS
|
||
print(LEAGUE_CONFIGS.keys()) # ['sba', 'pd']
|
||
|
||
# Use correct league ID
|
||
config = get_league_config("sba") # ✅
|
||
config = get_league_config("xyz") # ❌ ValueError
|
||
```
|
||
|
||
### Problem: Cannot modify config
|
||
|
||
**Symptom**:
|
||
```
|
||
ValidationError: "SbaConfig" object is immutable
|
||
```
|
||
|
||
**Cause**: Configs are frozen Pydantic models
|
||
|
||
**Solution**: Don't modify configs. They are immutable by design.
|
||
|
||
```python
|
||
# ❌ WRONG - Trying to modify
|
||
config = get_league_config("sba")
|
||
config.innings = 7 # ValidationError
|
||
|
||
# ✅ CORRECT - Create new state with different value
|
||
state.innings = 7 # Modify game state, not config
|
||
```
|
||
|
||
### Problem: PlayOutcome validation error
|
||
|
||
**Symptom**:
|
||
```
|
||
ValueError: 'invalid_outcome' is not a valid PlayOutcome
|
||
```
|
||
|
||
**Cause**: String doesn't match any enum value
|
||
|
||
**Solution**:
|
||
```python
|
||
# ❌ WRONG - Invalid string
|
||
outcome = PlayOutcome("invalid_outcome") # ValueError
|
||
|
||
# ✅ CORRECT - Use enum member
|
||
outcome = PlayOutcome.SINGLE_1
|
||
|
||
# ✅ CORRECT - Parse from valid string
|
||
outcome = PlayOutcome("single_1")
|
||
|
||
# ✅ CORRECT - Check if valid
|
||
try:
|
||
outcome = PlayOutcome(user_input)
|
||
except ValueError:
|
||
# Handle invalid input
|
||
pass
|
||
```
|
||
|
||
### Problem: Result chart not found
|
||
|
||
**Symptom**:
|
||
```
|
||
KeyError: 'sba_standard_v1'
|
||
```
|
||
|
||
**Cause**: Result chart registry not implemented yet
|
||
|
||
**Solution**: Result charts are future implementation. Manual mode receives outcomes via WebSocket, not chart lookups.
|
||
|
||
```python
|
||
# ❌ WRONG - Trying to lookup chart directly
|
||
chart = RESULT_CHARTS[config.get_result_chart_name()]
|
||
|
||
# ✅ CORRECT - Manual outcomes come via WebSocket
|
||
@sio.event
|
||
async def submit_manual_outcome(sid: str, data: dict):
|
||
outcome = PlayOutcome(data['outcome'])
|
||
await process_outcome(outcome)
|
||
```
|
||
|
||
### Problem: Missing import
|
||
|
||
**Symptom**:
|
||
```
|
||
ImportError: cannot import name 'PlayOutcome' from 'app.config'
|
||
```
|
||
|
||
**Cause**: Not imported in `__init__.py`
|
||
|
||
**Solution**:
|
||
```python
|
||
# ✅ CORRECT - Import from package
|
||
from app.config import PlayOutcome, get_league_config, BaseGameConfig
|
||
|
||
# ❌ WRONG - Direct module import
|
||
from app.config.result_charts import PlayOutcome # Don't do this
|
||
```
|
||
|
||
## Examples
|
||
|
||
### Example 1: Basic Config Usage
|
||
|
||
```python
|
||
from app.config import get_league_config
|
||
|
||
# Get config for SBA league
|
||
sba_config = get_league_config("sba")
|
||
|
||
print(f"League: {sba_config.league_id}")
|
||
print(f"Innings: {sba_config.innings}")
|
||
print(f"API: {sba_config.get_api_base_url()}")
|
||
print(f"Chart: {sba_config.get_result_chart_name()}")
|
||
print(f"Manual mode: {sba_config.supports_manual_result_selection()}")
|
||
print(f"Auto mode: {sba_config.supports_auto_mode()}")
|
||
|
||
# Output:
|
||
# League: sba
|
||
# Innings: 9
|
||
# API: https://api.sba.manticorum.com
|
||
# Chart: sba_standard_v1
|
||
# Manual mode: True
|
||
# Auto mode: False
|
||
```
|
||
|
||
### Example 2: PlayOutcome Categorization
|
||
|
||
```python
|
||
from app.config import PlayOutcome
|
||
|
||
outcomes = [
|
||
PlayOutcome.SINGLE_1,
|
||
PlayOutcome.STRIKEOUT,
|
||
PlayOutcome.WALK,
|
||
PlayOutcome.SINGLE_UNCAPPED,
|
||
PlayOutcome.WILD_PITCH
|
||
]
|
||
|
||
for outcome in outcomes:
|
||
categories = []
|
||
if outcome.is_hit():
|
||
categories.append("HIT")
|
||
if outcome.is_out():
|
||
categories.append("OUT")
|
||
if outcome.is_walk():
|
||
categories.append("WALK")
|
||
if outcome.is_uncapped():
|
||
categories.append("UNCAPPED")
|
||
if outcome.is_interrupt():
|
||
categories.append("INTERRUPT")
|
||
|
||
print(f"{outcome.value}: {', '.join(categories) or 'OTHER'}")
|
||
|
||
# Output:
|
||
# single_1: HIT
|
||
# strikeout: OUT
|
||
# walk: WALK
|
||
# single_uncapped: HIT, UNCAPPED
|
||
# wild_pitch: INTERRUPT
|
||
```
|
||
|
||
### Example 3: Hit Location Calculation
|
||
|
||
```python
|
||
from app.config import calculate_hit_location, PlayOutcome
|
||
|
||
# Simulate 10 groundballs for right-handed batter
|
||
print("Right-handed batter groundballs:")
|
||
for _ in range(10):
|
||
location = calculate_hit_location(PlayOutcome.GROUNDBALL_A, 'R')
|
||
print(f" Hit to: {location}")
|
||
|
||
# Output (random, but follows pull rate):
|
||
# Right-handed batter groundballs:
|
||
# Hit to: 3B (pull side)
|
||
# Hit to: SS (pull side)
|
||
# Hit to: 2B (center)
|
||
# Hit to: P (center)
|
||
# Hit to: 3B (pull side)
|
||
# Hit to: 1B (opposite)
|
||
# Hit to: SS (pull side)
|
||
# Hit to: 2B (center)
|
||
# Hit to: 3B (pull side)
|
||
# Hit to: 2B (opposite)
|
||
```
|
||
|
||
### Example 4: League-Agnostic Game Logic
|
||
|
||
```python
|
||
from app.config import get_league_config, PlayOutcome
|
||
from app.models import GameState
|
||
|
||
async def handle_play_outcome(state: GameState, outcome: PlayOutcome):
|
||
"""Process play outcome in league-agnostic way."""
|
||
# Get league config
|
||
config = get_league_config(state.league_id)
|
||
|
||
# Different handling based on outcome type
|
||
if outcome.is_interrupt():
|
||
# Interrupt plays don't change batter
|
||
print(f"Interrupt play: {outcome.value}")
|
||
await log_interrupt_play(state, outcome)
|
||
|
||
elif outcome.is_uncapped() and state.on_base_code > 0:
|
||
# Uncapped hit with runners - need decision
|
||
print(f"Uncapped hit: {outcome.value} - requesting advancement decision")
|
||
if config.supports_auto_mode() and state.auto_mode_enabled:
|
||
# Auto-resolve advancement
|
||
await auto_resolve_advancement(state, outcome)
|
||
else:
|
||
# Request manual decision
|
||
await request_advancement_decision(state, outcome)
|
||
|
||
elif outcome.is_hit():
|
||
# Standard hit - advance batter
|
||
bases = outcome.get_bases_advanced()
|
||
print(f"Hit: {outcome.value} - batter to base {bases}")
|
||
await advance_batter(state, bases)
|
||
|
||
elif outcome.is_walk():
|
||
# Walk - advance batter to first
|
||
print(f"Walk: {outcome.value}")
|
||
await walk_batter(state)
|
||
|
||
elif outcome.is_out():
|
||
# Out - increment out counter
|
||
print(f"Out: {outcome.value}")
|
||
state.outs += 1
|
||
await check_inning_over(state)
|
||
```
|
||
|
||
### Example 5: Config-Driven Feature Flags
|
||
|
||
```python
|
||
from app.config import get_league_config
|
||
|
||
def should_calculate_wpa(league_id: str) -> bool:
|
||
"""Check if league tracks win probability added."""
|
||
config = get_league_config(league_id)
|
||
|
||
# PD-specific feature
|
||
if hasattr(config, 'wpa_calculation'):
|
||
return config.wpa_calculation
|
||
|
||
return False
|
||
|
||
def requires_cardset_validation(league_id: str) -> bool:
|
||
"""Check if league requires cardset validation."""
|
||
config = get_league_config(league_id)
|
||
|
||
# PD-specific feature
|
||
if hasattr(config, 'cardset_validation'):
|
||
return config.cardset_validation
|
||
|
||
return False
|
||
|
||
# Usage
|
||
if should_calculate_wpa(state.league_id):
|
||
wpa = calculate_win_probability_added(state, outcome)
|
||
play.wpa = wpa
|
||
|
||
if requires_cardset_validation(state.league_id):
|
||
validate_cardsets(game_id, card_id)
|
||
```
|
||
|
||
## Testing
|
||
|
||
### Unit Tests
|
||
|
||
**Location**: `tests/unit/config/`
|
||
|
||
**Test Coverage**:
|
||
- `test_league_configs.py` (28 tests): Config registry, implementations, immutability
|
||
- `test_play_outcome.py` (30 tests): Enum helpers, categorization, edge cases
|
||
|
||
**Run Tests**:
|
||
```bash
|
||
# All config tests
|
||
pytest tests/unit/config/ -v
|
||
|
||
# Specific file
|
||
pytest tests/unit/config/test_league_configs.py -v
|
||
|
||
# Specific test
|
||
pytest tests/unit/config/test_play_outcome.py::test_is_hit -v
|
||
```
|
||
|
||
### Test Examples
|
||
|
||
```python
|
||
# Test config retrieval
|
||
def test_get_sba_config():
|
||
config = get_league_config("sba")
|
||
assert config.league_id == "sba"
|
||
assert isinstance(config, SbaConfig)
|
||
|
||
# Test immutability
|
||
def test_config_immutable():
|
||
config = get_league_config("sba")
|
||
with pytest.raises(ValidationError):
|
||
config.innings = 7
|
||
|
||
# Test PlayOutcome helpers
|
||
def test_single_uncapped_is_hit():
|
||
outcome = PlayOutcome.SINGLE_UNCAPPED
|
||
assert outcome.is_hit()
|
||
assert outcome.is_uncapped()
|
||
assert not outcome.is_out()
|
||
assert outcome.get_bases_advanced() == 1
|
||
```
|
||
|
||
## Related Files
|
||
|
||
### Source Files
|
||
- `app/config/base_config.py` - Abstract base configuration
|
||
- `app/config/league_configs.py` - Concrete implementations
|
||
- `app/config/result_charts.py` - PlayOutcome enum
|
||
- `app/config/__init__.py` - Public API
|
||
|
||
### Test Files
|
||
- `tests/unit/config/test_league_configs.py` - Config system tests
|
||
- `tests/unit/config/test_play_outcome.py` - PlayOutcome tests
|
||
|
||
### Integration Points
|
||
- `app/core/game_engine.py` - Uses configs for league-specific rules
|
||
- `app/core/play_resolver.py` - Uses PlayOutcome for resolution logic
|
||
- `app/models/game_models.py` - GameState uses league_id
|
||
- `app/models/player_models.py` - Player models use handedness for hit location
|
||
- `app/websocket/handlers.py` - Validates league capabilities
|
||
|
||
## Key Takeaways
|
||
|
||
1. **Immutability**: Configs are frozen and cannot be modified after creation
|
||
2. **Registry**: Use `get_league_config()` to access pre-instantiated singletons
|
||
3. **Type Safety**: Always use `BaseGameConfig` for league-agnostic code
|
||
4. **Helper Methods**: Use PlayOutcome helpers instead of duplicate categorization logic
|
||
5. **No Static Charts**: Result charts come from card data (PD) or manual entry (SBA)
|
||
6. **League Agnostic**: Game engine adapts to leagues via config, not conditionals
|
||
|
||
## References
|
||
|
||
- Parent backend documentation: `../CLAUDE.md`
|
||
- Week 6 implementation: `../../../../.claude/implementation/02-week6-player-models.md`
|
||
- PlayResolver integration: `../core/play_resolver.py`
|
||
- Game engine usage: `../core/game_engine.py`
|