CLAUDE: Refactor ManualOutcomeSubmission to use PlayOutcome enum + comprehensive documentation
## Refactoring - Changed `ManualOutcomeSubmission.outcome` from `str` to `PlayOutcome` enum type - Removed custom validator (Pydantic handles enum validation automatically) - Added direct import of PlayOutcome (no circular dependency due to TYPE_CHECKING guard) - Updated tests to use enum values while maintaining backward compatibility Benefits: - Better type safety with IDE autocomplete - Cleaner code (removed 15 lines of validator boilerplate) - Backward compatible (Pydantic auto-converts strings to enum) - Access to helper methods (is_hit(), is_out(), etc.) Files modified: - app/models/game_models.py: Enum type + import - tests/unit/config/test_result_charts.py: Updated 7 tests + added compatibility test ## Documentation Created comprehensive CLAUDE.md files for all backend/app/ subdirectories to help future AI agents quickly understand and work with the code. Added 8,799 lines of documentation covering: - api/ (906 lines): FastAPI routes, health checks, auth patterns - config/ (906 lines): League configs, PlayOutcome enum, result charts - core/ (1,288 lines): GameEngine, StateManager, PlayResolver, dice system - data/ (937 lines): API clients (planned), caching layer - database/ (945 lines): Async sessions, operations, recovery - models/ (1,270 lines): Pydantic/SQLAlchemy models, polymorphic patterns - utils/ (959 lines): Logging, JWT auth, security - websocket/ (1,588 lines): Socket.io handlers, real-time events - tests/ (475 lines): Testing patterns and structure Each CLAUDE.md includes: - Purpose & architecture overview - Key components with detailed explanations - Patterns & conventions - Integration points - Common tasks (step-by-step guides) - Troubleshooting with solutions - Working code examples - Testing guidance Total changes: +9,294 lines / -24 lines Tests: All passing (62/62 model tests, 7/7 ManualOutcomeSubmission tests)
This commit is contained in:
parent
119f169474
commit
76e24ab22b
906
backend/app/api/CLAUDE.md
Normal file
906
backend/app/api/CLAUDE.md
Normal file
@ -0,0 +1,906 @@
|
|||||||
|
# Backend API Layer - REST Endpoints
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `app/api/` directory contains REST API endpoints for the Paper Dynasty game backend. This layer provides traditional HTTP request/response endpoints for operations like authentication, game creation, health checks, and data retrieval.
|
||||||
|
|
||||||
|
**Architecture Note**: Most real-time gameplay happens via WebSocket (see `app/websocket/`). REST API is primarily for:
|
||||||
|
- Authentication & authorization
|
||||||
|
- Game lifecycle (create, list, retrieve)
|
||||||
|
- Health monitoring
|
||||||
|
- Data queries that don't require real-time updates
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
app/api/
|
||||||
|
├── __init__.py # Empty package marker
|
||||||
|
├── routes/ # API route modules
|
||||||
|
│ ├── __init__.py # Empty package marker
|
||||||
|
│ ├── health.py # Health check endpoints (✅ complete)
|
||||||
|
│ ├── auth.py # Authentication endpoints (stub)
|
||||||
|
│ └── games.py # Game CRUD endpoints (stub)
|
||||||
|
└── CLAUDE.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: No `dependencies.py` file yet. Common dependencies (auth, DB sessions) will be added as needed during Phase 2+ implementation.
|
||||||
|
|
||||||
|
## Current Implementation Status
|
||||||
|
|
||||||
|
### ✅ Fully Implemented
|
||||||
|
|
||||||
|
#### Health Endpoints (`routes/health.py`)
|
||||||
|
|
||||||
|
**Purpose**: Service health monitoring for deployment and operations
|
||||||
|
|
||||||
|
**Endpoints**:
|
||||||
|
|
||||||
|
1. **GET `/api/health`** - Application health check
|
||||||
|
- Returns: Service status, timestamp, environment, version
|
||||||
|
- Use: Load balancer health probes, monitoring systems
|
||||||
|
- Response time: < 10ms
|
||||||
|
|
||||||
|
2. **GET `/api/health/db`** - Database connectivity check
|
||||||
|
- Returns: Database connection status
|
||||||
|
- Use: Verify database availability
|
||||||
|
- Response time: < 50ms
|
||||||
|
- Graceful error handling (returns unhealthy status instead of 500)
|
||||||
|
|
||||||
|
**Example Responses**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// GET /api/health
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"timestamp": "2025-10-31T14:23:45.123456+00:00",
|
||||||
|
"environment": "development",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/health/db (success)
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"database": "connected",
|
||||||
|
"timestamp": "2025-10-31T14:23:45.123456+00:00"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/health/db (failure)
|
||||||
|
{
|
||||||
|
"status": "unhealthy",
|
||||||
|
"database": "disconnected",
|
||||||
|
"error": "connection refused",
|
||||||
|
"timestamp": "2025-10-31T14:23:45.123456+00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🟡 Stub Implementation (Phase 1)
|
||||||
|
|
||||||
|
#### Authentication Endpoints (`routes/auth.py`)
|
||||||
|
|
||||||
|
**Purpose**: User authentication via Discord OAuth (planned for Phase 1, currently stub)
|
||||||
|
|
||||||
|
**Endpoints**:
|
||||||
|
|
||||||
|
1. **POST `/api/auth/token`** - Create JWT token
|
||||||
|
- Request: `TokenRequest` (user_id, username, discord_id)
|
||||||
|
- Response: `TokenResponse` (access_token, token_type)
|
||||||
|
- Current: Directly creates token from provided data (no OAuth yet)
|
||||||
|
- TODO: Implement full Discord OAuth flow
|
||||||
|
|
||||||
|
2. **GET `/api/auth/verify`** - Verify authentication status
|
||||||
|
- Response: Stub acknowledgment
|
||||||
|
- TODO: Implement token verification with user context
|
||||||
|
|
||||||
|
**TODO Items**:
|
||||||
|
- [ ] Discord OAuth callback handler
|
||||||
|
- [ ] State parameter validation
|
||||||
|
- [ ] Token refresh endpoint
|
||||||
|
- [ ] User session management
|
||||||
|
- [ ] Authentication middleware/dependency
|
||||||
|
|
||||||
|
#### Game Endpoints (`routes/games.py`)
|
||||||
|
|
||||||
|
**Purpose**: Game lifecycle management (planned for Phase 2+, currently stub)
|
||||||
|
|
||||||
|
**Endpoints**:
|
||||||
|
|
||||||
|
1. **GET `/api/games/`** - List all games
|
||||||
|
- Response: List of `GameListItem` (game_id, league_id, status, teams)
|
||||||
|
- TODO: Implement with database query, pagination, filters
|
||||||
|
|
||||||
|
2. **GET `/api/games/{game_id}`** - Get game details
|
||||||
|
- Response: Full game state and metadata
|
||||||
|
- TODO: Load from database, include plays, lineups, current state
|
||||||
|
|
||||||
|
3. **POST `/api/games/`** - Create new game
|
||||||
|
- Request: Game creation parameters (league, teams, settings)
|
||||||
|
- Response: Created game details
|
||||||
|
- TODO: Create game in DB, initialize state, return game_id
|
||||||
|
|
||||||
|
**TODO Items**:
|
||||||
|
- [ ] Game creation with validation
|
||||||
|
- [ ] Game listing with filters (by league, status, user)
|
||||||
|
- [ ] Game detail retrieval with related data
|
||||||
|
- [ ] Game deletion/cancellation
|
||||||
|
- [ ] Game history and statistics
|
||||||
|
|
||||||
|
## FastAPI Patterns Used
|
||||||
|
|
||||||
|
### 1. APIRouter Pattern
|
||||||
|
|
||||||
|
Each route module creates its own `APIRouter` instance:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/endpoint")
|
||||||
|
async def handler():
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Routers are registered in `app/main.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.api.routes import health, auth, games
|
||||||
|
|
||||||
|
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"])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Conventions**:
|
||||||
|
- Each module exports a `router` variable
|
||||||
|
- Prefix and tags applied during registration in main.py
|
||||||
|
- Tags group endpoints in Swagger UI docs
|
||||||
|
|
||||||
|
### 2. Pydantic Request/Response Models
|
||||||
|
|
||||||
|
Use Pydantic models for type safety and validation:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
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):
|
||||||
|
# FastAPI automatically validates request body against TokenRequest
|
||||||
|
# and serializes return value according to TokenResponse
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
- Automatic request validation with clear error messages
|
||||||
|
- Automatic response serialization
|
||||||
|
- OpenAPI schema generation for docs
|
||||||
|
- Type hints for IDE support
|
||||||
|
|
||||||
|
### 3. Async Handlers
|
||||||
|
|
||||||
|
All route handlers use `async def`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.get("/health/db")
|
||||||
|
async def database_health():
|
||||||
|
# Can use await for DB queries, external APIs, etc.
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text("SELECT 1"))
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why Async**:
|
||||||
|
- Non-blocking I/O operations (database, external APIs)
|
||||||
|
- Better concurrency for high-traffic endpoints
|
||||||
|
- Consistent with WebSocket handlers
|
||||||
|
- Required for async database operations (asyncpg)
|
||||||
|
|
||||||
|
### 4. Logging Pattern
|
||||||
|
|
||||||
|
Module-level logger with descriptive name:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(f'{__name__}.health')
|
||||||
|
|
||||||
|
@router.get("/endpoint")
|
||||||
|
async def handler():
|
||||||
|
logger.info("Endpoint called")
|
||||||
|
logger.error(f"Error occurred: {error}", exc_info=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Logging Levels**:
|
||||||
|
- `DEBUG`: Detailed diagnostic information
|
||||||
|
- `INFO`: Normal operation events (endpoint calls, state changes)
|
||||||
|
- `WARNING`: Unusual but handled situations
|
||||||
|
- `ERROR`: Error conditions that need attention
|
||||||
|
- `CRITICAL`: Severe errors requiring immediate action
|
||||||
|
|
||||||
|
### 5. Error Handling
|
||||||
|
|
||||||
|
Use FastAPI's HTTPException for error responses:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
@router.post("/endpoint")
|
||||||
|
async def handler():
|
||||||
|
try:
|
||||||
|
# operation
|
||||||
|
return result
|
||||||
|
except ValidationError as e:
|
||||||
|
logger.error(f"Validation failed: {e}")
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid request data")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes**:
|
||||||
|
- 200: Success
|
||||||
|
- 201: Created
|
||||||
|
- 400: Bad request (validation error)
|
||||||
|
- 401: Unauthorized (missing/invalid token)
|
||||||
|
- 403: Forbidden (valid token, insufficient permissions)
|
||||||
|
- 404: Not found
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
### 6. Settings Injection
|
||||||
|
|
||||||
|
Use `get_settings()` for configuration:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
@router.get("/endpoint")
|
||||||
|
async def handler():
|
||||||
|
api_url = settings.sba_api_url
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available Settings**:
|
||||||
|
- `app_env`: "development", "staging", "production"
|
||||||
|
- `debug`: Enable debug mode
|
||||||
|
- `database_url`: PostgreSQL connection string
|
||||||
|
- `secret_key`: JWT signing key
|
||||||
|
- `discord_client_id`, `discord_client_secret`: OAuth credentials
|
||||||
|
- `sba_api_url`, `pd_api_url`: League API endpoints
|
||||||
|
- `cors_origins`: Allowed frontend origins
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
### With Other Backend Components
|
||||||
|
|
||||||
|
#### Database Layer (`app/database/`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.database.session import get_session
|
||||||
|
from app.database.operations import DatabaseOperations
|
||||||
|
|
||||||
|
@router.get("/games/{game_id}")
|
||||||
|
async def get_game(game_id: str):
|
||||||
|
db_ops = DatabaseOperations()
|
||||||
|
game_data = await db_ops.load_game_state(game_id)
|
||||||
|
return game_data
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Models (`app/models/`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.models import GameState
|
||||||
|
from app.models.db_models import Game
|
||||||
|
|
||||||
|
@router.post("/games/")
|
||||||
|
async def create_game(request: CreateGameRequest):
|
||||||
|
# Use Pydantic models for validation
|
||||||
|
# Use SQLAlchemy models for database operations
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Authentication (`app/utils/auth.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.utils.auth import create_token, verify_token
|
||||||
|
|
||||||
|
@router.post("/auth/token")
|
||||||
|
async def create_auth_token(request: TokenRequest):
|
||||||
|
token = create_token(user_data)
|
||||||
|
return TokenResponse(access_token=token)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### State Manager (`app/core/state_manager.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.core.state_manager import state_manager
|
||||||
|
|
||||||
|
@router.get("/games/{game_id}")
|
||||||
|
async def get_game(game_id: str):
|
||||||
|
# Check in-memory state first (fast)
|
||||||
|
state = state_manager.get_state(game_id)
|
||||||
|
if not state:
|
||||||
|
# Fall back to database
|
||||||
|
state = await state_manager.recover_game(game_id)
|
||||||
|
return state
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Frontend
|
||||||
|
|
||||||
|
Frontend applications make HTTP requests to these endpoints:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Example frontend code (Vue/Nuxt)
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: 'http://localhost:8000/api',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
const health = await api.get('/health');
|
||||||
|
|
||||||
|
// Create game
|
||||||
|
const game = await api.post('/games/', {
|
||||||
|
league_id: 'sba',
|
||||||
|
home_team_id: 1,
|
||||||
|
away_team_id: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
// List games
|
||||||
|
const games = await api.get('/games/');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Adding a New Endpoint
|
||||||
|
|
||||||
|
1. **Choose appropriate route module** (or create new one)
|
||||||
|
```bash
|
||||||
|
# If creating new module
|
||||||
|
touch app/api/routes/teams.py
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Define Pydantic models** for request/response
|
||||||
|
```python
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class CreateTeamRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
league_id: str
|
||||||
|
owner_id: str
|
||||||
|
|
||||||
|
class TeamResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
league_id: str
|
||||||
|
created_at: str
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Create route handler**
|
||||||
|
```python
|
||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from app.database.operations import DatabaseOperations
|
||||||
|
|
||||||
|
logger = logging.getLogger(f'{__name__}.teams')
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.post("/", response_model=TeamResponse)
|
||||||
|
async def create_team(request: CreateTeamRequest):
|
||||||
|
"""Create a new team"""
|
||||||
|
logger.info(f"Creating team: {request.name}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
db_ops = DatabaseOperations()
|
||||||
|
team = await db_ops.create_team(
|
||||||
|
name=request.name,
|
||||||
|
league_id=request.league_id,
|
||||||
|
owner_id=request.owner_id
|
||||||
|
)
|
||||||
|
return TeamResponse(**team)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create team: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create team")
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Register router in main.py**
|
||||||
|
```python
|
||||||
|
from app.api.routes import teams
|
||||||
|
|
||||||
|
app.include_router(teams.router, prefix="/api/teams", tags=["teams"])
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Test the endpoint**
|
||||||
|
```bash
|
||||||
|
# Start server
|
||||||
|
python -m app.main
|
||||||
|
|
||||||
|
# Test with curl
|
||||||
|
curl -X POST http://localhost:8000/api/teams/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name": "Test Team", "league_id": "sba", "owner_id": "user123"}'
|
||||||
|
|
||||||
|
# Or use Swagger UI
|
||||||
|
# http://localhost:8000/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding Authentication Dependency
|
||||||
|
|
||||||
|
When authentication is implemented, add a dependency for protected endpoints:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import Depends, HTTPException, Header
|
||||||
|
from app.utils.auth import verify_token
|
||||||
|
|
||||||
|
async def get_current_user(authorization: str = Header(...)):
|
||||||
|
"""Extract and verify user from JWT token"""
|
||||||
|
try:
|
||||||
|
scheme, token = authorization.split()
|
||||||
|
if scheme.lower() != 'bearer':
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid auth scheme")
|
||||||
|
|
||||||
|
payload = verify_token(token)
|
||||||
|
return payload
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||||||
|
|
||||||
|
@router.post("/games/")
|
||||||
|
async def create_game(
|
||||||
|
request: CreateGameRequest,
|
||||||
|
user = Depends(get_current_user) # Automatically extracts and validates token
|
||||||
|
):
|
||||||
|
# user contains decoded JWT payload
|
||||||
|
owner_id = user['user_id']
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding Database Operations
|
||||||
|
|
||||||
|
For endpoints that need database access:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.database.operations import DatabaseOperations
|
||||||
|
from app.database.session import get_session
|
||||||
|
|
||||||
|
@router.get("/games/{game_id}")
|
||||||
|
async def get_game(game_id: str):
|
||||||
|
db_ops = DatabaseOperations()
|
||||||
|
|
||||||
|
# Load game with all related data
|
||||||
|
game_data = await db_ops.load_game_state(game_id)
|
||||||
|
|
||||||
|
if not game_data:
|
||||||
|
raise HTTPException(status_code=404, detail="Game not found")
|
||||||
|
|
||||||
|
return game_data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding Request Validation
|
||||||
|
|
||||||
|
Pydantic provides powerful validation:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pydantic import BaseModel, Field, validator
|
||||||
|
|
||||||
|
class CreateGameRequest(BaseModel):
|
||||||
|
league_id: str = Field(..., regex="^(sba|pd)$")
|
||||||
|
home_team_id: int = Field(..., gt=0)
|
||||||
|
away_team_id: int = Field(..., gt=0)
|
||||||
|
game_mode: str = Field(default="friendly", regex="^(ranked|friendly|practice)$")
|
||||||
|
|
||||||
|
@validator('away_team_id')
|
||||||
|
def teams_must_differ(cls, v, values):
|
||||||
|
if 'home_team_id' in values and v == values['home_team_id']:
|
||||||
|
raise ValueError('Home and away teams must be different')
|
||||||
|
return v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding Query Parameters
|
||||||
|
|
||||||
|
For list endpoints with filters:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
@router.get("/games/")
|
||||||
|
async def list_games(
|
||||||
|
league_id: Optional[str] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List games with optional filters
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- league_id: Filter by league ('sba' or 'pd')
|
||||||
|
- status: Filter by status ('pending', 'active', 'completed')
|
||||||
|
- limit: Number of results (default 50, max 100)
|
||||||
|
- offset: Pagination offset (default 0)
|
||||||
|
"""
|
||||||
|
db_ops = DatabaseOperations()
|
||||||
|
games = await db_ops.list_games(
|
||||||
|
league_id=league_id,
|
||||||
|
status=status,
|
||||||
|
limit=min(limit, 100),
|
||||||
|
offset=offset
|
||||||
|
)
|
||||||
|
return games
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Manual Testing with Swagger UI
|
||||||
|
|
||||||
|
FastAPI automatically generates interactive API documentation:
|
||||||
|
|
||||||
|
1. Start the backend server:
|
||||||
|
```bash
|
||||||
|
python -m app.main
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Open Swagger UI:
|
||||||
|
```
|
||||||
|
http://localhost:8000/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Features:
|
||||||
|
- View all endpoints grouped by tags
|
||||||
|
- See request/response schemas
|
||||||
|
- Try out endpoints with test data
|
||||||
|
- View response bodies and status codes
|
||||||
|
- Copy curl commands
|
||||||
|
|
||||||
|
### Manual Testing with curl
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:8000/api/health
|
||||||
|
|
||||||
|
# Database health check
|
||||||
|
curl http://localhost:8000/api/health/db
|
||||||
|
|
||||||
|
# Create token (stub)
|
||||||
|
curl -X POST http://localhost:8000/api/auth/token \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"user_id": "test123",
|
||||||
|
"username": "testuser",
|
||||||
|
"discord_id": "123456789"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# List games (stub)
|
||||||
|
curl http://localhost:8000/api/games/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unit Testing
|
||||||
|
|
||||||
|
Create test files in `tests/unit/api/`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_endpoint():
|
||||||
|
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||||
|
response = await client.get("/api/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] == "healthy"
|
||||||
|
assert "timestamp" in data
|
||||||
|
assert "version" in data
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_token():
|
||||||
|
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||||
|
response = await client.post("/api/auth/token", json={
|
||||||
|
"user_id": "test123",
|
||||||
|
"username": "testuser",
|
||||||
|
"discord_id": "123456789"
|
||||||
|
})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "access_token" in data
|
||||||
|
assert data["token_type"] == "bearer"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Testing
|
||||||
|
|
||||||
|
Test with real database:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from app.main import app
|
||||||
|
from app.database.session import init_db
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_database_health_endpoint():
|
||||||
|
await init_db()
|
||||||
|
|
||||||
|
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||||
|
response = await client.get("/api/health/db")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] == "healthy"
|
||||||
|
assert data["database"] == "connected"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Issue: 422 Unprocessable Entity
|
||||||
|
|
||||||
|
**Cause**: Request body doesn't match Pydantic model schema
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Check Swagger UI for expected schema
|
||||||
|
2. Verify all required fields are present
|
||||||
|
3. Check field types match (string vs int, etc.)
|
||||||
|
4. Check for custom validators
|
||||||
|
|
||||||
|
**Example Error**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": [
|
||||||
|
{
|
||||||
|
"loc": ["body", "user_id"],
|
||||||
|
"msg": "field required",
|
||||||
|
"type": "value_error.missing"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: 401 Unauthorized
|
||||||
|
|
||||||
|
**Cause**: Missing or invalid authentication token
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Verify token is included in Authorization header
|
||||||
|
2. Check token format: `Bearer <token>`
|
||||||
|
3. Verify token hasn't expired
|
||||||
|
4. Check secret_key matches between token creation and verification
|
||||||
|
|
||||||
|
### Issue: 500 Internal Server Error
|
||||||
|
|
||||||
|
**Cause**: Unhandled exception in route handler
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Check backend logs for stack trace
|
||||||
|
2. Add try/except blocks with specific error handling
|
||||||
|
3. Use HTTPException for expected errors
|
||||||
|
4. Log errors with `exc_info=True` for full stack trace
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
result = await some_operation()
|
||||||
|
except ValidationError as e:
|
||||||
|
# Expected error - return 400
|
||||||
|
logger.warning(f"Validation failed: {e}")
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
# Unexpected error - return 500 and log
|
||||||
|
logger.error(f"Unexpected error: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: CORS Errors in Frontend
|
||||||
|
|
||||||
|
**Cause**: Frontend origin not in CORS allowed origins
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Check frontend URL in browser console error
|
||||||
|
2. Add origin to `.env`:
|
||||||
|
```bash
|
||||||
|
CORS_ORIGINS=["http://localhost:3000", "http://localhost:3001", "http://your-frontend.com"]
|
||||||
|
```
|
||||||
|
3. Restart backend server
|
||||||
|
|
||||||
|
### Issue: Route Not Found (404)
|
||||||
|
|
||||||
|
**Cause**: Route not registered or incorrect path
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Verify router is imported and registered in `app/main.py`
|
||||||
|
2. Check prefix matches: `/api/games/` vs `/api/game/`
|
||||||
|
3. Verify route path in decorator matches request
|
||||||
|
4. Check method matches (GET vs POST)
|
||||||
|
|
||||||
|
**Example Registration**:
|
||||||
|
```python
|
||||||
|
# In app/main.py
|
||||||
|
from app.api.routes import games
|
||||||
|
|
||||||
|
app.include_router(
|
||||||
|
games.router,
|
||||||
|
prefix="/api/games", # All routes will be /api/games/*
|
||||||
|
tags=["games"]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Keep Route Handlers Thin
|
||||||
|
|
||||||
|
Route handlers should orchestrate, not implement logic:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ❌ Bad - business logic in route handler
|
||||||
|
@router.post("/games/")
|
||||||
|
async def create_game(request: CreateGameRequest):
|
||||||
|
# Validate teams exist
|
||||||
|
# Check user permissions
|
||||||
|
# Create game record
|
||||||
|
# Initialize state
|
||||||
|
# Send notifications
|
||||||
|
# ... 100 lines of logic
|
||||||
|
|
||||||
|
# ✅ Good - orchestrate using service layer
|
||||||
|
@router.post("/games/")
|
||||||
|
async def create_game(request: CreateGameRequest, user = Depends(get_current_user)):
|
||||||
|
game_service = GameService()
|
||||||
|
game = await game_service.create_game(request, user['user_id'])
|
||||||
|
return game
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Use Explicit Response Models
|
||||||
|
|
||||||
|
Always specify `response_model` for type safety and documentation:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ❌ Bad - no response model
|
||||||
|
@router.get("/games/{game_id}")
|
||||||
|
async def get_game(game_id: str):
|
||||||
|
return {"game_id": game_id, "status": "active"}
|
||||||
|
|
||||||
|
# ✅ Good - explicit response model
|
||||||
|
@router.get("/games/{game_id}", response_model=GameResponse)
|
||||||
|
async def get_game(game_id: str):
|
||||||
|
return GameResponse(game_id=game_id, status="active")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Document Endpoints
|
||||||
|
|
||||||
|
Use docstrings for endpoint documentation:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.get("/games/", response_model=List[GameListItem])
|
||||||
|
async def list_games(
|
||||||
|
league_id: Optional[str] = None,
|
||||||
|
status: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List games with optional filters.
|
||||||
|
|
||||||
|
Query Parameters:
|
||||||
|
- league_id: Filter by league ('sba' or 'pd')
|
||||||
|
- status: Filter by status ('pending', 'active', 'completed')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- List of GameListItem objects
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
- 400: Invalid filter parameters
|
||||||
|
- 500: Database error
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Handle Errors Gracefully
|
||||||
|
|
||||||
|
Return meaningful error messages:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.get("/games/{game_id}")
|
||||||
|
async def get_game(game_id: str):
|
||||||
|
game = await db_ops.get_game(game_id)
|
||||||
|
|
||||||
|
if not game:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Game {game_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
return game
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Use Dependency Injection
|
||||||
|
|
||||||
|
Share common logic via dependencies:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
async def get_db_ops():
|
||||||
|
"""Dependency for database operations"""
|
||||||
|
return DatabaseOperations()
|
||||||
|
|
||||||
|
@router.get("/games/{game_id}")
|
||||||
|
async def get_game(
|
||||||
|
game_id: str,
|
||||||
|
db_ops: DatabaseOperations = Depends(get_db_ops)
|
||||||
|
):
|
||||||
|
# db_ops injected automatically
|
||||||
|
game = await db_ops.get_game(game_id)
|
||||||
|
return game
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Version Your API
|
||||||
|
|
||||||
|
When making breaking changes, use API versioning:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Option 1: URL path versioning
|
||||||
|
app.include_router(games_v1.router, prefix="/api/v1/games")
|
||||||
|
app.include_router(games_v2.router, prefix="/api/v2/games")
|
||||||
|
|
||||||
|
# Option 2: Header versioning (more complex)
|
||||||
|
@router.get("/games/")
|
||||||
|
async def list_games(api_version: str = Header(default="v1")):
|
||||||
|
if api_version == "v2":
|
||||||
|
return new_format_games()
|
||||||
|
else:
|
||||||
|
return legacy_format_games()
|
||||||
|
```
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- **FastAPI Documentation**: https://fastapi.tiangolo.com/
|
||||||
|
- **Pydantic Documentation**: https://docs.pydantic.dev/
|
||||||
|
- **Main Application**: `app/main.py` (router registration)
|
||||||
|
- **Settings**: `app/config.py` (application configuration)
|
||||||
|
- **Database Layer**: `app/database/operations.py` (DB operations)
|
||||||
|
- **WebSocket Layer**: `app/websocket/handlers.py` (real-time events)
|
||||||
|
- **Authentication**: `app/utils/auth.py` (JWT utilities)
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Phase 1 (Weeks 1-4)
|
||||||
|
|
||||||
|
- [ ] Complete Discord OAuth flow in `auth.py`
|
||||||
|
- [ ] Add authentication middleware
|
||||||
|
- [ ] Create user session endpoints
|
||||||
|
- [ ] Add token refresh mechanism
|
||||||
|
|
||||||
|
### Phase 2+ (Weeks 5-13)
|
||||||
|
|
||||||
|
- [ ] Implement game CRUD in `games.py`
|
||||||
|
- [ ] Add game listing with filters and pagination
|
||||||
|
- [ ] Create team management endpoints
|
||||||
|
- [ ] Add roster/lineup endpoints
|
||||||
|
- [ ] Create statistics/analytics endpoints
|
||||||
|
- [ ] Add game history endpoints
|
||||||
|
- [ ] Implement WebSocket-REST synchronization
|
||||||
|
|
||||||
|
### Beyond MVP
|
||||||
|
|
||||||
|
- [ ] Add comprehensive API rate limiting
|
||||||
|
- [ ] Implement API key authentication for external integrations
|
||||||
|
- [ ] Create admin endpoints for moderation
|
||||||
|
- [ ] Add bulk operations endpoints
|
||||||
|
- [ ] Create export endpoints (CSV, JSON)
|
||||||
|
- [ ] Add advanced search and filtering
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Note**: This directory is currently 33% complete (health endpoints only). Most endpoints are stubs awaiting Phase 2+ implementation. Focus on health endpoints for current operational needs.
|
||||||
|
|
||||||
|
**Current Phase**: Phase 2 - Week 8
|
||||||
|
**Last Updated**: 2025-10-31
|
||||||
906
backend/app/config/CLAUDE.md
Normal file
906
backend/app/config/CLAUDE.md
Normal file
@ -0,0 +1,906 @@
|
|||||||
|
# Configuration System - League Rules & Play Outcomes
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
The configuration system provides immutable, league-specific game rules and play outcome definitions for the Paper Dynasty game engine. It serves as the single source of truth for:
|
||||||
|
|
||||||
|
- League-specific game rules (innings, outs, feature flags)
|
||||||
|
- API endpoint configuration for external data sources
|
||||||
|
- Universal play outcome definitions (hits, outs, walks, etc.)
|
||||||
|
- Card-based resolution mechanics for both manual and auto modes
|
||||||
|
- Hit location calculation for runner advancement logic
|
||||||
|
|
||||||
|
This system enables a **league-agnostic game engine** that adapts to SBA and PD league differences through configuration rather than conditional logic.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
app/config/
|
||||||
|
├── __init__.py # Public API exports
|
||||||
|
├── base_config.py # Abstract base configuration
|
||||||
|
├── league_configs.py # Concrete SBA/PD implementations
|
||||||
|
└── result_charts.py # PlayOutcome enum + result chart abstractions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Design Principles
|
||||||
|
|
||||||
|
1. **Immutability**: Configs are frozen Pydantic models (cannot be modified after creation)
|
||||||
|
2. **Registry Pattern**: Pre-instantiated singletons in `LEAGUE_CONFIGS` dict
|
||||||
|
3. **Type Safety**: Full Pydantic validation with abstract base class enforcement
|
||||||
|
4. **League Agnostic**: Game engine uses `BaseGameConfig` interface, never concrete types
|
||||||
|
|
||||||
|
## Key Components
|
||||||
|
|
||||||
|
### 1. BaseGameConfig (Abstract Base Class)
|
||||||
|
|
||||||
|
**Location**: `base_config.py:13-77`
|
||||||
|
|
||||||
|
Defines the interface all league configs must implement.
|
||||||
|
|
||||||
|
**Common Fields**:
|
||||||
|
- `league_id` (str): League identifier ('sba' or 'pd')
|
||||||
|
- `version` (str): Config version for compatibility tracking
|
||||||
|
- `innings` (int): Standard innings per game (default 9)
|
||||||
|
- `outs_per_inning` (int): Outs required per half-inning (default 3)
|
||||||
|
|
||||||
|
**Abstract Methods** (must be implemented by subclasses):
|
||||||
|
```python
|
||||||
|
@abstractmethod
|
||||||
|
def get_result_chart_name(self) -> str:
|
||||||
|
"""Get name of result chart to use for this league."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def supports_manual_result_selection(self) -> bool:
|
||||||
|
"""Whether players manually select results after dice roll."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def supports_auto_mode(self) -> bool:
|
||||||
|
"""Whether this league supports auto-resolution of outcomes."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_api_base_url(self) -> str:
|
||||||
|
"""Get base URL for league's external API."""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Configuration**:
|
||||||
|
```python
|
||||||
|
class Config:
|
||||||
|
frozen = True # Immutable - prevents accidental modification
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. League-Specific Configs
|
||||||
|
|
||||||
|
#### SbaConfig
|
||||||
|
|
||||||
|
**Location**: `league_configs.py:17-46`
|
||||||
|
|
||||||
|
Configuration for SBA League with manual result selection.
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Manual result selection only (physical cards, not digitized)
|
||||||
|
- Simple player data model
|
||||||
|
- Standard baseball rules
|
||||||
|
|
||||||
|
**Unique Fields**:
|
||||||
|
- `player_selection_mode`: "manual" (always manual selection)
|
||||||
|
|
||||||
|
**Methods**:
|
||||||
|
- `get_result_chart_name()` → "sba_standard_v1"
|
||||||
|
- `supports_manual_result_selection()` → True
|
||||||
|
- `supports_auto_mode()` → False (cards not digitized)
|
||||||
|
- `get_api_base_url()` → "https://api.sba.manticorum.com"
|
||||||
|
|
||||||
|
#### PdConfig
|
||||||
|
|
||||||
|
**Location**: `league_configs.py:49-86`
|
||||||
|
|
||||||
|
Configuration for Paper Dynasty League with flexible resolution modes.
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Flexible result selection (manual OR auto via scouting)
|
||||||
|
- Complex scouting data model (PdBattingRating/PdPitchingRating)
|
||||||
|
- Cardset validation
|
||||||
|
- Advanced analytics (WPA, RE24)
|
||||||
|
|
||||||
|
**Unique Fields**:
|
||||||
|
- `player_selection_mode`: "flexible" (manual or auto)
|
||||||
|
- `use_scouting_model`: True (use detailed ratings for auto)
|
||||||
|
- `cardset_validation`: True (validate cards against approved sets)
|
||||||
|
- `detailed_analytics`: True (track advanced stats)
|
||||||
|
- `wpa_calculation`: True (calculate win probability added)
|
||||||
|
|
||||||
|
**Methods**:
|
||||||
|
- `get_result_chart_name()` → "pd_standard_v1"
|
||||||
|
- `supports_manual_result_selection()` → True (though auto is also available)
|
||||||
|
- `supports_auto_mode()` → True (via digitized scouting data)
|
||||||
|
- `get_api_base_url()` → "https://pd.manticorum.com"
|
||||||
|
|
||||||
|
### 3. Config Registry
|
||||||
|
|
||||||
|
**Location**: `league_configs.py:88-115`
|
||||||
|
|
||||||
|
Pre-instantiated singletons for O(1) lookup.
|
||||||
|
|
||||||
|
```python
|
||||||
|
LEAGUE_CONFIGS: Dict[str, BaseGameConfig] = {
|
||||||
|
"sba": SbaConfig(),
|
||||||
|
"pd": PdConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_league_config(league_id: str) -> BaseGameConfig:
|
||||||
|
"""Get configuration for specified league."""
|
||||||
|
config = LEAGUE_CONFIGS.get(league_id)
|
||||||
|
if not config:
|
||||||
|
raise ValueError(f"Unknown league: {league_id}")
|
||||||
|
return config
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. PlayOutcome Enum
|
||||||
|
|
||||||
|
**Location**: `result_charts.py:38-197`
|
||||||
|
|
||||||
|
Universal enum defining all possible play outcomes for both leagues.
|
||||||
|
|
||||||
|
**Outcome Categories**:
|
||||||
|
|
||||||
|
1. **Outs** (9 types):
|
||||||
|
- `STRIKEOUT`
|
||||||
|
- `GROUNDBALL_A` / `GROUNDBALL_B` / `GROUNDBALL_C` (double play vs groundout)
|
||||||
|
- `FLYOUT_A` / `FLYOUT_B` / `FLYOUT_C` (different trajectories/depths)
|
||||||
|
- `LINEOUT`
|
||||||
|
- `POPOUT`
|
||||||
|
|
||||||
|
2. **Hits** (8 types):
|
||||||
|
- `SINGLE_1` / `SINGLE_2` / `SINGLE_UNCAPPED` (standard vs enhanced vs decision tree)
|
||||||
|
- `DOUBLE_2` / `DOUBLE_3` / `DOUBLE_UNCAPPED` (2nd base vs 3rd base vs decision tree)
|
||||||
|
- `TRIPLE`
|
||||||
|
- `HOMERUN`
|
||||||
|
|
||||||
|
3. **Walks/HBP** (3 types):
|
||||||
|
- `WALK`
|
||||||
|
- `HIT_BY_PITCH`
|
||||||
|
- `INTENTIONAL_WALK`
|
||||||
|
|
||||||
|
4. **Errors** (1 type):
|
||||||
|
- `ERROR`
|
||||||
|
|
||||||
|
5. **Interrupt Plays** (6 types) - logged with `pa=0`:
|
||||||
|
- `WILD_PITCH` (Play.wp = 1)
|
||||||
|
- `PASSED_BALL` (Play.pb = 1)
|
||||||
|
- `STOLEN_BASE` (Play.sb = 1)
|
||||||
|
- `CAUGHT_STEALING` (Play.cs = 1)
|
||||||
|
- `BALK` (Play.balk = 1)
|
||||||
|
- `PICK_OFF` (Play.pick_off = 1)
|
||||||
|
|
||||||
|
6. **Ballpark Power** (4 types) - PD league specific:
|
||||||
|
- `BP_HOMERUN` (Play.bphr = 1)
|
||||||
|
- `BP_SINGLE` (Play.bp1b = 1)
|
||||||
|
- `BP_FLYOUT` (Play.bpfo = 1)
|
||||||
|
- `BP_LINEOUT` (Play.bplo = 1)
|
||||||
|
|
||||||
|
**Helper Methods**:
|
||||||
|
```python
|
||||||
|
outcome = PlayOutcome.SINGLE_UNCAPPED
|
||||||
|
|
||||||
|
# Categorization helpers
|
||||||
|
outcome.is_hit() # True
|
||||||
|
outcome.is_out() # False
|
||||||
|
outcome.is_walk() # False
|
||||||
|
outcome.is_uncapped() # True - requires advancement decision
|
||||||
|
outcome.is_interrupt() # False
|
||||||
|
outcome.is_extra_base_hit() # False
|
||||||
|
|
||||||
|
# Advancement logic
|
||||||
|
outcome.get_bases_advanced() # 1
|
||||||
|
outcome.requires_hit_location() # False (only groundballs/flyouts)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Hit Location Calculation
|
||||||
|
|
||||||
|
**Location**: `result_charts.py:206-279`
|
||||||
|
|
||||||
|
Calculates fielder positions for groundballs and flyouts based on batter handedness.
|
||||||
|
|
||||||
|
**Function**:
|
||||||
|
```python
|
||||||
|
def calculate_hit_location(
|
||||||
|
outcome: PlayOutcome,
|
||||||
|
batter_handedness: str
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Calculate hit location based on outcome and batter handedness.
|
||||||
|
|
||||||
|
Pull Rate Distribution:
|
||||||
|
- 45% pull side (RHB left, LHB right)
|
||||||
|
- 35% center
|
||||||
|
- 20% opposite field
|
||||||
|
|
||||||
|
Groundball Locations: P, C, 1B, 2B, SS, 3B (infield)
|
||||||
|
Fly Ball Locations: LF, CF, RF (outfield)
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage**:
|
||||||
|
```python
|
||||||
|
from app.config import calculate_hit_location, PlayOutcome
|
||||||
|
|
||||||
|
# Calculate location for groundball
|
||||||
|
location = calculate_hit_location(PlayOutcome.GROUNDBALL_A, 'R') # '3B', 'SS', etc.
|
||||||
|
|
||||||
|
# Only works for groundballs/flyouts
|
||||||
|
location = calculate_hit_location(PlayOutcome.HOMERUN, 'R') # None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. ResultChart Abstraction (Future)
|
||||||
|
|
||||||
|
**Location**: `result_charts.py:285-588`
|
||||||
|
|
||||||
|
Abstract base class for result chart implementations. Currently defines interface for future auto-mode implementation.
|
||||||
|
|
||||||
|
**Classes**:
|
||||||
|
- `ResultChart` (ABC): Abstract interface
|
||||||
|
- `ManualResultChart`: Placeholder (not used - manual outcomes come via WebSocket)
|
||||||
|
- `PdAutoResultChart`: Auto-resolution for PD league using digitized card data
|
||||||
|
|
||||||
|
**Note**: Manual mode doesn't use result charts - outcomes come directly from WebSocket handlers.
|
||||||
|
|
||||||
|
## Patterns & Conventions
|
||||||
|
|
||||||
|
### 1. Immutable Configuration
|
||||||
|
|
||||||
|
All configs are frozen after instantiation to prevent accidental modification.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ✅ CORRECT - Read-only access
|
||||||
|
config = get_league_config("sba")
|
||||||
|
api_url = config.get_api_base_url()
|
||||||
|
chart_name = config.get_result_chart_name()
|
||||||
|
|
||||||
|
# ❌ WRONG - Raises ValidationError
|
||||||
|
config.innings = 7 # ValidationError: "Game" object is immutable
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Registry Pattern
|
||||||
|
|
||||||
|
Configs are pre-instantiated singletons in the registry, not created per-request.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ✅ CORRECT - Use registry
|
||||||
|
from app.config import get_league_config
|
||||||
|
config = get_league_config(league_id)
|
||||||
|
|
||||||
|
# ❌ WRONG - Don't instantiate directly
|
||||||
|
from app.config import SbaConfig
|
||||||
|
config = SbaConfig() # Creates unnecessary instance
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. League-Agnostic Code
|
||||||
|
|
||||||
|
Game engine uses `BaseGameConfig` interface, never concrete types.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ✅ CORRECT - Works for any league
|
||||||
|
def resolve_play(state: GameState, config: BaseGameConfig):
|
||||||
|
if config.supports_auto_mode():
|
||||||
|
# Auto-resolve
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Wait for manual input
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ❌ WRONG - Hard-coded league logic
|
||||||
|
def resolve_play(state: GameState):
|
||||||
|
if state.league_id == "sba":
|
||||||
|
# SBA-specific logic
|
||||||
|
pass
|
||||||
|
elif state.league_id == "pd":
|
||||||
|
# PD-specific logic
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Enum Helper Methods
|
||||||
|
|
||||||
|
Use PlayOutcome helper methods instead of duplicate logic.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ✅ CORRECT - Use helper methods
|
||||||
|
if outcome.is_hit():
|
||||||
|
record_hit()
|
||||||
|
elif outcome.is_walk():
|
||||||
|
record_walk()
|
||||||
|
elif outcome.is_interrupt():
|
||||||
|
log_interrupt_play()
|
||||||
|
|
||||||
|
# ❌ WRONG - Duplicate categorization logic
|
||||||
|
if outcome in {PlayOutcome.SINGLE_1, PlayOutcome.SINGLE_2, PlayOutcome.HOMERUN, ...}:
|
||||||
|
record_hit()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Type Safety
|
||||||
|
|
||||||
|
Always use type hints with `BaseGameConfig` for league-agnostic code.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ✅ CORRECT - Type-safe
|
||||||
|
from app.config import BaseGameConfig
|
||||||
|
|
||||||
|
def process_game(config: BaseGameConfig) -> None:
|
||||||
|
# Works for SBA or PD
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ❌ WRONG - No type safety
|
||||||
|
def process_game(config) -> None:
|
||||||
|
# Could be anything
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
### With Game Engine
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.config import get_league_config, PlayOutcome
|
||||||
|
from app.models import GameState
|
||||||
|
|
||||||
|
async def resolve_play(state: GameState, outcome: PlayOutcome):
|
||||||
|
# Get league-specific config
|
||||||
|
config = get_league_config(state.league_id)
|
||||||
|
|
||||||
|
# Handle based on outcome type
|
||||||
|
if outcome.is_uncapped() and state.on_base_code > 0:
|
||||||
|
# Uncapped hit with runners - need advancement decision
|
||||||
|
await request_advancement_decision(state)
|
||||||
|
elif outcome.is_interrupt():
|
||||||
|
# Interrupt play - logged with pa=0
|
||||||
|
await log_interrupt_play(state, outcome)
|
||||||
|
elif outcome.is_hit():
|
||||||
|
# Standard hit - advance runners
|
||||||
|
bases = outcome.get_bases_advanced()
|
||||||
|
await advance_batter(state, bases)
|
||||||
|
elif outcome.is_out():
|
||||||
|
# Record out
|
||||||
|
state.outs += 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Database Models
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.config import PlayOutcome
|
||||||
|
from app.models import Play
|
||||||
|
|
||||||
|
async def save_play(outcome: PlayOutcome, state: GameState):
|
||||||
|
play = Play(
|
||||||
|
game_id=state.game_id,
|
||||||
|
outcome=outcome.value, # Store enum value as string
|
||||||
|
pa=0 if outcome.is_interrupt() else 1,
|
||||||
|
ab=1 if not outcome.is_walk() and not outcome.is_interrupt() else 0,
|
||||||
|
hit=1 if outcome.is_hit() else 0,
|
||||||
|
# ... other fields
|
||||||
|
)
|
||||||
|
await db_ops.save_play(play)
|
||||||
|
```
|
||||||
|
|
||||||
|
### With WebSocket Handlers
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.config import get_league_config, PlayOutcome
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def submit_manual_outcome(sid: str, data: dict):
|
||||||
|
"""Handle manual outcome submission from player."""
|
||||||
|
# Validate league supports manual mode
|
||||||
|
config = get_league_config(data['league_id'])
|
||||||
|
if not config.supports_manual_result_selection():
|
||||||
|
raise ValueError("Manual selection not supported for this league")
|
||||||
|
|
||||||
|
# Parse outcome
|
||||||
|
outcome = PlayOutcome(data['outcome'])
|
||||||
|
|
||||||
|
# Process play
|
||||||
|
await process_play_outcome(data['game_id'], outcome)
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Player Models
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.config import calculate_hit_location, PlayOutcome
|
||||||
|
from app.models import PdPlayer
|
||||||
|
|
||||||
|
def resolve_groundball(batter: PdPlayer, outcome: PlayOutcome):
|
||||||
|
# Get batter handedness
|
||||||
|
handedness = batter.batting_card.hand if batter.batting_card else 'R'
|
||||||
|
|
||||||
|
# Calculate hit location
|
||||||
|
location = calculate_hit_location(outcome, handedness)
|
||||||
|
|
||||||
|
# Use location for advancement logic
|
||||||
|
if location in ['1B', '2B']:
|
||||||
|
# Right side groundball - slower to turn double play
|
||||||
|
pass
|
||||||
|
elif location in ['SS', '3B']:
|
||||||
|
# Left side groundball - easier double play
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Adding a New League Config
|
||||||
|
|
||||||
|
1. **Create config class** in `league_configs.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class NewLeagueConfig(BaseGameConfig):
|
||||||
|
"""Configuration for New League."""
|
||||||
|
|
||||||
|
league_id: str = "new_league"
|
||||||
|
|
||||||
|
# New league-specific features
|
||||||
|
custom_feature: bool = True
|
||||||
|
|
||||||
|
def get_result_chart_name(self) -> str:
|
||||||
|
return "new_league_standard_v1"
|
||||||
|
|
||||||
|
def supports_manual_result_selection(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def supports_auto_mode(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_api_base_url(self) -> str:
|
||||||
|
return "https://api.newleague.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Register in LEAGUE_CONFIGS**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
LEAGUE_CONFIGS: Dict[str, BaseGameConfig] = {
|
||||||
|
"sba": SbaConfig(),
|
||||||
|
"pd": PdConfig(),
|
||||||
|
"new_league": NewLeagueConfig() # Add here
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Write tests** in `tests/unit/config/test_league_configs.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_new_league_config():
|
||||||
|
config = get_league_config("new_league")
|
||||||
|
assert config.league_id == "new_league"
|
||||||
|
assert config.get_result_chart_name() == "new_league_standard_v1"
|
||||||
|
assert config.supports_manual_result_selection() is True
|
||||||
|
assert config.supports_auto_mode() is False
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding a New PlayOutcome
|
||||||
|
|
||||||
|
1. **Add to enum** in `result_charts.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class PlayOutcome(str, Enum):
|
||||||
|
# ... existing outcomes
|
||||||
|
|
||||||
|
# New outcome
|
||||||
|
BUNT_SINGLE = "bunt_single" # New bunt result
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Update helper methods** if needed:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def is_hit(self) -> bool:
|
||||||
|
return self in {
|
||||||
|
self.SINGLE_1, self.SINGLE_2, self.SINGLE_UNCAPPED,
|
||||||
|
# ... existing hits
|
||||||
|
self.BUNT_SINGLE # Add to hit category
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Write tests** in `tests/unit/config/test_play_outcome.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_bunt_single_categorization():
|
||||||
|
outcome = PlayOutcome.BUNT_SINGLE
|
||||||
|
assert outcome.is_hit()
|
||||||
|
assert not outcome.is_out()
|
||||||
|
assert outcome.get_bases_advanced() == 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modifying Existing Config
|
||||||
|
|
||||||
|
**DON'T**: Configs are immutable by design.
|
||||||
|
|
||||||
|
**DO**: Create new version if rules change:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Old version (keep for compatibility)
|
||||||
|
class SbaConfigV1(BaseGameConfig):
|
||||||
|
league_id: str = "sba"
|
||||||
|
version: str = "1.0.0"
|
||||||
|
innings: int = 9
|
||||||
|
|
||||||
|
# New version (different rules)
|
||||||
|
class SbaConfigV2(BaseGameConfig):
|
||||||
|
league_id: str = "sba"
|
||||||
|
version: str = "2.0.0"
|
||||||
|
innings: int = 7 # New: 7-inning games
|
||||||
|
|
||||||
|
# Registry supports versioning
|
||||||
|
LEAGUE_CONFIGS = {
|
||||||
|
"sba:v1": SbaConfigV1(),
|
||||||
|
"sba:v2": SbaConfigV2(),
|
||||||
|
"sba": SbaConfigV2() # Default to latest
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checking League Capabilities
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.config import get_league_config
|
||||||
|
|
||||||
|
def can_use_auto_mode(league_id: str) -> bool:
|
||||||
|
"""Check if league supports auto-resolution."""
|
||||||
|
config = get_league_config(league_id)
|
||||||
|
return config.supports_auto_mode()
|
||||||
|
|
||||||
|
def requires_cardset_validation(league_id: str) -> bool:
|
||||||
|
"""Check if league requires cardset validation."""
|
||||||
|
config = get_league_config(league_id)
|
||||||
|
# PD-specific check
|
||||||
|
return hasattr(config, 'cardset_validation') and config.cardset_validation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Problem: "Unknown league" error
|
||||||
|
|
||||||
|
**Symptom**:
|
||||||
|
```
|
||||||
|
ValueError: Unknown league: xyz. Valid leagues: ['sba', 'pd']
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause**: League ID not in registry
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
```python
|
||||||
|
# Check valid leagues
|
||||||
|
from app.config import LEAGUE_CONFIGS
|
||||||
|
print(LEAGUE_CONFIGS.keys()) # ['sba', 'pd']
|
||||||
|
|
||||||
|
# Use correct league ID
|
||||||
|
config = get_league_config("sba") # ✅
|
||||||
|
config = get_league_config("xyz") # ❌ ValueError
|
||||||
|
```
|
||||||
|
|
||||||
|
### Problem: Cannot modify config
|
||||||
|
|
||||||
|
**Symptom**:
|
||||||
|
```
|
||||||
|
ValidationError: "SbaConfig" object is immutable
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause**: Configs are frozen Pydantic models
|
||||||
|
|
||||||
|
**Solution**: Don't modify configs. They are immutable by design.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ❌ WRONG - Trying to modify
|
||||||
|
config = get_league_config("sba")
|
||||||
|
config.innings = 7 # ValidationError
|
||||||
|
|
||||||
|
# ✅ CORRECT - Create new state with different value
|
||||||
|
state.innings = 7 # Modify game state, not config
|
||||||
|
```
|
||||||
|
|
||||||
|
### Problem: PlayOutcome validation error
|
||||||
|
|
||||||
|
**Symptom**:
|
||||||
|
```
|
||||||
|
ValueError: 'invalid_outcome' is not a valid PlayOutcome
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause**: String doesn't match any enum value
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
```python
|
||||||
|
# ❌ WRONG - Invalid string
|
||||||
|
outcome = PlayOutcome("invalid_outcome") # ValueError
|
||||||
|
|
||||||
|
# ✅ CORRECT - Use enum member
|
||||||
|
outcome = PlayOutcome.SINGLE_1
|
||||||
|
|
||||||
|
# ✅ CORRECT - Parse from valid string
|
||||||
|
outcome = PlayOutcome("single_1")
|
||||||
|
|
||||||
|
# ✅ CORRECT - Check if valid
|
||||||
|
try:
|
||||||
|
outcome = PlayOutcome(user_input)
|
||||||
|
except ValueError:
|
||||||
|
# Handle invalid input
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Problem: Result chart not found
|
||||||
|
|
||||||
|
**Symptom**:
|
||||||
|
```
|
||||||
|
KeyError: 'sba_standard_v1'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause**: Result chart registry not implemented yet
|
||||||
|
|
||||||
|
**Solution**: Result charts are future implementation. Manual mode receives outcomes via WebSocket, not chart lookups.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ❌ WRONG - Trying to lookup chart directly
|
||||||
|
chart = RESULT_CHARTS[config.get_result_chart_name()]
|
||||||
|
|
||||||
|
# ✅ CORRECT - Manual outcomes come via WebSocket
|
||||||
|
@sio.event
|
||||||
|
async def submit_manual_outcome(sid: str, data: dict):
|
||||||
|
outcome = PlayOutcome(data['outcome'])
|
||||||
|
await process_outcome(outcome)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Problem: Missing import
|
||||||
|
|
||||||
|
**Symptom**:
|
||||||
|
```
|
||||||
|
ImportError: cannot import name 'PlayOutcome' from 'app.config'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause**: Not imported in `__init__.py`
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
```python
|
||||||
|
# ✅ CORRECT - Import from package
|
||||||
|
from app.config import PlayOutcome, get_league_config, BaseGameConfig
|
||||||
|
|
||||||
|
# ❌ WRONG - Direct module import
|
||||||
|
from app.config.result_charts import PlayOutcome # Don't do this
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Example 1: Basic Config Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.config import get_league_config
|
||||||
|
|
||||||
|
# Get config for SBA league
|
||||||
|
sba_config = get_league_config("sba")
|
||||||
|
|
||||||
|
print(f"League: {sba_config.league_id}")
|
||||||
|
print(f"Innings: {sba_config.innings}")
|
||||||
|
print(f"API: {sba_config.get_api_base_url()}")
|
||||||
|
print(f"Chart: {sba_config.get_result_chart_name()}")
|
||||||
|
print(f"Manual mode: {sba_config.supports_manual_result_selection()}")
|
||||||
|
print(f"Auto mode: {sba_config.supports_auto_mode()}")
|
||||||
|
|
||||||
|
# Output:
|
||||||
|
# League: sba
|
||||||
|
# Innings: 9
|
||||||
|
# API: https://api.sba.manticorum.com
|
||||||
|
# Chart: sba_standard_v1
|
||||||
|
# Manual mode: True
|
||||||
|
# Auto mode: False
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: PlayOutcome Categorization
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.config import PlayOutcome
|
||||||
|
|
||||||
|
outcomes = [
|
||||||
|
PlayOutcome.SINGLE_1,
|
||||||
|
PlayOutcome.STRIKEOUT,
|
||||||
|
PlayOutcome.WALK,
|
||||||
|
PlayOutcome.SINGLE_UNCAPPED,
|
||||||
|
PlayOutcome.WILD_PITCH
|
||||||
|
]
|
||||||
|
|
||||||
|
for outcome in outcomes:
|
||||||
|
categories = []
|
||||||
|
if outcome.is_hit():
|
||||||
|
categories.append("HIT")
|
||||||
|
if outcome.is_out():
|
||||||
|
categories.append("OUT")
|
||||||
|
if outcome.is_walk():
|
||||||
|
categories.append("WALK")
|
||||||
|
if outcome.is_uncapped():
|
||||||
|
categories.append("UNCAPPED")
|
||||||
|
if outcome.is_interrupt():
|
||||||
|
categories.append("INTERRUPT")
|
||||||
|
|
||||||
|
print(f"{outcome.value}: {', '.join(categories) or 'OTHER'}")
|
||||||
|
|
||||||
|
# Output:
|
||||||
|
# single_1: HIT
|
||||||
|
# strikeout: OUT
|
||||||
|
# walk: WALK
|
||||||
|
# single_uncapped: HIT, UNCAPPED
|
||||||
|
# wild_pitch: INTERRUPT
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 3: Hit Location Calculation
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.config import calculate_hit_location, PlayOutcome
|
||||||
|
|
||||||
|
# Simulate 10 groundballs for right-handed batter
|
||||||
|
print("Right-handed batter groundballs:")
|
||||||
|
for _ in range(10):
|
||||||
|
location = calculate_hit_location(PlayOutcome.GROUNDBALL_A, 'R')
|
||||||
|
print(f" Hit to: {location}")
|
||||||
|
|
||||||
|
# Output (random, but follows pull rate):
|
||||||
|
# Right-handed batter groundballs:
|
||||||
|
# Hit to: 3B (pull side)
|
||||||
|
# Hit to: SS (pull side)
|
||||||
|
# Hit to: 2B (center)
|
||||||
|
# Hit to: P (center)
|
||||||
|
# Hit to: 3B (pull side)
|
||||||
|
# Hit to: 1B (opposite)
|
||||||
|
# Hit to: SS (pull side)
|
||||||
|
# Hit to: 2B (center)
|
||||||
|
# Hit to: 3B (pull side)
|
||||||
|
# Hit to: 2B (opposite)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 4: League-Agnostic Game Logic
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.config import get_league_config, PlayOutcome
|
||||||
|
from app.models import GameState
|
||||||
|
|
||||||
|
async def handle_play_outcome(state: GameState, outcome: PlayOutcome):
|
||||||
|
"""Process play outcome in league-agnostic way."""
|
||||||
|
# Get league config
|
||||||
|
config = get_league_config(state.league_id)
|
||||||
|
|
||||||
|
# Different handling based on outcome type
|
||||||
|
if outcome.is_interrupt():
|
||||||
|
# Interrupt plays don't change batter
|
||||||
|
print(f"Interrupt play: {outcome.value}")
|
||||||
|
await log_interrupt_play(state, outcome)
|
||||||
|
|
||||||
|
elif outcome.is_uncapped() and state.on_base_code > 0:
|
||||||
|
# Uncapped hit with runners - need decision
|
||||||
|
print(f"Uncapped hit: {outcome.value} - requesting advancement decision")
|
||||||
|
if config.supports_auto_mode() and state.auto_mode_enabled:
|
||||||
|
# Auto-resolve advancement
|
||||||
|
await auto_resolve_advancement(state, outcome)
|
||||||
|
else:
|
||||||
|
# Request manual decision
|
||||||
|
await request_advancement_decision(state, outcome)
|
||||||
|
|
||||||
|
elif outcome.is_hit():
|
||||||
|
# Standard hit - advance batter
|
||||||
|
bases = outcome.get_bases_advanced()
|
||||||
|
print(f"Hit: {outcome.value} - batter to base {bases}")
|
||||||
|
await advance_batter(state, bases)
|
||||||
|
|
||||||
|
elif outcome.is_walk():
|
||||||
|
# Walk - advance batter to first
|
||||||
|
print(f"Walk: {outcome.value}")
|
||||||
|
await walk_batter(state)
|
||||||
|
|
||||||
|
elif outcome.is_out():
|
||||||
|
# Out - increment out counter
|
||||||
|
print(f"Out: {outcome.value}")
|
||||||
|
state.outs += 1
|
||||||
|
await check_inning_over(state)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 5: Config-Driven Feature Flags
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.config import get_league_config
|
||||||
|
|
||||||
|
def should_calculate_wpa(league_id: str) -> bool:
|
||||||
|
"""Check if league tracks win probability added."""
|
||||||
|
config = get_league_config(league_id)
|
||||||
|
|
||||||
|
# PD-specific feature
|
||||||
|
if hasattr(config, 'wpa_calculation'):
|
||||||
|
return config.wpa_calculation
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def requires_cardset_validation(league_id: str) -> bool:
|
||||||
|
"""Check if league requires cardset validation."""
|
||||||
|
config = get_league_config(league_id)
|
||||||
|
|
||||||
|
# PD-specific feature
|
||||||
|
if hasattr(config, 'cardset_validation'):
|
||||||
|
return config.cardset_validation
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
if should_calculate_wpa(state.league_id):
|
||||||
|
wpa = calculate_win_probability_added(state, outcome)
|
||||||
|
play.wpa = wpa
|
||||||
|
|
||||||
|
if requires_cardset_validation(state.league_id):
|
||||||
|
validate_cardsets(game_id, card_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
**Location**: `tests/unit/config/`
|
||||||
|
|
||||||
|
**Test Coverage**:
|
||||||
|
- `test_league_configs.py` (28 tests): Config registry, implementations, immutability
|
||||||
|
- `test_play_outcome.py` (30 tests): Enum helpers, categorization, edge cases
|
||||||
|
|
||||||
|
**Run Tests**:
|
||||||
|
```bash
|
||||||
|
# All config tests
|
||||||
|
pytest tests/unit/config/ -v
|
||||||
|
|
||||||
|
# Specific file
|
||||||
|
pytest tests/unit/config/test_league_configs.py -v
|
||||||
|
|
||||||
|
# Specific test
|
||||||
|
pytest tests/unit/config/test_play_outcome.py::test_is_hit -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Examples
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Test config retrieval
|
||||||
|
def test_get_sba_config():
|
||||||
|
config = get_league_config("sba")
|
||||||
|
assert config.league_id == "sba"
|
||||||
|
assert isinstance(config, SbaConfig)
|
||||||
|
|
||||||
|
# Test immutability
|
||||||
|
def test_config_immutable():
|
||||||
|
config = get_league_config("sba")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
config.innings = 7
|
||||||
|
|
||||||
|
# Test PlayOutcome helpers
|
||||||
|
def test_single_uncapped_is_hit():
|
||||||
|
outcome = PlayOutcome.SINGLE_UNCAPPED
|
||||||
|
assert outcome.is_hit()
|
||||||
|
assert outcome.is_uncapped()
|
||||||
|
assert not outcome.is_out()
|
||||||
|
assert outcome.get_bases_advanced() == 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
### Source Files
|
||||||
|
- `app/config/base_config.py` - Abstract base configuration
|
||||||
|
- `app/config/league_configs.py` - Concrete implementations
|
||||||
|
- `app/config/result_charts.py` - PlayOutcome enum
|
||||||
|
- `app/config/__init__.py` - Public API
|
||||||
|
|
||||||
|
### Test Files
|
||||||
|
- `tests/unit/config/test_league_configs.py` - Config system tests
|
||||||
|
- `tests/unit/config/test_play_outcome.py` - PlayOutcome tests
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
- `app/core/game_engine.py` - Uses configs for league-specific rules
|
||||||
|
- `app/core/play_resolver.py` - Uses PlayOutcome for resolution logic
|
||||||
|
- `app/models/game_models.py` - GameState uses league_id
|
||||||
|
- `app/models/player_models.py` - Player models use handedness for hit location
|
||||||
|
- `app/websocket/handlers.py` - Validates league capabilities
|
||||||
|
|
||||||
|
## Key Takeaways
|
||||||
|
|
||||||
|
1. **Immutability**: Configs are frozen and cannot be modified after creation
|
||||||
|
2. **Registry**: Use `get_league_config()` to access pre-instantiated singletons
|
||||||
|
3. **Type Safety**: Always use `BaseGameConfig` for league-agnostic code
|
||||||
|
4. **Helper Methods**: Use PlayOutcome helpers instead of duplicate categorization logic
|
||||||
|
5. **No Static Charts**: Result charts come from card data (PD) or manual entry (SBA)
|
||||||
|
6. **League Agnostic**: Game engine adapts to leagues via config, not conditionals
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Parent backend documentation: `../CLAUDE.md`
|
||||||
|
- Week 6 implementation: `../../../../.claude/implementation/02-week6-player-models.md`
|
||||||
|
- PlayResolver integration: `../core/play_resolver.py`
|
||||||
|
- Game engine usage: `../core/game_engine.py`
|
||||||
1288
backend/app/core/CLAUDE.md
Normal file
1288
backend/app/core/CLAUDE.md
Normal file
File diff suppressed because it is too large
Load Diff
937
backend/app/data/CLAUDE.md
Normal file
937
backend/app/data/CLAUDE.md
Normal file
@ -0,0 +1,937 @@
|
|||||||
|
# Data Layer - External API Integration & Caching
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The data layer provides external data integration for the Paper Dynasty game engine. It handles communication with league REST APIs to fetch team rosters, player data, and submit completed game results.
|
||||||
|
|
||||||
|
**Status**: 🚧 **NOT YET IMPLEMENTED** - This directory is currently empty and awaits implementation.
|
||||||
|
|
||||||
|
**Purpose**:
|
||||||
|
- Fetch team/roster data from league-specific REST APIs (SBA and PD)
|
||||||
|
- Retrieve detailed player/card information
|
||||||
|
- Submit completed game results to league systems
|
||||||
|
- Cache frequently accessed data to reduce API calls
|
||||||
|
- Abstract API differences between leagues
|
||||||
|
|
||||||
|
## Planned Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
app/data/
|
||||||
|
├── __init__.py # Public API exports
|
||||||
|
├── api_client.py # Base API client with HTTP operations
|
||||||
|
├── sba_client.py # SBA League API wrapper
|
||||||
|
├── pd_client.py # PD League API wrapper
|
||||||
|
└── cache.py # Optional caching layer (Redis or in-memory)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
### With Game Engine
|
||||||
|
```python
|
||||||
|
# Game engine needs player data at game start
|
||||||
|
from app.data import get_api_client
|
||||||
|
|
||||||
|
# Get league-specific client
|
||||||
|
api_client = get_api_client(league_id="sba")
|
||||||
|
|
||||||
|
# Fetch roster for game
|
||||||
|
roster = await api_client.get_team_roster(team_id=123)
|
||||||
|
|
||||||
|
# Create lineup from roster data
|
||||||
|
lineup = create_lineup_from_roster(roster)
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Database Layer
|
||||||
|
```python
|
||||||
|
# Store fetched data in database
|
||||||
|
from app.database.operations import DatabaseOperations
|
||||||
|
|
||||||
|
db_ops = DatabaseOperations()
|
||||||
|
|
||||||
|
# Fetch and persist player data
|
||||||
|
player_data = await api_client.get_player(player_id=456)
|
||||||
|
await db_ops.store_player_metadata(player_data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Player Models
|
||||||
|
```python
|
||||||
|
# Parse API responses into typed player models
|
||||||
|
from app.models import SbaPlayer, PdPlayer
|
||||||
|
|
||||||
|
# SBA league
|
||||||
|
sba_data = await sba_client.get_player(player_id=123)
|
||||||
|
player = SbaPlayer.from_api_response(sba_data)
|
||||||
|
|
||||||
|
# PD league (with scouting data)
|
||||||
|
pd_data = await pd_client.get_player(player_id=456)
|
||||||
|
batting_data = await pd_client.get_batting_card(player_id=456)
|
||||||
|
pitching_data = await pd_client.get_pitching_card(player_id=456)
|
||||||
|
player = PdPlayer.from_api_response(pd_data, batting_data, pitching_data)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Clients Design
|
||||||
|
|
||||||
|
### Base API Client Pattern
|
||||||
|
|
||||||
|
**Location**: `app/data/api_client.py` (not yet created)
|
||||||
|
|
||||||
|
**Purpose**: Abstract HTTP client with common patterns for all league APIs.
|
||||||
|
|
||||||
|
**Key Features**:
|
||||||
|
- Async HTTP requests using `httpx` or `aiohttp`
|
||||||
|
- Automatic retry logic with exponential backoff
|
||||||
|
- Request/response logging
|
||||||
|
- Error handling and custom exceptions
|
||||||
|
- Authentication header management
|
||||||
|
- Rate limiting protection
|
||||||
|
|
||||||
|
**Example Implementation**:
|
||||||
|
```python
|
||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
logger = logging.getLogger(f'{__name__}.BaseApiClient')
|
||||||
|
|
||||||
|
class ApiClientError(Exception):
|
||||||
|
"""Base exception for API client errors"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ApiConnectionError(ApiClientError):
|
||||||
|
"""Raised when API connection fails"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ApiAuthenticationError(ApiClientError):
|
||||||
|
"""Raised when authentication fails"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ApiNotFoundError(ApiClientError):
|
||||||
|
"""Raised when resource not found (404)"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class BaseApiClient(ABC):
|
||||||
|
"""Abstract base class for league API clients
|
||||||
|
|
||||||
|
Provides common HTTP operations and error handling.
|
||||||
|
Subclasses implement league-specific endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, api_key: Optional[str] = None):
|
||||||
|
self.base_url = base_url.rstrip('/')
|
||||||
|
self.api_key = api_key
|
||||||
|
self._client: Optional[httpx.AsyncClient] = None
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
"""Async context manager entry"""
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
base_url=self.base_url,
|
||||||
|
headers=self._get_headers(),
|
||||||
|
timeout=30.0
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
"""Async context manager exit"""
|
||||||
|
if self._client:
|
||||||
|
await self._client.aclose()
|
||||||
|
|
||||||
|
def _get_headers(self) -> Dict[str, str]:
|
||||||
|
"""Get request headers including auth"""
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
if self.api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
async def _get(self, endpoint: str, params: Optional[Dict] = None) -> Dict[str, Any]:
|
||||||
|
"""Execute GET request with error handling
|
||||||
|
|
||||||
|
Args:
|
||||||
|
endpoint: API endpoint path (e.g., "/teams/123")
|
||||||
|
params: Optional query parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed JSON response
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ApiConnectionError: Network/connection error
|
||||||
|
ApiAuthenticationError: Auth failure (401, 403)
|
||||||
|
ApiNotFoundError: Resource not found (404)
|
||||||
|
ApiClientError: Other API errors
|
||||||
|
"""
|
||||||
|
if not self._client:
|
||||||
|
raise RuntimeError("Client not initialized. Use 'async with' context manager.")
|
||||||
|
|
||||||
|
url = endpoint if endpoint.startswith('/') else f'/{endpoint}'
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.debug(f"GET {url} with params {params}")
|
||||||
|
response = await self._client.get(url, params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
logger.debug(f"Response: {data}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 404:
|
||||||
|
raise ApiNotFoundError(f"Resource not found: {url}") from e
|
||||||
|
elif e.response.status_code in (401, 403):
|
||||||
|
raise ApiAuthenticationError(f"Authentication failed: {e.response.text}") from e
|
||||||
|
else:
|
||||||
|
raise ApiClientError(f"HTTP {e.response.status_code}: {e.response.text}") from e
|
||||||
|
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Connection error for {url}: {e}")
|
||||||
|
raise ApiConnectionError(f"Failed to connect to API: {e}") from e
|
||||||
|
|
||||||
|
async def _post(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Execute POST request with error handling"""
|
||||||
|
if not self._client:
|
||||||
|
raise RuntimeError("Client not initialized. Use 'async with' context manager.")
|
||||||
|
|
||||||
|
url = endpoint if endpoint.startswith('/') else f'/{endpoint}'
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.debug(f"POST {url} with data {data}")
|
||||||
|
response = await self._client.post(url, json=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
logger.debug(f"Response: {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code in (401, 403):
|
||||||
|
raise ApiAuthenticationError(f"Authentication failed: {e.response.text}") from e
|
||||||
|
else:
|
||||||
|
raise ApiClientError(f"HTTP {e.response.status_code}: {e.response.text}") from e
|
||||||
|
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Connection error for {url}: {e}")
|
||||||
|
raise ApiConnectionError(f"Failed to connect to API: {e}") from e
|
||||||
|
|
||||||
|
# Abstract methods for subclasses to implement
|
||||||
|
@abstractmethod
|
||||||
|
async def get_team(self, team_id: int) -> Dict[str, Any]:
|
||||||
|
"""Fetch team details"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_team_roster(self, team_id: int) -> Dict[str, Any]:
|
||||||
|
"""Fetch team roster with all cards/players"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_player(self, player_id: int) -> Dict[str, Any]:
|
||||||
|
"""Fetch player details"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def submit_game_result(self, game_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Submit completed game results to league system"""
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### SBA League API Client
|
||||||
|
|
||||||
|
**Location**: `app/data/sba_client.py` (not yet created)
|
||||||
|
|
||||||
|
**API Base URL**: `https://api.sba.manticorum.com` (from config)
|
||||||
|
|
||||||
|
**Key Endpoints**:
|
||||||
|
- `GET /teams/:id` - Team details
|
||||||
|
- `GET /teams/:id/roster` - Team roster
|
||||||
|
- `GET /players/:id` - Player details (simple model)
|
||||||
|
- `POST /games/submit` - Submit completed game
|
||||||
|
|
||||||
|
**Example Implementation**:
|
||||||
|
```python
|
||||||
|
from typing import Dict, Any
|
||||||
|
from .api_client import BaseApiClient
|
||||||
|
|
||||||
|
class SbaApiClient(BaseApiClient):
|
||||||
|
"""SBA League API client
|
||||||
|
|
||||||
|
Handles communication with SBA REST API for team/player data.
|
||||||
|
Uses simple player model (id, name, image, positions).
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def get_team(self, team_id: int) -> Dict[str, Any]:
|
||||||
|
"""Fetch SBA team details
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team_id: SBA team ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Team data including name, manager, season
|
||||||
|
"""
|
||||||
|
return await self._get(f"/teams/{team_id}")
|
||||||
|
|
||||||
|
async def get_team_roster(self, team_id: int) -> Dict[str, Any]:
|
||||||
|
"""Fetch SBA team roster
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team_id: SBA team ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Roster data with list of players
|
||||||
|
"""
|
||||||
|
return await self._get(f"/teams/{team_id}/roster")
|
||||||
|
|
||||||
|
async def get_player(self, player_id: int) -> Dict[str, Any]:
|
||||||
|
"""Fetch SBA player details
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player_id: SBA player ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Player data (id, name, image, positions, wara, team)
|
||||||
|
"""
|
||||||
|
return await self._get(f"/players/{player_id}")
|
||||||
|
|
||||||
|
async def submit_game_result(self, game_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Submit completed SBA game to league system
|
||||||
|
|
||||||
|
Args:
|
||||||
|
game_data: Complete game data including plays, stats, final score
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation response from API
|
||||||
|
"""
|
||||||
|
return await self._post("/games/submit", game_data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### PD League API Client
|
||||||
|
|
||||||
|
**Location**: `app/data/pd_client.py` (not yet created)
|
||||||
|
|
||||||
|
**API Base URL**: `https://api.pd.manticorum.com` (from config)
|
||||||
|
|
||||||
|
**Key Endpoints**:
|
||||||
|
- `GET /api/v2/teams/:id` - Team details
|
||||||
|
- `GET /api/v2/teams/:id/roster` - Team roster
|
||||||
|
- `GET /api/v2/players/:id` - Player/card details
|
||||||
|
- `GET /api/v2/battingcardratings/player/:id` - Batting scouting data
|
||||||
|
- `GET /api/v2/pitchingcardratings/player/:id` - Pitching scouting data
|
||||||
|
- `GET /api/v2/cardsets/:id` - Cardset details
|
||||||
|
- `POST /api/v2/games/submit` - Submit completed game
|
||||||
|
|
||||||
|
**Example Implementation**:
|
||||||
|
```python
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from .api_client import BaseApiClient
|
||||||
|
|
||||||
|
class PdApiClient(BaseApiClient):
|
||||||
|
"""PD League API client
|
||||||
|
|
||||||
|
Handles communication with PD REST API for team/player/card data.
|
||||||
|
Supports detailed scouting data with batting/pitching ratings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def get_team(self, team_id: int) -> Dict[str, Any]:
|
||||||
|
"""Fetch PD team details"""
|
||||||
|
return await self._get(f"/api/v2/teams/{team_id}")
|
||||||
|
|
||||||
|
async def get_team_roster(self, team_id: int) -> Dict[str, Any]:
|
||||||
|
"""Fetch PD team roster"""
|
||||||
|
return await self._get(f"/api/v2/teams/{team_id}/roster")
|
||||||
|
|
||||||
|
async def get_player(self, player_id: int) -> Dict[str, Any]:
|
||||||
|
"""Fetch PD player/card details
|
||||||
|
|
||||||
|
Returns basic card info without scouting data.
|
||||||
|
Use get_batting_card() and get_pitching_card() for detailed ratings.
|
||||||
|
"""
|
||||||
|
return await self._get(f"/api/v2/players/{player_id}")
|
||||||
|
|
||||||
|
async def get_batting_card(self, player_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Fetch PD batting card scouting data
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player_id: PD card ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Batting ratings (steal, bunting, hit ratings vs LHP/RHP) or None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await self._get(f"/api/v2/battingcardratings/player/{player_id}")
|
||||||
|
except ApiNotFoundError:
|
||||||
|
# Not all cards have batting ratings (pitcher-only cards)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_pitching_card(self, player_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Fetch PD pitching card scouting data
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player_id: PD card ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Pitching ratings (balk, hold, ratings vs LHB/RHB) or None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await self._get(f"/api/v2/pitchingcardratings/player/{player_id}")
|
||||||
|
except ApiNotFoundError:
|
||||||
|
# Not all cards have pitching ratings (position players)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_cardset(self, cardset_id: int) -> Dict[str, Any]:
|
||||||
|
"""Fetch PD cardset details
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cardset_id: Cardset ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cardset info (name, description, ranked_legal)
|
||||||
|
"""
|
||||||
|
return await self._get(f"/api/v2/cardsets/{cardset_id}")
|
||||||
|
|
||||||
|
async def submit_game_result(self, game_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Submit completed PD game to league system"""
|
||||||
|
return await self._post("/api/v2/games/submit", game_data)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Caching Strategy
|
||||||
|
|
||||||
|
**Location**: `app/data/cache.py` (not yet created)
|
||||||
|
|
||||||
|
**Purpose**: Reduce API calls by caching frequently accessed data.
|
||||||
|
|
||||||
|
**Caching Targets**:
|
||||||
|
- Player/card data (rarely changes)
|
||||||
|
- Team rosters (changes during roster management, not during games)
|
||||||
|
- Cardset information (static)
|
||||||
|
- **NOT** game state (always use database)
|
||||||
|
|
||||||
|
**Cache Backend Options**:
|
||||||
|
|
||||||
|
### Option 1: Redis (Recommended for Production)
|
||||||
|
```python
|
||||||
|
import redis.asyncio as redis
|
||||||
|
import json
|
||||||
|
from typing import Optional, Any
|
||||||
|
|
||||||
|
class RedisCache:
|
||||||
|
"""Redis-based cache for API data
|
||||||
|
|
||||||
|
Provides async get/set operations with TTL support.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, redis_url: str = "redis://localhost:6379"):
|
||||||
|
self.redis = redis.from_url(redis_url, decode_responses=True)
|
||||||
|
|
||||||
|
async def get(self, key: str) -> Optional[Any]:
|
||||||
|
"""Get cached value
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Cache key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cached value or None if not found/expired
|
||||||
|
"""
|
||||||
|
value = await self.redis.get(key)
|
||||||
|
if value:
|
||||||
|
return json.loads(value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def set(self, key: str, value: Any, ttl: int = 3600) -> None:
|
||||||
|
"""Set cache value with TTL
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Cache key
|
||||||
|
value: Value to cache (must be JSON-serializable)
|
||||||
|
ttl: Time to live in seconds (default 1 hour)
|
||||||
|
"""
|
||||||
|
await self.redis.set(key, json.dumps(value), ex=ttl)
|
||||||
|
|
||||||
|
async def delete(self, key: str) -> None:
|
||||||
|
"""Delete cached value"""
|
||||||
|
await self.redis.delete(key)
|
||||||
|
|
||||||
|
async def clear_pattern(self, pattern: str) -> None:
|
||||||
|
"""Delete all keys matching pattern
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pattern: Redis key pattern (e.g., "player:*")
|
||||||
|
"""
|
||||||
|
keys = await self.redis.keys(pattern)
|
||||||
|
if keys:
|
||||||
|
await self.redis.delete(*keys)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: In-Memory Cache (Development/Testing)
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
import pendulum
|
||||||
|
from typing import Dict, Any, Optional, Tuple
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CacheEntry:
|
||||||
|
"""Cache entry with expiration"""
|
||||||
|
value: Any
|
||||||
|
expires_at: pendulum.DateTime
|
||||||
|
|
||||||
|
class MemoryCache:
|
||||||
|
"""In-memory cache for API data
|
||||||
|
|
||||||
|
Simple dict-based cache with TTL support.
|
||||||
|
Suitable for development/testing, not production.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._cache: Dict[str, CacheEntry] = {}
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def get(self, key: str) -> Optional[Any]:
|
||||||
|
"""Get cached value if not expired"""
|
||||||
|
async with self._lock:
|
||||||
|
if key in self._cache:
|
||||||
|
entry = self._cache[key]
|
||||||
|
if pendulum.now('UTC') < entry.expires_at:
|
||||||
|
return entry.value
|
||||||
|
else:
|
||||||
|
# Expired, remove it
|
||||||
|
del self._cache[key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def set(self, key: str, value: Any, ttl: int = 3600) -> None:
|
||||||
|
"""Set cache value with TTL"""
|
||||||
|
expires_at = pendulum.now('UTC').add(seconds=ttl)
|
||||||
|
async with self._lock:
|
||||||
|
self._cache[key] = CacheEntry(value=value, expires_at=expires_at)
|
||||||
|
|
||||||
|
async def delete(self, key: str) -> None:
|
||||||
|
"""Delete cached value"""
|
||||||
|
async with self._lock:
|
||||||
|
self._cache.pop(key, None)
|
||||||
|
|
||||||
|
async def clear(self) -> None:
|
||||||
|
"""Clear entire cache"""
|
||||||
|
async with self._lock:
|
||||||
|
self._cache.clear()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cached API Client Pattern
|
||||||
|
```python
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from .api_client import BaseApiClient
|
||||||
|
from .cache import RedisCache
|
||||||
|
|
||||||
|
class CachedApiClient:
|
||||||
|
"""Wrapper that adds caching to API client
|
||||||
|
|
||||||
|
Caches player/team data to reduce API calls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, api_client: BaseApiClient, cache: RedisCache):
|
||||||
|
self.api_client = api_client
|
||||||
|
self.cache = cache
|
||||||
|
|
||||||
|
async def get_player(self, player_id: int, use_cache: bool = True) -> Dict[str, Any]:
|
||||||
|
"""Get player data with optional caching
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player_id: Player ID
|
||||||
|
use_cache: Whether to use cached data (default True)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Player data from cache or API
|
||||||
|
"""
|
||||||
|
cache_key = f"player:{player_id}"
|
||||||
|
|
||||||
|
# Try cache first
|
||||||
|
if use_cache:
|
||||||
|
cached = await self.cache.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
# Cache miss, fetch from API
|
||||||
|
data = await self.api_client.get_player(player_id)
|
||||||
|
|
||||||
|
# Store in cache (1 hour TTL for player data)
|
||||||
|
await self.cache.set(cache_key, data, ttl=3600)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def invalidate_player(self, player_id: int) -> None:
|
||||||
|
"""Invalidate cached player data
|
||||||
|
|
||||||
|
Call this when player data changes (roster updates).
|
||||||
|
"""
|
||||||
|
cache_key = f"player:{player_id}"
|
||||||
|
await self.cache.delete(cache_key)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Integration
|
||||||
|
|
||||||
|
API clients should read from league configs:
|
||||||
|
|
||||||
|
**Location**: `app/config/league_configs.py` (already exists)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.config import get_league_config
|
||||||
|
|
||||||
|
# Get league-specific API base URL
|
||||||
|
config = get_league_config("sba")
|
||||||
|
api_url = config.get_api_base_url() # "https://api.sba.manticorum.com"
|
||||||
|
|
||||||
|
# Or for PD
|
||||||
|
config = get_league_config("pd")
|
||||||
|
api_url = config.get_api_base_url() # "https://api.pd.manticorum.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Environment Variables** (`.env`):
|
||||||
|
```bash
|
||||||
|
# SBA League API
|
||||||
|
SBA_API_URL=https://api.sba.manticorum.com
|
||||||
|
SBA_API_KEY=your-api-key-here
|
||||||
|
|
||||||
|
# PD League API
|
||||||
|
PD_API_URL=https://api.pd.manticorum.com
|
||||||
|
PD_API_KEY=your-api-key-here
|
||||||
|
|
||||||
|
# Cache (optional)
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
CACHE_ENABLED=true
|
||||||
|
CACHE_DEFAULT_TTL=3600
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Fetching Team Roster
|
||||||
|
```python
|
||||||
|
from app.data import get_api_client
|
||||||
|
from app.models import SbaPlayer, PdPlayer
|
||||||
|
|
||||||
|
async def load_team_roster(league_id: str, team_id: int):
|
||||||
|
"""Load team roster from API"""
|
||||||
|
|
||||||
|
# Get league-specific client
|
||||||
|
api_client = get_api_client(league_id)
|
||||||
|
|
||||||
|
async with api_client:
|
||||||
|
# Fetch roster data
|
||||||
|
roster_data = await api_client.get_team_roster(team_id)
|
||||||
|
|
||||||
|
# Parse into player models
|
||||||
|
players = []
|
||||||
|
for player_data in roster_data['players']:
|
||||||
|
if league_id == "sba":
|
||||||
|
player = SbaPlayer.from_api_response(player_data)
|
||||||
|
else: # PD
|
||||||
|
# Fetch detailed scouting data
|
||||||
|
batting = await api_client.get_batting_card(player_data['id'])
|
||||||
|
pitching = await api_client.get_pitching_card(player_data['id'])
|
||||||
|
player = PdPlayer.from_api_response(player_data, batting, pitching)
|
||||||
|
players.append(player)
|
||||||
|
|
||||||
|
return players
|
||||||
|
```
|
||||||
|
|
||||||
|
### Submitting Game Results
|
||||||
|
```python
|
||||||
|
from app.data import get_api_client
|
||||||
|
|
||||||
|
async def submit_completed_game(game_id: str, league_id: str):
|
||||||
|
"""Submit completed game to league system"""
|
||||||
|
|
||||||
|
# Fetch game data from database
|
||||||
|
game_data = await db_ops.export_game_data(game_id)
|
||||||
|
|
||||||
|
# Get league API client
|
||||||
|
api_client = get_api_client(league_id)
|
||||||
|
|
||||||
|
async with api_client:
|
||||||
|
# Submit to league system
|
||||||
|
result = await api_client.submit_game_result(game_data)
|
||||||
|
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Cache
|
||||||
|
```python
|
||||||
|
from app.data import get_cached_client
|
||||||
|
|
||||||
|
async def get_player_with_cache(league_id: str, player_id: int):
|
||||||
|
"""Get player data with caching"""
|
||||||
|
|
||||||
|
# Get cached API client
|
||||||
|
client = get_cached_client(league_id)
|
||||||
|
|
||||||
|
async with client:
|
||||||
|
# First call fetches from API and caches
|
||||||
|
player_data = await client.get_player(player_id)
|
||||||
|
|
||||||
|
# Second call returns from cache (fast)
|
||||||
|
player_data = await client.get_player(player_id)
|
||||||
|
|
||||||
|
return player_data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
All API client methods raise specific exceptions:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.data import get_api_client, ApiClientError, ApiNotFoundError, ApiConnectionError
|
||||||
|
|
||||||
|
async def safe_api_call():
|
||||||
|
"""Example of proper error handling"""
|
||||||
|
|
||||||
|
api_client = get_api_client("sba")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with api_client:
|
||||||
|
player = await api_client.get_player(123)
|
||||||
|
|
||||||
|
except ApiNotFoundError:
|
||||||
|
# Player doesn't exist
|
||||||
|
logger.warning(f"Player 123 not found")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except ApiConnectionError:
|
||||||
|
# Network error, retry later
|
||||||
|
logger.error("API connection failed, will retry")
|
||||||
|
raise
|
||||||
|
|
||||||
|
except ApiClientError as e:
|
||||||
|
# Other API error
|
||||||
|
logger.error(f"API error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
return player
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Patterns
|
||||||
|
|
||||||
|
### Mocking API Clients
|
||||||
|
```python
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_sba_client():
|
||||||
|
"""Mock SBA API client for testing"""
|
||||||
|
client = Mock()
|
||||||
|
|
||||||
|
# Mock get_player method
|
||||||
|
client.get_player = AsyncMock(return_value={
|
||||||
|
"id": 123,
|
||||||
|
"name": "Mike Trout",
|
||||||
|
"image": "https://example.com/trout.jpg",
|
||||||
|
"pos_1": "CF",
|
||||||
|
"wara": 8.5
|
||||||
|
})
|
||||||
|
|
||||||
|
# Mock context manager
|
||||||
|
client.__aenter__ = AsyncMock(return_value=client)
|
||||||
|
client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
return client
|
||||||
|
|
||||||
|
async def test_game_with_mock_api(mock_sba_client):
|
||||||
|
"""Test game engine with mocked API"""
|
||||||
|
|
||||||
|
# Use mock instead of real API
|
||||||
|
async with mock_sba_client:
|
||||||
|
player_data = await mock_sba_client.get_player(123)
|
||||||
|
|
||||||
|
assert player_data['name'] == "Mike Trout"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Cache Behavior
|
||||||
|
```python
|
||||||
|
async def test_cache_hit():
|
||||||
|
"""Test cache returns cached value"""
|
||||||
|
cache = MemoryCache()
|
||||||
|
|
||||||
|
# Set value
|
||||||
|
await cache.set("test:key", {"data": "value"}, ttl=60)
|
||||||
|
|
||||||
|
# Get value (should be cached)
|
||||||
|
result = await cache.get("test:key")
|
||||||
|
assert result == {"data": "value"}
|
||||||
|
|
||||||
|
async def test_cache_expiration():
|
||||||
|
"""Test cache expires after TTL"""
|
||||||
|
cache = MemoryCache()
|
||||||
|
|
||||||
|
# Set value with 1 second TTL
|
||||||
|
await cache.set("test:key", {"data": "value"}, ttl=1)
|
||||||
|
|
||||||
|
# Wait for expiration
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
# Should be expired
|
||||||
|
result = await cache.get("test:key")
|
||||||
|
assert result is None
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Adding a New API Endpoint
|
||||||
|
|
||||||
|
1. **Add method to appropriate client**:
|
||||||
|
```python
|
||||||
|
# In sba_client.py or pd_client.py
|
||||||
|
async def get_new_endpoint(self, param: int) -> Dict[str, Any]:
|
||||||
|
"""Fetch new endpoint data"""
|
||||||
|
return await self._get(f"/new/endpoint/{param}")
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Update tests**:
|
||||||
|
```python
|
||||||
|
async def test_new_endpoint(sba_client):
|
||||||
|
"""Test new endpoint"""
|
||||||
|
result = await sba_client.get_new_endpoint(123)
|
||||||
|
assert result is not None
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Document in this file**: Add example to usage section
|
||||||
|
|
||||||
|
### Modifying Cache Behavior
|
||||||
|
|
||||||
|
1. **Adjust TTL for specific data type**:
|
||||||
|
```python
|
||||||
|
# Player data: 1 hour (changes rarely)
|
||||||
|
await cache.set(f"player:{player_id}", data, ttl=3600)
|
||||||
|
|
||||||
|
# Team roster: 10 minutes (may change during roster management)
|
||||||
|
await cache.set(f"roster:{team_id}", data, ttl=600)
|
||||||
|
|
||||||
|
# Cardset data: 24 hours (static)
|
||||||
|
await cache.set(f"cardset:{cardset_id}", data, ttl=86400)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add cache invalidation triggers**:
|
||||||
|
```python
|
||||||
|
async def on_roster_update(team_id: int):
|
||||||
|
"""Invalidate roster cache when roster changes"""
|
||||||
|
await cache.delete(f"roster:{team_id}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Switching Cache Backends
|
||||||
|
|
||||||
|
Development (in-memory):
|
||||||
|
```python
|
||||||
|
from app.data.cache import MemoryCache
|
||||||
|
|
||||||
|
cache = MemoryCache()
|
||||||
|
```
|
||||||
|
|
||||||
|
Production (Redis):
|
||||||
|
```python
|
||||||
|
from app.data.cache import RedisCache
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
cache = RedisCache(settings.redis_url)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### API Connection Issues
|
||||||
|
|
||||||
|
**Symptom**: `ApiConnectionError: Failed to connect to API`
|
||||||
|
|
||||||
|
**Checks**:
|
||||||
|
1. Verify API URL in config: `config.get_api_base_url()`
|
||||||
|
2. Check network connectivity: `curl https://api.sba.manticorum.com/health`
|
||||||
|
3. Verify API key is set: `echo $SBA_API_KEY`
|
||||||
|
4. Check firewall/network access
|
||||||
|
|
||||||
|
### Authentication Failures
|
||||||
|
|
||||||
|
**Symptom**: `ApiAuthenticationError: Authentication failed`
|
||||||
|
|
||||||
|
**Checks**:
|
||||||
|
1. Verify API key is correct
|
||||||
|
2. Check API key format (Bearer token vs other)
|
||||||
|
3. Confirm API key has required permissions
|
||||||
|
4. Check API key expiration
|
||||||
|
|
||||||
|
### Cache Not Working
|
||||||
|
|
||||||
|
**Symptom**: Every request hits API instead of cache
|
||||||
|
|
||||||
|
**Checks**:
|
||||||
|
1. Verify cache is initialized: `cache is not None`
|
||||||
|
2. Check Redis is running: `redis-cli ping` (should return "PONG")
|
||||||
|
3. Verify cache keys are consistent
|
||||||
|
4. Check TTL isn't too short
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
**Symptom**: API returns 429 (Too Many Requests)
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Implement exponential backoff retry logic
|
||||||
|
2. Reduce API call frequency
|
||||||
|
3. Increase cache TTL to reduce calls
|
||||||
|
4. Add rate limiting protection in client
|
||||||
|
|
||||||
|
## Performance Targets
|
||||||
|
|
||||||
|
- **API Response Time**: < 500ms for typical requests
|
||||||
|
- **Cache Hit Ratio**: > 80% for player/team data
|
||||||
|
- **Cache Lookup Time**: < 10ms (Redis), < 1ms (memory)
|
||||||
|
- **Concurrent Requests**: Support 10+ simultaneous API calls
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- **API Keys**: Store in environment variables, never commit to git
|
||||||
|
- **HTTPS Only**: All API communication over encrypted connections
|
||||||
|
- **Input Validation**: Validate all API responses with Pydantic models
|
||||||
|
- **Error Messages**: Don't expose API keys or internal details in logs
|
||||||
|
- **Rate Limiting**: Respect API rate limits, implement backoff
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
**Required**:
|
||||||
|
```txt
|
||||||
|
httpx>=0.25.0 # Async HTTP client
|
||||||
|
pydantic>=2.10.0 # Response validation (already installed)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Optional**:
|
||||||
|
```txt
|
||||||
|
redis>=5.0.0 # Redis cache backend
|
||||||
|
aiohttp>=3.9.0 # Alternative HTTP client
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Priority
|
||||||
|
|
||||||
|
**Phase 1** (Week 7-8):
|
||||||
|
1. ✅ Create base API client with error handling
|
||||||
|
2. ✅ Implement SBA client for simple player data
|
||||||
|
3. ✅ Add basic integration tests
|
||||||
|
4. ⏳ Connect to game engine for roster loading
|
||||||
|
|
||||||
|
**Phase 2** (Week 9-10):
|
||||||
|
1. ⏳ Implement PD client with scouting data
|
||||||
|
2. ⏳ Add in-memory cache for development
|
||||||
|
3. ⏳ Add result submission endpoints
|
||||||
|
|
||||||
|
**Phase 3** (Post-MVP):
|
||||||
|
1. ⏳ Add Redis cache for production
|
||||||
|
2. ⏳ Implement advanced retry logic
|
||||||
|
3. ⏳ Add request/response logging
|
||||||
|
4. ⏳ Performance optimization
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- **PRD API Section**: `../../prd-web-scorecard-1.1.md` (lines 87-90, 1119-1126)
|
||||||
|
- **Player Models**: `../models/player_models.py` - SbaPlayer, PdPlayer classes
|
||||||
|
- **League Configs**: `../config/league_configs.py` - API URLs and settings
|
||||||
|
- **Backend Architecture**: `../CLAUDE.md` - Overall backend structure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status**: 🚧 **AWAITING IMPLEMENTATION**
|
||||||
|
**Current Phase**: Phase 2 - Week 7 (Runner Advancement & Play Resolution)
|
||||||
|
**Next Steps**: Implement base API client and SBA client for roster loading
|
||||||
|
|
||||||
|
**Note**: This directory will be populated during Phase 2 integration work when game engine needs to fetch real player data from league APIs.
|
||||||
945
backend/app/database/CLAUDE.md
Normal file
945
backend/app/database/CLAUDE.md
Normal file
@ -0,0 +1,945 @@
|
|||||||
|
# Database Layer - Async Persistence for Game Data
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
The database layer provides async PostgreSQL persistence for all game data using SQLAlchemy 2.0 with asyncpg. It handles:
|
||||||
|
|
||||||
|
- **Session Management**: Connection pooling, lifecycle management, automatic commit/rollback
|
||||||
|
- **Database Operations**: CRUD operations for games, plays, lineups, rosters, dice rolls
|
||||||
|
- **State Persistence**: Async writes that don't block game logic
|
||||||
|
- **State Recovery**: Complete game state reconstruction from database
|
||||||
|
- **Transaction Safety**: Proper error handling and rollback on failures
|
||||||
|
|
||||||
|
**Architecture Pattern**: Write-through cache - update in-memory state immediately, persist to database asynchronously.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
app/database/
|
||||||
|
├── __init__.py # Empty package marker
|
||||||
|
├── session.py # Session factory, engine, Base declarative
|
||||||
|
└── operations.py # DatabaseOperations class with all CRUD methods
|
||||||
|
```
|
||||||
|
|
||||||
|
### Module Breakdown
|
||||||
|
|
||||||
|
#### `session.py` (55 lines)
|
||||||
|
- **Purpose**: Database connection and session management
|
||||||
|
- **Exports**: `engine`, `AsyncSessionLocal`, `Base`, `init_db()`, `get_session()`
|
||||||
|
- **Key Pattern**: Async context managers with automatic commit/rollback
|
||||||
|
|
||||||
|
#### `operations.py` (882 lines)
|
||||||
|
- **Purpose**: All database operations for game persistence
|
||||||
|
- **Exports**: `DatabaseOperations` class with 20+ async methods
|
||||||
|
- **Key Pattern**: Each operation uses its own session context manager
|
||||||
|
|
||||||
|
## Key Components
|
||||||
|
|
||||||
|
### 1. AsyncSessionLocal (Session Factory)
|
||||||
|
|
||||||
|
**Location**: `session.py:21-27`
|
||||||
|
|
||||||
|
Factory for creating async database sessions. Configured with optimal settings for game engine.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
|
||||||
|
|
||||||
|
AsyncSessionLocal = async_sessionmaker(
|
||||||
|
engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False, # Don't expire objects after commit (allows access after commit)
|
||||||
|
autocommit=False, # Explicit commit control
|
||||||
|
autoflush=False, # Manual flush control
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Configuration Notes**:
|
||||||
|
- `expire_on_commit=False`: Critical for accessing object attributes after commit without refetching
|
||||||
|
- `autocommit=False`: Requires explicit `await session.commit()`
|
||||||
|
- `autoflush=False`: Manual control over when SQL is flushed to database
|
||||||
|
|
||||||
|
### 2. Engine Configuration
|
||||||
|
|
||||||
|
**Location**: `session.py:13-18`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
settings.database_url, # postgresql+asyncpg://...
|
||||||
|
echo=settings.debug, # Log SQL in debug mode
|
||||||
|
pool_size=settings.db_pool_size, # Default: 10 connections
|
||||||
|
max_overflow=settings.db_max_overflow, # Default: 20 overflow connections
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Connection Pool**:
|
||||||
|
- Base pool: 10 connections (configured in `.env`)
|
||||||
|
- Max overflow: 20 additional connections under load
|
||||||
|
- Total max: 30 concurrent connections
|
||||||
|
|
||||||
|
**Environment Variables**:
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=postgresql+asyncpg://paperdynasty:PASSWORD@10.10.0.42:5432/paperdynasty_dev
|
||||||
|
DB_POOL_SIZE=10
|
||||||
|
DB_MAX_OVERFLOW=20
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Base Declarative Class
|
||||||
|
|
||||||
|
**Location**: `session.py:30`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.orm import declarative_base
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
```
|
||||||
|
|
||||||
|
All ORM models inherit from this Base class. Used in `app/models/db_models.py`.
|
||||||
|
|
||||||
|
### 4. DatabaseOperations Class
|
||||||
|
|
||||||
|
**Location**: `operations.py:26-882`
|
||||||
|
|
||||||
|
Singleton class providing all database operations. Instantiate once and reuse.
|
||||||
|
|
||||||
|
**Categories**:
|
||||||
|
- **Game Operations**: `create_game()`, `get_game()`, `update_game_state()`
|
||||||
|
- **Lineup Operations**: `add_pd_lineup_card()`, `add_sba_lineup_player()`, `get_active_lineup()`
|
||||||
|
- **Play Operations**: `save_play()`, `get_plays()`
|
||||||
|
- **Roster Operations**: `add_pd_roster_card()`, `add_sba_roster_player()`, `get_pd_roster()`, `get_sba_roster()`, `remove_roster_entry()`
|
||||||
|
- **Session Operations**: `create_game_session()`, `update_session_snapshot()`
|
||||||
|
- **Dice Roll Operations**: `save_rolls_batch()`, `get_rolls_for_game()`
|
||||||
|
- **Recovery Operations**: `load_game_state()`
|
||||||
|
- **Rollback Operations**: `delete_plays_after()`, `delete_substitutions_after()`, `delete_rolls_after()`
|
||||||
|
|
||||||
|
**Usage Pattern**:
|
||||||
|
```python
|
||||||
|
from app.database.operations import DatabaseOperations
|
||||||
|
|
||||||
|
db_ops = DatabaseOperations()
|
||||||
|
|
||||||
|
# Use methods
|
||||||
|
game = await db_ops.create_game(...)
|
||||||
|
plays = await db_ops.get_plays(game_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Patterns & Conventions
|
||||||
|
|
||||||
|
### 1. Async Session Context Manager Pattern
|
||||||
|
|
||||||
|
**Every database operation follows this pattern:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def some_operation(self, game_id: UUID) -> SomeModel:
|
||||||
|
"""
|
||||||
|
Operation description.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
game_id: Description
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Description
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SQLAlchemyError: If database operation fails
|
||||||
|
"""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
# 1. Query or create model
|
||||||
|
result = await session.execute(select(Model).where(...))
|
||||||
|
model = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# 2. Modify or create
|
||||||
|
if not model:
|
||||||
|
model = Model(...)
|
||||||
|
session.add(model)
|
||||||
|
|
||||||
|
# 3. Commit transaction
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# 4. Refresh if needed (loads relationships)
|
||||||
|
await session.refresh(model)
|
||||||
|
|
||||||
|
# 5. Log success
|
||||||
|
logger.info(f"Operation completed for {game_id}")
|
||||||
|
|
||||||
|
return model
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Automatic rollback on exception
|
||||||
|
await session.rollback()
|
||||||
|
logger.error(f"Operation failed: {e}")
|
||||||
|
raise
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Points**:
|
||||||
|
- Context manager handles session cleanup automatically
|
||||||
|
- Explicit `commit()` required (autocommit=False)
|
||||||
|
- `rollback()` on any exception
|
||||||
|
- Always log errors with context
|
||||||
|
- Session closes automatically when exiting context
|
||||||
|
|
||||||
|
### 2. Query Patterns
|
||||||
|
|
||||||
|
#### Simple SELECT
|
||||||
|
```python
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Game).where(Game.id == game_id)
|
||||||
|
)
|
||||||
|
game = result.scalar_one_or_none()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### SELECT with Ordering
|
||||||
|
```python
|
||||||
|
result = await session.execute(
|
||||||
|
select(Play)
|
||||||
|
.where(Play.game_id == game_id)
|
||||||
|
.order_by(Play.play_number)
|
||||||
|
)
|
||||||
|
plays = list(result.scalars().all())
|
||||||
|
```
|
||||||
|
|
||||||
|
#### SELECT with Multiple Filters
|
||||||
|
```python
|
||||||
|
result = await session.execute(
|
||||||
|
select(Lineup)
|
||||||
|
.where(
|
||||||
|
Lineup.game_id == game_id,
|
||||||
|
Lineup.team_id == team_id,
|
||||||
|
Lineup.is_active == True
|
||||||
|
)
|
||||||
|
.order_by(Lineup.batting_order)
|
||||||
|
)
|
||||||
|
lineups = list(result.scalars().all())
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Direct UPDATE (No SELECT)
|
||||||
|
```python
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
update(Game)
|
||||||
|
.where(Game.id == game_id)
|
||||||
|
.values(
|
||||||
|
current_inning=inning,
|
||||||
|
current_half=half,
|
||||||
|
home_score=home_score,
|
||||||
|
away_score=away_score
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Check if row was found
|
||||||
|
if result.rowcount == 0:
|
||||||
|
raise ValueError(f"Game {game_id} not found")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### DELETE
|
||||||
|
```python
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
stmt = delete(Play).where(
|
||||||
|
Play.game_id == game_id,
|
||||||
|
Play.play_number > after_play_number
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
deleted_count = result.rowcount
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Polymorphic Operations (League-Specific)
|
||||||
|
|
||||||
|
**Pattern**: Separate methods for PD vs SBA leagues using same underlying table.
|
||||||
|
|
||||||
|
#### Roster Links (PD vs SBA)
|
||||||
|
```python
|
||||||
|
# PD league - uses card_id
|
||||||
|
async def add_pd_roster_card(self, game_id: UUID, card_id: int, team_id: int):
|
||||||
|
roster_link = RosterLink(
|
||||||
|
game_id=game_id,
|
||||||
|
card_id=card_id, # PD: card_id populated
|
||||||
|
player_id=None, # SBA: player_id is None
|
||||||
|
team_id=team_id
|
||||||
|
)
|
||||||
|
# ... persist and return PdRosterLinkData
|
||||||
|
|
||||||
|
# SBA league - uses player_id
|
||||||
|
async def add_sba_roster_player(self, game_id: UUID, player_id: int, team_id: int):
|
||||||
|
roster_link = RosterLink(
|
||||||
|
game_id=game_id,
|
||||||
|
card_id=None, # PD: card_id is None
|
||||||
|
player_id=player_id, # SBA: player_id populated
|
||||||
|
team_id=team_id
|
||||||
|
)
|
||||||
|
# ... persist and return SbaRosterLinkData
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
- Type safety at application layer (PdRosterLinkData vs SbaRosterLinkData)
|
||||||
|
- Database enforces XOR constraint (exactly one ID populated)
|
||||||
|
- Single table avoids complex joins
|
||||||
|
|
||||||
|
#### Lineup Operations (PD vs SBA)
|
||||||
|
Same pattern - `add_pd_lineup_card()` vs `add_sba_lineup_player()`.
|
||||||
|
|
||||||
|
### 4. Batch Operations
|
||||||
|
|
||||||
|
**Pattern**: Add multiple records in single transaction for performance.
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def save_rolls_batch(self, rolls: List) -> None:
|
||||||
|
"""Save multiple dice rolls in a single transaction."""
|
||||||
|
if not rolls:
|
||||||
|
return
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
roll_records = [
|
||||||
|
Roll(
|
||||||
|
roll_id=roll.roll_id,
|
||||||
|
game_id=roll.game_id,
|
||||||
|
roll_type=roll.roll_type.value,
|
||||||
|
# ... other fields
|
||||||
|
)
|
||||||
|
for roll in rolls
|
||||||
|
]
|
||||||
|
|
||||||
|
session.add_all(roll_records) # Batch insert
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage**: Dice rolls are batched at end of inning for efficiency.
|
||||||
|
|
||||||
|
### 5. State Recovery Pattern
|
||||||
|
|
||||||
|
**Location**: `operations.py:338-424`
|
||||||
|
|
||||||
|
Load complete game state in single transaction for efficient recovery.
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def load_game_state(self, game_id: UUID) -> Optional[Dict]:
|
||||||
|
"""Load complete game state for recovery."""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
# 1. Load game
|
||||||
|
game_result = await session.execute(
|
||||||
|
select(Game).where(Game.id == game_id)
|
||||||
|
)
|
||||||
|
game = game_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not game:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 2. Load lineups
|
||||||
|
lineup_result = await session.execute(
|
||||||
|
select(Lineup).where(Lineup.game_id == game_id, Lineup.is_active == True)
|
||||||
|
)
|
||||||
|
lineups = list(lineup_result.scalars().all())
|
||||||
|
|
||||||
|
# 3. Load plays
|
||||||
|
play_result = await session.execute(
|
||||||
|
select(Play).where(Play.game_id == game_id).order_by(Play.play_number)
|
||||||
|
)
|
||||||
|
plays = list(play_result.scalars().all())
|
||||||
|
|
||||||
|
# 4. Return normalized dictionary
|
||||||
|
return {
|
||||||
|
'game': {...}, # Game data as dict
|
||||||
|
'lineups': [...], # Lineup data as list of dicts
|
||||||
|
'plays': [...] # Play data as list of dicts
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Used By**: `StateManager.recover_game()` to rebuild in-memory state.
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
### 1. With ORM Models (`app/models/db_models.py`)
|
||||||
|
|
||||||
|
Database operations directly use SQLAlchemy ORM models:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.models.db_models import Game, Play, Lineup, RosterLink, Roll, GameSession
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical**: Models are defined in `db_models.py`, operations use them in `operations.py`.
|
||||||
|
|
||||||
|
### 2. With StateManager (`app/core/state_manager.py`)
|
||||||
|
|
||||||
|
StateManager uses DatabaseOperations for all persistence:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.database.operations import DatabaseOperations
|
||||||
|
|
||||||
|
class StateManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.db_ops = DatabaseOperations()
|
||||||
|
|
||||||
|
async def create_game(self, ...):
|
||||||
|
# 1. Persist to database first
|
||||||
|
db_game = await self.db_ops.create_game(...)
|
||||||
|
|
||||||
|
# 2. Create in-memory state
|
||||||
|
state = GameState(...)
|
||||||
|
|
||||||
|
# 3. Cache in memory
|
||||||
|
self._states[game_id] = state
|
||||||
|
|
||||||
|
return state
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern**: Database is source of truth, in-memory is fast cache.
|
||||||
|
|
||||||
|
### 3. With GameEngine (`app/core/game_engine.py`)
|
||||||
|
|
||||||
|
GameEngine calls StateManager, which uses DatabaseOperations:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def resolve_play(self, game_id: UUID) -> dict:
|
||||||
|
# 1. Get in-memory state (fast)
|
||||||
|
state = self.state_manager.get_state(game_id)
|
||||||
|
|
||||||
|
# 2. Resolve play logic
|
||||||
|
result = self._resolve_outcome(state)
|
||||||
|
|
||||||
|
# 3. Persist play to database (async, non-blocking)
|
||||||
|
play_id = await self.state_manager.db_ops.save_play(play_data)
|
||||||
|
|
||||||
|
# 4. Update game state in database
|
||||||
|
await self.state_manager.db_ops.update_game_state(
|
||||||
|
game_id, state.inning, state.half, state.home_score, state.away_score
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. With Pydantic Models (`app/models/roster_models.py`)
|
||||||
|
|
||||||
|
Polymorphic operations return Pydantic models for type safety:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.models.roster_models import PdRosterLinkData, SbaRosterLinkData
|
||||||
|
|
||||||
|
# Returns typed Pydantic model
|
||||||
|
roster_data: PdRosterLinkData = await db_ops.add_pd_roster_card(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Adding a New Database Operation
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Add method to `DatabaseOperations` class in `operations.py`
|
||||||
|
2. Follow async session context manager pattern
|
||||||
|
3. Add comprehensive docstring
|
||||||
|
4. Add logging (info on success, error on failure)
|
||||||
|
5. Return typed result (model or primitive)
|
||||||
|
6. Handle errors with rollback
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```python
|
||||||
|
async def get_pitcher_stats(self, game_id: UUID, lineup_id: int) -> dict:
|
||||||
|
"""
|
||||||
|
Get pitching statistics for a pitcher in a game.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
game_id: Game identifier
|
||||||
|
lineup_id: Pitcher's lineup ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with pitching statistics
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If pitcher not found
|
||||||
|
"""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
result = await session.execute(
|
||||||
|
select(
|
||||||
|
func.sum(Play.outs_recorded).label('outs'),
|
||||||
|
func.sum(Play.hit).label('hits_allowed'),
|
||||||
|
func.sum(Play.bb).label('walks'),
|
||||||
|
func.sum(Play.so).label('strikeouts')
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Play.game_id == game_id,
|
||||||
|
Play.pitcher_id == lineup_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = result.one()
|
||||||
|
logger.debug(f"Retrieved pitcher stats for lineup {lineup_id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'outs': stats.outs or 0,
|
||||||
|
'hits_allowed': stats.hits_allowed or 0,
|
||||||
|
'walks': stats.walks or 0,
|
||||||
|
'strikeouts': stats.strikeouts or 0
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get pitcher stats: {e}")
|
||||||
|
raise ValueError(f"Could not retrieve pitcher stats: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Query Patterns
|
||||||
|
|
||||||
|
#### Aggregate Statistics
|
||||||
|
```python
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(
|
||||||
|
func.sum(Play.ab).label('at_bats'),
|
||||||
|
func.sum(Play.hit).label('hits'),
|
||||||
|
func.sum(Play.homerun).label('homeruns')
|
||||||
|
)
|
||||||
|
.where(Play.batter_id == batter_lineup_id)
|
||||||
|
)
|
||||||
|
stats = result.one()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Conditional Queries
|
||||||
|
```python
|
||||||
|
query = select(RosterLink).where(
|
||||||
|
RosterLink.game_id == game_id,
|
||||||
|
RosterLink.card_id.is_not(None) # PD only
|
||||||
|
)
|
||||||
|
|
||||||
|
if team_id is not None:
|
||||||
|
query = query.where(RosterLink.team_id == team_id)
|
||||||
|
|
||||||
|
result = await session.execute(query)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Filtering with IN Clause
|
||||||
|
```python
|
||||||
|
lineup_ids = [1, 2, 3, 4, 5]
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(Lineup).where(
|
||||||
|
Lineup.game_id == game_id,
|
||||||
|
Lineup.id.in_(lineup_ids)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lineups = list(result.scalars().all())
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transaction Management
|
||||||
|
|
||||||
|
#### Single Operation Transaction
|
||||||
|
```python
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
# Automatic transaction
|
||||||
|
session.add(model)
|
||||||
|
await session.commit()
|
||||||
|
# Auto-rollback on exception
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Multi-Step Transaction
|
||||||
|
```python
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
# Step 1
|
||||||
|
game = Game(...)
|
||||||
|
session.add(game)
|
||||||
|
|
||||||
|
# Step 2
|
||||||
|
for lineup_data in lineup_list:
|
||||||
|
lineup = Lineup(game_id=game.id, ...)
|
||||||
|
session.add(lineup)
|
||||||
|
|
||||||
|
# Step 3 - all or nothing
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await session.rollback() # Rolls back all steps
|
||||||
|
raise
|
||||||
|
```
|
||||||
|
|
||||||
|
### Handling Optional Results
|
||||||
|
|
||||||
|
```python
|
||||||
|
# May return None
|
||||||
|
game = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not game:
|
||||||
|
logger.warning(f"Game {game_id} not found")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Do something with game
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Connection Issues
|
||||||
|
|
||||||
|
**Symptom**: `asyncpg.exceptions.InvalidCatalogNameError: database "paperdynasty_dev" does not exist`
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Verify database exists: `psql -h 10.10.0.42 -U paperdynasty -l`
|
||||||
|
2. Create if needed: `createdb -h 10.10.0.42 -U paperdynasty paperdynasty_dev`
|
||||||
|
3. Check `DATABASE_URL` in `.env`
|
||||||
|
|
||||||
|
**Symptom**: `asyncpg.exceptions.InvalidPasswordError`
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Verify password in `.env` matches database
|
||||||
|
2. Test connection: `psql postgresql://paperdynasty:PASSWORD@10.10.0.42:5432/paperdynasty_dev`
|
||||||
|
|
||||||
|
### Pool Exhaustion
|
||||||
|
|
||||||
|
**Symptom**: `asyncio.TimeoutError` or hanging on database operations
|
||||||
|
|
||||||
|
**Cause**: All pool connections in use, new operations waiting for available connection.
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Increase pool size: `DB_POOL_SIZE=20` in `.env`
|
||||||
|
2. Increase overflow: `DB_MAX_OVERFLOW=30` in `.env`
|
||||||
|
3. Check for unclosed sessions (should be impossible with context managers)
|
||||||
|
4. Review long-running queries
|
||||||
|
|
||||||
|
### Async Session Errors
|
||||||
|
|
||||||
|
**Symptom**: `AttributeError: 'NoneType' object has no attribute 'id'` after commit
|
||||||
|
|
||||||
|
**Cause**: `expire_on_commit=True` (default) expires objects after commit.
|
||||||
|
|
||||||
|
**Solution**: Already configured with `expire_on_commit=False` in `AsyncSessionLocal`.
|
||||||
|
|
||||||
|
**Symptom**: `sqlalchemy.exc.InvalidRequestError: Object is already attached to session`
|
||||||
|
|
||||||
|
**Cause**: Trying to add same object to multiple sessions.
|
||||||
|
|
||||||
|
**Solution**: Use separate session for each operation. Don't share objects across sessions.
|
||||||
|
|
||||||
|
### SQLAlchemy Column Type Errors
|
||||||
|
|
||||||
|
**Symptom**: Type checker warns about `Column[int]` not assignable to `int`
|
||||||
|
|
||||||
|
**Explanation**: SQLAlchemy model attributes are typed as `Column[T]` for type checkers but are `T` at runtime.
|
||||||
|
|
||||||
|
**Solution**: Use `# type: ignore[assignment]` on known false positives:
|
||||||
|
```python
|
||||||
|
state.current_batter_id = lineup.id # type: ignore[assignment]
|
||||||
|
```
|
||||||
|
|
||||||
|
See backend CLAUDE.md section "Type Checking & Common False Positives" for full guide.
|
||||||
|
|
||||||
|
### Deadlocks
|
||||||
|
|
||||||
|
**Symptom**: `asyncpg.exceptions.DeadlockDetectedError`
|
||||||
|
|
||||||
|
**Cause**: Two transactions waiting on each other's locks.
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Keep transactions short
|
||||||
|
2. Access tables in consistent order across operations
|
||||||
|
3. Use `FOR UPDATE` sparingly
|
||||||
|
4. Retry transaction on deadlock
|
||||||
|
|
||||||
|
### Migration Issues
|
||||||
|
|
||||||
|
**Symptom**: `AttributeError: 'Game' object has no attribute 'some_field'`
|
||||||
|
|
||||||
|
**Cause**: Database schema doesn't match ORM models.
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Create migration: `alembic revision --autogenerate -m "Add some_field"`
|
||||||
|
2. Apply migration: `alembic upgrade head`
|
||||||
|
3. Verify: `alembic current`
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Example 1: Creating a Complete Game
|
||||||
|
|
||||||
|
```python
|
||||||
|
from uuid import uuid4
|
||||||
|
from app.database.operations import DatabaseOperations
|
||||||
|
|
||||||
|
async def create_complete_game():
|
||||||
|
db_ops = DatabaseOperations()
|
||||||
|
game_id = uuid4()
|
||||||
|
|
||||||
|
# 1. Create game
|
||||||
|
game = await db_ops.create_game(
|
||||||
|
game_id=game_id,
|
||||||
|
league_id="sba",
|
||||||
|
home_team_id=1,
|
||||||
|
away_team_id=2,
|
||||||
|
game_mode="friendly",
|
||||||
|
visibility="public"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Add home team lineup (SBA)
|
||||||
|
home_lineup = []
|
||||||
|
for i in range(1, 10):
|
||||||
|
lineup = await db_ops.add_sba_lineup_player(
|
||||||
|
game_id=game_id,
|
||||||
|
team_id=1,
|
||||||
|
player_id=100 + i,
|
||||||
|
position="P" if i == 1 else f"{i}B",
|
||||||
|
batting_order=i,
|
||||||
|
is_starter=True
|
||||||
|
)
|
||||||
|
home_lineup.append(lineup)
|
||||||
|
|
||||||
|
# 3. Add away team lineup
|
||||||
|
away_lineup = []
|
||||||
|
for i in range(1, 10):
|
||||||
|
lineup = await db_ops.add_sba_lineup_player(
|
||||||
|
game_id=game_id,
|
||||||
|
team_id=2,
|
||||||
|
player_id=200 + i,
|
||||||
|
position="P" if i == 1 else f"{i}B",
|
||||||
|
batting_order=i,
|
||||||
|
is_starter=True
|
||||||
|
)
|
||||||
|
away_lineup.append(lineup)
|
||||||
|
|
||||||
|
# 4. Create game session
|
||||||
|
session = await db_ops.create_game_session(game_id)
|
||||||
|
|
||||||
|
return game_id
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: Recording a Complete Play
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def record_play(game_id: UUID, play_data: dict):
|
||||||
|
db_ops = DatabaseOperations()
|
||||||
|
|
||||||
|
# Save play
|
||||||
|
play_id = await db_ops.save_play({
|
||||||
|
'game_id': game_id,
|
||||||
|
'play_number': play_data['play_number'],
|
||||||
|
'inning': play_data['inning'],
|
||||||
|
'half': play_data['half'],
|
||||||
|
'outs_before': play_data['outs_before'],
|
||||||
|
'batter_id': play_data['batter_lineup_id'],
|
||||||
|
'pitcher_id': play_data['pitcher_lineup_id'],
|
||||||
|
'dice_roll': play_data['dice_roll'],
|
||||||
|
'result_description': play_data['description'],
|
||||||
|
'pa': 1,
|
||||||
|
'ab': 1,
|
||||||
|
'hit': 1 if play_data['outcome'] in ['single', 'double', 'triple', 'homerun'] else 0,
|
||||||
|
'homerun': 1 if play_data['outcome'] == 'homerun' else 0,
|
||||||
|
'complete': True
|
||||||
|
})
|
||||||
|
|
||||||
|
# Update game state
|
||||||
|
await db_ops.update_game_state(
|
||||||
|
game_id=game_id,
|
||||||
|
inning=play_data['inning'],
|
||||||
|
half=play_data['half'],
|
||||||
|
home_score=play_data['home_score'],
|
||||||
|
away_score=play_data['away_score']
|
||||||
|
)
|
||||||
|
|
||||||
|
return play_id
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 3: Game State Recovery
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def recover_game(game_id: UUID):
|
||||||
|
db_ops = DatabaseOperations()
|
||||||
|
|
||||||
|
# Load complete state in single transaction
|
||||||
|
game_data = await db_ops.load_game_state(game_id)
|
||||||
|
|
||||||
|
if not game_data:
|
||||||
|
print(f"Game {game_id} not found")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Access loaded data
|
||||||
|
game = game_data['game']
|
||||||
|
lineups = game_data['lineups']
|
||||||
|
plays = game_data['plays']
|
||||||
|
|
||||||
|
print(f"Game: {game['league_id']}")
|
||||||
|
print(f"Score: {game['away_score']} - {game['home_score']}")
|
||||||
|
print(f"Inning: {game['current_inning']} {game['current_half']}")
|
||||||
|
print(f"Lineups: {len(lineups)} players")
|
||||||
|
print(f"Plays: {len(plays)} recorded")
|
||||||
|
|
||||||
|
return game_data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 4: Batch Saving Dice Rolls
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.models.dice_models import AbRoll, RollType
|
||||||
|
|
||||||
|
async def save_inning_rolls(game_id: UUID, rolls: List[AbRoll]):
|
||||||
|
db_ops = DatabaseOperations()
|
||||||
|
|
||||||
|
# Batch save all rolls from inning
|
||||||
|
await db_ops.save_rolls_batch(rolls)
|
||||||
|
|
||||||
|
print(f"Saved {len(rolls)} dice rolls for game {game_id}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 5: Rollback to Previous Play
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def rollback_to_play(game_id: UUID, play_number: int):
|
||||||
|
"""Rollback game to a specific play number."""
|
||||||
|
db_ops = DatabaseOperations()
|
||||||
|
|
||||||
|
# Delete all data after target play
|
||||||
|
plays_deleted = await db_ops.delete_plays_after(game_id, play_number)
|
||||||
|
subs_deleted = await db_ops.delete_substitutions_after(game_id, play_number)
|
||||||
|
rolls_deleted = await db_ops.delete_rolls_after(game_id, play_number)
|
||||||
|
|
||||||
|
print(f"Rolled back game {game_id} to play {play_number}")
|
||||||
|
print(f"Deleted: {plays_deleted} plays, {subs_deleted} subs, {rolls_deleted} rolls")
|
||||||
|
|
||||||
|
# Recover state from remaining plays
|
||||||
|
# (StateManager will rebuild from database)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Notes
|
||||||
|
|
||||||
|
### Optimizations Applied
|
||||||
|
|
||||||
|
1. **Direct UPDATE Statements** (`update_game_state`)
|
||||||
|
- Uses direct UPDATE without SELECT
|
||||||
|
- Faster than fetch-modify-commit pattern
|
||||||
|
|
||||||
|
2. **Conditional Updates** (Used by GameEngine)
|
||||||
|
- Only UPDATE when state actually changes
|
||||||
|
- ~40-60% fewer writes in low-scoring games
|
||||||
|
|
||||||
|
3. **Batch Operations** (`save_rolls_batch`)
|
||||||
|
- Single transaction for multiple inserts
|
||||||
|
- Reduces network round-trips
|
||||||
|
|
||||||
|
4. **Minimal Refreshes** (`save_play`)
|
||||||
|
- Returns ID only, doesn't refresh with relationships
|
||||||
|
- Avoids expensive JOINs when not needed
|
||||||
|
|
||||||
|
5. **Expire on Commit Disabled**
|
||||||
|
- Objects remain accessible after commit
|
||||||
|
- No automatic refetch when accessing attributes
|
||||||
|
|
||||||
|
### Connection Pool Tuning
|
||||||
|
|
||||||
|
**Default Settings** (for 10 concurrent games):
|
||||||
|
- Pool size: 10
|
||||||
|
- Max overflow: 20
|
||||||
|
- Total capacity: 30 connections
|
||||||
|
|
||||||
|
**High Load Settings** (for 20+ concurrent games):
|
||||||
|
```bash
|
||||||
|
DB_POOL_SIZE=20
|
||||||
|
DB_MAX_OVERFLOW=40
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Performance
|
||||||
|
|
||||||
|
**Expected Latency** (on local network):
|
||||||
|
- Simple SELECT: < 10ms
|
||||||
|
- INSERT with index updates: < 20ms
|
||||||
|
- UPDATE with WHERE: < 15ms
|
||||||
|
- Complex JOIN query: < 50ms
|
||||||
|
- Batch INSERT (10 records): < 30ms
|
||||||
|
|
||||||
|
**Performance Targets**:
|
||||||
|
- Database write: < 100ms (async, non-blocking)
|
||||||
|
- State recovery: < 2 seconds (loads 100+ plays)
|
||||||
|
|
||||||
|
## Key Files Reference
|
||||||
|
|
||||||
|
```
|
||||||
|
app/database/
|
||||||
|
├── session.py (55 lines)
|
||||||
|
│ ├── engine # SQLAlchemy async engine
|
||||||
|
│ ├── AsyncSessionLocal # Session factory
|
||||||
|
│ ├── Base # ORM base class
|
||||||
|
│ ├── init_db() # Create all tables
|
||||||
|
│ └── get_session() # FastAPI dependency
|
||||||
|
│
|
||||||
|
└── operations.py (882 lines)
|
||||||
|
└── DatabaseOperations class
|
||||||
|
├── Game Operations (3 methods)
|
||||||
|
│ ├── create_game()
|
||||||
|
│ ├── get_game()
|
||||||
|
│ └── update_game_state()
|
||||||
|
│
|
||||||
|
├── Lineup Operations (3 methods)
|
||||||
|
│ ├── add_pd_lineup_card()
|
||||||
|
│ ├── add_sba_lineup_player()
|
||||||
|
│ └── get_active_lineup()
|
||||||
|
│
|
||||||
|
├── Play Operations (2 methods)
|
||||||
|
│ ├── save_play()
|
||||||
|
│ └── get_plays()
|
||||||
|
│
|
||||||
|
├── Roster Operations (6 methods)
|
||||||
|
│ ├── add_pd_roster_card()
|
||||||
|
│ ├── add_sba_roster_player()
|
||||||
|
│ ├── get_pd_roster()
|
||||||
|
│ ├── get_sba_roster()
|
||||||
|
│ └── remove_roster_entry()
|
||||||
|
│
|
||||||
|
├── Session Operations (2 methods)
|
||||||
|
│ ├── create_game_session()
|
||||||
|
│ └── update_session_snapshot()
|
||||||
|
│
|
||||||
|
├── Dice Roll Operations (2 methods)
|
||||||
|
│ ├── save_rolls_batch()
|
||||||
|
│ └── get_rolls_for_game()
|
||||||
|
│
|
||||||
|
├── Recovery Operations (1 method)
|
||||||
|
│ └── load_game_state()
|
||||||
|
│
|
||||||
|
└── Rollback Operations (3 methods)
|
||||||
|
├── delete_plays_after()
|
||||||
|
├── delete_substitutions_after()
|
||||||
|
└── delete_rolls_after()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**Unit Tests**: Not applicable (database operations are integration by nature)
|
||||||
|
|
||||||
|
**Integration Tests**:
|
||||||
|
- `tests/integration/database/test_operations.py` (21 tests)
|
||||||
|
- `tests/integration/test_state_persistence.py` (8 tests)
|
||||||
|
|
||||||
|
**Running Tests**:
|
||||||
|
```bash
|
||||||
|
# All database integration tests
|
||||||
|
pytest tests/integration/database/ -v
|
||||||
|
|
||||||
|
# Specific operation test
|
||||||
|
pytest tests/integration/database/test_operations.py::TestGameOperations::test_create_game -v
|
||||||
|
|
||||||
|
# State persistence tests
|
||||||
|
pytest tests/integration/test_state_persistence.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test Requirements**:
|
||||||
|
- PostgreSQL database running at `10.10.0.42:5432`
|
||||||
|
- Database `paperdynasty_dev` exists
|
||||||
|
- User `paperdynasty` has permissions
|
||||||
|
- Environment variables configured in `.env`
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- **Backend CLAUDE.md**: `../CLAUDE.md` - Overall backend architecture
|
||||||
|
- **Database Models**: `../models/db_models.py` - SQLAlchemy ORM models
|
||||||
|
- **State Manager**: `../core/state_manager.py` - In-memory state management
|
||||||
|
- **Game Engine**: `../core/game_engine.py` - Game logic using database operations
|
||||||
|
- **Type Checking Guide**: `../../.claude/type-checking-guide.md` - SQLAlchemy type issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: 2025-10-31
|
||||||
|
**Author**: Claude
|
||||||
|
**Status**: Production-ready, optimized for performance
|
||||||
1270
backend/app/models/CLAUDE.md
Normal file
1270
backend/app/models/CLAUDE.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -15,6 +15,7 @@ import logging
|
|||||||
from typing import Optional, Dict, List, Any
|
from typing import Optional, Dict, List, Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from pydantic import BaseModel, Field, field_validator, ConfigDict
|
from pydantic import BaseModel, Field, field_validator, ConfigDict
|
||||||
|
from app.config.result_charts import PlayOutcome
|
||||||
|
|
||||||
logger = logging.getLogger(f'{__name__}')
|
logger = logging.getLogger(f'{__name__}')
|
||||||
|
|
||||||
@ -208,25 +209,9 @@ class ManualOutcomeSubmission(BaseModel):
|
|||||||
hit_location='SS'
|
hit_location='SS'
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
outcome: str # PlayOutcome enum value (e.g., "groundball_c")
|
outcome: PlayOutcome # PlayOutcome enum from result_charts
|
||||||
hit_location: Optional[str] = None # '1B', '2B', 'SS', '3B', 'LF', 'CF', 'RF', 'P', 'C'
|
hit_location: Optional[str] = None # '1B', '2B', 'SS', '3B', 'LF', 'CF', 'RF', 'P', 'C'
|
||||||
|
|
||||||
@field_validator('outcome')
|
|
||||||
@classmethod
|
|
||||||
def validate_outcome(cls, v: str) -> str:
|
|
||||||
"""Validate outcome is a valid PlayOutcome."""
|
|
||||||
from app.config.result_charts import PlayOutcome
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Try to convert to PlayOutcome enum
|
|
||||||
PlayOutcome(v)
|
|
||||||
return v
|
|
||||||
except ValueError:
|
|
||||||
valid_outcomes = [o.value for o in PlayOutcome]
|
|
||||||
raise ValueError(
|
|
||||||
f"outcome must be a valid PlayOutcome: {v} not in {valid_outcomes[:5]}..."
|
|
||||||
)
|
|
||||||
|
|
||||||
@field_validator('hit_location')
|
@field_validator('hit_location')
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_hit_location(cls, v: Optional[str]) -> Optional[str]:
|
def validate_hit_location(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
|||||||
959
backend/app/utils/CLAUDE.md
Normal file
959
backend/app/utils/CLAUDE.md
Normal file
@ -0,0 +1,959 @@
|
|||||||
|
# Utils - Shared Utilities and Helpers
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Centralized utility functions and helpers used across the backend application. Provides logging configuration, authentication utilities, and shared helper functions.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
- **Logging Setup**: Centralized logging configuration with rotating file handlers
|
||||||
|
- **Authentication**: JWT token creation and verification
|
||||||
|
- **Shared Helpers**: Common utility functions used throughout the application
|
||||||
|
|
||||||
|
## Module Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
app/utils/
|
||||||
|
├── __init__.py # Package marker
|
||||||
|
├── logging.py # Logging configuration (rotating handlers)
|
||||||
|
└── auth.py # JWT authentication utilities
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Components
|
||||||
|
|
||||||
|
### 1. Logging Module (`logging.py`)
|
||||||
|
|
||||||
|
Configures application-wide logging with both console and file handlers.
|
||||||
|
|
||||||
|
#### Features
|
||||||
|
|
||||||
|
**Rotating File Handlers**:
|
||||||
|
- Daily log files: `logs/app_YYYYMMDD.log`
|
||||||
|
- Max file size: 10MB per file
|
||||||
|
- Keep last 5 backup files
|
||||||
|
- Format: `YYYY-MM-DD HH:MM:SS - module.name - LEVEL - message`
|
||||||
|
|
||||||
|
**Dual Output**:
|
||||||
|
- Console (INFO level): For development monitoring
|
||||||
|
- File (DEBUG level): For detailed debugging
|
||||||
|
|
||||||
|
**Noisy Logger Silencing**:
|
||||||
|
- SQLAlchemy engine: WARNING level only
|
||||||
|
- Socket.io: INFO level only
|
||||||
|
- Engine.io: WARNING level only
|
||||||
|
|
||||||
|
#### Usage
|
||||||
|
|
||||||
|
**Application Startup** (`app/main.py`):
|
||||||
|
```python
|
||||||
|
from app.utils.logging import setup_logging
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Startup and shutdown events"""
|
||||||
|
logger.info("Starting Paper Dynasty Game Backend")
|
||||||
|
setup_logging() # Configure logging on startup
|
||||||
|
await init_db()
|
||||||
|
yield
|
||||||
|
logger.info("Shutting down Paper Dynasty Game Backend")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Module-Level Loggers** (Standard Pattern):
|
||||||
|
```python
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# For classes - include class name
|
||||||
|
logger = logging.getLogger(f'{__name__}.ClassName')
|
||||||
|
|
||||||
|
# For modules without classes - just module name
|
||||||
|
logger = logging.getLogger(f'{__name__}')
|
||||||
|
|
||||||
|
# Usage examples
|
||||||
|
logger.info("User connected")
|
||||||
|
logger.warning("Invalid request received")
|
||||||
|
logger.error("Failed to process action", exc_info=True)
|
||||||
|
logger.debug("Processing state transition")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Logging Pattern Examples Across Codebase**:
|
||||||
|
```python
|
||||||
|
# In game_engine.py
|
||||||
|
logger = logging.getLogger(f'{__name__}.GameEngine')
|
||||||
|
|
||||||
|
# In state_manager.py
|
||||||
|
logger = logging.getLogger(f'{__name__}.StateManager')
|
||||||
|
|
||||||
|
# In database operations
|
||||||
|
logger = logging.getLogger(f'{__name__}.DatabaseOperations')
|
||||||
|
|
||||||
|
# In WebSocket handlers
|
||||||
|
logger = logging.getLogger(f'{__name__}.handlers')
|
||||||
|
|
||||||
|
# In API routes
|
||||||
|
logger = logging.getLogger(f'{__name__}.games')
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Configuration Details
|
||||||
|
|
||||||
|
**Log Directory**:
|
||||||
|
- Location: `backend/logs/` (auto-created)
|
||||||
|
- Gitignored: Yes
|
||||||
|
- Naming: `app_YYYYMMDD.log` using Pendulum for UTC timestamps
|
||||||
|
|
||||||
|
**Log Levels**:
|
||||||
|
- Root logger: DEBUG (captures everything)
|
||||||
|
- Console handler: INFO (important messages only)
|
||||||
|
- File handler: DEBUG (full detail)
|
||||||
|
- SQLAlchemy engine: WARNING (reduce noise)
|
||||||
|
- Socket.io/Engine.io: INFO/WARNING (reduce noise)
|
||||||
|
|
||||||
|
**Rotation**:
|
||||||
|
- Size-based: 10MB max file size
|
||||||
|
- Time-based: Daily file names with UTC timestamps
|
||||||
|
- Backup count: Keep 5 most recent files
|
||||||
|
- Older logs automatically deleted
|
||||||
|
|
||||||
|
**Log Format**:
|
||||||
|
```
|
||||||
|
2025-10-31 14:23:45 - app.core.game_engine.GameEngine - INFO - Starting new game abc123
|
||||||
|
2025-10-31 14:23:46 - app.database.operations.DatabaseOperations - DEBUG - Executing query: INSERT INTO games...
|
||||||
|
2025-10-31 14:23:47 - app.websocket.handlers - ERROR - Connection failed: timeout
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Implementation Details
|
||||||
|
|
||||||
|
```python
|
||||||
|
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 (Pendulum for UTC)
|
||||||
|
now = pendulum.now('UTC')
|
||||||
|
log_file = os.path.join(log_dir, f"app_{now.format('YYYYMMDD')}.log")
|
||||||
|
|
||||||
|
# Formatter (consistent across handlers)
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
datefmt='%Y-%m-%d %H:%M:%S'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Console handler (INFO for development)
|
||||||
|
console_handler = logging.StreamHandler()
|
||||||
|
console_handler.setLevel(logging.INFO)
|
||||||
|
console_handler.setFormatter(formatter)
|
||||||
|
|
||||||
|
# Rotating file handler (DEBUG for full detail)
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Authentication Module (`auth.py`)
|
||||||
|
|
||||||
|
JWT token creation and verification for user authentication.
|
||||||
|
|
||||||
|
#### Features
|
||||||
|
|
||||||
|
**Token Creation**:
|
||||||
|
- JWT with HS256 algorithm
|
||||||
|
- 7-day expiration (configurable)
|
||||||
|
- Embeds arbitrary user data
|
||||||
|
- Uses Pendulum for UTC timestamps
|
||||||
|
|
||||||
|
**Token Verification**:
|
||||||
|
- Validates signature and expiration
|
||||||
|
- Returns decoded payload
|
||||||
|
- Raises `JWTError` on failure
|
||||||
|
|
||||||
|
#### Dependencies
|
||||||
|
|
||||||
|
```python
|
||||||
|
from jose import jwt, JWTError # Python-JOSE library
|
||||||
|
import pendulum # UTC timestamps
|
||||||
|
from app.config import get_settings # Secret key from settings
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Usage
|
||||||
|
|
||||||
|
**Creating Tokens**:
|
||||||
|
```python
|
||||||
|
from app.utils.auth import create_token
|
||||||
|
|
||||||
|
# Create token with user data
|
||||||
|
user_data = {
|
||||||
|
"user_id": 123,
|
||||||
|
"username": "player1",
|
||||||
|
"discord_id": "987654321",
|
||||||
|
"league": "sba"
|
||||||
|
}
|
||||||
|
token = create_token(user_data)
|
||||||
|
# Returns: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verifying Tokens**:
|
||||||
|
```python
|
||||||
|
from app.utils.auth import verify_token
|
||||||
|
from jose import JWTError
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = verify_token(token)
|
||||||
|
user_id = payload["user_id"]
|
||||||
|
username = payload["username"]
|
||||||
|
except JWTError:
|
||||||
|
# Token invalid or expired
|
||||||
|
return {"error": "Invalid token"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Integration with Discord OAuth** (`app/api/routes/auth.py`):
|
||||||
|
```python
|
||||||
|
from app.utils.auth import create_token
|
||||||
|
|
||||||
|
@router.post("/discord/callback")
|
||||||
|
async def discord_callback(code: str):
|
||||||
|
# Exchange code for Discord token
|
||||||
|
discord_token = await exchange_code_for_token(code)
|
||||||
|
|
||||||
|
# Get Discord user info
|
||||||
|
user_info = await get_discord_user(discord_token)
|
||||||
|
|
||||||
|
# Create JWT for our application
|
||||||
|
token = create_token({
|
||||||
|
"user_id": user_info["id"],
|
||||||
|
"username": user_info["username"],
|
||||||
|
"discord_id": user_info["id"]
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"token": token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**WebSocket Authentication** (`app/websocket/handlers.py`):
|
||||||
|
```python
|
||||||
|
from app.utils.auth import verify_token
|
||||||
|
from jose import JWTError
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def connect(sid, environ, auth):
|
||||||
|
"""Authenticate WebSocket connection"""
|
||||||
|
try:
|
||||||
|
# Verify token from auth dict
|
||||||
|
token = auth.get("token")
|
||||||
|
if not token:
|
||||||
|
raise ConnectionRefusedError("Missing token")
|
||||||
|
|
||||||
|
payload = verify_token(token)
|
||||||
|
|
||||||
|
# Store user info in session
|
||||||
|
await sio.save_session(sid, {
|
||||||
|
"user_id": payload["user_id"],
|
||||||
|
"username": payload["username"]
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"User {payload['username']} connected")
|
||||||
|
except JWTError:
|
||||||
|
logger.warning("Invalid token on connection attempt")
|
||||||
|
raise ConnectionRefusedError("Invalid token")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Implementation Details
|
||||||
|
|
||||||
|
**Token Creation**:
|
||||||
|
```python
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
**Token Verification**:
|
||||||
|
```python
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
**Configuration Requirements** (`.env`):
|
||||||
|
```bash
|
||||||
|
# Required for JWT signing
|
||||||
|
SECRET_KEY=your-secret-key-at-least-32-chars-long
|
||||||
|
```
|
||||||
|
|
||||||
|
## Patterns & Conventions
|
||||||
|
|
||||||
|
### Logging Pattern
|
||||||
|
|
||||||
|
**Standard Pattern** (Used Throughout Codebase):
|
||||||
|
```python
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Module-level logger with class name
|
||||||
|
logger = logging.getLogger(f'{__name__}.ClassName')
|
||||||
|
|
||||||
|
class ClassName:
|
||||||
|
def some_method(self):
|
||||||
|
logger.info("Method called")
|
||||||
|
logger.debug("Processing details...")
|
||||||
|
logger.error("Something went wrong", exc_info=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Log Level Guidelines**:
|
||||||
|
- `DEBUG`: Detailed diagnostic information (state changes, query details)
|
||||||
|
- `INFO`: General informational messages (connections, game events)
|
||||||
|
- `WARNING`: Warning messages (invalid input, deprecations)
|
||||||
|
- `ERROR`: Error messages (exceptions, failures)
|
||||||
|
- `CRITICAL`: Critical failures (system-level issues)
|
||||||
|
|
||||||
|
**Exception Logging**:
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
# operation
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Operation failed: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication Pattern
|
||||||
|
|
||||||
|
**Token in HTTP Headers**:
|
||||||
|
```python
|
||||||
|
# Client sends:
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {token}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Token in WebSocket Auth**:
|
||||||
|
```python
|
||||||
|
# Client connects with:
|
||||||
|
socket.io.connect("http://localhost:8000", {
|
||||||
|
auth: {
|
||||||
|
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**FastAPI Dependency**:
|
||||||
|
```python
|
||||||
|
from fastapi import Depends, HTTPException, Header
|
||||||
|
from app.utils.auth import verify_token
|
||||||
|
from jose import JWTError
|
||||||
|
|
||||||
|
async def get_current_user(authorization: str = Header(None)):
|
||||||
|
"""Extract and verify user from Bearer token"""
|
||||||
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
|
raise HTTPException(401, "Missing or invalid authorization header")
|
||||||
|
|
||||||
|
token = authorization.split(" ")[1]
|
||||||
|
try:
|
||||||
|
payload = verify_token(token)
|
||||||
|
return payload
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(401, "Invalid or expired token")
|
||||||
|
|
||||||
|
# Use in routes
|
||||||
|
@router.get("/protected")
|
||||||
|
async def protected_route(user = Depends(get_current_user)):
|
||||||
|
return {"user_id": user["user_id"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
### Application Startup
|
||||||
|
|
||||||
|
**main.py** - Initialize logging on application startup:
|
||||||
|
```python
|
||||||
|
from app.utils.logging import setup_logging
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
logger.info("Starting application")
|
||||||
|
setup_logging() # Must be called early
|
||||||
|
await init_db()
|
||||||
|
yield
|
||||||
|
logger.info("Shutting down")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Module Initialization
|
||||||
|
|
||||||
|
**Every Module with Logging**:
|
||||||
|
```python
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(f'{__name__}.ClassName')
|
||||||
|
```
|
||||||
|
|
||||||
|
**Modules Using Auth**:
|
||||||
|
```python
|
||||||
|
from app.utils.auth import create_token, verify_token
|
||||||
|
from jose import JWTError
|
||||||
|
```
|
||||||
|
|
||||||
|
### WebSocket Handlers
|
||||||
|
|
||||||
|
**Connection Authentication**:
|
||||||
|
```python
|
||||||
|
from app.utils.auth import verify_token
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def connect(sid, environ, auth):
|
||||||
|
token = auth.get("token")
|
||||||
|
payload = verify_token(token) # Raises JWTError if invalid
|
||||||
|
# Store session data
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Routes
|
||||||
|
|
||||||
|
**Protected Endpoints**:
|
||||||
|
```python
|
||||||
|
from app.utils.auth import verify_token
|
||||||
|
|
||||||
|
@router.get("/games")
|
||||||
|
async def get_games(authorization: str = Header(None)):
|
||||||
|
token = authorization.split(" ")[1]
|
||||||
|
user = verify_token(token)
|
||||||
|
# Use user data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Adding a New Utility Module
|
||||||
|
|
||||||
|
1. **Create Module File**:
|
||||||
|
```bash
|
||||||
|
touch app/utils/new_utility.py
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Implement Utility Functions**:
|
||||||
|
```python
|
||||||
|
# app/utils/new_utility.py
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(f'{__name__}.NewUtility')
|
||||||
|
|
||||||
|
def utility_function(param: str) -> str:
|
||||||
|
"""
|
||||||
|
Utility function description
|
||||||
|
|
||||||
|
Args:
|
||||||
|
param: Parameter description
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Return value description
|
||||||
|
"""
|
||||||
|
logger.debug(f"Processing {param}")
|
||||||
|
result = process(param)
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Export from __init__.py** (if needed for public API):
|
||||||
|
```python
|
||||||
|
# app/utils/__init__.py
|
||||||
|
from .logging import setup_logging
|
||||||
|
from .auth import create_token, verify_token
|
||||||
|
from .new_utility import utility_function
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"setup_logging",
|
||||||
|
"create_token",
|
||||||
|
"verify_token",
|
||||||
|
"utility_function"
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Add Tests**:
|
||||||
|
```python
|
||||||
|
# tests/unit/utils/test_new_utility.py
|
||||||
|
import pytest
|
||||||
|
from app.utils.new_utility import utility_function
|
||||||
|
|
||||||
|
def test_utility_function():
|
||||||
|
result = utility_function("test")
|
||||||
|
assert result == "expected"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modifying Logging Configuration
|
||||||
|
|
||||||
|
**Change Log Level**:
|
||||||
|
```python
|
||||||
|
# In setup_logging()
|
||||||
|
console_handler.setLevel(logging.DEBUG) # Was INFO
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add Custom Handler**:
|
||||||
|
```python
|
||||||
|
# In setup_logging()
|
||||||
|
# Add email handler for critical errors
|
||||||
|
from logging.handlers import SMTPHandler
|
||||||
|
|
||||||
|
email_handler = SMTPHandler(
|
||||||
|
mailhost=("smtp.example.com", 587),
|
||||||
|
fromaddr="alerts@example.com",
|
||||||
|
toaddrs=["admin@example.com"],
|
||||||
|
subject="Critical Error in Game Backend"
|
||||||
|
)
|
||||||
|
email_handler.setLevel(logging.CRITICAL)
|
||||||
|
email_handler.setFormatter(formatter)
|
||||||
|
root_logger.addHandler(email_handler)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Change Rotation Policy**:
|
||||||
|
```python
|
||||||
|
# In setup_logging()
|
||||||
|
file_handler = logging.handlers.RotatingFileHandler(
|
||||||
|
log_file,
|
||||||
|
maxBytes=50 * 1024 * 1024, # Change to 50MB
|
||||||
|
backupCount=10 # Keep 10 backups
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Changing Token Expiration
|
||||||
|
|
||||||
|
**Modify create_token()**:
|
||||||
|
```python
|
||||||
|
# In auth.py
|
||||||
|
def create_token(user_data: Dict[str, Any]) -> str:
|
||||||
|
payload = {
|
||||||
|
**user_data,
|
||||||
|
"exp": pendulum.now('UTC').add(days=30).int_timestamp # 30 days instead of 7
|
||||||
|
}
|
||||||
|
token = jwt.encode(payload, settings.secret_key, algorithm="HS256")
|
||||||
|
return token
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding Token Refresh
|
||||||
|
|
||||||
|
**Create refresh_token() utility**:
|
||||||
|
```python
|
||||||
|
# In auth.py
|
||||||
|
def refresh_token(old_token: str) -> str:
|
||||||
|
"""
|
||||||
|
Refresh an existing token with new expiration
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_token: Existing valid token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New token with extended expiration
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
JWTError: If old token is invalid
|
||||||
|
"""
|
||||||
|
# Verify old token
|
||||||
|
payload = verify_token(old_token)
|
||||||
|
|
||||||
|
# Remove old expiration
|
||||||
|
payload.pop("exp", None)
|
||||||
|
|
||||||
|
# Create new token
|
||||||
|
return create_token(payload)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Logging Issues
|
||||||
|
|
||||||
|
**Problem**: No logs appearing
|
||||||
|
- **Check**: `setup_logging()` called in `main.py` lifespan?
|
||||||
|
- **Check**: `logs/` directory exists and is writable?
|
||||||
|
- **Check**: Using correct logger pattern: `logger = logging.getLogger(f'{__name__}.ClassName')`?
|
||||||
|
|
||||||
|
**Problem**: Too much SQLAlchemy noise in logs
|
||||||
|
- **Solution**: Already silenced in `setup_logging()` - check if being called
|
||||||
|
- **Alternative**: Increase SQLAlchemy logger level:
|
||||||
|
```python
|
||||||
|
logging.getLogger("sqlalchemy.engine").setLevel(logging.ERROR)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem**: Logs not rotating
|
||||||
|
- **Check**: File size exceeding 10MB?
|
||||||
|
- **Check**: `RotatingFileHandler` configured correctly?
|
||||||
|
- **Debug**: Check `logs/` directory for backup files (`app_YYYYMMDD.log.1`, `.2`, etc.)
|
||||||
|
|
||||||
|
**Problem**: Daily log files not working
|
||||||
|
- **Solution**: Files are daily-named but don't auto-switch at midnight
|
||||||
|
- **Enhancement**: Use `TimedRotatingFileHandler` for true daily rotation:
|
||||||
|
```python
|
||||||
|
file_handler = logging.handlers.TimedRotatingFileHandler(
|
||||||
|
"logs/app.log",
|
||||||
|
when="midnight",
|
||||||
|
interval=1,
|
||||||
|
backupCount=30 # Keep 30 days
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication Issues
|
||||||
|
|
||||||
|
**Problem**: `JWTError: Invalid token`
|
||||||
|
- **Check**: Token copied correctly (no whitespace)?
|
||||||
|
- **Check**: Token not expired (7-day limit)?
|
||||||
|
- **Check**: `SECRET_KEY` same on creation and verification?
|
||||||
|
- **Check**: Using correct algorithm (HS256)?
|
||||||
|
|
||||||
|
**Problem**: `ImportError: cannot import name 'jwt' from 'jose'`
|
||||||
|
- **Solution**: Install python-jose: `pip install python-jose[cryptography]`
|
||||||
|
|
||||||
|
**Problem**: Tokens working in Postman but not browser
|
||||||
|
- **Check**: CORS settings in `config.py`
|
||||||
|
- **Check**: Browser sending `Authorization: Bearer <token>` header?
|
||||||
|
- **Check**: Token not being stripped by middleware?
|
||||||
|
|
||||||
|
**Problem**: WebSocket auth failing
|
||||||
|
- **Check**: Token in `auth` object, not headers
|
||||||
|
- **Client Code**:
|
||||||
|
```javascript
|
||||||
|
const socket = io("http://localhost:8000", {
|
||||||
|
auth: {
|
||||||
|
token: "YOUR_TOKEN_HERE"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pendulum Date Issues
|
||||||
|
|
||||||
|
**Problem**: `AttributeError: 'DateTime' object has no attribute 'int_timestamp'`
|
||||||
|
- **Check**: Using Pendulum 3.0+?
|
||||||
|
- **Solution**: `int_timestamp` is Pendulum 3.0 feature
|
||||||
|
- **Alternative** (Pendulum 2.x):
|
||||||
|
```python
|
||||||
|
"exp": int(pendulum.now('UTC').add(days=7).timestamp())
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### JWT Security
|
||||||
|
|
||||||
|
**Secret Key Requirements**:
|
||||||
|
- Minimum 32 characters
|
||||||
|
- Cryptographically random
|
||||||
|
- Never commit to git
|
||||||
|
- Rotate periodically
|
||||||
|
|
||||||
|
**Generate Secure Key**:
|
||||||
|
```bash
|
||||||
|
# Using Python
|
||||||
|
python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
|
|
||||||
|
# Using OpenSSL
|
||||||
|
openssl rand -base64 32
|
||||||
|
```
|
||||||
|
|
||||||
|
**Token Best Practices**:
|
||||||
|
- Always use HTTPS in production (tokens visible in HTTP)
|
||||||
|
- Short expiration times (7 days or less)
|
||||||
|
- Implement refresh token flow for long-lived sessions
|
||||||
|
- Never log token values
|
||||||
|
- Validate all claims on decode
|
||||||
|
|
||||||
|
### Logging Security
|
||||||
|
|
||||||
|
**Never Log**:
|
||||||
|
- Passwords or secrets
|
||||||
|
- Complete JWT tokens
|
||||||
|
- Database credentials
|
||||||
|
- API keys
|
||||||
|
- User personal information
|
||||||
|
|
||||||
|
**Safe Logging**:
|
||||||
|
```python
|
||||||
|
# ❌ DON'T
|
||||||
|
logger.info(f"User logged in with token: {token}")
|
||||||
|
|
||||||
|
# ✅ DO
|
||||||
|
logger.info(f"User {user_id} logged in")
|
||||||
|
logger.debug(f"Token prefix: {token[:10]}...")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Log Sanitization**:
|
||||||
|
```python
|
||||||
|
def sanitize_for_log(data: dict) -> dict:
|
||||||
|
"""Remove sensitive fields before logging"""
|
||||||
|
sensitive_keys = ["password", "token", "secret", "api_key"]
|
||||||
|
return {k: "***" if k in sensitive_keys else v for k, v in data.items()}
|
||||||
|
|
||||||
|
logger.info(f"Request data: {sanitize_for_log(request_data)}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit Tests for Utilities
|
||||||
|
|
||||||
|
**Test Structure**:
|
||||||
|
```
|
||||||
|
tests/unit/utils/
|
||||||
|
├── test_logging.py # Logging configuration tests
|
||||||
|
└── test_auth.py # JWT creation/verification tests
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example: Testing Token Creation**:
|
||||||
|
```python
|
||||||
|
# tests/unit/utils/test_auth.py
|
||||||
|
import pytest
|
||||||
|
from app.utils.auth import create_token, verify_token
|
||||||
|
from jose import JWTError
|
||||||
|
|
||||||
|
def test_create_token():
|
||||||
|
"""Test JWT token creation"""
|
||||||
|
user_data = {"user_id": 123, "username": "test"}
|
||||||
|
token = create_token(user_data)
|
||||||
|
|
||||||
|
assert token is not None
|
||||||
|
assert isinstance(token, str)
|
||||||
|
assert len(token) > 0
|
||||||
|
|
||||||
|
def test_verify_token_valid():
|
||||||
|
"""Test verifying valid token"""
|
||||||
|
user_data = {"user_id": 123, "username": "test"}
|
||||||
|
token = create_token(user_data)
|
||||||
|
|
||||||
|
payload = verify_token(token)
|
||||||
|
assert payload["user_id"] == 123
|
||||||
|
assert payload["username"] == "test"
|
||||||
|
assert "exp" in payload
|
||||||
|
|
||||||
|
def test_verify_token_invalid():
|
||||||
|
"""Test verifying invalid token"""
|
||||||
|
with pytest.raises(JWTError):
|
||||||
|
verify_token("invalid.token.here")
|
||||||
|
|
||||||
|
def test_token_expiration():
|
||||||
|
"""Test token expiration"""
|
||||||
|
# Would need to mock time or wait 7 days
|
||||||
|
# Use freezegun or similar for time-based tests
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example: Testing Logging Setup**:
|
||||||
|
```python
|
||||||
|
# tests/unit/utils/test_logging.py
|
||||||
|
import pytest
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from app.utils.logging import setup_logging
|
||||||
|
|
||||||
|
def test_setup_logging_creates_directory(tmp_path, monkeypatch):
|
||||||
|
"""Test that setup_logging creates logs directory"""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
setup_logging()
|
||||||
|
assert os.path.exists("logs")
|
||||||
|
|
||||||
|
def test_setup_logging_configures_handlers():
|
||||||
|
"""Test that handlers are configured"""
|
||||||
|
setup_logging()
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
|
||||||
|
assert len(root_logger.handlers) >= 2
|
||||||
|
assert any(isinstance(h, logging.StreamHandler) for h in root_logger.handlers)
|
||||||
|
assert any(isinstance(h, logging.handlers.RotatingFileHandler) for h in root_logger.handlers)
|
||||||
|
|
||||||
|
def test_setup_logging_sets_levels():
|
||||||
|
"""Test that log levels are set correctly"""
|
||||||
|
setup_logging()
|
||||||
|
|
||||||
|
assert logging.getLogger("sqlalchemy.engine").level == logging.WARNING
|
||||||
|
assert logging.getLogger("socketio").level == logging.INFO
|
||||||
|
assert logging.getLogger("engineio").level == logging.WARNING
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Complete Authentication Flow
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 1. User logs in via Discord OAuth
|
||||||
|
@router.post("/auth/discord/callback")
|
||||||
|
async def discord_callback(code: str):
|
||||||
|
# Exchange code for Discord access token
|
||||||
|
discord_token = await exchange_discord_code(code)
|
||||||
|
|
||||||
|
# Get Discord user info
|
||||||
|
user_info = await get_discord_user(discord_token)
|
||||||
|
|
||||||
|
# Create our JWT token
|
||||||
|
token = create_token({
|
||||||
|
"user_id": user_info["id"],
|
||||||
|
"username": user_info["username"],
|
||||||
|
"discriminator": user_info["discriminator"]
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"token": token}
|
||||||
|
|
||||||
|
# 2. Client stores token and uses for API calls
|
||||||
|
# Client code (JavaScript):
|
||||||
|
# localStorage.setItem("token", response.token)
|
||||||
|
|
||||||
|
# 3. Client connects to WebSocket with token
|
||||||
|
@sio.event
|
||||||
|
async def connect(sid, environ, auth):
|
||||||
|
try:
|
||||||
|
token = auth.get("token")
|
||||||
|
payload = verify_token(token)
|
||||||
|
|
||||||
|
await sio.save_session(sid, {
|
||||||
|
"user_id": payload["user_id"],
|
||||||
|
"username": payload["username"]
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"User {payload['username']} connected")
|
||||||
|
except JWTError:
|
||||||
|
raise ConnectionRefusedError("Invalid token")
|
||||||
|
|
||||||
|
# 4. Client makes authenticated API requests
|
||||||
|
@router.get("/games/my-games")
|
||||||
|
async def get_my_games(authorization: str = Header(None)):
|
||||||
|
if not authorization:
|
||||||
|
raise HTTPException(401, "Missing authorization")
|
||||||
|
|
||||||
|
token = authorization.replace("Bearer ", "")
|
||||||
|
payload = verify_token(token)
|
||||||
|
|
||||||
|
user_id = payload["user_id"]
|
||||||
|
games = await db.get_games_for_user(user_id)
|
||||||
|
return games
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Logger with Context
|
||||||
|
|
||||||
|
```python
|
||||||
|
import logging
|
||||||
|
from contextvars import ContextVar
|
||||||
|
|
||||||
|
# Context variable for request ID
|
||||||
|
request_id_var: ContextVar[str] = ContextVar("request_id", default="")
|
||||||
|
|
||||||
|
class ContextFilter(logging.Filter):
|
||||||
|
"""Add request ID to log records"""
|
||||||
|
def filter(self, record):
|
||||||
|
record.request_id = request_id_var.get()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def setup_logging():
|
||||||
|
"""Enhanced logging with request context"""
|
||||||
|
# Standard setup...
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
'%(asctime)s - %(request_id)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
datefmt='%Y-%m-%d %H:%M:%S'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add context filter
|
||||||
|
context_filter = ContextFilter()
|
||||||
|
console_handler.addFilter(context_filter)
|
||||||
|
file_handler.addFilter(context_filter)
|
||||||
|
|
||||||
|
# ... rest of setup
|
||||||
|
|
||||||
|
# In middleware/handler
|
||||||
|
import uuid
|
||||||
|
from contextvars import ContextVar
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def request_id_middleware(request, call_next):
|
||||||
|
request_id = str(uuid.uuid4())
|
||||||
|
request_id_var.set(request_id)
|
||||||
|
response = await call_next(request)
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Now all logs in that request context include request_id
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### Logging Performance
|
||||||
|
|
||||||
|
**Avoid String Formatting Before Logging**:
|
||||||
|
```python
|
||||||
|
# ❌ DON'T - always formats even if not logged
|
||||||
|
logger.debug("Processing: " + expensive_operation())
|
||||||
|
|
||||||
|
# ✅ DO - only formats if debug enabled
|
||||||
|
logger.debug("Processing: %s", expensive_operation())
|
||||||
|
|
||||||
|
# ✅ ALSO GOOD - f-strings are evaluated lazily in some cases
|
||||||
|
logger.debug(f"Processing: {expensive_operation()}") # But still evaluates
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Lazy Evaluation**:
|
||||||
|
```python
|
||||||
|
if logger.isEnabledFor(logging.DEBUG):
|
||||||
|
expensive_data = generate_debug_info()
|
||||||
|
logger.debug(f"Debug info: {expensive_data}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token Verification Performance
|
||||||
|
|
||||||
|
**Cache Decoded Tokens** (for repeated verifications):
|
||||||
|
```python
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1000)
|
||||||
|
def verify_token_cached(token: str) -> Dict[str, Any]:
|
||||||
|
"""Cached token verification (use with caution)"""
|
||||||
|
return verify_token(token)
|
||||||
|
|
||||||
|
# Clear cache periodically or on logout
|
||||||
|
verify_token_cached.cache_clear()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Be careful with caching - can't invalidate specific tokens, and cache may return expired tokens if not cleared.
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- **Main Backend CLAUDE.md**: `../CLAUDE.md` - Application architecture
|
||||||
|
- **Config Module**: `../config/CLAUDE.md` - Settings and configuration
|
||||||
|
- **WebSocket Module**: `../websocket/CLAUDE.md` - WebSocket integration
|
||||||
|
- **API Routes**: `../api/routes/CLAUDE.md` - REST endpoint patterns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Key Principles**:
|
||||||
|
1. **Centralized Logging**: All logging configuration in one place
|
||||||
|
2. **Consistent Patterns**: Use `f'{__name__}.ClassName'` everywhere
|
||||||
|
3. **Security First**: Never log sensitive data, use strong secret keys
|
||||||
|
4. **Simple Utilities**: Small, focused, reusable functions
|
||||||
|
5. **Type Safety**: Full type hints on all public functions
|
||||||
|
|
||||||
|
**Testing**: All utilities should have comprehensive unit tests covering normal and edge cases.
|
||||||
1588
backend/app/websocket/CLAUDE.md
Normal file
1588
backend/app/websocket/CLAUDE.md
Normal file
File diff suppressed because it is too large
Load Diff
475
backend/tests/CLAUDE.md
Normal file
475
backend/tests/CLAUDE.md
Normal file
@ -0,0 +1,475 @@
|
|||||||
|
# Backend Tests - Developer Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Comprehensive test suite for the Paper Dynasty backend game engine covering unit tests, integration tests, and end-to-end scenarios.
|
||||||
|
|
||||||
|
**Test Structure**:
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
├── unit/ # Fast, isolated unit tests (no DB)
|
||||||
|
│ ├── config/ # League configs, PlayOutcome enum
|
||||||
|
│ ├── core/ # Game engine, dice, state manager, validators
|
||||||
|
│ ├── models/ # Pydantic models (game, player, roster)
|
||||||
|
│ └── terminal_client/ # Terminal client modules
|
||||||
|
├── integration/ # Database-dependent tests
|
||||||
|
│ ├── database/ # DatabaseOperations tests
|
||||||
|
│ ├── test_game_engine.py # Full game engine with DB
|
||||||
|
│ └── test_state_persistence.py # State recovery tests
|
||||||
|
└── e2e/ # End-to-end tests (future)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Unit Tests (Recommended)
|
||||||
|
|
||||||
|
**Fast, reliable, no database required**:
|
||||||
|
```bash
|
||||||
|
# All unit tests
|
||||||
|
pytest tests/unit/ -v
|
||||||
|
|
||||||
|
# Specific module
|
||||||
|
pytest tests/unit/core/test_game_engine.py -v
|
||||||
|
|
||||||
|
# Specific test
|
||||||
|
pytest tests/unit/core/test_game_engine.py::TestGameEngine::test_start_game -v
|
||||||
|
|
||||||
|
# With coverage
|
||||||
|
pytest tests/unit/ --cov=app --cov-report=html
|
||||||
|
```
|
||||||
|
|
||||||
|
**Unit tests should always pass**. If they don't, it's a real code issue.
|
||||||
|
|
||||||
|
### Integration Tests (Database Required)
|
||||||
|
|
||||||
|
**⚠️ CRITICAL: Integration tests have known infrastructure issues**
|
||||||
|
|
||||||
|
#### Known Issue: AsyncPG Connection Conflicts
|
||||||
|
|
||||||
|
**Problem**: Integration tests share database connections and event loops, causing:
|
||||||
|
- `asyncpg.exceptions._base.InterfaceError: cannot perform operation: another operation is in progress`
|
||||||
|
- `Task got Future attached to a different loop`
|
||||||
|
|
||||||
|
**Why This Happens**:
|
||||||
|
- AsyncPG connections don't support concurrent operations
|
||||||
|
- pytest-asyncio fixtures with mismatched scopes (module vs function)
|
||||||
|
- Tests running in parallel try to reuse the same connection
|
||||||
|
|
||||||
|
**Current Workaround**: Run integration tests **individually** or **serially**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run one test at a time (always works)
|
||||||
|
pytest tests/integration/database/test_operations.py::TestDatabaseOperationsGame::test_create_game -v
|
||||||
|
|
||||||
|
# Run test class serially
|
||||||
|
pytest tests/integration/database/test_operations.py::TestDatabaseOperationsGame -v
|
||||||
|
|
||||||
|
# Run entire file (may have conflicts after first test)
|
||||||
|
pytest tests/integration/database/test_operations.py -v
|
||||||
|
|
||||||
|
# Force serial execution (slower but more reliable)
|
||||||
|
pytest tests/integration/ -v -x # -x stops on first failure
|
||||||
|
```
|
||||||
|
|
||||||
|
**DO NOT**:
|
||||||
|
- Run all integration tests at once: `pytest tests/integration/ -v` ❌ (will fail)
|
||||||
|
- Expect integration tests to work in parallel ❌
|
||||||
|
|
||||||
|
**Long-term Fix Needed**:
|
||||||
|
1. Update fixtures to use proper asyncio scope management
|
||||||
|
2. Ensure each test gets isolated database session
|
||||||
|
3. Consider using `pytest-xdist` with proper worker isolation
|
||||||
|
4. Or redesign fixtures to create fresh connections per test
|
||||||
|
|
||||||
|
### All Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run everything (expect integration failures due to connection issues)
|
||||||
|
pytest tests/ -v
|
||||||
|
|
||||||
|
# Run with markers
|
||||||
|
pytest tests/ -v -m "not integration" # Skip integration tests
|
||||||
|
pytest tests/ -v -m integration # Only integration tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Configuration
|
||||||
|
|
||||||
|
### pytest.ini
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
|
asyncio_default_fixture_loop_scope = function
|
||||||
|
testpaths = tests
|
||||||
|
python_files = test_*.py
|
||||||
|
python_classes = Test*
|
||||||
|
python_functions = test_*
|
||||||
|
markers =
|
||||||
|
integration: marks tests that require database (deselect with '-m "not integration"')
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Settings**:
|
||||||
|
- `asyncio_mode = auto`: Automatically detect async tests
|
||||||
|
- `asyncio_default_fixture_loop_scope = function`: Each test gets own event loop
|
||||||
|
|
||||||
|
### Fixture Scopes
|
||||||
|
|
||||||
|
**Critical for async tests**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ✅ CORRECT - Matching scopes
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
async def event_loop():
|
||||||
|
...
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
async def db_session(event_loop):
|
||||||
|
...
|
||||||
|
|
||||||
|
# ❌ WRONG - Mismatched scopes cause errors
|
||||||
|
@pytest.fixture(scope="module") # Module scope
|
||||||
|
async def setup_database(event_loop): # But depends on function-scoped event_loop
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule**: Async fixtures should typically use `scope="function"` to avoid event loop conflicts.
|
||||||
|
|
||||||
|
## Common Test Patterns
|
||||||
|
|
||||||
|
### Unit Test Pattern
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from app.core.game_engine import GameEngine
|
||||||
|
from app.models.game_models import GameState
|
||||||
|
|
||||||
|
class TestGameEngine:
|
||||||
|
"""Unit tests for game engine - no database required"""
|
||||||
|
|
||||||
|
def test_something(self):
|
||||||
|
"""Test description"""
|
||||||
|
# Arrange
|
||||||
|
engine = GameEngine()
|
||||||
|
state = GameState(game_id=uuid4(), league_id="sba", ...)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = engine.some_method(state)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result.success is True
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Test Pattern
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from app.database.operations import DatabaseOperations
|
||||||
|
from app.database.session import AsyncSessionLocal
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestDatabaseOperations:
|
||||||
|
"""Integration tests - requires database"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def db_ops(self):
|
||||||
|
"""Create DatabaseOperations instance"""
|
||||||
|
ops = DatabaseOperations()
|
||||||
|
yield ops
|
||||||
|
|
||||||
|
async def test_create_game(self, db_ops):
|
||||||
|
"""Test description"""
|
||||||
|
# Arrange
|
||||||
|
game_id = uuid4()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
await db_ops.create_game(game_id=game_id, ...)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
game = await db_ops.get_game(game_id)
|
||||||
|
assert game is not None
|
||||||
|
```
|
||||||
|
|
||||||
|
### Async Test Pattern
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# pytest-asyncio automatically detects async tests
|
||||||
|
async def test_async_operation():
|
||||||
|
result = await some_async_function()
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
# Or explicit marker (not required with asyncio_mode=auto)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_another_async_operation():
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Testing
|
||||||
|
|
||||||
|
### Test Database Setup
|
||||||
|
|
||||||
|
**Required Environment Variables** (`.env`):
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=postgresql+asyncpg://paperdynasty:PASSWORD@10.10.0.42:5432/paperdynasty_dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Database Connection**:
|
||||||
|
- Integration tests use the **same database** as development
|
||||||
|
- Tests should clean up after themselves (fixtures handle this)
|
||||||
|
- Each test should create unique game IDs to avoid conflicts
|
||||||
|
|
||||||
|
### Transaction Rollback Pattern
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.fixture
|
||||||
|
async def db_session():
|
||||||
|
"""Provide isolated database session with automatic rollback"""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
async with session.begin():
|
||||||
|
yield session
|
||||||
|
# Automatic rollback on fixture teardown
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Current fixtures may not properly isolate transactions, contributing to connection conflicts.
|
||||||
|
|
||||||
|
## Known Test Issues
|
||||||
|
|
||||||
|
### 1. Player Model Test Failures
|
||||||
|
|
||||||
|
**Issue**: `tests/unit/models/test_player_models.py` has 13 failures
|
||||||
|
|
||||||
|
**Root Causes**:
|
||||||
|
- `BasePlayer.get_image_url()` not marked as `@abstractmethod`
|
||||||
|
- Factory methods (`from_api_response()`) expect `pos_1` field but test fixtures don't provide it
|
||||||
|
- Attribute name mismatch: tests expect `player_id` but model has `id`
|
||||||
|
- Display name format mismatch in `PdPlayer.get_display_name()`
|
||||||
|
|
||||||
|
**Files to Fix**:
|
||||||
|
- `app/models/player_models.py` - Update abstract methods and factory methods
|
||||||
|
- `tests/unit/models/test_player_models.py` - Update test fixtures to match API response format
|
||||||
|
|
||||||
|
### 2. Dice System Test Failure
|
||||||
|
|
||||||
|
**Issue**: `tests/unit/core/test_dice.py::TestRollHistory::test_get_rolls_since`
|
||||||
|
|
||||||
|
**Symptom**: Expects 1 roll but gets 0
|
||||||
|
|
||||||
|
**Likely Cause**: Roll history not properly persisting or timestamp filtering issue
|
||||||
|
|
||||||
|
### 3. Integration Test Connection Conflicts
|
||||||
|
|
||||||
|
**Issue**: 49 integration test errors due to AsyncPG connection conflicts
|
||||||
|
|
||||||
|
**Status**: **Known infrastructure issue** - not code bugs
|
||||||
|
|
||||||
|
**When It Matters**: Only when running multiple integration tests in sequence
|
||||||
|
|
||||||
|
**When It Doesn't**: Unit tests (474 passing) validate business logic
|
||||||
|
|
||||||
|
### 4. Event Loop Scope Mismatch
|
||||||
|
|
||||||
|
**Issue**: `tests/integration/test_state_persistence.py` - all 7 tests fail with scope mismatch
|
||||||
|
|
||||||
|
**Error**: `ScopeMismatch: You tried to access the function scoped fixture event_loop with a module scoped request object`
|
||||||
|
|
||||||
|
**Root Cause**: `setup_database` fixture is `scope="module"` but depends on `event_loop` which is `scope="function"`
|
||||||
|
|
||||||
|
**Fix**: Change `setup_database` to `scope="function"` or create module-scoped event loop
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
**Current Status** (as of 2025-10-31):
|
||||||
|
- ✅ **474 unit tests passing** (91% of unit tests)
|
||||||
|
- ❌ **14 unit tests failing** (player models + 1 dice test)
|
||||||
|
- ❌ **49 integration test errors** (connection conflicts)
|
||||||
|
- ❌ **28 integration test failures** (various)
|
||||||
|
|
||||||
|
**Coverage by Module**:
|
||||||
|
```
|
||||||
|
app/config/ ✅ 58/58 tests passing
|
||||||
|
app/core/game_engine.py ✅ Well covered (unit tests)
|
||||||
|
app/core/state_manager.py ✅ 26/26 tests passing
|
||||||
|
app/core/dice.py ⚠️ 1 failure (roll history)
|
||||||
|
app/models/game_models.py ✅ 60/60 tests passing
|
||||||
|
app/models/player_models.py ❌ 13/32 tests failing
|
||||||
|
app/database/operations.py ⚠️ Integration tests have infrastructure issues
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Best Practices
|
||||||
|
|
||||||
|
### DO
|
||||||
|
|
||||||
|
- ✅ Write unit tests first (fast, reliable, no DB)
|
||||||
|
- ✅ Use descriptive test names: `test_game_ends_after_27_outs`
|
||||||
|
- ✅ Follow Arrange-Act-Assert pattern
|
||||||
|
- ✅ Use fixtures for common setup
|
||||||
|
- ✅ Test edge cases and error conditions
|
||||||
|
- ✅ Mock external dependencies (API calls, time, random)
|
||||||
|
- ✅ Keep tests independent (no shared state)
|
||||||
|
- ✅ Run unit tests frequently during development
|
||||||
|
|
||||||
|
### DON'T
|
||||||
|
|
||||||
|
- ❌ Don't run all integration tests at once (connection conflicts)
|
||||||
|
- ❌ Don't share state between tests
|
||||||
|
- ❌ Don't test implementation details (test behavior)
|
||||||
|
- ❌ Don't use real API calls in tests (use mocks)
|
||||||
|
- ❌ Don't depend on test execution order
|
||||||
|
- ❌ Don't skip writing tests because integration tests are flaky
|
||||||
|
|
||||||
|
### Mocking Examples
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unittest.mock import Mock, patch, AsyncMock
|
||||||
|
|
||||||
|
# Mock database operations
|
||||||
|
@patch('app.core.game_engine.DatabaseOperations')
|
||||||
|
def test_with_mock_db(mock_db_class):
|
||||||
|
mock_db = Mock()
|
||||||
|
mock_db_class.return_value = mock_db
|
||||||
|
mock_db.create_game = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
# Test code that uses DatabaseOperations
|
||||||
|
...
|
||||||
|
|
||||||
|
# Mock dice rolls for deterministic tests
|
||||||
|
@patch('app.core.dice.DiceSystem.roll_d20')
|
||||||
|
def test_with_fixed_roll(mock_roll):
|
||||||
|
mock_roll.return_value = 15
|
||||||
|
# Test code expecting roll of 15
|
||||||
|
...
|
||||||
|
|
||||||
|
# Mock Pendulum time
|
||||||
|
with time_machine.travel("2025-10-31 12:00:00", tick=False):
|
||||||
|
# Test time-dependent code
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debugging Failed Tests
|
||||||
|
|
||||||
|
### Verbose Output
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show full output including print statements
|
||||||
|
pytest tests/unit/core/test_game_engine.py -v -s
|
||||||
|
|
||||||
|
# Show local variables on failure
|
||||||
|
pytest tests/unit/core/test_game_engine.py -v -l
|
||||||
|
|
||||||
|
# Stop on first failure
|
||||||
|
pytest tests/unit/core/test_game_engine.py -v -x
|
||||||
|
|
||||||
|
# Show full traceback
|
||||||
|
pytest tests/unit/core/test_game_engine.py -v --tb=long
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interactive Debugging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Drop into debugger on failure
|
||||||
|
pytest tests/unit/core/test_game_engine.py --pdb
|
||||||
|
|
||||||
|
# Drop into debugger on first failure
|
||||||
|
pytest tests/unit/core/test_game_engine.py --pdb -x
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logging in Tests
|
||||||
|
|
||||||
|
Tests capture logs by default. View with `-o log_cli=true`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/unit/core/test_game_engine.py -v -o log_cli=true -o log_cli_level=DEBUG
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD Considerations
|
||||||
|
|
||||||
|
**Recommended CI Test Strategy**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Run fast unit tests on every commit
|
||||||
|
- name: Unit Tests
|
||||||
|
run: pytest tests/unit/ -v --cov=app
|
||||||
|
|
||||||
|
# Run integration tests serially (slower but reliable)
|
||||||
|
- name: Integration Tests
|
||||||
|
run: |
|
||||||
|
pytest tests/integration/database/test_operations.py::TestDatabaseOperationsGame -v
|
||||||
|
pytest tests/integration/database/test_operations.py::TestDatabaseOperationsLineup -v
|
||||||
|
# ... run each test class separately
|
||||||
|
```
|
||||||
|
|
||||||
|
**OR** fix the integration test infrastructure first, then run normally.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "cannot perform operation: another operation is in progress"
|
||||||
|
|
||||||
|
**Solution**: Run integration tests individually or fix fixture scopes
|
||||||
|
|
||||||
|
### "Task got Future attached to a different loop"
|
||||||
|
|
||||||
|
**Solution**: Ensure all fixtures use `scope="function"` or create proper module-scoped event loop
|
||||||
|
|
||||||
|
### "No module named 'app'"
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
```bash
|
||||||
|
# Set PYTHONPATH
|
||||||
|
export PYTHONPATH=/mnt/NV2/Development/strat-gameplay-webapp/backend
|
||||||
|
|
||||||
|
# Or run from backend directory
|
||||||
|
cd /mnt/NV2/Development/strat-gameplay-webapp/backend
|
||||||
|
pytest tests/unit/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests hang indefinitely
|
||||||
|
|
||||||
|
**Likely Cause**: Async test without proper event loop cleanup
|
||||||
|
|
||||||
|
**Solution**: Check fixture scopes and ensure `asyncio_mode = auto` in pytest.ini
|
||||||
|
|
||||||
|
### Database connection errors
|
||||||
|
|
||||||
|
**Check**:
|
||||||
|
1. PostgreSQL is running: `psql $DATABASE_URL`
|
||||||
|
2. `.env` has correct `DATABASE_URL`
|
||||||
|
3. Database exists and schema is migrated: `alembic upgrade head`
|
||||||
|
|
||||||
|
## Integration Test Refactor TODO
|
||||||
|
|
||||||
|
When refactoring integration tests to fix connection conflicts:
|
||||||
|
|
||||||
|
1. **Update fixtures in `tests/integration/conftest.py`**:
|
||||||
|
- Change all fixtures to `scope="function"`
|
||||||
|
- Ensure each test gets fresh database session
|
||||||
|
- Implement proper session cleanup
|
||||||
|
|
||||||
|
2. **Add connection pooling**:
|
||||||
|
- Consider using separate connection pool for tests
|
||||||
|
- Or create new engine per test (slower but isolated)
|
||||||
|
|
||||||
|
3. **Add transaction rollback**:
|
||||||
|
- Wrap each test in transaction
|
||||||
|
- Rollback after test completes
|
||||||
|
- Ensures database is clean for next test
|
||||||
|
|
||||||
|
4. **Consider pytest-xdist**:
|
||||||
|
- Run tests in parallel with proper worker isolation
|
||||||
|
- Each worker gets own database connection
|
||||||
|
- Faster test execution
|
||||||
|
|
||||||
|
5. **Update `test_state_persistence.py`**:
|
||||||
|
- Fix `setup_database` fixture scope mismatch
|
||||||
|
- Consider splitting into smaller fixtures
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- **pytest-asyncio docs**: https://pytest-asyncio.readthedocs.io/
|
||||||
|
- **AsyncPG docs**: https://magicstack.github.io/asyncpg/
|
||||||
|
- **SQLAlchemy async**: https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html
|
||||||
|
- **Backend CLAUDE.md**: `../CLAUDE.md` - Main backend documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Summary**: Unit tests are solid (91% passing), integration tests have known infrastructure issues that need fixture refactoring. Focus on unit tests for development, fix integration test infrastructure as separate task.
|
||||||
@ -416,22 +416,33 @@ class TestManualOutcomeSubmission:
|
|||||||
def test_valid_outcome_with_location(self):
|
def test_valid_outcome_with_location(self):
|
||||||
"""Test valid submission with outcome and location"""
|
"""Test valid submission with outcome and location"""
|
||||||
submission = ManualOutcomeSubmission(
|
submission = ManualOutcomeSubmission(
|
||||||
outcome='groundball_c',
|
outcome=PlayOutcome.GROUNDBALL_C,
|
||||||
hit_location='SS'
|
hit_location='SS'
|
||||||
)
|
)
|
||||||
|
|
||||||
assert submission.outcome == 'groundball_c'
|
assert submission.outcome == PlayOutcome.GROUNDBALL_C
|
||||||
assert submission.hit_location == 'SS'
|
assert submission.hit_location == 'SS'
|
||||||
|
|
||||||
def test_valid_outcome_without_location(self):
|
def test_valid_outcome_without_location(self):
|
||||||
"""Test valid submission without location"""
|
"""Test valid submission without location"""
|
||||||
submission = ManualOutcomeSubmission(
|
submission = ManualOutcomeSubmission(
|
||||||
outcome='strikeout'
|
outcome=PlayOutcome.STRIKEOUT
|
||||||
)
|
)
|
||||||
|
|
||||||
assert submission.outcome == 'strikeout'
|
assert submission.outcome == PlayOutcome.STRIKEOUT
|
||||||
assert submission.hit_location is None
|
assert submission.hit_location is None
|
||||||
|
|
||||||
|
def test_valid_outcome_from_string(self):
|
||||||
|
"""Test Pydantic converts string to enum automatically"""
|
||||||
|
submission = ManualOutcomeSubmission(
|
||||||
|
outcome='groundball_c',
|
||||||
|
hit_location='SS'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pydantic converts string to enum
|
||||||
|
assert submission.outcome == PlayOutcome.GROUNDBALL_C
|
||||||
|
assert submission.hit_location == 'SS'
|
||||||
|
|
||||||
def test_invalid_outcome_raises_error(self):
|
def test_invalid_outcome_raises_error(self):
|
||||||
"""Test invalid outcome value raises ValidationError"""
|
"""Test invalid outcome value raises ValidationError"""
|
||||||
with pytest.raises(ValidationError) as exc_info:
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
@ -443,7 +454,7 @@ class TestManualOutcomeSubmission:
|
|||||||
"""Test invalid hit location raises ValidationError"""
|
"""Test invalid hit location raises ValidationError"""
|
||||||
with pytest.raises(ValidationError) as exc_info:
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
ManualOutcomeSubmission(
|
ManualOutcomeSubmission(
|
||||||
outcome='groundball_c',
|
outcome=PlayOutcome.GROUNDBALL_C,
|
||||||
hit_location='INVALID'
|
hit_location='INVALID'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -455,7 +466,7 @@ class TestManualOutcomeSubmission:
|
|||||||
|
|
||||||
for location in valid_locations:
|
for location in valid_locations:
|
||||||
submission = ManualOutcomeSubmission(
|
submission = ManualOutcomeSubmission(
|
||||||
outcome='groundball_c',
|
outcome=PlayOutcome.GROUNDBALL_C,
|
||||||
hit_location=location
|
hit_location=location
|
||||||
)
|
)
|
||||||
assert submission.hit_location == location
|
assert submission.hit_location == location
|
||||||
@ -463,7 +474,7 @@ class TestManualOutcomeSubmission:
|
|||||||
def test_none_hit_location_is_valid(self):
|
def test_none_hit_location_is_valid(self):
|
||||||
"""Test None for hit_location is valid"""
|
"""Test None for hit_location is valid"""
|
||||||
submission = ManualOutcomeSubmission(
|
submission = ManualOutcomeSubmission(
|
||||||
outcome='strikeout',
|
outcome=PlayOutcome.STRIKEOUT,
|
||||||
hit_location=None
|
hit_location=None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user