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>
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
- Minimal Data: Only fields used during gameplay
- Fast Serialization: Small models for WebSocket broadcasts
- No Nested Objects: Flatten complex structures (team → team_name)
- 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