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

22 KiB

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:

  • 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 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

# 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

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

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

# 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

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

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

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

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

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:

-- 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:

# 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

version: '3.8'

services:
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    restart: unless-stopped

volumes:
  redis_data:

Start Redis:

cd backend
docker-compose up -d

Frontend Setup

1. Initialize Nuxt 3 Projects

# 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

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

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

# 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

# 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.


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