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>
1271 lines
36 KiB
Markdown
1271 lines
36 KiB
Markdown
# Models Directory - Data Models for Game Engine
|
|
|
|
## Purpose
|
|
|
|
This directory contains all data models for the game engine, split into two complementary systems:
|
|
- **Pydantic Models**: Type-safe, validated models for in-memory game state and API contracts
|
|
- **SQLAlchemy Models**: ORM models for PostgreSQL database persistence
|
|
|
|
The separation optimizes for different use cases:
|
|
- Pydantic: Fast in-memory operations, WebSocket serialization, validation
|
|
- SQLAlchemy: Database persistence, complex relationships, audit trail
|
|
|
|
## Directory Structure
|
|
|
|
```
|
|
models/
|
|
├── __init__.py # Exports all models for easy importing
|
|
├── game_models.py # Pydantic in-memory game state models
|
|
├── player_models.py # Polymorphic player models (SBA/PD)
|
|
├── db_models.py # SQLAlchemy database models
|
|
└── roster_models.py # Pydantic roster link models
|
|
```
|
|
|
|
## File Overview
|
|
|
|
### `__init__.py`
|
|
|
|
Central export point for all models. Use this for imports throughout the codebase.
|
|
|
|
**Exports**:
|
|
- Database models: `Game`, `Play`, `Lineup`, `GameSession`, `RosterLink`, `GameCardsetLink`
|
|
- Game state models: `GameState`, `LineupPlayerState`, `TeamLineupState`, `DefensiveDecision`, `OffensiveDecision`
|
|
- Roster models: `BaseRosterLinkData`, `PdRosterLinkData`, `SbaRosterLinkData`, `RosterLinkCreate`
|
|
- Player models: `BasePlayer`, `SbaPlayer`, `PdPlayer`, `PdBattingCard`, `PdPitchingCard`
|
|
|
|
**Usage**:
|
|
```python
|
|
# ✅ Import from models package
|
|
from app.models import GameState, Game, SbaPlayer
|
|
|
|
# ❌ Don't import from individual files
|
|
from app.models.game_models import GameState # Less convenient
|
|
```
|
|
|
|
---
|
|
|
|
### `game_models.py` - In-Memory Game State
|
|
|
|
**Purpose**: Pydantic models representing active game state cached in memory for fast gameplay.
|
|
|
|
**Key Models**:
|
|
|
|
#### `GameState` - Core Game State
|
|
Complete in-memory representation of an active game. The heart of the game engine.
|
|
|
|
**Critical Fields**:
|
|
- **Identity**: `game_id` (UUID), `league_id` (str: 'sba' or 'pd')
|
|
- **Teams**: `home_team_id`, `away_team_id`, `home_team_is_ai`, `away_team_is_ai`
|
|
- **Resolution**: `auto_mode` (True = PD auto-resolve, False = manual submissions)
|
|
- **Game State**: `status`, `inning`, `half`, `outs`, `home_score`, `away_score`
|
|
- **Runners**: `on_first`, `on_second`, `on_third` (direct LineupPlayerState references)
|
|
- **Batting Order**: `away_team_batter_idx` (0-8), `home_team_batter_idx` (0-8)
|
|
- **Play Snapshot**: `current_batter_lineup_id`, `current_pitcher_lineup_id`, `current_catcher_lineup_id`, `current_on_base_code`
|
|
- **Decision Tracking**: `pending_decision`, `decisions_this_play`, `pending_defensive_decision`, `pending_offensive_decision`, `decision_phase`
|
|
- **Manual Mode**: `pending_manual_roll` (AbRoll stored when dice rolled in manual mode)
|
|
- **Play History**: `play_count`, `last_play_result`
|
|
|
|
**Helper Methods** (20+ utility methods):
|
|
- Team queries: `get_batting_team_id()`, `get_fielding_team_id()`, `is_batting_team_ai()`, `is_fielding_team_ai()`
|
|
- Runner queries: `is_runner_on_first()`, `get_runner_at_base()`, `bases_occupied()`, `get_all_runners()`
|
|
- Runner management: `add_runner()`, `advance_runner()`, `clear_bases()`
|
|
- Game flow: `increment_outs()`, `end_half_inning()`, `is_game_over()`
|
|
|
|
**Usage Example**:
|
|
```python
|
|
from app.models import GameState, LineupPlayerState
|
|
|
|
# Create game state
|
|
state = GameState(
|
|
game_id=uuid4(),
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2,
|
|
current_batter_lineup_id=10
|
|
)
|
|
|
|
# Add runner
|
|
runner = LineupPlayerState(lineup_id=5, card_id=123, position="CF", batting_order=2)
|
|
state.add_runner(runner, base=1)
|
|
|
|
# Advance runner and score
|
|
state.advance_runner(from_base=1, to_base=4) # Auto-increments score
|
|
|
|
# Check game over
|
|
if state.is_game_over():
|
|
state.status = "completed"
|
|
```
|
|
|
|
#### `LineupPlayerState` - Player in Lineup
|
|
Lightweight reference to a player in the game lineup. Full player data is cached separately.
|
|
|
|
**Fields**:
|
|
- `lineup_id` (int): Database ID of lineup entry
|
|
- `card_id` (int): PD card ID or SBA player ID
|
|
- `position` (str): P, C, 1B, 2B, 3B, SS, LF, CF, RF, DH
|
|
- `batting_order` (Optional[int]): 1-9 if in batting order
|
|
- `is_active` (bool): Currently in game vs substituted
|
|
|
|
**Validators**:
|
|
- Position must be valid baseball position
|
|
- Batting order must be 1-9 if provided
|
|
|
|
#### `TeamLineupState` - Team's Active Lineup
|
|
Container for a team's players with helper methods for common queries.
|
|
|
|
**Fields**:
|
|
- `team_id` (int): Team identifier
|
|
- `players` (List[LineupPlayerState]): All players on this team
|
|
|
|
**Helper Methods**:
|
|
- `get_batting_order()`: Returns players sorted by batting_order (1-9)
|
|
- `get_pitcher()`: Returns active pitcher or None
|
|
- `get_player_by_lineup_id()`: Lookup by lineup ID
|
|
- `get_batter()`: Get batter by batting order index (0-8)
|
|
|
|
**Usage Example**:
|
|
```python
|
|
lineup = TeamLineupState(team_id=1, players=[...])
|
|
|
|
# Get batting order
|
|
order = lineup.get_batting_order() # [player1, player2, ..., player9]
|
|
|
|
# Get current pitcher
|
|
pitcher = lineup.get_pitcher()
|
|
|
|
# Get 3rd batter in order
|
|
third_batter = lineup.get_batter(2) # 0-indexed
|
|
```
|
|
|
|
#### `DefensiveDecision` - Defensive Strategy
|
|
Strategic decisions made by the fielding team.
|
|
|
|
**Fields**:
|
|
- `alignment` (str): normal, shifted_left, shifted_right, extreme_shift
|
|
- `infield_depth` (str): infield_in, normal, corners_in
|
|
- `outfield_depth` (str): in, normal
|
|
- `hold_runners` (List[int]): Bases to hold runners on (e.g., [1, 3])
|
|
|
|
**Impact**: Affects double play chances, hit probabilities, runner advancement.
|
|
|
|
#### `OffensiveDecision` - Offensive Strategy
|
|
Strategic decisions made by the batting team.
|
|
|
|
**Fields**:
|
|
- `approach` (str): normal, contact, power, patient
|
|
- `steal_attempts` (List[int]): Bases to steal (2, 3, or 4 for home)
|
|
- `hit_and_run` (bool): Attempt hit-and-run
|
|
- `bunt_attempt` (bool): Attempt bunt
|
|
|
|
**Impact**: Affects outcome probabilities, baserunner actions.
|
|
|
|
#### `ManualOutcomeSubmission` - Manual Play Outcome
|
|
Model for human players submitting outcomes after reading physical cards.
|
|
|
|
**Fields**:
|
|
- `outcome` (PlayOutcome): The outcome from the card
|
|
- `hit_location` (Optional[str]): Position where ball was hit (for groundballs/flyballs)
|
|
|
|
**Validators**:
|
|
- `hit_location` must be valid position if provided
|
|
- Validation that location is required for certain outcomes happens in handler
|
|
|
|
**Usage**:
|
|
```python
|
|
# Player reads card, submits outcome
|
|
submission = ManualOutcomeSubmission(
|
|
outcome=PlayOutcome.GROUNDBALL_C,
|
|
hit_location='SS'
|
|
)
|
|
```
|
|
|
|
**Design Patterns**:
|
|
- All models use Pydantic v2 with `field_validator` decorators
|
|
- Extensive validation ensures data integrity
|
|
- Helper methods reduce duplicate logic in game engine
|
|
- Immutable by default (use `.model_copy()` to modify)
|
|
|
|
---
|
|
|
|
### `player_models.py` - Polymorphic Player System
|
|
|
|
**Purpose**: League-agnostic player models supporting both SBA (simple) and PD (complex) leagues.
|
|
|
|
**Architecture**:
|
|
```
|
|
BasePlayer (Abstract)
|
|
├── SbaPlayer (Simple)
|
|
└── PdPlayer (Complex with scouting)
|
|
```
|
|
|
|
**Key Models**:
|
|
|
|
#### `BasePlayer` - Abstract Base Class
|
|
Abstract interface ensuring consistent player API across leagues.
|
|
|
|
**Required Abstract Methods**:
|
|
- `get_positions() -> List[str]`: All positions player can play
|
|
- `get_display_name() -> str`: Formatted name for UI
|
|
|
|
**Common Fields**:
|
|
- **Identity**: `id` (Player ID for SBA, Card ID for PD), `name`
|
|
- **Images**: `image` (primary card), `image2` (alt card), `headshot` (league default), `vanity_card` (custom upload)
|
|
- **Positions**: `pos_1` through `pos_8` (up to 8 positions)
|
|
|
|
**Image Priority**:
|
|
```python
|
|
def get_player_image_url(self) -> str:
|
|
return self.vanity_card or self.headshot or ""
|
|
```
|
|
|
|
#### `SbaPlayer` - Simple Player Model
|
|
Minimal data needed for SBA league gameplay.
|
|
|
|
**SBA-Specific Fields**:
|
|
- `wara` (float): Wins Above Replacement Average
|
|
- `team_id`, `team_name`, `season`: Current team info
|
|
- `strat_code`, `bbref_id`, `injury_rating`: Reference IDs
|
|
|
|
**Factory Method**:
|
|
```python
|
|
# Create from API response
|
|
player = SbaPlayer.from_api_response(api_data)
|
|
|
|
# Use abstract interface
|
|
positions = player.get_positions() # ['RF', 'CF', 'LF']
|
|
name = player.get_display_name() # 'Ronald Acuna Jr'
|
|
```
|
|
|
|
**Image Methods**:
|
|
```python
|
|
# Get appropriate card for role
|
|
pitching_card = player.get_pitching_card_url() # Uses image2 if two-way player
|
|
batting_card = player.get_batting_card_url() # Uses image for position players
|
|
```
|
|
|
|
#### `PdPlayer` - Complex Player Model
|
|
Detailed scouting data for PD league simulation.
|
|
|
|
**PD-Specific Fields**:
|
|
- **Card Info**: `cost`, `cardset`, `rarity`, `set_num`, `quantity`, `description`
|
|
- **Team**: `mlbclub`, `franchise`
|
|
- **References**: `strat_code`, `bbref_id`, `fangr_id`
|
|
- **Scouting**: `batting_card`, `pitching_card` (optional, loaded separately)
|
|
|
|
**Scouting Data Structure**:
|
|
```python
|
|
# Batting Card (from /api/v2/battingcardratings/player/:id)
|
|
batting_card: PdBattingCard
|
|
- Base running: steal_low, steal_high, steal_auto, steal_jump, running
|
|
- Skills: bunting, hit_and_run (A/B/C/D ratings)
|
|
- hand (L/R), offense_col (1 or 2)
|
|
- ratings: Dict[str, PdBattingRating]
|
|
- 'L': vs Left-handed pitchers
|
|
- 'R': vs Right-handed pitchers
|
|
|
|
# Pitching Card (from /api/v2/pitchingcardratings/player/:id)
|
|
pitching_card: PdPitchingCard
|
|
- Control: balk, wild_pitch, hold
|
|
- Roles: starter_rating, relief_rating, closer_rating
|
|
- hand (L/R), offense_col (1 or 2)
|
|
- ratings: Dict[str, PdPitchingRating]
|
|
- 'L': vs Left-handed batters
|
|
- 'R': vs Right-handed batters
|
|
```
|
|
|
|
**PdBattingRating** (per handedness matchup):
|
|
- **Hit Location**: `pull_rate`, `center_rate`, `slap_rate`
|
|
- **Outcomes**: `homerun`, `triple`, `double_three`, `double_two`, `single_*`, `walk`, `strikeout`, `lineout`, `popout`, `flyout_*`, `groundout_*`
|
|
- **Summary**: `avg`, `obp`, `slg`
|
|
|
|
**PdPitchingRating** (per handedness matchup):
|
|
- **Outcomes**: `homerun`, `triple`, `double_*`, `single_*`, `walk`, `strikeout`, `flyout_*`, `groundout_*`
|
|
- **X-checks** (defensive probabilities): `xcheck_p`, `xcheck_c`, `xcheck_1b`, `xcheck_2b`, `xcheck_3b`, `xcheck_ss`, `xcheck_lf`, `xcheck_cf`, `xcheck_rf`
|
|
- **Summary**: `avg`, `obp`, `slg`
|
|
|
|
**Factory Method**:
|
|
```python
|
|
# Create with optional scouting data
|
|
player = PdPlayer.from_api_response(
|
|
player_data=player_api_response,
|
|
batting_data=batting_api_response, # Optional
|
|
pitching_data=pitching_api_response # Optional
|
|
)
|
|
|
|
# Get ratings for specific matchup
|
|
rating_vs_lhp = player.get_batting_rating('L')
|
|
if rating_vs_lhp:
|
|
print(f"HR rate vs LHP: {rating_vs_lhp.homerun}%")
|
|
print(f"OBP: {rating_vs_lhp.obp}")
|
|
|
|
rating_vs_rhb = player.get_pitching_rating('R')
|
|
if rating_vs_rhb:
|
|
print(f"X-check SS: {rating_vs_rhb.xcheck_ss}%")
|
|
```
|
|
|
|
**Design Patterns**:
|
|
- Abstract base class enforces consistent interface
|
|
- Factory methods for easy API parsing
|
|
- Optional scouting data (can load player without ratings)
|
|
- Type-safe with full Pydantic validation
|
|
|
|
---
|
|
|
|
### `db_models.py` - SQLAlchemy Database Models
|
|
|
|
**Purpose**: ORM models for PostgreSQL persistence, relationships, and audit trail.
|
|
|
|
**Key Models**:
|
|
|
|
#### `Game` - Primary Game Container
|
|
Central game record with state tracking.
|
|
|
|
**Key Fields**:
|
|
- **Identity**: `id` (UUID), `league_id` ('sba' or 'pd')
|
|
- **Teams**: `home_team_id`, `away_team_id`
|
|
- **State**: `status` (pending, active, paused, completed), `game_mode`, `visibility`
|
|
- **Current**: `current_inning`, `current_half`, `home_score`, `away_score`
|
|
- **AI**: `home_team_is_ai`, `away_team_is_ai`, `ai_difficulty`
|
|
- **Timestamps**: `created_at`, `started_at`, `completed_at`
|
|
- **Results**: `winner_team_id`, `game_metadata` (JSON)
|
|
|
|
**Relationships**:
|
|
- `plays`: All plays (cascade delete)
|
|
- `lineups`: All lineup entries (cascade delete)
|
|
- `cardset_links`: PD cardsets (cascade delete)
|
|
- `roster_links`: Roster tracking (cascade delete)
|
|
- `session`: WebSocket session (cascade delete)
|
|
- `rolls`: Dice roll history (cascade delete)
|
|
|
|
#### `Play` - Individual At-Bat Record
|
|
Records every play with full statistics and game context.
|
|
|
|
**Game State Snapshot**:
|
|
- `play_number`, `inning`, `half`, `outs_before`, `batting_order`
|
|
- `away_score`, `home_score`: Score at play start
|
|
- `on_base_code` (Integer): Bit field for efficient queries (1=1st, 2=2nd, 4=3rd, 7=loaded)
|
|
|
|
**Player References** (FKs to Lineup):
|
|
- Required: `batter_id`, `pitcher_id`, `catcher_id`
|
|
- Optional: `defender_id`, `runner_id`
|
|
- Base runners: `on_first_id`, `on_second_id`, `on_third_id`
|
|
|
|
**Runner Outcomes**:
|
|
- `on_first_final`, `on_second_final`, `on_third_final`: Final base (None = out, 1-4 = base)
|
|
- `batter_final`: Where batter ended up
|
|
|
|
**Strategic Decisions**:
|
|
- `defensive_choices` (JSON): Alignment, holds, shifts
|
|
- `offensive_choices` (JSON): Steal attempts, bunts, hit-and-run
|
|
|
|
**Play Result**:
|
|
- `dice_roll`, `hit_type`, `result_description`
|
|
- `outs_recorded`, `runs_scored`
|
|
- `check_pos`, `error`
|
|
|
|
**Statistics** (25+ fields):
|
|
- Batting: `pa`, `ab`, `hit`, `double`, `triple`, `homerun`, `bb`, `so`, `hbp`, `rbi`, `sac`, `ibb`, `gidp`
|
|
- Baserunning: `sb`, `cs`
|
|
- Pitching events: `wild_pitch`, `passed_ball`, `pick_off`, `balk`
|
|
- Ballpark: `bphr`, `bpfo`, `bp1b`, `bplo`
|
|
- Advanced: `wpa`, `re24`
|
|
- Earned runs: `run`, `e_run`
|
|
|
|
**Game Situation**:
|
|
- `is_tied`, `is_go_ahead`, `is_new_inning`, `in_pow`
|
|
|
|
**Workflow**:
|
|
- `complete`, `locked`: Play state flags
|
|
- `play_metadata` (JSON): Extensibility
|
|
|
|
**Helper Properties**:
|
|
```python
|
|
@property
|
|
def ai_is_batting(self) -> bool:
|
|
"""True if batting team is AI-controlled"""
|
|
return (self.half == 'top' and self.game.away_team_is_ai) or \
|
|
(self.half == 'bot' and self.game.home_team_is_ai)
|
|
|
|
@property
|
|
def ai_is_fielding(self) -> bool:
|
|
"""True if fielding team is AI-controlled"""
|
|
return not self.ai_is_batting
|
|
```
|
|
|
|
#### `Lineup` - Player Assignment & Substitutions
|
|
Tracks player assignments in a game.
|
|
|
|
**Polymorphic Design**: Single table for both leagues.
|
|
|
|
**Fields**:
|
|
- `game_id`, `team_id`
|
|
- `card_id` (PD) / `player_id` (SBA): Exactly one must be populated
|
|
- `position`, `batting_order`
|
|
- **Substitution**: `is_starter`, `is_active`, `entered_inning`, `replacing_id`, `after_play`
|
|
- **Pitcher**: `is_fatigued`
|
|
- `lineup_metadata` (JSON): Extensibility
|
|
|
|
**Constraints**:
|
|
- XOR CHECK: Exactly one of `card_id` or `player_id` must be populated
|
|
- `(card_id IS NOT NULL)::int + (player_id IS NOT NULL)::int = 1`
|
|
|
|
#### `RosterLink` - Eligible Cards/Players
|
|
Tracks which cards (PD) or players (SBA) are eligible for a game.
|
|
|
|
**Polymorphic Design**: Single table supporting both leagues.
|
|
|
|
**Fields**:
|
|
- `id` (auto-increment surrogate key)
|
|
- `game_id`, `team_id`
|
|
- `card_id` (PD) / `player_id` (SBA): Exactly one must be populated
|
|
|
|
**Constraints**:
|
|
- XOR CHECK: Exactly one ID must be populated
|
|
- UNIQUE on (game_id, card_id) for PD
|
|
- UNIQUE on (game_id, player_id) for SBA
|
|
|
|
**Usage Pattern**:
|
|
```python
|
|
# PD league - add card to roster
|
|
roster = await db_ops.add_pd_roster_card(game_id, card_id=123, team_id=1)
|
|
|
|
# SBA league - add player to roster
|
|
roster = await db_ops.add_sba_roster_player(game_id, player_id=456, team_id=2)
|
|
```
|
|
|
|
#### `GameCardsetLink` - PD Cardset Restrictions
|
|
PD league only - defines legal cardsets for a game.
|
|
|
|
**Fields**:
|
|
- `game_id`, `cardset_id`: Composite primary key
|
|
- `priority` (Integer): 1 = primary, 2+ = backup
|
|
|
|
**Usage**:
|
|
- SBA games: Empty (no cardset restrictions)
|
|
- PD games: Required (validates card eligibility)
|
|
|
|
#### `GameSession` - WebSocket State
|
|
Real-time WebSocket state tracking.
|
|
|
|
**Fields**:
|
|
- `game_id` (UUID): One-to-one with Game
|
|
- `connected_users` (JSON): Active connections
|
|
- `last_action_at` (DateTime): Last activity
|
|
- `state_snapshot` (JSON): In-memory state cache
|
|
|
|
#### `Roll` - Dice Roll History
|
|
Auditing and analytics for all dice rolls.
|
|
|
|
**Fields**:
|
|
- `roll_id` (String): Primary key
|
|
- `game_id`, `roll_type`, `league_id`
|
|
- `team_id`, `player_id`: For analytics
|
|
- `roll_data` (JSONB): Complete roll with all dice values
|
|
- `context` (JSONB): Pitcher, inning, outs, etc.
|
|
- `timestamp`, `created_at`
|
|
|
|
**Design Patterns**:
|
|
- All DateTime fields use Pendulum via `default=lambda: pendulum.now('UTC').naive()`
|
|
- CASCADE DELETE: Deleting game removes all related records
|
|
- Relationships use `lazy="joined"` for common queries, `lazy="select"` (default) for rare
|
|
- Polymorphic tables use CHECK constraints for data integrity
|
|
- JSON/JSONB for extensibility
|
|
|
|
---
|
|
|
|
### `roster_models.py` - Roster Link Type Safety
|
|
|
|
**Purpose**: Pydantic models providing type-safe abstractions over the polymorphic `RosterLink` table.
|
|
|
|
**Key Models**:
|
|
|
|
#### `BaseRosterLinkData` - Abstract Base
|
|
Common interface for roster operations.
|
|
|
|
**Required Abstract Methods**:
|
|
- `get_entity_id() -> int`: Get the entity ID (card_id or player_id)
|
|
- `get_entity_type() -> str`: Get entity type ('card' or 'player')
|
|
|
|
**Common Fields**:
|
|
- `id` (Optional[int]): Database ID (populated after save)
|
|
- `game_id` (UUID)
|
|
- `team_id` (int)
|
|
|
|
#### `PdRosterLinkData` - PD League Roster
|
|
Tracks cards for PD league games.
|
|
|
|
**Fields**:
|
|
- Inherits: `id`, `game_id`, `team_id`
|
|
- `card_id` (int): PD card identifier (validated > 0)
|
|
|
|
**Methods**:
|
|
- `get_entity_id()`: Returns `card_id`
|
|
- `get_entity_type()`: Returns `"card"`
|
|
|
|
#### `SbaRosterLinkData` - SBA League Roster
|
|
Tracks players for SBA league games.
|
|
|
|
**Fields**:
|
|
- Inherits: `id`, `game_id`, `team_id`
|
|
- `player_id` (int): SBA player identifier (validated > 0)
|
|
|
|
**Methods**:
|
|
- `get_entity_id()`: Returns `player_id`
|
|
- `get_entity_type()`: Returns `"player"`
|
|
|
|
#### `RosterLinkCreate` - Request Model
|
|
API request model for creating roster links.
|
|
|
|
**Fields**:
|
|
- `game_id`, `team_id`
|
|
- `card_id` (Optional[int]): PD card
|
|
- `player_id` (Optional[int]): SBA player
|
|
|
|
**Validators**:
|
|
- `model_post_init()`: Ensures exactly one ID is provided (XOR check)
|
|
- Fails if both or neither are provided
|
|
|
|
**Conversion Methods**:
|
|
```python
|
|
request = RosterLinkCreate(game_id=game_id, team_id=1, card_id=123)
|
|
|
|
# Convert to league-specific data
|
|
pd_data = request.to_pd_data() # PdRosterLinkData
|
|
sba_data = request.to_sba_data() # Fails - card_id provided, not player_id
|
|
```
|
|
|
|
**Design Patterns**:
|
|
- Abstract base enforces consistent interface
|
|
- League-specific subclasses provide type safety
|
|
- Application-layer validation complements database constraints
|
|
- Conversion methods for easy API → model transformation
|
|
|
|
---
|
|
|
|
## Key Patterns & Conventions
|
|
|
|
### 1. Pydantic v2 Validation
|
|
|
|
All Pydantic models use v2 syntax with `field_validator` decorators:
|
|
|
|
```python
|
|
@field_validator('position')
|
|
@classmethod
|
|
def validate_position(cls, v: str) -> str:
|
|
valid_positions = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH']
|
|
if v not in valid_positions:
|
|
raise ValueError(f"Position must be one of {valid_positions}")
|
|
return v
|
|
```
|
|
|
|
**Key Points**:
|
|
- Use `@classmethod` decorator after `@field_validator`
|
|
- Type hints are required for validator methods
|
|
- Validators should raise `ValueError` with clear messages
|
|
|
|
### 2. SQLAlchemy Relationships
|
|
|
|
**Lazy Loading Strategy**:
|
|
```python
|
|
# Common queries - eager load
|
|
batter = relationship("Lineup", foreign_keys=[batter_id], lazy="joined")
|
|
|
|
# Rare queries - lazy load (default)
|
|
defender = relationship("Lineup", foreign_keys=[defender_id])
|
|
```
|
|
|
|
**Foreign Keys with Multiple References**:
|
|
```python
|
|
# When same table referenced multiple times, use foreign_keys parameter
|
|
batter = relationship("Lineup", foreign_keys=[batter_id])
|
|
pitcher = relationship("Lineup", foreign_keys=[pitcher_id])
|
|
```
|
|
|
|
### 3. Polymorphic Tables
|
|
|
|
Pattern for supporting both SBA and PD leagues in single table:
|
|
|
|
**Database Level**:
|
|
```python
|
|
# Two nullable columns
|
|
card_id = Column(Integer, nullable=True) # PD
|
|
player_id = Column(Integer, nullable=True) # SBA
|
|
|
|
# CHECK constraint ensures exactly one is populated (XOR)
|
|
CheckConstraint(
|
|
'(card_id IS NOT NULL)::int + (player_id IS NOT NULL)::int = 1',
|
|
name='one_id_required'
|
|
)
|
|
```
|
|
|
|
**Application Level**:
|
|
```python
|
|
# Pydantic models provide type safety
|
|
class PdRosterLinkData(BaseRosterLinkData):
|
|
card_id: int # Required, not nullable
|
|
|
|
class SbaRosterLinkData(BaseRosterLinkData):
|
|
player_id: int # Required, not nullable
|
|
```
|
|
|
|
### 4. DateTime Handling
|
|
|
|
**ALWAYS use Pendulum for all datetime operations:**
|
|
|
|
```python
|
|
import pendulum
|
|
|
|
# SQLAlchemy default
|
|
created_at = Column(DateTime, default=lambda: pendulum.now('UTC').naive())
|
|
|
|
# Manual creation
|
|
now = pendulum.now('UTC')
|
|
```
|
|
|
|
**Critical**: Use `.naive()` for PostgreSQL compatibility with asyncpg driver.
|
|
|
|
### 5. Factory Methods
|
|
|
|
Player models use factory methods for easy API parsing:
|
|
|
|
```python
|
|
@classmethod
|
|
def from_api_response(cls, data: Dict[str, Any]) -> "SbaPlayer":
|
|
"""Create from API response with field mapping"""
|
|
return cls(
|
|
id=data["id"],
|
|
name=data["name"],
|
|
# ... map all fields
|
|
)
|
|
```
|
|
|
|
**Benefits**:
|
|
- Encapsulates API → model transformation
|
|
- Single source of truth for field mapping
|
|
- Easy to test independently
|
|
|
|
### 6. Helper Methods on Models
|
|
|
|
Models include helper methods to reduce duplicate logic:
|
|
|
|
```python
|
|
class GameState(BaseModel):
|
|
# ... fields
|
|
|
|
def get_batting_team_id(self) -> int:
|
|
"""Get the ID of the team currently batting"""
|
|
return self.away_team_id if self.half == "top" else self.home_team_id
|
|
|
|
def bases_occupied(self) -> List[int]:
|
|
"""Get list of occupied bases"""
|
|
bases = []
|
|
if self.on_first:
|
|
bases.append(1)
|
|
if self.on_second:
|
|
bases.append(2)
|
|
if self.on_third:
|
|
bases.append(3)
|
|
return bases
|
|
```
|
|
|
|
**Benefits**:
|
|
- Encapsulates common queries
|
|
- Reduces duplication in game engine
|
|
- Easier to test
|
|
- Self-documenting code
|
|
|
|
### 7. Immutability
|
|
|
|
Pydantic models are immutable by default. To modify:
|
|
|
|
```python
|
|
# ❌ This raises an error
|
|
state.inning = 5
|
|
|
|
# ✅ Use model_copy() to create modified copy
|
|
updated_state = state.model_copy(update={'inning': 5})
|
|
|
|
# ✅ Or use StateManager which handles updates
|
|
state_manager.update_state(game_id, state)
|
|
```
|
|
|
|
---
|
|
|
|
## Integration Points
|
|
|
|
### Game Engine → Models
|
|
|
|
```python
|
|
from app.models import GameState, LineupPlayerState
|
|
|
|
# Create state
|
|
state = GameState(
|
|
game_id=game_id,
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2,
|
|
current_batter_lineup_id=10
|
|
)
|
|
|
|
# Use helper methods
|
|
batting_team = state.get_batting_team_id()
|
|
is_runner_on = state.is_runner_on_first()
|
|
```
|
|
|
|
### Database Operations → Models
|
|
|
|
```python
|
|
from app.models import Game, Play, Lineup
|
|
|
|
# Create SQLAlchemy model
|
|
game = Game(
|
|
id=uuid4(),
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2,
|
|
status="pending",
|
|
game_mode="friendly",
|
|
visibility="public"
|
|
)
|
|
|
|
# Persist
|
|
async with session.begin():
|
|
session.add(game)
|
|
```
|
|
|
|
### Models → API Responses
|
|
|
|
```python
|
|
from app.models import GameState
|
|
|
|
# Pydantic models serialize to JSON automatically
|
|
state = GameState(...)
|
|
state_json = state.model_dump() # Dict for JSON serialization
|
|
state_json = state.model_dump_json() # JSON string
|
|
```
|
|
|
|
### Player Polymorphism
|
|
|
|
```python
|
|
from app.models import BasePlayer, SbaPlayer, PdPlayer
|
|
|
|
def process_batter(batter: BasePlayer):
|
|
"""Works for both SBA and PD players"""
|
|
print(f"Batter: {batter.get_display_name()}")
|
|
print(f"Positions: {batter.get_positions()}")
|
|
|
|
# Use with any league
|
|
sba_batter = SbaPlayer(...)
|
|
pd_batter = PdPlayer(...)
|
|
process_batter(sba_batter) # Works
|
|
process_batter(pd_batter) # Works
|
|
```
|
|
|
|
---
|
|
|
|
## Common Tasks
|
|
|
|
### Adding a New Field to GameState
|
|
|
|
1. **Add field to Pydantic model** (`game_models.py`):
|
|
```python
|
|
class GameState(BaseModel):
|
|
# ... existing fields
|
|
new_field: str = "default_value"
|
|
```
|
|
|
|
2. **Add validator if needed**:
|
|
```python
|
|
@field_validator('new_field')
|
|
@classmethod
|
|
def validate_new_field(cls, v: str) -> str:
|
|
if not v:
|
|
raise ValueError("new_field cannot be empty")
|
|
return v
|
|
```
|
|
|
|
3. **Update tests** (`tests/unit/models/test_game_models.py`):
|
|
```python
|
|
def test_new_field_validation():
|
|
with pytest.raises(ValidationError):
|
|
GameState(
|
|
game_id=uuid4(),
|
|
league_id="sba",
|
|
# ... required fields
|
|
new_field="" # Invalid
|
|
)
|
|
```
|
|
|
|
4. **No database migration needed** (Pydantic models are in-memory only)
|
|
|
|
### Adding a New SQLAlchemy Model
|
|
|
|
1. **Define model** (`db_models.py`):
|
|
```python
|
|
class NewModel(Base):
|
|
__tablename__ = "new_models"
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
game_id = Column(UUID(as_uuid=True), ForeignKey("games.id"))
|
|
# ... fields
|
|
|
|
# Relationship
|
|
game = relationship("Game", back_populates="new_models")
|
|
```
|
|
|
|
2. **Add relationship to Game model**:
|
|
```python
|
|
class Game(Base):
|
|
# ... existing fields
|
|
new_models = relationship("NewModel", back_populates="game", cascade="all, delete-orphan")
|
|
```
|
|
|
|
3. **Create migration**:
|
|
```bash
|
|
alembic revision --autogenerate -m "Add new_models table"
|
|
alembic upgrade head
|
|
```
|
|
|
|
4. **Export from `__init__.py`**:
|
|
```python
|
|
from app.models.db_models import NewModel
|
|
|
|
__all__ = [
|
|
# ... existing exports
|
|
"NewModel",
|
|
]
|
|
```
|
|
|
|
### Adding a New Player Field
|
|
|
|
1. **Update BasePlayer if common** (`player_models.py`):
|
|
```python
|
|
class BasePlayer(BaseModel, ABC):
|
|
# ... existing fields
|
|
new_common_field: Optional[str] = None
|
|
```
|
|
|
|
2. **Or update league-specific class**:
|
|
```python
|
|
class SbaPlayer(BasePlayer):
|
|
# ... existing fields
|
|
new_sba_field: Optional[int] = None
|
|
```
|
|
|
|
3. **Update factory method**:
|
|
```python
|
|
@classmethod
|
|
def from_api_response(cls, data: Dict[str, Any]) -> "SbaPlayer":
|
|
return cls(
|
|
# ... existing fields
|
|
new_sba_field=data.get("new_sba_field"),
|
|
)
|
|
```
|
|
|
|
4. **Update tests** (`tests/unit/models/test_player_models.py`)
|
|
|
|
### Creating a New Pydantic Model
|
|
|
|
1. **Define in appropriate file**:
|
|
```python
|
|
class NewModel(BaseModel):
|
|
"""Purpose of this model"""
|
|
field1: str
|
|
field2: int = 0
|
|
|
|
@field_validator('field1')
|
|
@classmethod
|
|
def validate_field1(cls, v: str) -> str:
|
|
if len(v) < 3:
|
|
raise ValueError("field1 must be at least 3 characters")
|
|
return v
|
|
```
|
|
|
|
2. **Export from `__init__.py`**:
|
|
```python
|
|
from app.models.game_models import NewModel
|
|
|
|
__all__ = [
|
|
# ... existing exports
|
|
"NewModel",
|
|
]
|
|
```
|
|
|
|
3. **Add tests**
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### ValidationError: Field required
|
|
|
|
**Problem**: Missing required field when creating model.
|
|
|
|
**Solution**: Check field definition - remove `Optional` or provide default:
|
|
```python
|
|
# Required field (no default)
|
|
name: str
|
|
|
|
# Optional field
|
|
name: Optional[str] = None
|
|
|
|
# Field with default
|
|
name: str = "default"
|
|
```
|
|
|
|
### ValidationError: Field value validation failed
|
|
|
|
**Problem**: Field validator rejected value.
|
|
|
|
**Solution**: Check validator logic and error message:
|
|
```python
|
|
@field_validator('position')
|
|
@classmethod
|
|
def validate_position(cls, v: str) -> str:
|
|
valid = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH']
|
|
if v not in valid:
|
|
raise ValueError(f"Position must be one of {valid}") # Clear message
|
|
return v
|
|
```
|
|
|
|
### SQLAlchemy: Cannot access attribute before flush
|
|
|
|
**Problem**: Trying to access auto-generated ID before commit.
|
|
|
|
**Solution**: Commit first or use `session.flush()`:
|
|
```python
|
|
async with session.begin():
|
|
session.add(game)
|
|
await session.flush() # Generates ID without committing
|
|
game_id = game.id # Now accessible
|
|
```
|
|
|
|
### IntegrityError: violates check constraint
|
|
|
|
**Problem**: Polymorphic table constraint violated (both IDs populated or neither).
|
|
|
|
**Solution**: Use Pydantic models to enforce XOR at application level:
|
|
```python
|
|
# ❌ Don't create RosterLink directly
|
|
roster = RosterLink(game_id=game_id, team_id=1, card_id=123, player_id=456) # Both IDs
|
|
|
|
# ✅ Use league-specific Pydantic models
|
|
pd_roster = PdRosterLinkData(game_id=game_id, team_id=1, card_id=123)
|
|
```
|
|
|
|
### Type Error: Column vs Actual Value
|
|
|
|
**Problem**: SQLAlchemy model attributes typed as `Column[int]` but are `int` at runtime.
|
|
|
|
**Solution**: Use targeted type ignore comments:
|
|
```python
|
|
# SQLAlchemy ORM magic: .id is Column[int] for type checker, int at runtime
|
|
state.current_batter_lineup_id = lineup_player.id # type: ignore[assignment]
|
|
```
|
|
|
|
**When to use**:
|
|
- Assigning SQLAlchemy model attributes to Pydantic fields
|
|
- Common in game_engine.py when bridging ORM and Pydantic
|
|
|
|
**When NOT to use**:
|
|
- Pure Pydantic → Pydantic operations (should type check cleanly)
|
|
- New code (only for SQLAlchemy ↔ Pydantic bridging)
|
|
|
|
### AttributeError: 'GameState' object has no attribute
|
|
|
|
**Problem**: Trying to access field that doesn't exist or was renamed.
|
|
|
|
**Solution**:
|
|
1. Check model definition
|
|
2. If field was renamed, update all references
|
|
3. Use IDE autocomplete to avoid typos
|
|
|
|
Example:
|
|
```python
|
|
# ❌ Old field name
|
|
state.runners # AttributeError - removed in refactor
|
|
|
|
# ✅ New API
|
|
state.get_all_runners() # Returns list of (base, player) tuples
|
|
```
|
|
|
|
---
|
|
|
|
## Testing Models
|
|
|
|
### Unit Test Structure
|
|
|
|
Each model file has corresponding test file:
|
|
```
|
|
tests/unit/models/
|
|
├── test_game_models.py # GameState, LineupPlayerState, etc.
|
|
├── test_player_models.py # BasePlayer, SbaPlayer, PdPlayer
|
|
└── test_roster_models.py # RosterLink Pydantic models
|
|
```
|
|
|
|
### Test Patterns
|
|
|
|
**Valid Model Creation**:
|
|
```python
|
|
def test_create_game_state():
|
|
state = GameState(
|
|
game_id=uuid4(),
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2,
|
|
current_batter_lineup_id=10
|
|
)
|
|
assert state.league_id == "sba"
|
|
assert state.inning == 1
|
|
```
|
|
|
|
**Validation Errors**:
|
|
```python
|
|
def test_invalid_league_id():
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
GameState(
|
|
game_id=uuid4(),
|
|
league_id="invalid", # Not 'sba' or 'pd'
|
|
home_team_id=1,
|
|
away_team_id=2,
|
|
current_batter_lineup_id=10
|
|
)
|
|
assert "league_id must be one of ['sba', 'pd']" in str(exc_info.value)
|
|
```
|
|
|
|
**Helper Methods**:
|
|
```python
|
|
def test_advance_runner_scoring():
|
|
state = GameState(...)
|
|
runner = LineupPlayerState(lineup_id=1, card_id=101, position="CF")
|
|
state.add_runner(runner, base=3)
|
|
|
|
state.advance_runner(from_base=3, to_base=4)
|
|
|
|
assert state.on_third is None
|
|
assert state.home_score == 1 or state.away_score == 1 # Depends on half
|
|
```
|
|
|
|
### Running Model Tests
|
|
|
|
```bash
|
|
# All model tests
|
|
uv run pytest tests/unit/models/ -v
|
|
|
|
# Specific file
|
|
uv run pytest tests/unit/models/test_game_models.py -v
|
|
|
|
# Specific test
|
|
uv run pytest tests/unit/models/test_game_models.py::test_advance_runner_scoring -v
|
|
|
|
# With coverage
|
|
uv run pytest tests/unit/models/ --cov=app.models --cov-report=html
|
|
```
|
|
|
|
---
|
|
|
|
## Design Rationale
|
|
|
|
### Why Separate Pydantic and SQLAlchemy Models?
|
|
|
|
**Pydantic Models** (`game_models.py`, `player_models.py`, `roster_models.py`):
|
|
- Fast in-memory operations (no ORM overhead)
|
|
- WebSocket serialization (automatic JSON conversion)
|
|
- Validation and type safety
|
|
- Immutable by default
|
|
- Helper methods for game logic
|
|
|
|
**SQLAlchemy Models** (`db_models.py`):
|
|
- Database persistence
|
|
- Complex relationships
|
|
- Audit trail and history
|
|
- Transaction management
|
|
- Query optimization
|
|
|
|
**Tradeoff**: Some duplication, but optimized for different use cases.
|
|
|
|
### Why Direct Base References Instead of List?
|
|
|
|
**Before**: `runners: List[RunnerState]`
|
|
**After**: `on_first`, `on_second`, `on_third`
|
|
|
|
**Reasons**:
|
|
1. Matches database structure exactly (`Play` has `on_first_id`, `on_second_id`, `on_third_id`)
|
|
2. Simpler state management (direct assignment vs list operations)
|
|
3. Type safety (LineupPlayerState vs generic runner)
|
|
4. Easier to work with in game engine
|
|
5. No list management overhead
|
|
|
|
### Why Polymorphic Tables Instead of League-Specific?
|
|
|
|
**Single polymorphic table** (`RosterLink`, `Lineup`) instead of separate `PdRosterLink`/`SbaRosterLink`:
|
|
|
|
**Advantages**:
|
|
- Simpler schema (fewer tables)
|
|
- Easier queries (no UNIONs needed)
|
|
- Single code path for common operations
|
|
- Foreign key relationships work naturally
|
|
|
|
**Type Safety**: Pydantic models (`PdRosterLinkData`, `SbaRosterLinkData`) provide application-layer safety.
|
|
|
|
### Why Factory Methods for Player Models?
|
|
|
|
API responses have inconsistent field names and nested structures. Factory methods:
|
|
1. Encapsulate field mapping logic
|
|
2. Handle nested data (team info, cardsets)
|
|
3. Provide single source of truth
|
|
4. Easy to test independently
|
|
5. Future-proof for API changes
|
|
|
|
---
|
|
|
|
## Examples
|
|
|
|
### Complete Game State Management
|
|
|
|
```python
|
|
from app.models import GameState, LineupPlayerState
|
|
from uuid import uuid4
|
|
|
|
# Create game
|
|
state = GameState(
|
|
game_id=uuid4(),
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2,
|
|
current_batter_lineup_id=10
|
|
)
|
|
|
|
# Add runners
|
|
runner1 = LineupPlayerState(lineup_id=5, card_id=101, position="CF", batting_order=2)
|
|
runner2 = LineupPlayerState(lineup_id=8, card_id=104, position="SS", batting_order=5)
|
|
state.add_runner(runner1, base=1)
|
|
state.add_runner(runner2, base=2)
|
|
|
|
# Check state
|
|
print(f"Runners on base: {state.bases_occupied()}") # [1, 2]
|
|
print(f"Is runner on first: {state.is_runner_on_first()}") # True
|
|
|
|
# Advance runners (single to right field)
|
|
state.advance_runner(from_base=2, to_base=4) # Runner scores from 2nd
|
|
state.advance_runner(from_base=1, to_base=3) # Runner to 3rd from 1st
|
|
|
|
# Batter to first
|
|
batter = LineupPlayerState(lineup_id=10, card_id=107, position="RF", batting_order=7)
|
|
state.add_runner(batter, base=1)
|
|
|
|
# Check updated state
|
|
print(f"Score: {state.away_score}-{state.home_score}") # 1-0 if top of inning
|
|
print(f"Runners: {state.bases_occupied()}") # [1, 3]
|
|
|
|
# Record out
|
|
half_over = state.increment_outs() # False (1 out)
|
|
half_over = state.increment_outs() # False (2 outs)
|
|
half_over = state.increment_outs() # True (3 outs)
|
|
|
|
# End half inning
|
|
if half_over:
|
|
state.end_half_inning()
|
|
print(f"Now: Inning {state.inning}, {state.half}") # Inning 1, bottom
|
|
print(f"Runners: {state.bases_occupied()}") # [] (cleared)
|
|
```
|
|
|
|
### Player Model Polymorphism
|
|
|
|
```python
|
|
from app.models import BasePlayer, SbaPlayer, PdPlayer
|
|
|
|
def display_player_card(player: BasePlayer):
|
|
"""Works for both SBA and PD players"""
|
|
print(f"Name: {player.get_display_name()}")
|
|
print(f"Positions: {', '.join(player.get_positions())}")
|
|
print(f"Image: {player.get_player_image_url()}")
|
|
|
|
# League-specific logic
|
|
if isinstance(player, PdPlayer):
|
|
rating = player.get_batting_rating('L')
|
|
if rating:
|
|
print(f"Batting vs LHP: {rating.avg:.3f}")
|
|
elif isinstance(player, SbaPlayer):
|
|
print(f"WARA: {player.wara}")
|
|
|
|
# Use with SBA player
|
|
sba_player = SbaPlayer.from_api_response(sba_api_data)
|
|
display_player_card(sba_player)
|
|
|
|
# Use with PD player
|
|
pd_player = PdPlayer.from_api_response(
|
|
player_data=pd_api_data,
|
|
batting_data=batting_api_data
|
|
)
|
|
display_player_card(pd_player)
|
|
```
|
|
|
|
### Database Operations with Models
|
|
|
|
```python
|
|
from app.models import Game, Play, Lineup
|
|
from app.database.session import get_session
|
|
from sqlalchemy import select
|
|
import pendulum
|
|
import uuid
|
|
|
|
async def create_game_with_lineups():
|
|
game_id = uuid.uuid4()
|
|
|
|
async with get_session() as session:
|
|
# Create game
|
|
game = Game(
|
|
id=game_id,
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2,
|
|
status="active",
|
|
game_mode="friendly",
|
|
visibility="public",
|
|
created_at=pendulum.now('UTC').naive()
|
|
)
|
|
session.add(game)
|
|
|
|
# Add lineup entries
|
|
lineup_entries = [
|
|
Lineup(game_id=game_id, team_id=1, player_id=101, position="P", batting_order=9),
|
|
Lineup(game_id=game_id, team_id=1, player_id=102, position="C", batting_order=2),
|
|
Lineup(game_id=game_id, team_id=1, player_id=103, position="1B", batting_order=3),
|
|
# ... more players
|
|
]
|
|
session.add_all(lineup_entries)
|
|
|
|
await session.commit()
|
|
|
|
return game_id
|
|
|
|
async def get_active_pitcher(game_id: uuid.UUID, team_id: int):
|
|
async with get_session() as session:
|
|
result = await session.execute(
|
|
select(Lineup)
|
|
.where(
|
|
Lineup.game_id == game_id,
|
|
Lineup.team_id == team_id,
|
|
Lineup.position == 'P',
|
|
Lineup.is_active == True
|
|
)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
```
|
|
|
|
---
|
|
|
|
## Related Documentation
|
|
|
|
- **Backend CLAUDE.md**: `../CLAUDE.md` - Overall backend architecture
|
|
- **Database Operations**: `../database/CLAUDE.md` - Database layer patterns
|
|
- **Game Engine**: `../core/CLAUDE.md` - Game logic using these models
|
|
- **Player Data Catalog**: `../../../.claude/implementation/player-data-catalog.md` - API response examples
|
|
|
|
---
|
|
|
|
**Last Updated**: 2025-10-31
|
|
**Status**: Complete and production-ready
|
|
**Test Coverage**: 110+ tests across all model files
|