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

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