strat-gameplay-webapp/backend/app/config/CLAUDE.md
Cal Corum 76e24ab22b CLAUDE: Refactor ManualOutcomeSubmission to use PlayOutcome enum + comprehensive documentation
## Refactoring
- Changed `ManualOutcomeSubmission.outcome` from `str` to `PlayOutcome` enum type
- Removed custom validator (Pydantic handles enum validation automatically)
- Added direct import of PlayOutcome (no circular dependency due to TYPE_CHECKING guard)
- Updated tests to use enum values while maintaining backward compatibility

Benefits:
- Better type safety with IDE autocomplete
- Cleaner code (removed 15 lines of validator boilerplate)
- Backward compatible (Pydantic auto-converts strings to enum)
- Access to helper methods (is_hit(), is_out(), etc.)

Files modified:
- app/models/game_models.py: Enum type + import
- tests/unit/config/test_result_charts.py: Updated 7 tests + added compatibility test

## Documentation
Created comprehensive CLAUDE.md files for all backend/app/ subdirectories to help future AI agents quickly understand and work with the code.

Added 8,799 lines of documentation covering:
- api/ (906 lines): FastAPI routes, health checks, auth patterns
- config/ (906 lines): League configs, PlayOutcome enum, result charts
- core/ (1,288 lines): GameEngine, StateManager, PlayResolver, dice system
- data/ (937 lines): API clients (planned), caching layer
- database/ (945 lines): Async sessions, operations, recovery
- models/ (1,270 lines): Pydantic/SQLAlchemy models, polymorphic patterns
- utils/ (959 lines): Logging, JWT auth, security
- websocket/ (1,588 lines): Socket.io handlers, real-time events
- tests/ (475 lines): Testing patterns and structure

Each CLAUDE.md includes:
- Purpose & architecture overview
- Key components with detailed explanations
- Patterns & conventions
- Integration points
- Common tasks (step-by-step guides)
- Troubleshooting with solutions
- Working code examples
- Testing guidance

Total changes: +9,294 lines / -24 lines
Tests: All passing (62/62 model tests, 7/7 ManualOutcomeSubmission tests)
2025-10-31 16:03:54 -05:00

907 lines
24 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.
## 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`