Updated terminal client REPL to work with refactored GameState structure where current_batter/pitcher/catcher are now LineupPlayerState objects instead of integer IDs. Also standardized all documentation to properly show 'uv run' prefixes for Python commands. REPL Updates: - terminal_client/display.py: Access lineup_id from LineupPlayerState objects - terminal_client/repl.py: Fix typos (self.current_game → self.current_game_id) - tests/unit/terminal_client/test_commands.py: Create proper LineupPlayerState objects in test fixtures (2 tests fixed, all 105 terminal client tests passing) Documentation Updates (100+ command examples): - CLAUDE.md: Updated pytest examples to use 'uv run' prefix - terminal_client/CLAUDE.md: Updated ~40 command examples - tests/CLAUDE.md: Updated all test commands (unit, integration, debugging) - app/*/CLAUDE.md: Updated test and server startup commands (5 files) All Python commands now consistently use 'uv run' prefix to align with project's UV migration, improving developer experience and preventing confusion about virtual environment activation. 🤖 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
|
||
uv run pytest tests/unit/config/ -v
|
||
|
||
# Specific file
|
||
uv run pytest tests/unit/config/test_league_configs.py -v
|
||
|
||
# Specific test
|
||
uv run 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`
|