CLAUDE: Complete Phase 1 backend infrastructure setup

Implemented full FastAPI backend with WebSocket support, database models,
and comprehensive documentation for the Paper Dynasty game engine.

Backend Implementation:
- FastAPI application with Socket.io WebSocket server
- SQLAlchemy async database models (Game, Play, Lineup, GameSession)
- PostgreSQL connection to dev server (10.10.0.42:5432)
- Connection manager for WebSocket lifecycle
- JWT authentication utilities
- Health check and stub API endpoints
- Rotating file logger with Pendulum datetime handling
- Redis via Docker Compose for caching

Technical Details:
- Python 3.13 with updated package versions
- Pendulum 3.0 for all datetime operations
- Greenlet for SQLAlchemy async support
- Fixed SQLAlchemy reserved column names (metadata -> *_metadata)
- Pydantic Settings with JSON array format for lists
- Docker Compose V2 commands

Documentation:
- Updated backend/CLAUDE.md with environment-specific details
- Created .claude/ENVIRONMENT.md for gotchas and quirks
- Created QUICKSTART.md for developer onboarding
- Documented all critical learnings and troubleshooting steps

Database:
- Tables created: games, plays, lineups, game_sessions
- All indexes and foreign keys configured
- Successfully tested connection and health checks

Verified:
- Server starts at http://localhost:8000
- Health endpoints responding
- Database connection working
- WebSocket infrastructure functional
- Hot-reload working

🎯 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-10-21 19:46:16 -05:00
parent 5c75b935f0
commit fc7f53adf3
25 changed files with 1322 additions and 5 deletions

235
.claude/ENVIRONMENT.md Normal file
View File

@ -0,0 +1,235 @@
# Environment-Specific Details & Gotchas
**Last Updated**: 2025-10-21
This document contains critical environment-specific configurations and common pitfalls to avoid.
## System Information
- **OS**: Linux (Nobara Fedora 42)
- **Python**: 3.13.3
- **Docker**: Compose V2 (use `docker compose` not `docker-compose`)
- **Database Server**: 10.10.0.42:5432 (PostgreSQL)
## Critical Gotchas
### 1. Docker Compose Command
**WRONG**: `docker-compose up -d`
**CORRECT**: `docker compose up -d`
This system uses Docker Compose V2. The legacy `docker-compose` command does not exist.
### 2. Pendulum for ALL DateTime Operations
**NEVER DO THIS**:
```python
from datetime import datetime
now = datetime.utcnow() # DEPRECATED & WRONG
```
**ALWAYS DO THIS**:
```python
import pendulum
now = pendulum.now('UTC')
```
**Reason**: We standardized on Pendulum for better timezone handling and to avoid deprecated Python datetime methods.
### 3. SQLAlchemy Reserved Column Names
**THESE WILL FAIL**:
```python
class MyModel(Base):
metadata = Column(JSON) # RESERVED
registry = Column(String) # RESERVED
__mapper__ = Column(String) # RESERVED
```
**USE DESCRIPTIVE NAMES**:
```python
class MyModel(Base):
game_metadata = Column(JSON)
user_registry = Column(String)
mapper_data = Column(String)
```
### 4. Pydantic Settings List Format
**WRONG** (.env file):
```bash
CORS_ORIGINS=http://localhost:3000,http://localhost:3001
```
**CORRECT** (.env file):
```bash
CORS_ORIGINS=["http://localhost:3000", "http://localhost:3001"]
```
**Reason**: Pydantic Settings expects JSON format for list types.
### 5. AsyncPG Requires Greenlet
If you see this error:
```
ValueError: the greenlet library is required to use this function
```
**Solution**: Install greenlet explicitly:
```bash
pip install greenlet
```
This is needed for SQLAlchemy's async support with asyncpg.
## Database Configuration
### Development Database
- **Host**: `10.10.0.42`
- **Port**: `5432`
- **Database**: `paperdynasty_dev`
- **User**: `paperdynasty`
- **Connection String**: `postgresql+asyncpg://paperdynasty:PASSWORD@10.10.0.42:5432/paperdynasty_dev`
### Creating the Database (if needed)
```sql
-- On PostgreSQL server (via Adminer or psql)
CREATE DATABASE paperdynasty_dev;
CREATE USER paperdynasty WITH PASSWORD 'your-password';
GRANT ALL PRIVILEGES ON DATABASE paperdynasty_dev TO paperdynasty;
-- After connecting to paperdynasty_dev
GRANT ALL ON SCHEMA public TO paperdynasty;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO paperdynasty;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO paperdynasty;
```
### Production Database
- **Host**: TBD
- **Database**: `paperdynasty_prod`
- Use different, secure credentials
## Package Versions
Due to Python 3.13 compatibility, we use newer versions than originally specified:
| Package | Original | Updated | Reason |
|---------|----------|---------|--------|
| fastapi | 0.104.1 | 0.115.6 | Python 3.13 support |
| uvicorn | 0.24.0 | 0.34.0 | Python 3.13 support |
| pydantic | 2.5.0 | 2.10.6 | Python 3.13 support |
| sqlalchemy | 2.0.23 | 2.0.36 | Python 3.13 support |
| asyncpg | 0.29.0 | 0.30.0 | Python 3.13 wheels |
| pendulum | N/A | 3.0.0 | New requirement |
| pytest | 7.4.3 | 8.3.4 | Python 3.13 support |
## Virtual Environment
### Backend
```bash
cd backend
source venv/bin/activate # Activate
python -m app.main # Run server
deactivate # When done
```
### Frontends (when created)
```bash
cd frontend-sba
npm install
npm run dev
cd frontend-pd
npm install
npm run dev
```
## Server Endpoints
### Backend (Port 8000)
- **Main API**: http://localhost:8000
- **Swagger UI**: http://localhost:8000/docs
- **ReDoc**: http://localhost:8000/redoc
- **Health Check**: http://localhost:8000/api/health
- **DB Health**: http://localhost:8000/api/health/db
### Frontends (when created)
- **SBA League**: http://localhost:3000
- **PD League**: http://localhost:3001
### Services
- **Redis**: localhost:6379
- **PostgreSQL**: 10.10.0.42:5432
## Common Commands
### Backend Development
```bash
# Start Redis
docker compose up -d
# Run backend (from backend/ directory)
source venv/bin/activate
python -m app.main
# Run tests
pytest tests/ -v
# Code formatting
black app/ tests/
# Type checking
mypy app/
```
### Docker Management
```bash
# Start Redis
docker compose up -d
# Stop Redis
docker compose down
# View Redis logs
docker compose logs redis
# Check running containers
docker ps
```
## Troubleshooting
### Server Won't Start
1. Check virtual environment is activated: `which python` should show `venv/bin/python`
2. Verify .env file has correct DATABASE_URL with password
3. Ensure CORS_ORIGINS is JSON array format
4. Check PostgreSQL server is accessible: `psql postgresql://paperdynasty:PASSWORD@10.10.0.42:5432/paperdynasty_dev`
### Import Errors
1. Ensure all `__init__.py` files exist in package directories
2. Run from backend directory: `python -m app.main` not `python app/main.py`
### Database Connection Errors
1. Verify database exists: `psql -h 10.10.0.42 -U paperdynasty -d paperdynasty_dev`
2. Check firewall rules if connection times out
3. Verify credentials in .env file
### WebSocket Connection Issues
1. Check CORS_ORIGINS includes frontend URL
2. Verify JWT token is being sent from client
3. Check browser console for specific error messages
## Security Notes
- ✅ `.env` files are gitignored
- ✅ Secret key is randomly generated (32+ chars)
- ✅ Database credentials never committed to git
- ✅ JWT tokens expire after 7 days
- ✅ All WebSocket connections require authentication
## Next Steps
- [ ] Set up Discord OAuth integration
- [ ] Create frontend projects (Nuxt 3)
- [ ] Implement game engine (Phase 2)
- [ ] Add comprehensive test coverage
- [ ] Set up CI/CD pipeline
---
**Note**: Keep this document updated as new environment-specific details are discovered.

207
QUICKSTART.md Normal file
View File

@ -0,0 +1,207 @@
# Paper Dynasty - Quick Start Guide
**Last Updated**: 2025-10-21
## Prerequisites
- Python 3.13+
- Node.js 18+ (for frontends)
- Docker with Compose V2
- Access to PostgreSQL server (10.10.0.42:5432)
- Git
## Initial Setup (One-Time)
### 1. Clone and Navigate
```bash
cd /mnt/NV2/Development/strat-gameplay-webapp
```
### 2. Backend Setup
```bash
cd backend
# Create virtual environment
python3 -m venv venv
source venv/bin/activate
# Install dependencies
pip install -r requirements-dev.txt
# Configure environment
cp .env.example .env
# Edit .env and set DATABASE_URL password
# Start Redis
docker compose up -d
# Verify setup
python -m app.main
# Server should start at http://localhost:8000
```
### 3. Database Setup (If Not Already Created)
Connect to your PostgreSQL server via Adminer or psql and run:
```sql
CREATE DATABASE paperdynasty_dev;
CREATE USER paperdynasty WITH PASSWORD 'your-secure-password';
GRANT ALL PRIVILEGES ON DATABASE paperdynasty_dev TO paperdynasty;
-- Connect to paperdynasty_dev
\c paperdynasty_dev
GRANT ALL ON SCHEMA public TO paperdynasty;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO paperdynasty;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO paperdynasty;
```
## Daily Development
### Starting the Backend
```bash
# Terminal 1: Start Redis (if not running)
cd backend
docker compose up -d
# Terminal 2: Run Backend
cd backend
source venv/bin/activate
python -m app.main
```
Backend will be available at:
- Main API: http://localhost:8000
- Swagger UI: http://localhost:8000/docs
- Health Check: http://localhost:8000/api/health
### Starting Frontends (When Available)
```bash
# Terminal 3: SBA League Frontend
cd frontend-sba
npm run dev
# Available at http://localhost:3000
# Terminal 4: PD League Frontend
cd frontend-pd
npm run dev
# Available at http://localhost:3001
```
## Useful Commands
### Backend
```bash
# Run tests
cd backend
source venv/bin/activate
pytest tests/ -v
# Format code
black app/ tests/
# Type checking
mypy app/
# Check logs
tail -f backend/logs/app_*.log
```
### Docker
```bash
# Check running containers
docker ps
# View Redis logs
cd backend
docker compose logs redis
# Stop Redis
docker compose down
```
### Database
```bash
# Connect to database
psql postgresql://paperdynasty:PASSWORD@10.10.0.42:5432/paperdynasty_dev
# Or use Adminer web interface
# (Running on same Docker host as PostgreSQL)
```
## Troubleshooting
### "docker-compose: command not found"
Use `docker compose` (with space) not `docker-compose`
### "greenlet library required"
```bash
pip install greenlet
```
### Import errors / Module not found
Ensure virtual environment is activated:
```bash
source venv/bin/activate
which python # Should show path to venv/bin/python
```
### Database connection errors
1. Check .env has correct DATABASE_URL with password
2. Verify database exists: `psql -h 10.10.0.42 -U paperdynasty -d paperdynasty_dev`
3. Ping database server: `ping 10.10.0.42`
### Server won't start
1. Check CORS_ORIGINS format in .env: `CORS_ORIGINS=["url1", "url2"]`
2. Ensure Redis is running: `docker ps | grep redis`
3. Check logs: `tail -f backend/logs/app_*.log`
## Project Status
**Phase 1: Core Infrastructure** - COMPLETE (2025-10-21)
- Backend FastAPI server running
- PostgreSQL database configured
- WebSocket support (Socket.io)
- Health check endpoints
- JWT authentication stubs
- Redis for caching
🚧 **Next Steps**:
- Discord OAuth integration
- Frontend setup (Nuxt 3)
- Phase 2: Game Engine Core
## Key Files
- **Backend Config**: `backend/.env`
- **Backend Code**: `backend/app/`
- **Database Models**: `backend/app/models/db_models.py`
- **API Routes**: `backend/app/api/routes/`
- **WebSocket**: `backend/app/websocket/`
- **Logs**: `backend/logs/`
## Documentation
- **Main README**: `README.md`
- **Backend Details**: `backend/CLAUDE.md`
- **Environment Notes**: `.claude/ENVIRONMENT.md`
- **Implementation Plan**: `.claude/implementation/01-infrastructure.md`
- **Full PRD**: `prd-web-scorecard-1.1.md`
## Support
For issues or questions:
1. Check `backend/CLAUDE.md` for backend-specific details
2. Check `.claude/ENVIRONMENT.md` for gotchas and environment quirks
3. Review implementation plans in `.claude/implementation/`
---
**Happy Coding! ⚾**

25
backend/.env.example Normal file
View File

@ -0,0 +1,25 @@
# 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@10.10.0.42: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 (must be JSON array format)
CORS_ORIGINS=["http://localhost:3000", "http://localhost:3001"]
# Redis (optional - for caching)
# REDIS_URL=redis://localhost:6379

View File

@ -6,11 +6,12 @@ FastAPI-based real-time game backend handling WebSocket communication, game stat
## Technology Stack ## Technology Stack
- **Framework**: FastAPI (Python 3.11+) - **Framework**: FastAPI (Python 3.13)
- **WebSocket**: Socket.io (python-socketio) - **WebSocket**: Socket.io (python-socketio)
- **Database**: PostgreSQL 14+ with SQLAlchemy 2.0 (async) - **Database**: PostgreSQL 14+ with SQLAlchemy 2.0 (async)
- **ORM**: SQLAlchemy with asyncpg driver - **ORM**: SQLAlchemy with asyncpg driver
- **Validation**: Pydantic v2 - **Validation**: Pydantic v2
- **DateTime**: Pendulum 3.0 (replaces Python's datetime module)
- **Testing**: pytest with pytest-asyncio - **Testing**: pytest with pytest-asyncio
- **Code Quality**: black, flake8, mypy - **Code Quality**: black, flake8, mypy
@ -118,8 +119,8 @@ Lineup.from_api_data(config, data)
# Activate virtual environment # Activate virtual environment
source venv/bin/activate source venv/bin/activate
# Start Redis (in separate terminal) # Start Redis (in separate terminal or use -d for detached)
docker-compose up docker compose up -d
# Run backend with hot-reload # Run backend with hot-reload
python -m app.main python -m app.main
@ -169,6 +170,29 @@ logger.info(f"User {user_id} connected")
logger.error(f"Failed to process action: {error}", exc_info=True) 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 ### Error Handling
- **Raise or Return**: Never return `Optional` unless specifically required - **Raise or Return**: Never return `Optional` unless specifically required
- **Custom Exceptions**: Use for domain-specific errors - **Custom Exceptions**: Use for domain-specific errors
@ -321,6 +345,106 @@ PD_API_KEY=your-api-key
- Check logs for connection errors - Check logs for connection errors
- Ensure Socket.io versions match (client/server) - 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
## References ## References
- **Implementation Guide**: `../.claude/implementation/01-infrastructure.md` - **Implementation Guide**: `../.claude/implementation/01-infrastructure.md`
@ -331,5 +455,9 @@ PD_API_KEY=your-api-key
--- ---
**Current Phase**: Phase 1 - Core Infrastructure **Current Phase**: Phase 1 - Core Infrastructure (✅ Complete)
**Next Phase**: Phase 2 - Game Engine Core **Next Phase**: Phase 2 - Game Engine Core
**Setup Completed**: 2025-10-21
**Python Version**: 3.13.3
**Database Server**: 10.10.0.42:5432

0
backend/app/__init__.py Normal file
View File

View File

View File

View File

@ -0,0 +1,56 @@
import logging
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.utils.auth import create_token
logger = logging.getLogger(f'{__name__}.auth')
router = APIRouter()
class TokenRequest(BaseModel):
"""Request model for token creation"""
user_id: str
username: str
discord_id: str
class TokenResponse(BaseModel):
"""Response model for token creation"""
access_token: str
token_type: str = "bearer"
@router.post("/token", response_model=TokenResponse)
async def create_auth_token(request: TokenRequest):
"""
Create JWT token for authenticated user
TODO Phase 1: Implement Discord OAuth flow
For now, this is a stub that creates tokens from provided user data
"""
try:
user_data = {
"user_id": request.user_id,
"username": request.username,
"discord_id": request.discord_id
}
token = create_token(user_data)
return TokenResponse(access_token=token)
except Exception as e:
logger.error(f"Token creation error: {e}")
raise HTTPException(status_code=500, detail="Failed to create token")
@router.get("/verify")
async def verify_auth():
"""
Verify authentication status
TODO Phase 1: Implement full auth verification
"""
return {"authenticated": True, "message": "Auth verification stub"}

View File

@ -0,0 +1,50 @@
import logging
from fastapi import APIRouter
from typing import List
from pydantic import BaseModel
logger = logging.getLogger(f'{__name__}.games')
router = APIRouter()
class GameListItem(BaseModel):
"""Game list item model"""
game_id: str
league_id: str
status: str
home_team_id: int
away_team_id: int
@router.get("/", response_model=List[GameListItem])
async def list_games():
"""
List all games
TODO Phase 2: Implement game listing with database query
"""
logger.info("List games endpoint called (stub)")
return []
@router.get("/{game_id}")
async def get_game(game_id: str):
"""
Get game details
TODO Phase 2: Implement game retrieval
"""
logger.info(f"Get game {game_id} endpoint called (stub)")
return {"game_id": game_id, "message": "Game retrieval stub"}
@router.post("/")
async def create_game():
"""
Create new game
TODO Phase 2: Implement game creation
"""
logger.info("Create game endpoint called (stub)")
return {"message": "Game creation stub"}

View File

@ -0,0 +1,46 @@
import logging
from fastapi import APIRouter
import pendulum
from app.config import get_settings
logger = logging.getLogger(f'{__name__}.health')
router = APIRouter()
settings = get_settings()
@router.get("/health")
async def health_check():
"""Health check endpoint"""
return {
"status": "healthy",
"timestamp": pendulum.now('UTC').to_iso8601_string(),
"environment": settings.app_env,
"version": "1.0.0"
}
@router.get("/health/db")
async def database_health():
"""Database health check"""
from app.database.session import engine
from sqlalchemy import text
try:
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
return {
"status": "healthy",
"database": "connected",
"timestamp": pendulum.now('UTC').to_iso8601_string()
}
except Exception as e:
logger.error(f"Database health check failed: {e}")
return {
"status": "unhealthy",
"database": "disconnected",
"error": str(e),
"timestamp": pendulum.now('UTC').to_iso8601_string()
}

48
backend/app/config.py Normal file
View File

@ -0,0 +1,48 @@
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()

View File

View File

@ -0,0 +1,54 @@
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()

82
backend/app/main.py Normal file
View File

@ -0,0 +1,82 @@
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"
)

View File

View File

@ -0,0 +1,81 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, JSON, Text, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
import uuid
import pendulum
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=lambda: pendulum.now('UTC'), index=True)
started_at = Column(DateTime)
completed_at = Column(DateTime)
winner_team_id = Column(Integer)
game_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=lambda: pendulum.now('UTC'), index=True)
play_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)
lineup_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=lambda: pendulum.now('UTC'), index=True)
state_snapshot = Column(JSON, default=dict)

View File

49
backend/app/utils/auth.py Normal file
View File

@ -0,0 +1,49 @@
import logging
from typing import Dict, Any
from jose import jwt, JWTError
import pendulum
from app.config import get_settings
logger = logging.getLogger(f'{__name__}.auth')
settings = get_settings()
def create_token(user_data: Dict[str, Any]) -> str:
"""
Create JWT token for user
Args:
user_data: User information to encode in token
Returns:
JWT token string
"""
payload = {
**user_data,
"exp": pendulum.now('UTC').add(days=7).int_timestamp
}
token = jwt.encode(payload, settings.secret_key, algorithm="HS256")
return token
def verify_token(token: str) -> Dict[str, Any]:
"""
Verify and decode JWT token
Args:
token: JWT token string
Returns:
Decoded token payload
Raises:
JWTError: If token is invalid or expired
"""
try:
payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"])
return payload
except JWTError as e:
logger.warning(f"Invalid token: {e}")
raise

View File

@ -0,0 +1,47 @@
import logging
import logging.handlers
import os
import pendulum
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
now = pendulum.now('UTC')
log_file = os.path.join(log_dir, f"app_{now.format('YYYYMMDD')}.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)

View File

View File

@ -0,0 +1,80 @@
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())

View File

@ -0,0 +1,91 @@
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)

View File

@ -0,0 +1,13 @@
version: '3.8'
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
restart: unless-stopped
volumes:
redis_data:

View File

@ -0,0 +1,7 @@
-r requirements.txt
pytest==8.3.4
pytest-asyncio==0.25.2
pytest-cov==6.0.0
black==24.10.0
flake8==7.1.1
mypy==1.14.1

18
backend/requirements.txt Normal file
View File

@ -0,0 +1,18 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
python-socketio==5.11.4
python-multipart==0.0.20
pydantic==2.10.6
pydantic-settings==2.7.1
sqlalchemy==2.0.36
alembic==1.14.0
asyncpg==0.30.0
psycopg2-binary==2.9.10
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-dotenv==1.0.1
httpx==0.28.1
redis==5.2.1
aiofiles==24.1.0
pendulum==3.0.0
greenlet==3.2.4