# Week 6: League Features & Integration **Duration**: Week 6 of Phase 2 **Prerequisites**: Week 5 Complete (Game engine working) **Focus**: League-specific features, configurations, and API integration **Status**: 75% Complete (2025-10-28) **Last Session**: Config system and PlayOutcome implemented **Next Session**: Dice system update and PlayResolver integration --- ## ⚠️ IMPORTANT: Player Models Documentation Player model implementation is now documented separately due to complexity: **→ See [02-week6-player-models-overview.md](./02-week6-player-models-overview.md) for complete player model specifications** This includes: - Two-layer architecture (API models + Game models) - Real API data structures for PD and SBA - Mapper layer for transformations - Complete implementation guide **This file focuses on configs and result charts only.** --- ## Overview Complete Phase 2 by implementing: 1. **Player Models** (see separate documentation above) 2. **League Configuration System** (this file) 3. **Result Chart System** (this file) 4. **PlayResolver Integration** (this file) ## Goals By end of Week 6: - ✅ Player models (polymorphic with factory methods) - **COMPLETE 2025-10-28** - ✅ League configuration framework (BaseConfig → SbaConfig/PdConfig) - **COMPLETE 2025-10-28** - ✅ PlayOutcome enum (universal for both leagues) - **COMPLETE 2025-10-28** - ⏳ Dice system update (check_d20 → chaos_d20) - **PENDING** - ⏳ PlayResolver updated to use PlayOutcome and configs - **PENDING** - ⏳ Play.metadata support for uncapped hits - **PENDING** - ⏳ Full test coverage for integration - **PENDING** ## Architecture ``` ┌──────────────────────────────────────────────────────────┐ │ League-Agnostic Core │ │ ┌─────────────┐ ┌──────────────┐ ┌────────────┐ │ │ │ GameEngine │ │ StateManager │ │ PlayResolver│ │ │ └─────────────┘ └──────────────┘ └────────────┘ │ │ ↓ │ │ LeagueConfig │ │ ↓ ↓ │ │ SbaConfig PdConfig │ │ ↓ ↓ │ │ SbaPlayer PdPlayer (see separate docs) │ └──────────────────────────────────────────────────────────┘ ``` ## Components to Build ### 1. Player Models **→ MOVED TO SEPARATE DOCUMENTATION** See [02-week6-player-models-overview.md](./02-week6-player-models-overview.md) and the `player-model-specs/` directory for: - API models (exact API responses) - Game models (gameplay-optimized) - Mappers and factories - API client - Complete testing strategy Abstract player model with league-specific implementations. ```python from abc import ABC, abstractmethod from typing import Optional, Dict, Any, List from pydantic import BaseModel, Field class BasePlayer(BaseModel, ABC): """ Abstract base player model All players have basic identification fields. League-specific data is added in subclasses. """ card_id: int name: str team_id: int @abstractmethod def get_image_url(self) -> str: """Get player image URL""" pass @abstractmethod def get_display_name(self) -> str: """Get display name for UI""" pass class Config: # Allow subclassing with Pydantic orm_mode = True class SbaPlayer(BasePlayer): """ SBA League player model Minimal data model - most stats on backend via API """ player_id: int # SBA player ID position: str image_url: Optional[str] = None def get_image_url(self) -> str: """Get image URL""" return self.image_url or f"https://sba-api.example.com/images/{self.player_id}.png" def get_display_name(self) -> str: """Display name""" return f"{self.name} ({self.position})" class Config: json_schema_extra = { "example": { "card_id": 123, "player_id": 456, "name": "John Smith", "team_id": 1, "position": "CF", "image_url": "https://example.com/image.png" } } class ScoutingGrades(BaseModel): """PD scouting grades for detailed probability calculations""" contact: int = Field(ge=1, le=10) # 1-10 scale gap_power: int = Field(ge=1, le=10) raw_power: int = Field(ge=1, le=10) eye: int = Field(ge=1, le=10) avoid_k: int = Field(ge=1, le=10) speed: int = Field(ge=1, le=10) stealing: Optional[int] = Field(default=None, ge=1, le=10) # Fielding (position-specific) fielding: Optional[int] = Field(default=None, ge=1, le=10) arm: Optional[int] = Field(default=None, ge=1, le=10) # Pitching (pitchers only) stuff: Optional[int] = Field(default=None, ge=1, le=10) movement: Optional[int] = Field(default=None, ge=1, le=10) control: Optional[int] = Field(default=None, ge=1, le=10) class PdPlayer(BasePlayer): """ Paper Dynasty player model Includes detailed scouting grades for probability calculations """ player_id: int # PD player ID position: str cardset_id: int image_url: Optional[str] = None # Scouting model scouting: ScoutingGrades # Metadata is_pitcher: bool = False handedness: str = "R" # R, L, S def get_image_url(self) -> str: """Get image URL""" return self.image_url or f"https://pd-api.example.com/images/{self.player_id}.png" def get_display_name(self) -> str: """Display name with handedness""" hand_symbol = {"R": "⟩", "L": "⟨", "S": "⟨⟩"}.get(self.handedness, "") return f"{self.name} {hand_symbol} ({self.position})" def calculate_contact_probability(self, pitcher_stuff: int) -> float: """ Calculate contact probability based on scouting grades This is where PD's detailed model comes into play. Real implementation will use complex formulas. """ # Simplified placeholder batter_skill = (self.scouting.contact + self.scouting.eye) / 2 pitcher_skill = pitcher_stuff base_prob = 0.5 + (batter_skill - pitcher_skill) * 0.05 return max(0.1, min(0.9, base_prob)) class Config: json_schema_extra = { "example": { "card_id": 789, "player_id": 101, "name": "Jane Doe", "team_id": 2, "position": "SS", "cardset_id": 5, "handedness": "R", "scouting": { "contact": 8, "gap_power": 6, "raw_power": 5, "eye": 7, "avoid_k": 8, "speed": 9, "stealing": 8, "fielding": 9, "arm": 8 } } } class LineupFactory: """Factory for creating league-specific lineup instances""" @staticmethod def create_player(league_id: str, data: Dict[str, Any]) -> BasePlayer: """ Create player instance based on league Args: league_id: 'sba' or 'pd' data: Player data from API or database Returns: SbaPlayer or PdPlayer instance """ if league_id == "sba": return SbaPlayer(**data) elif league_id == "pd": return PdPlayer(**data) else: raise ValueError(f"Unknown league: {league_id}") @staticmethod def create_lineup( league_id: str, team_data: List[Dict[str, Any]] ) -> List[BasePlayer]: """Create full lineup from team data""" return [ LineupFactory.create_player(league_id, player_data) for player_data in team_data ] ``` **Implementation Steps:** 1. Create `backend/app/models/player_models.py` 2. Implement BasePlayer abstract class 3. Implement SbaPlayer (simple) 4. Implement PdPlayer with scouting grades 5. Implement LineupFactory 6. Write unit tests **Tests:** - `tests/unit/models/test_player_models.py` - Test SbaPlayer instantiation - Test PdPlayer with scouting - Test factory pattern - Test type guards --- ### 2. League Configuration System (`backend/app/config/`) Configuration classes for league-specific game rules. ```python # backend/app/config/base_config.py from abc import ABC, abstractmethod from typing import Dict, Any from pydantic import BaseModel class BaseGameConfig(BaseModel, ABC): """Base configuration for all leagues""" league_id: str version: str = "1.0.0" # Basic rules innings: int = 9 outs_per_inning: int = 3 strikes_for_out: int = 3 balls_for_walk: int = 4 @abstractmethod def get_result_chart_name(self) -> str: """Get name of result chart to use""" pass @abstractmethod def supports_result_selection(self) -> bool: """Whether players manually select results""" pass @abstractmethod def get_api_base_url(self) -> str: """Get base URL for league API""" pass class Config: frozen = True # Immutable config ``` ```python # backend/app/config/league_configs.py from app.config.base_config import BaseGameConfig class SbaConfig(BaseGameConfig): """SBA League configuration""" league_id: str = "sba" # SBA-specific rules player_selection_mode: str = "manual" # Players select from chart after dice def get_result_chart_name(self) -> str: return "sba_standard_v1" def supports_result_selection(self) -> bool: return True # SBA players see dice, then pick result def get_api_base_url(self) -> str: return "https://sba-api.example.com" class PdConfig(BaseGameConfig): """Paper Dynasty League configuration""" league_id: str = "pd" # PD-specific rules player_selection_mode: str = "flexible" # Manual or auto via scouting model use_scouting_model: bool = True cardset_validation: bool = True # Validate cards against approved cardsets # Advanced features detailed_analytics: bool = True wpa_calculation: bool = True def get_result_chart_name(self) -> str: return "pd_standard_v1" def supports_result_selection(self) -> bool: return True # Can be manual or auto def get_api_base_url(self) -> str: return "https://pd-api.example.com" # Config registry LEAGUE_CONFIGS = { "sba": SbaConfig(), "pd": PdConfig() } def get_league_config(league_id: str) -> BaseGameConfig: """Get configuration for league""" config = LEAGUE_CONFIGS.get(league_id) if not config: raise ValueError(f"Unknown league: {league_id}") return config ``` **Implementation Steps:** 1. Create `backend/app/config/base_config.py` 2. Create `backend/app/config/league_configs.py` 3. Implement SbaConfig 4. Implement PdConfig 5. Create config registry **Tests:** - `tests/unit/config/test_league_configs.py` - Test config loading - Test config immutability - Test config registry --- ### 3. Result Charts (`backend/app/config/result_charts.py`) Result chart definitions for each league. ```python from typing import Dict, List from enum import Enum class ChartOutcome(str, Enum): """Standardized outcome types""" STRIKEOUT = "strikeout" GROUNDOUT = "groundout" FLYOUT = "flyout" LINEOUT = "lineout" SINGLE = "single" DOUBLE = "double" TRIPLE = "triple" HOMERUN = "homerun" WALK = "walk" HBP = "hbp" ERROR = "error" class ResultChart: """Base result chart""" def __init__(self, name: str, outcomes: Dict[int, List[ChartOutcome]]): self.name = name self.outcomes = outcomes def get_outcomes(self, roll: int) -> List[ChartOutcome]: """Get available outcomes for dice roll""" return self.outcomes.get(roll, []) # SBA Standard Chart (simplified placeholder) SBA_STANDARD_CHART = ResultChart( name="sba_standard_v1", outcomes={ 1: [ChartOutcome.STRIKEOUT], 2: [ChartOutcome.STRIKEOUT, ChartOutcome.GROUNDOUT], 3: [ChartOutcome.GROUNDOUT], 4: [ChartOutcome.GROUNDOUT, ChartOutcome.FLYOUT], 5: [ChartOutcome.FLYOUT], 6: [ChartOutcome.FLYOUT, ChartOutcome.LINEOUT], 7: [ChartOutcome.LINEOUT], 8: [ChartOutcome.GROUNDOUT], 9: [ChartOutcome.FLYOUT], 10: [ChartOutcome.SINGLE], 11: [ChartOutcome.SINGLE], 12: [ChartOutcome.SINGLE, ChartOutcome.ERROR], 13: [ChartOutcome.WALK], 14: [ChartOutcome.SINGLE], 15: [ChartOutcome.SINGLE, ChartOutcome.DOUBLE], 16: [ChartOutcome.DOUBLE], 17: [ChartOutcome.DOUBLE, ChartOutcome.TRIPLE], 18: [ChartOutcome.TRIPLE], 19: [ChartOutcome.TRIPLE, ChartOutcome.HOMERUN], 20: [ChartOutcome.HOMERUN] } ) # PD Standard Chart (simplified placeholder) PD_STANDARD_CHART = ResultChart( name="pd_standard_v1", outcomes={ # Similar structure but auto-selectable based on scouting model 1: [ChartOutcome.STRIKEOUT], 2: [ChartOutcome.GROUNDOUT], 3: [ChartOutcome.GROUNDOUT], 4: [ChartOutcome.FLYOUT], 5: [ChartOutcome.FLYOUT], 6: [ChartOutcome.LINEOUT], 7: [ChartOutcome.GROUNDOUT], 8: [ChartOutcome.FLYOUT], 9: [ChartOutcome.SINGLE], 10: [ChartOutcome.SINGLE], 11: [ChartOutcome.SINGLE], 12: [ChartOutcome.WALK], 13: [ChartOutcome.SINGLE], 14: [ChartOutcome.SINGLE], 15: [ChartOutcome.DOUBLE], 16: [ChartOutcome.DOUBLE], 17: [ChartOutcome.TRIPLE], 18: [ChartOutcome.TRIPLE], 19: [ChartOutcome.HOMERUN], 20: [ChartOutcome.HOMERUN] } ) # Chart registry RESULT_CHARTS = { "sba_standard_v1": SBA_STANDARD_CHART, "pd_standard_v1": PD_STANDARD_CHART } def get_result_chart(chart_name: str) -> ResultChart: """Get result chart by name""" chart = RESULT_CHARTS.get(chart_name) if not chart: raise ValueError(f"Unknown chart: {chart_name}") return chart ``` **Implementation Steps:** 1. Create `backend/app/config/result_charts.py` 2. Define ChartOutcome enum 3. Create SBA chart (placeholder) 4. Create PD chart (placeholder) 5. Write tests --- ### 4. League API Client (`backend/app/data/api_client.py`) HTTP client for fetching team and player data from league APIs. ```python import logging from typing import Dict, List, Optional import httpx from app.config.league_configs import get_league_config logger = logging.getLogger(f'{__name__}.LeagueApiClient') class LeagueApiClient: """Client for league REST APIs""" def __init__(self, league_id: str): self.league_id = league_id self.config = get_league_config(league_id) self.base_url = self.config.get_api_base_url() self.client = httpx.AsyncClient( base_url=self.base_url, timeout=10.0 ) async def get_team(self, team_id: int) -> Dict: """Fetch team data""" try: response = await self.client.get(f"/teams/{team_id}") response.raise_for_status() data = response.json() logger.info(f"Fetched team {team_id} from {self.league_id} API") return data except httpx.HTTPError as e: logger.error(f"Failed to fetch team {team_id}: {e}") raise async def get_roster(self, team_id: int) -> List[Dict]: """Fetch team roster""" try: response = await self.client.get(f"/teams/{team_id}/roster") response.raise_for_status() data = response.json() logger.info(f"Fetched roster for team {team_id}") return data except httpx.HTTPError as e: logger.error(f"Failed to fetch roster: {e}") raise async def get_player(self, player_id: int) -> Dict: """Fetch player/card data""" try: response = await self.client.get(f"/players/{player_id}") response.raise_for_status() data = response.json() logger.debug(f"Fetched player {player_id}") return data except httpx.HTTPError as e: logger.error(f"Failed to fetch player: {e}") raise async def close(self): """Close HTTP client""" await self.client.aclose() class LeagueApiClientFactory: """Factory for creating API clients""" @staticmethod def create(league_id: str) -> LeagueApiClient: """Create API client for league""" return LeagueApiClient(league_id) ``` **Implementation Steps:** 1. Create `backend/app/data/__init__.py` 2. Create `backend/app/data/api_client.py` 3. Implement LeagueApiClient with httpx 4. Add error handling and retries 5. Write integration tests with mocked API **Tests:** - `tests/integration/data/test_api_client.py` - Mock API responses - Test team fetching - Test roster fetching - Test error handling --- ### 5. Integration - Update Play Resolver Update play resolver to use league configs and result charts. ```python # backend/app/core/play_resolver.py (updates) from app.config.league_configs import get_league_config from app.config.result_charts import get_result_chart class PlayResolver: """Updated to use league configs""" def __init__(self, league_id: str): self.league_id = league_id self.config = get_league_config(league_id) self.result_chart = get_result_chart(self.config.get_result_chart_name()) self.dice = DiceSystem() def resolve_play( self, state: GameState, defensive_decision: DefensiveDecision, offensive_decision: OffensiveDecision, selected_outcome: Optional[str] = None # For manual selection ) -> PlayResult: """ Resolve play with league-specific logic Args: selected_outcome: For SBA/manual mode, player's outcome choice """ # Roll dice dice_roll = self.dice.roll_d20() # Get available outcomes available_outcomes = self.result_chart.get_outcomes(dice_roll.roll) # Determine final outcome if selected_outcome: # Manual selection (SBA) outcome = ChartOutcome(selected_outcome) if outcome not in available_outcomes: raise ValueError(f"Invalid outcome selection: {outcome}") else: # Auto-select (PD with scouting model or simplified) outcome = self._auto_select_outcome( available_outcomes, state, defensive_decision, offensive_decision ) # Rest of resolution logic... return self._resolve_outcome(outcome, state, dice_roll) def _auto_select_outcome( self, available: List[ChartOutcome], state: GameState, def_decision: DefensiveDecision, off_decision: OffensiveDecision ) -> ChartOutcome: """Auto-select outcome for PD or fallback""" # Simplified: just pick first available # TODO: Use scouting model for PD return available[0] ``` --- ## Week 6 Deliverables ### Code Files - ✅ `backend/app/models/player_models.py` - Polymorphic players - ✅ `backend/app/config/base_config.py` - Base config - ✅ `backend/app/config/league_configs.py` - SBA/PD configs - ✅ `backend/app/config/result_charts.py` - Result charts - ✅ `backend/app/data/api_client.py` - League API client - ✅ Updated `backend/app/core/play_resolver.py` - League-aware resolution ### Tests - ✅ `tests/unit/models/test_player_models.py` - ✅ `tests/unit/config/test_league_configs.py` - ✅ `tests/unit/config/test_result_charts.py` - ✅ `tests/integration/data/test_api_client.py` - ✅ `tests/integration/test_sba_game_flow.py` - Full SBA at-bat - ✅ `tests/integration/test_pd_game_flow.py` - Full PD at-bat ### Integration Test Scripts ```python # tests/integration/test_sba_game_flow.py import pytest from uuid import uuid4 from app.core.state_manager import state_manager from app.core.game_engine import GameEngine from app.models.game_models import DefensiveDecision, OffensiveDecision @pytest.mark.asyncio async def test_complete_sba_at_bat(): """Test complete SBA at-bat with manual result selection""" game_id = uuid4() # Create SBA game state = await state_manager.create_game( game_id=game_id, league_id="sba", home_team_id=1, away_team_id=2 ) engine = GameEngine(league_id="sba") # Start game await engine.start_game(game_id) # Submit decisions await engine.submit_defensive_decision( game_id, DefensiveDecision(alignment="normal") ) await engine.submit_offensive_decision( game_id, OffensiveDecision(approach="normal") ) # Resolve with manual selection result = await engine.resolve_play( game_id, selected_outcome="single" # Player chose single from available outcomes ) assert result.outcome == "single" assert result.dice_roll is not None # Verify persistence final_state = state_manager.get_state(game_id) assert final_state.play_count == 1 @pytest.mark.asyncio async def test_complete_pd_at_bat(): """Test complete PD at-bat with auto-resolution""" game_id = uuid4() # Create PD game state = await state_manager.create_game( game_id=game_id, league_id="pd", home_team_id=1, away_team_id=2 ) engine = GameEngine(league_id="pd") # Start game await engine.start_game(game_id) # Submit decisions await engine.submit_defensive_decision( game_id, DefensiveDecision(alignment="normal") ) await engine.submit_offensive_decision( game_id, OffensiveDecision(approach="normal") ) # Resolve with auto-selection (no manual choice) result = await engine.resolve_play(game_id) assert result.outcome is not None assert result.dice_roll is not None # Verify persistence final_state = state_manager.get_state(game_id) assert final_state.play_count == 1 ``` --- ## Phase 2 Final Deliverables ### Complete File Structure ``` backend/app/ ├── core/ │ ├── __init__.py │ ├── game_engine.py ✅ Week 5 │ ├── state_manager.py ✅ Week 4 │ ├── play_resolver.py ✅ Week 5, updated Week 6 │ ├── dice.py ✅ Week 5 │ └── validators.py ✅ Week 5 ├── config/ │ ├── __init__.py │ ├── base_config.py ✅ Week 6 │ ├── league_configs.py ✅ Week 6 │ └── result_charts.py ✅ Week 6 ├── models/ │ ├── game_models.py ✅ Week 4 │ ├── player_models.py ✅ Week 6 │ └── db_models.py ✅ Phase 1 ├── database/ │ ├── operations.py ✅ Week 4 │ └── session.py ✅ Phase 1 └── data/ ├── __init__.py └── api_client.py ✅ Week 6 ``` ### Success Criteria - Phase 2 Complete - [ ] Can create game for SBA league - [ ] Can create game for PD league - [ ] Complete at-bat works for SBA (with manual result selection) - [ ] Complete at-bat works for PD (with auto-resolution) - [ ] State persists to database - [ ] State recovers from database - [ ] All unit tests pass (90%+ coverage) - [ ] All integration tests pass - [ ] Dice distribution verified as uniform - [ ] Action response time < 500ms - [ ] Database writes complete in < 100ms --- ## Next Phase After completing Phase 2, move to **Phase 3: Gameplay Features** which will add: - Advanced strategic decisions (stolen bases, substitutions) - Complete result charts with all edge cases - PD scouting model probability calculations - Full WebSocket integration - UI testing Detailed plan: [03-gameplay-features.md](./03-gameplay-features.md) --- **Phase 2 Status**: Planning Complete (2025-10-22) **Ready to Begin**: Week 4 - State Management