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>
27 KiB
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
- Immutability: Configs are frozen Pydantic models (cannot be modified after creation)
- Registry Pattern: Pre-instantiated singletons in
LEAGUE_CONFIGSdict - Type Safety: Full Pydantic validation with abstract base class enforcement
- League Agnostic: Game engine uses
BaseGameConfiginterface, 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 trackinginnings(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()→ Truesupports_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:
-
Outs (9 types):
STRIKEOUTGROUNDBALL_A/GROUNDBALL_B/GROUNDBALL_C(double play vs groundout)FLYOUT_A/FLYOUT_B/FLYOUT_C(different trajectories/depths)LINEOUTPOPOUT
-
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)TRIPLEHOMERUN
-
Walks/HBP (3 types):
WALKHIT_BY_PITCHINTENTIONAL_WALK
-
Errors (1 type):
ERROR
-
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)
-
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 interfaceManualResultChart: 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:
-
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
-
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)
-
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
- Returns positions holding runners (e.g.,
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
- 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"
- Register in LEAGUE_CONFIGS:
LEAGUE_CONFIGS: Dict[str, BaseGameConfig] = {
"sba": SbaConfig(),
"pd": PdConfig(),
"new_league": NewLeagueConfig() # Add here
}
- 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
- Add to enum in
result_charts.py:
class PlayOutcome(str, Enum):
# ... existing outcomes
# New outcome
BUNT_SINGLE = "bunt_single" # New bunt result
- 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
}
- 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, immutabilitytest_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
Related Files
Source Files
app/config/base_config.py- Abstract base configurationapp/config/league_configs.py- Concrete implementationsapp/config/result_charts.py- PlayOutcome enumapp/config/__init__.py- Public API
Test Files
tests/unit/config/test_league_configs.py- Config system teststests/unit/config/test_play_outcome.py- PlayOutcome tests
Integration Points
app/core/game_engine.py- Uses configs for league-specific rulesapp/core/play_resolver.py- Uses PlayOutcome for resolution logicapp/models/game_models.py- GameState uses league_idapp/models/player_models.py- Player models use handedness for hit locationapp/websocket/handlers.py- Validates league capabilities
Key Takeaways
- Immutability: Configs are frozen and cannot be modified after creation
- Registry: Use
get_league_config()to access pre-instantiated singletons - Type Safety: Always use
BaseGameConfigfor league-agnostic code - Helper Methods: Use PlayOutcome helpers instead of duplicate categorization logic
- No Static Charts: Result charts come from card data (PD) or manual entry (SBA)
- 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