strat-gameplay-webapp/backend/app/config/CLAUDE.md
Cal Corum 0b6076d5b8 CLAUDE: Implement Phase 3B - X-Check league config tables
Complete X-Check resolution table system for defensive play outcomes.

Components:
- Defense range tables (20×5) for infield, outfield, catcher
- Error charts for LF/RF and CF (ratings 0-25)
- Placeholder error charts for P, C, 1B, 2B, 3B, SS (awaiting data)
- get_fielders_holding_runners() - Complete implementation
- get_error_chart_for_position() - Maps all 9 positions
- 6 X-Check placeholder advancement functions (g1-g3, f1-f3)

League Config Integration:
- Both SbaConfig and PdConfig include X-Check tables
- Shared common tables via league_configs.py
- Attributes: x_check_defense_tables, x_check_error_charts, x_check_holding_runners

Testing:
- 36 tests for X-Check tables (all passing)
- 9 tests for X-Check placeholders (all passing)
- Total: 45/45 tests passing

Documentation:
- Updated backend/CLAUDE.md with Phase 3B section
- Updated app/config/CLAUDE.md with X-Check tables documentation
- Updated app/core/CLAUDE.md with X-Check placeholder functions
- Updated tests/CLAUDE.md with new test counts (519 unit tests)
- Updated phase-3b-league-config-tables.md (marked complete)
- Updated NEXT_SESSION.md with Phase 3B completion

What's Pending:
- 6 infield error charts need actual data (P, C, 1B, 2B, 3B, SS)
- Phase 3C will implement full X-Check resolution logic

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-01 19:50:55 -05: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
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

# 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