strat-gameplay-webapp/.claude/archive/02-week6-league-features.md
Cal Corum c4e051c4a9 Documentation Archival
Moving unneeded notes to archive directory.
2025-11-01 01:17:15 -05:00

24 KiB

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 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 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.

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.

# 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
# 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.

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.

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.

# 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

# 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


Phase 2 Status: Planning Complete (2025-10-22) Ready to Begin: Week 4 - State Management