Add comprehensive project documentation and Docker infrastructure for Paper Dynasty Real-Time Game Engine - a web-based multiplayer baseball simulation platform replacing the legacy Google Sheets system. Documentation Added: - Complete PRD (Product Requirements Document) - Project README with dual development workflows - Implementation guide with 5-phase roadmap - Architecture docs (backend, frontend, database, WebSocket) - CLAUDE.md context files for each major directory Infrastructure Added: - Root docker-compose.yml for full stack orchestration - Dockerfiles for backend and both frontends (multi-stage builds) - .dockerignore files for optimal build context - .env.example with all required configuration - Updated .gitignore for Python, Node, Nuxt, and Docker Project Structure: - backend/ - FastAPI + Socket.io game engine (Python 3.11+) - frontend-sba/ - SBA League Nuxt 3 frontend - frontend-pd/ - PD League Nuxt 3 frontend - .claude/implementation/ - Detailed implementation guides Supports two development workflows: 1. Local dev (recommended): Services run natively with hot-reload 2. Full Docker: One-command stack orchestration for testing/demos Next: Phase 1 implementation (backend/frontend foundations)
484 lines
15 KiB
Markdown
484 lines
15 KiB
Markdown
# Backend Architecture
|
|
|
|
## Overview
|
|
|
|
FastAPI-based game backend serving as the central game engine for both SBA and PD leagues. Handles real-time WebSocket communication, game state management, and database persistence.
|
|
|
|
## Directory Structure
|
|
|
|
```
|
|
backend/
|
|
├── app/
|
|
│ ├── main.py # FastAPI app initialization, CORS, middleware
|
|
│ ├── config.py # Environment variables, settings
|
|
│ │
|
|
│ ├── core/ # Core game logic
|
|
│ │ ├── __init__.py
|
|
│ │ ├── game_engine.py # Main game simulation engine
|
|
│ │ ├── state_manager.py # In-memory state management
|
|
│ │ ├── play_resolver.py # Play outcome resolution logic
|
|
│ │ ├── dice.py # Cryptographic random roll generation
|
|
│ │ └── validators.py # Game rule validation
|
|
│ │
|
|
│ ├── config/ # League configurations
|
|
│ │ ├── __init__.py
|
|
│ │ ├── base_config.py # Shared BaseGameConfig
|
|
│ │ ├── league_configs.py # SBA/PD specific configs
|
|
│ │ ├── result_charts.py # d20 outcome tables
|
|
│ │ └── loader.py # Config loading utilities
|
|
│ │
|
|
│ ├── models/ # Data models
|
|
│ │ ├── __init__.py
|
|
│ │ ├── game_models.py # Pydantic models for game state
|
|
│ │ ├── player_models.py # Polymorphic player models
|
|
│ │ ├── db_models.py # SQLAlchemy ORM models
|
|
│ │ └── api_schemas.py # Request/response schemas
|
|
│ │
|
|
│ ├── websocket/ # WebSocket handling
|
|
│ │ ├── __init__.py
|
|
│ │ ├── connection_manager.py # Connection lifecycle
|
|
│ │ ├── events.py # Event definitions
|
|
│ │ ├── handlers.py # Action handlers
|
|
│ │ └── rooms.py # Socket.io room management
|
|
│ │
|
|
│ ├── api/ # REST API endpoints
|
|
│ │ ├── __init__.py
|
|
│ │ ├── routes/
|
|
│ │ │ ├── games.py # Game CRUD operations
|
|
│ │ │ ├── auth.py # Discord OAuth endpoints
|
|
│ │ │ └── health.py # Health check endpoints
|
|
│ │ └── dependencies.py # FastAPI dependencies
|
|
│ │
|
|
│ ├── database/ # Database operations
|
|
│ │ ├── __init__.py
|
|
│ │ ├── session.py # DB connection management
|
|
│ │ ├── operations.py # Async DB operations
|
|
│ │ └── migrations/ # Alembic migrations
|
|
│ │
|
|
│ ├── data/ # External data integration
|
|
│ │ ├── __init__.py
|
|
│ │ ├── api_client.py # League REST API client
|
|
│ │ ├── cache.py # Optional caching layer
|
|
│ │ └── models.py # API response models
|
|
│ │
|
|
│ └── utils/ # Utilities
|
|
│ ├── __init__.py
|
|
│ ├── logging.py # Logging configuration
|
|
│ ├── auth.py # JWT token handling
|
|
│ └── errors.py # Custom exceptions
|
|
│
|
|
├── tests/
|
|
│ ├── unit/ # Unit tests
|
|
│ │ ├── test_game_engine.py
|
|
│ │ ├── test_dice.py
|
|
│ │ ├── test_play_resolver.py
|
|
│ │ └── test_player_models.py
|
|
│ ├── integration/ # Integration tests
|
|
│ │ ├── test_websocket.py
|
|
│ │ ├── test_database.py
|
|
│ │ └── test_api_client.py
|
|
│ └── e2e/ # End-to-end tests
|
|
│ └── test_full_game.py
|
|
│
|
|
├── alembic.ini # Alembic configuration
|
|
├── requirements.txt # Python dependencies
|
|
├── requirements-dev.txt # Development dependencies
|
|
├── docker-compose.yml # Local development setup
|
|
├── Dockerfile # Production container
|
|
└── pytest.ini # Pytest configuration
|
|
```
|
|
|
|
## Core Components
|
|
|
|
### 1. Game Engine (`core/game_engine.py`)
|
|
|
|
**Responsibilities**:
|
|
- Manage in-memory game state
|
|
- Process player actions
|
|
- Coordinate play resolution
|
|
- Emit events to WebSocket clients
|
|
|
|
**Key Methods**:
|
|
```python
|
|
class GameEngine:
|
|
def __init__(self, game_id: str, config: LeagueConfig)
|
|
async def start_game(self) -> None
|
|
async def process_defensive_positioning(self, user_id: str, positioning: str) -> PlayOutcome
|
|
async def process_stolen_base_attempt(self, runner_positions: List[str]) -> PlayOutcome
|
|
async def process_offensive_approach(self, approach: str) -> PlayOutcome
|
|
async def make_substitution(self, card_id: int, position: str) -> None
|
|
async def change_pitcher(self, card_id: int) -> None
|
|
def get_current_state(self) -> GameState
|
|
async def persist_play(self, play: Play) -> None
|
|
```
|
|
|
|
### 2. State Manager (`core/state_manager.py`)
|
|
|
|
**Responsibilities**:
|
|
- Hold active game states in memory
|
|
- Provide fast read/write access
|
|
- Handle state recovery from database
|
|
- Manage state lifecycle (creation, updates, cleanup)
|
|
|
|
**Key Methods**:
|
|
```python
|
|
class StateManager:
|
|
def create_game_state(self, game_id: str, config: LeagueConfig) -> GameState
|
|
def get_game_state(self, game_id: str) -> Optional[GameState]
|
|
def update_state(self, game_id: str, updates: dict) -> None
|
|
async def recover_state(self, game_id: str) -> GameState
|
|
def remove_state(self, game_id: str) -> None
|
|
def get_active_games(self) -> List[str]
|
|
```
|
|
|
|
**Data Structure**:
|
|
```python
|
|
@dataclass
|
|
class GameState:
|
|
game_id: str
|
|
league_id: str
|
|
inning: int
|
|
half: str # 'top' or 'bottom'
|
|
outs: int
|
|
balls: int
|
|
strikes: int
|
|
home_score: int
|
|
away_score: int
|
|
runners: Dict[str, Optional[int]] # {'first': card_id, 'second': None, 'third': card_id}
|
|
current_batter_idx: int
|
|
lineups: Dict[str, List[Lineup]] # {'home': [...], 'away': [...]}
|
|
current_decisions: Dict[str, Any] # Pending decisions
|
|
play_history: List[int] # Play IDs
|
|
metadata: dict
|
|
```
|
|
|
|
### 3. Play Resolver (`core/play_resolver.py`)
|
|
|
|
**Responsibilities**:
|
|
- Roll dice and determine outcomes
|
|
- Apply league-specific result charts
|
|
- Calculate runner advancement
|
|
- Update game state based on outcome
|
|
|
|
**Key Methods**:
|
|
```python
|
|
class PlayResolver:
|
|
def __init__(self, config: LeagueConfig)
|
|
def roll_dice(self) -> int
|
|
def resolve_at_bat(
|
|
self,
|
|
batter: PdPlayer | SbaPlayer,
|
|
pitcher: PdPlayer | SbaPlayer,
|
|
defensive_positioning: str,
|
|
offensive_approach: str,
|
|
count: Dict[str, int]
|
|
) -> PlayOutcome
|
|
def advance_runners(
|
|
self,
|
|
hit_type: str,
|
|
runners_before: Dict[str, Optional[int]],
|
|
hit_location: Optional[str] = None
|
|
) -> Tuple[Dict[str, Optional[int]], int] # (runners_after, runs_scored)
|
|
```
|
|
|
|
### 4. Polymorphic Player Models (`models/player_models.py`)
|
|
|
|
**Class Hierarchy**:
|
|
```python
|
|
class BasePlayer(BaseModel, ABC):
|
|
id: int
|
|
name: str
|
|
|
|
@abstractmethod
|
|
def get_image_url(self) -> str:
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_display_data(self) -> dict:
|
|
pass
|
|
|
|
class SbaPlayer(BasePlayer):
|
|
image: str
|
|
team: Optional[str] = None
|
|
manager: Optional[str] = None
|
|
|
|
def get_image_url(self) -> str:
|
|
return self.image
|
|
|
|
class PdPlayer(BasePlayer):
|
|
image: str
|
|
scouting_data: dict
|
|
ratings: dict
|
|
probabilities: dict
|
|
|
|
def get_image_url(self) -> str:
|
|
return self.image
|
|
|
|
class Lineup(BaseModel):
|
|
id: int
|
|
game_id: str
|
|
card_id: int
|
|
position: str
|
|
batting_order: Optional[int] = None
|
|
player: Union[SbaPlayer, PdPlayer]
|
|
|
|
@classmethod
|
|
def from_api_data(cls, game_config: LeagueConfig, api_data: dict) -> 'Lineup':
|
|
player_data = api_data.pop('player')
|
|
if game_config.league_id == "sba":
|
|
player = SbaPlayer(**player_data)
|
|
elif game_config.league_id == "pd":
|
|
player = PdPlayer(**player_data)
|
|
else:
|
|
raise ValueError(f"Unknown league: {game_config.league_id}")
|
|
return cls(player=player, **api_data)
|
|
```
|
|
|
|
### 5. WebSocket Connection Manager (`websocket/connection_manager.py`)
|
|
|
|
**Responsibilities**:
|
|
- Manage Socket.io connections
|
|
- Handle room assignments (game rooms)
|
|
- Broadcast events to participants
|
|
- Handle disconnections and reconnections
|
|
|
|
**Key Methods**:
|
|
```python
|
|
class ConnectionManager:
|
|
def __init__(self, sio: socketio.AsyncServer)
|
|
async def connect(self, sid: str, user_id: str) -> None
|
|
async def disconnect(self, sid: str) -> None
|
|
async def join_game(self, sid: str, game_id: str, role: str) -> None
|
|
async def leave_game(self, sid: str, game_id: str) -> None
|
|
async def broadcast_to_game(self, game_id: str, event: str, data: dict) -> None
|
|
async def emit_to_user(self, sid: str, event: str, data: dict) -> None
|
|
def get_game_participants(self, game_id: str) -> List[str]
|
|
```
|
|
|
|
## Data Flow
|
|
|
|
### Game Action Processing
|
|
|
|
```
|
|
1. Client sends WebSocket event
|
|
↓
|
|
2. handlers.py receives and validates
|
|
↓
|
|
3. Verify user authorization
|
|
↓
|
|
4. Load game state from StateManager
|
|
↓
|
|
5. GameEngine processes action
|
|
↓
|
|
6. PlayResolver determines outcome (if dice roll needed)
|
|
↓
|
|
7. StateManager updates in-memory state
|
|
↓
|
|
8. Database operations (async, non-blocking)
|
|
↓
|
|
9. ConnectionManager broadcasts update to all clients
|
|
↓
|
|
10. Clients update UI
|
|
```
|
|
|
|
### State Recovery on Reconnect
|
|
|
|
```
|
|
1. Client reconnects to WebSocket
|
|
↓
|
|
2. Client sends join_game event
|
|
↓
|
|
3. Check if state exists in StateManager
|
|
↓
|
|
4. If not in memory:
|
|
a. Load game metadata from database
|
|
b. Load all plays from database
|
|
c. Replay plays through GameEngine
|
|
d. Store reconstructed state in StateManager
|
|
↓
|
|
5. Send current state to client
|
|
↓
|
|
6. Client resumes gameplay
|
|
```
|
|
|
|
## League Configuration System
|
|
|
|
### Config Loading
|
|
|
|
```python
|
|
# config/base_config.py
|
|
@dataclass
|
|
class BaseGameConfig:
|
|
innings: int = 9
|
|
outs_per_inning: int = 3
|
|
strikes_for_out: int = 3
|
|
balls_for_walk: int = 4
|
|
roster_size: int = 26
|
|
positions: List[str] = field(default_factory=lambda: ['C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF'])
|
|
|
|
# config/league_configs.py
|
|
@dataclass
|
|
class SbaConfig(BaseGameConfig):
|
|
league_id: str = "sba"
|
|
league_name: str = "Super Baseball Alliance"
|
|
api_url: str = field(default_factory=lambda: os.getenv("SBA_API_URL"))
|
|
result_charts: dict = field(default_factory=lambda: load_sba_charts())
|
|
special_rules: dict = field(default_factory=dict)
|
|
|
|
@dataclass
|
|
class PdConfig(BaseGameConfig):
|
|
league_id: str = "pd"
|
|
league_name: str = "Paper Dynasty"
|
|
api_url: str = field(default_factory=lambda: os.getenv("PD_API_URL"))
|
|
result_charts: dict = field(default_factory=lambda: load_pd_charts())
|
|
special_rules: dict = field(default_factory=dict)
|
|
|
|
# config/loader.py
|
|
def get_league_config(league_id: str) -> BaseGameConfig:
|
|
configs = {
|
|
"sba": SbaConfig,
|
|
"pd": PdConfig
|
|
}
|
|
if league_id not in configs:
|
|
raise ValueError(f"Unknown league: {league_id}")
|
|
return configs[league_id]()
|
|
```
|
|
|
|
## Database Integration
|
|
|
|
### Async Operations
|
|
|
|
All database operations use async patterns to avoid blocking game logic:
|
|
|
|
```python
|
|
# database/operations.py
|
|
async def save_play(play: Play) -> int:
|
|
"""Save play to database asynchronously"""
|
|
async with get_session() as session:
|
|
db_play = DbPlay(**play.dict())
|
|
session.add(db_play)
|
|
await session.commit()
|
|
await session.refresh(db_play)
|
|
return db_play.id
|
|
|
|
async def load_game_plays(game_id: str) -> List[Play]:
|
|
"""Load all plays for a game"""
|
|
async with get_session() as session:
|
|
result = await session.execute(
|
|
select(DbPlay)
|
|
.where(DbPlay.game_id == game_id)
|
|
.order_by(DbPlay.play_number)
|
|
)
|
|
db_plays = result.scalars().all()
|
|
return [Play.from_orm(p) for p in db_plays]
|
|
```
|
|
|
|
## Security Considerations
|
|
|
|
### Authentication
|
|
- Discord OAuth tokens validated on REST endpoints
|
|
- WebSocket connections require valid JWT token
|
|
- Token expiration and refresh handled
|
|
|
|
### Authorization
|
|
- Verify user owns team before allowing actions
|
|
- Spectator permissions enforced (read-only)
|
|
- Rate limiting on API endpoints
|
|
|
|
### Game Integrity
|
|
- All rule validation server-side
|
|
- Cryptographically secure dice rolls
|
|
- Audit trail of all plays
|
|
- State validation before each action
|
|
|
|
## Performance Optimizations
|
|
|
|
### In-Memory State
|
|
- Active games kept in memory for fast access
|
|
- Idle games evicted after timeout (configurable)
|
|
- State recovery only when needed
|
|
|
|
### Async Database Writes
|
|
- Play persistence doesn't block game logic
|
|
- Connection pooling for efficiency
|
|
- Batch writes where appropriate
|
|
|
|
### Caching Layer (Optional)
|
|
- Cache team/roster data from league APIs
|
|
- Redis for distributed caching (if multi-server)
|
|
- TTL-based cache invalidation
|
|
|
|
## Logging Strategy
|
|
|
|
### Log Levels
|
|
- **DEBUG**: State transitions, action processing details
|
|
- **INFO**: Game started/completed, player joins/leaves
|
|
- **WARNING**: Invalid actions, timeouts, retry attempts
|
|
- **ERROR**: Database errors, API failures, state corruption
|
|
- **CRITICAL**: System failures requiring immediate attention
|
|
|
|
### Log Format
|
|
```python
|
|
import logging
|
|
|
|
logger = logging.getLogger(f'{__name__}.GameEngine')
|
|
|
|
# Usage
|
|
logger.info(f"Game {game_id} started", extra={
|
|
"game_id": game_id,
|
|
"league_id": league_id,
|
|
"home_team": home_team_id,
|
|
"away_team": away_team_id
|
|
})
|
|
```
|
|
|
|
## Error Handling
|
|
|
|
### Custom Exceptions
|
|
```python
|
|
class GameEngineError(Exception):
|
|
"""Base exception for game engine errors"""
|
|
pass
|
|
|
|
class InvalidActionError(GameEngineError):
|
|
"""Raised when action violates game rules"""
|
|
pass
|
|
|
|
class StateRecoveryError(GameEngineError):
|
|
"""Raised when state cannot be recovered"""
|
|
pass
|
|
|
|
class UnauthorizedActionError(GameEngineError):
|
|
"""Raised when user not authorized for action"""
|
|
pass
|
|
```
|
|
|
|
### Error Recovery
|
|
- Automatic reconnection for database issues
|
|
- State recovery from last known good state
|
|
- Graceful degradation when league API unavailable
|
|
- Transaction rollback on database errors
|
|
|
|
## Testing Strategy
|
|
|
|
### Unit Tests
|
|
- Test each core component in isolation
|
|
- Mock external dependencies (DB, APIs)
|
|
- Verify dice roll randomness and distribution
|
|
- Test all play resolution scenarios
|
|
|
|
### Integration Tests
|
|
- Test WebSocket event flow
|
|
- Test database persistence and recovery
|
|
- Test league config loading
|
|
- Test API client interactions
|
|
|
|
### Performance Tests
|
|
- Measure action processing time
|
|
- Test concurrent game handling
|
|
- Monitor memory usage under load
|
|
- Test state recovery speed
|
|
|
|
---
|
|
|
|
**Next Steps**: See [02-game-engine.md](./02-game-engine.md) for implementation details. |