strat-gameplay-webapp/backend/CLAUDE.md
Cal Corum 3c5055dbf6 CLAUDE: Implement polymorphic RosterLink for both PD and SBA leagues
Added league-agnostic roster tracking with single-table design:

Database Changes:
- Modified RosterLink model with surrogate primary key (id)
- Added nullable card_id (PD) and player_id (SBA) columns
- Added CHECK constraint ensuring exactly one ID populated (XOR logic)
- Added unique constraints for (game_id, card_id) and (game_id, player_id)
- Imported CheckConstraint and UniqueConstraint from SQLAlchemy

New Files:
- app/models/roster_models.py: Pydantic models for type safety
  - BaseRosterLinkData: Abstract base class
  - PdRosterLinkData: PD league card-based rosters
  - SbaRosterLinkData: SBA league player-based rosters
  - RosterLinkCreate: Request validation model

- tests/unit/models/test_roster_models.py: 24 unit tests (all passing)
  - Tests for PD/SBA roster link creation and validation
  - Tests for RosterLinkCreate XOR validation
  - Tests for polymorphic behavior

Database Operations:
- add_pd_roster_card(): Add PD card to game roster
- add_sba_roster_player(): Add SBA player to game roster
- get_pd_roster(): Get PD cards with optional team filter
- get_sba_roster(): Get SBA players with optional team filter
- remove_roster_entry(): Remove roster entry by ID

Tests:
- Added 12 integration tests for roster operations
- Fixed setup_database fixture scope (module → function)

Documentation:
- Updated backend/CLAUDE.md with RosterLink documentation
- Added usage examples and design rationale
- Updated Game model relationship description

Design Pattern:
Single table with application-layer type safety rather than SQLAlchemy
polymorphic inheritance. Simpler queries, database-enforced integrity,
and Pydantic type safety at application layer.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 22:45:44 -05:00

985 lines
29 KiB
Markdown

# 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
├── 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
### Daily Development
```bash
# Activate virtual environment
source venv/bin/activate
# Start Redis (in separate terminal or use -d for detached)
docker compose up -d
# Run backend with hot-reload
python -m app.main
# Backend available at http://localhost:8000
# API docs at http://localhost:8000/docs
```
### Testing
```bash
# Run all tests
pytest tests/ -v
# Run with coverage
pytest tests/ --cov=app --cov-report=html
# Run specific test file
pytest tests/unit/test_game_engine.py -v
# Type checking
mypy app/
# Code formatting
black app/ tests/
# Linting
flake8 app/ tests/
```
## 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
```
## 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)
- **Virtual Environment**: Located at `backend/venv/`
- **Activation**: `source venv/bin/activate` (from backend directory)
### 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
pytest tests/unit/ -v
# Integration tests (requires database)
pytest tests/integration/ -v -m integration
# Specific file
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)
---
## 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 - Game Engine Core (Week 4 Complete)
**Next Phase**: Phase 2 - Game Logic (Week 5)
**Phase 1 Completed**: 2025-10-21
**Week 4 Completed**: 2025-10-22
**Python Version**: 3.13.3
**Database Server**: 10.10.0.42:5432
## 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