Updated terminal client REPL to work with refactored GameState structure where current_batter/pitcher/catcher are now LineupPlayerState objects instead of integer IDs. Also standardized all documentation to properly show 'uv run' prefixes for Python commands. REPL Updates: - terminal_client/display.py: Access lineup_id from LineupPlayerState objects - terminal_client/repl.py: Fix typos (self.current_game → self.current_game_id) - tests/unit/terminal_client/test_commands.py: Create proper LineupPlayerState objects in test fixtures (2 tests fixed, all 105 terminal client tests passing) Documentation Updates (100+ command examples): - CLAUDE.md: Updated pytest examples to use 'uv run' prefix - terminal_client/CLAUDE.md: Updated ~40 command examples - tests/CLAUDE.md: Updated all test commands (unit, integration, debugging) - app/*/CLAUDE.md: Updated test and server startup commands (5 files) All Python commands now consistently use 'uv run' prefix to align with project's UV migration, improving developer experience and preventing confusion about virtual environment activation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
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
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:
- 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