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>
494 lines
14 KiB
Markdown
494 lines
14 KiB
Markdown
# 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
|