diff --git a/backend/app/api/CLAUDE.md b/backend/app/api/CLAUDE.md new file mode 100644 index 0000000..498a55f --- /dev/null +++ b/backend/app/api/CLAUDE.md @@ -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 ` +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 diff --git a/backend/app/config/CLAUDE.md b/backend/app/config/CLAUDE.md new file mode 100644 index 0000000..b3f6dec --- /dev/null +++ b/backend/app/config/CLAUDE.md @@ -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` diff --git a/backend/app/core/CLAUDE.md b/backend/app/core/CLAUDE.md new file mode 100644 index 0000000..b61295c --- /dev/null +++ b/backend/app/core/CLAUDE.md @@ -0,0 +1,1288 @@ +# Core - Game Engine & Logic + +## Purpose + +The `core` directory contains the heart of the baseball simulation engine. It orchestrates complete gameplay flow from dice rolls to play resolution, managing in-memory game state for fast real-time performance (<500ms response target). + +**Key Responsibilities**: +- In-memory state management (StateManager) +- Game orchestration and workflow (GameEngine) +- Cryptographic dice rolling (DiceSystem) +- Play outcome resolution (PlayResolver) +- Runner advancement logic (RunnerAdvancement) +- Rule validation (GameValidator) +- AI opponent decision-making (AIOpponent) + +## Architecture Overview + +### Data Flow + +``` +Player Action (WebSocket) + ↓ +GameEngine.submit_defensive_decision() +GameEngine.submit_offensive_decision() + ↓ +GameEngine.resolve_play() + ↓ +DiceSystem.roll_ab() → AbRoll (1d6 + 2d6 + 2d20) + ↓ +PlayResolver.resolve_outcome() + ├─ Manual mode: Use player-submitted outcome + └─ Auto mode: Generate from PdPlayer ratings + ↓ +RunnerAdvancement.advance_runners() (for groundballs) + ├─ Determine result (1-13) based on situation + ├─ Execute result (movements, outs, runs) + └─ Return AdvancementResult + ↓ +GameEngine._apply_play_result() → Update state + ↓ +GameEngine._save_play_to_db() → Persist play + ↓ +StateManager.update_state() → Cache updated state + ↓ +WebSocket broadcast → All clients +``` + +### Component Relationships + +``` +StateManager (singleton) + ├─ Stores: Dict[UUID, GameState] + ├─ Stores: Dict[UUID, Dict[int, TeamLineupState]] + └─ Provides: O(1) lookups, recovery from DB + +GameEngine (singleton) + ├─ Uses: StateManager for state + ├─ Uses: PlayResolver for outcomes + ├─ Uses: RunnerAdvancement (via PlayResolver) + ├─ Uses: DiceSystem for rolls + ├─ Uses: GameValidator for rules + ├─ Uses: AIOpponent for AI decisions + └─ Uses: DatabaseOperations for persistence + +PlayResolver + ├─ Uses: DiceSystem for rolls + ├─ Uses: RunnerAdvancement for groundballs + ├─ Uses: PdAutoResultChart (auto mode only) + └─ Returns: PlayResult + +RunnerAdvancement + ├─ Uses: GameState for context + ├─ Uses: DefensiveDecision for positioning + └─ Returns: AdvancementResult with movements + +DiceSystem (singleton) + ├─ Uses: secrets module (cryptographic) + ├─ Maintains: roll_history for auditing + └─ Returns: Typed roll objects (AbRoll, JumpRoll, etc.) +``` + +## Core Modules + +### 1. state_manager.py + +**Purpose**: In-memory game state management with O(1) lookups + +**Key Classes**: +- `StateManager`: Singleton managing all active game states + +**Storage**: +```python +_states: Dict[UUID, GameState] # O(1) state lookup +_lineups: Dict[UUID, Dict[int, TeamLineupState]] # Cached lineups +_last_access: Dict[UUID, pendulum.DateTime] # For eviction +_pending_decisions: Dict[tuple, asyncio.Future] # Phase 3 async decisions +``` + +**Common Methods**: +```python +# Create new game +state = await state_manager.create_game(game_id, league_id, home_id, away_id) + +# Get state (fast O(1)) +state = state_manager.get_state(game_id) + +# Update state +state_manager.update_state(game_id, state) + +# Lineup caching +state_manager.set_lineup(game_id, team_id, lineup_state) +lineup = state_manager.get_lineup(game_id, team_id) + +# Recovery from database +state = await state_manager.recover_game(game_id) + +# Memory management +evicted_count = state_manager.evict_idle_games(idle_minutes=60) +``` + +**Performance**: +- State access: O(1) dictionary lookup +- Memory per game: ~1-2KB (without player data) +- Evicts idle games after configurable timeout + +**Recovery Strategy**: +- Uses last completed play to rebuild runner positions +- Extracts on_first_final, on_second_final, on_third_final, batter_final +- Reconstructs batter indices (corrected by _prepare_next_play) +- No play replay needed (efficient recovery) + +--- + +### 2. game_engine.py + +**Purpose**: Main orchestrator coordinating complete gameplay workflow + +**Key Classes**: +- `GameEngine`: Singleton handling game lifecycle and play resolution + +**Core Workflow** (resolve_play): +```python +# STEP 1: Resolve play with dice rolls +result = resolver.resolve_outcome(outcome, hit_location, state, decisions, ab_roll) + +# STEP 2: Save play to DB (uses snapshot from GameState) +await self._save_play_to_db(state, result) + +# STEP 3: Apply result to state (outs, score, runners) +self._apply_play_result(state, result) + +# STEP 4: Update game state in DB (only if changed) +if state_changed: + await self.db_ops.update_game_state(...) + +# STEP 5: Check for inning change +if state.outs >= 3: + await self._advance_inning(state, game_id) + +# STEP 6: Prepare next play (always last step) +await self._prepare_next_play(state) +``` + +**Key Methods**: + +```python +# Game lifecycle +await game_engine.start_game(game_id) +await game_engine.end_game(game_id) + +# Decision submission +await game_engine.submit_defensive_decision(game_id, decision) +await game_engine.submit_offensive_decision(game_id, decision) + +# Play resolution +result = await game_engine.resolve_play(game_id, forced_outcome=None) +result = await game_engine.resolve_manual_play(game_id, ab_roll, outcome, hit_location) + +# Phase 3: Async decision awaiting +defensive_dec = await game_engine.await_defensive_decision(state, timeout=30) +offensive_dec = await game_engine.await_offensive_decision(state, timeout=30) + +# State management +state = await game_engine.get_game_state(game_id) +state = await game_engine.rollback_plays(game_id, num_plays) +``` + +**_prepare_next_play()**: Critical method that: +1. Advances batting order index (with wraparound) +2. Fetches/caches active lineups +3. Sets snapshot fields: current_batter/pitcher/catcher_lineup_id +4. Calculates on_base_code bit field +5. Used by _save_play_to_db() for Play record + +**Optimization Notes**: +- Lineup caching eliminates redundant DB queries +- Conditional state updates (only when score/inning/status changes) +- Batch roll saving at inning boundaries +- 60% query reduction vs naive implementation + +--- + +### 3. play_resolver.py + +**Purpose**: Resolves play outcomes based on dice rolls and game state + +**Architecture**: Outcome-first design +- **Manual mode** (primary): Players submit outcomes after reading physical cards +- **Auto mode** (rare): System generates outcomes from digitized ratings (PD only) + +**Key Classes**: +- `PlayResolver`: Core resolution logic +- `PlayResult`: Complete outcome with statistics + +**Initialization**: +```python +# Manual mode (SBA + PD manual) +resolver = PlayResolver(league_id='sba', auto_mode=False) + +# Auto mode (PD only, rare) +resolver = PlayResolver(league_id='pd', auto_mode=True) +``` + +**Resolution Methods**: + +```python +# MANUAL: Player submits outcome from physical card +result = resolver.resolve_manual_play( + submission=ManualOutcomeSubmission(outcome='single_1', hit_location='CF'), + state=state, + defensive_decision=def_dec, + offensive_decision=off_dec, + ab_roll=ab_roll +) + +# AUTO: System generates outcome (PD only) +result = resolver.resolve_auto_play( + state=state, + batter=pd_player, + pitcher=pd_pitcher, + defensive_decision=def_dec, + offensive_decision=off_dec +) + +# CORE: All resolution logic lives here +result = resolver.resolve_outcome( + outcome=PlayOutcome.GROUNDBALL_A, + hit_location='SS', + state=state, + defensive_decision=def_dec, + offensive_decision=off_dec, + ab_roll=ab_roll +) +``` + +**PlayResult Structure**: +```python +@dataclass +class PlayResult: + outcome: PlayOutcome + outs_recorded: int + runs_scored: int + batter_result: Optional[int] # None=out, 1-4=base + runners_advanced: List[tuple[int, int]] # [(from, to), ...] + description: str + ab_roll: AbRoll + hit_location: Optional[str] + # Statistics + is_hit: bool + is_out: bool + is_walk: bool +``` + +**Groundball Integration**: +- Delegates all groundball outcomes to RunnerAdvancement +- Converts AdvancementResult to PlayResult format +- Extracts batter movement from RunnerMovement list + +**Supported Outcomes**: +- Strikeouts +- Groundballs (A, B, C) → delegates to RunnerAdvancement +- Flyouts (A, B, C) +- Lineouts +- Walks +- Singles (1, 2, uncapped) +- Doubles (2, 3, uncapped) +- Triples +- Home runs +- Wild pitch / Passed ball + +--- + +### 4. runner_advancement.py + +**Purpose**: Implements complete groundball runner advancement system + +**Key Classes**: +- `RunnerAdvancement`: Main logic handler +- `GroundballResultType`: Enum of 13 result types (matches rulebook) +- `RunnerMovement`: Single runner's movement +- `AdvancementResult`: Complete result with all movements + +**Result Types** (1-13): +```python +1: BATTER_OUT_RUNNERS_HOLD +2: DOUBLE_PLAY_AT_SECOND +3: BATTER_OUT_RUNNERS_ADVANCE +4: BATTER_SAFE_FORCE_OUT_AT_SECOND +5: CONDITIONAL_ON_MIDDLE_INFIELD +6: CONDITIONAL_ON_RIGHT_SIDE +7: BATTER_OUT_FORCED_ONLY +8: BATTER_OUT_FORCED_ONLY_ALT +9: LEAD_HOLDS_TRAIL_ADVANCES +10: DOUBLE_PLAY_HOME_TO_FIRST +11: BATTER_SAFE_LEAD_OUT +12: DECIDE_OPPORTUNITY +13: CONDITIONAL_DOUBLE_PLAY +``` + +**Usage**: +```python +runner_advancement = RunnerAdvancement() + +result = runner_advancement.advance_runners( + outcome=PlayOutcome.GROUNDBALL_A, + hit_location='SS', + state=state, + defensive_decision=defensive_decision +) + +# Result contains: +result.movements # List[RunnerMovement] +result.outs_recorded # int +result.runs_scored # int +result.result_type # GroundballResultType +result.description # str +``` + +**Chart Selection Logic**: +``` +Special case: 2 outs → Result 1 (batter out, runners hold) + +Infield In (runner on 3rd scenarios): + - Applies when: defensive_decision.infield_depth == "infield_in" + - Affects bases: 3, 5, 6, 7 (any scenario with runner on 3rd) + - Uses: _apply_infield_in_chart() + +Corners In (hybrid approach): + - Applies when: defensive_decision.infield_depth == "corners_in" + - Only for corner hits: P, C, 1B, 3B + - Middle infield (2B, SS) uses Infield Back + +Infield Back (default): + - Normal defensive positioning + - Uses: _apply_infield_back_chart() +``` + +**Double Play Mechanics**: +- Base probability: 45% +- Positioning modifiers: infield_in -15% +- Hit location modifiers: up middle +10%, corners -10% +- TODO: Runner speed modifiers when ratings available + +**DECIDE Opportunity** (Result 12): +- Lead runner can attempt to advance +- Offense chooses whether to attempt +- Defense responds (take sure out at 1st OR throw to lead runner) +- Currently simplified (conservative default) +- TODO: Interactive decision-making via WebSocket + +--- + +### 5. dice.py + +**Purpose**: Cryptographically secure dice rolling system + +**Key Classes**: +- `DiceSystem`: Singleton dice roller + +**Roll Types**: +- `AbRoll`: At-bat (1d6 + 2d6 + 2d20) +- `JumpRoll`: Stolen base attempt +- `FieldingRoll`: Defensive play (1d20 + 3d6 + 1d100) +- `D20Roll`: Generic d20 + +**Usage**: +```python +from app.core.dice import dice_system + +# At-bat roll +ab_roll = dice_system.roll_ab(league_id='sba', game_id=game_id) +# Returns: AbRoll with d6_one, d6_two_a, d6_two_b, chaos_d20, resolution_d20 + +# Jump roll (stolen base) +jump_roll = dice_system.roll_jump(league_id='sba', game_id=game_id) + +# Fielding roll +fielding_roll = dice_system.roll_fielding(position='SS', league_id='sba', game_id=game_id) + +# Generic d20 +d20_roll = dice_system.roll_d20(league_id='sba', game_id=game_id) +``` + +**Roll History**: +```python +# Get recent rolls +rolls = dice_system.get_roll_history(roll_type=RollType.AB, game_id=game_id, limit=10) + +# Get rolls since timestamp (for batch saving) +rolls = dice_system.get_rolls_since(game_id, since_timestamp) + +# Verify roll authenticity +is_valid = dice_system.verify_roll(roll_id) + +# Statistics +stats = dice_system.get_distribution_stats(roll_type=RollType.AB) +``` + +**Security**: +- Uses Python's `secrets` module (cryptographically secure) +- Unique roll_id for each roll (16 char hex) +- Complete audit trail in roll history +- Cannot be predicted or manipulated + +--- + +### 6. validators.py + +**Purpose**: Enforce baseball rules and validate game actions + +**Key Classes**: +- `GameValidator`: Static validation methods +- `ValidationError`: Custom exception for rule violations + +**Common Validations**: +```python +from app.core.validators import game_validator, ValidationError + +# Game status +game_validator.validate_game_active(state) + +# Defensive decision +game_validator.validate_defensive_decision(decision, state) +# Checks: +# - Valid depth choices +# - Can't hold runner on empty base +# - Infield in/corners in requires runner on 3rd + +# Offensive decision +game_validator.validate_offensive_decision(decision, state) +# Checks: +# - Valid approach +# - Can't steal from empty base +# - Can't bunt with 2 outs +# - Hit-and-run requires runner on base + +# Defensive lineup +game_validator.validate_defensive_lineup_positions(lineup) +# Checks: +# - Exactly 1 active player per position (P, C, 1B, 2B, 3B, SS, LF, CF, RF) +# - Called at game start and start of each half-inning + +# Game state +can_continue = game_validator.can_continue_inning(state) # outs < 3 +is_over = game_validator.is_game_over(state) # inning >= 9 and score not tied +``` + +--- + +### 7. ai_opponent.py + +**Purpose**: AI decision-making for AI-controlled teams + +**Status**: Week 7 stub implementation (full AI in Week 9) + +**Key Classes**: +- `AIOpponent`: Decision generator + +**Current Implementation**: +```python +from app.core.ai_opponent import ai_opponent + +# Generate defensive decision +decision = await ai_opponent.generate_defensive_decision(state) +# Returns: DefensiveDecision with default "normal" settings + +# Generate offensive decision +decision = await ai_opponent.generate_offensive_decision(state) +# Returns: OffensiveDecision with default "normal" approach +``` + +**Difficulty Levels**: +- `"balanced"`: Standard decision-making (current default) +- `"yolo"`: Aggressive playstyle (more risks) - TODO Week 9 +- `"safe"`: Conservative playstyle (fewer risks) - TODO Week 9 + +**TODO Week 9**: +- Analyze batter tendencies for defensive positioning +- Consider runner speed for hold decisions +- Evaluate double play opportunities +- Implement stealing logic based on runner speed, pitcher hold, catcher arm +- Implement bunting decisions +- Adjust strategies based on game situation (score, inning, outs) + +--- + +### 8. roll_types.py + +**Purpose**: Type-safe dice roll data structures + +**Key Classes**: +- `RollType`: Enum of roll types (AB, JUMP, FIELDING, D20) +- `DiceRoll`: Base class with auditing fields +- `AbRoll`: At-bat roll (1d6 + 2d6 + 2d20) +- `JumpRoll`: Baserunning roll +- `FieldingRoll`: Defensive play roll +- `D20Roll`: Generic d20 roll + +**AbRoll Structure**: +```python +@dataclass(kw_only=True) +class AbRoll(DiceRoll): + # Required dice + d6_one: int # 1-6 + d6_two_a: int # 1-6 + d6_two_b: int # 1-6 + chaos_d20: int # 1-20 (1=WP, 2=PB, 3+=normal) + resolution_d20: int # 1-20 (for WP/PB resolution or split results) + + # Derived values + d6_two_total: int # Sum of 2d6 + check_wild_pitch: bool # chaos_d20 == 1 + check_passed_ball: bool # chaos_d20 == 2 +``` + +**Roll Flow**: +``` +1. Roll chaos_d20 first +2. If chaos_d20 == 1: Check wild pitch (use resolution_d20) +3. If chaos_d20 == 2: Check passed ball (use resolution_d20) +4. If chaos_d20 >= 3: Normal at-bat (use chaos_d20 for result, resolution_d20 for splits) +``` + +**Auditing Fields** (on all rolls): +```python +roll_id: str # Unique cryptographic ID +roll_type: RollType # AB, JUMP, FIELDING, D20 +league_id: str # 'sba' or 'pd' +timestamp: pendulum.DateTime +game_id: Optional[UUID] +team_id: Optional[int] +player_id: Optional[int] # Polymorphic: player_id (SBA) or card_id (PD) +context: Optional[Dict] # Additional metadata (JSONB) +``` + +## Common Patterns + +### 1. Async Database Operations + +All database operations use async/await and don't block game logic: + +```python +# Write operations are fire-and-forget +await self.db_ops.save_play(play_data) +await self.db_ops.update_game_state(...) + +# But still use try/except for error handling +try: + await self.db_ops.save_play(play_data) +except Exception as e: + logger.error(f"Failed to save play: {e}") + # Game continues - play is in memory +``` + +### 2. State Snapshot Pattern + +GameEngine prepares a snapshot BEFORE each play for database persistence: + +```python +# BEFORE play +await self._prepare_next_play(state) # Sets snapshot fields + +# Snapshot fields used by _save_play_to_db(): +state.current_batter_lineup_id # Who's batting +state.current_pitcher_lineup_id # Who's pitching +state.current_catcher_lineup_id # Who's catching +state.current_on_base_code # Bit field (1=1st, 2=2nd, 4=3rd, 7=loaded) + +# AFTER play +result = await self.resolve_play(...) +await self._save_play_to_db(state, result) # Uses snapshot +``` + +### 3. Orchestration Sequence + +GameEngine always follows this sequence: + +```python +# STEP 1: Resolve play +result = resolver.resolve_outcome(...) + +# STEP 2: Save play to DB (uses snapshot) +await self._save_play_to_db(state, result) + +# STEP 3: Apply result to state +self._apply_play_result(state, result) + +# STEP 4: Update game state in DB (conditional) +if state_changed: + await self.db_ops.update_game_state(...) + +# STEP 5: Check for inning change +if state.outs >= 3: + await self._advance_inning(state, game_id) + # Batch save rolls at half-inning boundary + await self._batch_save_inning_rolls(game_id) + +# STEP 6: Prepare next play (always last) +await self._prepare_next_play(state) +``` + +### 4. Lineup Caching + +StateManager caches lineups to avoid redundant DB queries: + +```python +# First access - fetches from DB +lineup = await db_ops.get_active_lineup(game_id, team_id) +lineup_state = TeamLineupState(team_id=team_id, players=[...]) +state_manager.set_lineup(game_id, team_id, lineup_state) + +# Subsequent accesses - uses cache +lineup_state = state_manager.get_lineup(game_id, team_id) + +# Cache persists for entire game +``` + +### 5. Error Handling Pattern + +Core modules use "Raise or Return" pattern: + +```python +# ✅ DO: Raise exceptions for invalid states +if game_id not in self._states: + raise ValueError(f"Game {game_id} not found") + +# ✅ DO: Return None only when semantically valid +def get_state(self, game_id: UUID) -> Optional[GameState]: + return self._states.get(game_id) + +# ❌ DON'T: Return Optional[T] unless specifically required +def process_action(...) -> Optional[Result]: # Avoid this pattern +``` + +## Integration Points + +### With Database Layer + +```python +from app.database.operations import DatabaseOperations + +db_ops = DatabaseOperations() + +# Game CRUD +await db_ops.create_game(game_id, league_id, home_id, away_id, ...) +await db_ops.update_game_state(game_id, inning, half, home_score, away_score, status) + +# Play persistence +await db_ops.save_play(play_data) +plays = await db_ops.get_plays(game_id, limit=10) + +# Lineup management +lineup_id = await db_ops.create_lineup_entry(game_id, team_id, card_id, position, ...) +lineup = await db_ops.get_active_lineup(game_id, team_id) + +# State recovery +game_data = await db_ops.load_game_state(game_id) + +# Batch operations +await db_ops.save_rolls_batch(rolls) + +# Rollback +deleted = await db_ops.delete_plays_after(game_id, play_number) +deleted_subs = await db_ops.delete_substitutions_after(game_id, play_number) +``` + +### With Models + +```python +from app.models.game_models import ( + GameState, DefensiveDecision, OffensiveDecision, + LineupPlayerState, TeamLineupState, ManualOutcomeSubmission +) + +# Game state +state = GameState( + game_id=game_id, + league_id='sba', + home_team_id=1, + away_team_id=2 +) + +# Strategic decisions +def_decision = DefensiveDecision( + alignment='shifted_left', + infield_depth='normal', + outfield_depth='normal', + hold_runners=[3] +) + +off_decision = OffensiveDecision( + approach='power', + steal_attempts=[2], + hit_and_run=False, + bunt_attempt=False +) + +# Manual outcome submission +submission = ManualOutcomeSubmission( + outcome='groundball_a', + hit_location='SS' +) +``` + +### With Config + +```python +from app.config import PlayOutcome, get_league_config + +# Get league configuration +config = get_league_config(state.league_id) + +# Check league capabilities +supports_auto = config.supports_auto_mode() # PD: True, SBA: False + +# Play outcomes +outcome = PlayOutcome.GROUNDBALL_A + +# Outcome helpers +if outcome.is_hit(): + print("Hit!") +if outcome.is_uncapped(): + # Trigger advancement decision tree + pass +if outcome.requires_hit_location(): + # Must specify where ball was hit + pass +``` + +### With WebSocket (Future) + +```python +# TODO Week 7-8: WebSocket integration + +# Emit state updates +await connection_manager.broadcast_to_game( + game_id, + 'game_state_update', + state_data +) + +# Request decisions +await connection_manager.emit_decision_required( + game_id=game_id, + team_id=team_id, + decision_type='defensive', + timeout=30, + game_situation=state.to_situation_summary() +) + +# Broadcast play result +await connection_manager.broadcast_play_result( + game_id, + result_data +) +``` + +## Common Tasks + +### Starting a New Game + +```python +from app.core.game_engine import game_engine +from app.core.state_manager import state_manager + +# 1. Create game in database +game_id = uuid4() +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. Create state in memory +state = await state_manager.create_game( + game_id=game_id, + league_id='sba', + home_team_id=1, + away_team_id=2 +) + +# 3. Set up lineups (must have 9+ players, all positions filled) +# ... create lineup entries in database ... + +# 4. Start game (transitions from 'pending' to 'active') +state = await game_engine.start_game(game_id) +``` + +### Resolving a Play (Manual Mode) + +```python +# 1. Get decisions +await game_engine.submit_defensive_decision(game_id, defensive_decision) +await game_engine.submit_offensive_decision(game_id, offensive_decision) + +# 2. Player submits outcome from physical card +submission = ManualOutcomeSubmission( + outcome='groundball_a', + hit_location='SS' +) + +# 3. Server rolls dice for audit trail +ab_roll = dice_system.roll_ab(league_id='sba', game_id=game_id) + +# 4. Resolve play +result = await game_engine.resolve_manual_play( + game_id=game_id, + ab_roll=ab_roll, + outcome=PlayOutcome(submission.outcome), + hit_location=submission.hit_location +) + +# Result is automatically: +# - Saved to database +# - Applied to game state +# - Prepared for next play +``` + +### Resolving a Play (Auto Mode - PD Only) + +```python +# 1. Get decisions (same as manual) + +# 2. Fetch player data with ratings +batter = await api_client.get_pd_player(batter_id, include_ratings=True) +pitcher = await api_client.get_pd_player(pitcher_id, include_ratings=True) + +# 3. Create resolver in auto mode +resolver = PlayResolver(league_id='pd', auto_mode=True) + +# 4. Auto-generate outcome from ratings +result = resolver.resolve_auto_play( + state=state, + batter=batter, + pitcher=pitcher, + defensive_decision=defensive_decision, + offensive_decision=offensive_decision +) + +# 5. Apply to game engine (same as manual) +await game_engine.resolve_play(game_id) +``` + +### Adding a New PlayOutcome + +```python +# 1. Add to PlayOutcome enum (app/config/result_charts.py) +class PlayOutcome(str, Enum): + NEW_OUTCOME = "new_outcome" + +# 2. Add helper method if needed +def is_new_category(self) -> bool: + return self in [PlayOutcome.NEW_OUTCOME] + +# 3. Add resolution logic (app/core/play_resolver.py) +def resolve_outcome(self, outcome, ...): + # ... existing outcomes ... + + elif outcome == PlayOutcome.NEW_OUTCOME: + # Calculate movements + runners_advanced = self._advance_on_new_outcome(state) + runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4) + + return PlayResult( + outcome=outcome, + outs_recorded=..., + runs_scored=runs_scored, + batter_result=..., + runners_advanced=runners_advanced, + description="...", + ab_roll=ab_roll, + is_hit=True/False + ) + +# 4. Add advancement helper method +def _advance_on_new_outcome(self, state: GameState) -> List[tuple[int, int]]: + advances = [] + # ... calculate runner movements ... + return advances + +# 5. Add unit tests (tests/unit/core/test_play_resolver.py) +``` + +### Recovering a Game After Restart + +```python +# 1. Load game from database +state = await state_manager.recover_game(game_id) + +# 2. State is automatically: +# - Rebuilt from last completed play +# - Runner positions recovered +# - Batter indices set +# - Cached in memory + +# 3. Game ready to continue +# (Next call to _prepare_next_play will correct batter index if needed) +``` + +### Rolling Back Plays + +```python +# Roll back last 3 plays +state = await game_engine.rollback_plays(game_id, num_plays=3) + +# This automatically: +# - Deletes plays from database +# - Deletes related substitutions +# - Recovers game state by replaying remaining plays +# - Updates in-memory state +``` + +### Debugging Game State + +```python +# Get current state +state = state_manager.get_state(game_id) + +# Print state details +print(f"Inning {state.inning} {state.half}") +print(f"Score: {state.away_score} - {state.home_score}") +print(f"Outs: {state.outs}") +print(f"Runners: {state.get_all_runners()}") +print(f"Play count: {state.play_count}") + +# Check decisions +print(f"Pending: {state.pending_decision}") +print(f"Decisions: {state.decisions_this_play}") + +# Check batter +print(f"Batter lineup_id: {state.current_batter_lineup_id}") + +# Get statistics +stats = state_manager.get_stats() +print(f"Active games: {stats['active_games']}") +print(f"By league: {stats['games_by_league']}") +``` + +## Troubleshooting + +### Issue: Game state not found + +**Symptom**: `ValueError: Game {game_id} not found` + +**Causes**: +1. Game was evicted from memory (idle timeout) +2. Game never created in state manager +3. Server restarted + +**Solutions**: +```python +# Try recovery first +state = await state_manager.recover_game(game_id) + +# If still None, game doesn't exist in database +if not state: + # Create new game or return error to client +``` + +### Issue: outs_before incorrect in database + +**Symptom**: Play records show wrong outs_before value + +**Cause**: Play saved AFTER outs were applied to state + +**Solution**: This was fixed in 2025-10-28 update. Ensure `_save_play_to_db` is called in STEP 2 (before `_apply_play_result`). + +```python +# CORRECT sequence: +result = resolver.resolve_outcome(...) # STEP 1 +await self._save_play_to_db(state, result) # STEP 2 - outs not yet applied +self._apply_play_result(state, result) # STEP 3 - outs applied here +``` + +### Issue: Cannot find batter/pitcher in lineup + +**Symptom**: `ValueError: Cannot save play: batter_id is None` + +**Cause**: `_prepare_next_play()` not called before play resolution + +**Solution**: +```python +# Always call _prepare_next_play before resolving play +await self._prepare_next_play(state) + +# This is handled automatically by game_engine orchestration +# But check if you're calling resolver directly +``` + +### Issue: Runner positions lost after recovery + +**Symptom**: Recovered game has no runners on base but should have runners + +**Cause**: Last play was incomplete or not marked complete + +**Solution**: +```python +# Only complete plays are used for recovery +# Verify play was marked complete in database +play_data['complete'] = True + +# Check if last play exists +plays = await db_ops.get_plays(game_id, limit=1) +if not plays: + # No plays saved - fresh game + pass +``` + +### Issue: Lineup cache out of sync + +**Symptom**: Wrong player batting or incorrect defensive positions + +**Cause**: Lineup modified in database but cache not updated + +**Solution**: +```python +# After lineup changes, update cache +lineup = await db_ops.get_active_lineup(game_id, team_id) +lineup_state = TeamLineupState(team_id=team_id, players=[...]) +state_manager.set_lineup(game_id, team_id, lineup_state) + +# Or clear and recover game +state_manager.remove_game(game_id) +state = await state_manager.recover_game(game_id) +``` + +### Issue: Integration tests fail with connection errors + +**Symptom**: `asyncpg connection closed` or `event loop closed` + +**Cause**: Database connection pooling conflicts when tests run in parallel + +**Solution**: +```bash +# Run integration tests individually +pytest tests/integration/test_game_engine.py::TestGameEngine::test_resolve_play -v + +# Or use -x flag to stop on first failure +pytest tests/integration/test_game_engine.py -x -v +``` + +### Issue: Type errors with SQLAlchemy models + +**Symptom**: `Type "Column[int]" is not assignable to type "int | None"` + +**Cause**: Known false positive - SQLAlchemy Column is int at runtime + +**Solution**: Use targeted type: ignore comment +```python +state.current_batter_lineup_id = lineup_player.id # type: ignore[assignment] +``` + +See `backend/CLAUDE.md` section on Type Checking for comprehensive guidance. + +## Testing + +### Unit Tests + +```bash +# All core unit tests +pytest tests/unit/core/ -v + +# Specific module +pytest tests/unit/core/test_game_engine.py -v +pytest tests/unit/core/test_play_resolver.py -v +pytest tests/unit/core/test_runner_advancement.py -v +pytest tests/unit/core/test_state_manager.py -v + +# With coverage +pytest tests/unit/core/ --cov=app.core --cov-report=html +``` + +### Integration Tests + +```bash +# All core integration tests +pytest tests/integration/test_state_persistence.py -v + +# Run individually (recommended due to connection pooling) +pytest tests/integration/test_game_engine.py::TestGameEngine::test_complete_game -v +``` + +### Terminal Client + +Best tool for testing game engine in isolation: + +```bash +# Start REPL +python -m terminal_client + +# Create and play game +⚾ > new_game +⚾ > defensive +⚾ > offensive +⚾ > resolve +⚾ > status +⚾ > quick_play 10 +⚾ > quit +``` + +See `terminal_client/CLAUDE.md` for full documentation. + +## Performance Notes + +### Current Performance + +- State access: O(1) dictionary lookup (~1μs) +- Play resolution: 50-100ms (includes DB write) +- Lineup cache: Eliminates 2 SELECT queries per play +- Conditional updates: ~40-60% fewer UPDATE queries + +### Optimization History + +**2025-10-28: 60% Query Reduction** +- Before: 5 queries per play +- After: 2 queries per play (INSERT + UPDATE) +- Changes: + - Added lineup caching + - Removed unnecessary refresh after save + - Direct UPDATE statements + - Conditional game state updates + +### Memory Usage + +- GameState: ~1-2KB per game +- TeamLineupState: ~500 bytes per team +- Roll history: ~200 bytes per roll +- Total per active game: ~3-5KB + +### Scaling Targets + +- Support: 10+ simultaneous games +- Memory: <1GB with 10 active games +- Response: <500ms action to state update +- Recovery: <2 seconds from database + +## Examples + +### Complete Game Flow + +```python +from uuid import uuid4 +from app.core.game_engine import game_engine +from app.core.state_manager import state_manager +from app.models.game_models import DefensiveDecision, OffensiveDecision +from app.config import PlayOutcome + +# 1. Create game +game_id = uuid4() +await db_ops.create_game(game_id, 'sba', home_id=1, away_id=2) +state = await state_manager.create_game(game_id, 'sba', 1, 2) + +# 2. Set up lineups +# ... create 9+ players per team in database ... + +# 3. Start game +state = await game_engine.start_game(game_id) + +# 4. Submit decisions +def_dec = DefensiveDecision(alignment='normal', infield_depth='normal', outfield_depth='normal') +off_dec = OffensiveDecision(approach='normal') + +await game_engine.submit_defensive_decision(game_id, def_dec) +await game_engine.submit_offensive_decision(game_id, off_dec) + +# 5. Resolve play (manual mode) +ab_roll = dice_system.roll_ab('sba', game_id) +result = await game_engine.resolve_manual_play( + game_id=game_id, + ab_roll=ab_roll, + outcome=PlayOutcome.GROUNDBALL_A, + hit_location='SS' +) + +# 6. Check result +print(f"{result.description}") +print(f"Outs: {result.outs_recorded}, Runs: {result.runs_scored}") +print(f"Batter result: {result.batter_result}") + +# 7. Continue game loop +state = state_manager.get_state(game_id) +print(f"Score: {state.away_score} - {state.home_score}") +print(f"Inning {state.inning} {state.half}, {state.outs} outs") +``` + +### Groundball Resolution + +```python +from app.core.runner_advancement import RunnerAdvancement +from app.models.game_models import GameState, DefensiveDecision +from app.config import PlayOutcome + +# Set up situation +state = GameState(game_id=game_id, league_id='sba', ...) +state.on_first = LineupPlayerState(lineup_id=1, card_id=101, position='RF') +state.on_third = LineupPlayerState(lineup_id=2, card_id=102, position='CF') +state.outs = 1 +state.current_on_base_code = 5 # 1st and 3rd + +# Defensive positioning +def_dec = DefensiveDecision(infield_depth='infield_in') + +# Resolve groundball +runner_adv = RunnerAdvancement() +result = runner_adv.advance_runners( + outcome=PlayOutcome.GROUNDBALL_A, + hit_location='SS', + state=state, + defensive_decision=def_dec +) + +# Check result +print(f"Result type: {result.result_type}") # GroundballResultType.BATTER_OUT_FORCED_ONLY +print(f"Description: {result.description}") +print(f"Outs recorded: {result.outs_recorded}") +print(f"Runs scored: {result.runs_scored}") + +# Check movements +for movement in result.movements: + print(f" {movement}") +``` + +### AI Decision Making + +```python +from app.core.ai_opponent import ai_opponent + +# Check if team is AI-controlled +if state.is_fielding_team_ai(): + # Generate defensive decision + def_decision = await ai_opponent.generate_defensive_decision(state) + await game_engine.submit_defensive_decision(game_id, def_decision) + +if state.is_batting_team_ai(): + # Generate offensive decision + off_decision = await ai_opponent.generate_offensive_decision(state) + await game_engine.submit_offensive_decision(game_id, off_decision) +``` + +--- + +## Summary + +The `core` directory is the beating heart of the baseball simulation engine. It manages in-memory game state for fast performance, orchestrates complete gameplay flow, resolves play outcomes using card-based mechanics, and enforces all baseball rules. + +**Key files**: +- `state_manager.py` - O(1) state lookups, lineup caching, recovery +- `game_engine.py` - Orchestration, workflow, persistence +- `play_resolver.py` - Outcome-first resolution (manual + auto) +- `runner_advancement.py` - Groundball advancement (13 result types) +- `dice.py` - Cryptographic dice rolling system +- `validators.py` - Rule enforcement +- `ai_opponent.py` - AI decision-making (stub) +- `roll_types.py` - Type-safe roll data structures + +**Architecture principles**: +- Async-first: All DB operations are non-blocking +- Outcome-first: Manual submissions are primary workflow +- Type-safe: Pydantic models throughout +- Single source of truth: StateManager for active games +- Raise or Return: No Optional unless semantically valid + +**Performance**: 50-100ms play resolution, 60% query reduction, <500ms target achieved + +**Next phase**: WebSocket integration (Week 7-8) for real-time multiplayer gameplay diff --git a/backend/app/data/CLAUDE.md b/backend/app/data/CLAUDE.md new file mode 100644 index 0000000..d1ff75e --- /dev/null +++ b/backend/app/data/CLAUDE.md @@ -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. diff --git a/backend/app/database/CLAUDE.md b/backend/app/database/CLAUDE.md new file mode 100644 index 0000000..8b3d086 --- /dev/null +++ b/backend/app/database/CLAUDE.md @@ -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 diff --git a/backend/app/models/CLAUDE.md b/backend/app/models/CLAUDE.md new file mode 100644 index 0000000..be8e06c --- /dev/null +++ b/backend/app/models/CLAUDE.md @@ -0,0 +1,1270 @@ +# Models Directory - Data Models for Game Engine + +## Purpose + +This directory contains all data models for the game engine, split into two complementary systems: +- **Pydantic Models**: Type-safe, validated models for in-memory game state and API contracts +- **SQLAlchemy Models**: ORM models for PostgreSQL database persistence + +The separation optimizes for different use cases: +- Pydantic: Fast in-memory operations, WebSocket serialization, validation +- SQLAlchemy: Database persistence, complex relationships, audit trail + +## Directory Structure + +``` +models/ +├── __init__.py # Exports all models for easy importing +├── game_models.py # Pydantic in-memory game state models +├── player_models.py # Polymorphic player models (SBA/PD) +├── db_models.py # SQLAlchemy database models +└── roster_models.py # Pydantic roster link models +``` + +## File Overview + +### `__init__.py` + +Central export point for all models. Use this for imports throughout the codebase. + +**Exports**: +- Database models: `Game`, `Play`, `Lineup`, `GameSession`, `RosterLink`, `GameCardsetLink` +- Game state models: `GameState`, `LineupPlayerState`, `TeamLineupState`, `DefensiveDecision`, `OffensiveDecision` +- Roster models: `BaseRosterLinkData`, `PdRosterLinkData`, `SbaRosterLinkData`, `RosterLinkCreate` +- Player models: `BasePlayer`, `SbaPlayer`, `PdPlayer`, `PdBattingCard`, `PdPitchingCard` + +**Usage**: +```python +# ✅ Import from models package +from app.models import GameState, Game, SbaPlayer + +# ❌ Don't import from individual files +from app.models.game_models import GameState # Less convenient +``` + +--- + +### `game_models.py` - In-Memory Game State + +**Purpose**: Pydantic models representing active game state cached in memory for fast gameplay. + +**Key Models**: + +#### `GameState` - Core Game State +Complete in-memory representation of an active game. The heart of the game engine. + +**Critical Fields**: +- **Identity**: `game_id` (UUID), `league_id` (str: 'sba' or 'pd') +- **Teams**: `home_team_id`, `away_team_id`, `home_team_is_ai`, `away_team_is_ai` +- **Resolution**: `auto_mode` (True = PD auto-resolve, False = manual submissions) +- **Game State**: `status`, `inning`, `half`, `outs`, `home_score`, `away_score` +- **Runners**: `on_first`, `on_second`, `on_third` (direct LineupPlayerState references) +- **Batting Order**: `away_team_batter_idx` (0-8), `home_team_batter_idx` (0-8) +- **Play Snapshot**: `current_batter_lineup_id`, `current_pitcher_lineup_id`, `current_catcher_lineup_id`, `current_on_base_code` +- **Decision Tracking**: `pending_decision`, `decisions_this_play`, `pending_defensive_decision`, `pending_offensive_decision`, `decision_phase` +- **Manual Mode**: `pending_manual_roll` (AbRoll stored when dice rolled in manual mode) +- **Play History**: `play_count`, `last_play_result` + +**Helper Methods** (20+ utility methods): +- Team queries: `get_batting_team_id()`, `get_fielding_team_id()`, `is_batting_team_ai()`, `is_fielding_team_ai()` +- Runner queries: `is_runner_on_first()`, `get_runner_at_base()`, `bases_occupied()`, `get_all_runners()` +- Runner management: `add_runner()`, `advance_runner()`, `clear_bases()` +- Game flow: `increment_outs()`, `end_half_inning()`, `is_game_over()` + +**Usage Example**: +```python +from app.models import GameState, LineupPlayerState + +# Create game state +state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + current_batter_lineup_id=10 +) + +# Add runner +runner = LineupPlayerState(lineup_id=5, card_id=123, position="CF", batting_order=2) +state.add_runner(runner, base=1) + +# Advance runner and score +state.advance_runner(from_base=1, to_base=4) # Auto-increments score + +# Check game over +if state.is_game_over(): + state.status = "completed" +``` + +#### `LineupPlayerState` - Player in Lineup +Lightweight reference to a player in the game lineup. Full player data is cached separately. + +**Fields**: +- `lineup_id` (int): Database ID of lineup entry +- `card_id` (int): PD card ID or SBA player ID +- `position` (str): P, C, 1B, 2B, 3B, SS, LF, CF, RF, DH +- `batting_order` (Optional[int]): 1-9 if in batting order +- `is_active` (bool): Currently in game vs substituted + +**Validators**: +- Position must be valid baseball position +- Batting order must be 1-9 if provided + +#### `TeamLineupState` - Team's Active Lineup +Container for a team's players with helper methods for common queries. + +**Fields**: +- `team_id` (int): Team identifier +- `players` (List[LineupPlayerState]): All players on this team + +**Helper Methods**: +- `get_batting_order()`: Returns players sorted by batting_order (1-9) +- `get_pitcher()`: Returns active pitcher or None +- `get_player_by_lineup_id()`: Lookup by lineup ID +- `get_batter()`: Get batter by batting order index (0-8) + +**Usage Example**: +```python +lineup = TeamLineupState(team_id=1, players=[...]) + +# Get batting order +order = lineup.get_batting_order() # [player1, player2, ..., player9] + +# Get current pitcher +pitcher = lineup.get_pitcher() + +# Get 3rd batter in order +third_batter = lineup.get_batter(2) # 0-indexed +``` + +#### `DefensiveDecision` - Defensive Strategy +Strategic decisions made by the fielding team. + +**Fields**: +- `alignment` (str): normal, shifted_left, shifted_right, extreme_shift +- `infield_depth` (str): infield_in, normal, corners_in +- `outfield_depth` (str): in, normal +- `hold_runners` (List[int]): Bases to hold runners on (e.g., [1, 3]) + +**Impact**: Affects double play chances, hit probabilities, runner advancement. + +#### `OffensiveDecision` - Offensive Strategy +Strategic decisions made by the batting team. + +**Fields**: +- `approach` (str): normal, contact, power, patient +- `steal_attempts` (List[int]): Bases to steal (2, 3, or 4 for home) +- `hit_and_run` (bool): Attempt hit-and-run +- `bunt_attempt` (bool): Attempt bunt + +**Impact**: Affects outcome probabilities, baserunner actions. + +#### `ManualOutcomeSubmission` - Manual Play Outcome +Model for human players submitting outcomes after reading physical cards. + +**Fields**: +- `outcome` (PlayOutcome): The outcome from the card +- `hit_location` (Optional[str]): Position where ball was hit (for groundballs/flyballs) + +**Validators**: +- `hit_location` must be valid position if provided +- Validation that location is required for certain outcomes happens in handler + +**Usage**: +```python +# Player reads card, submits outcome +submission = ManualOutcomeSubmission( + outcome=PlayOutcome.GROUNDBALL_C, + hit_location='SS' +) +``` + +**Design Patterns**: +- All models use Pydantic v2 with `field_validator` decorators +- Extensive validation ensures data integrity +- Helper methods reduce duplicate logic in game engine +- Immutable by default (use `.model_copy()` to modify) + +--- + +### `player_models.py` - Polymorphic Player System + +**Purpose**: League-agnostic player models supporting both SBA (simple) and PD (complex) leagues. + +**Architecture**: +``` +BasePlayer (Abstract) + ├── SbaPlayer (Simple) + └── PdPlayer (Complex with scouting) +``` + +**Key Models**: + +#### `BasePlayer` - Abstract Base Class +Abstract interface ensuring consistent player API across leagues. + +**Required Abstract Methods**: +- `get_positions() -> List[str]`: All positions player can play +- `get_display_name() -> str`: Formatted name for UI + +**Common Fields**: +- **Identity**: `id` (Player ID for SBA, Card ID for PD), `name` +- **Images**: `image` (primary card), `image2` (alt card), `headshot` (league default), `vanity_card` (custom upload) +- **Positions**: `pos_1` through `pos_8` (up to 8 positions) + +**Image Priority**: +```python +def get_player_image_url(self) -> str: + return self.vanity_card or self.headshot or "" +``` + +#### `SbaPlayer` - Simple Player Model +Minimal data needed for SBA league gameplay. + +**SBA-Specific Fields**: +- `wara` (float): Wins Above Replacement Average +- `team_id`, `team_name`, `season`: Current team info +- `strat_code`, `bbref_id`, `injury_rating`: Reference IDs + +**Factory Method**: +```python +# Create from API response +player = SbaPlayer.from_api_response(api_data) + +# Use abstract interface +positions = player.get_positions() # ['RF', 'CF', 'LF'] +name = player.get_display_name() # 'Ronald Acuna Jr' +``` + +**Image Methods**: +```python +# Get appropriate card for role +pitching_card = player.get_pitching_card_url() # Uses image2 if two-way player +batting_card = player.get_batting_card_url() # Uses image for position players +``` + +#### `PdPlayer` - Complex Player Model +Detailed scouting data for PD league simulation. + +**PD-Specific Fields**: +- **Card Info**: `cost`, `cardset`, `rarity`, `set_num`, `quantity`, `description` +- **Team**: `mlbclub`, `franchise` +- **References**: `strat_code`, `bbref_id`, `fangr_id` +- **Scouting**: `batting_card`, `pitching_card` (optional, loaded separately) + +**Scouting Data Structure**: +```python +# Batting Card (from /api/v2/battingcardratings/player/:id) +batting_card: PdBattingCard + - Base running: steal_low, steal_high, steal_auto, steal_jump, running + - Skills: bunting, hit_and_run (A/B/C/D ratings) + - hand (L/R), offense_col (1 or 2) + - ratings: Dict[str, PdBattingRating] + - 'L': vs Left-handed pitchers + - 'R': vs Right-handed pitchers + +# Pitching Card (from /api/v2/pitchingcardratings/player/:id) +pitching_card: PdPitchingCard + - Control: balk, wild_pitch, hold + - Roles: starter_rating, relief_rating, closer_rating + - hand (L/R), offense_col (1 or 2) + - ratings: Dict[str, PdPitchingRating] + - 'L': vs Left-handed batters + - 'R': vs Right-handed batters +``` + +**PdBattingRating** (per handedness matchup): +- **Hit Location**: `pull_rate`, `center_rate`, `slap_rate` +- **Outcomes**: `homerun`, `triple`, `double_three`, `double_two`, `single_*`, `walk`, `strikeout`, `lineout`, `popout`, `flyout_*`, `groundout_*` +- **Summary**: `avg`, `obp`, `slg` + +**PdPitchingRating** (per handedness matchup): +- **Outcomes**: `homerun`, `triple`, `double_*`, `single_*`, `walk`, `strikeout`, `flyout_*`, `groundout_*` +- **X-checks** (defensive probabilities): `xcheck_p`, `xcheck_c`, `xcheck_1b`, `xcheck_2b`, `xcheck_3b`, `xcheck_ss`, `xcheck_lf`, `xcheck_cf`, `xcheck_rf` +- **Summary**: `avg`, `obp`, `slg` + +**Factory Method**: +```python +# Create with optional scouting data +player = PdPlayer.from_api_response( + player_data=player_api_response, + batting_data=batting_api_response, # Optional + pitching_data=pitching_api_response # Optional +) + +# Get ratings for specific matchup +rating_vs_lhp = player.get_batting_rating('L') +if rating_vs_lhp: + print(f"HR rate vs LHP: {rating_vs_lhp.homerun}%") + print(f"OBP: {rating_vs_lhp.obp}") + +rating_vs_rhb = player.get_pitching_rating('R') +if rating_vs_rhb: + print(f"X-check SS: {rating_vs_rhb.xcheck_ss}%") +``` + +**Design Patterns**: +- Abstract base class enforces consistent interface +- Factory methods for easy API parsing +- Optional scouting data (can load player without ratings) +- Type-safe with full Pydantic validation + +--- + +### `db_models.py` - SQLAlchemy Database Models + +**Purpose**: ORM models for PostgreSQL persistence, relationships, and audit trail. + +**Key Models**: + +#### `Game` - Primary Game Container +Central game record with state tracking. + +**Key Fields**: +- **Identity**: `id` (UUID), `league_id` ('sba' or 'pd') +- **Teams**: `home_team_id`, `away_team_id` +- **State**: `status` (pending, active, paused, completed), `game_mode`, `visibility` +- **Current**: `current_inning`, `current_half`, `home_score`, `away_score` +- **AI**: `home_team_is_ai`, `away_team_is_ai`, `ai_difficulty` +- **Timestamps**: `created_at`, `started_at`, `completed_at` +- **Results**: `winner_team_id`, `game_metadata` (JSON) + +**Relationships**: +- `plays`: All plays (cascade delete) +- `lineups`: All lineup entries (cascade delete) +- `cardset_links`: PD cardsets (cascade delete) +- `roster_links`: Roster tracking (cascade delete) +- `session`: WebSocket session (cascade delete) +- `rolls`: Dice roll history (cascade delete) + +#### `Play` - Individual At-Bat Record +Records every play with full statistics and game context. + +**Game State Snapshot**: +- `play_number`, `inning`, `half`, `outs_before`, `batting_order` +- `away_score`, `home_score`: Score at play start +- `on_base_code` (Integer): Bit field for efficient queries (1=1st, 2=2nd, 4=3rd, 7=loaded) + +**Player References** (FKs to Lineup): +- Required: `batter_id`, `pitcher_id`, `catcher_id` +- Optional: `defender_id`, `runner_id` +- Base runners: `on_first_id`, `on_second_id`, `on_third_id` + +**Runner Outcomes**: +- `on_first_final`, `on_second_final`, `on_third_final`: Final base (None = out, 1-4 = base) +- `batter_final`: Where batter ended up + +**Strategic Decisions**: +- `defensive_choices` (JSON): Alignment, holds, shifts +- `offensive_choices` (JSON): Steal attempts, bunts, hit-and-run + +**Play Result**: +- `dice_roll`, `hit_type`, `result_description` +- `outs_recorded`, `runs_scored` +- `check_pos`, `error` + +**Statistics** (25+ fields): +- Batting: `pa`, `ab`, `hit`, `double`, `triple`, `homerun`, `bb`, `so`, `hbp`, `rbi`, `sac`, `ibb`, `gidp` +- Baserunning: `sb`, `cs` +- Pitching events: `wild_pitch`, `passed_ball`, `pick_off`, `balk` +- Ballpark: `bphr`, `bpfo`, `bp1b`, `bplo` +- Advanced: `wpa`, `re24` +- Earned runs: `run`, `e_run` + +**Game Situation**: +- `is_tied`, `is_go_ahead`, `is_new_inning`, `in_pow` + +**Workflow**: +- `complete`, `locked`: Play state flags +- `play_metadata` (JSON): Extensibility + +**Helper Properties**: +```python +@property +def ai_is_batting(self) -> bool: + """True if batting team is AI-controlled""" + return (self.half == 'top' and self.game.away_team_is_ai) or \ + (self.half == 'bot' and self.game.home_team_is_ai) + +@property +def ai_is_fielding(self) -> bool: + """True if fielding team is AI-controlled""" + return not self.ai_is_batting +``` + +#### `Lineup` - Player Assignment & Substitutions +Tracks player assignments in a game. + +**Polymorphic Design**: Single table for both leagues. + +**Fields**: +- `game_id`, `team_id` +- `card_id` (PD) / `player_id` (SBA): Exactly one must be populated +- `position`, `batting_order` +- **Substitution**: `is_starter`, `is_active`, `entered_inning`, `replacing_id`, `after_play` +- **Pitcher**: `is_fatigued` +- `lineup_metadata` (JSON): Extensibility + +**Constraints**: +- XOR CHECK: Exactly one of `card_id` or `player_id` must be populated +- `(card_id IS NOT NULL)::int + (player_id IS NOT NULL)::int = 1` + +#### `RosterLink` - Eligible Cards/Players +Tracks which cards (PD) or players (SBA) are eligible for a game. + +**Polymorphic Design**: Single table supporting both leagues. + +**Fields**: +- `id` (auto-increment surrogate key) +- `game_id`, `team_id` +- `card_id` (PD) / `player_id` (SBA): Exactly one must be populated + +**Constraints**: +- XOR CHECK: Exactly one ID must be populated +- UNIQUE on (game_id, card_id) for PD +- UNIQUE on (game_id, player_id) for SBA + +**Usage Pattern**: +```python +# PD league - add card to roster +roster = await db_ops.add_pd_roster_card(game_id, card_id=123, team_id=1) + +# SBA league - add player to roster +roster = await db_ops.add_sba_roster_player(game_id, player_id=456, team_id=2) +``` + +#### `GameCardsetLink` - PD Cardset Restrictions +PD league only - defines legal cardsets for a game. + +**Fields**: +- `game_id`, `cardset_id`: Composite primary key +- `priority` (Integer): 1 = primary, 2+ = backup + +**Usage**: +- SBA games: Empty (no cardset restrictions) +- PD games: Required (validates card eligibility) + +#### `GameSession` - WebSocket State +Real-time WebSocket state tracking. + +**Fields**: +- `game_id` (UUID): One-to-one with Game +- `connected_users` (JSON): Active connections +- `last_action_at` (DateTime): Last activity +- `state_snapshot` (JSON): In-memory state cache + +#### `Roll` - Dice Roll History +Auditing and analytics for all dice rolls. + +**Fields**: +- `roll_id` (String): Primary key +- `game_id`, `roll_type`, `league_id` +- `team_id`, `player_id`: For analytics +- `roll_data` (JSONB): Complete roll with all dice values +- `context` (JSONB): Pitcher, inning, outs, etc. +- `timestamp`, `created_at` + +**Design Patterns**: +- All DateTime fields use Pendulum via `default=lambda: pendulum.now('UTC').naive()` +- CASCADE DELETE: Deleting game removes all related records +- Relationships use `lazy="joined"` for common queries, `lazy="select"` (default) for rare +- Polymorphic tables use CHECK constraints for data integrity +- JSON/JSONB for extensibility + +--- + +### `roster_models.py` - Roster Link Type Safety + +**Purpose**: Pydantic models providing type-safe abstractions over the polymorphic `RosterLink` table. + +**Key Models**: + +#### `BaseRosterLinkData` - Abstract Base +Common interface for roster operations. + +**Required Abstract Methods**: +- `get_entity_id() -> int`: Get the entity ID (card_id or player_id) +- `get_entity_type() -> str`: Get entity type ('card' or 'player') + +**Common Fields**: +- `id` (Optional[int]): Database ID (populated after save) +- `game_id` (UUID) +- `team_id` (int) + +#### `PdRosterLinkData` - PD League Roster +Tracks cards for PD league games. + +**Fields**: +- Inherits: `id`, `game_id`, `team_id` +- `card_id` (int): PD card identifier (validated > 0) + +**Methods**: +- `get_entity_id()`: Returns `card_id` +- `get_entity_type()`: Returns `"card"` + +#### `SbaRosterLinkData` - SBA League Roster +Tracks players for SBA league games. + +**Fields**: +- Inherits: `id`, `game_id`, `team_id` +- `player_id` (int): SBA player identifier (validated > 0) + +**Methods**: +- `get_entity_id()`: Returns `player_id` +- `get_entity_type()`: Returns `"player"` + +#### `RosterLinkCreate` - Request Model +API request model for creating roster links. + +**Fields**: +- `game_id`, `team_id` +- `card_id` (Optional[int]): PD card +- `player_id` (Optional[int]): SBA player + +**Validators**: +- `model_post_init()`: Ensures exactly one ID is provided (XOR check) +- Fails if both or neither are provided + +**Conversion Methods**: +```python +request = RosterLinkCreate(game_id=game_id, team_id=1, card_id=123) + +# Convert to league-specific data +pd_data = request.to_pd_data() # PdRosterLinkData +sba_data = request.to_sba_data() # Fails - card_id provided, not player_id +``` + +**Design Patterns**: +- Abstract base enforces consistent interface +- League-specific subclasses provide type safety +- Application-layer validation complements database constraints +- Conversion methods for easy API → model transformation + +--- + +## Key Patterns & Conventions + +### 1. Pydantic v2 Validation + +All Pydantic models use v2 syntax with `field_validator` decorators: + +```python +@field_validator('position') +@classmethod +def validate_position(cls, v: str) -> str: + valid_positions = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH'] + if v not in valid_positions: + raise ValueError(f"Position must be one of {valid_positions}") + return v +``` + +**Key Points**: +- Use `@classmethod` decorator after `@field_validator` +- Type hints are required for validator methods +- Validators should raise `ValueError` with clear messages + +### 2. SQLAlchemy Relationships + +**Lazy Loading Strategy**: +```python +# Common queries - eager load +batter = relationship("Lineup", foreign_keys=[batter_id], lazy="joined") + +# Rare queries - lazy load (default) +defender = relationship("Lineup", foreign_keys=[defender_id]) +``` + +**Foreign Keys with Multiple References**: +```python +# When same table referenced multiple times, use foreign_keys parameter +batter = relationship("Lineup", foreign_keys=[batter_id]) +pitcher = relationship("Lineup", foreign_keys=[pitcher_id]) +``` + +### 3. Polymorphic Tables + +Pattern for supporting both SBA and PD leagues in single table: + +**Database Level**: +```python +# Two nullable columns +card_id = Column(Integer, nullable=True) # PD +player_id = Column(Integer, nullable=True) # SBA + +# CHECK constraint ensures exactly one is populated (XOR) +CheckConstraint( + '(card_id IS NOT NULL)::int + (player_id IS NOT NULL)::int = 1', + name='one_id_required' +) +``` + +**Application Level**: +```python +# Pydantic models provide type safety +class PdRosterLinkData(BaseRosterLinkData): + card_id: int # Required, not nullable + +class SbaRosterLinkData(BaseRosterLinkData): + player_id: int # Required, not nullable +``` + +### 4. DateTime Handling + +**ALWAYS use Pendulum for all datetime operations:** + +```python +import pendulum + +# SQLAlchemy default +created_at = Column(DateTime, default=lambda: pendulum.now('UTC').naive()) + +# Manual creation +now = pendulum.now('UTC') +``` + +**Critical**: Use `.naive()` for PostgreSQL compatibility with asyncpg driver. + +### 5. Factory Methods + +Player models use factory methods for easy API parsing: + +```python +@classmethod +def from_api_response(cls, data: Dict[str, Any]) -> "SbaPlayer": + """Create from API response with field mapping""" + return cls( + id=data["id"], + name=data["name"], + # ... map all fields + ) +``` + +**Benefits**: +- Encapsulates API → model transformation +- Single source of truth for field mapping +- Easy to test independently + +### 6. Helper Methods on Models + +Models include helper methods to reduce duplicate logic: + +```python +class GameState(BaseModel): + # ... fields + + def get_batting_team_id(self) -> int: + """Get the ID of the team currently batting""" + return self.away_team_id if self.half == "top" else self.home_team_id + + def bases_occupied(self) -> List[int]: + """Get list of occupied bases""" + bases = [] + if self.on_first: + bases.append(1) + if self.on_second: + bases.append(2) + if self.on_third: + bases.append(3) + return bases +``` + +**Benefits**: +- Encapsulates common queries +- Reduces duplication in game engine +- Easier to test +- Self-documenting code + +### 7. Immutability + +Pydantic models are immutable by default. To modify: + +```python +# ❌ This raises an error +state.inning = 5 + +# ✅ Use model_copy() to create modified copy +updated_state = state.model_copy(update={'inning': 5}) + +# ✅ Or use StateManager which handles updates +state_manager.update_state(game_id, state) +``` + +--- + +## Integration Points + +### Game Engine → Models + +```python +from app.models import GameState, LineupPlayerState + +# Create state +state = GameState( + game_id=game_id, + league_id="sba", + home_team_id=1, + away_team_id=2, + current_batter_lineup_id=10 +) + +# Use helper methods +batting_team = state.get_batting_team_id() +is_runner_on = state.is_runner_on_first() +``` + +### Database Operations → Models + +```python +from app.models import Game, Play, Lineup + +# Create SQLAlchemy model +game = Game( + id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + status="pending", + game_mode="friendly", + visibility="public" +) + +# Persist +async with session.begin(): + session.add(game) +``` + +### Models → API Responses + +```python +from app.models import GameState + +# Pydantic models serialize to JSON automatically +state = GameState(...) +state_json = state.model_dump() # Dict for JSON serialization +state_json = state.model_dump_json() # JSON string +``` + +### Player Polymorphism + +```python +from app.models import BasePlayer, SbaPlayer, PdPlayer + +def process_batter(batter: BasePlayer): + """Works for both SBA and PD players""" + print(f"Batter: {batter.get_display_name()}") + print(f"Positions: {batter.get_positions()}") + +# Use with any league +sba_batter = SbaPlayer(...) +pd_batter = PdPlayer(...) +process_batter(sba_batter) # Works +process_batter(pd_batter) # Works +``` + +--- + +## Common Tasks + +### Adding a New Field to GameState + +1. **Add field to Pydantic model** (`game_models.py`): +```python +class GameState(BaseModel): + # ... existing fields + new_field: str = "default_value" +``` + +2. **Add validator if needed**: +```python +@field_validator('new_field') +@classmethod +def validate_new_field(cls, v: str) -> str: + if not v: + raise ValueError("new_field cannot be empty") + return v +``` + +3. **Update tests** (`tests/unit/models/test_game_models.py`): +```python +def test_new_field_validation(): + with pytest.raises(ValidationError): + GameState( + game_id=uuid4(), + league_id="sba", + # ... required fields + new_field="" # Invalid + ) +``` + +4. **No database migration needed** (Pydantic models are in-memory only) + +### Adding a New SQLAlchemy Model + +1. **Define model** (`db_models.py`): +```python +class NewModel(Base): + __tablename__ = "new_models" + + id = Column(Integer, primary_key=True) + game_id = Column(UUID(as_uuid=True), ForeignKey("games.id")) + # ... fields + + # Relationship + game = relationship("Game", back_populates="new_models") +``` + +2. **Add relationship to Game model**: +```python +class Game(Base): + # ... existing fields + new_models = relationship("NewModel", back_populates="game", cascade="all, delete-orphan") +``` + +3. **Create migration**: +```bash +alembic revision --autogenerate -m "Add new_models table" +alembic upgrade head +``` + +4. **Export from `__init__.py`**: +```python +from app.models.db_models import NewModel + +__all__ = [ + # ... existing exports + "NewModel", +] +``` + +### Adding a New Player Field + +1. **Update BasePlayer if common** (`player_models.py`): +```python +class BasePlayer(BaseModel, ABC): + # ... existing fields + new_common_field: Optional[str] = None +``` + +2. **Or update league-specific class**: +```python +class SbaPlayer(BasePlayer): + # ... existing fields + new_sba_field: Optional[int] = None +``` + +3. **Update factory method**: +```python +@classmethod +def from_api_response(cls, data: Dict[str, Any]) -> "SbaPlayer": + return cls( + # ... existing fields + new_sba_field=data.get("new_sba_field"), + ) +``` + +4. **Update tests** (`tests/unit/models/test_player_models.py`) + +### Creating a New Pydantic Model + +1. **Define in appropriate file**: +```python +class NewModel(BaseModel): + """Purpose of this model""" + field1: str + field2: int = 0 + + @field_validator('field1') + @classmethod + def validate_field1(cls, v: str) -> str: + if len(v) < 3: + raise ValueError("field1 must be at least 3 characters") + return v +``` + +2. **Export from `__init__.py`**: +```python +from app.models.game_models import NewModel + +__all__ = [ + # ... existing exports + "NewModel", +] +``` + +3. **Add tests** + +--- + +## Troubleshooting + +### ValidationError: Field required + +**Problem**: Missing required field when creating model. + +**Solution**: Check field definition - remove `Optional` or provide default: +```python +# Required field (no default) +name: str + +# Optional field +name: Optional[str] = None + +# Field with default +name: str = "default" +``` + +### ValidationError: Field value validation failed + +**Problem**: Field validator rejected value. + +**Solution**: Check validator logic and error message: +```python +@field_validator('position') +@classmethod +def validate_position(cls, v: str) -> str: + valid = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH'] + if v not in valid: + raise ValueError(f"Position must be one of {valid}") # Clear message + return v +``` + +### SQLAlchemy: Cannot access attribute before flush + +**Problem**: Trying to access auto-generated ID before commit. + +**Solution**: Commit first or use `session.flush()`: +```python +async with session.begin(): + session.add(game) + await session.flush() # Generates ID without committing + game_id = game.id # Now accessible +``` + +### IntegrityError: violates check constraint + +**Problem**: Polymorphic table constraint violated (both IDs populated or neither). + +**Solution**: Use Pydantic models to enforce XOR at application level: +```python +# ❌ Don't create RosterLink directly +roster = RosterLink(game_id=game_id, team_id=1, card_id=123, player_id=456) # Both IDs + +# ✅ Use league-specific Pydantic models +pd_roster = PdRosterLinkData(game_id=game_id, team_id=1, card_id=123) +``` + +### Type Error: Column vs Actual Value + +**Problem**: SQLAlchemy model attributes typed as `Column[int]` but are `int` at runtime. + +**Solution**: Use targeted type ignore comments: +```python +# SQLAlchemy ORM magic: .id is Column[int] for type checker, int at runtime +state.current_batter_lineup_id = lineup_player.id # type: ignore[assignment] +``` + +**When to use**: +- Assigning SQLAlchemy model attributes to Pydantic fields +- Common in game_engine.py when bridging ORM and Pydantic + +**When NOT to use**: +- Pure Pydantic → Pydantic operations (should type check cleanly) +- New code (only for SQLAlchemy ↔ Pydantic bridging) + +### AttributeError: 'GameState' object has no attribute + +**Problem**: Trying to access field that doesn't exist or was renamed. + +**Solution**: +1. Check model definition +2. If field was renamed, update all references +3. Use IDE autocomplete to avoid typos + +Example: +```python +# ❌ Old field name +state.runners # AttributeError - removed in refactor + +# ✅ New API +state.get_all_runners() # Returns list of (base, player) tuples +``` + +--- + +## Testing Models + +### Unit Test Structure + +Each model file has corresponding test file: +``` +tests/unit/models/ +├── test_game_models.py # GameState, LineupPlayerState, etc. +├── test_player_models.py # BasePlayer, SbaPlayer, PdPlayer +└── test_roster_models.py # RosterLink Pydantic models +``` + +### Test Patterns + +**Valid Model Creation**: +```python +def test_create_game_state(): + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + current_batter_lineup_id=10 + ) + assert state.league_id == "sba" + assert state.inning == 1 +``` + +**Validation Errors**: +```python +def test_invalid_league_id(): + with pytest.raises(ValidationError) as exc_info: + GameState( + game_id=uuid4(), + league_id="invalid", # Not 'sba' or 'pd' + home_team_id=1, + away_team_id=2, + current_batter_lineup_id=10 + ) + assert "league_id must be one of ['sba', 'pd']" in str(exc_info.value) +``` + +**Helper Methods**: +```python +def test_advance_runner_scoring(): + state = GameState(...) + runner = LineupPlayerState(lineup_id=1, card_id=101, position="CF") + state.add_runner(runner, base=3) + + state.advance_runner(from_base=3, to_base=4) + + assert state.on_third is None + assert state.home_score == 1 or state.away_score == 1 # Depends on half +``` + +### Running Model Tests + +```bash +# All model tests +pytest tests/unit/models/ -v + +# Specific file +pytest tests/unit/models/test_game_models.py -v + +# Specific test +pytest tests/unit/models/test_game_models.py::test_advance_runner_scoring -v + +# With coverage +pytest tests/unit/models/ --cov=app.models --cov-report=html +``` + +--- + +## Design Rationale + +### Why Separate Pydantic and SQLAlchemy Models? + +**Pydantic Models** (`game_models.py`, `player_models.py`, `roster_models.py`): +- Fast in-memory operations (no ORM overhead) +- WebSocket serialization (automatic JSON conversion) +- Validation and type safety +- Immutable by default +- Helper methods for game logic + +**SQLAlchemy Models** (`db_models.py`): +- Database persistence +- Complex relationships +- Audit trail and history +- Transaction management +- Query optimization + +**Tradeoff**: Some duplication, but optimized for different use cases. + +### Why Direct Base References Instead of List? + +**Before**: `runners: List[RunnerState]` +**After**: `on_first`, `on_second`, `on_third` + +**Reasons**: +1. Matches database structure exactly (`Play` has `on_first_id`, `on_second_id`, `on_third_id`) +2. Simpler state management (direct assignment vs list operations) +3. Type safety (LineupPlayerState vs generic runner) +4. Easier to work with in game engine +5. No list management overhead + +### Why Polymorphic Tables Instead of League-Specific? + +**Single polymorphic table** (`RosterLink`, `Lineup`) instead of separate `PdRosterLink`/`SbaRosterLink`: + +**Advantages**: +- Simpler schema (fewer tables) +- Easier queries (no UNIONs needed) +- Single code path for common operations +- Foreign key relationships work naturally + +**Type Safety**: Pydantic models (`PdRosterLinkData`, `SbaRosterLinkData`) provide application-layer safety. + +### Why Factory Methods for Player Models? + +API responses have inconsistent field names and nested structures. Factory methods: +1. Encapsulate field mapping logic +2. Handle nested data (team info, cardsets) +3. Provide single source of truth +4. Easy to test independently +5. Future-proof for API changes + +--- + +## Examples + +### Complete Game State Management + +```python +from app.models import GameState, LineupPlayerState +from uuid import uuid4 + +# Create game +state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + current_batter_lineup_id=10 +) + +# Add runners +runner1 = LineupPlayerState(lineup_id=5, card_id=101, position="CF", batting_order=2) +runner2 = LineupPlayerState(lineup_id=8, card_id=104, position="SS", batting_order=5) +state.add_runner(runner1, base=1) +state.add_runner(runner2, base=2) + +# Check state +print(f"Runners on base: {state.bases_occupied()}") # [1, 2] +print(f"Is runner on first: {state.is_runner_on_first()}") # True + +# Advance runners (single to right field) +state.advance_runner(from_base=2, to_base=4) # Runner scores from 2nd +state.advance_runner(from_base=1, to_base=3) # Runner to 3rd from 1st + +# Batter to first +batter = LineupPlayerState(lineup_id=10, card_id=107, position="RF", batting_order=7) +state.add_runner(batter, base=1) + +# Check updated state +print(f"Score: {state.away_score}-{state.home_score}") # 1-0 if top of inning +print(f"Runners: {state.bases_occupied()}") # [1, 3] + +# Record out +half_over = state.increment_outs() # False (1 out) +half_over = state.increment_outs() # False (2 outs) +half_over = state.increment_outs() # True (3 outs) + +# End half inning +if half_over: + state.end_half_inning() + print(f"Now: Inning {state.inning}, {state.half}") # Inning 1, bottom + print(f"Runners: {state.bases_occupied()}") # [] (cleared) +``` + +### Player Model Polymorphism + +```python +from app.models import BasePlayer, SbaPlayer, PdPlayer + +def display_player_card(player: BasePlayer): + """Works for both SBA and PD players""" + print(f"Name: {player.get_display_name()}") + print(f"Positions: {', '.join(player.get_positions())}") + print(f"Image: {player.get_player_image_url()}") + + # League-specific logic + if isinstance(player, PdPlayer): + rating = player.get_batting_rating('L') + if rating: + print(f"Batting vs LHP: {rating.avg:.3f}") + elif isinstance(player, SbaPlayer): + print(f"WARA: {player.wara}") + +# Use with SBA player +sba_player = SbaPlayer.from_api_response(sba_api_data) +display_player_card(sba_player) + +# Use with PD player +pd_player = PdPlayer.from_api_response( + player_data=pd_api_data, + batting_data=batting_api_data +) +display_player_card(pd_player) +``` + +### Database Operations with Models + +```python +from app.models import Game, Play, Lineup +from app.database.session import get_session +from sqlalchemy import select +import pendulum +import uuid + +async def create_game_with_lineups(): + game_id = uuid.uuid4() + + async with get_session() as session: + # Create game + game = Game( + id=game_id, + league_id="sba", + home_team_id=1, + away_team_id=2, + status="active", + game_mode="friendly", + visibility="public", + created_at=pendulum.now('UTC').naive() + ) + session.add(game) + + # Add lineup entries + lineup_entries = [ + Lineup(game_id=game_id, team_id=1, player_id=101, position="P", batting_order=9), + Lineup(game_id=game_id, team_id=1, player_id=102, position="C", batting_order=2), + Lineup(game_id=game_id, team_id=1, player_id=103, position="1B", batting_order=3), + # ... more players + ] + session.add_all(lineup_entries) + + await session.commit() + + return game_id + +async def get_active_pitcher(game_id: uuid.UUID, team_id: int): + async with get_session() as session: + result = await session.execute( + select(Lineup) + .where( + Lineup.game_id == game_id, + Lineup.team_id == team_id, + Lineup.position == 'P', + Lineup.is_active == True + ) + ) + return result.scalar_one_or_none() +``` + +--- + +## Related Documentation + +- **Backend CLAUDE.md**: `../CLAUDE.md` - Overall backend architecture +- **Database Operations**: `../database/CLAUDE.md` - Database layer patterns +- **Game Engine**: `../core/CLAUDE.md` - Game logic using these models +- **Player Data Catalog**: `../../../.claude/implementation/player-data-catalog.md` - API response examples + +--- + +**Last Updated**: 2025-10-31 +**Status**: Complete and production-ready +**Test Coverage**: 110+ tests across all model files diff --git a/backend/app/models/game_models.py b/backend/app/models/game_models.py index 08e8804..6f2433f 100644 --- a/backend/app/models/game_models.py +++ b/backend/app/models/game_models.py @@ -15,6 +15,7 @@ import logging from typing import Optional, Dict, List, Any from uuid import UUID from pydantic import BaseModel, Field, field_validator, ConfigDict +from app.config.result_charts import PlayOutcome logger = logging.getLogger(f'{__name__}') @@ -208,25 +209,9 @@ class ManualOutcomeSubmission(BaseModel): 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' - @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') @classmethod def validate_hit_location(cls, v: Optional[str]) -> Optional[str]: diff --git a/backend/app/utils/CLAUDE.md b/backend/app/utils/CLAUDE.md new file mode 100644 index 0000000..46f63fb --- /dev/null +++ b/backend/app/utils/CLAUDE.md @@ -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 ` 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. diff --git a/backend/app/websocket/CLAUDE.md b/backend/app/websocket/CLAUDE.md new file mode 100644 index 0000000..082ff4d --- /dev/null +++ b/backend/app/websocket/CLAUDE.md @@ -0,0 +1,1588 @@ +# WebSocket Module - Real-Time Game Communication + +## Purpose + +Real-time bidirectional communication layer for Paper Dynasty game engine using Socket.io. Handles connection lifecycle, room management, game event broadcasting, and player action processing. + +**Critical Role**: This is the primary interface between players and the game engine. All game actions flow through WebSocket events, ensuring real-time updates for all participants. + +## Architecture Overview + +``` +Client (Browser) + ↓ Socket.io +ConnectionManager + ↓ +Event Handlers + ↓ +Game Engine → StateManager → Database + ↓ +Broadcast to All Players +``` + +**Key Characteristics**: +- **Async-first**: All handlers use async/await +- **Room-based**: Games are isolated rooms (game_id as room name) +- **JWT Authentication**: All connections require valid token +- **Event-driven**: Actions trigger events, results broadcast to rooms +- **Error isolation**: Exceptions caught per-event, emit error to client + +## Structure + +### Module Files + +``` +app/websocket/ +├── __init__.py # Package marker (minimal/empty) +├── connection_manager.py # Connection lifecycle & broadcasting +└── handlers.py # Event handler registration +``` + +### Dependencies + +**Internal**: +- `app.core.state_manager` - In-memory game state +- `app.core.game_engine` - Play resolution logic +- `app.core.dice` - Dice rolling system +- `app.core.validators` - Rule validation +- `app.models.game_models` - Pydantic game state models +- `app.utils.auth` - JWT token verification +- `app.config.result_charts` - PlayOutcome enum + +**External**: +- `socketio.AsyncServer` - Socket.io server implementation +- `pydantic` - Data validation + +## Key Components + +### 1. ConnectionManager (`connection_manager.py`) + +**Purpose**: Manages WebSocket connection lifecycle, room membership, and message broadcasting. + +**State Tracking**: +```python +self.sio: socketio.AsyncServer # Socket.io server instance +self.user_sessions: Dict[str, str] # sid → user_id mapping +self.game_rooms: Dict[str, Set[str]] # game_id → set of sids +``` + +**Core Methods**: + +#### `async connect(sid: str, user_id: str) -> None` +Register a new connection after authentication. + +```python +await manager.connect(sid, user_id) +# Logs: "User {user_id} connected with session {sid}" +``` + +#### `async disconnect(sid: str) -> None` +Handle disconnection - cleanup sessions and notify game rooms. + +```python +await manager.disconnect(sid) +# Automatically: +# - Removes user from user_sessions +# - Removes from all game_rooms +# - Broadcasts "user_disconnected" to affected games +``` + +#### `async join_game(sid: str, game_id: str, role: str) -> None` +Add user to game room and broadcast join event. + +```python +await manager.join_game(sid, game_id, role="player") +# - Calls sio.enter_room(sid, game_id) +# - Tracks in game_rooms dict +# - Broadcasts "user_connected" to room +``` + +#### `async leave_game(sid: str, game_id: str) -> None` +Remove user from game room. + +```python +await manager.leave_game(sid, game_id) +# - Calls sio.leave_room(sid, game_id) +# - Updates game_rooms tracking +``` + +#### `async broadcast_to_game(game_id: str, event: str, data: dict) -> None` +Send event to all users in game room. + +```python +await manager.broadcast_to_game( + game_id="123e4567-e89b-12d3-a456-426614174000", + event="play_resolved", + data={"description": "Single to CF", "runs_scored": 1} +) +# All players in game receive event +``` + +#### `async emit_to_user(sid: str, event: str, data: dict) -> None` +Send event to specific user. + +```python +await manager.emit_to_user( + sid="abc123", + event="error", + data={"message": "Invalid action"} +) +# Only that user receives event +``` + +#### `get_game_participants(game_id: str) -> Set[str]` +Get all session IDs currently in game room. + +```python +sids = manager.get_game_participants(game_id) +print(f"{len(sids)} players connected") +``` + +--- + +### 2. Event Handlers (`handlers.py`) + +**Purpose**: Register and process all client-initiated events. Validates inputs, coordinates with game engine, emits responses. + +**Registration Pattern**: +```python +def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: + """Register all WebSocket event handlers""" + + @sio.event + async def event_name(sid, data): + # Handler implementation +``` + +**Handler Design Pattern**: +```python +@sio.event +async def some_event(sid, data): + """ + Event description. + + Event data: + field1: type - description + field2: type - description + + Emits: + success_event: To requester/room on success + error: To requester on failure + """ + try: + # 1. Extract and validate inputs + game_id = UUID(data.get("game_id")) + field = data.get("field") + + # 2. Get game state + state = state_manager.get_state(game_id) + if not state: + await manager.emit_to_user(sid, "error", {"message": "Game not found"}) + return + + # 3. Validate authorization (TODO: implement) + # user_id = manager.user_sessions.get(sid) + + # 4. Process action + result = await game_engine.do_something(game_id, field) + + # 5. Emit success + await manager.emit_to_user(sid, "success_event", result) + + # 6. Broadcast to game room if needed + await manager.broadcast_to_game(game_id, "state_update", data) + + except ValidationError as e: + # Pydantic validation error - user-friendly message + await manager.emit_to_user(sid, "error_event", {"message": str(e)}) + except Exception as e: + # Unexpected error - log and return generic message + logger.error(f"Event error: {e}", exc_info=True) + await manager.emit_to_user(sid, "error", {"message": str(e)}) +``` + +--- + +### Core Event Handlers + +#### `connect(sid, environ, auth) -> bool` + +**Purpose**: Authenticate new WebSocket connections using JWT. + +**Flow**: +1. Extract JWT token from `auth` dict +2. Verify token using `verify_token()` +3. Extract `user_id` from token payload +4. Register connection with ConnectionManager +5. Emit "connected" event to user + +**Returns**: `True` to accept, `False` to reject connection + +**Security**: First line of defense - all connections must have valid JWT. + +```python +# Client connection attempt +socket.connect({ + auth: { + token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } +}) + +# On success, receives: +{"user_id": "12345"} +``` + +--- + +#### `disconnect(sid)` + +**Purpose**: Clean up when user disconnects (intentional or network failure). + +**Flow**: +1. Remove from `user_sessions` +2. Remove from all `game_rooms` +3. Broadcast "user_disconnected" to affected games + +**Automatic**: Called by Socket.io on connection loss. + +--- + +#### `join_game(sid, data)` + +**Purpose**: Add user to game room for real-time updates. + +**Event Data**: +```python +{ + "game_id": "123e4567-e89b-12d3-a456-426614174000", + "role": "player" # or "spectator" +} +``` + +**Emits**: +- `game_joined` → To requester with confirmation +- `user_connected` → Broadcast to game room + +**TODO**: Verify user has access to game (authorization check) + +--- + +#### `leave_game(sid, data)` + +**Purpose**: Remove user from game room. + +**Event Data**: +```python +{ + "game_id": "123e4567-e89b-12d3-a456-426614174000" +} +``` + +**Use Case**: User navigates away, switches games, or voluntarily leaves. + +--- + +#### `heartbeat(sid)` + +**Purpose**: Keep-alive mechanism to detect stale connections. + +**Flow**: +1. Client sends periodic "heartbeat" events +2. Server immediately responds with "heartbeat_ack" + +**Usage**: Client can detect server unresponsiveness if ack not received. + +--- + +#### `roll_dice(sid, data)` + +**Purpose**: Roll dice for manual outcome selection (core gameplay event). + +**Event Data**: +```python +{ + "game_id": "123e4567-e89b-12d3-a456-426614174000" +} +``` + +**Flow**: +1. Validate game_id (UUID format, game exists) +2. Verify user is participant (TODO: implement authorization) +3. Roll dice using `dice_system.roll_ab()` +4. Store roll in `state.pending_manual_roll` (one-time use) +5. Broadcast dice results to all players in game + +**Emits**: +- `dice_rolled` → Broadcast to game room with roll results +- `error` → To requester if validation fails + +**Dice Roll Structure**: +```python +{ + "game_id": "123e4567-...", + "roll_id": "unique-roll-identifier", + "d6_one": 4, # First d6 (card selection) + "d6_two_total": 7, # Sum of 2d6 (row selection) + "chaos_d20": 14, # d20 for split results + "resolution_d20": 8, # d20 for secondary checks + "check_wild_pitch": False, + "check_passed_ball": False, + "timestamp": "2025-10-31T12:34:56Z", + "message": "Dice rolled - read your card and submit outcome" +} +``` + +**Players' Workflow**: +1. Receive `dice_rolled` event +2. d6_one determines column (1-3: batter card, 4-6: pitcher card) +3. d6_two_total determines row on card (2-12) +4. Read physical card result at that position +5. Submit outcome using `submit_manual_outcome` + +**Security**: Roll stored in `pending_manual_roll` to prevent replay attacks. Cleared after single use. + +--- + +#### `submit_manual_outcome(sid, data)` + +**Purpose**: Submit manually-selected play outcome after reading physical card. + +**Event Data**: +```python +{ + "game_id": "123e4567-e89b-12d3-a456-426614174000", + "outcome": "single", # PlayOutcome enum value + "hit_location": "CF" # Optional: required for hits +} +``` + +**Flow**: +1. Validate game_id (UUID format, game exists) +2. Verify user is authorized (TODO: implement - active batter or game admin) +3. Extract outcome and hit_location +4. Validate using `ManualOutcomeSubmission` Pydantic model +5. Convert outcome string to `PlayOutcome` enum +6. Check if outcome requires hit_location (groundball, flyball, line drive) +7. Verify `pending_manual_roll` exists (must call `roll_dice` first) +8. Emit `outcome_accepted` to requester (immediate feedback) +9. Process play through `game_engine.resolve_manual_play()` +10. Clear `pending_manual_roll` (one-time use) +11. Broadcast `play_resolved` to game room with full result + +**Emits**: +- `outcome_accepted` → To requester (immediate confirmation) +- `play_resolved` → Broadcast to game room (full play result) +- `outcome_rejected` → To requester if validation fails +- `error` → To requester if processing fails + +**Validation Errors**: +```python +# Missing game_id +{"message": "Missing game_id", "field": "game_id"} + +# Invalid UUID format +{"message": "Invalid game_id format", "field": "game_id"} + +# Game not found +{"message": "Game {game_id} not found"} + +# Missing outcome +{"message": "Missing outcome", "field": "outcome"} + +# Invalid outcome value +{"message": "Invalid outcome", "field": "outcome", "errors": [...]} + +# Missing required hit_location +{"message": "Outcome groundball_c requires hit_location", "field": "hit_location"} + +# No pending roll +{"message": "No pending dice roll - call roll_dice first", "field": "game_state"} +``` + +**Play Result Structure**: +```python +{ + "game_id": "123e4567-...", + "play_number": 15, + "outcome": "single", + "hit_location": "CF", + "description": "Single to center field", + "outs_recorded": 0, + "runs_scored": 1, + "batter_result": "1B", + "runners_advanced": [ + {"from": 2, "to": 4}, # Runner scored from 2nd + {"from": 0, "to": 1} # Batter to 1st + ], + "is_hit": true, + "is_out": false, + "is_walk": false, + "roll_id": "unique-roll-identifier" +} +``` + +**Error Handling**: +- `ValidationError` (Pydantic): User-friendly field-level errors → `outcome_rejected` +- `GameValidationError`: Business rule violations → `outcome_rejected` +- `Exception`: Unexpected errors → logged with stack trace, `error` emitted + +**Security**: +- Validates `pending_manual_roll` exists (prevents fabricated submissions) +- One-time use: roll cleared after processing +- TODO: Verify user authorization (active batter or game admin) + +--- + +## Patterns & Conventions + +### 1. Error Handling + +**Three-tier error handling**: + +```python +try: + # Main logic + result = await process_action() + +except ValidationError as e: + # Pydantic validation - user-friendly error + first_error = e.errors()[0] + field = first_error['loc'][0] if first_error['loc'] else 'unknown' + message = first_error['msg'] + + await manager.emit_to_user( + sid, + "outcome_rejected", # or "error" + {"message": message, "field": field, "errors": e.errors()} + ) + logger.warning(f"Validation failed: {message}") + return # Don't continue + +except GameValidationError as e: + # Business rule violation + await manager.emit_to_user( + sid, + "outcome_rejected", + {"message": str(e), "field": "validation"} + ) + logger.warning(f"Game validation failed: {e}") + return + +except Exception as e: + # Unexpected error - log full stack trace + logger.error(f"Unexpected error: {e}", exc_info=True) + await manager.emit_to_user( + sid, + "error", + {"message": f"Failed to process action: {str(e)}"} + ) + return +``` + +**Error Event Types**: +- `error` - Generic error (connection, processing failures) +- `outcome_rejected` - Play-specific validation failure (user-friendly) + +### 2. Logging + +All logs use structured format with module name: + +```python +import logging + +logger = logging.getLogger(f'{__name__}.ConnectionManager') +logger = logging.getLogger(f'{__name__}.handlers') + +# Log levels +logger.info(f"User {user_id} connected") # Normal operations +logger.warning(f"Validation failed: {message}") # Expected errors +logger.error(f"Error: {e}", exc_info=True) # Unexpected errors +logger.debug(f"Broadcast {event} to game") # Verbose details +``` + +### 3. UUID Validation + +All game_id values must be validated as UUIDs: + +```python +from uuid import UUID + +try: + game_id = UUID(data.get("game_id")) +except (ValueError, AttributeError): + await manager.emit_to_user( + sid, + "error", + {"message": "Invalid game_id format"} + ) + return +``` + +### 4. State Validation + +Always verify game state exists: + +```python +state = state_manager.get_state(game_id) +if not state: + await manager.emit_to_user( + sid, + "error", + {"message": f"Game {game_id} not found"} + ) + return +``` + +### 5. Authorization Pattern (TODO) + +Framework for future authorization checks: + +```python +# Get user_id from session +user_id = manager.user_sessions.get(sid) + +# Verify user has access (not yet implemented) +# if not is_game_participant(game_id, user_id): +# await manager.emit_to_user(sid, "error", {"message": "Not authorized"}) +# return + +# Verify user can perform action (not yet implemented) +# if not is_active_batter(game_id, user_id): +# await manager.emit_to_user(sid, "error", {"message": "Not your turn"}) +# return +``` + +### 6. Pydantic Validation + +Use Pydantic models for input validation: + +```python +from app.models.game_models import ManualOutcomeSubmission + +try: + submission = ManualOutcomeSubmission( + outcome=data.get("outcome"), + hit_location=data.get("hit_location") + ) +except ValidationError as e: + # Extract user-friendly error + first_error = e.errors()[0] + field = first_error['loc'][0] + message = first_error['msg'] + + await manager.emit_to_user(sid, "outcome_rejected", { + "message": message, + "field": field, + "errors": e.errors() + }) + return +``` + +### 7. Event Response Flow + +**Request → Validate → Process → Respond → Broadcast** + +```python +@sio.event +async def some_action(sid, data): + # 1. VALIDATE inputs + game_id = validate_game_id(data) + state = get_and_verify_state(game_id) + + # 2. PROCESS action + result = await game_engine.process(game_id, data) + + # 3. RESPOND to requester + await manager.emit_to_user(sid, "action_accepted", {"status": "success"}) + + # 4. BROADCAST to game room + await manager.broadcast_to_game(game_id, "state_update", result) +``` + +### 8. Async Best Practices + +- All handlers are `async def` +- Use `await` for I/O operations (database, game engine) +- Non-blocking: multiple events can be processed concurrently +- Game engine operations are async for database writes + +--- + +## Integration Points + +### With Game Engine + +Event handlers coordinate with game engine for play resolution: + +```python +from app.core.game_engine import game_engine + +# Roll dice +ab_roll = dice_system.roll_ab(league_id=state.league_id, game_id=game_id) + +# Store in state (pending) +state.pending_manual_roll = ab_roll +state_manager.update_state(game_id, state) + +# Resolve manual play +result = await game_engine.resolve_manual_play( + game_id=game_id, + ab_roll=ab_roll, + outcome=PlayOutcome.SINGLE, + hit_location="CF" +) +``` + +### With State Manager + +Real-time state access and updates: + +```python +from app.core.state_manager import state_manager + +# Get current state (O(1) lookup) +state = state_manager.get_state(game_id) + +# Update state (in-memory) +state.pending_manual_roll = ab_roll +state_manager.update_state(game_id, state) + +# Clear pending roll after use +state.pending_manual_roll = None +state_manager.update_state(game_id, state) +``` + +### With Database + +Async database writes happen in game engine (non-blocking): + +```python +from app.core.game_engine import game_engine + +# Game engine handles async DB operations +result = await game_engine.resolve_manual_play(...) +# - Updates in-memory state (immediate) +# - Writes to database (async, non-blocking) +# - Returns result for broadcasting +``` + +### With Clients + +**Client-side Socket.io integration**: + +```javascript +// Connect with JWT +const socket = io('ws://localhost:8000', { + auth: { + token: localStorage.getItem('jwt') + } +}); + +// Connection confirmed +socket.on('connected', (data) => { + console.log('Connected as user', data.user_id); +}); + +// Join game room +socket.emit('join_game', { + game_id: '123e4567-e89b-12d3-a456-426614174000', + role: 'player' +}); + +// Roll dice +socket.emit('roll_dice', { + game_id: '123e4567-e89b-12d3-a456-426614174000' +}); + +// Receive dice results +socket.on('dice_rolled', (data) => { + console.log('Dice:', data.d6_one, data.d6_two_total); + // Show UI for outcome selection +}); + +// Submit outcome +socket.emit('submit_manual_outcome', { + game_id: '123e4567-e89b-12d3-a456-426614174000', + outcome: 'single', + hit_location: 'CF' +}); + +// Receive play result +socket.on('play_resolved', (data) => { + console.log('Play:', data.description); + console.log('Runs scored:', data.runs_scored); + // Update game UI +}); + +// Handle errors +socket.on('error', (data) => { + console.error('Error:', data.message); +}); + +socket.on('outcome_rejected', (data) => { + console.error('Rejected:', data.message, 'Field:', data.field); +}); +``` + +--- + +## Common Tasks + +### Adding a New Event Handler + +1. **Define handler function** in `handlers.py`: + +```python +@sio.event +async def new_event(sid, data): + """ + Description of what this event does. + + Event data: + field1: type - description + field2: type - description + + Emits: + success_event: To requester/room on success + error: To requester on failure + """ + try: + # 1. Extract and validate inputs + game_id = UUID(data.get("game_id")) + + # 2. Get game state + state = state_manager.get_state(game_id) + if not state: + await manager.emit_to_user(sid, "error", {"message": "Game not found"}) + return + + # 3. Process action + result = await game_engine.some_action(game_id, data) + + # 4. Emit success + await manager.emit_to_user(sid, "success_event", result) + + # 5. Broadcast to game room + await manager.broadcast_to_game(game_id, "state_update", result) + + except Exception as e: + logger.error(f"New event error: {e}", exc_info=True) + await manager.emit_to_user(sid, "error", {"message": str(e)}) +``` + +2. **Register automatically**: `@sio.event` decorator auto-registers + +3. **Add client-side handler**: + +```javascript +socket.emit('new_event', { game_id: '...', field1: 'value' }); +socket.on('success_event', (data) => { /* handle */ }); +``` + +### Modifying Broadcast Logic + +**To broadcast to specific users**: + +```python +# Get participants +sids = manager.get_game_participants(game_id) + +# Emit to each with custom logic +for sid in sids: + user_id = manager.user_sessions.get(sid) + + # Custom data per user + custom_data = build_user_specific_data(user_id) + + await manager.emit_to_user(sid, "custom_event", custom_data) +``` + +**To broadcast to teams separately**: + +```python +# Get participants +sids = manager.get_game_participants(game_id) + +for sid in sids: + user_id = manager.user_sessions.get(sid) + + # Determine team + if is_home_team(user_id, game_id): + await manager.emit_to_user(sid, "home_event", data) + else: + await manager.emit_to_user(sid, "away_event", data) +``` + +### Adding Authorization Checks + +**TODO**: Implement authorization service and add checks: + +```python +from app.utils.auth import verify_game_access, verify_active_player + +@sio.event +async def protected_event(sid, data): + game_id = UUID(data.get("game_id")) + user_id = manager.user_sessions.get(sid) + + # Verify user has access to game + if not verify_game_access(user_id, game_id): + await manager.emit_to_user(sid, "error", {"message": "Not authorized"}) + return + + # Verify user is active player + if not verify_active_player(user_id, game_id): + await manager.emit_to_user(sid, "error", {"message": "Not your turn"}) + return + + # Process action + ... +``` + +### Testing Event Handlers + +**Unit tests** (using pytest-asyncio): + +```python +import pytest +from unittest.mock import AsyncMock, MagicMock +from app.websocket.handlers import register_handlers + +@pytest.mark.asyncio +async def test_roll_dice(): + # Mock Socket.io server + sio = MagicMock() + manager = MagicMock() + + # Register handlers + register_handlers(sio, manager) + + # Get the roll_dice handler + roll_dice_handler = sio.event.call_args_list[2][0][0] # 3rd registered event + + # Mock data + sid = "test-sid" + data = {"game_id": "123e4567-e89b-12d3-a456-426614174000"} + + # Call handler + await roll_dice_handler(sid, data) + + # Verify broadcast + manager.broadcast_to_game.assert_called_once() +``` + +**Integration tests** (with test database): + +```python +import pytest +from socketio import AsyncClient +from app.main import app + +@pytest.mark.asyncio +async def test_roll_dice_integration(): + # Create test client + client = AsyncClient() + await client.connect('http://localhost:8000', auth={'token': test_jwt}) + + # Join game + await client.emit('join_game', {'game_id': test_game_id}) + + # Roll dice + await client.emit('roll_dice', {'game_id': test_game_id}) + + # Wait for response + result = await client.receive() + assert result[0] == 'dice_rolled' + assert 'roll_id' in result[1] + + await client.disconnect() +``` + +--- + +## Troubleshooting + +### Connection Issues + +**Problem**: Client can't connect to WebSocket + +**Checklist**: +1. Verify JWT token is valid and not expired +2. Check CORS settings in `app/config.py` +3. Ensure Socket.io versions match (client and server) +4. Check server logs for connection rejection reasons +5. Verify network/firewall allows WebSocket connections +6. Test with curl: `curl -H "Authorization: Bearer TOKEN" http://localhost:8000/socket.io/` + +**Debug logs**: +```python +# Enable Socket.io debug logging +import socketio +sio = socketio.AsyncServer(logger=True, engineio_logger=True) +``` + +--- + +### Events Not Received + +**Problem**: Client emits event but no response + +**Checklist**: +1. Verify event name matches exactly (case-sensitive) +2. Check server logs for handler errors +3. Ensure game_id exists and is valid UUID +4. Verify user is in game room (call `join_game` first) +5. Check data format matches expected structure + +**Debug**: +```javascript +// Client-side logging +socket.onAny((event, data) => { + console.log('Received:', event, data); +}); + +socket.on('error', (data) => { + console.error('Error:', data); +}); +``` + +--- + +### Game State Desynchronization + +**Problem**: Client UI doesn't match server state + +**Common Causes**: +1. Client missed broadcast due to disconnection +2. Event handler error prevented broadcast +3. Client state update logic has bug + +**Solutions**: + +1. **Add state synchronization event**: + +```python +@sio.event +async def request_game_state(sid, data): + """Client requests full game state (recovery after disconnect)""" + game_id = UUID(data.get("game_id")) + state = state_manager.get_state(game_id) + + if state: + await manager.emit_to_user(sid, "game_state", state.model_dump()) + else: + await manager.emit_to_user(sid, "error", {"message": "Game not found"}) +``` + +2. **Implement reconnection logic**: + +```javascript +socket.on('reconnect', () => { + console.log('Reconnected - requesting state'); + socket.emit('request_game_state', { game_id: currentGameId }); +}); +``` + +--- + +### Broadcast Not Reaching All Players + +**Problem**: Some users don't receive broadcasts + +**Checklist**: +1. Verify all users called `join_game` +2. Check `game_rooms` dict in ConnectionManager +3. Ensure room name matches game_id exactly +4. Verify users haven't silently disconnected + +**Debug**: +```python +# Add logging to broadcasts +async def broadcast_to_game(self, game_id: str, event: str, data: dict) -> None: + participants = self.get_game_participants(game_id) + logger.info(f"Broadcasting {event} to {len(participants)} participants") + + await self.sio.emit(event, data, room=game_id) + + # Verify delivery + for sid in participants: + user_id = self.user_sessions.get(sid) + logger.debug(f"Sent to user {user_id} (sid={sid})") +``` + +--- + +### Pending Roll Not Found + +**Problem**: `submit_manual_outcome` fails with "No pending dice roll" + +**Common Causes**: +1. User didn't call `roll_dice` first +2. Roll expired due to timeout +3. Another user already submitted outcome +4. Server restarted (in-memory state lost) + +**Solutions**: +1. Enforce UI workflow: disable submit button until `dice_rolled` received +2. Add roll expiration check (optional): + +```python +# In roll_dice handler +state.pending_manual_roll = ab_roll +state.pending_manual_roll_expires_at = pendulum.now('UTC').add(minutes=5) + +# In submit_manual_outcome handler +if state.pending_manual_roll_expires_at < pendulum.now('UTC'): + state.pending_manual_roll = None + await manager.emit_to_user(sid, "outcome_rejected", { + "message": "Roll expired - please roll again", + "field": "game_state" + }) + return +``` + +3. Implement roll persistence in database for recovery + +--- + +### Authorization Not Enforced + +**Problem**: Users can perform actions they shouldn't be able to + +**Current Status**: Authorization checks are stubbed out with TODO comments + +**Implementation Plan**: + +1. **Create authorization service**: + +```python +# app/utils/authorization.py +async def is_game_participant(game_id: UUID, user_id: str) -> bool: + """Check if user is a participant in this game""" + # Query database for game participants + pass + +async def is_active_batter(game_id: UUID, user_id: str) -> bool: + """Check if user is the active batter""" + state = state_manager.get_state(game_id) + # Check current batter lineup ID against user's lineup assignments + pass + +async def is_game_admin(game_id: UUID, user_id: str) -> bool: + """Check if user is game creator or admin""" + pass +``` + +2. **Add checks to handlers**: + +```python +from app.utils.authorization import is_game_participant, is_active_batter + +@sio.event +async def submit_manual_outcome(sid, data): + game_id = UUID(data.get("game_id")) + user_id = manager.user_sessions.get(sid) + + # Verify participation + if not await is_game_participant(game_id, user_id): + await manager.emit_to_user(sid, "error", {"message": "Not authorized"}) + return + + # Verify active player + if not await is_active_batter(game_id, user_id): + await manager.emit_to_user(sid, "error", {"message": "Not your turn"}) + return + + # Process action + ... +``` + +--- + +### Memory Leaks in ConnectionManager + +**Problem**: `user_sessions` or `game_rooms` grows indefinitely + +**Prevention**: +1. `disconnect()` handler automatically cleans up sessions +2. Monitor dict sizes: + +```python +@sio.event +async def heartbeat(sid): + await sio.emit("heartbeat_ack", {}, room=sid) + + # Periodic cleanup (every 100 heartbeats) + if random.randint(1, 100) == 1: + logger.info(f"Active sessions: {len(manager.user_sessions)}") + logger.info(f"Active games: {len(manager.game_rooms)}") +``` + +3. Add periodic cleanup task: + +```python +async def cleanup_stale_sessions(): + """Remove sessions that haven't sent heartbeat in 5 minutes""" + while True: + await asyncio.sleep(300) # 5 minutes + + stale_sids = [] + for sid, user_id in manager.user_sessions.items(): + # Check last heartbeat timestamp + if is_stale(sid): + stale_sids.append(sid) + + for sid in stale_sids: + await manager.disconnect(sid) + + if stale_sids: + logger.info(f"Cleaned up {len(stale_sids)} stale sessions") +``` + +--- + +## Examples + +### Example 1: Complete Dice Roll → Outcome Flow + +**Server-side handlers**: + +```python +# handlers.py +@sio.event +async def roll_dice(sid, data): + game_id = UUID(data.get("game_id")) + state = state_manager.get_state(game_id) + + # Roll dice + ab_roll = dice_system.roll_ab(league_id=state.league_id, game_id=game_id) + + # Store pending roll + state.pending_manual_roll = ab_roll + state_manager.update_state(game_id, state) + + # Broadcast results + await manager.broadcast_to_game( + str(game_id), + "dice_rolled", + { + "roll_id": ab_roll.roll_id, + "d6_one": ab_roll.d6_one, + "d6_two_total": ab_roll.d6_two_total, + "chaos_d20": ab_roll.chaos_d20, + "message": "Read your card and submit outcome" + } + ) + +@sio.event +async def submit_manual_outcome(sid, data): + game_id = UUID(data.get("game_id")) + outcome_str = data.get("outcome") + hit_location = data.get("hit_location") + + # Validate + submission = ManualOutcomeSubmission( + outcome=outcome_str, + hit_location=hit_location + ) + outcome = PlayOutcome(submission.outcome) + + # Get pending roll + state = state_manager.get_state(game_id) + ab_roll = state.pending_manual_roll + + # Confirm acceptance + await manager.emit_to_user(sid, "outcome_accepted", { + "outcome": outcome.value, + "hit_location": submission.hit_location + }) + + # Clear pending roll + state.pending_manual_roll = None + state_manager.update_state(game_id, state) + + # Resolve play + result = await game_engine.resolve_manual_play( + game_id=game_id, + ab_roll=ab_roll, + outcome=outcome, + hit_location=submission.hit_location + ) + + # Broadcast result + await manager.broadcast_to_game( + str(game_id), + "play_resolved", + { + "description": result.description, + "runs_scored": result.runs_scored, + "outs_recorded": result.outs_recorded + } + ) +``` + +**Client-side flow**: + +```javascript +// 1. Roll dice button clicked +document.getElementById('roll-btn').addEventListener('click', () => { + socket.emit('roll_dice', { game_id: currentGameId }); + setButtonState('rolling'); +}); + +// 2. Receive dice results +socket.on('dice_rolled', (data) => { + console.log('Dice:', data.d6_one, data.d6_two_total, data.chaos_d20); + + // Show dice animation + displayDiceResults(data); + + // Enable outcome selection + showOutcomeSelector(); +}); + +// 3. User selects outcome from UI +document.getElementById('submit-outcome-btn').addEventListener('click', () => { + const outcome = document.getElementById('outcome-select').value; + const hitLocation = document.getElementById('hit-location-select').value; + + socket.emit('submit_manual_outcome', { + game_id: currentGameId, + outcome: outcome, + hit_location: hitLocation + }); + + setButtonState('submitting'); +}); + +// 4. Receive confirmation +socket.on('outcome_accepted', (data) => { + console.log('Outcome accepted:', data.outcome); + showSuccessMessage('Outcome accepted - resolving play...'); +}); + +// 5. Receive play result +socket.on('play_resolved', (data) => { + console.log('Play result:', data.description); + + // Update game state UI + updateScore(data.runs_scored); + updateOuts(data.outs_recorded); + addPlayToLog(data.description); + + // Reset for next play + resetDiceRoller(); + setButtonState('ready'); +}); + +// 6. Handle errors +socket.on('outcome_rejected', (data) => { + console.error('Outcome rejected:', data.message, data.field); + showErrorMessage(`Error: ${data.message}`); + setButtonState('ready'); +}); +``` + +--- + +### Example 2: Broadcasting Team-Specific Data + +```python +@sio.event +async def request_hand_cards(sid, data): + """Send player's hand to them (but not opponents)""" + game_id = UUID(data.get("game_id")) + user_id = manager.user_sessions.get(sid) + + # Get user's team + team_id = get_user_team(user_id, game_id) + + # Get hand for that team + hand = get_team_hand(game_id, team_id) + + # Send ONLY to requesting user (private data) + await manager.emit_to_user(sid, "hand_cards", { + "cards": hand, + "team_id": team_id + }) + + # Broadcast to game that player viewed hand (no details) + await manager.broadcast_to_game(str(game_id), "player_action", { + "user_id": user_id, + "action": "viewed_hand" + }) +``` + +--- + +### Example 3: Handling Spectators vs Players + +```python +@sio.event +async def join_game(sid, data): + game_id = data.get("game_id") + role = data.get("role", "player") # "player" or "spectator" + + await manager.join_game(sid, game_id, role) + + # Store role in session data (extend ConnectionManager) + manager.user_roles[sid] = role + + if role == "spectator": + # Send spectator-specific state (no hidden info) + state = state_manager.get_state(UUID(game_id)) + spectator_state = state.to_spectator_view() + + await manager.emit_to_user(sid, "game_state", spectator_state) + else: + # Send full player state + state = state_manager.get_state(UUID(game_id)) + await manager.emit_to_user(sid, "game_state", state.model_dump()) + +# When broadcasting, respect roles +async def broadcast_play_result(game_id: str, result: PlayResult): + sids = manager.get_game_participants(game_id) + + for sid in sids: + role = manager.user_roles.get(sid, "player") + + if role == "spectator": + # Send spectator-safe data (no hole cards, etc.) + await manager.emit_to_user(sid, "play_resolved", result.to_spectator_view()) + else: + # Send full data + await manager.emit_to_user(sid, "play_resolved", result.model_dump()) +``` + +--- + +### Example 4: Reconnection Recovery + +```python +@sio.event +async def request_game_state(sid, data): + """ + Client requests full game state after reconnection. + + Use this to recover from disconnections without reloading page. + """ + game_id = UUID(data.get("game_id")) + user_id = manager.user_sessions.get(sid) + + # Verify user is participant + if not await is_game_participant(game_id, user_id): + await manager.emit_to_user(sid, "error", {"message": "Not authorized"}) + return + + # Get current state + state = state_manager.get_state(game_id) + if not state: + # Try to recover from database + state = await state_manager.recover_game(game_id) + + if not state: + await manager.emit_to_user(sid, "error", {"message": "Game not found"}) + return + + # Get recent plays for context + plays = await db_ops.get_plays(game_id, limit=10) + + # Send full state + await manager.emit_to_user(sid, "game_state_sync", { + "state": state.model_dump(), + "recent_plays": [p.to_dict() for p in plays], + "timestamp": pendulum.now('UTC').to_iso8601_string() + }) + + logger.info(f"Game state synced for user {user_id} in game {game_id}") +``` + +**Client-side**: + +```javascript +socket.on('reconnect', () => { + console.log('Reconnected - syncing state'); + socket.emit('request_game_state', { game_id: currentGameId }); +}); + +socket.on('game_state_sync', (data) => { + console.log('State synced:', data.timestamp); + + // Rebuild UI from full state + rebuildGameUI(data.state); + + // Show recent plays + displayRecentPlays(data.recent_plays); + + // Resume normal operation + enableGameControls(); +}); +``` + +--- + +## Performance Considerations + +### Broadcasting Efficiency + +- Socket.io's room-based broadcasting is O(n) where n = room size +- Keep room sizes reasonable (players + spectators, not entire league) +- Use targeted `emit_to_user()` for private data +- Serialize Pydantic models once, broadcast same dict to all users + +### Connection Scalability + +- Each connection consumes one socket + memory for session tracking +- Target: Support 100+ concurrent games (1000+ connections) +- Consider horizontal scaling with Redis pub/sub for multi-server: + +```python +# Future: Redis-backed Socket.io manager +sio = socketio.AsyncServer( + client_manager=socketio.AsyncRedisManager('redis://localhost:6379') +) +``` + +### Event Loop Blocking + +- Never use blocking I/O in event handlers (always `async/await`) +- Database writes are async (non-blocking) +- Heavy computation should use thread pool executor + +### Memory Management + +- ConnectionManager dicts are bounded by active connections +- StateManager handles game state eviction (idle timeout) +- No memory leaks if `disconnect()` handler works correctly + +--- + +## Security Considerations + +### Authentication +- ✅ All connections require valid JWT token +- ✅ Token verified in `connect()` handler before accepting +- ❌ TODO: Token expiration handling (refresh mechanism) + +### Authorization +- ❌ TODO: Verify user is participant before allowing actions +- ❌ TODO: Verify user is active player for turn-based actions +- ❌ TODO: Prevent spectators from performing player actions + +### Input Validation +- ✅ Pydantic models validate all inputs +- ✅ UUID validation for game_id +- ✅ Enum validation for outcomes +- ✅ Required field checks + +### Anti-Cheating +- ✅ Dice rolls are cryptographically secure (server-side) +- ✅ Pending roll is one-time use (cleared after submission) +- ❌ TODO: Rate limiting on dice rolls (prevent spam) +- ❌ TODO: Verify outcome matches roll (if cards are digitized) +- ❌ TODO: Track submission history for audit trail + +### Data Privacy +- Emit private data only to authorized users +- Use `emit_to_user()` for sensitive information +- Broadcasts should only contain public game state +- TODO: Implement spectator-safe data filtering + +--- + +## Future Enhancements + +### Planned Features + +1. **Authorization System** + - User-game participant mapping + - Role-based permissions (player, spectator, admin) + - Turn-based action validation + +2. **Reconnection Improvements** + - Automatic state synchronization on reconnect + - Missed event replay + - Persistent pending actions + +3. **Spectator Mode** + - Separate spectator rooms + - Filtered game state (no hidden information) + - Spectator chat + +4. **Rate Limiting** + - Prevent event spam + - Configurable limits per event type + - IP-based blocking for abuse + +5. **Analytics Events** + - Track user actions for analytics + - Performance monitoring + - Error rate tracking + +6. **Advanced Broadcasting** + - Team-specific channels + - Private player-to-player messaging + - Game admin announcements + +--- + +## Related Documentation + +- **Game Engine**: `../core/CLAUDE.md` - Play resolution logic +- **State Manager**: `../core/CLAUDE.md` - In-memory state management +- **Database**: `../database/CLAUDE.md` - Persistence layer +- **Models**: `../models/CLAUDE.md` - Pydantic game state models +- **WebSocket Protocol**: `../../../.claude/implementation/websocket-protocol.md` - Event specifications + +--- + +## Quick Reference + +### Event Summary + +| Event | Direction | Purpose | Authentication | +|-------|-----------|---------|----------------| +| `connect` | Client → Server | Establish connection | JWT required | +| `disconnect` | Client → Server | End connection | Automatic | +| `join_game` | Client → Server | Join game room | ✅ Token | +| `leave_game` | Client → Server | Leave game room | ✅ Token | +| `heartbeat` | Client → Server | Keep-alive ping | ✅ Token | +| `roll_dice` | Client → Server | Roll dice for play | ✅ Token | +| `submit_manual_outcome` | Client → Server | Submit card outcome | ✅ Token | +| `connected` | Server → Client | Connection confirmed | - | +| `dice_rolled` | Server → Room | Dice results | - | +| `outcome_accepted` | Server → Client | Outcome confirmed | - | +| `play_resolved` | Server → Room | Play result | - | +| `outcome_rejected` | Server → Client | Validation error | - | +| `error` | Server → Client | Generic error | - | + +### Common Imports + +```python +# WebSocket +from socketio import AsyncServer +from app.websocket.connection_manager import ConnectionManager + +# Game Logic +from app.core.state_manager import state_manager +from app.core.game_engine import game_engine +from app.core.dice import dice_system + +# Models +from app.models.game_models import ManualOutcomeSubmission +from app.config.result_charts import PlayOutcome + +# Validation +from uuid import UUID +from pydantic import ValidationError + +# Logging +import logging +logger = logging.getLogger(f'{__name__}.handlers') +``` + +--- + +**Last Updated**: 2025-10-31 +**Module Version**: Week 5 Implementation +**Status**: Production-ready for manual outcome gameplay diff --git a/backend/tests/CLAUDE.md b/backend/tests/CLAUDE.md new file mode 100644 index 0000000..ac4004c --- /dev/null +++ b/backend/tests/CLAUDE.md @@ -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. diff --git a/backend/tests/unit/config/test_result_charts.py b/backend/tests/unit/config/test_result_charts.py index b9075c6..8097758 100644 --- a/backend/tests/unit/config/test_result_charts.py +++ b/backend/tests/unit/config/test_result_charts.py @@ -416,22 +416,33 @@ class TestManualOutcomeSubmission: def test_valid_outcome_with_location(self): """Test valid submission with outcome and location""" submission = ManualOutcomeSubmission( - outcome='groundball_c', + outcome=PlayOutcome.GROUNDBALL_C, hit_location='SS' ) - assert submission.outcome == 'groundball_c' + assert submission.outcome == PlayOutcome.GROUNDBALL_C assert submission.hit_location == 'SS' def test_valid_outcome_without_location(self): """Test valid submission without location""" submission = ManualOutcomeSubmission( - outcome='strikeout' + outcome=PlayOutcome.STRIKEOUT ) - assert submission.outcome == 'strikeout' + assert submission.outcome == PlayOutcome.STRIKEOUT 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): """Test invalid outcome value raises ValidationError""" with pytest.raises(ValidationError) as exc_info: @@ -443,7 +454,7 @@ class TestManualOutcomeSubmission: """Test invalid hit location raises ValidationError""" with pytest.raises(ValidationError) as exc_info: ManualOutcomeSubmission( - outcome='groundball_c', + outcome=PlayOutcome.GROUNDBALL_C, hit_location='INVALID' ) @@ -455,7 +466,7 @@ class TestManualOutcomeSubmission: for location in valid_locations: submission = ManualOutcomeSubmission( - outcome='groundball_c', + outcome=PlayOutcome.GROUNDBALL_C, hit_location=location ) assert submission.hit_location == location @@ -463,7 +474,7 @@ class TestManualOutcomeSubmission: def test_none_hit_location_is_valid(self): """Test None for hit_location is valid""" submission = ManualOutcomeSubmission( - outcome='strikeout', + outcome=PlayOutcome.STRIKEOUT, hit_location=None )