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:
parent
5c75b935f0
commit
fc7f53adf3
235
.claude/ENVIRONMENT.md
Normal file
235
.claude/ENVIRONMENT.md
Normal 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
207
QUICKSTART.md
Normal 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
25
backend/.env.example
Normal 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
|
||||
@ -6,11 +6,12 @@ FastAPI-based real-time game backend handling WebSocket communication, game stat
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Framework**: FastAPI (Python 3.11+)
|
||||
- **Framework**: FastAPI (Python 3.13)
|
||||
- **WebSocket**: Socket.io (python-socketio)
|
||||
- **Database**: PostgreSQL 14+ with SQLAlchemy 2.0 (async)
|
||||
- **ORM**: SQLAlchemy with asyncpg driver
|
||||
- **Validation**: Pydantic v2
|
||||
- **DateTime**: Pendulum 3.0 (replaces Python's datetime module)
|
||||
- **Testing**: pytest with pytest-asyncio
|
||||
- **Code Quality**: black, flake8, mypy
|
||||
|
||||
@ -118,8 +119,8 @@ Lineup.from_api_data(config, data)
|
||||
# Activate virtual environment
|
||||
source venv/bin/activate
|
||||
|
||||
# Start Redis (in separate terminal)
|
||||
docker-compose up
|
||||
# Start Redis (in separate terminal or use -d for detached)
|
||||
docker compose up -d
|
||||
|
||||
# Run backend with hot-reload
|
||||
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)
|
||||
```
|
||||
|
||||
### 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
|
||||
- **Raise or Return**: Never return `Optional` unless specifically required
|
||||
- **Custom Exceptions**: Use for domain-specific errors
|
||||
@ -321,6 +345,106 @@ PD_API_KEY=your-api-key
|
||||
- Check logs for connection errors
|
||||
- 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
|
||||
|
||||
- **Implementation Guide**: `../.claude/implementation/01-infrastructure.md`
|
||||
@ -331,5 +455,9 @@ PD_API_KEY=your-api-key
|
||||
|
||||
---
|
||||
|
||||
**Current Phase**: Phase 1 - Core Infrastructure
|
||||
**Next Phase**: Phase 2 - Game Engine Core
|
||||
**Current Phase**: Phase 1 - Core Infrastructure (✅ Complete)
|
||||
**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
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/routes/__init__.py
Normal file
0
backend/app/api/routes/__init__.py
Normal file
56
backend/app/api/routes/auth.py
Normal file
56
backend/app/api/routes/auth.py
Normal 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"}
|
||||
50
backend/app/api/routes/games.py
Normal file
50
backend/app/api/routes/games.py
Normal 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"}
|
||||
46
backend/app/api/routes/health.py
Normal file
46
backend/app/api/routes/health.py
Normal 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
48
backend/app/config.py
Normal 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()
|
||||
0
backend/app/database/__init__.py
Normal file
0
backend/app/database/__init__.py
Normal file
54
backend/app/database/session.py
Normal file
54
backend/app/database/session.py
Normal 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
82
backend/app/main.py
Normal 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"
|
||||
)
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
81
backend/app/models/db_models.py
Normal file
81
backend/app/models/db_models.py
Normal 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)
|
||||
0
backend/app/utils/__init__.py
Normal file
0
backend/app/utils/__init__.py
Normal file
49
backend/app/utils/auth.py
Normal file
49
backend/app/utils/auth.py
Normal 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
|
||||
47
backend/app/utils/logging.py
Normal file
47
backend/app/utils/logging.py
Normal 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)
|
||||
0
backend/app/websocket/__init__.py
Normal file
0
backend/app/websocket/__init__.py
Normal file
80
backend/app/websocket/connection_manager.py
Normal file
80
backend/app/websocket/connection_manager.py
Normal 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())
|
||||
91
backend/app/websocket/handlers.py
Normal file
91
backend/app/websocket/handlers.py
Normal 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)
|
||||
13
backend/docker-compose.yml
Normal file
13
backend/docker-compose.yml
Normal 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:
|
||||
7
backend/requirements-dev.txt
Normal file
7
backend/requirements-dev.txt
Normal 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
18
backend/requirements.txt
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user