Updated CLAUDE.md files to document recent changes including the GameState
refactoring and new X-Check testing capabilities in the terminal client.
Changes to app/models/CLAUDE.md:
- Updated GameState field documentation
- Replaced current_batter_lineup_id references with current_batter
- Documented LineupPlayerState requirement for current players
- Added comprehensive usage examples
- Added "Recent Updates" section documenting GameState refactoring
- Before/after code examples showing migration path
- Explanation of why the change was made
- Migration notes for developers
- List of all affected files (7 files updated)
Changes to terminal_client/CLAUDE.md:
- Added "2025-11-04: X-Check Testing & GameState Refactoring" section
- New feature: resolve_with x-check <position> command
- Complete X-Check resolution with defense tables and error charts
- Shows all resolution steps with audit trail
- Works with actual player ratings from PD API
- Documented 8 X-Check commands now in help system
- roll_jump / test_jump, roll_fielding / test_fielding
- test_location, rollback, force_wild_pitch, force_passed_ball
- Bug fixes documented
- GameState structure updates (display.py, repl.py)
- Game recovery fix (state_manager.py)
- DO3 advancement fix (play_resolver.py)
- Complete testing workflow examples
- List of 7 files updated
- Test coverage status (all passing)
- Updated footer: Last Updated 2025-11-04
These documentation updates provide clear migration guides for the GameState
refactoring and comprehensive examples for the new X-Check testing features.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
1340 lines
38 KiB
Markdown
1340 lines
38 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)
|
|
- **Current Players**: `current_batter` (LineupPlayerState, required), `current_pitcher` (Optional[LineupPlayerState]), `current_catcher` (Optional[LineupPlayerState]), `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 current batter (required field)
|
|
current_batter = LineupPlayerState(
|
|
lineup_id=10,
|
|
card_id=100,
|
|
position="CF",
|
|
batting_order=1
|
|
)
|
|
|
|
# Create game state
|
|
state = GameState(
|
|
game_id=uuid4(),
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2,
|
|
current_batter=current_batter # Required LineupPlayerState object
|
|
)
|
|
|
|
# Add runner
|
|
runner = LineupPlayerState(lineup_id=5, card_id=123, position="SS", 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
|
|
|
|
# Access current player info
|
|
print(f"Current batter: {state.current_batter.lineup_id}")
|
|
if state.current_pitcher:
|
|
print(f"Current pitcher: {state.current_pitcher.lineup_id}")
|
|
|
|
# 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
|
|
```
|
|
|
|
---
|
|
|
|
## Recent Updates
|
|
|
|
### GameState Refactoring (2025-11-04)
|
|
|
|
**Changed**: `current_batter`, `current_pitcher`, and `current_catcher` fields
|
|
|
|
**Before** (Old Structure):
|
|
```python
|
|
state = GameState(
|
|
game_id=uuid4(),
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2,
|
|
current_batter_lineup_id=10 # Integer ID
|
|
)
|
|
# Access: state.current_batter_lineup_id
|
|
```
|
|
|
|
**After** (New Structure):
|
|
```python
|
|
current_batter = LineupPlayerState(lineup_id=10, card_id=100, position="CF", batting_order=1)
|
|
|
|
state = GameState(
|
|
game_id=uuid4(),
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2,
|
|
current_batter=current_batter # LineupPlayerState object (required)
|
|
)
|
|
# Access: state.current_batter.lineup_id
|
|
```
|
|
|
|
**Why**:
|
|
- More type-safe (full player context instead of just ID)
|
|
- Enables position rating access during X-Check resolution
|
|
- Matches pattern used for `on_first`, `on_second`, `on_third` runners
|
|
- Simplifies game engine logic
|
|
|
|
**Migration Notes**:
|
|
- `current_batter` is **required** (not Optional)
|
|
- `current_pitcher` and `current_catcher` are **Optional[LineupPlayerState]**
|
|
- All code accessing `state.current_batter_lineup_id` must change to `state.current_batter.lineup_id`
|
|
- Game recovery creates placeholder `current_batter` from first active batter in lineup
|
|
- `_prepare_next_play()` sets correct current players after recovery
|
|
|
|
**Affected Files**:
|
|
- ✅ `app/models/game_models.py` - Model definition updated
|
|
- ✅ `app/core/game_engine.py` - Uses new structure throughout
|
|
- ✅ `app/core/state_manager.py` - Recovery creates placeholders
|
|
- ✅ `app/core/runner_advancement.py` - All 17 references updated
|
|
- ✅ `terminal_client/display.py` - Display updated
|
|
- ✅ `terminal_client/repl.py` - Commands updated
|
|
- ✅ All test files - Fixtures updated to create LineupPlayerState objects
|
|
|
|
---
|
|
|
|
## 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
|