## 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)
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 entrycard_id(int): PD card ID or SBA player IDposition(str): P, C, 1B, 2B, 3B, SS, LF, CF, RF, DHbatting_order(Optional[int]): 1-9 if in batting orderis_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 identifierplayers(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 Noneget_player_by_lineup_id(): Lookup by lineup IDget_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_shiftinfield_depth(str): infield_in, normal, corners_inoutfield_depth(str): in, normalhold_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, patientsteal_attempts(List[int]): Bases to steal (2, 3, or 4 for home)hit_and_run(bool): Attempt hit-and-runbunt_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 cardhit_location(Optional[str]): Position where ball was hit (for groundballs/flyballs)
Validators:
hit_locationmust 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_validatordecorators - 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 playget_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_1throughpos_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 Averageteam_id,team_name,season: Current team infostrat_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_orderaway_score,home_score: Score at play starton_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, shiftsoffensive_choices(JSON): Steal attempts, bunts, hit-and-run
Play Result:
dice_roll,hit_type,result_descriptionouts_recorded,runs_scoredcheck_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 flagsplay_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_idcard_id(PD) /player_id(SBA): Exactly one must be populatedposition,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_idorplayer_idmust 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_idcard_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)
GameCardsetLink - PD Cardset Restrictions
PD league only - defines legal cardsets for a game.
Fields:
game_id,cardset_id: Composite primary keypriority(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 Gameconnected_users(JSON): Active connectionslast_action_at(DateTime): Last activitystate_snapshot(JSON): In-memory state cache
Roll - Dice Roll History
Auditing and analytics for all dice rolls.
Fields:
roll_id(String): Primary keygame_id,roll_type,league_idteam_id,player_id: For analyticsroll_data(JSONB): Complete roll with all dice valuescontext(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(): Returnscard_idget_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(): Returnsplayer_idget_entity_type(): Returns"player"
RosterLinkCreate - Request Model
API request model for creating roster links.
Fields:
game_id,team_idcard_id(Optional[int]): PD cardplayer_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
@classmethoddecorator after@field_validator - Type hints are required for validator methods
- Validators should raise
ValueErrorwith 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
- Add field to Pydantic model (
game_models.py):
class GameState(BaseModel):
# ... existing fields
new_field: str = "default_value"
- 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
- 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
)
- No database migration needed (Pydantic models are in-memory only)
Adding a New SQLAlchemy Model
- 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")
- Add relationship to Game model:
class Game(Base):
# ... existing fields
new_models = relationship("NewModel", back_populates="game", cascade="all, delete-orphan")
- Create migration:
alembic revision --autogenerate -m "Add new_models table"
alembic upgrade head
- Export from
__init__.py:
from app.models.db_models import NewModel
__all__ = [
# ... existing exports
"NewModel",
]
Adding a New Player Field
- Update BasePlayer if common (
player_models.py):
class BasePlayer(BaseModel, ABC):
# ... existing fields
new_common_field: Optional[str] = None
- Or update league-specific class:
class SbaPlayer(BasePlayer):
# ... existing fields
new_sba_field: Optional[int] = None
- 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"),
)
- Update tests (
tests/unit/models/test_player_models.py)
Creating a New Pydantic Model
- 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
- Export from
__init__.py:
from app.models.game_models import NewModel
__all__ = [
# ... existing exports
"NewModel",
]
- 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:
- Check model definition
- If field was renamed, update all references
- 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:
- Matches database structure exactly (
Playhason_first_id,on_second_id,on_third_id) - Simpler state management (direct assignment vs list operations)
- Type safety (LineupPlayerState vs generic runner)
- Easier to work with in game engine
- 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:
- Encapsulate field mapping logic
- Handle nested data (team info, cardsets)
- Provide single source of truth
- Easy to test independently
- 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()
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