# 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