# 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