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>
985 lines
29 KiB
Markdown
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 |