# 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 ```python 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 ```python 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) ```python 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) ```python 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) ```python 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 ```python 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: ```python # 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](./mappers-and-factories.md) for API → Game transformations