## 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)
907 lines
24 KiB
Markdown
907 lines
24 KiB
Markdown
# Configuration System - League Rules & Play Outcomes
|
|
|
|
## Purpose
|
|
|
|
The configuration system provides immutable, league-specific game rules and play outcome definitions for the Paper Dynasty game engine. It serves as the single source of truth for:
|
|
|
|
- League-specific game rules (innings, outs, feature flags)
|
|
- API endpoint configuration for external data sources
|
|
- Universal play outcome definitions (hits, outs, walks, etc.)
|
|
- Card-based resolution mechanics for both manual and auto modes
|
|
- Hit location calculation for runner advancement logic
|
|
|
|
This system enables a **league-agnostic game engine** that adapts to SBA and PD league differences through configuration rather than conditional logic.
|
|
|
|
## Architecture Overview
|
|
|
|
```
|
|
app/config/
|
|
├── __init__.py # Public API exports
|
|
├── base_config.py # Abstract base configuration
|
|
├── league_configs.py # Concrete SBA/PD implementations
|
|
└── result_charts.py # PlayOutcome enum + result chart abstractions
|
|
```
|
|
|
|
### Design Principles
|
|
|
|
1. **Immutability**: Configs are frozen Pydantic models (cannot be modified after creation)
|
|
2. **Registry Pattern**: Pre-instantiated singletons in `LEAGUE_CONFIGS` dict
|
|
3. **Type Safety**: Full Pydantic validation with abstract base class enforcement
|
|
4. **League Agnostic**: Game engine uses `BaseGameConfig` interface, never concrete types
|
|
|
|
## Key Components
|
|
|
|
### 1. BaseGameConfig (Abstract Base Class)
|
|
|
|
**Location**: `base_config.py:13-77`
|
|
|
|
Defines the interface all league configs must implement.
|
|
|
|
**Common Fields**:
|
|
- `league_id` (str): League identifier ('sba' or 'pd')
|
|
- `version` (str): Config version for compatibility tracking
|
|
- `innings` (int): Standard innings per game (default 9)
|
|
- `outs_per_inning` (int): Outs required per half-inning (default 3)
|
|
|
|
**Abstract Methods** (must be implemented by subclasses):
|
|
```python
|
|
@abstractmethod
|
|
def get_result_chart_name(self) -> str:
|
|
"""Get name of result chart to use for this league."""
|
|
|
|
@abstractmethod
|
|
def supports_manual_result_selection(self) -> bool:
|
|
"""Whether players manually select results after dice roll."""
|
|
|
|
@abstractmethod
|
|
def supports_auto_mode(self) -> bool:
|
|
"""Whether this league supports auto-resolution of outcomes."""
|
|
|
|
@abstractmethod
|
|
def get_api_base_url(self) -> str:
|
|
"""Get base URL for league's external API."""
|
|
```
|
|
|
|
**Configuration**:
|
|
```python
|
|
class Config:
|
|
frozen = True # Immutable - prevents accidental modification
|
|
```
|
|
|
|
### 2. League-Specific Configs
|
|
|
|
#### SbaConfig
|
|
|
|
**Location**: `league_configs.py:17-46`
|
|
|
|
Configuration for SBA League with manual result selection.
|
|
|
|
**Features**:
|
|
- Manual result selection only (physical cards, not digitized)
|
|
- Simple player data model
|
|
- Standard baseball rules
|
|
|
|
**Unique Fields**:
|
|
- `player_selection_mode`: "manual" (always manual selection)
|
|
|
|
**Methods**:
|
|
- `get_result_chart_name()` → "sba_standard_v1"
|
|
- `supports_manual_result_selection()` → True
|
|
- `supports_auto_mode()` → False (cards not digitized)
|
|
- `get_api_base_url()` → "https://api.sba.manticorum.com"
|
|
|
|
#### PdConfig
|
|
|
|
**Location**: `league_configs.py:49-86`
|
|
|
|
Configuration for Paper Dynasty League with flexible resolution modes.
|
|
|
|
**Features**:
|
|
- Flexible result selection (manual OR auto via scouting)
|
|
- Complex scouting data model (PdBattingRating/PdPitchingRating)
|
|
- Cardset validation
|
|
- Advanced analytics (WPA, RE24)
|
|
|
|
**Unique Fields**:
|
|
- `player_selection_mode`: "flexible" (manual or auto)
|
|
- `use_scouting_model`: True (use detailed ratings for auto)
|
|
- `cardset_validation`: True (validate cards against approved sets)
|
|
- `detailed_analytics`: True (track advanced stats)
|
|
- `wpa_calculation`: True (calculate win probability added)
|
|
|
|
**Methods**:
|
|
- `get_result_chart_name()` → "pd_standard_v1"
|
|
- `supports_manual_result_selection()` → True (though auto is also available)
|
|
- `supports_auto_mode()` → True (via digitized scouting data)
|
|
- `get_api_base_url()` → "https://pd.manticorum.com"
|
|
|
|
### 3. Config Registry
|
|
|
|
**Location**: `league_configs.py:88-115`
|
|
|
|
Pre-instantiated singletons for O(1) lookup.
|
|
|
|
```python
|
|
LEAGUE_CONFIGS: Dict[str, BaseGameConfig] = {
|
|
"sba": SbaConfig(),
|
|
"pd": PdConfig()
|
|
}
|
|
|
|
def get_league_config(league_id: str) -> BaseGameConfig:
|
|
"""Get configuration for specified league."""
|
|
config = LEAGUE_CONFIGS.get(league_id)
|
|
if not config:
|
|
raise ValueError(f"Unknown league: {league_id}")
|
|
return config
|
|
```
|
|
|
|
### 4. PlayOutcome Enum
|
|
|
|
**Location**: `result_charts.py:38-197`
|
|
|
|
Universal enum defining all possible play outcomes for both leagues.
|
|
|
|
**Outcome Categories**:
|
|
|
|
1. **Outs** (9 types):
|
|
- `STRIKEOUT`
|
|
- `GROUNDBALL_A` / `GROUNDBALL_B` / `GROUNDBALL_C` (double play vs groundout)
|
|
- `FLYOUT_A` / `FLYOUT_B` / `FLYOUT_C` (different trajectories/depths)
|
|
- `LINEOUT`
|
|
- `POPOUT`
|
|
|
|
2. **Hits** (8 types):
|
|
- `SINGLE_1` / `SINGLE_2` / `SINGLE_UNCAPPED` (standard vs enhanced vs decision tree)
|
|
- `DOUBLE_2` / `DOUBLE_3` / `DOUBLE_UNCAPPED` (2nd base vs 3rd base vs decision tree)
|
|
- `TRIPLE`
|
|
- `HOMERUN`
|
|
|
|
3. **Walks/HBP** (3 types):
|
|
- `WALK`
|
|
- `HIT_BY_PITCH`
|
|
- `INTENTIONAL_WALK`
|
|
|
|
4. **Errors** (1 type):
|
|
- `ERROR`
|
|
|
|
5. **Interrupt Plays** (6 types) - logged with `pa=0`:
|
|
- `WILD_PITCH` (Play.wp = 1)
|
|
- `PASSED_BALL` (Play.pb = 1)
|
|
- `STOLEN_BASE` (Play.sb = 1)
|
|
- `CAUGHT_STEALING` (Play.cs = 1)
|
|
- `BALK` (Play.balk = 1)
|
|
- `PICK_OFF` (Play.pick_off = 1)
|
|
|
|
6. **Ballpark Power** (4 types) - PD league specific:
|
|
- `BP_HOMERUN` (Play.bphr = 1)
|
|
- `BP_SINGLE` (Play.bp1b = 1)
|
|
- `BP_FLYOUT` (Play.bpfo = 1)
|
|
- `BP_LINEOUT` (Play.bplo = 1)
|
|
|
|
**Helper Methods**:
|
|
```python
|
|
outcome = PlayOutcome.SINGLE_UNCAPPED
|
|
|
|
# Categorization helpers
|
|
outcome.is_hit() # True
|
|
outcome.is_out() # False
|
|
outcome.is_walk() # False
|
|
outcome.is_uncapped() # True - requires advancement decision
|
|
outcome.is_interrupt() # False
|
|
outcome.is_extra_base_hit() # False
|
|
|
|
# Advancement logic
|
|
outcome.get_bases_advanced() # 1
|
|
outcome.requires_hit_location() # False (only groundballs/flyouts)
|
|
```
|
|
|
|
### 5. Hit Location Calculation
|
|
|
|
**Location**: `result_charts.py:206-279`
|
|
|
|
Calculates fielder positions for groundballs and flyouts based on batter handedness.
|
|
|
|
**Function**:
|
|
```python
|
|
def calculate_hit_location(
|
|
outcome: PlayOutcome,
|
|
batter_handedness: str
|
|
) -> Optional[str]:
|
|
"""
|
|
Calculate hit location based on outcome and batter handedness.
|
|
|
|
Pull Rate Distribution:
|
|
- 45% pull side (RHB left, LHB right)
|
|
- 35% center
|
|
- 20% opposite field
|
|
|
|
Groundball Locations: P, C, 1B, 2B, SS, 3B (infield)
|
|
Fly Ball Locations: LF, CF, RF (outfield)
|
|
"""
|
|
```
|
|
|
|
**Usage**:
|
|
```python
|
|
from app.config import calculate_hit_location, PlayOutcome
|
|
|
|
# Calculate location for groundball
|
|
location = calculate_hit_location(PlayOutcome.GROUNDBALL_A, 'R') # '3B', 'SS', etc.
|
|
|
|
# Only works for groundballs/flyouts
|
|
location = calculate_hit_location(PlayOutcome.HOMERUN, 'R') # None
|
|
```
|
|
|
|
### 6. ResultChart Abstraction (Future)
|
|
|
|
**Location**: `result_charts.py:285-588`
|
|
|
|
Abstract base class for result chart implementations. Currently defines interface for future auto-mode implementation.
|
|
|
|
**Classes**:
|
|
- `ResultChart` (ABC): Abstract interface
|
|
- `ManualResultChart`: Placeholder (not used - manual outcomes come via WebSocket)
|
|
- `PdAutoResultChart`: Auto-resolution for PD league using digitized card data
|
|
|
|
**Note**: Manual mode doesn't use result charts - outcomes come directly from WebSocket handlers.
|
|
|
|
## Patterns & Conventions
|
|
|
|
### 1. Immutable Configuration
|
|
|
|
All configs are frozen after instantiation to prevent accidental modification.
|
|
|
|
```python
|
|
# ✅ CORRECT - Read-only access
|
|
config = get_league_config("sba")
|
|
api_url = config.get_api_base_url()
|
|
chart_name = config.get_result_chart_name()
|
|
|
|
# ❌ WRONG - Raises ValidationError
|
|
config.innings = 7 # ValidationError: "Game" object is immutable
|
|
```
|
|
|
|
### 2. Registry Pattern
|
|
|
|
Configs are pre-instantiated singletons in the registry, not created per-request.
|
|
|
|
```python
|
|
# ✅ CORRECT - Use registry
|
|
from app.config import get_league_config
|
|
config = get_league_config(league_id)
|
|
|
|
# ❌ WRONG - Don't instantiate directly
|
|
from app.config import SbaConfig
|
|
config = SbaConfig() # Creates unnecessary instance
|
|
```
|
|
|
|
### 3. League-Agnostic Code
|
|
|
|
Game engine uses `BaseGameConfig` interface, never concrete types.
|
|
|
|
```python
|
|
# ✅ CORRECT - Works for any league
|
|
def resolve_play(state: GameState, config: BaseGameConfig):
|
|
if config.supports_auto_mode():
|
|
# Auto-resolve
|
|
pass
|
|
else:
|
|
# Wait for manual input
|
|
pass
|
|
|
|
# ❌ WRONG - Hard-coded league logic
|
|
def resolve_play(state: GameState):
|
|
if state.league_id == "sba":
|
|
# SBA-specific logic
|
|
pass
|
|
elif state.league_id == "pd":
|
|
# PD-specific logic
|
|
pass
|
|
```
|
|
|
|
### 4. Enum Helper Methods
|
|
|
|
Use PlayOutcome helper methods instead of duplicate logic.
|
|
|
|
```python
|
|
# ✅ CORRECT - Use helper methods
|
|
if outcome.is_hit():
|
|
record_hit()
|
|
elif outcome.is_walk():
|
|
record_walk()
|
|
elif outcome.is_interrupt():
|
|
log_interrupt_play()
|
|
|
|
# ❌ WRONG - Duplicate categorization logic
|
|
if outcome in {PlayOutcome.SINGLE_1, PlayOutcome.SINGLE_2, PlayOutcome.HOMERUN, ...}:
|
|
record_hit()
|
|
```
|
|
|
|
### 5. Type Safety
|
|
|
|
Always use type hints with `BaseGameConfig` for league-agnostic code.
|
|
|
|
```python
|
|
# ✅ CORRECT - Type-safe
|
|
from app.config import BaseGameConfig
|
|
|
|
def process_game(config: BaseGameConfig) -> None:
|
|
# Works for SBA or PD
|
|
pass
|
|
|
|
# ❌ WRONG - No type safety
|
|
def process_game(config) -> None:
|
|
# Could be anything
|
|
pass
|
|
```
|
|
|
|
## Integration Points
|
|
|
|
### With Game Engine
|
|
|
|
```python
|
|
from app.config import get_league_config, PlayOutcome
|
|
from app.models import GameState
|
|
|
|
async def resolve_play(state: GameState, outcome: PlayOutcome):
|
|
# Get league-specific config
|
|
config = get_league_config(state.league_id)
|
|
|
|
# Handle based on outcome type
|
|
if outcome.is_uncapped() and state.on_base_code > 0:
|
|
# Uncapped hit with runners - need advancement decision
|
|
await request_advancement_decision(state)
|
|
elif outcome.is_interrupt():
|
|
# Interrupt play - logged with pa=0
|
|
await log_interrupt_play(state, outcome)
|
|
elif outcome.is_hit():
|
|
# Standard hit - advance runners
|
|
bases = outcome.get_bases_advanced()
|
|
await advance_batter(state, bases)
|
|
elif outcome.is_out():
|
|
# Record out
|
|
state.outs += 1
|
|
```
|
|
|
|
### With Database Models
|
|
|
|
```python
|
|
from app.config import PlayOutcome
|
|
from app.models import Play
|
|
|
|
async def save_play(outcome: PlayOutcome, state: GameState):
|
|
play = Play(
|
|
game_id=state.game_id,
|
|
outcome=outcome.value, # Store enum value as string
|
|
pa=0 if outcome.is_interrupt() else 1,
|
|
ab=1 if not outcome.is_walk() and not outcome.is_interrupt() else 0,
|
|
hit=1 if outcome.is_hit() else 0,
|
|
# ... other fields
|
|
)
|
|
await db_ops.save_play(play)
|
|
```
|
|
|
|
### With WebSocket Handlers
|
|
|
|
```python
|
|
from app.config import get_league_config, PlayOutcome
|
|
|
|
@sio.event
|
|
async def submit_manual_outcome(sid: str, data: dict):
|
|
"""Handle manual outcome submission from player."""
|
|
# Validate league supports manual mode
|
|
config = get_league_config(data['league_id'])
|
|
if not config.supports_manual_result_selection():
|
|
raise ValueError("Manual selection not supported for this league")
|
|
|
|
# Parse outcome
|
|
outcome = PlayOutcome(data['outcome'])
|
|
|
|
# Process play
|
|
await process_play_outcome(data['game_id'], outcome)
|
|
```
|
|
|
|
### With Player Models
|
|
|
|
```python
|
|
from app.config import calculate_hit_location, PlayOutcome
|
|
from app.models import PdPlayer
|
|
|
|
def resolve_groundball(batter: PdPlayer, outcome: PlayOutcome):
|
|
# Get batter handedness
|
|
handedness = batter.batting_card.hand if batter.batting_card else 'R'
|
|
|
|
# Calculate hit location
|
|
location = calculate_hit_location(outcome, handedness)
|
|
|
|
# Use location for advancement logic
|
|
if location in ['1B', '2B']:
|
|
# Right side groundball - slower to turn double play
|
|
pass
|
|
elif location in ['SS', '3B']:
|
|
# Left side groundball - easier double play
|
|
pass
|
|
```
|
|
|
|
## Common Tasks
|
|
|
|
### Adding a New League Config
|
|
|
|
1. **Create config class** in `league_configs.py`:
|
|
|
|
```python
|
|
class NewLeagueConfig(BaseGameConfig):
|
|
"""Configuration for New League."""
|
|
|
|
league_id: str = "new_league"
|
|
|
|
# New league-specific features
|
|
custom_feature: bool = True
|
|
|
|
def get_result_chart_name(self) -> str:
|
|
return "new_league_standard_v1"
|
|
|
|
def supports_manual_result_selection(self) -> bool:
|
|
return True
|
|
|
|
def supports_auto_mode(self) -> bool:
|
|
return False
|
|
|
|
def get_api_base_url(self) -> str:
|
|
return "https://api.newleague.com"
|
|
```
|
|
|
|
2. **Register in LEAGUE_CONFIGS**:
|
|
|
|
```python
|
|
LEAGUE_CONFIGS: Dict[str, BaseGameConfig] = {
|
|
"sba": SbaConfig(),
|
|
"pd": PdConfig(),
|
|
"new_league": NewLeagueConfig() # Add here
|
|
}
|
|
```
|
|
|
|
3. **Write tests** in `tests/unit/config/test_league_configs.py`:
|
|
|
|
```python
|
|
def test_new_league_config():
|
|
config = get_league_config("new_league")
|
|
assert config.league_id == "new_league"
|
|
assert config.get_result_chart_name() == "new_league_standard_v1"
|
|
assert config.supports_manual_result_selection() is True
|
|
assert config.supports_auto_mode() is False
|
|
```
|
|
|
|
### Adding a New PlayOutcome
|
|
|
|
1. **Add to enum** in `result_charts.py`:
|
|
|
|
```python
|
|
class PlayOutcome(str, Enum):
|
|
# ... existing outcomes
|
|
|
|
# New outcome
|
|
BUNT_SINGLE = "bunt_single" # New bunt result
|
|
```
|
|
|
|
2. **Update helper methods** if needed:
|
|
|
|
```python
|
|
def is_hit(self) -> bool:
|
|
return self in {
|
|
self.SINGLE_1, self.SINGLE_2, self.SINGLE_UNCAPPED,
|
|
# ... existing hits
|
|
self.BUNT_SINGLE # Add to hit category
|
|
}
|
|
```
|
|
|
|
3. **Write tests** in `tests/unit/config/test_play_outcome.py`:
|
|
|
|
```python
|
|
def test_bunt_single_categorization():
|
|
outcome = PlayOutcome.BUNT_SINGLE
|
|
assert outcome.is_hit()
|
|
assert not outcome.is_out()
|
|
assert outcome.get_bases_advanced() == 1
|
|
```
|
|
|
|
### Modifying Existing Config
|
|
|
|
**DON'T**: Configs are immutable by design.
|
|
|
|
**DO**: Create new version if rules change:
|
|
|
|
```python
|
|
# Old version (keep for compatibility)
|
|
class SbaConfigV1(BaseGameConfig):
|
|
league_id: str = "sba"
|
|
version: str = "1.0.0"
|
|
innings: int = 9
|
|
|
|
# New version (different rules)
|
|
class SbaConfigV2(BaseGameConfig):
|
|
league_id: str = "sba"
|
|
version: str = "2.0.0"
|
|
innings: int = 7 # New: 7-inning games
|
|
|
|
# Registry supports versioning
|
|
LEAGUE_CONFIGS = {
|
|
"sba:v1": SbaConfigV1(),
|
|
"sba:v2": SbaConfigV2(),
|
|
"sba": SbaConfigV2() # Default to latest
|
|
}
|
|
```
|
|
|
|
### Checking League Capabilities
|
|
|
|
```python
|
|
from app.config import get_league_config
|
|
|
|
def can_use_auto_mode(league_id: str) -> bool:
|
|
"""Check if league supports auto-resolution."""
|
|
config = get_league_config(league_id)
|
|
return config.supports_auto_mode()
|
|
|
|
def requires_cardset_validation(league_id: str) -> bool:
|
|
"""Check if league requires cardset validation."""
|
|
config = get_league_config(league_id)
|
|
# PD-specific check
|
|
return hasattr(config, 'cardset_validation') and config.cardset_validation
|
|
```
|
|
|
|
## Troubleshooting
|
|
|
|
### Problem: "Unknown league" error
|
|
|
|
**Symptom**:
|
|
```
|
|
ValueError: Unknown league: xyz. Valid leagues: ['sba', 'pd']
|
|
```
|
|
|
|
**Cause**: League ID not in registry
|
|
|
|
**Solution**:
|
|
```python
|
|
# Check valid leagues
|
|
from app.config import LEAGUE_CONFIGS
|
|
print(LEAGUE_CONFIGS.keys()) # ['sba', 'pd']
|
|
|
|
# Use correct league ID
|
|
config = get_league_config("sba") # ✅
|
|
config = get_league_config("xyz") # ❌ ValueError
|
|
```
|
|
|
|
### Problem: Cannot modify config
|
|
|
|
**Symptom**:
|
|
```
|
|
ValidationError: "SbaConfig" object is immutable
|
|
```
|
|
|
|
**Cause**: Configs are frozen Pydantic models
|
|
|
|
**Solution**: Don't modify configs. They are immutable by design.
|
|
|
|
```python
|
|
# ❌ WRONG - Trying to modify
|
|
config = get_league_config("sba")
|
|
config.innings = 7 # ValidationError
|
|
|
|
# ✅ CORRECT - Create new state with different value
|
|
state.innings = 7 # Modify game state, not config
|
|
```
|
|
|
|
### Problem: PlayOutcome validation error
|
|
|
|
**Symptom**:
|
|
```
|
|
ValueError: 'invalid_outcome' is not a valid PlayOutcome
|
|
```
|
|
|
|
**Cause**: String doesn't match any enum value
|
|
|
|
**Solution**:
|
|
```python
|
|
# ❌ WRONG - Invalid string
|
|
outcome = PlayOutcome("invalid_outcome") # ValueError
|
|
|
|
# ✅ CORRECT - Use enum member
|
|
outcome = PlayOutcome.SINGLE_1
|
|
|
|
# ✅ CORRECT - Parse from valid string
|
|
outcome = PlayOutcome("single_1")
|
|
|
|
# ✅ CORRECT - Check if valid
|
|
try:
|
|
outcome = PlayOutcome(user_input)
|
|
except ValueError:
|
|
# Handle invalid input
|
|
pass
|
|
```
|
|
|
|
### Problem: Result chart not found
|
|
|
|
**Symptom**:
|
|
```
|
|
KeyError: 'sba_standard_v1'
|
|
```
|
|
|
|
**Cause**: Result chart registry not implemented yet
|
|
|
|
**Solution**: Result charts are future implementation. Manual mode receives outcomes via WebSocket, not chart lookups.
|
|
|
|
```python
|
|
# ❌ WRONG - Trying to lookup chart directly
|
|
chart = RESULT_CHARTS[config.get_result_chart_name()]
|
|
|
|
# ✅ CORRECT - Manual outcomes come via WebSocket
|
|
@sio.event
|
|
async def submit_manual_outcome(sid: str, data: dict):
|
|
outcome = PlayOutcome(data['outcome'])
|
|
await process_outcome(outcome)
|
|
```
|
|
|
|
### Problem: Missing import
|
|
|
|
**Symptom**:
|
|
```
|
|
ImportError: cannot import name 'PlayOutcome' from 'app.config'
|
|
```
|
|
|
|
**Cause**: Not imported in `__init__.py`
|
|
|
|
**Solution**:
|
|
```python
|
|
# ✅ CORRECT - Import from package
|
|
from app.config import PlayOutcome, get_league_config, BaseGameConfig
|
|
|
|
# ❌ WRONG - Direct module import
|
|
from app.config.result_charts import PlayOutcome # Don't do this
|
|
```
|
|
|
|
## Examples
|
|
|
|
### Example 1: Basic Config Usage
|
|
|
|
```python
|
|
from app.config import get_league_config
|
|
|
|
# Get config for SBA league
|
|
sba_config = get_league_config("sba")
|
|
|
|
print(f"League: {sba_config.league_id}")
|
|
print(f"Innings: {sba_config.innings}")
|
|
print(f"API: {sba_config.get_api_base_url()}")
|
|
print(f"Chart: {sba_config.get_result_chart_name()}")
|
|
print(f"Manual mode: {sba_config.supports_manual_result_selection()}")
|
|
print(f"Auto mode: {sba_config.supports_auto_mode()}")
|
|
|
|
# Output:
|
|
# League: sba
|
|
# Innings: 9
|
|
# API: https://api.sba.manticorum.com
|
|
# Chart: sba_standard_v1
|
|
# Manual mode: True
|
|
# Auto mode: False
|
|
```
|
|
|
|
### Example 2: PlayOutcome Categorization
|
|
|
|
```python
|
|
from app.config import PlayOutcome
|
|
|
|
outcomes = [
|
|
PlayOutcome.SINGLE_1,
|
|
PlayOutcome.STRIKEOUT,
|
|
PlayOutcome.WALK,
|
|
PlayOutcome.SINGLE_UNCAPPED,
|
|
PlayOutcome.WILD_PITCH
|
|
]
|
|
|
|
for outcome in outcomes:
|
|
categories = []
|
|
if outcome.is_hit():
|
|
categories.append("HIT")
|
|
if outcome.is_out():
|
|
categories.append("OUT")
|
|
if outcome.is_walk():
|
|
categories.append("WALK")
|
|
if outcome.is_uncapped():
|
|
categories.append("UNCAPPED")
|
|
if outcome.is_interrupt():
|
|
categories.append("INTERRUPT")
|
|
|
|
print(f"{outcome.value}: {', '.join(categories) or 'OTHER'}")
|
|
|
|
# Output:
|
|
# single_1: HIT
|
|
# strikeout: OUT
|
|
# walk: WALK
|
|
# single_uncapped: HIT, UNCAPPED
|
|
# wild_pitch: INTERRUPT
|
|
```
|
|
|
|
### Example 3: Hit Location Calculation
|
|
|
|
```python
|
|
from app.config import calculate_hit_location, PlayOutcome
|
|
|
|
# Simulate 10 groundballs for right-handed batter
|
|
print("Right-handed batter groundballs:")
|
|
for _ in range(10):
|
|
location = calculate_hit_location(PlayOutcome.GROUNDBALL_A, 'R')
|
|
print(f" Hit to: {location}")
|
|
|
|
# Output (random, but follows pull rate):
|
|
# Right-handed batter groundballs:
|
|
# Hit to: 3B (pull side)
|
|
# Hit to: SS (pull side)
|
|
# Hit to: 2B (center)
|
|
# Hit to: P (center)
|
|
# Hit to: 3B (pull side)
|
|
# Hit to: 1B (opposite)
|
|
# Hit to: SS (pull side)
|
|
# Hit to: 2B (center)
|
|
# Hit to: 3B (pull side)
|
|
# Hit to: 2B (opposite)
|
|
```
|
|
|
|
### Example 4: League-Agnostic Game Logic
|
|
|
|
```python
|
|
from app.config import get_league_config, PlayOutcome
|
|
from app.models import GameState
|
|
|
|
async def handle_play_outcome(state: GameState, outcome: PlayOutcome):
|
|
"""Process play outcome in league-agnostic way."""
|
|
# Get league config
|
|
config = get_league_config(state.league_id)
|
|
|
|
# Different handling based on outcome type
|
|
if outcome.is_interrupt():
|
|
# Interrupt plays don't change batter
|
|
print(f"Interrupt play: {outcome.value}")
|
|
await log_interrupt_play(state, outcome)
|
|
|
|
elif outcome.is_uncapped() and state.on_base_code > 0:
|
|
# Uncapped hit with runners - need decision
|
|
print(f"Uncapped hit: {outcome.value} - requesting advancement decision")
|
|
if config.supports_auto_mode() and state.auto_mode_enabled:
|
|
# Auto-resolve advancement
|
|
await auto_resolve_advancement(state, outcome)
|
|
else:
|
|
# Request manual decision
|
|
await request_advancement_decision(state, outcome)
|
|
|
|
elif outcome.is_hit():
|
|
# Standard hit - advance batter
|
|
bases = outcome.get_bases_advanced()
|
|
print(f"Hit: {outcome.value} - batter to base {bases}")
|
|
await advance_batter(state, bases)
|
|
|
|
elif outcome.is_walk():
|
|
# Walk - advance batter to first
|
|
print(f"Walk: {outcome.value}")
|
|
await walk_batter(state)
|
|
|
|
elif outcome.is_out():
|
|
# Out - increment out counter
|
|
print(f"Out: {outcome.value}")
|
|
state.outs += 1
|
|
await check_inning_over(state)
|
|
```
|
|
|
|
### Example 5: Config-Driven Feature Flags
|
|
|
|
```python
|
|
from app.config import get_league_config
|
|
|
|
def should_calculate_wpa(league_id: str) -> bool:
|
|
"""Check if league tracks win probability added."""
|
|
config = get_league_config(league_id)
|
|
|
|
# PD-specific feature
|
|
if hasattr(config, 'wpa_calculation'):
|
|
return config.wpa_calculation
|
|
|
|
return False
|
|
|
|
def requires_cardset_validation(league_id: str) -> bool:
|
|
"""Check if league requires cardset validation."""
|
|
config = get_league_config(league_id)
|
|
|
|
# PD-specific feature
|
|
if hasattr(config, 'cardset_validation'):
|
|
return config.cardset_validation
|
|
|
|
return False
|
|
|
|
# Usage
|
|
if should_calculate_wpa(state.league_id):
|
|
wpa = calculate_win_probability_added(state, outcome)
|
|
play.wpa = wpa
|
|
|
|
if requires_cardset_validation(state.league_id):
|
|
validate_cardsets(game_id, card_id)
|
|
```
|
|
|
|
## Testing
|
|
|
|
### Unit Tests
|
|
|
|
**Location**: `tests/unit/config/`
|
|
|
|
**Test Coverage**:
|
|
- `test_league_configs.py` (28 tests): Config registry, implementations, immutability
|
|
- `test_play_outcome.py` (30 tests): Enum helpers, categorization, edge cases
|
|
|
|
**Run Tests**:
|
|
```bash
|
|
# All config tests
|
|
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
|
|
|
|
```python
|
|
# Test config retrieval
|
|
def test_get_sba_config():
|
|
config = get_league_config("sba")
|
|
assert config.league_id == "sba"
|
|
assert isinstance(config, SbaConfig)
|
|
|
|
# Test immutability
|
|
def test_config_immutable():
|
|
config = get_league_config("sba")
|
|
with pytest.raises(ValidationError):
|
|
config.innings = 7
|
|
|
|
# Test PlayOutcome helpers
|
|
def test_single_uncapped_is_hit():
|
|
outcome = PlayOutcome.SINGLE_UNCAPPED
|
|
assert outcome.is_hit()
|
|
assert outcome.is_uncapped()
|
|
assert not outcome.is_out()
|
|
assert outcome.get_bases_advanced() == 1
|
|
```
|
|
|
|
## Related Files
|
|
|
|
### Source Files
|
|
- `app/config/base_config.py` - Abstract base configuration
|
|
- `app/config/league_configs.py` - Concrete implementations
|
|
- `app/config/result_charts.py` - PlayOutcome enum
|
|
- `app/config/__init__.py` - Public API
|
|
|
|
### Test Files
|
|
- `tests/unit/config/test_league_configs.py` - Config system tests
|
|
- `tests/unit/config/test_play_outcome.py` - PlayOutcome tests
|
|
|
|
### Integration Points
|
|
- `app/core/game_engine.py` - Uses configs for league-specific rules
|
|
- `app/core/play_resolver.py` - Uses PlayOutcome for resolution logic
|
|
- `app/models/game_models.py` - GameState uses league_id
|
|
- `app/models/player_models.py` - Player models use handedness for hit location
|
|
- `app/websocket/handlers.py` - Validates league capabilities
|
|
|
|
## Key Takeaways
|
|
|
|
1. **Immutability**: Configs are frozen and cannot be modified after creation
|
|
2. **Registry**: Use `get_league_config()` to access pre-instantiated singletons
|
|
3. **Type Safety**: Always use `BaseGameConfig` for league-agnostic code
|
|
4. **Helper Methods**: Use PlayOutcome helpers instead of duplicate categorization logic
|
|
5. **No Static Charts**: Result charts come from card data (PD) or manual entry (SBA)
|
|
6. **League Agnostic**: Game engine adapts to leagues via config, not conditionals
|
|
|
|
## References
|
|
|
|
- Parent backend documentation: `../CLAUDE.md`
|
|
- Week 6 implementation: `../../../../.claude/implementation/02-week6-player-models.md`
|
|
- PlayResolver integration: `../core/play_resolver.py`
|
|
- Game engine usage: `../core/game_engine.py`
|