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