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

27 KiB
Raw Blame History

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):

@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:

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.

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:

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:

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:

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:

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:

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.

# ✅ 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.

# ✅ 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.

# ✅ 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.

# ✅ 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.

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

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

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

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

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:
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"
  1. Register in LEAGUE_CONFIGS:
LEAGUE_CONFIGS: Dict[str, BaseGameConfig] = {
    "sba": SbaConfig(),
    "pd": PdConfig(),
    "new_league": NewLeagueConfig()  # Add here
}
  1. Write tests in tests/unit/config/test_league_configs.py:
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:
class PlayOutcome(str, Enum):
    # ... existing outcomes

    # New outcome
    BUNT_SINGLE = "bunt_single"  # New bunt result
  1. Update helper methods if needed:
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
    }
  1. Write tests in tests/unit/config/test_play_outcome.py:
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:

# 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

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:

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

# ❌ 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:

# ❌ 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.

# ❌ 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:

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

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

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

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

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

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:

# 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

# 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

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