strat-gameplay-webapp/.claude/implementation/01-infrastructure.md
Cal Corum 5c75b935f0 CLAUDE: Initial project setup - documentation and infrastructure
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)
2025-10-21 16:21:13 -05:00

848 lines
22 KiB
Markdown

# Phase 1: Core Infrastructure Setup
**Duration**: Weeks 1-3
**Goal**: Establish foundation for both backend and frontend with authentication and real-time communication
## Objectives
By end of Phase 1, you should have:
- ✅ FastAPI backend running with PostgreSQL
- ✅ Socket.io WebSocket server functional
- ✅ Discord OAuth working for both leagues
- ✅ Basic Nuxt 3 frontends operational
- ✅ WebSocket connections established between frontend/backend
- ✅ Users can authenticate and see connection status
## Development Workflow Options
This project supports **two development workflows**:
### Option 1: Local Development (Recommended for Daily Work)
- Backend runs locally with Python hot-reload
- Frontends run locally with Nuxt hot-reload
- Only Redis runs in Docker
- **Advantages**: Fast, easy debugging, instant code changes
### Option 2: Full Docker Orchestration
- Everything runs in containers
- One-command startup with `docker-compose up`
- **Advantages**: Production-like environment, easy sharing
**See the project [README.md](../../../README.md) for detailed instructions on both workflows.**
For this guide, we'll focus on **local development** setup. Docker configuration files are already provided:
- Root `docker-compose.yml` - Full stack orchestration
- `backend/Dockerfile` - Backend container
- `frontend-sba/Dockerfile` & `frontend-pd/Dockerfile` - Frontend containers
---
## Backend Setup
### 1. Initialize FastAPI Project
```bash
# Create backend directory
mkdir -p backend/app/{core,config,models,websocket,api,database,data,utils}
mkdir -p backend/tests/{unit,integration,e2e}
cd backend
# Create virtual environment
python3.11 -m venv venv
source venv/bin/activate
# Create requirements.txt
cat > requirements.txt << EOF
fastapi==0.104.1
uvicorn[standard]==0.24.0
python-socketio==5.10.0
python-multipart==0.0.6
pydantic==2.5.0
pydantic-settings==2.1.0
sqlalchemy==2.0.23
alembic==1.12.1
asyncpg==0.29.0
psycopg2-binary==2.9.9
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-dotenv==1.0.0
httpx==0.25.1
redis==5.0.1
aiofiles==23.2.1
EOF
# Development dependencies
cat > requirements-dev.txt << EOF
-r requirements.txt
pytest==7.4.3
pytest-asyncio==0.21.1
pytest-cov==4.1.0
black==23.11.0
flake8==6.1.0
mypy==1.7.1
httpx==0.25.1
EOF
# Install dependencies
pip install -r requirements-dev.txt
```
### 2. Setup FastAPI Application
**File**: `backend/app/main.py`
```python
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import socketio
from app.config import get_settings
from app.api.routes import games, auth, health
from app.websocket.connection_manager import ConnectionManager
from app.websocket.handlers import register_handlers
from app.database.session import init_db
from app.utils.logging import setup_logging
logger = logging.getLogger(f'{__name__}.main')
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup and shutdown events"""
# Startup
logger.info("Starting Paper Dynasty Game Backend")
setup_logging()
await init_db()
logger.info("Database initialized")
yield
# Shutdown
logger.info("Shutting down Paper Dynasty Game Backend")
# Initialize FastAPI app
app = FastAPI(
title="Paper Dynasty Game Backend",
description="Real-time baseball game engine for Paper Dynasty leagues",
version="1.0.0",
lifespan=lifespan
)
# CORS middleware
settings = get_settings()
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Initialize Socket.io
sio = socketio.AsyncServer(
async_mode='asgi',
cors_allowed_origins=settings.cors_origins,
logger=True,
engineio_logger=False
)
# Create Socket.io ASGI app
socket_app = socketio.ASGIApp(sio, app)
# Initialize connection manager and register handlers
connection_manager = ConnectionManager(sio)
register_handlers(sio, connection_manager)
# Include API routes
app.include_router(health.router, prefix="/api", tags=["health"])
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(games.router, prefix="/api/games", tags=["games"])
@app.get("/")
async def root():
return {"message": "Paper Dynasty Game Backend", "version": "1.0.0"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:socket_app",
host="0.0.0.0",
port=8000,
reload=True,
log_level="info"
)
```
**File**: `backend/app/config.py`
```python
from functools import lru_cache
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application settings"""
# Application
app_env: str = "development"
debug: bool = True
secret_key: str
# Database
database_url: str
db_pool_size: int = 20
db_max_overflow: int = 10
# Discord OAuth
discord_client_id: str
discord_client_secret: str
discord_redirect_uri: str
# League APIs
sba_api_url: str
sba_api_key: str
pd_api_url: str
pd_api_key: str
# WebSocket
ws_heartbeat_interval: int = 30
ws_connection_timeout: int = 60
# CORS
cors_origins: list[str] = ["http://localhost:3000", "http://localhost:3001"]
# Game settings
max_concurrent_games: int = 20
game_idle_timeout: int = 86400 # 24 hours
class Config:
env_file = ".env"
case_sensitive = False
@lru_cache()
def get_settings() -> Settings:
"""Get cached settings instance"""
return Settings()
```
**File**: `backend/.env.example`
```bash
# Application
APP_ENV=development
DEBUG=true
SECRET_KEY=your-secret-key-change-in-production
# Database
# Update with your actual database server hostname/IP and credentials
DATABASE_URL=postgresql+asyncpg://paperdynasty:your-password@your-db-server:5432/paperdynasty_dev
# Discord OAuth
DISCORD_CLIENT_ID=your-discord-client-id
DISCORD_CLIENT_SECRET=your-discord-client-secret
DISCORD_REDIRECT_URI=http://localhost:3000/auth/callback
# League APIs
SBA_API_URL=https://sba-api.example.com
SBA_API_KEY=your-sba-api-key
PD_API_URL=https://pd-api.example.com
PD_API_KEY=your-pd-api-key
# CORS
CORS_ORIGINS=http://localhost:3000,http://localhost:3001
# Redis (optional - for caching)
# REDIS_URL=redis://localhost:6379
```
### 3. Database Setup
**File**: `backend/app/database/session.py`
```python
import logging
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import declarative_base
from app.config import get_settings
logger = logging.getLogger(f'{__name__}.session')
settings = get_settings()
# Create async engine
engine = create_async_engine(
settings.database_url,
echo=settings.debug,
pool_size=settings.db_pool_size,
max_overflow=settings.db_max_overflow,
)
# Create session factory
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
# Base class for models
Base = declarative_base()
async def init_db() -> None:
"""Initialize database tables"""
async with engine.begin() as conn:
# Import all models here to ensure they're registered
from app.models import db_models
# Create tables
await conn.run_sync(Base.metadata.create_all)
logger.info("Database tables created")
async def get_session() -> AsyncGenerator[AsyncSession, None]:
"""Dependency for getting database session"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
```
**File**: `backend/app/models/db_models.py`
```python
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, JSON, Text, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
import uuid
from app.database.session import Base
class Game(Base):
"""Game model"""
__tablename__ = "games"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
league_id = Column(String(50), nullable=False, index=True)
home_team_id = Column(Integer, nullable=False)
away_team_id = Column(Integer, nullable=False)
status = Column(String(20), nullable=False, default="pending", index=True)
game_mode = Column(String(20), nullable=False)
visibility = Column(String(20), nullable=False)
current_inning = Column(Integer)
current_half = Column(String(10))
home_score = Column(Integer, default=0)
away_score = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow, index=True)
started_at = Column(DateTime)
completed_at = Column(DateTime)
winner_team_id = Column(Integer)
metadata = Column(JSON, default=dict)
class Play(Base):
"""Play model"""
__tablename__ = "plays"
id = Column(Integer, primary_key=True, autoincrement=True)
game_id = Column(UUID(as_uuid=True), ForeignKey("games.id"), nullable=False, index=True)
play_number = Column(Integer, nullable=False)
inning = Column(Integer, nullable=False)
half = Column(String(10), nullable=False)
outs_before = Column(Integer, nullable=False)
outs_recorded = Column(Integer, nullable=False)
batter_id = Column(Integer, nullable=False)
pitcher_id = Column(Integer, nullable=False)
runners_before = Column(JSON)
runners_after = Column(JSON)
balls = Column(Integer)
strikes = Column(Integer)
defensive_positioning = Column(String(50))
offensive_approach = Column(String(50))
dice_roll = Column(Integer)
hit_type = Column(String(50))
result_description = Column(Text)
runs_scored = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow, index=True)
metadata = Column(JSON, default=dict)
class Lineup(Base):
"""Lineup model"""
__tablename__ = "lineups"
id = Column(Integer, primary_key=True, autoincrement=True)
game_id = Column(UUID(as_uuid=True), ForeignKey("games.id"), nullable=False, index=True)
team_id = Column(Integer, nullable=False, index=True)
card_id = Column(Integer, nullable=False)
position = Column(String(10), nullable=False)
batting_order = Column(Integer)
is_starter = Column(Boolean, default=True)
is_active = Column(Boolean, default=True, index=True)
entered_inning = Column(Integer, default=1)
metadata = Column(JSON, default=dict)
class GameSession(Base):
"""Game session tracking"""
__tablename__ = "game_sessions"
game_id = Column(UUID(as_uuid=True), ForeignKey("games.id"), primary_key=True)
connected_users = Column(JSON, default=dict)
last_action_at = Column(DateTime, default=datetime.utcnow, index=True)
state_snapshot = Column(JSON, default=dict)
```
### 4. WebSocket Connection Manager
**File**: `backend/app/websocket/connection_manager.py`
```python
import logging
from typing import Dict, Set
import socketio
logger = logging.getLogger(f'{__name__}.ConnectionManager')
class ConnectionManager:
"""Manages WebSocket connections and rooms"""
def __init__(self, sio: socketio.AsyncServer):
self.sio = sio
self.user_sessions: Dict[str, str] = {} # sid -> user_id
self.game_rooms: Dict[str, Set[str]] = {} # game_id -> set of sids
async def connect(self, sid: str, user_id: str) -> None:
"""Register a new connection"""
self.user_sessions[sid] = user_id
logger.info(f"User {user_id} connected with session {sid}")
async def disconnect(self, sid: str) -> None:
"""Handle disconnection"""
user_id = self.user_sessions.pop(sid, None)
if user_id:
logger.info(f"User {user_id} disconnected (session {sid})")
# Remove from all game rooms
for game_id, sids in self.game_rooms.items():
if sid in sids:
sids.remove(sid)
await self.broadcast_to_game(
game_id,
"user_disconnected",
{"user_id": user_id}
)
async def join_game(self, sid: str, game_id: str, role: str) -> None:
"""Add user to game room"""
await self.sio.enter_room(sid, game_id)
if game_id not in self.game_rooms:
self.game_rooms[game_id] = set()
self.game_rooms[game_id].add(sid)
user_id = self.user_sessions.get(sid)
logger.info(f"User {user_id} joined game {game_id} as {role}")
await self.broadcast_to_game(
game_id,
"user_connected",
{"user_id": user_id, "role": role}
)
async def leave_game(self, sid: str, game_id: str) -> None:
"""Remove user from game room"""
await self.sio.leave_room(sid, game_id)
if game_id in self.game_rooms:
self.game_rooms[game_id].discard(sid)
user_id = self.user_sessions.get(sid)
logger.info(f"User {user_id} left game {game_id}")
async def broadcast_to_game(
self,
game_id: str,
event: str,
data: dict
) -> None:
"""Broadcast event to all users in game room"""
await self.sio.emit(event, data, room=game_id)
logger.debug(f"Broadcast {event} to game {game_id}")
async def emit_to_user(self, sid: str, event: str, data: dict) -> None:
"""Emit event to specific user"""
await self.sio.emit(event, data, room=sid)
def get_game_participants(self, game_id: str) -> Set[str]:
"""Get all session IDs in game room"""
return self.game_rooms.get(game_id, set())
```
**File**: `backend/app/websocket/handlers.py`
```python
import logging
from socketio import AsyncServer
from app.websocket.connection_manager import ConnectionManager
from app.utils.auth import verify_token
logger = logging.getLogger(f'{__name__}.handlers')
def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
"""Register all WebSocket event handlers"""
@sio.event
async def connect(sid, environ, auth):
"""Handle new connection"""
try:
# Verify JWT token
token = auth.get("token")
if not token:
logger.warning(f"Connection {sid} rejected: no token")
return False
user_data = verify_token(token)
user_id = user_data.get("user_id")
if not user_id:
logger.warning(f"Connection {sid} rejected: invalid token")
return False
await manager.connect(sid, user_id)
await sio.emit("connected", {"user_id": user_id}, room=sid)
logger.info(f"Connection {sid} accepted for user {user_id}")
return True
except Exception as e:
logger.error(f"Connection error: {e}")
return False
@sio.event
async def disconnect(sid):
"""Handle disconnection"""
await manager.disconnect(sid)
@sio.event
async def join_game(sid, data):
"""Handle join game request"""
try:
game_id = data.get("game_id")
role = data.get("role", "player")
if not game_id:
await manager.emit_to_user(
sid,
"error",
{"message": "Missing game_id"}
)
return
# TODO: Verify user has access to game
await manager.join_game(sid, game_id, role)
await manager.emit_to_user(
sid,
"game_joined",
{"game_id": game_id, "role": role}
)
except Exception as e:
logger.error(f"Join game error: {e}")
await manager.emit_to_user(
sid,
"error",
{"message": str(e)}
)
@sio.event
async def leave_game(sid, data):
"""Handle leave game request"""
try:
game_id = data.get("game_id")
if game_id:
await manager.leave_game(sid, game_id)
except Exception as e:
logger.error(f"Leave game error: {e}")
@sio.event
async def heartbeat(sid):
"""Handle heartbeat ping"""
await sio.emit("heartbeat_ack", {}, room=sid)
```
### 5. Logging Configuration
**File**: `backend/app/utils/logging.py`
```python
import logging
import logging.handlers
import os
from datetime import datetime
def setup_logging() -> None:
"""Configure application logging"""
# Create logs directory
log_dir = "logs"
os.makedirs(log_dir, exist_ok=True)
# Log file name with date
log_file = os.path.join(log_dir, f"app_{datetime.now().strftime('%Y%m%d')}.log")
# Create formatter
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
# Console handler
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(formatter)
# Rotating file handler
file_handler = logging.handlers.RotatingFileHandler(
log_file,
maxBytes=10 * 1024 * 1024, # 10MB
backupCount=5
)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
# Configure root logger
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG)
root_logger.addHandler(console_handler)
root_logger.addHandler(file_handler)
# Silence noisy loggers
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
logging.getLogger("socketio").setLevel(logging.INFO)
logging.getLogger("engineio").setLevel(logging.WARNING)
```
### 6. Database Setup
**Note**: This project uses existing PostgreSQL servers (dev and prod). You'll need to manually create the database before running the backend.
**Create Database on Your PostgreSQL Server**:
```sql
-- On your dev PostgreSQL server
CREATE DATABASE paperdynasty_dev;
CREATE USER paperdynasty WITH PASSWORD 'your-secure-password';
GRANT ALL PRIVILEGES ON DATABASE paperdynasty_dev TO paperdynasty;
-- Later, on your prod PostgreSQL server
CREATE DATABASE paperdynasty_prod;
-- Use a different, secure password for production
```
**Test Connection**:
```bash
# Test that you can connect to the database
psql postgresql://paperdynasty:your-password@your-db-server:5432/paperdynasty_dev
```
### 7. Docker Compose for Redis (Development)
**File**: `backend/docker-compose.yml`
```yaml
version: '3.8'
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
restart: unless-stopped
volumes:
redis_data:
```
**Start Redis**:
```bash
cd backend
docker-compose up -d
```
## Frontend Setup
### 1. Initialize Nuxt 3 Projects
```bash
# Create SBA League frontend
npx nuxi@latest init frontend-sba
cd frontend-sba
# Install dependencies
npm install @nuxtjs/tailwindcss @pinia/nuxt socket.io-client axios
npm install -D @nuxtjs/eslint-config-typescript
# Initialize Tailwind
npx tailwindcss init
cd ..
# Repeat for PD League
npx nuxi@latest init frontend-pd
cd frontend-pd
npm install @nuxtjs/tailwindcss @pinia/nuxt socket.io-client axios
npm install -D @nuxtjs/eslint-config-typescript
npx tailwindcss init
```
### 2. Configure Nuxt
**File**: `frontend-sba/nuxt.config.ts`
```typescript
export default defineNuxtConfig({
modules: ['@nuxtjs/tailwindcss', '@pinia/nuxt'],
runtimeConfig: {
public: {
leagueId: 'sba',
leagueName: 'Super Baseball Alliance',
apiUrl: process.env.NUXT_PUBLIC_API_URL || 'http://localhost:8000',
wsUrl: process.env.NUXT_PUBLIC_WS_URL || 'http://localhost:8000',
discordClientId: process.env.NUXT_PUBLIC_DISCORD_CLIENT_ID || '',
discordRedirectUri: process.env.NUXT_PUBLIC_DISCORD_REDIRECT_URI || 'http://localhost:3000/auth/callback',
}
},
css: ['~/assets/css/tailwind.css'],
devtools: { enabled: true },
typescript: {
strict: true,
typeCheck: true
}
})
```
### 3. Setup WebSocket Plugin
**File**: `frontend-sba/plugins/socket.client.ts`
```typescript
import { io, Socket } from 'socket.io-client'
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig()
let socket: Socket | null = null
const connect = (token: string) => {
if (socket?.connected) return socket
socket = io(config.public.wsUrl, {
auth: { token },
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5
})
socket.on('connect', () => {
console.log('WebSocket connected')
})
socket.on('disconnect', () => {
console.log('WebSocket disconnected')
})
socket.on('connect_error', (error) => {
console.error('WebSocket connection error:', error)
})
return socket
}
const disconnect = () => {
socket?.disconnect()
socket = null
}
return {
provide: {
socket: {
connect,
disconnect,
get instance() {
return socket
}
}
}
}
})
```
## Testing Phase 1
### Backend Tests
```bash
# Run backend tests
cd backend
pytest tests/ -v
# Test WebSocket connection
python -m pytest tests/integration/test_websocket.py -v
# Check code quality
black app/ tests/
flake8 app/ tests/
mypy app/
```
### Frontend Tests
```bash
# Run frontend dev server
cd frontend-sba
npm run dev
# Test in browser
# Navigate to http://localhost:3000
# Check console for WebSocket connection
```
## Phase 1 Completion Checklist
- [ ] PostgreSQL database created on existing server
- [ ] Database connection tested successfully
- [ ] FastAPI server running on port 8000
- [ ] Socket.io WebSocket server operational
- [ ] Database tables created successfully
- [ ] Logging system working (check logs/ directory)
- [ ] Redis running via Docker Compose
- [ ] WebSocket connections established
- [ ] Nuxt 3 apps running (SBA on 3000, PD on 3001)
- [ ] CORS configured correctly
- [ ] Health check endpoint responding
- [ ] Basic error handling in place
## Next Steps
Once Phase 1 is complete, proceed to [Phase 2: Game Engine Core](./02-game-engine.md).
---
**Notes**:
- Keep `.env` files secure and never commit to git
- Test WebSocket connections thoroughly before proceeding
- Document any deviations from this setup
- Update CLAUDE.md in subdirectories as needed