# 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`