# Backend - Paper Dynasty Game Engine

## Overview

FastAPI-based real-time game backend handling WebSocket communication, game state management, and database persistence for both SBA and PD leagues.

## Technology Stack

- **Framework**: FastAPI (Python 3.13)
- **WebSocket**: Socket.io (python-socketio)
- **Database**: PostgreSQL 14+ with SQLAlchemy 2.0 (async)
- **ORM**: SQLAlchemy with asyncpg driver
- **Validation**: Pydantic v2
- **DateTime**: Pendulum 3.0 (replaces Python's datetime module)
- **Testing**: pytest with pytest-asyncio
- **Code Quality**: black, flake8, mypy

## Project Structure

```
backend/
├── app/
│   ├── main.py                 # FastAPI app + Socket.io initialization
│   ├── config.py               # Settings with pydantic-settings
│   │
│   ├── core/                   # Game logic (Phase 2+)
│   │   ├── game_engine.py      # Main game simulation
│   │   ├── state_manager.py    # In-memory state
│   │   ├── play_resolver.py    # Play outcome resolution
│   │   ├── dice.py             # Secure random rolls
│   │   └── validators.py       # Rule validation
│   │
│   ├── config/                 # League configurations (Phase 2+)
│   │   ├── base_config.py      # Shared configuration
│   │   ├── league_configs.py   # SBA/PD specific
│   │   └── result_charts.py    # d20 outcome tables
│   │
│   ├── models/                 # Data models
│   │   ├── db_models.py        # SQLAlchemy ORM models
│   │   ├── game_models.py      # Pydantic game state models (Phase 2+)
│   │   └── player_models.py    # Polymorphic player models (Phase 2+)
│   │
│   ├── websocket/              # WebSocket handling
│   │   ├── connection_manager.py  # Connection lifecycle
│   │   ├── handlers.py            # Event handlers
│   │   └── events.py              # Event definitions (Phase 2+)
│   │
│   ├── api/                    # REST API
│   │   ├── routes/
│   │   │   ├── health.py       # Health check endpoints
│   │   │   ├── auth.py         # Discord OAuth (Phase 1)
│   │   │   └── games.py        # Game CRUD (Phase 2+)
│   │   └── dependencies.py     # FastAPI dependencies
│   │
│   ├── database/               # Database layer
│   │   ├── session.py          # Async session management
│   │   └── operations.py       # DB operations (Phase 2+)
│   │
│   ├── data/                   # External data (Phase 2+)
│   │   ├── api_client.py       # League REST API client
│   │   └── cache.py            # Caching layer
│   │
│   └── utils/                  # Utilities
│       ├── logging.py          # Logging setup
│       └── auth.py             # JWT utilities (Phase 1)
│
├── tests/
│   ├── unit/                   # Unit tests
│   ├── integration/            # Integration tests
│   └── e2e/                    # End-to-end tests
│
├── terminal_client/            # Interactive testing REPL
│   ├── __init__.py             # Package marker
│   ├── __main__.py             # Entry point
│   ├── repl.py                 # Interactive REPL (cmd module)
│   ├── main.py                 # Click CLI commands
│   ├── display.py              # Rich formatting
│   ├── config.py               # Persistent config file
│   └── CLAUDE.md               # Terminal client docs
│
├── logs/                       # Application logs (gitignored)
├── venv/                       # Virtual environment (gitignored)
├── .env                        # Environment variables (gitignored)
├── .env.example                # Environment template
├── requirements.txt            # Production dependencies
├── requirements-dev.txt        # Dev dependencies
├── Dockerfile                  # Container definition
├── docker-compose.yml          # Redis for local dev
└── pytest.ini                  # Pytest configuration
```

## Key Architectural Patterns

### 1. Hybrid State Management
- **In-Memory**: Active game states for fast access (<500ms response)
- **PostgreSQL**: Persistent storage for recovery and history
- **Pattern**: Write-through cache (update memory + async DB write)

### 2. Polymorphic Player Models
```python
# Base class with abstract methods
class BasePlayer(BaseModel, ABC):
    @abstractmethod
    def get_image_url(self) -> str: ...

# League-specific implementations
class SbaPlayer(BasePlayer): ...
class PdPlayer(BasePlayer): ...

# Factory pattern for instantiation
Lineup.from_api_data(config, data)
```

### 3. League-Agnostic Core
- Game engine works for any league
- League-specific logic in config classes
- Result charts loaded per league

### 4. Async-First
- All database operations use `async/await`
- Database writes don't block game logic
- Connection pooling for efficiency

## Development Workflow

### Package Management

This project uses **UV** for fast, reliable package management.

**Installing UV** (one-time setup):
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```

**Setting up the project**:
```bash
cd backend
uv sync  # Creates .venv and installs all dependencies
```

### Daily Development
```bash
# Start Redis (in separate terminal or use -d for detached)
docker compose up -d

# Run backend with hot-reload (UV auto-activates .venv)
uv run python -m app.main

# Backend available at http://localhost:8000
# API docs at http://localhost:8000/docs

# Alternative: Activate .venv manually
source .venv/bin/activate
python -m app.main
```

### Testing

#### Terminal Client (Interactive Game Engine Testing)

Test the game engine directly without needing a frontend:

```bash
# Start interactive REPL (recommended for rapid testing)
uv run python -m terminal_client

# Then interact:
⚾ > new_game
⚾ > defensive
⚾ > offensive
⚾ > resolve
⚾ > quick_play 10
⚾ > status
⚾ > quit
```

**Features**:
- ✅ Persistent in-memory state throughout session
- ✅ Direct GameEngine access (no WebSocket overhead)
- ✅ Beautiful Rich formatting
- ✅ Auto-generated test lineups
- ✅ Perfect for rapid iteration

See `terminal_client/CLAUDE.md` for full documentation.

#### Unit & Integration Tests

```bash
# Run all tests
uv run pytest tests/ -v

# Run with coverage
uv run pytest tests/ --cov=app --cov-report=html

# Run specific test file
uv run pytest tests/unit/test_game_engine.py -v

# Type checking
uv run mypy app/

# Code formatting
uv run black app/ tests/

# Linting
uv run flake8 app/ tests/
```

## Testing Policy

**REQUIRED: 100% unit tests passing before any commit to feature branches.**

### Commit Requirements

**Feature Branches:**
- ✅ **REQUIRED**: All unit tests must pass (609/609)
- ✅ **REQUIRED**: Run tests before every commit
- ⚠️ **ALLOWED**: `[WIP]` commits with `--no-verify` (feature branches only)
- ✅ **REQUIRED**: 100% pass before merge to main

**Main/Master Branch:**
- ✅ **REQUIRED**: 100% unit tests passing
- ✅ **REQUIRED**: Code review approval
- ✅ **REQUIRED**: CI/CD green build
- ❌ **NEVER**: Commit with failing tests

**Integration Tests:**
- ⚠️ Known infrastructure issues (asyncpg connection pooling)
- ℹ️ Run individually during development
- ℹ️ Fix infrastructure as separate task

### Quick Test Commands

```bash
# Before every commit - REQUIRED
uv run pytest tests/unit/ -q

# Expected output:
# ======================= 609 passed, 3 warnings in 0.91s ========================

# If tests fail, fix them before committing!
```

### Git Hook Setup (Automated Enforcement)

We provide a pre-commit hook that automatically runs unit tests before each commit.

**Installation:**
```bash
# From backend directory
cd /mnt/NV2/Development/strat-gameplay-webapp/backend

# Copy the pre-commit hook
cp .git-hooks/pre-commit .git/hooks/pre-commit

# Make it executable
chmod +x .git/hooks/pre-commit
```

**What it does:**
- ✅ Runs all unit tests automatically before commit
- ✅ Prevents commits if tests fail
- ✅ Shows clear error messages
- ✅ Fast execution (~1 second)

**Bypassing the hook (use sparingly):**
```bash
# For WIP commits on feature branches ONLY
git commit -m "[WIP] Work in progress" --no-verify

# NEVER bypass on main branch
# ALWAYS fix tests before final commit
```

### Test Troubleshooting

**If tests fail:**
```bash
# Run with verbose output to see which test failed
uv run pytest tests/unit/ -v

# Run with full traceback
uv run pytest tests/unit/ -v --tb=long

# Run specific failing test
uv run pytest tests/unit/path/to/test.py::test_name -v
```

**Common issues:**
- Import errors → Check PYTHONPATH or use `uv run`
- Module not found → Run `uv sync` to install dependencies
- Type errors → Check SQLAlchemy Column vs runtime types (use `# type: ignore[assignment]`)

### Why This Matters

**Benefits:**
- 🛡️ **Prevents regressions** - Broken code never reaches main
- ⚡ **Fast feedback** - Know immediately if you broke something
- 📜 **Clean history** - Main branch always deployable
- 🎯 **High confidence** - 609 tests verify core behavior
- 🔄 **Forces good habits** - Write tests as you code

**The cost of skipping:**
- 🐛 Bugs reach production
- ⏰ Hours debugging what tests would catch instantly
- 😰 Fear of refactoring (might break something)
- 🔥 Emergency hotfixes instead of planned releases

### Test Coverage Baseline

Current baseline (must maintain or improve):
- ✅ Unit tests: 609/609 passing (100%)
- ⏱️ Execution time: <1 second
- 📊 Coverage: High coverage of core game engine, state management, models

## Coding Standards

### Python Style
- **Formatting**: Black with default settings
- **Line Length**: 88 characters (black default)
- **Imports**: Group stdlib, third-party, local (isort compatible)
- **Type Hints**: Required for all public functions
- **Docstrings**: Google style for classes and public methods

### Logging Pattern
```python
import logging

logger = logging.getLogger(f'{__name__}.ClassName')

# Usage
logger.info(f"User {user_id} connected")
logger.error(f"Failed to process action: {error}", exc_info=True)
```

### DateTime Handling
**ALWAYS use Pendulum, NEVER use Python's datetime module:**
```python
import pendulum

# Get current UTC time
now = pendulum.now('UTC')

# Format for display
formatted = now.format('YYYY-MM-DD HH:mm:ss')
formatted_iso = now.to_iso8601_string()

# Parse dates
parsed = pendulum.parse('2025-10-21')

# Timezones
eastern = pendulum.now('America/New_York')
utc = eastern.in_timezone('UTC')

# Database defaults (in models)
created_at = Column(DateTime, default=lambda: pendulum.now('UTC'))
```

### Error Handling
- **Raise or Return**: Never return `Optional` unless specifically required
- **Custom Exceptions**: Use for domain-specific errors
- **Logging**: Always log exceptions with context

### Dataclasses
```python
from dataclasses import dataclass

@dataclass
class GameState:
    game_id: str
    inning: int
    outs: int
    # ... fields
```

## Type Checking & Common False Positives

### Overview

This project uses **Pylance** (Pyright) for real-time type checking in VS Code and **mypy** for validation. Due to SQLAlchemy's ORM magic and Pydantic's settings pattern, we encounter known false positives that must be handled strategically.

**Critical**: Do NOT disable type checking globally. Use targeted suppressions only where needed.

### 🔴 Known False Positive #1: SQLAlchemy Model Attributes

**Problem**: SQLAlchemy model instances have `.id`, `.position`, etc. that are `int`/`str` at runtime but typed as `Column[int]`/`Column[str]` for type checkers.

**Symptom**:
```
Cannot assign to attribute "current_batter_lineup_id" for class "GameState"
  Type "Column[int]" is not assignable to type "int | None"
```

**Solution**: Use targeted `# type: ignore[assignment]` comments:

```python
# ❌ DON'T: Disable all type checking
lineup_player.id  # type: ignore

# ❌ DON'T: Runtime conversion (unnecessary overhead)
int(lineup_player.id)

# ✅ DO: Targeted suppression with explanation
lineup_player.id  # type: ignore[assignment]  # SQLAlchemy Column is int at runtime
```

**When to Apply**:
- Assigning SQLAlchemy model attributes to Pydantic model fields
- Common locations: `game_engine.py`, any code interfacing between ORM and Pydantic

**Example**:
```python
# In game_engine.py
state.current_batter_lineup_id = batting_order[current_idx].id  # type: ignore[assignment]
state.current_pitcher_lineup_id = pitcher.id if pitcher else None  # type: ignore[assignment]
```

### 🔴 Known False Positive #2: SQLAlchemy Declarative Base

**Problem**: mypy doesn't understand SQLAlchemy's `declarative_base()` pattern.

**Symptom**:
```
app/models/db_models.py:10: error: Variable "app.database.session.Base" is not valid as a type
app/models/db_models.py:10: error: Invalid base class "Base"
```

**Solution**: Configure `mypy.ini` to disable strict checking for ORM files:

```ini
[mypy-app.models.db_models]
disallow_untyped_defs = False
warn_return_any = False

[mypy-app.database.operations]
disallow_untyped_defs = False
```

**Status**: Already configured in `mypy.ini`. These warnings are expected and safe to ignore.

### 🔴 Known False Positive #3: Pydantic Settings Constructor

**Problem**: Pydantic `BaseSettings` loads from environment variables, not constructor arguments.

**Symptom**:
```
app/config.py:48: error: Missing named argument "secret_key" for "Settings"
app/config.py:48: error: Missing named argument "database_url" for "Settings"
```

**Solution**: Configure `mypy.ini` to disable checks for config module:

```ini
[mypy-app.config]
disallow_untyped_defs = False
```

**Status**: Already configured. This is expected Pydantic-settings behavior.

### 🟡 Legitimate Type Issues to Fix

#### Missing Type Annotations

**Problem**: Dictionary or variable without type hint.

**Symptom**:
```
Need type annotation for "position_counts"
```

**Solution**: Add explicit type annotation:

```python
# ❌ Missing type hint
position_counts = {}

# ✅ With type hint
position_counts: dict[str, int] = {}
```

#### Optional in Lambda/Sorted

**Problem**: Using `Optional[int]` in comparison context.

**Symptom**:
```
Argument "key" to "sorted" has incompatible type "Callable[[LineupPlayerState], int | None]"
```

**Solution**: Provide fallback value in lambda:

```python
# ❌ Optional can be None
sorted(players, key=lambda x: x.batting_order)

# ✅ With fallback (we already filtered None above)
sorted(players, key=lambda x: x.batting_order or 0)
```

### Best Practices

#### ✅ DO: Use Specific Type Ignore Codes

```python
# Specific - only ignores assignment mismatch
value = something  # type: ignore[assignment]

# Specific - only ignores argument type
func(arg)  # type: ignore[arg-type]
```

#### ✅ DO: Add Explanatory Comments

```python
# SQLAlchemy model .id is int at runtime, but typed as Column[int]
state.current_batter_lineup_id = lineup_player.id  # type: ignore[assignment]
```

#### ✅ DO: Keep Type Checking Strict Elsewhere

Only suppress where SQLAlchemy/Pydantic cause false positives. New code should pass type checking without suppressions.

#### ❌ DON'T: Disable Type Checking Globally

```python
# ❌ Too broad - hides real errors
# type: ignore at top of file

# ❌ Disables all checks for file
# In pyrightconfig.json: "ignore": ["app/core/game_engine.py"]
```

#### ❌ DON'T: Use Unnecessary Runtime Conversions

```python
# ❌ int() is unnecessary (already int at runtime) and adds overhead
state.current_batter_lineup_id = int(lineup_player.id)

# ✅ Direct assignment with targeted suppression
state.current_batter_lineup_id = lineup_player.id  # type: ignore[assignment]
```

### Configuration Files

#### mypy.ini

Manages mypy-specific type checking configuration:
- Disables strict checks for SQLAlchemy ORM files (`db_models.py`, `operations.py`)
- Disables checks for Pydantic Settings (`config.py`)
- Enables SQLAlchemy plugin support

#### pyrightconfig.json

Manages Pylance/Pyright configuration in VS Code:
- Sets `typeCheckingMode: "basic"` (not too strict)
- Suppresses some SQLAlchemy-related global warnings
- Keeps strict checking for application logic

### Common Error Codes Reference

| Code | Meaning | Common Fix |
|------|---------|-----------|
| `[assignment]` | Type mismatch in assignment | SQLAlchemy Column → use `# type: ignore[assignment]` |
| `[arg-type]` | Argument type mismatch | SQLAlchemy Column in function call → use `# type: ignore[arg-type]` |
| `[attr-defined]` | Attribute doesn't exist | Usually a real error - check for typos |
| `[var-annotated]` | Missing type annotation | Add `: dict[str, int]` etc. |
| `[return-value]` | Return type mismatch | Usually a real error - fix return value |
| `[operator]` | Unsupported operation | Check if operation makes sense |

### Verification Checklist

Before considering type warnings as false positives:

1. ✅ Is it a SQLAlchemy model attribute? → Use `# type: ignore[assignment]`
2. ✅ Is it in `db_models.py` or `operations.py`? → Expected, configured in `mypy.ini`
3. ✅ Is it in `config.py` (Pydantic Settings)? → Expected, configured in `mypy.ini`
4. ❌ Is it in game logic (`game_engine.py`, `validators.py`, etc.)? → **FIX IT** - likely a real issue

### Example: Correct SQLAlchemy-Pydantic Bridging

```python
async def _prepare_next_play(self, state: GameState) -> None:
    """Prepare snapshot for the next play."""

    # Fetch SQLAlchemy models from database
    batting_lineup = await self.db_ops.get_active_lineup(state.game_id, batting_team)
    fielding_lineup = await self.db_ops.get_active_lineup(state.game_id, fielding_team)

    # Extract values for Pydantic model
    # SQLAlchemy model .id is int at runtime, but typed as Column[int]
    state.current_batter_lineup_id = batting_order[current_idx].id  # type: ignore[assignment]

    pitcher = next((p for p in fielding_lineup if p.position == "P"), None)
    state.current_pitcher_lineup_id = pitcher.id if pitcher else None  # type: ignore[assignment]
```

### Resources

- **Type Checking Guide**: `.claude/type-checking-guide.md` (comprehensive documentation)
- **mypy Configuration**: `mypy.ini` (type checker settings)
- **Pylance Configuration**: `pyrightconfig.json` (VS Code settings)

---

## Database Models

Our database schema is designed based on the proven Discord game implementation, with enhancements for web real-time gameplay.

### Core Tables

#### **Game** (`games`)
Primary game container with state tracking.

**Key Fields:**
- `id` (UUID): Primary key, prevents ID collisions across distributed systems
- `league_id` (String): 'sba' or 'pd', determines league-specific behavior
- `status` (String): 'pending', 'active', 'completed'
- `game_mode` (String): 'ranked', 'friendly', 'practice'
- `visibility` (String): 'public', 'private'
- `current_inning`, `current_half`: Current game state
- `home_score`, `away_score`: Running scores

**AI Support:**
- `home_team_is_ai` (Boolean): Home team controlled by AI
- `away_team_is_ai` (Boolean): Away team controlled by AI
- `ai_difficulty` (String): 'balanced', 'yolo', 'safe'

**Relationships:**
- `plays`: All plays in the game (cascade delete)
- `lineups`: All lineup entries (cascade delete)
- `cardset_links`: PD only - approved cardsets (cascade delete)
- `roster_links`: Roster tracking - cards (PD) or players (SBA) (cascade delete)
- `session`: Real-time WebSocket session (cascade delete)

---

#### **Play** (`plays`)
Records every at-bat with full statistics and game state.

**Game State Snapshot:**
- `play_number`: Sequential play counter
- `inning`, `half`: Inning state
- `outs_before`: Outs at start of play
- `batting_order`: Current spot in order
- `away_score`, `home_score`: Score at play start

**Player References (FKs to Lineup):**
- `batter_id`, `pitcher_id`, `catcher_id`: Required players
- `defender_id`, `runner_id`: Optional for specific plays
- `on_first_id`, `on_second_id`, `on_third_id`: Base runners

**Runner Outcomes:**
- `on_first_final`, `on_second_final`, `on_third_final`: Final base (None = out, 1-4 = base)
- `batter_final`: Where batter ended up
- `on_base_code` (Integer): Bit field for efficient queries (1=1st, 2=2nd, 4=3rd, 7=loaded)

**Strategic Decisions:**
- `defensive_choices` (JSON): Alignment, holds, shifts
- `offensive_choices` (JSON): Steal attempts, bunts, hit-and-run

**Play Result:**
- `dice_roll` (String): Dice notation (e.g., "14+6")
- `hit_type` (String): GB, FB, LD, etc.
- `result_description` (Text): Human-readable result
- `outs_recorded`, `runs_scored`: Play outcome
- `check_pos` (String): Defensive position for X-check

**Batting Statistics (25+ fields):**
- `pa`, `ab`, `hit`, `double`, `triple`, `homerun`
- `bb`, `so`, `hbp`, `rbi`, `sac`, `ibb`, `gidp`
- `sb`, `cs`: Base stealing
- `wild_pitch`, `passed_ball`, `pick_off`, `balk`
- `bphr`, `bpfo`, `bp1b`, `bplo`: Ballpark power events
- `run`, `e_run`: Earned/unearned runs

**Advanced Analytics:**
- `wpa` (Float): Win Probability Added
- `re24` (Float): Run Expectancy 24 base-out states

**Game Situation Flags:**
- `is_tied`, `is_go_ahead`, `is_new_inning`: Context flags
- `in_pow`: Pitcher over workload
- `complete`, `locked`: Workflow state

**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** (`lineups`)
Tracks player assignments and substitutions.

**Key Fields:**
- `game_id`, `team_id`, `card_id`: Links to game/team/card
- `position` (String): P, C, 1B, 2B, 3B, SS, LF, CF, RF, DH
- `batting_order` (Integer): 1-9

**Substitution Tracking:**
- `is_starter` (Boolean): Original lineup vs substitute
- `is_active` (Boolean): Currently in game
- `entered_inning` (Integer): When player entered
- `replacing_id` (Integer): Lineup ID of replaced player
- `after_play` (Integer): Exact play number of substitution

**Pitcher Management:**
- `is_fatigued` (Boolean): Triggers bullpen decisions

---

#### **GameCardsetLink** (`game_cardset_links`)
PD league only - defines legal cardsets for a game.

**Key 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)

---

#### **RosterLink** (`roster_links`)
Tracks eligible cards (PD) or players (SBA) for a game.

**Polymorphic Design**: Single table supporting both leagues with application-layer type safety.

**Key Fields:**
- `id` (Integer): Surrogate primary key (auto-increment)
- `game_id` (UUID): Foreign key to games table
- `card_id` (Integer, nullable): PD league - card identifier
- `player_id` (Integer, nullable): SBA league - player identifier
- `team_id` (Integer): Which team owns this entity in this game

**Constraints:**
- `roster_link_one_id_required`: CHECK constraint ensures exactly one of `card_id` or `player_id` is populated (XOR logic)
- `uq_game_card`: UNIQUE constraint on (game_id, card_id) for PD
- `uq_game_player`: UNIQUE constraint on (game_id, player_id) for SBA

**Usage Pattern:**
```python
# PD league - add card to roster
roster_data = await db_ops.add_pd_roster_card(
    game_id=game_id,
    card_id=123,
    team_id=1
)

# SBA league - add player to roster
roster_data = await db_ops.add_sba_roster_player(
    game_id=game_id,
    player_id=456,
    team_id=2
)

# Get roster (league-specific)
pd_roster = await db_ops.get_pd_roster(game_id, team_id=1)
sba_roster = await db_ops.get_sba_roster(game_id, team_id=2)
```

**Design Rationale:**
- Single table avoids complex joins and simplifies queries
- Nullable columns with CHECK constraint ensures data integrity at database level
- Pydantic models (`PdRosterLinkData`, `SbaRosterLinkData`) provide type safety at application layer
- Surrogate key allows nullable columns (can't use nullable columns in composite PK)

---

#### **GameSession** (`game_sessions`)
Real-time WebSocket state tracking.

**Key Fields:**
- `game_id` (UUID): Primary key, one-to-one with Game
- `connected_users` (JSON): Active WebSocket connections
- `last_action_at` (DateTime): Last activity timestamp
- `state_snapshot` (JSON): In-memory game state cache

---

### Database Patterns

### Async Session Usage
```python
from app.database.session import get_session

async def some_function():
    async with get_session() as session:
        result = await session.execute(query)
        # session.commit() happens automatically
```

### Model Definitions
```python
from app.database.session import Base
from sqlalchemy import Column, String, Integer

class Game(Base):
    __tablename__ = "games"

    id = Column(UUID(as_uuid=True), primary_key=True)
    # ... columns
```

### Relationship Patterns

**Using Lazy Loading:**
```python
# In Play model - common players loaded automatically
batter = relationship("Lineup", foreign_keys=[batter_id], lazy="joined")
pitcher = relationship("Lineup", foreign_keys=[pitcher_id], lazy="joined")

# Rare players loaded on demand
defender = relationship("Lineup", foreign_keys=[defender_id])  # lazy="select" (default)
```

**Querying with Relationships:**
```python
from sqlalchemy.orm import joinedload

# Efficient loading for broadcasting
play = await session.execute(
    select(Play)
    .options(
        joinedload(Play.batter),
        joinedload(Play.pitcher),
        joinedload(Play.on_first),
        joinedload(Play.on_second),
        joinedload(Play.on_third)
    )
    .where(Play.id == play_id)
)
```

### Common Query Patterns

**Find plays with bases loaded:**
```python
# Using on_base_code bit field
bases_loaded_plays = await session.execute(
    select(Play).where(Play.on_base_code == 7)  # 1+2+4 = 7
)
```

**Get active pitcher for team:**
```python
pitcher = await session.execute(
    select(Lineup)
    .where(
        Lineup.game_id == game_id,
        Lineup.team_id == team_id,
        Lineup.position == 'P',
        Lineup.is_active == True
    )
)
```

**Calculate box score stats:**
```python
# Player batting stats for game
stats = await session.execute(
    select(
        func.sum(Play.ab).label('ab'),
        func.sum(Play.hit).label('hits'),
        func.sum(Play.homerun).label('hr'),
        func.sum(Play.rbi).label('rbi')
    )
    .where(
        Play.game_id == game_id,
        Play.batter_id == lineup_id
    )
)
```

## WebSocket Patterns

### Event Handler Registration
```python
@sio.event
async def some_event(sid, data):
    """Handle some_event from client"""
    try:
        # Validate data
        # Process action
        # Emit response
        await sio.emit('response_event', result, room=sid)
    except Exception as e:
        logger.error(f"Error handling event: {e}")
        await sio.emit('error', {'message': str(e)}, room=sid)
```

### Broadcasting
```python
# To specific game room
await connection_manager.broadcast_to_game(
    game_id,
    'game_state_update',
    state_data
)

# To specific user
await connection_manager.emit_to_user(
    sid,
    'decision_required',
    decision_data
)
```

## Environment Variables

Required in `.env`:
```bash
# Database
DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/dbname

# Application
SECRET_KEY=your-secret-key-at-least-32-chars

# Discord OAuth
DISCORD_CLIENT_ID=your-client-id
DISCORD_CLIENT_SECRET=your-client-secret

# League APIs
SBA_API_URL=https://sba-api.example.com
SBA_API_KEY=your-api-key
PD_API_URL=https://pd-api.example.com
PD_API_KEY=your-api-key
```

## Performance Targets

- **Action Response**: < 500ms from user action to state update
- **WebSocket Delivery**: < 200ms
- **Database Write**: < 100ms (async, non-blocking)
- **State Recovery**: < 2 seconds
- **Concurrent Games**: Support 10+ simultaneous games
- **Memory**: < 1GB with 10 active games

## Security Considerations

- **Authentication**: All WebSocket connections require valid JWT
- **Authorization**: Verify team ownership before allowing actions
- **Input Validation**: Pydantic models validate all inputs
- **SQL Injection**: Prevented by SQLAlchemy ORM
- **Dice Rolls**: Cryptographically secure random generation
- **Server-Side Logic**: All game rules enforced server-side

## Common Tasks

### Adding a New API Endpoint
1. Create route in `app/api/routes/`
2. Define Pydantic request/response models
3. Add dependency injection if needed
4. Register router in `app/main.py`

### Adding a New WebSocket Event
1. Define event handler in `app/websocket/handlers.py`
2. Register with `@sio.event` decorator
3. Validate data with Pydantic
4. Add corresponding client handling in frontend

### Adding a New Database Model
1. Define SQLAlchemy model in `app/models/db_models.py`
2. Create Alembic migration: `alembic revision --autogenerate -m "description"`
3. Apply migration: `alembic upgrade head`

## Troubleshooting

### Import Errors
- Ensure virtual environment is activated
- Check `PYTHONPATH` if using custom structure
- Verify all `__init__.py` files exist

### Database Connection Issues
- Verify `DATABASE_URL` in `.env` is correct
- Test connection: `psql $DATABASE_URL`
- Check firewall/network access
- Verify database exists

### WebSocket Not Connecting
- Check CORS settings in `config.py`
- Verify token is being sent from client
- Check logs for connection errors
- Ensure Socket.io versions match (client/server)

## Environment-Specific Configuration

### Database Connection
- **Dev Server**: PostgreSQL at `10.10.0.42:5432`
- **Database Name**: `paperdynasty_dev`
- **User**: `paperdynasty`
- **Connection String Format**: `postgresql+asyncpg://paperdynasty:PASSWORD@10.10.0.42:5432/paperdynasty_dev`

### Docker Compose Commands
This system uses **Docker Compose V2** (not legacy docker-compose):
```bash
# Correct (with space)
docker compose up -d
docker compose down
docker compose logs

# Incorrect (will not work)
docker-compose up -d  # Old command not available
```

### Python Environment
- **Version**: Python 3.13.3 (not 3.11 as originally planned)
- **Package Manager**: UV (v0.9.7+) - Fast, reliable Python package management
- **Virtual Environment**: Located at `backend/.venv/` (UV default)
- **Activation**: `source .venv/bin/activate` (from backend directory)
  - Or use `uv run <command>` to auto-activate

### Package Management with UV

**Add a new dependency**:
```bash
# Production dependency
uv add package-name==1.2.3

# Development dependency
uv add --dev package-name==1.2.3

# With extras
uv add "package[extra]==1.2.3"
```

**Update dependencies**:
```bash
# Update a specific package
uv add package-name@latest

# Sync dependencies (after pulling pyproject.toml changes)
uv sync
```

**Remove a dependency**:
```bash
uv remove package-name
```

**Key Files**:
- `pyproject.toml` - Project metadata and dependencies (commit to git)
- `uv.lock` - Locked dependency versions (commit to git)
- `.venv/` - Virtual environment (gitignored)

### Critical Dependencies
- **greenlet**: Required for SQLAlchemy async support (must be explicitly installed)
- **Pendulum**: Used for ALL datetime operations (replaces Python's datetime module)
  ```python
  import pendulum

  # Always use Pendulum
  now = pendulum.now('UTC')

  # Never use
  from datetime import datetime  # ❌ Don't import this
  ```

### Environment Variable Format

**IMPORTANT**: Pydantic Settings requires specific formats for complex types:

```bash
# Lists must be JSON arrays
CORS_ORIGINS=["http://localhost:3000", "http://localhost:3001"]  # ✅ Correct

# NOT comma-separated strings
CORS_ORIGINS=http://localhost:3000,http://localhost:3001  # ❌ Will fail
```

### SQLAlchemy Reserved Names

The following column names are **reserved** in SQLAlchemy and will cause errors:

```python
# ❌ NEVER use these as column names
metadata = Column(JSON)  # RESERVED - will fail

# ✅ Use descriptive alternatives
game_metadata = Column(JSON)
play_metadata = Column(JSON)
lineup_metadata = Column(JSON)
```

**Other reserved names to avoid**: `metadata`, `registry`, `__tablename__`, `__mapper__`, `__table__`

### Package Version Notes

Due to Python 3.13 compatibility, we use newer versions than originally planned:

```txt
# Updated versions (from requirements.txt)
fastapi==0.115.6          # (was 0.104.1)
uvicorn==0.34.0           # (was 0.24.0)
pydantic==2.10.6          # (was 2.5.0)
sqlalchemy==2.0.36        # (was 2.0.23)
asyncpg==0.30.0           # (was 0.29.0)
pendulum==3.0.0           # (new addition)
```

### Server Startup

```bash
# From backend directory with venv activated
python -m app.main

# Server runs at:
# - Main API: http://localhost:8000
# - Swagger UI: http://localhost:8000/docs
# - ReDoc: http://localhost:8000/redoc

# With hot-reload enabled by default
```

### Logs Directory
- Auto-created at `backend/logs/`
- Daily rotating logs: `app_YYYYMMDD.log`
- 10MB max size, 5 backup files
- Gitignored

## Week 4 Implementation (Phase 2 - State Management)

**Completed**: 2025-10-22
**Status**: ✅ **COMPLETE** - All deliverables achieved

### Components Implemented

#### 1. Pydantic Game State Models (`app/models/game_models.py`)

**Purpose**: Type-safe models for in-memory game state representation

**Key Models**:
- `GameState`: Core game state (inning, score, runners, decisions)
- `RunnerState`: Base runner tracking
- `LineupPlayerState` / `TeamLineupState`: Lineup management
- `DefensiveDecision` / `OffensiveDecision`: Strategic decisions

**Features**:
- Full Pydantic v2 validation with field validators
- 20+ helper methods on GameState
- Optimized for fast serialization (WebSocket broadcasts)
- Game over logic, runner advancement, scoring

**Usage Example**:
```python
from app.models.game_models import GameState, RunnerState

# Create game state
state = GameState(
    game_id=uuid4(),
    league_id="sba",
    home_team_id=1,
    away_team_id=2
)

# Add runner
state.add_runner(lineup_id=1, card_id=101, base=1)

# Advance runner and score
state.advance_runner(from_base=1, to_base=4)  # Scores run
```

#### 2. State Manager (`app/core/state_manager.py`)

**Purpose**: In-memory state management with O(1) lookups

**Key Features**:
- Dictionary-based storage: `_states: Dict[UUID, GameState]`
- Lineup caching per game
- Last access tracking with Pendulum timestamps
- Idle game eviction (configurable timeout)
- State recovery from database
- Statistics tracking

**Usage Example**:
```python
from app.core.state_manager import state_manager

# Create game
state = await state_manager.create_game(
    game_id=game_id,
    league_id="sba",
    home_team_id=1,
    away_team_id=2
)

# Get state (fast O(1) lookup)
state = state_manager.get_state(game_id)

# Update state
state.inning = 5
state_manager.update_state(game_id, state)

# Recover from database
recovered = await state_manager.recover_game(game_id)
```

**Performance**:
- State access: O(1) via dictionary lookup
- Memory per game: ~1KB (just state, no player data yet)
- Target response time: <500ms ✅

#### 3. Database Operations (`app/database/operations.py`)

**Purpose**: Async PostgreSQL persistence layer

**Key Methods**:
- `create_game()`: Create game in database
- `update_game_state()`: Update inning, score, status
- `create_lineup_entry()` / `get_active_lineup()`: Lineup persistence
- `save_play()` / `get_plays()`: Play recording
- `load_game_state()`: Complete state loading for recovery
- `create_game_session()` / `update_session_snapshot()`: WebSocket state

**Usage Example**:
```python
from app.database.operations import DatabaseOperations

db_ops = DatabaseOperations()

# Create game
await db_ops.create_game(
    game_id=game_id,
    league_id="sba",
    home_team_id=1,
    away_team_id=2,
    game_mode="friendly",
    visibility="public"
)

# Update state (async, non-blocking)
await db_ops.update_game_state(
    game_id=game_id,
    inning=5,
    half="bottom",
    home_score=3,
    away_score=2
)

# Load for recovery
game_data = await db_ops.load_game_state(game_id)
```

**Pattern**: All operations use async/await with proper error handling

### Testing

**Unit Tests** (86 tests, 100% passing):
- `tests/unit/models/test_game_models.py` (60 tests)
- `tests/unit/core/test_state_manager.py` (26 tests)

**Integration Tests** (29 tests written):
- `tests/integration/database/test_operations.py` (21 tests)
- `tests/integration/test_state_persistence.py` (8 tests)

**Run Tests**:
```bash
# All unit tests
uv run pytest tests/unit/ -v

# Integration tests (requires database)
uv run pytest tests/integration/ -v -m integration

# Specific file
uv run pytest tests/unit/models/test_game_models.py -v
```

### Patterns Established

#### 1. Pydantic Field Validation
```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
```

#### 2. Async Database Session Management
```python
async with AsyncSessionLocal() as session:
    try:
        # database operations
        await session.commit()
    except Exception as e:
        await session.rollback()
        logger.error(f"Error: {e}")
        raise
```

#### 3. State Recovery Pattern
```python
# 1. Load from database
game_data = await db_ops.load_game_state(game_id)

# 2. Rebuild in-memory state
state = await state_manager._rebuild_state_from_data(game_data)

# 3. Cache in memory
state_manager._states[game_id] = state
```

#### 4. Helper Methods on Models
```python
# GameState helper methods
def get_batting_team_id(self) -> int:
    return self.away_team_id if self.half == "top" else self.home_team_id

def is_runner_on_first(self) -> bool:
    return any(r.on_base == 1 for r in self.runners)

def advance_runner(self, from_base: int, to_base: int) -> None:
    # Auto-score when to_base == 4
    if to_base == 4:
        if self.half == "top":
            self.away_score += 1
        else:
            self.home_score += 1
```

### Configuration

**pytest.ini** (created):
```ini
[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
```

**Fixed Warnings**:
- ✅ Pydantic v2 config deprecation (migrated to `model_config = ConfigDict()`)
- ✅ pytest-asyncio loop scope configuration

### Key Files

```
app/models/game_models.py      (492 lines) - Pydantic state models
app/core/state_manager.py      (296 lines) - In-memory state management
app/database/operations.py     (362 lines) - Async database operations
tests/unit/models/test_game_models.py      (788 lines, 60 tests)
tests/unit/core/test_state_manager.py      (447 lines, 26 tests)
tests/integration/database/test_operations.py      (438 lines, 21 tests)
tests/integration/test_state_persistence.py        (290 lines, 8 tests)
pytest.ini                     (23 lines)  - Test configuration
```

### Next Phase (Week 5)

Week 5 will build game logic on top of this state management foundation:
1. Dice system (cryptographic d20 rolls)
2. Play resolver (result charts and outcome determination)
3. Game engine (orchestrate complete at-bat flow)
4. Rule validators (enforce baseball rules)
5. Enhanced state recovery (replay plays to rebuild complete state)

---

## Player Models (2025-10-28)

### Overview

Polymorphic player model system that supports both SBA and PD leagues with different data complexity levels. Uses abstract base class pattern to ensure league-agnostic game engine.

### Architecture

```
BasePlayer (Abstract)
    ├── SbaPlayer (Simple)
    └── PdPlayer (Complex with scouting data)
```

**Key Design Decisions**:
- Abstract base ensures consistent interface across leagues
- Factory methods for easy creation from API responses
- Pydantic validation for type safety
- Optional scouting data for PD league (loaded separately)

### Models

#### BasePlayer (Abstract Base Class)

**Location**: `app/models/player_models.py:18-44`

Common interface for all player types. Game engine uses this abstraction.

**Required Abstract Methods**:
- `get_image_url() -> str`: Get player image with fallback logic
- `get_positions() -> List[str]`: Get all playable positions
- `get_display_name() -> str`: Get formatted name for UI

**Common Fields**:
- `id`: Player ID (SBA) or Card ID (PD)
- `name`: Player display name
- `image`: Primary image URL

#### SbaPlayer Model

**Location**: `app/models/player_models.py:49-145`

Simple model for SBA league with minimal data.

**Key Fields**:
- Basic: `id`, `name`, `image`, `wara`
- Team: `team_id`, `team_name`, `season`
- Positions: `pos_1` through `pos_8` (up to 8 positions)
- References: `headshot`, `vanity_card`, `strat_code`, `bbref_id`, `injury_rating`

**Usage**:
```python
from app.models import SbaPlayer

# Create from API response
player = SbaPlayer.from_api_response(api_data)

# Use common interface
positions = player.get_positions()  # ['RF', 'CF']
image = player.get_image_url()      # With fallback logic
name = player.get_display_name()    # 'Ronald Acuna Jr'
```

**API Mapping**:
- Endpoint: `{{baseUrl}}/players/:player_id`
- Maps API response fields to model fields
- Extracts nested team data automatically

#### PdPlayer Model

**Location**: `app/models/player_models.py:254-495`

Complex model for PD league with detailed scouting data.

**Key Fields**:
- Basic: `player_id`, `name`, `cost`, `description`
- Card: `cardset`, `rarity`, `set_num`, `quantity`
- Team: `mlbclub`, `franchise`
- Positions: `pos_1` through `pos_8`
- References: `headshot`, `vanity_card`, `strat_code`, `bbref_id`, `fangr_id`
- **Scouting**: `batting_card`, `pitching_card` (optional)

**Scouting Data Structure**:

```python
# Batting Card (loaded from /api/v2/battingcardratings/player/:id)
batting_card:
  - steal_low, steal_high, steal_auto, steal_jump
  - bunting, hit_and_run, running ratings
  - hand (L/R), offense_col (1/2)
  - ratings: Dict[str, PdBattingRating]
    - 'L': vs Left-handed pitchers
    - 'R': vs Right-handed pitchers

# Pitching Card (loaded from /api/v2/pitchingcardratings/player/:id)
pitching_card:
  - balk, wild_pitch, hold
  - starter_rating, relief_rating, closer_rating
  - hand (L/R), offense_col (1/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_*`, `single_*`, `walk`, `strikeout`, etc.
- Summary: `avg`, `obp`, `slg`

**PdPitchingRating** (per handedness matchup):
- Outcomes: `homerun`, `triple`, `double_*`, `single_*`, `walk`, `strikeout`, etc.
- X-checks: `xcheck_p`, `xcheck_c`, `xcheck_1b`, etc. (defensive play probabilities)
- Summary: `avg`, `obp`, `slg`

**Usage**:
```python
from app.models import PdPlayer

# Create from API responses (scouting data optional)
player = PdPlayer.from_api_response(
    player_data=player_api_response,
    batting_data=batting_api_response,    # Optional
    pitching_data=pitching_api_response   # Optional
)

# Use common interface
positions = player.get_positions()  # ['2B', 'SS']
name = player.get_display_name()    # 'Chuck Knoblauch (1998 Season)'

# Access scouting data
rating_vs_lhp = player.get_batting_rating('L')
if rating_vs_lhp:
    print(f"HR rate vs LHP: {rating_vs_lhp.homerun}%")
    print(f"Walk rate: {rating_vs_lhp.walk}%")
    print(f"Strikeout rate: {rating_vs_lhp.strikeout}%")

rating_vs_rhb = player.get_pitching_rating('R')
if rating_vs_rhb:
    print(f"X-check SS: {rating_vs_rhb.xcheck_ss}%")
```

**API Mapping**:
- Player data: `{{baseUrl}}/api/v2/players/:player_id`
- Batting data: `{{baseUrl}}/api/v2/battingcardratings/player/:player_id`
- Pitching data: `{{baseUrl}}/api/v2/pitchingcardratings/player/:player_id`

### Supporting Models

#### PdCardset
Card set information: `id`, `name`, `description`, `ranked_legal`

#### PdRarity
Card rarity: `id`, `value`, `name` (MVP, Starter, Replacement), `color` (hex)

#### PdBattingCard
Container for batting statistics with ratings for both vs LHP and vs RHP

#### PdPitchingCard
Container for pitching statistics with ratings for both vs LHB and vs RHB

### Integration Points

**With Game Engine**:
```python
# Game engine uses BasePlayer interface
def process_at_bat(batter: BasePlayer, pitcher: BasePlayer):
    # Works for both SBA and PD players
    print(f"{batter.get_display_name()} batting")
    print(f"Positions: {batter.get_positions()}")
```

**With API Client** (future):
```python
# API client will fetch and parse player data
async def get_player(league: str, player_id: int) -> BasePlayer:
    if league == "sba":
        data = await fetch_sba_player(player_id)
        return SbaPlayer.from_api_response(data)
    else:  # PD league
        player_data = await fetch_pd_player(player_id)
        batting_data = await fetch_pd_batting(player_id)
        pitching_data = await fetch_pd_pitching(player_id)
        return PdPlayer.from_api_response(player_data, batting_data, pitching_data)
```

### Testing

**Unit Tests**: `tests/unit/models/test_player_models.py`
- BasePlayer abstract methods
- SbaPlayer creation and methods
- PdPlayer creation with/without scouting data
- Factory method validation
- Edge cases (missing positions, partial data)

**Test Data**: Uses real API response examples from `app/models/player_model_info.md`

### Key Files

```
app/models/player_models.py        (516 lines) - Player model implementations
tests/unit/models/test_player_models.py        - Comprehensive unit tests
app/models/player_model_info.md    (540 lines) - API response examples
```

---

## Recent Optimizations & Bug Fixes (2025-10-28)

### Performance Optimizations - 60% Query Reduction

Optimized play resolution to eliminate unnecessary database queries:

**Before Optimization**: 5 queries per play
1. INSERT INTO plays (necessary)
2. SELECT plays with LEFT JOINs (refresh - unnecessary)
3. SELECT games (for update - inefficient)
4. SELECT lineups team 1 (unnecessary - should use cache)
5. SELECT lineups team 2 (unnecessary - should use cache)

**After Optimization**: 2 queries per play (60% reduction)
1. INSERT INTO plays (necessary)
2. UPDATE games (necessary, now uses direct UPDATE)

**Changes Made**:

1. **Lineup Caching** (`app/core/game_engine.py:384-425`)
   - `_prepare_next_play()` now checks `state_manager.get_lineup()` cache first
   - Only fetches from database if not cached
   - Cache persists for entire game lifecycle
   - **Impact**: Eliminates 2 SELECT queries per play

2. **Removed Unnecessary Refresh** (`app/database/operations.py:281-302`)
   - `save_play()` no longer calls `session.refresh(play)`
   - Play ID is available after commit without refresh
   - Returns just the ID instead of full Play object with relationships
   - **Impact**: Eliminates 1 SELECT with 3 expensive LEFT JOINs per play

3. **Direct UPDATE Statement** (`app/database/operations.py:109-165`)
   - `update_game_state()` now uses direct UPDATE statement
   - No longer does SELECT + modify + commit
   - Uses `result.rowcount` to verify game exists
   - **Impact**: Cleaner code, slightly faster (was already a simple SELECT)

4. **Conditional Game State Updates** (`app/core/game_engine.py:200-217`)
   - Only UPDATE games table when score/inning/status actually changes
   - Captures state before/after play resolution and compares
   - Many plays don't change game state (outs without runs, singles without scoring)
   - **Impact**: ~40-60% fewer UPDATE queries (depends on scoring frequency)

**Performance Impact**:
- Typical play resolution: ~50-100ms (down from ~150-200ms)
- Only necessary write operations remain
- Scalable for high-throughput gameplay
- Combined with conditional updates: ~70% fewer queries in low-scoring innings

### Critical Bug Fixes

#### 1. Fixed outs_before Tracking (`app/core/game_engine.py:551`)

**Issue**: Play records had incorrect `outs_before` values due to wrong calculation.

**Root Cause**:
```python
# WRONG - calculated after outs were applied
"outs_before": state.outs - result.outs_recorded
```

**Fix**:
```python
# CORRECT - captures outs BEFORE applying result
"outs_before": state.outs
```

**Why It Works**:
- `_save_play_to_db()` is called in STEP 2 (before result is applied)
- `_apply_play_result()` is called in STEP 3 (after save)
- `state.outs` already contains the correct "outs before" value at save time

**Impact**: All play records now have accurate out counts for historical analysis.

#### 2. Fixed Game Recovery (`app/core/state_manager.py:312-314`)

**Issue**: Recovered games crashed with `AttributeError: 'GameState' object has no attribute 'runners'`

**Root Cause**: Logging statement tried to access non-existent `state.runners` attribute.

**Fix**:
```python
# Count runners on base using the correct method
runners_on_base = len(state.get_all_runners())
logger.info(f"Rebuilt state for game {state.game_id}: {state.play_count} plays, {runners_on_base} runners")
```

**Impact**: Games can now be properly recovered from database after server restart or REPL exit.

### Testing Notes

**Integration Tests**:
- Known issue: Integration tests in `tests/integration/test_game_engine.py` must be run individually
- Reason: Database connection pooling conflicts when running in parallel
- Workaround: `uv run pytest tests/integration/test_game_engine.py::TestClassName::test_method -v`
- All tests pass when run individually

**Terminal Client**:
- Best tool for testing game engine optimizations
- REPL mode maintains persistent state and event loop
- See `terminal_client/CLAUDE.md` for usage

### Game Models Refactor

**Simplified Runner Management** (`app/models/game_models.py`)

**Problem**: `RunnerState` class was redundant and created complexity.
- Had separate `List[RunnerState]` for tracking runners
- Required list management operations (add, remove, filter)
- Didn't match database structure (plays table has on_first_id, on_second_id, on_third_id)
- Extra layer of indirection

**Solution**: Direct base references in GameState
```python
# Before (redundant)
runners: List[RunnerState] = Field(default_factory=list)

# After (direct)
on_first: Optional[LineupPlayerState] = None
on_second: Optional[LineupPlayerState] = None
on_third: Optional[LineupPlayerState] = None
```

**Benefits**:
- ✅ Matches database structure exactly
- ✅ Simpler state management (direct assignment vs list operations)
- ✅ Better type safety (LineupPlayerState vs generic runner)
- ✅ Easier to work with in game engine
- ✅ Fewer lines of code

**Updated Methods**:
- `get_runner_at_base(base: int) -> Optional[LineupPlayerState]` - Returns direct reference
- `get_all_runners() -> List[Tuple[int, LineupPlayerState]]` - Returns list when needed
- `is_runner_on_first/second/third()` - Simple `is not None` checks

**Impact**: All tests updated and passing. Game engine logic simplified.

### Terminal Client Modularization

**Problem**: Code duplication between CLI (`main.py`) and REPL (`repl.py`)
- Same command logic in two places
- Hard to maintain consistency
- Difficult to test

**Solution**: Modular architecture with shared modules

**New Modules Created**:

1. **`terminal_client/commands.py`** (10,243 bytes)
   - Shared command functions: `submit_defensive_decision`, `submit_offensive_decision`, `resolve_play`
   - Used by both CLI and REPL
   - Single source of truth for command logic
   - Fully tested independently

2. **`terminal_client/arg_parser.py`** (7,280 bytes)
   - Centralized argument parsing and validation
   - Handles defensive/offensive decision arguments
   - Validates formats (alignment, depths, hold runners, steal attempts)
   - Reusable across both interfaces

3. **`terminal_client/completions.py`** (10,357 bytes)
   - TAB completion support for REPL mode
   - Command completions, option completions
   - Dynamic completions (game IDs, defensive/offensive options)
   - Improves REPL user experience

4. **`terminal_client/help_text.py`** (10,839 bytes)
   - Centralized help text and command documentation
   - Detailed command descriptions and usage examples
   - Consistent help across CLI and REPL
   - Easy to update in one place

**Benefits**:
- ✅ DRY principle - no code duplication
- ✅ Behavior consistent between CLI and REPL modes
- ✅ Easier to maintain (changes in one place)
- ✅ Better testability (modules tested independently)
- ✅ Clear separation of concerns
- ✅ Improved user experience (completions, better help)

**Test Coverage**:
- `tests/unit/terminal_client/test_commands.py`
- `tests/unit/terminal_client/test_arg_parser.py`
- `tests/unit/terminal_client/test_completions.py`
- `tests/unit/terminal_client/test_help_text.py`

**File Structure**:
```
terminal_client/
├── __init__.py
├── main.py              # CLI entry point (simplified)
├── repl.py              # REPL mode (simplified)
├── display.py           # Display formatting
├── config.py            # Configuration
├── commands.py          # NEW - Shared command logic
├── arg_parser.py        # NEW - Argument parsing
├── completions.py       # NEW - TAB completions
└── help_text.py         # NEW - Help documentation
```

### Summary of 2025-10-28 Updates

**Total Changes**:
- 36 files modified/created
- +9,034 lines added
- -645 lines removed
- 2 git commits

**Major Improvements**:

1. **Player Models** (Week 6 - 50% Complete)
   - BasePlayer, SbaPlayer, PdPlayer with factory methods
   - 32 comprehensive tests (all passing)
   - Single-layer architecture (simpler than planned two-layer)
   - Ready for API integration

2. **Performance Optimizations**
   - 60-70% database query reduction
   - Lineup caching eliminates redundant SELECTs
   - Conditional updates only when state changes
   - ~50-100ms play resolution (was ~150-200ms)

3. **Model Simplification**
   - Removed redundant RunnerState class
   - Direct base references match DB structure
   - Cleaner game engine logic

4. **Terminal Client Enhancement**
   - Modularized into 4 shared modules
   - DRY principle - no duplication between CLI/REPL
   - TAB completions for better UX
   - Comprehensive test coverage

5. **Bug Fixes**
   - outs_before tracking corrected
   - Game recovery AttributeError fixed
   - Enhanced status display with action guidance

**Test Status**:
- All existing tests passing
- 32 new player model tests
- 4 new terminal client test suites
- Integration tests verified

**Documentation**:
- Player models documented in CLAUDE.md
- Week 6 status assessment created
- Terminal client modularization documented
- Architecture decisions explained

**Next Priorities** (Week 6 Remaining):
1. Configuration system (BaseConfig, SbaConfig, PdConfig)
2. Result charts & PD play resolution with ratings
3. API client (deferred for now)

---

## References

- **Implementation Guide**: `../.claude/implementation/01-infrastructure.md`
- **Backend Architecture**: `../.claude/implementation/backend-architecture.md`
- **Week 4 Plan**: `../.claude/implementation/02-week4-state-management.md`
- **Week 5 Plan**: `../.claude/implementation/02-week5-game-logic.md`
- **Player Data Catalog**: `../.claude/implementation/player-data-catalog.md`
- **WebSocket Protocol**: `../.claude/implementation/websocket-protocol.md`
- **Database Design**: `../.claude/implementation/database-design.md`
- **Full PRD**: `../prd-web-scorecard-1.1.md`

---

**Current Phase**: Phase 2 - Week 6 (Player Models & League Integration)

**Completion Status**:
- ✅ Phase 1 (Infrastructure): Complete (2025-10-21)
- ✅ Week 4 (State Management): Complete (2025-10-22)
- ✅ Week 5 (Game Logic): Complete (2025-10-26)
  - Game engine orchestration
  - Play resolver with dice system
  - Full at-bat flow working
  - Terminal client for testing
- 🟡 Week 6 (Player Models & League Features): ~50% Complete (2025-10-28)
  - ✅ Player models (BasePlayer, SbaPlayer, PdPlayer)
  - ✅ Factory methods for API parsing
  - ✅ Comprehensive test coverage (32/32 tests passing)
  - ⏳ Configuration system (not started)
  - ⏳ Result charts & PD integration (not started)
  - ⏳ API client (deferred)

---

## Week 6: League Configuration & Play Outcome System (2025-10-28)

**Status**: 75% Complete
**Phase**: Phase 2 - Week 6 (League Features & Integration)

### Overview

Implemented foundational configuration and outcome systems for both SBA and PD leagues, establishing the framework for card-based play resolution.

### Components Implemented

#### 1. League Configuration System ✅

**Location**: `app/config/`

Provides immutable, league-specific configuration objects for game rules and API endpoints.

**Files Created**:
- `app/config/base_config.py` - Abstract base configuration
- `app/config/league_configs.py` - SBA and PD implementations
- `app/config/__init__.py` - Public API
- `tests/unit/config/test_league_configs.py` - 28 tests (all passing)

**Key Features**:
```python
from app.config import get_league_config

# Get league-specific config
config = get_league_config("sba")
api_url = config.get_api_base_url()  # "https://api.sba.manticorum.com"
chart = config.get_result_chart_name()  # "sba_standard_v1"

# Configs are immutable (frozen=True)
# config.innings = 7  # Raises ValidationError
```

**League Differences**:
- **SBA**: Manual result selection, simple player data
- **PD**: Flexible selection (manual or auto), detailed scouting data, cardset validation, advanced analytics

**Config Registry**:
```python
LEAGUE_CONFIGS = {
    "sba": SbaConfig(),
    "pd": PdConfig()
}
```

#### 2. PlayOutcome Enum ✅

**Location**: `app/config/result_charts.py`

Universal enum defining all possible play outcomes for both leagues.

**Files Created**:
- `app/config/result_charts.py` - PlayOutcome enum with helpers
- `tests/unit/config/test_play_outcome.py` - 30 tests (all passing)

**Outcome Categories**:
```python
from app.config import PlayOutcome

# Standard outcomes
PlayOutcome.STRIKEOUT
PlayOutcome.SINGLE
PlayOutcome.HOMERUN

# Uncapped hits (pitching cards only)
PlayOutcome.SINGLE_UNCAPPED  # si(cf) - triggers advancement decisions
PlayOutcome.DOUBLE_UNCAPPED  # do(cf) - triggers advancement decisions

# Interrupt plays (logged with pa=0)
PlayOutcome.WILD_PITCH       # Play.wp = 1
PlayOutcome.PASSED_BALL      # Play.pb = 1
PlayOutcome.STOLEN_BASE      # Play.sb = 1
PlayOutcome.CAUGHT_STEALING  # Play.cs = 1

# Ballpark power (PD specific)
PlayOutcome.BP_HOMERUN       # Play.bphr = 1
PlayOutcome.BP_SINGLE        # Play.bp1b = 1
```

**Helper Methods**:
```python
outcome = PlayOutcome.SINGLE_UNCAPPED

outcome.is_hit()              # True
outcome.is_out()              # False
outcome.is_uncapped()         # True - requires decision tree
outcome.is_interrupt()        # False
outcome.get_bases_advanced()  # 1
```

#### 3. Card-Based Resolution System

**Resolution Mechanics** (Both SBA and PD):
1. Roll 1d6 → determines column (1-3: batter card, 4-6: pitcher card)
2. Roll 2d6 → selects row 2-12 on that card
3. Roll 1d20 → resolves split results (e.g., 1-16: HR, 17-20: 2B)
4. Outcome from card = `PlayOutcome` enum value

**League Differences**:
- **PD**: Card data digitized in `PdBattingRating`/`PdPitchingRating`
  - Can auto-resolve using probabilities
  - OR manual selection by players
- **SBA**: Physical cards only (not digitized)
  - Players read physical cards and manually enter outcomes
  - System validates and records the human-selected outcome

### Testing

**Test Coverage**:
- 28 config tests (league configs, registry, immutability)
- 30 PlayOutcome tests (helpers, categorization, edge cases)
- **Total: 58 tests, all passing**

**Test Files**:
```
tests/unit/config/
├── test_league_configs.py    # Config system tests
└── test_play_outcome.py       # Outcome enum tests
```

### Architecture Decisions

**1. Immutable Configs**
- Used Pydantic `frozen=True` to prevent accidental modification
- Configs are singletons in registry
- Type-safe with full validation

**2. Universal PlayOutcome Enum**
- Single source of truth for all possible outcomes
- Works for both SBA and PD leagues
- Helper methods reduce duplicate logic in resolvers

**3. No Static Result Charts**
- Originally planned d20 charts for SBA
- Realized both leagues use same card-based mechanics
- Charts come from player card data (PD) or manual entry (SBA)

### Integration Points

**With Game Engine**:
```python
from app.config import get_league_config, PlayOutcome

# Get league config
config = get_league_config(state.league_id)

# Resolve outcome (from card or manual entry)
outcome = PlayOutcome.SINGLE_UNCAPPED

# Handle based on outcome type
if outcome.is_uncapped() and state.on_base_code > 0:
    # Trigger advancement decision tree
    present_advancement_options()
elif outcome.is_interrupt():
    # Log interrupt play (pa=0)
    log_interrupt_play(outcome)
else:
    # Standard play resolution
    resolve_standard_play(outcome)
```

### Remaining Work (Week 6)

**1. Dice System Update** ⏳
- Rename `check_d20` → `chaos_d20` in AbRoll
- Update all references

**2. PlayResolver Integration** ⏳
- Replace old `PlayOutcome` enum with new one
- Use `PlayOutcome` throughout resolution logic
- Handle uncapped hit decision trees

**3. Play.metadata Support** ⏳
- Add JSON metadata field for uncapped hit tracking
- Log `{"uncapped": true}` when applicable

### Key Files

```
app/config/
├── __init__.py                    # Public API
├── base_config.py                 # Abstract base config
├── league_configs.py              # SBA/PD implementations
└── result_charts.py               # PlayOutcome enum

tests/unit/config/
├── test_league_configs.py         # 28 tests
└── test_play_outcome.py           # 30 tests
```

---

**Next Priorities**:
1. Update dice system (chaos_d20)
2. Integrate PlayOutcome into PlayResolver
3. Add Play.metadata support for uncapped hits
4. Complete week 6 remaining work

**Python Version**: 3.13.3
**Database Server**: 10.10.0.42:5432

**Implementation Status**: Week 6 - 75% Complete (Config & PlayOutcome ✅, Integration pending)

## Database Model Updates (2025-10-21)

Enhanced all database models based on proven Discord game implementation:

### Changes from Initial Design:
- ✅ Added `GameCardsetLink` and `RosterLink` tables for PD league cardset management
- ✅ Enhanced `Game` model with AI opponent support (`home_team_is_ai`, `away_team_is_ai`, `ai_difficulty`)
- ✅ Added 25+ statistic fields to `Play` model (pa, ab, hit, hr, rbi, sb, wpa, re24, etc.)
- ✅ Added player reference FKs to `Play` (batter, pitcher, catcher, defender, runner)
- ✅ Added base runner tracking with `on_base_code` bit field for efficient queries
- ✅ Added game situation flags to `Play` (is_tied, is_go_ahead, is_new_inning, in_pow)
- ✅ Added play workflow flags (complete, locked)
- ✅ Enhanced `Lineup` with substitution tracking (replacing_id, after_play, is_fatigued)
- ✅ Changed strategic decisions to JSON (defensive_choices, offensive_choices)
- ✅ Added helper properties for AI decision-making (ai_is_batting, ai_is_fielding)

### Design Decisions:
- **UUID vs BigInteger**: Kept UUIDs for Game primary key (better for distributed systems)
- **AI Tracking**: Per-team booleans instead of single `ai_team` field (supports AI vs AI simulations)
- **Runner Tracking**: Removed JSON fields, using FKs + `on_base_code` for type safety
- **Cardsets**: Optional relationships - empty for SBA, required for PD
- **Relationships**: Using SQLAlchemy relationships with strategic lazy loading

## Lineup Polymorphic Migration (2025-10-23)

Updated `Lineup` model to support both PD and SBA leagues using polymorphic `card_id`/`player_id` fields, matching the `RosterLink` pattern.

### Changes:
- ✅ Made `card_id` nullable (PD league)
- ✅ Added `player_id` nullable (SBA league)
- ✅ Added XOR CHECK constraint: exactly one ID must be populated
- ✅ Created league-specific methods: `add_pd_lineup_card()` and `add_sba_lineup_player()`
- ✅ Fixed Pendulum DateTime + asyncpg compatibility issue with `.naive()`

### Archived Files:
- Migration documentation: `../../.claude/archive/LINEUP_POLYMORPHIC_MIGRATION.md`
- Migration script: `../../.claude/archive/migrate_lineup_schema.py`

**Note**: Migration has been applied to database. Script archived for reference only.

## Phase 3B: X-Check League Config Tables (2025-11-01)

Implemented complete X-Check resolution table system for defensive play outcomes.

**Status**: ✅ Complete

### Components Implemented

1. **Defense Range Tables** (`app/config/common_x_check_tables.py`)
   - Complete 20×5 tables for infield, outfield, and catcher positions
   - Maps d20 roll × defense range (1-5) → result code
   - Result codes: G1-G3, G2#/G3# (holding), SI1-SI2, F1-F3, DO2-DO3, TR3, SPD, FO, PO

2. **Error Charts** (3d6 by error rating 0-25)
   - ✅ Complete: LF/RF and CF error charts (26 ratings each)
   - ⏳ Placeholders: P, C, 1B, 2B, 3B, SS (empty dicts awaiting data)
   - Error types: RP (replay), E1-E3 (severity), NO (no error)

3. **Helper Functions**
   - `get_fielders_holding_runners(runner_bases, batter_handedness)` - Complete implementation
     - Tracks all fielders holding runners by position
     - R1: 1B + middle infielder (2B for RHB, SS for LHB)
     - R2: Middle infielder (if not already added)
     - R3: 3B
   - `get_error_chart_for_position(position)` - Maps all 9 positions to error charts

4. **League Config Integration** (`app/config/league_configs.py`)
   - Both SbaConfig and PdConfig include X-Check tables
   - Attributes: `x_check_defense_tables`, `x_check_error_charts`, `x_check_holding_runners`
   - Shared common tables for both leagues

5. **X-Check Placeholder Functions** (`app/core/runner_advancement.py`)
   - 6 placeholder functions: `x_check_g1`, `x_check_g2`, `x_check_g3`, `x_check_f1`, `x_check_f2`, `x_check_f3`
   - All return valid `AdvancementResult` structures
   - Ready for Phase 3C implementation

### Test Coverage

- ✅ 36 tests for X-Check tables (`tests/unit/config/test_x_check_tables.py`)
  - Defense table dimensions and valid result codes
  - Error chart structure validation
  - Helper function behavior
  - Integration workflows
- ✅ 9 tests for X-Check placeholders (`tests/unit/core/test_runner_advancement.py`)
  - Function signatures and return types
  - Error type acceptance
  - On-base code support

**Total**: 45/45 tests passing

### What's Pending

**Infield Error Charts** - 6 positions awaiting actual data:
- PITCHER_ERROR_CHART
- CATCHER_ERROR_CHART
- FIRST_BASE_ERROR_CHART
- SECOND_BASE_ERROR_CHART
- THIRD_BASE_ERROR_CHART
- SHORTSTOP_ERROR_CHART

Once data is provided, these empty dicts will be populated with the same structure as outfield charts.

### Next Phase

✅ **COMPLETED** - Phase 3C implemented full defensive play resolution

---

## Phase 3C: X-Check Resolution Logic (2025-11-02)

Implemented complete X-Check resolution system in PlayResolver with full integration of Phase 3B tables.

**Status**: ✅ Complete

### Components Implemented

1. **Main Resolution Method** (`_resolve_x_check()` in `app/core/play_resolver.py`)
   - 10-step resolution process from dice rolls to final outcome
   - Rolls 1d20 for defense table + 3d6 for error chart
   - Adjusts range if defender playing in
   - Looks up base result from defense table
   - Applies SPD test if needed (placeholder)
   - Converts G2#/G3# to SI2 based on conditions
   - Looks up error result from error chart
   - Determines final outcome with error overrides
   - Creates XCheckResult audit trail
   - Returns PlayResult with full details

2. **Helper Methods** (6 new methods in PlayResolver)
   - `_adjust_range_for_defensive_position()` - Range +1 if playing in (max 5)
   - `_lookup_defense_table()` - Maps d20 + range → result code
   - `_apply_hash_conversion()` - G2#/G3# → SI2 if playing in OR holding runner
   - `_lookup_error_chart()` - Maps 3d6 + error rating → error type
   - `_determine_final_x_check_outcome()` - Maps result + error → PlayOutcome

3. **Integration Points**
   - Added X_CHECK case to `resolve_outcome()` method
   - Extended PlayResult dataclass with `x_check_details: Optional[XCheckResult]`
   - Imported all Phase 3B tables: INFIELD/OUTFIELD/CATCHER defense tables
   - Imported helper functions: `get_error_chart_for_position()`, `get_fielders_holding_runners()`

### Key Features

**Defense Table Lookup**:
- Selects correct table based on position (infield/outfield/catcher)
- 0-indexed lookup: `table[d20_roll - 1][defense_range - 1]`
- Returns result codes: G1-G3, G2#/G3#, F1-F3, SI1-SI2, DO2-DO3, TR3, SPD, FO, PO

**Range Adjustment**:
- Corners in: +1 range for 1B, 3B, P, C
- Infield in: +1 range for 1B, 2B, 3B, SS, P, C
- Maximum range capped at 5

**Hash Conversion Logic**:
```python
G2# or G3# → SI2 if:
  a) Playing in (adjusted_range > base_range), OR
  b) Holding runner (position in holding_positions list)
Otherwise: G2# → G2, G3# → G3
```

**Error Chart Lookup**:
- Priority order: RP > E3 > E2 > E1 > NO
- Uses 3d6 sum (3-18) against defender's error rating
- Returns: 'RP', 'E3', 'E2', 'E1', or 'NO'

**Final Outcome Determination**:
```python
If error_result == 'NO':
  outcome = base_outcome, hit_type = "{result}_no_error"

If error_result == 'RP':
  outcome = ERROR, hit_type = "{result}_rare_play"

If error_result in ['E1', 'E2', 'E3']:
  If base_outcome is out:
    outcome = ERROR  # Error overrides
  Else:
    outcome = base_outcome  # Hit + error keeps hit
  hit_type = "{result}_plus_error_{n}"
```

### Placeholders (Future Phases)

1. **Defender Retrieval** - Currently uses placeholder ratings (TODO: lineup integration)
2. **SPD Test** - Currently defaults to G3 fail (TODO: batter speed rating)
3. **Batter Handedness** - Currently hardcoded to 'R' (TODO: player model)
4. **Runner Advancement** - Currently returns empty list (TODO Phase 3D: advancement tables)

### Testing

**Test Coverage**:
- ✅ All 9 PlayResolver tests passing
- ✅ All 36 X-Check table tests passing
- ✅ All 51 runner advancement tests passing
- ✅ 325/327 total tests passing (99.4%)
- ⚠️ 2 pre-existing failures (unrelated: dice history, config URL)

### Files Modified

```
app/core/play_resolver.py  (+397 lines, -2 lines)
  - Added X_CHECK resolution case
  - Added 6 helper methods (397 lines)
  - Extended PlayResult with x_check_details
  - Imported Phase 3B tables and helpers
```

### Next Phase

**Phase 3D**: X-Check Runner Advancement Tables
- Implement groundball advancement (G1, G2, G3)
- Implement flyball advancement (F1, F2, F3)
- Implement hit advancement with errors (SI1, SI2, DO2, DO3, TR3)
- Implement out advancement with errors (FO, PO)
- Fill in placeholder `_get_x_check_advancement()` method

---

## Phase 3E-Main: Position Ratings Integration (2025-11-03)

Integrated position ratings system enabling X-Check defensive plays to use actual player ratings from PD API with intelligent fallbacks for SBA.

**Status**: ✅ Complete - Live API verified with player 8807

### Components Implemented

1. **PD API Client** (`app/services/pd_api_client.py`)
   - Endpoint: `GET /api/v2/cardpositions?player_id={id}&position={pos}`
   - Async HTTP client using httpx
   - Optional position filtering: `get_position_ratings(8807, ['SS', '2B'])`
   - Returns `List[PositionRating]` for all positions
   - Handles both list and dict response formats
   - Comprehensive error handling

2. **Position Rating Service** (`app/services/position_rating_service.py`)
   - In-memory caching (16,601x performance improvement)
   - `get_ratings_for_card(card_id, league_id)` - All positions
   - `get_rating_for_position(card_id, position, league_id)` - Specific position
   - Singleton pattern: `position_rating_service` instance
   - TODO Phase 3E-Final: Upgrade to Redis

3. **GameState Integration** (`app/models/game_models.py`)
   - LineupPlayerState: Added `position_rating` field (Optional[PositionRating])
   - GameState: Added `get_defender_for_position(position, state_manager)` method
   - Uses StateManager's lineup cache to find active defender
   - No database lookups during play resolution

4. **League Configuration** (`app/config/league_configs.py`)
   - SbaConfig: `supports_position_ratings()` → False
   - PdConfig: `supports_position_ratings()` → True
   - Enables league-specific behavior without hardcoded conditionals

5. **PlayResolver Integration** (`app/core/play_resolver.py`)
   - Added `state_manager` parameter to constructor
   - `_resolve_x_check()`: Replaced placeholder ratings with actual lookup
   - Uses league config check: `config.supports_position_ratings()`
   - Falls back to defaults (range=3, error=15) if unavailable

6. **Game Start Rating Loader** (`app/core/game_engine.py`)
   - `_load_position_ratings_for_lineup()` method
   - Loads all position ratings at game start for PD league
   - Skips loading for SBA (league config check)
   - Called in `start_game()` for both teams
   - Logs: "Loaded X/9 position ratings for team Y"

### Live API Testing

**Verified with Player 8807** (7 positions):
```
Position   Range    Error    Innings
CF         3        2        372
2B         3        8        212
SS         4        12       159
RF         2        2        74
LF         3        2        62
1B         4        0        46
3B         3        65       34
```

**Performance**:
- API call: 0.214s
- Cache hit: 0.000s
- Speedup: 16,601x

### X-Check Resolution Flow

1. Check league config: `supports_position_ratings()`?
2. Get defender: `state.get_defender_for_position(pos, state_manager)`
3. If PD + `defender.position_rating` exists: Use actual range/error
4. Else if defender found: Use defaults (range=3, error=15)
5. Else: Log warning, use defaults

### Testing

**Live Integration**:
- ✅ Real API: Player 8807 → 7 positions retrieved
- ✅ Caching: 16,601x performance improvement
- ✅ League configs: SBA skips API, PD fetches ratings
- ✅ GameState: Defender lookup working
- ✅ Existing tests: 27/28 config tests passing

**Test Files Created**:
- `test_pd_api_live.py` - Live API integration test
- `test_pd_api_mock.py` - Mock test for CI/CD
- `tests/integration/test_position_ratings_api.py` - Pytest suite

### Files Created/Modified

**Created**:
```
app/services/__init__.py              - Package exports
app/services/pd_api_client.py         - PD API client (97 lines)
app/services/position_rating_service.py - Caching service (120 lines)
```

**Modified**:
```
app/models/game_models.py             - Added position_rating field, get_defender_for_position()
app/config/league_configs.py          - Added supports_position_ratings()
app/core/play_resolver.py             - Integrated actual ratings lookup
app/core/game_engine.py                - Load ratings at game start
```

### Key Features

**League-Aware Behavior**:
- PD: Fetches ratings from API with caching
- SBA: Skips API calls, uses defaults

**Self-Contained GameState**:
- All X-Check data in memory (no lookups during resolution)
- Direct access: `defender.position_rating.range`

**Graceful Degradation**:
- API unavailable → Use defaults
- Player has no rating → Use defaults
- Defaults: range=3 (average), error=15 (average)

### Next Phase

**Phase 3E-Final**: WebSocket Events & Full Integration
- WebSocket event handlers for X-Check UI
- Upgrade to Redis caching
- Full defensive lineup in GameState (all 9 positions)
- Manual vs Auto mode workflows

---

**Updated**: 2025-11-03
**Total Unit Tests**: 325 passing (2 pre-existing failures in unrelated systems)
**Live API**: Verified with PD player 8807