strat-gameplay-webapp/backend/CLAUDE.md
Cal Corum 918beadf24 CLAUDE: Add interactive terminal client for game engine testing
Created comprehensive terminal testing tool with two modes:
1. Interactive REPL (recommended) - Persistent in-memory state
2. Standalone CLI commands - Config file persistence

Features:
- Interactive REPL using Python cmd module
- Persistent event loop prevents DB connection issues
- 11 commands for full game control (new_game, defensive, offensive, resolve, etc.)
- Beautiful Rich formatting with colors and panels
- Auto-generated test lineups for rapid testing
- Direct GameEngine access (no WebSocket overhead)
- Config file (~/.terminal_client_config.json) for state persistence

Files added:
- terminal_client/repl.py (525 lines) - Interactive REPL
- terminal_client/main.py (516 lines) - Click standalone commands
- terminal_client/display.py (218 lines) - Rich formatting
- terminal_client/config.py (89 lines) - Persistent config
- terminal_client/__main__.py - Dual mode entry point
- terminal_client/CLAUDE.md (725 lines) - Full documentation

Updated:
- backend/CLAUDE.md - Added terminal client to testing section
- requirements.txt - Added rich==13.9.4

Perfect for rapid iteration on game engine without building frontend!

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-26 12:51:01 -05:00

38 KiB

Backend - Paper Dynasty Game Engine

Overview

FastAPI-based real-time game backend handling WebSocket communication, game state management, and database persistence for both SBA and PD leagues.

Technology Stack

  • Framework: FastAPI (Python 3.13)
  • WebSocket: Socket.io (python-socketio)
  • Database: PostgreSQL 14+ with SQLAlchemy 2.0 (async)
  • ORM: SQLAlchemy with asyncpg driver
  • Validation: Pydantic v2
  • DateTime: Pendulum 3.0 (replaces Python's datetime module)
  • Testing: pytest with pytest-asyncio
  • Code Quality: black, flake8, mypy

Project Structure

backend/
├── app/
│   ├── main.py                 # FastAPI app + Socket.io initialization
│   ├── config.py               # Settings with pydantic-settings
│   │
│   ├── core/                   # Game logic (Phase 2+)
│   │   ├── game_engine.py      # Main game simulation
│   │   ├── state_manager.py    # In-memory state
│   │   ├── play_resolver.py    # Play outcome resolution
│   │   ├── dice.py             # Secure random rolls
│   │   └── validators.py       # Rule validation
│   │
│   ├── config/                 # League configurations (Phase 2+)
│   │   ├── base_config.py      # Shared configuration
│   │   ├── league_configs.py   # SBA/PD specific
│   │   └── result_charts.py    # d20 outcome tables
│   │
│   ├── models/                 # Data models
│   │   ├── db_models.py        # SQLAlchemy ORM models
│   │   ├── game_models.py      # Pydantic game state models (Phase 2+)
│   │   └── player_models.py    # Polymorphic player models (Phase 2+)
│   │
│   ├── websocket/              # WebSocket handling
│   │   ├── connection_manager.py  # Connection lifecycle
│   │   ├── handlers.py            # Event handlers
│   │   └── events.py              # Event definitions (Phase 2+)
│   │
│   ├── api/                    # REST API
│   │   ├── routes/
│   │   │   ├── health.py       # Health check endpoints
│   │   │   ├── auth.py         # Discord OAuth (Phase 1)
│   │   │   └── games.py        # Game CRUD (Phase 2+)
│   │   └── dependencies.py     # FastAPI dependencies
│   │
│   ├── database/               # Database layer
│   │   ├── session.py          # Async session management
│   │   └── operations.py       # DB operations (Phase 2+)
│   │
│   ├── data/                   # External data (Phase 2+)
│   │   ├── api_client.py       # League REST API client
│   │   └── cache.py            # Caching layer
│   │
│   └── utils/                  # Utilities
│       ├── logging.py          # Logging setup
│       └── auth.py             # JWT utilities (Phase 1)
│
├── tests/
│   ├── unit/                   # Unit tests
│   ├── integration/            # Integration tests
│   └── e2e/                    # End-to-end tests
│
├── terminal_client/            # Interactive testing REPL
│   ├── __init__.py             # Package marker
│   ├── __main__.py             # Entry point
│   ├── repl.py                 # Interactive REPL (cmd module)
│   ├── main.py                 # Click CLI commands
│   ├── display.py              # Rich formatting
│   ├── config.py               # Persistent config file
│   └── CLAUDE.md               # Terminal client docs
│
├── logs/                       # Application logs (gitignored)
├── venv/                       # Virtual environment (gitignored)
├── .env                        # Environment variables (gitignored)
├── .env.example                # Environment template
├── requirements.txt            # Production dependencies
├── requirements-dev.txt        # Dev dependencies
├── Dockerfile                  # Container definition
├── docker-compose.yml          # Redis for local dev
└── pytest.ini                  # Pytest configuration

Key Architectural Patterns

1. Hybrid State Management

  • In-Memory: Active game states for fast access (<500ms response)
  • PostgreSQL: Persistent storage for recovery and history
  • Pattern: Write-through cache (update memory + async DB write)

2. Polymorphic Player Models

# Base class with abstract methods
class BasePlayer(BaseModel, ABC):
    @abstractmethod
    def get_image_url(self) -> str: ...

# League-specific implementations
class SbaPlayer(BasePlayer): ...
class PdPlayer(BasePlayer): ...

# Factory pattern for instantiation
Lineup.from_api_data(config, data)

3. League-Agnostic Core

  • Game engine works for any league
  • League-specific logic in config classes
  • Result charts loaded per league

4. Async-First

  • All database operations use async/await
  • Database writes don't block game logic
  • Connection pooling for efficiency

Development Workflow

Daily Development

# Activate virtual environment
source venv/bin/activate

# Start Redis (in separate terminal or use -d for detached)
docker compose up -d

# Run backend with hot-reload
python -m app.main

# Backend available at http://localhost:8000
# API docs at http://localhost:8000/docs

Testing

Terminal Client (Interactive Game Engine Testing)

Test the game engine directly without needing a frontend:

# Start interactive REPL (recommended for rapid testing)
python -m terminal_client

# Then interact:
⚾ > new_game
⚾ > defensive
⚾ > offensive
⚾ > resolve
⚾ > quick_play 10
⚾ > status
⚾ > quit

Features:

  • Persistent in-memory state throughout session
  • Direct GameEngine access (no WebSocket overhead)
  • Beautiful Rich formatting
  • Auto-generated test lineups
  • Perfect for rapid iteration

See terminal_client/CLAUDE.md for full documentation.

Unit & Integration Tests

# Run all tests
pytest tests/ -v

# Run with coverage
pytest tests/ --cov=app --cov-report=html

# Run specific test file
pytest tests/unit/test_game_engine.py -v

# Type checking
mypy app/

# Code formatting
black app/ tests/

# Linting
flake8 app/ tests/

Coding Standards

Python Style

  • Formatting: Black with default settings
  • Line Length: 88 characters (black default)
  • Imports: Group stdlib, third-party, local (isort compatible)
  • Type Hints: Required for all public functions
  • Docstrings: Google style for classes and public methods

Logging Pattern

import logging

logger = logging.getLogger(f'{__name__}.ClassName')

# Usage
logger.info(f"User {user_id} connected")
logger.error(f"Failed to process action: {error}", exc_info=True)

DateTime Handling

ALWAYS use Pendulum, NEVER use Python's datetime module:

import pendulum

# Get current UTC time
now = pendulum.now('UTC')

# Format for display
formatted = now.format('YYYY-MM-DD HH:mm:ss')
formatted_iso = now.to_iso8601_string()

# Parse dates
parsed = pendulum.parse('2025-10-21')

# Timezones
eastern = pendulum.now('America/New_York')
utc = eastern.in_timezone('UTC')

# Database defaults (in models)
created_at = Column(DateTime, default=lambda: pendulum.now('UTC'))

Error Handling

  • Raise or Return: Never return Optional unless specifically required
  • Custom Exceptions: Use for domain-specific errors
  • Logging: Always log exceptions with context

Dataclasses

from dataclasses import dataclass

@dataclass
class GameState:
    game_id: str
    inning: int
    outs: int
    # ... fields

Type Checking & Common False Positives

Overview

This project uses Pylance (Pyright) for real-time type checking in VS Code and mypy for validation. Due to SQLAlchemy's ORM magic and Pydantic's settings pattern, we encounter known false positives that must be handled strategically.

Critical: Do NOT disable type checking globally. Use targeted suppressions only where needed.

🔴 Known False Positive #1: SQLAlchemy Model Attributes

Problem: SQLAlchemy model instances have .id, .position, etc. that are int/str at runtime but typed as Column[int]/Column[str] for type checkers.

Symptom:

Cannot assign to attribute "current_batter_lineup_id" for class "GameState"
  Type "Column[int]" is not assignable to type "int | None"

Solution: Use targeted # type: ignore[assignment] comments:

# ❌ DON'T: Disable all type checking
lineup_player.id  # type: ignore

# ❌ DON'T: Runtime conversion (unnecessary overhead)
int(lineup_player.id)

# ✅ DO: Targeted suppression with explanation
lineup_player.id  # type: ignore[assignment]  # SQLAlchemy Column is int at runtime

When to Apply:

  • Assigning SQLAlchemy model attributes to Pydantic model fields
  • Common locations: game_engine.py, any code interfacing between ORM and Pydantic

Example:

# In game_engine.py
state.current_batter_lineup_id = batting_order[current_idx].id  # type: ignore[assignment]
state.current_pitcher_lineup_id = pitcher.id if pitcher else None  # type: ignore[assignment]

🔴 Known False Positive #2: SQLAlchemy Declarative Base

Problem: mypy doesn't understand SQLAlchemy's declarative_base() pattern.

Symptom:

app/models/db_models.py:10: error: Variable "app.database.session.Base" is not valid as a type
app/models/db_models.py:10: error: Invalid base class "Base"

Solution: Configure mypy.ini to disable strict checking for ORM files:

[mypy-app.models.db_models]
disallow_untyped_defs = False
warn_return_any = False

[mypy-app.database.operations]
disallow_untyped_defs = False

Status: Already configured in mypy.ini. These warnings are expected and safe to ignore.

🔴 Known False Positive #3: Pydantic Settings Constructor

Problem: Pydantic BaseSettings loads from environment variables, not constructor arguments.

Symptom:

app/config.py:48: error: Missing named argument "secret_key" for "Settings"
app/config.py:48: error: Missing named argument "database_url" for "Settings"

Solution: Configure mypy.ini to disable checks for config module:

[mypy-app.config]
disallow_untyped_defs = False

Status: Already configured. This is expected Pydantic-settings behavior.

🟡 Legitimate Type Issues to Fix

Missing Type Annotations

Problem: Dictionary or variable without type hint.

Symptom:

Need type annotation for "position_counts"

Solution: Add explicit type annotation:

# ❌ Missing type hint
position_counts = {}

# ✅ With type hint
position_counts: dict[str, int] = {}

Optional in Lambda/Sorted

Problem: Using Optional[int] in comparison context.

Symptom:

Argument "key" to "sorted" has incompatible type "Callable[[LineupPlayerState], int | None]"

Solution: Provide fallback value in lambda:

# ❌ Optional can be None
sorted(players, key=lambda x: x.batting_order)

# ✅ With fallback (we already filtered None above)
sorted(players, key=lambda x: x.batting_order or 0)

Best Practices

DO: Use Specific Type Ignore Codes

# Specific - only ignores assignment mismatch
value = something  # type: ignore[assignment]

# Specific - only ignores argument type
func(arg)  # type: ignore[arg-type]

DO: Add Explanatory Comments

# SQLAlchemy model .id is int at runtime, but typed as Column[int]
state.current_batter_lineup_id = lineup_player.id  # type: ignore[assignment]

DO: Keep Type Checking Strict Elsewhere

Only suppress where SQLAlchemy/Pydantic cause false positives. New code should pass type checking without suppressions.

DON'T: Disable Type Checking Globally

# ❌ Too broad - hides real errors
# type: ignore at top of file

# ❌ Disables all checks for file
# In pyrightconfig.json: "ignore": ["app/core/game_engine.py"]

DON'T: Use Unnecessary Runtime Conversions

# ❌ int() is unnecessary (already int at runtime) and adds overhead
state.current_batter_lineup_id = int(lineup_player.id)

# ✅ Direct assignment with targeted suppression
state.current_batter_lineup_id = lineup_player.id  # type: ignore[assignment]

Configuration Files

mypy.ini

Manages mypy-specific type checking configuration:

  • Disables strict checks for SQLAlchemy ORM files (db_models.py, operations.py)
  • Disables checks for Pydantic Settings (config.py)
  • Enables SQLAlchemy plugin support

pyrightconfig.json

Manages Pylance/Pyright configuration in VS Code:

  • Sets typeCheckingMode: "basic" (not too strict)
  • Suppresses some SQLAlchemy-related global warnings
  • Keeps strict checking for application logic

Common Error Codes Reference

Code Meaning Common Fix
[assignment] Type mismatch in assignment SQLAlchemy Column → use # type: ignore[assignment]
[arg-type] Argument type mismatch SQLAlchemy Column in function call → use # type: ignore[arg-type]
[attr-defined] Attribute doesn't exist Usually a real error - check for typos
[var-annotated] Missing type annotation Add : dict[str, int] etc.
[return-value] Return type mismatch Usually a real error - fix return value
[operator] Unsupported operation Check if operation makes sense

Verification Checklist

Before considering type warnings as false positives:

  1. Is it a SQLAlchemy model attribute? → Use # type: ignore[assignment]
  2. Is it in db_models.py or operations.py? → Expected, configured in mypy.ini
  3. Is it in config.py (Pydantic Settings)? → Expected, configured in mypy.ini
  4. Is it in game logic (game_engine.py, validators.py, etc.)? → FIX IT - likely a real issue

Example: Correct SQLAlchemy-Pydantic Bridging

async def _prepare_next_play(self, state: GameState) -> None:
    """Prepare snapshot for the next play."""

    # Fetch SQLAlchemy models from database
    batting_lineup = await self.db_ops.get_active_lineup(state.game_id, batting_team)
    fielding_lineup = await self.db_ops.get_active_lineup(state.game_id, fielding_team)

    # Extract values for Pydantic model
    # SQLAlchemy model .id is int at runtime, but typed as Column[int]
    state.current_batter_lineup_id = batting_order[current_idx].id  # type: ignore[assignment]

    pitcher = next((p for p in fielding_lineup if p.position == "P"), None)
    state.current_pitcher_lineup_id = pitcher.id if pitcher else None  # type: ignore[assignment]

Resources

  • Type Checking Guide: .claude/type-checking-guide.md (comprehensive documentation)
  • mypy Configuration: mypy.ini (type checker settings)
  • Pylance Configuration: pyrightconfig.json (VS Code settings)

Database Models

Our database schema is designed based on the proven Discord game implementation, with enhancements for web real-time gameplay.

Core Tables

Game (games)

Primary game container with state tracking.

Key Fields:

  • id (UUID): Primary key, prevents ID collisions across distributed systems
  • league_id (String): 'sba' or 'pd', determines league-specific behavior
  • status (String): 'pending', 'active', 'completed'
  • game_mode (String): 'ranked', 'friendly', 'practice'
  • visibility (String): 'public', 'private'
  • current_inning, current_half: Current game state
  • home_score, away_score: Running scores

AI Support:

  • home_team_is_ai (Boolean): Home team controlled by AI
  • away_team_is_ai (Boolean): Away team controlled by AI
  • ai_difficulty (String): 'balanced', 'yolo', 'safe'

Relationships:

  • plays: All plays in the game (cascade delete)
  • lineups: All lineup entries (cascade delete)
  • cardset_links: PD only - approved cardsets (cascade delete)
  • roster_links: Roster tracking - cards (PD) or players (SBA) (cascade delete)
  • session: Real-time WebSocket session (cascade delete)

Play (plays)

Records every at-bat with full statistics and game state.

Game State Snapshot:

  • play_number: Sequential play counter
  • inning, half: Inning state
  • outs_before: Outs at start of play
  • batting_order: Current spot in order
  • away_score, home_score: Score at play start

Player References (FKs to Lineup):

  • batter_id, pitcher_id, catcher_id: Required players
  • defender_id, runner_id: Optional for specific plays
  • on_first_id, on_second_id, on_third_id: Base runners

Runner Outcomes:

  • on_first_final, on_second_final, on_third_final: Final base (None = out, 1-4 = base)
  • batter_final: Where batter ended up
  • on_base_code (Integer): Bit field for efficient queries (1=1st, 2=2nd, 4=3rd, 7=loaded)

Strategic Decisions:

  • defensive_choices (JSON): Alignment, holds, shifts
  • offensive_choices (JSON): Steal attempts, bunts, hit-and-run

Play Result:

  • dice_roll (String): Dice notation (e.g., "14+6")
  • hit_type (String): GB, FB, LD, etc.
  • result_description (Text): Human-readable result
  • outs_recorded, runs_scored: Play outcome
  • check_pos (String): Defensive position for X-check

Batting Statistics (25+ fields):

  • pa, ab, hit, double, triple, homerun
  • bb, so, hbp, rbi, sac, ibb, gidp
  • sb, cs: Base stealing
  • wild_pitch, passed_ball, pick_off, balk
  • bphr, bpfo, bp1b, bplo: Ballpark power events
  • run, e_run: Earned/unearned runs

Advanced Analytics:

  • wpa (Float): Win Probability Added
  • re24 (Float): Run Expectancy 24 base-out states

Game Situation Flags:

  • is_tied, is_go_ahead, is_new_inning: Context flags
  • in_pow: Pitcher over workload
  • complete, locked: Workflow state

Helper Properties:

@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 (lineups)

Tracks player assignments and substitutions.

Key Fields:

  • game_id, team_id, card_id: Links to game/team/card
  • position (String): P, C, 1B, 2B, 3B, SS, LF, CF, RF, DH
  • batting_order (Integer): 1-9

Substitution Tracking:

  • is_starter (Boolean): Original lineup vs substitute
  • is_active (Boolean): Currently in game
  • entered_inning (Integer): When player entered
  • replacing_id (Integer): Lineup ID of replaced player
  • after_play (Integer): Exact play number of substitution

Pitcher Management:

  • is_fatigued (Boolean): Triggers bullpen decisions

PD league only - defines legal cardsets for a game.

Key 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)

Tracks eligible cards (PD) or players (SBA) for a game.

Polymorphic Design: Single table supporting both leagues with application-layer type safety.

Key Fields:

  • id (Integer): Surrogate primary key (auto-increment)
  • game_id (UUID): Foreign key to games table
  • card_id (Integer, nullable): PD league - card identifier
  • player_id (Integer, nullable): SBA league - player identifier
  • team_id (Integer): Which team owns this entity in this game

Constraints:

  • roster_link_one_id_required: CHECK constraint ensures exactly one of card_id or player_id is populated (XOR logic)
  • uq_game_card: UNIQUE constraint on (game_id, card_id) for PD
  • uq_game_player: UNIQUE constraint on (game_id, player_id) for SBA

Usage Pattern:

# PD league - add card to roster
roster_data = await db_ops.add_pd_roster_card(
    game_id=game_id,
    card_id=123,
    team_id=1
)

# SBA league - add player to roster
roster_data = await db_ops.add_sba_roster_player(
    game_id=game_id,
    player_id=456,
    team_id=2
)

# Get roster (league-specific)
pd_roster = await db_ops.get_pd_roster(game_id, team_id=1)
sba_roster = await db_ops.get_sba_roster(game_id, team_id=2)

Design Rationale:

  • Single table avoids complex joins and simplifies queries
  • Nullable columns with CHECK constraint ensures data integrity at database level
  • Pydantic models (PdRosterLinkData, SbaRosterLinkData) provide type safety at application layer
  • Surrogate key allows nullable columns (can't use nullable columns in composite PK)

GameSession (game_sessions)

Real-time WebSocket state tracking.

Key Fields:

  • game_id (UUID): Primary key, one-to-one with Game
  • connected_users (JSON): Active WebSocket connections
  • last_action_at (DateTime): Last activity timestamp
  • state_snapshot (JSON): In-memory game state cache

Database Patterns

Async Session Usage

from app.database.session import get_session

async def some_function():
    async with get_session() as session:
        result = await session.execute(query)
        # session.commit() happens automatically

Model Definitions

from app.database.session import Base
from sqlalchemy import Column, String, Integer

class Game(Base):
    __tablename__ = "games"

    id = Column(UUID(as_uuid=True), primary_key=True)
    # ... columns

Relationship Patterns

Using Lazy Loading:

# In Play model - common players loaded automatically
batter = relationship("Lineup", foreign_keys=[batter_id], lazy="joined")
pitcher = relationship("Lineup", foreign_keys=[pitcher_id], lazy="joined")

# Rare players loaded on demand
defender = relationship("Lineup", foreign_keys=[defender_id])  # lazy="select" (default)

Querying with Relationships:

from sqlalchemy.orm import joinedload

# Efficient loading for broadcasting
play = await session.execute(
    select(Play)
    .options(
        joinedload(Play.batter),
        joinedload(Play.pitcher),
        joinedload(Play.on_first),
        joinedload(Play.on_second),
        joinedload(Play.on_third)
    )
    .where(Play.id == play_id)
)

Common Query Patterns

Find plays with bases loaded:

# Using on_base_code bit field
bases_loaded_plays = await session.execute(
    select(Play).where(Play.on_base_code == 7)  # 1+2+4 = 7
)

Get active pitcher for team:

pitcher = await session.execute(
    select(Lineup)
    .where(
        Lineup.game_id == game_id,
        Lineup.team_id == team_id,
        Lineup.position == 'P',
        Lineup.is_active == True
    )
)

Calculate box score stats:

# Player batting stats for game
stats = await session.execute(
    select(
        func.sum(Play.ab).label('ab'),
        func.sum(Play.hit).label('hits'),
        func.sum(Play.homerun).label('hr'),
        func.sum(Play.rbi).label('rbi')
    )
    .where(
        Play.game_id == game_id,
        Play.batter_id == lineup_id
    )
)

WebSocket Patterns

Event Handler Registration

@sio.event
async def some_event(sid, data):
    """Handle some_event from client"""
    try:
        # Validate data
        # Process action
        # Emit response
        await sio.emit('response_event', result, room=sid)
    except Exception as e:
        logger.error(f"Error handling event: {e}")
        await sio.emit('error', {'message': str(e)}, room=sid)

Broadcasting

# To specific game room
await connection_manager.broadcast_to_game(
    game_id,
    'game_state_update',
    state_data
)

# To specific user
await connection_manager.emit_to_user(
    sid,
    'decision_required',
    decision_data
)

Environment Variables

Required in .env:

# Database
DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/dbname

# Application
SECRET_KEY=your-secret-key-at-least-32-chars

# Discord OAuth
DISCORD_CLIENT_ID=your-client-id
DISCORD_CLIENT_SECRET=your-client-secret

# League APIs
SBA_API_URL=https://sba-api.example.com
SBA_API_KEY=your-api-key
PD_API_URL=https://pd-api.example.com
PD_API_KEY=your-api-key

Performance Targets

  • Action Response: < 500ms from user action to state update
  • WebSocket Delivery: < 200ms
  • Database Write: < 100ms (async, non-blocking)
  • State Recovery: < 2 seconds
  • Concurrent Games: Support 10+ simultaneous games
  • Memory: < 1GB with 10 active games

Security Considerations

  • Authentication: All WebSocket connections require valid JWT
  • Authorization: Verify team ownership before allowing actions
  • Input Validation: Pydantic models validate all inputs
  • SQL Injection: Prevented by SQLAlchemy ORM
  • Dice Rolls: Cryptographically secure random generation
  • Server-Side Logic: All game rules enforced server-side

Common Tasks

Adding a New API Endpoint

  1. Create route in app/api/routes/
  2. Define Pydantic request/response models
  3. Add dependency injection if needed
  4. Register router in app/main.py

Adding a New WebSocket Event

  1. Define event handler in app/websocket/handlers.py
  2. Register with @sio.event decorator
  3. Validate data with Pydantic
  4. Add corresponding client handling in frontend

Adding a New Database Model

  1. Define SQLAlchemy model in app/models/db_models.py
  2. Create Alembic migration: alembic revision --autogenerate -m "description"
  3. Apply migration: alembic upgrade head

Troubleshooting

Import Errors

  • Ensure virtual environment is activated
  • Check PYTHONPATH if using custom structure
  • Verify all __init__.py files exist

Database Connection Issues

  • Verify DATABASE_URL in .env is correct
  • Test connection: psql $DATABASE_URL
  • Check firewall/network access
  • Verify database exists

WebSocket Not Connecting

  • Check CORS settings in config.py
  • Verify token is being sent from client
  • Check logs for connection errors
  • Ensure Socket.io versions match (client/server)

Environment-Specific Configuration

Database Connection

  • Dev Server: PostgreSQL at 10.10.0.42:5432
  • Database Name: paperdynasty_dev
  • User: paperdynasty
  • Connection String Format: postgresql+asyncpg://paperdynasty:PASSWORD@10.10.0.42:5432/paperdynasty_dev

Docker Compose Commands

This system uses Docker Compose V2 (not legacy docker-compose):

# Correct (with space)
docker compose up -d
docker compose down
docker compose logs

# Incorrect (will not work)
docker-compose up -d  # Old command not available

Python Environment

  • Version: Python 3.13.3 (not 3.11 as originally planned)
  • Virtual Environment: Located at backend/venv/
  • Activation: source venv/bin/activate (from backend directory)

Critical Dependencies

  • greenlet: Required for SQLAlchemy async support (must be explicitly installed)
  • Pendulum: Used for ALL datetime operations (replaces Python's datetime module)
    import pendulum
    
    # Always use Pendulum
    now = pendulum.now('UTC')
    
    # Never use
    from datetime import datetime  # ❌ Don't import this
    

Environment Variable Format

IMPORTANT: Pydantic Settings requires specific formats for complex types:

# Lists must be JSON arrays
CORS_ORIGINS=["http://localhost:3000", "http://localhost:3001"]  # ✅ Correct

# NOT comma-separated strings
CORS_ORIGINS=http://localhost:3000,http://localhost:3001  # ❌ Will fail

SQLAlchemy Reserved Names

The following column names are reserved in SQLAlchemy and will cause errors:

# ❌ NEVER use these as column names
metadata = Column(JSON)  # RESERVED - will fail

# ✅ Use descriptive alternatives
game_metadata = Column(JSON)
play_metadata = Column(JSON)
lineup_metadata = Column(JSON)

Other reserved names to avoid: metadata, registry, __tablename__, __mapper__, __table__

Package Version Notes

Due to Python 3.13 compatibility, we use newer versions than originally planned:

# Updated versions (from requirements.txt)
fastapi==0.115.6          # (was 0.104.1)
uvicorn==0.34.0           # (was 0.24.0)
pydantic==2.10.6          # (was 2.5.0)
sqlalchemy==2.0.36        # (was 2.0.23)
asyncpg==0.30.0           # (was 0.29.0)
pendulum==3.0.0           # (new addition)

Server Startup

# From backend directory with venv activated
python -m app.main

# Server runs at:
# - Main API: http://localhost:8000
# - Swagger UI: http://localhost:8000/docs
# - ReDoc: http://localhost:8000/redoc

# With hot-reload enabled by default

Logs Directory

  • Auto-created at backend/logs/
  • Daily rotating logs: app_YYYYMMDD.log
  • 10MB max size, 5 backup files
  • Gitignored

Week 4 Implementation (Phase 2 - State Management)

Completed: 2025-10-22 Status: COMPLETE - All deliverables achieved

Components Implemented

1. Pydantic Game State Models (app/models/game_models.py)

Purpose: Type-safe models for in-memory game state representation

Key Models:

  • GameState: Core game state (inning, score, runners, decisions)
  • RunnerState: Base runner tracking
  • LineupPlayerState / TeamLineupState: Lineup management
  • DefensiveDecision / OffensiveDecision: Strategic decisions

Features:

  • Full Pydantic v2 validation with field validators
  • 20+ helper methods on GameState
  • Optimized for fast serialization (WebSocket broadcasts)
  • Game over logic, runner advancement, scoring

Usage Example:

from app.models.game_models import GameState, RunnerState

# Create game state
state = GameState(
    game_id=uuid4(),
    league_id="sba",
    home_team_id=1,
    away_team_id=2
)

# Add runner
state.add_runner(lineup_id=1, card_id=101, base=1)

# Advance runner and score
state.advance_runner(from_base=1, to_base=4)  # Scores run

2. State Manager (app/core/state_manager.py)

Purpose: In-memory state management with O(1) lookups

Key Features:

  • Dictionary-based storage: _states: Dict[UUID, GameState]
  • Lineup caching per game
  • Last access tracking with Pendulum timestamps
  • Idle game eviction (configurable timeout)
  • State recovery from database
  • Statistics tracking

Usage Example:

from app.core.state_manager import state_manager

# Create game
state = await state_manager.create_game(
    game_id=game_id,
    league_id="sba",
    home_team_id=1,
    away_team_id=2
)

# Get state (fast O(1) lookup)
state = state_manager.get_state(game_id)

# Update state
state.inning = 5
state_manager.update_state(game_id, state)

# Recover from database
recovered = await state_manager.recover_game(game_id)

Performance:

  • State access: O(1) via dictionary lookup
  • Memory per game: ~1KB (just state, no player data yet)
  • Target response time: <500ms

3. Database Operations (app/database/operations.py)

Purpose: Async PostgreSQL persistence layer

Key Methods:

  • create_game(): Create game in database
  • update_game_state(): Update inning, score, status
  • create_lineup_entry() / get_active_lineup(): Lineup persistence
  • save_play() / get_plays(): Play recording
  • load_game_state(): Complete state loading for recovery
  • create_game_session() / update_session_snapshot(): WebSocket state

Usage Example:

from app.database.operations import DatabaseOperations

db_ops = DatabaseOperations()

# Create 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"
)

# Update state (async, non-blocking)
await db_ops.update_game_state(
    game_id=game_id,
    inning=5,
    half="bottom",
    home_score=3,
    away_score=2
)

# Load for recovery
game_data = await db_ops.load_game_state(game_id)

Pattern: All operations use async/await with proper error handling

Testing

Unit Tests (86 tests, 100% passing):

  • tests/unit/models/test_game_models.py (60 tests)
  • tests/unit/core/test_state_manager.py (26 tests)

Integration Tests (29 tests written):

  • tests/integration/database/test_operations.py (21 tests)
  • tests/integration/test_state_persistence.py (8 tests)

Run Tests:

# All unit tests
pytest tests/unit/ -v

# Integration tests (requires database)
pytest tests/integration/ -v -m integration

# Specific file
pytest tests/unit/models/test_game_models.py -v

Patterns Established

1. Pydantic Field Validation

@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

2. Async Database Session Management

async with AsyncSessionLocal() as session:
    try:
        # database operations
        await session.commit()
    except Exception as e:
        await session.rollback()
        logger.error(f"Error: {e}")
        raise

3. State Recovery Pattern

# 1. Load from database
game_data = await db_ops.load_game_state(game_id)

# 2. Rebuild in-memory state
state = await state_manager._rebuild_state_from_data(game_data)

# 3. Cache in memory
state_manager._states[game_id] = state

4. Helper Methods on Models

# GameState helper methods
def get_batting_team_id(self) -> int:
    return self.away_team_id if self.half == "top" else self.home_team_id

def is_runner_on_first(self) -> bool:
    return any(r.on_base == 1 for r in self.runners)

def advance_runner(self, from_base: int, to_base: int) -> None:
    # Auto-score when to_base == 4
    if to_base == 4:
        if self.half == "top":
            self.away_score += 1
        else:
            self.home_score += 1

Configuration

pytest.ini (created):

[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function

Fixed Warnings:

  • Pydantic v2 config deprecation (migrated to model_config = ConfigDict())
  • pytest-asyncio loop scope configuration

Key Files

app/models/game_models.py      (492 lines) - Pydantic state models
app/core/state_manager.py      (296 lines) - In-memory state management
app/database/operations.py     (362 lines) - Async database operations
tests/unit/models/test_game_models.py      (788 lines, 60 tests)
tests/unit/core/test_state_manager.py      (447 lines, 26 tests)
tests/integration/database/test_operations.py      (438 lines, 21 tests)
tests/integration/test_state_persistence.py        (290 lines, 8 tests)
pytest.ini                     (23 lines)  - Test configuration

Next Phase (Week 5)

Week 5 will build game logic on top of this state management foundation:

  1. Dice system (cryptographic d20 rolls)
  2. Play resolver (result charts and outcome determination)
  3. Game engine (orchestrate complete at-bat flow)
  4. Rule validators (enforce baseball rules)
  5. Enhanced state recovery (replay plays to rebuild complete state)

References

  • Implementation Guide: ../.claude/implementation/01-infrastructure.md
  • Backend Architecture: ../.claude/implementation/backend-architecture.md
  • Week 4 Plan: ../.claude/implementation/02-week4-state-management.md
  • Week 5 Plan: ../.claude/implementation/02-week5-game-logic.md
  • Player Data Catalog: ../.claude/implementation/player-data-catalog.md
  • WebSocket Protocol: ../.claude/implementation/websocket-protocol.md
  • Database Design: ../.claude/implementation/database-design.md
  • Full PRD: ../prd-web-scorecard-1.1.md

Current Phase: Phase 2 - Game Engine Core (Week 4 Complete) Next Phase: Phase 2 - Game Logic (Week 5)

Phase 1 Completed: 2025-10-21 Week 4 Completed: 2025-10-22 Python Version: 3.13.3 Database Server: 10.10.0.42:5432

Database Model Updates (2025-10-21)

Enhanced all database models based on proven Discord game implementation:

Changes from Initial Design:

  • Added GameCardsetLink and RosterLink tables for PD league cardset management
  • Enhanced Game model with AI opponent support (home_team_is_ai, away_team_is_ai, ai_difficulty)
  • Added 25+ statistic fields to Play model (pa, ab, hit, hr, rbi, sb, wpa, re24, etc.)
  • Added player reference FKs to Play (batter, pitcher, catcher, defender, runner)
  • Added base runner tracking with on_base_code bit field for efficient queries
  • Added game situation flags to Play (is_tied, is_go_ahead, is_new_inning, in_pow)
  • Added play workflow flags (complete, locked)
  • Enhanced Lineup with substitution tracking (replacing_id, after_play, is_fatigued)
  • Changed strategic decisions to JSON (defensive_choices, offensive_choices)
  • Added helper properties for AI decision-making (ai_is_batting, ai_is_fielding)

Design Decisions:

  • UUID vs BigInteger: Kept UUIDs for Game primary key (better for distributed systems)
  • AI Tracking: Per-team booleans instead of single ai_team field (supports AI vs AI simulations)
  • Runner Tracking: Removed JSON fields, using FKs + on_base_code for type safety
  • Cardsets: Optional relationships - empty for SBA, required for PD
  • Relationships: Using SQLAlchemy relationships with strategic lazy loading

Lineup Polymorphic Migration (2025-10-23)

Updated Lineup model to support both PD and SBA leagues using polymorphic card_id/player_id fields, matching the RosterLink pattern.

Changes:

  • Made card_id nullable (PD league)
  • Added player_id nullable (SBA league)
  • Added XOR CHECK constraint: exactly one ID must be populated
  • Created league-specific methods: add_pd_lineup_card() and add_sba_lineup_player()
  • Fixed Pendulum DateTime + asyncpg compatibility issue with .naive()

Archived Files:

  • Migration documentation: ../../.claude/archive/LINEUP_POLYMORPHIC_MIGRATION.md
  • Migration script: ../../.claude/archive/migrate_lineup_schema.py

Note: Migration has been applied to database. Script archived for reference only.