strat-gameplay-webapp/backend/app/config/CLAUDE.md
Cal Corum 440adf2c26 CLAUDE: Update REPL for new GameState and standardize UV commands
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>
2025-11-04 09:59:13 -06:00

987 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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