# 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