strat-gameplay-webapp/.claude/implementation/player-model-specs/game-models.md
Cal Corum f9aa653c37 CLAUDE: Reorganize Week 6 documentation and separate player model specifications
Split player model architecture into dedicated documentation files for clarity
and maintainability. Added Phase 1 status tracking and comprehensive player
model specs covering API models, game models, mappers, and testing strategy.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-25 23:48:57 -05:00

14 KiB

Game Models Specification

Purpose: Game-optimized player models used by GameEngine and PlayResolver

File: backend/app/models/player_models.py


Overview

Game models contain only the fields needed for gameplay:

  • Player identification (id, name)
  • Positions (normalized list)
  • Image URL (for UI)
  • PD only: Outcome probabilities (batting/pitching ratings)

Everything else from the API (team hierarchy, metadata, etc.) is discarded.


Design Principles

  1. Minimal Data: Only fields used during gameplay
  2. Fast Serialization: Small models for WebSocket broadcasts
  3. No Nested Objects: Flatten complex structures (team → team_name)
  4. League Polymorphism: Common base class with league-specific subclasses

Base Player Model

BasePlayer (Abstract)

Purpose: Common interface for all players

from abc import ABC, abstractmethod
from typing import List
from pydantic import BaseModel


class BasePlayer(BaseModel, ABC):
    """
    Abstract base player model for gameplay

    All players have basic identification regardless of league.
    League-specific data in subclasses.
    """

    # Core identification
    player_id: int  # Standardized field name (maps from 'id' or 'player_id')
    name: str

    # Gameplay essentials
    positions: List[str]  # Extracted from pos_1-8, e.g., ["RF", "CF"]
    image_url: str  # Card image

    @abstractmethod
    def get_primary_position(self) -> str:
        """Get primary position (first in list)"""
        pass

    @abstractmethod
    def can_play_position(self, position: str) -> bool:
        """Check if player can play position"""
        pass

    model_config = {
        "frozen": False  # Allow mutation during game
    }

SBA Game Model

SbaPlayer

Purpose: Simplified SBA player for gameplay

What's Included:

  • Basic player info
  • Positions
  • Team name (flattened from nested team object)
  • Image URL

What's Excluded:

  • Full team object (manager, division, stadium, etc.)
  • Injury data (not needed during gameplay)
  • Season/WARA stats
  • Baseball Reference IDs
class SbaPlayer(BasePlayer):
    """
    SBA player model for gameplay

    Simplified from SbaPlayerApi - only gameplay essentials.
    """

    # Additional SBA-specific fields
    team_name: str  # Flattened from team.lname
    team_abbrev: str  # Flattened from team.abbrev
    strat_code: Optional[str] = None  # For reference

    def get_primary_position(self) -> str:
        """Get primary position"""
        return self.positions[0] if self.positions else "UTIL"

    def can_play_position(self, position: str) -> bool:
        """Check if player can play position"""
        return position in self.positions

    def get_display_name(self) -> str:
        """Get display name for UI"""
        primary_pos = self.get_primary_position()
        return f"{self.name} ({primary_pos})"

    model_config = {
        "json_schema_extra": {
            "example": {
                "player_id": 12288,
                "name": "Ronald Acuna Jr",
                "positions": ["RF"],
                "image_url": "https://sba-cards-2024.s3.us-east-1.amazonaws.com/2024-cards/ronald-acuna-jr.png",
                "team_name": "West Virginia Black Bears",
                "team_abbrev": "WV",
                "strat_code": "Acuna,R"
            }
        }
    }

PD Game Models

PdBattingRating

Purpose: Batting outcome probabilities for one handedness (vs L or vs R)

What's Included:

  • Outcome probabilities (homerun, strikeout, walk, etc.)
  • Summary stats (avg, obp, slg) for quick reference

What's Excluded:

  • Full battingcard object (card metadata not needed)
  • ID fields
  • Player reference (already have player)
class PdBattingRating(BaseModel):
    """
    Batting outcome probabilities for one handedness

    Extracted from PdBattingRatingApi - just the probabilities needed
    for play resolution.
    """

    vs_hand: str  # "L" or "R" (vs LHP or RHP)

    # Hit outcomes (probabilities)
    homerun: float
    triple: float
    double: float  # Sum of double_three, double_two, double_pull
    single: float  # Sum of single_two, single_one, single_center

    # Plate discipline
    walk: float
    hbp: float
    strikeout: float

    # Out types
    flyout: float  # Sum of all flyout types
    lineout: float
    popout: float
    groundout: float  # Sum of groundout_a, groundout_b, groundout_c

    # Ballpark outcomes
    bp_homerun: float
    bp_single: float

    # Summary stats (for quick checks)
    avg: float
    obp: float
    slg: float

    def total_probability(self) -> float:
        """Calculate total probability (should be ~100.0)"""
        return (
            self.homerun + self.triple + self.double + self.single +
            self.walk + self.hbp + self.strikeout +
            self.flyout + self.lineout + self.popout + self.groundout +
            self.bp_homerun + self.bp_single
        )

PdPitchingRating

Purpose: Pitching outcome probabilities for one handedness (vs L or vs R)

class PdPitchingRating(BaseModel):
    """
    Pitching outcome probabilities for one handedness

    Extracted from PdPitchingRatingApi - just the probabilities needed
    for play resolution.
    """

    vs_hand: str  # "L" or "R" (vs LHB or RHB)

    # Hit outcomes
    homerun: float
    triple: float
    double: float  # Sum of double_three, double_two, double_cf
    single: float  # Sum of single_two, single_one, single_center

    # Plate discipline
    walk: float
    hbp: float
    strikeout: float

    # Out types
    flyout: float  # Sum of flyout types
    groundout: float  # Sum of groundout_a, groundout_b

    # Ballpark outcomes
    bp_homerun: float
    bp_single: float

    # X-check probabilities (fielding checks)
    xcheck: dict[str, float]  # Position → probability

    # Summary stats
    avg: float
    obp: float
    slg: float

    def total_probability(self) -> float:
        """Calculate total probability (should be ~100.0)"""
        return (
            self.homerun + self.triple + self.double + self.single +
            self.walk + self.hbp + self.strikeout +
            self.flyout + self.groundout +
            self.bp_homerun + self.bp_single
        )

PdPlayer

Purpose: PD player with attached ratings for gameplay

What's Included:

  • Basic player info
  • Batting ratings (vs L and vs R)
  • Pitching ratings (vs L and vs R) if pitcher
  • Cardset name (for eligibility checks)

What's Excluded:

  • Full cardset object (just need name/id)
  • Rarity (not used in gameplay)
  • MLB player data (not used in gameplay)
  • Paperdex (ownership tracking, not gameplay)
class PdPlayer(BasePlayer):
    """
    PD player model for gameplay

    Includes outcome probability ratings from batting/pitching cards.
    Much more complex than SBA due to detailed simulation model.
    """

    # Basic info
    team_name: str  # From mlbclub
    cardset_id: int
    cardset_name: str  # From cardset.name

    # Player type
    is_pitcher: bool  # Determines which ratings to use
    hand: str  # R, L, S (batter handedness or pitcher throws)

    # Batting ratings (always present, even for pitchers)
    batting_vs_lhp: Optional[PdBattingRating] = None
    batting_vs_rhp: Optional[PdBattingRating] = None

    # Pitching ratings (only for pitchers)
    pitching_vs_lhb: Optional[PdPitchingRating] = None
    pitching_vs_rhb: Optional[PdPitchingRating] = None

    # Baserunning (from batting card)
    steal_rating: Optional[str] = None  # e.g., "8-11" (steal_low-steal_high)
    running_speed: Optional[int] = None  # 1-20 scale

    # Pitcher specific
    wild_pitch_range: Optional[int] = None  # d20 range (e.g., 20)

    def get_primary_position(self) -> str:
        """Get primary position"""
        return self.positions[0] if self.positions else "UTIL"

    def can_play_position(self, position: str) -> bool:
        """Check if player can play position"""
        return position in self.positions

    def get_batting_rating(self, vs_hand: str) -> Optional[PdBattingRating]:
        """Get batting rating vs pitcher hand"""
        if vs_hand == "L":
            return self.batting_vs_lhp
        elif vs_hand == "R":
            return self.batting_vs_rhp
        return None

    def get_pitching_rating(self, vs_hand: str) -> Optional[PdPitchingRating]:
        """Get pitching rating vs batter hand"""
        if not self.is_pitcher:
            return None
        if vs_hand == "L":
            return self.pitching_vs_lhb
        elif vs_hand == "R":
            return self.pitching_vs_rhb
        return None

    def get_display_name(self) -> str:
        """Get display name with handedness indicator"""
        hand_symbol = {
            "R": "⟩",  # Right-handed arrow
            "L": "⟨",  # Left-handed arrow
            "S": "⟨⟩"  # Switch hitter
        }.get(self.hand, "")
        primary_pos = self.get_primary_position()
        return f"{self.name} {hand_symbol} ({primary_pos})"

    model_config = {
        "json_schema_extra": {
            "example": {
                "player_id": 10633,
                "name": "Chuck Knoblauch",
                "positions": ["2B"],
                "image_url": "https://pd.manticorum.com/api/v2/players/10633/battingcard?d=2025-4-14",
                "team_name": "New York Yankees",
                "cardset_id": 20,
                "cardset_name": "1998 Season",
                "is_pitcher": False,
                "hand": "R",
                "batting_vs_lhp": {
                    "vs_hand": "L",
                    "homerun": 0.0,
                    "triple": 1.4,
                    "double": 10.2,
                    "single": 9.3,
                    "walk": 18.25,
                    "hbp": 2.0,
                    "strikeout": 9.75,
                    "flyout": 3.55,
                    "lineout": 9.0,
                    "popout": 16.0,
                    "groundout": 19.5,
                    "bp_homerun": 2.0,
                    "bp_single": 5.0,
                    "avg": 0.226,
                    "obp": 0.414,
                    "slg": 0.375
                },
                "batting_vs_rhp": {
                    "vs_hand": "R",
                    "homerun": 1.05,
                    "triple": 1.2,
                    "double": 7.0,
                    "single": 13.1,
                    "walk": 12.1,
                    "hbp": 3.0,
                    "strikeout": 9.9,
                    "flyout": 3.15,
                    "lineout": 11.0,
                    "popout": 13.0,
                    "groundout": 23.6,
                    "bp_homerun": 3.0,
                    "bp_single": 5.0,
                    "avg": 0.244,
                    "obp": 0.384,
                    "slg": 0.402
                },
                "steal_rating": "8-11",
                "running_speed": 13
            }
        }
    }

Field Selection Rationale

Why These Fields?

Included in Game Models:

Field Why Needed
player_id Primary key for references
name Display to users
positions Position eligibility validation
image_url Display card image
team_name Display team affiliation
PD: ratings Critical - drive play resolution
PD: hand Determines which rating to use
PD: is_pitcher Determines card type

Excluded from Game Models:

Field Why Not Needed
Full team object Only need team name string
Manager data Not used in gameplay
Division data Not used in gameplay
Rarity Doesn't affect gameplay
Paperdex Collection tracking, not gameplay
MLB IDs External references, not gameplay
Injury data Roster management, not gameplay

Size Comparison

SBA Player

  • API Model: ~50 fields (with nested team/manager/division)
  • Game Model: ~7 fields
  • Size Reduction: ~85%

PD Player

  • API Models Combined: ~150 fields (player + 2 batting ratings + 2 pitching ratings)
  • Game Model: ~15 fields (with flattened ratings)
  • Size Reduction: ~90%

Usage in GameEngine

from app.models.player_models import SbaPlayer, PdPlayer

# In PlayResolver
def resolve_play(
    self,
    batter: BasePlayer,
    pitcher: BasePlayer,
    ...
) -> PlayResult:

    # Type-safe access to league-specific features
    if isinstance(batter, PdPlayer) and isinstance(pitcher, PdPlayer):
        # Use detailed probability model
        batting_rating = batter.get_batting_rating(pitcher.hand)
        pitching_rating = pitcher.get_pitching_rating(batter.hand)

        # Resolve using outcome probabilities
        outcome = self._resolve_from_probabilities(batting_rating, pitching_rating)

    elif isinstance(batter, SbaPlayer):
        # Use simplified result chart
        outcome = self._resolve_from_chart(dice_roll)

    return outcome

Validation

All game models include validation:

# In tests
def test_pd_batting_rating_probabilities_sum_to_100():
    """Ensure all probabilities sum to ~100"""
    rating = PdBattingRating(...)
    total = rating.total_probability()
    assert 99.0 <= total <= 101.0  # Allow small float rounding

def test_position_list_not_empty():
    """Players must have at least one position"""
    with pytest.raises(ValidationError):
        SbaPlayer(
            player_id=1,
            name="Test",
            positions=[],  # Invalid!
            ...
        )

Next: See mappers-and-factories.md for API → Game transformations