# 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
