strat-gameplay-webapp/backend/app/models/CLAUDE.md
Cal Corum 440adf2c26 CLAUDE: Update REPL for new GameState and standardize UV commands
Updated terminal client REPL to work with refactored GameState structure
where current_batter/pitcher/catcher are now LineupPlayerState objects
instead of integer IDs. Also standardized all documentation to properly
show 'uv run' prefixes for Python commands.

REPL Updates:
- terminal_client/display.py: Access lineup_id from LineupPlayerState objects
- terminal_client/repl.py: Fix typos (self.current_game → self.current_game_id)
- tests/unit/terminal_client/test_commands.py: Create proper LineupPlayerState
  objects in test fixtures (2 tests fixed, all 105 terminal client tests passing)

Documentation Updates (100+ command examples):
- CLAUDE.md: Updated pytest examples to use 'uv run' prefix
- terminal_client/CLAUDE.md: Updated ~40 command examples
- tests/CLAUDE.md: Updated all test commands (unit, integration, debugging)
- app/*/CLAUDE.md: Updated test and server startup commands (5 files)

All Python commands now consistently use 'uv run' prefix to align with
project's UV migration, improving developer experience and preventing
confusion about virtual environment activation.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 09:59:13 -06:00

1271 lines
36 KiB
Markdown

# Models Directory - Data Models for Game Engine
## Purpose
This directory contains all data models for the game engine, split into two complementary systems:
- **Pydantic Models**: Type-safe, validated models for in-memory game state and API contracts
- **SQLAlchemy Models**: ORM models for PostgreSQL database persistence
The separation optimizes for different use cases:
- Pydantic: Fast in-memory operations, WebSocket serialization, validation
- SQLAlchemy: Database persistence, complex relationships, audit trail
## Directory Structure
```
models/
├── __init__.py # Exports all models for easy importing
├── game_models.py # Pydantic in-memory game state models
├── player_models.py # Polymorphic player models (SBA/PD)
├── db_models.py # SQLAlchemy database models
└── roster_models.py # Pydantic roster link models
```
## File Overview
### `__init__.py`
Central export point for all models. Use this for imports throughout the codebase.
**Exports**:
- Database models: `Game`, `Play`, `Lineup`, `GameSession`, `RosterLink`, `GameCardsetLink`
- Game state models: `GameState`, `LineupPlayerState`, `TeamLineupState`, `DefensiveDecision`, `OffensiveDecision`
- Roster models: `BaseRosterLinkData`, `PdRosterLinkData`, `SbaRosterLinkData`, `RosterLinkCreate`
- Player models: `BasePlayer`, `SbaPlayer`, `PdPlayer`, `PdBattingCard`, `PdPitchingCard`
**Usage**:
```python
# ✅ Import from models package
from app.models import GameState, Game, SbaPlayer
# ❌ Don't import from individual files
from app.models.game_models import GameState # Less convenient
```
---
### `game_models.py` - In-Memory Game State
**Purpose**: Pydantic models representing active game state cached in memory for fast gameplay.
**Key Models**:
#### `GameState` - Core Game State
Complete in-memory representation of an active game. The heart of the game engine.
**Critical Fields**:
- **Identity**: `game_id` (UUID), `league_id` (str: 'sba' or 'pd')
- **Teams**: `home_team_id`, `away_team_id`, `home_team_is_ai`, `away_team_is_ai`
- **Resolution**: `auto_mode` (True = PD auto-resolve, False = manual submissions)
- **Game State**: `status`, `inning`, `half`, `outs`, `home_score`, `away_score`
- **Runners**: `on_first`, `on_second`, `on_third` (direct LineupPlayerState references)
- **Batting Order**: `away_team_batter_idx` (0-8), `home_team_batter_idx` (0-8)
- **Play Snapshot**: `current_batter_lineup_id`, `current_pitcher_lineup_id`, `current_catcher_lineup_id`, `current_on_base_code`
- **Decision Tracking**: `pending_decision`, `decisions_this_play`, `pending_defensive_decision`, `pending_offensive_decision`, `decision_phase`
- **Manual Mode**: `pending_manual_roll` (AbRoll stored when dice rolled in manual mode)
- **Play History**: `play_count`, `last_play_result`
**Helper Methods** (20+ utility methods):
- Team queries: `get_batting_team_id()`, `get_fielding_team_id()`, `is_batting_team_ai()`, `is_fielding_team_ai()`
- Runner queries: `is_runner_on_first()`, `get_runner_at_base()`, `bases_occupied()`, `get_all_runners()`
- Runner management: `add_runner()`, `advance_runner()`, `clear_bases()`
- Game flow: `increment_outs()`, `end_half_inning()`, `is_game_over()`
**Usage Example**:
```python
from app.models import GameState, LineupPlayerState
# Create game state
state = GameState(
game_id=uuid4(),
league_id="sba",
home_team_id=1,
away_team_id=2,
current_batter_lineup_id=10
)
# Add runner
runner = LineupPlayerState(lineup_id=5, card_id=123, position="CF", batting_order=2)
state.add_runner(runner, base=1)
# Advance runner and score
state.advance_runner(from_base=1, to_base=4) # Auto-increments score
# Check game over
if state.is_game_over():
state.status = "completed"
```
#### `LineupPlayerState` - Player in Lineup
Lightweight reference to a player in the game lineup. Full player data is cached separately.
**Fields**:
- `lineup_id` (int): Database ID of lineup entry
- `card_id` (int): PD card ID or SBA player ID
- `position` (str): P, C, 1B, 2B, 3B, SS, LF, CF, RF, DH
- `batting_order` (Optional[int]): 1-9 if in batting order
- `is_active` (bool): Currently in game vs substituted
**Validators**:
- Position must be valid baseball position
- Batting order must be 1-9 if provided
#### `TeamLineupState` - Team's Active Lineup
Container for a team's players with helper methods for common queries.
**Fields**:
- `team_id` (int): Team identifier
- `players` (List[LineupPlayerState]): All players on this team
**Helper Methods**:
- `get_batting_order()`: Returns players sorted by batting_order (1-9)
- `get_pitcher()`: Returns active pitcher or None
- `get_player_by_lineup_id()`: Lookup by lineup ID
- `get_batter()`: Get batter by batting order index (0-8)
**Usage Example**:
```python
lineup = TeamLineupState(team_id=1, players=[...])
# Get batting order
order = lineup.get_batting_order() # [player1, player2, ..., player9]
# Get current pitcher
pitcher = lineup.get_pitcher()
# Get 3rd batter in order
third_batter = lineup.get_batter(2) # 0-indexed
```
#### `DefensiveDecision` - Defensive Strategy
Strategic decisions made by the fielding team.
**Fields**:
- `alignment` (str): normal, shifted_left, shifted_right, extreme_shift
- `infield_depth` (str): infield_in, normal, corners_in
- `outfield_depth` (str): in, normal
- `hold_runners` (List[int]): Bases to hold runners on (e.g., [1, 3])
**Impact**: Affects double play chances, hit probabilities, runner advancement.
#### `OffensiveDecision` - Offensive Strategy
Strategic decisions made by the batting team.
**Fields**:
- `approach` (str): normal, contact, power, patient
- `steal_attempts` (List[int]): Bases to steal (2, 3, or 4 for home)
- `hit_and_run` (bool): Attempt hit-and-run
- `bunt_attempt` (bool): Attempt bunt
**Impact**: Affects outcome probabilities, baserunner actions.
#### `ManualOutcomeSubmission` - Manual Play Outcome
Model for human players submitting outcomes after reading physical cards.
**Fields**:
- `outcome` (PlayOutcome): The outcome from the card
- `hit_location` (Optional[str]): Position where ball was hit (for groundballs/flyballs)
**Validators**:
- `hit_location` must be valid position if provided
- Validation that location is required for certain outcomes happens in handler
**Usage**:
```python
# Player reads card, submits outcome
submission = ManualOutcomeSubmission(
outcome=PlayOutcome.GROUNDBALL_C,
hit_location='SS'
)
```
**Design Patterns**:
- All models use Pydantic v2 with `field_validator` decorators
- Extensive validation ensures data integrity
- Helper methods reduce duplicate logic in game engine
- Immutable by default (use `.model_copy()` to modify)
---
### `player_models.py` - Polymorphic Player System
**Purpose**: League-agnostic player models supporting both SBA (simple) and PD (complex) leagues.
**Architecture**:
```
BasePlayer (Abstract)
├── SbaPlayer (Simple)
└── PdPlayer (Complex with scouting)
```
**Key Models**:
#### `BasePlayer` - Abstract Base Class
Abstract interface ensuring consistent player API across leagues.
**Required Abstract Methods**:
- `get_positions() -> List[str]`: All positions player can play
- `get_display_name() -> str`: Formatted name for UI
**Common Fields**:
- **Identity**: `id` (Player ID for SBA, Card ID for PD), `name`
- **Images**: `image` (primary card), `image2` (alt card), `headshot` (league default), `vanity_card` (custom upload)
- **Positions**: `pos_1` through `pos_8` (up to 8 positions)
**Image Priority**:
```python
def get_player_image_url(self) -> str:
return self.vanity_card or self.headshot or ""
```
#### `SbaPlayer` - Simple Player Model
Minimal data needed for SBA league gameplay.
**SBA-Specific Fields**:
- `wara` (float): Wins Above Replacement Average
- `team_id`, `team_name`, `season`: Current team info
- `strat_code`, `bbref_id`, `injury_rating`: Reference IDs
**Factory Method**:
```python
# Create from API response
player = SbaPlayer.from_api_response(api_data)
# Use abstract interface
positions = player.get_positions() # ['RF', 'CF', 'LF']
name = player.get_display_name() # 'Ronald Acuna Jr'
```
**Image Methods**:
```python
# Get appropriate card for role
pitching_card = player.get_pitching_card_url() # Uses image2 if two-way player
batting_card = player.get_batting_card_url() # Uses image for position players
```
#### `PdPlayer` - Complex Player Model
Detailed scouting data for PD league simulation.
**PD-Specific Fields**:
- **Card Info**: `cost`, `cardset`, `rarity`, `set_num`, `quantity`, `description`
- **Team**: `mlbclub`, `franchise`
- **References**: `strat_code`, `bbref_id`, `fangr_id`
- **Scouting**: `batting_card`, `pitching_card` (optional, loaded separately)
**Scouting Data Structure**:
```python
# Batting Card (from /api/v2/battingcardratings/player/:id)
batting_card: PdBattingCard
- Base running: steal_low, steal_high, steal_auto, steal_jump, running
- Skills: bunting, hit_and_run (A/B/C/D ratings)
- hand (L/R), offense_col (1 or 2)
- ratings: Dict[str, PdBattingRating]
- 'L': vs Left-handed pitchers
- 'R': vs Right-handed pitchers
# Pitching Card (from /api/v2/pitchingcardratings/player/:id)
pitching_card: PdPitchingCard
- Control: balk, wild_pitch, hold
- Roles: starter_rating, relief_rating, closer_rating
- hand (L/R), offense_col (1 or 2)
- ratings: Dict[str, PdPitchingRating]
- 'L': vs Left-handed batters
- 'R': vs Right-handed batters
```
**PdBattingRating** (per handedness matchup):
- **Hit Location**: `pull_rate`, `center_rate`, `slap_rate`
- **Outcomes**: `homerun`, `triple`, `double_three`, `double_two`, `single_*`, `walk`, `strikeout`, `lineout`, `popout`, `flyout_*`, `groundout_*`
- **Summary**: `avg`, `obp`, `slg`
**PdPitchingRating** (per handedness matchup):
- **Outcomes**: `homerun`, `triple`, `double_*`, `single_*`, `walk`, `strikeout`, `flyout_*`, `groundout_*`
- **X-checks** (defensive probabilities): `xcheck_p`, `xcheck_c`, `xcheck_1b`, `xcheck_2b`, `xcheck_3b`, `xcheck_ss`, `xcheck_lf`, `xcheck_cf`, `xcheck_rf`
- **Summary**: `avg`, `obp`, `slg`
**Factory Method**:
```python
# Create with optional scouting data
player = PdPlayer.from_api_response(
player_data=player_api_response,
batting_data=batting_api_response, # Optional
pitching_data=pitching_api_response # Optional
)
# Get ratings for specific matchup
rating_vs_lhp = player.get_batting_rating('L')
if rating_vs_lhp:
print(f"HR rate vs LHP: {rating_vs_lhp.homerun}%")
print(f"OBP: {rating_vs_lhp.obp}")
rating_vs_rhb = player.get_pitching_rating('R')
if rating_vs_rhb:
print(f"X-check SS: {rating_vs_rhb.xcheck_ss}%")
```
**Design Patterns**:
- Abstract base class enforces consistent interface
- Factory methods for easy API parsing
- Optional scouting data (can load player without ratings)
- Type-safe with full Pydantic validation
---
### `db_models.py` - SQLAlchemy Database Models
**Purpose**: ORM models for PostgreSQL persistence, relationships, and audit trail.
**Key Models**:
#### `Game` - Primary Game Container
Central game record with state tracking.
**Key Fields**:
- **Identity**: `id` (UUID), `league_id` ('sba' or 'pd')
- **Teams**: `home_team_id`, `away_team_id`
- **State**: `status` (pending, active, paused, completed), `game_mode`, `visibility`
- **Current**: `current_inning`, `current_half`, `home_score`, `away_score`
- **AI**: `home_team_is_ai`, `away_team_is_ai`, `ai_difficulty`
- **Timestamps**: `created_at`, `started_at`, `completed_at`
- **Results**: `winner_team_id`, `game_metadata` (JSON)
**Relationships**:
- `plays`: All plays (cascade delete)
- `lineups`: All lineup entries (cascade delete)
- `cardset_links`: PD cardsets (cascade delete)
- `roster_links`: Roster tracking (cascade delete)
- `session`: WebSocket session (cascade delete)
- `rolls`: Dice roll history (cascade delete)
#### `Play` - Individual At-Bat Record
Records every play with full statistics and game context.
**Game State Snapshot**:
- `play_number`, `inning`, `half`, `outs_before`, `batting_order`
- `away_score`, `home_score`: Score at play start
- `on_base_code` (Integer): Bit field for efficient queries (1=1st, 2=2nd, 4=3rd, 7=loaded)
**Player References** (FKs to Lineup):
- Required: `batter_id`, `pitcher_id`, `catcher_id`
- Optional: `defender_id`, `runner_id`
- Base runners: `on_first_id`, `on_second_id`, `on_third_id`
**Runner Outcomes**:
- `on_first_final`, `on_second_final`, `on_third_final`: Final base (None = out, 1-4 = base)
- `batter_final`: Where batter ended up
**Strategic Decisions**:
- `defensive_choices` (JSON): Alignment, holds, shifts
- `offensive_choices` (JSON): Steal attempts, bunts, hit-and-run
**Play Result**:
- `dice_roll`, `hit_type`, `result_description`
- `outs_recorded`, `runs_scored`
- `check_pos`, `error`
**Statistics** (25+ fields):
- Batting: `pa`, `ab`, `hit`, `double`, `triple`, `homerun`, `bb`, `so`, `hbp`, `rbi`, `sac`, `ibb`, `gidp`
- Baserunning: `sb`, `cs`
- Pitching events: `wild_pitch`, `passed_ball`, `pick_off`, `balk`
- Ballpark: `bphr`, `bpfo`, `bp1b`, `bplo`
- Advanced: `wpa`, `re24`
- Earned runs: `run`, `e_run`
**Game Situation**:
- `is_tied`, `is_go_ahead`, `is_new_inning`, `in_pow`
**Workflow**:
- `complete`, `locked`: Play state flags
- `play_metadata` (JSON): Extensibility
**Helper Properties**:
```python
@property
def ai_is_batting(self) -> bool:
"""True if batting team is AI-controlled"""
return (self.half == 'top' and self.game.away_team_is_ai) or \
(self.half == 'bot' and self.game.home_team_is_ai)
@property
def ai_is_fielding(self) -> bool:
"""True if fielding team is AI-controlled"""
return not self.ai_is_batting
```
#### `Lineup` - Player Assignment & Substitutions
Tracks player assignments in a game.
**Polymorphic Design**: Single table for both leagues.
**Fields**:
- `game_id`, `team_id`
- `card_id` (PD) / `player_id` (SBA): Exactly one must be populated
- `position`, `batting_order`
- **Substitution**: `is_starter`, `is_active`, `entered_inning`, `replacing_id`, `after_play`
- **Pitcher**: `is_fatigued`
- `lineup_metadata` (JSON): Extensibility
**Constraints**:
- XOR CHECK: Exactly one of `card_id` or `player_id` must be populated
- `(card_id IS NOT NULL)::int + (player_id IS NOT NULL)::int = 1`
#### `RosterLink` - Eligible Cards/Players
Tracks which cards (PD) or players (SBA) are eligible for a game.
**Polymorphic Design**: Single table supporting both leagues.
**Fields**:
- `id` (auto-increment surrogate key)
- `game_id`, `team_id`
- `card_id` (PD) / `player_id` (SBA): Exactly one must be populated
**Constraints**:
- XOR CHECK: Exactly one ID must be populated
- UNIQUE on (game_id, card_id) for PD
- UNIQUE on (game_id, player_id) for SBA
**Usage Pattern**:
```python
# PD league - add card to roster
roster = await db_ops.add_pd_roster_card(game_id, card_id=123, team_id=1)
# SBA league - add player to roster
roster = await db_ops.add_sba_roster_player(game_id, player_id=456, team_id=2)
```
#### `GameCardsetLink` - PD Cardset Restrictions
PD league only - defines legal cardsets for a game.
**Fields**:
- `game_id`, `cardset_id`: Composite primary key
- `priority` (Integer): 1 = primary, 2+ = backup
**Usage**:
- SBA games: Empty (no cardset restrictions)
- PD games: Required (validates card eligibility)
#### `GameSession` - WebSocket State
Real-time WebSocket state tracking.
**Fields**:
- `game_id` (UUID): One-to-one with Game
- `connected_users` (JSON): Active connections
- `last_action_at` (DateTime): Last activity
- `state_snapshot` (JSON): In-memory state cache
#### `Roll` - Dice Roll History
Auditing and analytics for all dice rolls.
**Fields**:
- `roll_id` (String): Primary key
- `game_id`, `roll_type`, `league_id`
- `team_id`, `player_id`: For analytics
- `roll_data` (JSONB): Complete roll with all dice values
- `context` (JSONB): Pitcher, inning, outs, etc.
- `timestamp`, `created_at`
**Design Patterns**:
- All DateTime fields use Pendulum via `default=lambda: pendulum.now('UTC').naive()`
- CASCADE DELETE: Deleting game removes all related records
- Relationships use `lazy="joined"` for common queries, `lazy="select"` (default) for rare
- Polymorphic tables use CHECK constraints for data integrity
- JSON/JSONB for extensibility
---
### `roster_models.py` - Roster Link Type Safety
**Purpose**: Pydantic models providing type-safe abstractions over the polymorphic `RosterLink` table.
**Key Models**:
#### `BaseRosterLinkData` - Abstract Base
Common interface for roster operations.
**Required Abstract Methods**:
- `get_entity_id() -> int`: Get the entity ID (card_id or player_id)
- `get_entity_type() -> str`: Get entity type ('card' or 'player')
**Common Fields**:
- `id` (Optional[int]): Database ID (populated after save)
- `game_id` (UUID)
- `team_id` (int)
#### `PdRosterLinkData` - PD League Roster
Tracks cards for PD league games.
**Fields**:
- Inherits: `id`, `game_id`, `team_id`
- `card_id` (int): PD card identifier (validated > 0)
**Methods**:
- `get_entity_id()`: Returns `card_id`
- `get_entity_type()`: Returns `"card"`
#### `SbaRosterLinkData` - SBA League Roster
Tracks players for SBA league games.
**Fields**:
- Inherits: `id`, `game_id`, `team_id`
- `player_id` (int): SBA player identifier (validated > 0)
**Methods**:
- `get_entity_id()`: Returns `player_id`
- `get_entity_type()`: Returns `"player"`
#### `RosterLinkCreate` - Request Model
API request model for creating roster links.
**Fields**:
- `game_id`, `team_id`
- `card_id` (Optional[int]): PD card
- `player_id` (Optional[int]): SBA player
**Validators**:
- `model_post_init()`: Ensures exactly one ID is provided (XOR check)
- Fails if both or neither are provided
**Conversion Methods**:
```python
request = RosterLinkCreate(game_id=game_id, team_id=1, card_id=123)
# Convert to league-specific data
pd_data = request.to_pd_data() # PdRosterLinkData
sba_data = request.to_sba_data() # Fails - card_id provided, not player_id
```
**Design Patterns**:
- Abstract base enforces consistent interface
- League-specific subclasses provide type safety
- Application-layer validation complements database constraints
- Conversion methods for easy API → model transformation
---
## Key Patterns & Conventions
### 1. Pydantic v2 Validation
All Pydantic models use v2 syntax with `field_validator` decorators:
```python
@field_validator('position')
@classmethod
def validate_position(cls, v: str) -> str:
valid_positions = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH']
if v not in valid_positions:
raise ValueError(f"Position must be one of {valid_positions}")
return v
```
**Key Points**:
- Use `@classmethod` decorator after `@field_validator`
- Type hints are required for validator methods
- Validators should raise `ValueError` with clear messages
### 2. SQLAlchemy Relationships
**Lazy Loading Strategy**:
```python
# Common queries - eager load
batter = relationship("Lineup", foreign_keys=[batter_id], lazy="joined")
# Rare queries - lazy load (default)
defender = relationship("Lineup", foreign_keys=[defender_id])
```
**Foreign Keys with Multiple References**:
```python
# When same table referenced multiple times, use foreign_keys parameter
batter = relationship("Lineup", foreign_keys=[batter_id])
pitcher = relationship("Lineup", foreign_keys=[pitcher_id])
```
### 3. Polymorphic Tables
Pattern for supporting both SBA and PD leagues in single table:
**Database Level**:
```python
# Two nullable columns
card_id = Column(Integer, nullable=True) # PD
player_id = Column(Integer, nullable=True) # SBA
# CHECK constraint ensures exactly one is populated (XOR)
CheckConstraint(
'(card_id IS NOT NULL)::int + (player_id IS NOT NULL)::int = 1',
name='one_id_required'
)
```
**Application Level**:
```python
# Pydantic models provide type safety
class PdRosterLinkData(BaseRosterLinkData):
card_id: int # Required, not nullable
class SbaRosterLinkData(BaseRosterLinkData):
player_id: int # Required, not nullable
```
### 4. DateTime Handling
**ALWAYS use Pendulum for all datetime operations:**
```python
import pendulum
# SQLAlchemy default
created_at = Column(DateTime, default=lambda: pendulum.now('UTC').naive())
# Manual creation
now = pendulum.now('UTC')
```
**Critical**: Use `.naive()` for PostgreSQL compatibility with asyncpg driver.
### 5. Factory Methods
Player models use factory methods for easy API parsing:
```python
@classmethod
def from_api_response(cls, data: Dict[str, Any]) -> "SbaPlayer":
"""Create from API response with field mapping"""
return cls(
id=data["id"],
name=data["name"],
# ... map all fields
)
```
**Benefits**:
- Encapsulates API → model transformation
- Single source of truth for field mapping
- Easy to test independently
### 6. Helper Methods on Models
Models include helper methods to reduce duplicate logic:
```python
class GameState(BaseModel):
# ... fields
def get_batting_team_id(self) -> int:
"""Get the ID of the team currently batting"""
return self.away_team_id if self.half == "top" else self.home_team_id
def bases_occupied(self) -> List[int]:
"""Get list of occupied bases"""
bases = []
if self.on_first:
bases.append(1)
if self.on_second:
bases.append(2)
if self.on_third:
bases.append(3)
return bases
```
**Benefits**:
- Encapsulates common queries
- Reduces duplication in game engine
- Easier to test
- Self-documenting code
### 7. Immutability
Pydantic models are immutable by default. To modify:
```python
# ❌ This raises an error
state.inning = 5
# ✅ Use model_copy() to create modified copy
updated_state = state.model_copy(update={'inning': 5})
# ✅ Or use StateManager which handles updates
state_manager.update_state(game_id, state)
```
---
## Integration Points
### Game Engine → Models
```python
from app.models import GameState, LineupPlayerState
# Create state
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
current_batter_lineup_id=10
)
# Use helper methods
batting_team = state.get_batting_team_id()
is_runner_on = state.is_runner_on_first()
```
### Database Operations → Models
```python
from app.models import Game, Play, Lineup
# Create SQLAlchemy model
game = Game(
id=uuid4(),
league_id="sba",
home_team_id=1,
away_team_id=2,
status="pending",
game_mode="friendly",
visibility="public"
)
# Persist
async with session.begin():
session.add(game)
```
### Models → API Responses
```python
from app.models import GameState
# Pydantic models serialize to JSON automatically
state = GameState(...)
state_json = state.model_dump() # Dict for JSON serialization
state_json = state.model_dump_json() # JSON string
```
### Player Polymorphism
```python
from app.models import BasePlayer, SbaPlayer, PdPlayer
def process_batter(batter: BasePlayer):
"""Works for both SBA and PD players"""
print(f"Batter: {batter.get_display_name()}")
print(f"Positions: {batter.get_positions()}")
# Use with any league
sba_batter = SbaPlayer(...)
pd_batter = PdPlayer(...)
process_batter(sba_batter) # Works
process_batter(pd_batter) # Works
```
---
## Common Tasks
### Adding a New Field to GameState
1. **Add field to Pydantic model** (`game_models.py`):
```python
class GameState(BaseModel):
# ... existing fields
new_field: str = "default_value"
```
2. **Add validator if needed**:
```python
@field_validator('new_field')
@classmethod
def validate_new_field(cls, v: str) -> str:
if not v:
raise ValueError("new_field cannot be empty")
return v
```
3. **Update tests** (`tests/unit/models/test_game_models.py`):
```python
def test_new_field_validation():
with pytest.raises(ValidationError):
GameState(
game_id=uuid4(),
league_id="sba",
# ... required fields
new_field="" # Invalid
)
```
4. **No database migration needed** (Pydantic models are in-memory only)
### Adding a New SQLAlchemy Model
1. **Define model** (`db_models.py`):
```python
class NewModel(Base):
__tablename__ = "new_models"
id = Column(Integer, primary_key=True)
game_id = Column(UUID(as_uuid=True), ForeignKey("games.id"))
# ... fields
# Relationship
game = relationship("Game", back_populates="new_models")
```
2. **Add relationship to Game model**:
```python
class Game(Base):
# ... existing fields
new_models = relationship("NewModel", back_populates="game", cascade="all, delete-orphan")
```
3. **Create migration**:
```bash
alembic revision --autogenerate -m "Add new_models table"
alembic upgrade head
```
4. **Export from `__init__.py`**:
```python
from app.models.db_models import NewModel
__all__ = [
# ... existing exports
"NewModel",
]
```
### Adding a New Player Field
1. **Update BasePlayer if common** (`player_models.py`):
```python
class BasePlayer(BaseModel, ABC):
# ... existing fields
new_common_field: Optional[str] = None
```
2. **Or update league-specific class**:
```python
class SbaPlayer(BasePlayer):
# ... existing fields
new_sba_field: Optional[int] = None
```
3. **Update factory method**:
```python
@classmethod
def from_api_response(cls, data: Dict[str, Any]) -> "SbaPlayer":
return cls(
# ... existing fields
new_sba_field=data.get("new_sba_field"),
)
```
4. **Update tests** (`tests/unit/models/test_player_models.py`)
### Creating a New Pydantic Model
1. **Define in appropriate file**:
```python
class NewModel(BaseModel):
"""Purpose of this model"""
field1: str
field2: int = 0
@field_validator('field1')
@classmethod
def validate_field1(cls, v: str) -> str:
if len(v) < 3:
raise ValueError("field1 must be at least 3 characters")
return v
```
2. **Export from `__init__.py`**:
```python
from app.models.game_models import NewModel
__all__ = [
# ... existing exports
"NewModel",
]
```
3. **Add tests**
---
## Troubleshooting
### ValidationError: Field required
**Problem**: Missing required field when creating model.
**Solution**: Check field definition - remove `Optional` or provide default:
```python
# Required field (no default)
name: str
# Optional field
name: Optional[str] = None
# Field with default
name: str = "default"
```
### ValidationError: Field value validation failed
**Problem**: Field validator rejected value.
**Solution**: Check validator logic and error message:
```python
@field_validator('position')
@classmethod
def validate_position(cls, v: str) -> str:
valid = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH']
if v not in valid:
raise ValueError(f"Position must be one of {valid}") # Clear message
return v
```
### SQLAlchemy: Cannot access attribute before flush
**Problem**: Trying to access auto-generated ID before commit.
**Solution**: Commit first or use `session.flush()`:
```python
async with session.begin():
session.add(game)
await session.flush() # Generates ID without committing
game_id = game.id # Now accessible
```
### IntegrityError: violates check constraint
**Problem**: Polymorphic table constraint violated (both IDs populated or neither).
**Solution**: Use Pydantic models to enforce XOR at application level:
```python
# ❌ Don't create RosterLink directly
roster = RosterLink(game_id=game_id, team_id=1, card_id=123, player_id=456) # Both IDs
# ✅ Use league-specific Pydantic models
pd_roster = PdRosterLinkData(game_id=game_id, team_id=1, card_id=123)
```
### Type Error: Column vs Actual Value
**Problem**: SQLAlchemy model attributes typed as `Column[int]` but are `int` at runtime.
**Solution**: Use targeted type ignore comments:
```python
# SQLAlchemy ORM magic: .id is Column[int] for type checker, int at runtime
state.current_batter_lineup_id = lineup_player.id # type: ignore[assignment]
```
**When to use**:
- Assigning SQLAlchemy model attributes to Pydantic fields
- Common in game_engine.py when bridging ORM and Pydantic
**When NOT to use**:
- Pure Pydantic → Pydantic operations (should type check cleanly)
- New code (only for SQLAlchemy ↔ Pydantic bridging)
### AttributeError: 'GameState' object has no attribute
**Problem**: Trying to access field that doesn't exist or was renamed.
**Solution**:
1. Check model definition
2. If field was renamed, update all references
3. Use IDE autocomplete to avoid typos
Example:
```python
# ❌ Old field name
state.runners # AttributeError - removed in refactor
# ✅ New API
state.get_all_runners() # Returns list of (base, player) tuples
```
---
## Testing Models
### Unit Test Structure
Each model file has corresponding test file:
```
tests/unit/models/
├── test_game_models.py # GameState, LineupPlayerState, etc.
├── test_player_models.py # BasePlayer, SbaPlayer, PdPlayer
└── test_roster_models.py # RosterLink Pydantic models
```
### Test Patterns
**Valid Model Creation**:
```python
def test_create_game_state():
state = GameState(
game_id=uuid4(),
league_id="sba",
home_team_id=1,
away_team_id=2,
current_batter_lineup_id=10
)
assert state.league_id == "sba"
assert state.inning == 1
```
**Validation Errors**:
```python
def test_invalid_league_id():
with pytest.raises(ValidationError) as exc_info:
GameState(
game_id=uuid4(),
league_id="invalid", # Not 'sba' or 'pd'
home_team_id=1,
away_team_id=2,
current_batter_lineup_id=10
)
assert "league_id must be one of ['sba', 'pd']" in str(exc_info.value)
```
**Helper Methods**:
```python
def test_advance_runner_scoring():
state = GameState(...)
runner = LineupPlayerState(lineup_id=1, card_id=101, position="CF")
state.add_runner(runner, base=3)
state.advance_runner(from_base=3, to_base=4)
assert state.on_third is None
assert state.home_score == 1 or state.away_score == 1 # Depends on half
```
### Running Model Tests
```bash
# All model tests
uv run pytest tests/unit/models/ -v
# Specific file
uv run pytest tests/unit/models/test_game_models.py -v
# Specific test
uv run pytest tests/unit/models/test_game_models.py::test_advance_runner_scoring -v
# With coverage
uv run pytest tests/unit/models/ --cov=app.models --cov-report=html
```
---
## Design Rationale
### Why Separate Pydantic and SQLAlchemy Models?
**Pydantic Models** (`game_models.py`, `player_models.py`, `roster_models.py`):
- Fast in-memory operations (no ORM overhead)
- WebSocket serialization (automatic JSON conversion)
- Validation and type safety
- Immutable by default
- Helper methods for game logic
**SQLAlchemy Models** (`db_models.py`):
- Database persistence
- Complex relationships
- Audit trail and history
- Transaction management
- Query optimization
**Tradeoff**: Some duplication, but optimized for different use cases.
### Why Direct Base References Instead of List?
**Before**: `runners: List[RunnerState]`
**After**: `on_first`, `on_second`, `on_third`
**Reasons**:
1. Matches database structure exactly (`Play` has `on_first_id`, `on_second_id`, `on_third_id`)
2. Simpler state management (direct assignment vs list operations)
3. Type safety (LineupPlayerState vs generic runner)
4. Easier to work with in game engine
5. No list management overhead
### Why Polymorphic Tables Instead of League-Specific?
**Single polymorphic table** (`RosterLink`, `Lineup`) instead of separate `PdRosterLink`/`SbaRosterLink`:
**Advantages**:
- Simpler schema (fewer tables)
- Easier queries (no UNIONs needed)
- Single code path for common operations
- Foreign key relationships work naturally
**Type Safety**: Pydantic models (`PdRosterLinkData`, `SbaRosterLinkData`) provide application-layer safety.
### Why Factory Methods for Player Models?
API responses have inconsistent field names and nested structures. Factory methods:
1. Encapsulate field mapping logic
2. Handle nested data (team info, cardsets)
3. Provide single source of truth
4. Easy to test independently
5. Future-proof for API changes
---
## Examples
### Complete Game State Management
```python
from app.models import GameState, LineupPlayerState
from uuid import uuid4
# Create game
state = GameState(
game_id=uuid4(),
league_id="sba",
home_team_id=1,
away_team_id=2,
current_batter_lineup_id=10
)
# Add runners
runner1 = LineupPlayerState(lineup_id=5, card_id=101, position="CF", batting_order=2)
runner2 = LineupPlayerState(lineup_id=8, card_id=104, position="SS", batting_order=5)
state.add_runner(runner1, base=1)
state.add_runner(runner2, base=2)
# Check state
print(f"Runners on base: {state.bases_occupied()}") # [1, 2]
print(f"Is runner on first: {state.is_runner_on_first()}") # True
# Advance runners (single to right field)
state.advance_runner(from_base=2, to_base=4) # Runner scores from 2nd
state.advance_runner(from_base=1, to_base=3) # Runner to 3rd from 1st
# Batter to first
batter = LineupPlayerState(lineup_id=10, card_id=107, position="RF", batting_order=7)
state.add_runner(batter, base=1)
# Check updated state
print(f"Score: {state.away_score}-{state.home_score}") # 1-0 if top of inning
print(f"Runners: {state.bases_occupied()}") # [1, 3]
# Record out
half_over = state.increment_outs() # False (1 out)
half_over = state.increment_outs() # False (2 outs)
half_over = state.increment_outs() # True (3 outs)
# End half inning
if half_over:
state.end_half_inning()
print(f"Now: Inning {state.inning}, {state.half}") # Inning 1, bottom
print(f"Runners: {state.bases_occupied()}") # [] (cleared)
```
### Player Model Polymorphism
```python
from app.models import BasePlayer, SbaPlayer, PdPlayer
def display_player_card(player: BasePlayer):
"""Works for both SBA and PD players"""
print(f"Name: {player.get_display_name()}")
print(f"Positions: {', '.join(player.get_positions())}")
print(f"Image: {player.get_player_image_url()}")
# League-specific logic
if isinstance(player, PdPlayer):
rating = player.get_batting_rating('L')
if rating:
print(f"Batting vs LHP: {rating.avg:.3f}")
elif isinstance(player, SbaPlayer):
print(f"WARA: {player.wara}")
# Use with SBA player
sba_player = SbaPlayer.from_api_response(sba_api_data)
display_player_card(sba_player)
# Use with PD player
pd_player = PdPlayer.from_api_response(
player_data=pd_api_data,
batting_data=batting_api_data
)
display_player_card(pd_player)
```
### Database Operations with Models
```python
from app.models import Game, Play, Lineup
from app.database.session import get_session
from sqlalchemy import select
import pendulum
import uuid
async def create_game_with_lineups():
game_id = uuid.uuid4()
async with get_session() as session:
# Create game
game = Game(
id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
status="active",
game_mode="friendly",
visibility="public",
created_at=pendulum.now('UTC').naive()
)
session.add(game)
# Add lineup entries
lineup_entries = [
Lineup(game_id=game_id, team_id=1, player_id=101, position="P", batting_order=9),
Lineup(game_id=game_id, team_id=1, player_id=102, position="C", batting_order=2),
Lineup(game_id=game_id, team_id=1, player_id=103, position="1B", batting_order=3),
# ... more players
]
session.add_all(lineup_entries)
await session.commit()
return game_id
async def get_active_pitcher(game_id: uuid.UUID, team_id: int):
async with get_session() as session:
result = await session.execute(
select(Lineup)
.where(
Lineup.game_id == game_id,
Lineup.team_id == team_id,
Lineup.position == 'P',
Lineup.is_active == True
)
)
return result.scalar_one_or_none()
```
---
## Related Documentation
- **Backend CLAUDE.md**: `../CLAUDE.md` - Overall backend architecture
- **Database Operations**: `../database/CLAUDE.md` - Database layer patterns
- **Game Engine**: `../core/CLAUDE.md` - Game logic using these models
- **Player Data Catalog**: `../../../.claude/implementation/player-data-catalog.md` - API response examples
---
**Last Updated**: 2025-10-31
**Status**: Complete and production-ready
**Test Coverage**: 110+ tests across all model files