strat-gameplay-webapp/backend/app/models/CLAUDE.md
Cal Corum c7b376df4f CLAUDE: Update documentation for GameState refactoring and X-Check testing
Updated CLAUDE.md files to document recent changes including the GameState
refactoring and new X-Check testing capabilities in the terminal client.

Changes to app/models/CLAUDE.md:
- Updated GameState field documentation
  - Replaced current_batter_lineup_id references with current_batter
  - Documented LineupPlayerState requirement for current players
  - Added comprehensive usage examples

- Added "Recent Updates" section documenting GameState refactoring
  - Before/after code examples showing migration path
  - Explanation of why the change was made
  - Migration notes for developers
  - List of all affected files (7 files updated)

Changes to terminal_client/CLAUDE.md:
- Added "2025-11-04: X-Check Testing & GameState Refactoring" section
  - New feature: resolve_with x-check <position> command
    - Complete X-Check resolution with defense tables and error charts
    - Shows all resolution steps with audit trail
    - Works with actual player ratings from PD API

  - Documented 8 X-Check commands now in help system
    - roll_jump / test_jump, roll_fielding / test_fielding
    - test_location, rollback, force_wild_pitch, force_passed_ball

  - Bug fixes documented
    - GameState structure updates (display.py, repl.py)
    - Game recovery fix (state_manager.py)
    - DO3 advancement fix (play_resolver.py)

  - Complete testing workflow examples
  - List of 7 files updated
  - Test coverage status (all passing)

- Updated footer: Last Updated 2025-11-04

These documentation updates provide clear migration guides for the GameState
refactoring and comprehensive examples for the new X-Check testing features.

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

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

1340 lines
38 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)
- **Current Players**: `current_batter` (LineupPlayerState, required), `current_pitcher` (Optional[LineupPlayerState]), `current_catcher` (Optional[LineupPlayerState]), `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 current batter (required field)
current_batter = LineupPlayerState(
lineup_id=10,
card_id=100,
position="CF",
batting_order=1
)
# Create game state
state = GameState(
game_id=uuid4(),
league_id="sba",
home_team_id=1,
away_team_id=2,
current_batter=current_batter # Required LineupPlayerState object
)
# Add runner
runner = LineupPlayerState(lineup_id=5, card_id=123, position="SS", 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
# Access current player info
print(f"Current batter: {state.current_batter.lineup_id}")
if state.current_pitcher:
print(f"Current pitcher: {state.current_pitcher.lineup_id}")
# 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
```
---
## Recent Updates
### GameState Refactoring (2025-11-04)
**Changed**: `current_batter`, `current_pitcher`, and `current_catcher` fields
**Before** (Old Structure):
```python
state = GameState(
game_id=uuid4(),
league_id="sba",
home_team_id=1,
away_team_id=2,
current_batter_lineup_id=10 # Integer ID
)
# Access: state.current_batter_lineup_id
```
**After** (New Structure):
```python
current_batter = LineupPlayerState(lineup_id=10, card_id=100, position="CF", batting_order=1)
state = GameState(
game_id=uuid4(),
league_id="sba",
home_team_id=1,
away_team_id=2,
current_batter=current_batter # LineupPlayerState object (required)
)
# Access: state.current_batter.lineup_id
```
**Why**:
- More type-safe (full player context instead of just ID)
- Enables position rating access during X-Check resolution
- Matches pattern used for `on_first`, `on_second`, `on_third` runners
- Simplifies game engine logic
**Migration Notes**:
- `current_batter` is **required** (not Optional)
- `current_pitcher` and `current_catcher` are **Optional[LineupPlayerState]**
- All code accessing `state.current_batter_lineup_id` must change to `state.current_batter.lineup_id`
- Game recovery creates placeholder `current_batter` from first active batter in lineup
- `_prepare_next_play()` sets correct current players after recovery
**Affected Files**:
-`app/models/game_models.py` - Model definition updated
-`app/core/game_engine.py` - Uses new structure throughout
-`app/core/state_manager.py` - Recovery creates placeholders
-`app/core/runner_advancement.py` - All 17 references updated
-`terminal_client/display.py` - Display updated
-`terminal_client/repl.py` - Commands updated
- ✅ All test files - Fixtures updated to create LineupPlayerState objects
---
## 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