strat-gameplay-webapp/backend/app/models/CLAUDE.md
Cal Corum 76e24ab22b CLAUDE: Refactor ManualOutcomeSubmission to use PlayOutcome enum + comprehensive documentation
## Refactoring
- Changed `ManualOutcomeSubmission.outcome` from `str` to `PlayOutcome` enum type
- Removed custom validator (Pydantic handles enum validation automatically)
- Added direct import of PlayOutcome (no circular dependency due to TYPE_CHECKING guard)
- Updated tests to use enum values while maintaining backward compatibility

Benefits:
- Better type safety with IDE autocomplete
- Cleaner code (removed 15 lines of validator boilerplate)
- Backward compatible (Pydantic auto-converts strings to enum)
- Access to helper methods (is_hit(), is_out(), etc.)

Files modified:
- app/models/game_models.py: Enum type + import
- tests/unit/config/test_result_charts.py: Updated 7 tests + added compatibility test

## Documentation
Created comprehensive CLAUDE.md files for all backend/app/ subdirectories to help future AI agents quickly understand and work with the code.

Added 8,799 lines of documentation covering:
- api/ (906 lines): FastAPI routes, health checks, auth patterns
- config/ (906 lines): League configs, PlayOutcome enum, result charts
- core/ (1,288 lines): GameEngine, StateManager, PlayResolver, dice system
- data/ (937 lines): API clients (planned), caching layer
- database/ (945 lines): Async sessions, operations, recovery
- models/ (1,270 lines): Pydantic/SQLAlchemy models, polymorphic patterns
- utils/ (959 lines): Logging, JWT auth, security
- websocket/ (1,588 lines): Socket.io handlers, real-time events
- tests/ (475 lines): Testing patterns and structure

Each CLAUDE.md includes:
- Purpose & architecture overview
- Key components with detailed explanations
- Patterns & conventions
- Integration points
- Common tasks (step-by-step guides)
- Troubleshooting with solutions
- Working code examples
- Testing guidance

Total changes: +9,294 lines / -24 lines
Tests: All passing (62/62 model tests, 7/7 ManualOutcomeSubmission tests)
2025-10-31 16:03:54 -05:00

36 KiB

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:

# ✅ 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:

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:

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:

# 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:

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:

# 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:

# 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:

# 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:

# 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:

@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

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:

# 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)

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

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:

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:

@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:

# 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:

# 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:

# 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:

# 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:

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:

@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:

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:

# ❌ 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

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

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

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

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):
class GameState(BaseModel):
    # ... existing fields
    new_field: str = "default_value"
  1. Add validator if needed:
@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
  1. Update tests (tests/unit/models/test_game_models.py):
def test_new_field_validation():
    with pytest.raises(ValidationError):
        GameState(
            game_id=uuid4(),
            league_id="sba",
            # ... required fields
            new_field=""  # Invalid
        )
  1. No database migration needed (Pydantic models are in-memory only)

Adding a New SQLAlchemy Model

  1. Define model (db_models.py):
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")
  1. Add relationship to Game model:
class Game(Base):
    # ... existing fields
    new_models = relationship("NewModel", back_populates="game", cascade="all, delete-orphan")
  1. Create migration:
alembic revision --autogenerate -m "Add new_models table"
alembic upgrade head
  1. Export from __init__.py:
from app.models.db_models import NewModel

__all__ = [
    # ... existing exports
    "NewModel",
]

Adding a New Player Field

  1. Update BasePlayer if common (player_models.py):
class BasePlayer(BaseModel, ABC):
    # ... existing fields
    new_common_field: Optional[str] = None
  1. Or update league-specific class:
class SbaPlayer(BasePlayer):
    # ... existing fields
    new_sba_field: Optional[int] = None
  1. Update factory method:
@classmethod
def from_api_response(cls, data: Dict[str, Any]) -> "SbaPlayer":
    return cls(
        # ... existing fields
        new_sba_field=data.get("new_sba_field"),
    )
  1. Update tests (tests/unit/models/test_player_models.py)

Creating a New Pydantic Model

  1. Define in appropriate file:
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
  1. Export from __init__.py:
from app.models.game_models import NewModel

__all__ = [
    # ... existing exports
    "NewModel",
]
  1. Add tests

Troubleshooting

ValidationError: Field required

Problem: Missing required field when creating model.

Solution: Check field definition - remove Optional or provide default:

# 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:

@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():

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:

# ❌ 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:

# 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:

# ❌ 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:

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:

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:

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

# All model tests
pytest tests/unit/models/ -v

# Specific file
pytest tests/unit/models/test_game_models.py -v

# Specific test
pytest tests/unit/models/test_game_models.py::test_advance_runner_scoring -v

# With coverage
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

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

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

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()

  • 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