## Refactoring - Changed `ManualOutcomeSubmission.outcome` from `str` to `PlayOutcome` enum type - Removed custom validator (Pydantic handles enum validation automatically) - Added direct import of PlayOutcome (no circular dependency due to TYPE_CHECKING guard) - Updated tests to use enum values while maintaining backward compatibility Benefits: - Better type safety with IDE autocomplete - Cleaner code (removed 15 lines of validator boilerplate) - Backward compatible (Pydantic auto-converts strings to enum) - Access to helper methods (is_hit(), is_out(), etc.) Files modified: - app/models/game_models.py: Enum type + import - tests/unit/config/test_result_charts.py: Updated 7 tests + added compatibility test ## Documentation Created comprehensive CLAUDE.md files for all backend/app/ subdirectories to help future AI agents quickly understand and work with the code. Added 8,799 lines of documentation covering: - api/ (906 lines): FastAPI routes, health checks, auth patterns - config/ (906 lines): League configs, PlayOutcome enum, result charts - core/ (1,288 lines): GameEngine, StateManager, PlayResolver, dice system - data/ (937 lines): API clients (planned), caching layer - database/ (945 lines): Async sessions, operations, recovery - models/ (1,270 lines): Pydantic/SQLAlchemy models, polymorphic patterns - utils/ (959 lines): Logging, JWT auth, security - websocket/ (1,588 lines): Socket.io handlers, real-time events - tests/ (475 lines): Testing patterns and structure Each CLAUDE.md includes: - Purpose & architecture overview - Key components with detailed explanations - Patterns & conventions - Integration points - Common tasks (step-by-step guides) - Troubleshooting with solutions - Working code examples - Testing guidance Total changes: +9,294 lines / -24 lines Tests: All passing (62/62 model tests, 7/7 ManualOutcomeSubmission tests)
24 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.
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
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
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