strat-gameplay-webapp/backend/CLAUDE.md
Cal Corum beb939b32a CLAUDE: Fix all unit test failures and implement 100% test requirement
Test Fixes (609/609 passing):
- Fixed DiceSystem API to accept team_id/player_id parameters for audit trails
- Fixed dice roll history timing issue in test
- Fixed terminal client mock to match resolve_play signature (X-Check params)
- Fixed result chart test mocks with missing pitching fields
- Fixed flaky test by using groundball_a (exists in both batting/pitching)

Documentation Updates:
- Added Testing Policy section to backend/CLAUDE.md
- Added Testing Policy section to tests/CLAUDE.md
- Documented 100% unit test requirement before commits
- Added git hook setup instructions

Git Hook System:
- Created .git-hooks/pre-commit script (enforces 100% test pass)
- Created .git-hooks/install-hooks.sh (easy installation)
- Created .git-hooks/README.md (hook documentation)
- Hook automatically runs all unit tests before each commit
- Blocks commits if any test fails

All 609 unit tests now passing (100%)
Integration tests have known asyncpg connection issues (documented)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 19:35:21 -06:00

75 KiB
Raw Blame History

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

Package Management

This project uses UV for fast, reliable package management.

Installing UV (one-time setup):

curl -LsSf https://astral.sh/uv/install.sh | sh

Setting up the project:

cd backend
uv sync  # Creates .venv and installs all dependencies

Daily Development

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

# Run backend with hot-reload (UV auto-activates .venv)
uv run python -m app.main

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

# Alternative: Activate .venv manually
source .venv/bin/activate
python -m app.main

Testing

Terminal Client (Interactive Game Engine Testing)

Test the game engine directly without needing a frontend:

# Start interactive REPL (recommended for rapid testing)
uv run 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
uv run pytest tests/ -v

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

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

# Type checking
uv run mypy app/

# Code formatting
uv run black app/ tests/

# Linting
uv run flake8 app/ tests/

Testing Policy

REQUIRED: 100% unit tests passing before any commit to feature branches.

Commit Requirements

Feature Branches:

  • REQUIRED: All unit tests must pass (609/609)
  • REQUIRED: Run tests before every commit
  • ⚠️ ALLOWED: [WIP] commits with --no-verify (feature branches only)
  • REQUIRED: 100% pass before merge to main

Main/Master Branch:

  • REQUIRED: 100% unit tests passing
  • REQUIRED: Code review approval
  • REQUIRED: CI/CD green build
  • NEVER: Commit with failing tests

Integration Tests:

  • ⚠️ Known infrastructure issues (asyncpg connection pooling)
  • Run individually during development
  • Fix infrastructure as separate task

Quick Test Commands

# Before every commit - REQUIRED
uv run pytest tests/unit/ -q

# Expected output:
# ======================= 609 passed, 3 warnings in 0.91s ========================

# If tests fail, fix them before committing!

Git Hook Setup (Automated Enforcement)

We provide a pre-commit hook that automatically runs unit tests before each commit.

Installation:

# From backend directory
cd /mnt/NV2/Development/strat-gameplay-webapp/backend

# Copy the pre-commit hook
cp .git-hooks/pre-commit .git/hooks/pre-commit

# Make it executable
chmod +x .git/hooks/pre-commit

What it does:

  • Runs all unit tests automatically before commit
  • Prevents commits if tests fail
  • Shows clear error messages
  • Fast execution (~1 second)

Bypassing the hook (use sparingly):

# For WIP commits on feature branches ONLY
git commit -m "[WIP] Work in progress" --no-verify

# NEVER bypass on main branch
# ALWAYS fix tests before final commit

Test Troubleshooting

If tests fail:

# Run with verbose output to see which test failed
uv run pytest tests/unit/ -v

# Run with full traceback
uv run pytest tests/unit/ -v --tb=long

# Run specific failing test
uv run pytest tests/unit/path/to/test.py::test_name -v

Common issues:

  • Import errors → Check PYTHONPATH or use uv run
  • Module not found → Run uv sync to install dependencies
  • Type errors → Check SQLAlchemy Column vs runtime types (use # type: ignore[assignment])

Why This Matters

Benefits:

  • 🛡️ Prevents regressions - Broken code never reaches main
  • Fast feedback - Know immediately if you broke something
  • 📜 Clean history - Main branch always deployable
  • 🎯 High confidence - 609 tests verify core behavior
  • 🔄 Forces good habits - Write tests as you code

The cost of skipping:

  • 🐛 Bugs reach production
  • Hours debugging what tests would catch instantly
  • 😰 Fear of refactoring (might break something)
  • 🔥 Emergency hotfixes instead of planned releases

Test Coverage Baseline

Current baseline (must maintain or improve):

  • Unit tests: 609/609 passing (100%)
  • ⏱️ Execution time: <1 second
  • 📊 Coverage: High coverage of core game engine, state management, models

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)
  • Package Manager: UV (v0.9.7+) - Fast, reliable Python package management
  • Virtual Environment: Located at backend/.venv/ (UV default)
  • Activation: source .venv/bin/activate (from backend directory)
    • Or use uv run <command> to auto-activate

Package Management with UV

Add a new dependency:

# Production dependency
uv add package-name==1.2.3

# Development dependency
uv add --dev package-name==1.2.3

# With extras
uv add "package[extra]==1.2.3"

Update dependencies:

# Update a specific package
uv add package-name@latest

# Sync dependencies (after pulling pyproject.toml changes)
uv sync

Remove a dependency:

uv remove package-name

Key Files:

  • pyproject.toml - Project metadata and dependencies (commit to git)
  • uv.lock - Locked dependency versions (commit to git)
  • .venv/ - Virtual environment (gitignored)

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
uv run pytest tests/unit/ -v

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

# Specific file
uv run 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)

Player Models (2025-10-28)

Overview

Polymorphic player model system that supports both SBA and PD leagues with different data complexity levels. Uses abstract base class pattern to ensure league-agnostic game engine.

Architecture

BasePlayer (Abstract)
    ├── SbaPlayer (Simple)
    └── PdPlayer (Complex with scouting data)

Key Design Decisions:

  • Abstract base ensures consistent interface across leagues
  • Factory methods for easy creation from API responses
  • Pydantic validation for type safety
  • Optional scouting data for PD league (loaded separately)

Models

BasePlayer (Abstract Base Class)

Location: app/models/player_models.py:18-44

Common interface for all player types. Game engine uses this abstraction.

Required Abstract Methods:

  • get_image_url() -> str: Get player image with fallback logic
  • get_positions() -> List[str]: Get all playable positions
  • get_display_name() -> str: Get formatted name for UI

Common Fields:

  • id: Player ID (SBA) or Card ID (PD)
  • name: Player display name
  • image: Primary image URL

SbaPlayer Model

Location: app/models/player_models.py:49-145

Simple model for SBA league with minimal data.

Key Fields:

  • Basic: id, name, image, wara
  • Team: team_id, team_name, season
  • Positions: pos_1 through pos_8 (up to 8 positions)
  • References: headshot, vanity_card, strat_code, bbref_id, injury_rating

Usage:

from app.models import SbaPlayer

# Create from API response
player = SbaPlayer.from_api_response(api_data)

# Use common interface
positions = player.get_positions()  # ['RF', 'CF']
image = player.get_image_url()      # With fallback logic
name = player.get_display_name()    # 'Ronald Acuna Jr'

API Mapping:

  • Endpoint: {{baseUrl}}/players/:player_id
  • Maps API response fields to model fields
  • Extracts nested team data automatically

PdPlayer Model

Location: app/models/player_models.py:254-495

Complex model for PD league with detailed scouting data.

Key Fields:

  • Basic: player_id, name, cost, description
  • Card: cardset, rarity, set_num, quantity
  • Team: mlbclub, franchise
  • Positions: pos_1 through pos_8
  • References: headshot, vanity_card, strat_code, bbref_id, fangr_id
  • Scouting: batting_card, pitching_card (optional)

Scouting Data Structure:

# Batting Card (loaded from /api/v2/battingcardratings/player/:id)
batting_card:
  - steal_low, steal_high, steal_auto, steal_jump
  - bunting, hit_and_run, running ratings
  - hand (L/R), offense_col (1/2)
  - ratings: Dict[str, PdBattingRating]
    - 'L': vs Left-handed pitchers
    - 'R': vs Right-handed pitchers

# Pitching Card (loaded from /api/v2/pitchingcardratings/player/:id)
pitching_card:
  - balk, wild_pitch, hold
  - starter_rating, relief_rating, closer_rating
  - hand (L/R), offense_col (1/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_*, single_*, walk, strikeout, etc.
  • Summary: avg, obp, slg

PdPitchingRating (per handedness matchup):

  • Outcomes: homerun, triple, double_*, single_*, walk, strikeout, etc.
  • X-checks: xcheck_p, xcheck_c, xcheck_1b, etc. (defensive play probabilities)
  • Summary: avg, obp, slg

Usage:

from app.models import PdPlayer

# Create from API responses (scouting data optional)
player = PdPlayer.from_api_response(
    player_data=player_api_response,
    batting_data=batting_api_response,    # Optional
    pitching_data=pitching_api_response   # Optional
)

# Use common interface
positions = player.get_positions()  # ['2B', 'SS']
name = player.get_display_name()    # 'Chuck Knoblauch (1998 Season)'

# Access scouting data
rating_vs_lhp = player.get_batting_rating('L')
if rating_vs_lhp:
    print(f"HR rate vs LHP: {rating_vs_lhp.homerun}%")
    print(f"Walk rate: {rating_vs_lhp.walk}%")
    print(f"Strikeout rate: {rating_vs_lhp.strikeout}%")

rating_vs_rhb = player.get_pitching_rating('R')
if rating_vs_rhb:
    print(f"X-check SS: {rating_vs_rhb.xcheck_ss}%")

API Mapping:

  • Player data: {{baseUrl}}/api/v2/players/:player_id
  • Batting data: {{baseUrl}}/api/v2/battingcardratings/player/:player_id
  • Pitching data: {{baseUrl}}/api/v2/pitchingcardratings/player/:player_id

Supporting Models

PdCardset

Card set information: id, name, description, ranked_legal

PdRarity

Card rarity: id, value, name (MVP, Starter, Replacement), color (hex)

PdBattingCard

Container for batting statistics with ratings for both vs LHP and vs RHP

PdPitchingCard

Container for pitching statistics with ratings for both vs LHB and vs RHB

Integration Points

With Game Engine:

# Game engine uses BasePlayer interface
def process_at_bat(batter: BasePlayer, pitcher: BasePlayer):
    # Works for both SBA and PD players
    print(f"{batter.get_display_name()} batting")
    print(f"Positions: {batter.get_positions()}")

With API Client (future):

# API client will fetch and parse player data
async def get_player(league: str, player_id: int) -> BasePlayer:
    if league == "sba":
        data = await fetch_sba_player(player_id)
        return SbaPlayer.from_api_response(data)
    else:  # PD league
        player_data = await fetch_pd_player(player_id)
        batting_data = await fetch_pd_batting(player_id)
        pitching_data = await fetch_pd_pitching(player_id)
        return PdPlayer.from_api_response(player_data, batting_data, pitching_data)

Testing

Unit Tests: tests/unit/models/test_player_models.py

  • BasePlayer abstract methods
  • SbaPlayer creation and methods
  • PdPlayer creation with/without scouting data
  • Factory method validation
  • Edge cases (missing positions, partial data)

Test Data: Uses real API response examples from app/models/player_model_info.md

Key Files

app/models/player_models.py        (516 lines) - Player model implementations
tests/unit/models/test_player_models.py        - Comprehensive unit tests
app/models/player_model_info.md    (540 lines) - API response examples

Recent Optimizations & Bug Fixes (2025-10-28)

Performance Optimizations - 60% Query Reduction

Optimized play resolution to eliminate unnecessary database queries:

Before Optimization: 5 queries per play

  1. INSERT INTO plays (necessary)
  2. SELECT plays with LEFT JOINs (refresh - unnecessary)
  3. SELECT games (for update - inefficient)
  4. SELECT lineups team 1 (unnecessary - should use cache)
  5. SELECT lineups team 2 (unnecessary - should use cache)

After Optimization: 2 queries per play (60% reduction)

  1. INSERT INTO plays (necessary)
  2. UPDATE games (necessary, now uses direct UPDATE)

Changes Made:

  1. Lineup Caching (app/core/game_engine.py:384-425)

    • _prepare_next_play() now checks state_manager.get_lineup() cache first
    • Only fetches from database if not cached
    • Cache persists for entire game lifecycle
    • Impact: Eliminates 2 SELECT queries per play
  2. Removed Unnecessary Refresh (app/database/operations.py:281-302)

    • save_play() no longer calls session.refresh(play)
    • Play ID is available after commit without refresh
    • Returns just the ID instead of full Play object with relationships
    • Impact: Eliminates 1 SELECT with 3 expensive LEFT JOINs per play
  3. Direct UPDATE Statement (app/database/operations.py:109-165)

    • update_game_state() now uses direct UPDATE statement
    • No longer does SELECT + modify + commit
    • Uses result.rowcount to verify game exists
    • Impact: Cleaner code, slightly faster (was already a simple SELECT)
  4. Conditional Game State Updates (app/core/game_engine.py:200-217)

    • Only UPDATE games table when score/inning/status actually changes
    • Captures state before/after play resolution and compares
    • Many plays don't change game state (outs without runs, singles without scoring)
    • Impact: ~40-60% fewer UPDATE queries (depends on scoring frequency)

Performance Impact:

  • Typical play resolution: ~50-100ms (down from ~150-200ms)
  • Only necessary write operations remain
  • Scalable for high-throughput gameplay
  • Combined with conditional updates: ~70% fewer queries in low-scoring innings

Critical Bug Fixes

1. Fixed outs_before Tracking (app/core/game_engine.py:551)

Issue: Play records had incorrect outs_before values due to wrong calculation.

Root Cause:

# WRONG - calculated after outs were applied
"outs_before": state.outs - result.outs_recorded

Fix:

# CORRECT - captures outs BEFORE applying result
"outs_before": state.outs

Why It Works:

  • _save_play_to_db() is called in STEP 2 (before result is applied)
  • _apply_play_result() is called in STEP 3 (after save)
  • state.outs already contains the correct "outs before" value at save time

Impact: All play records now have accurate out counts for historical analysis.

2. Fixed Game Recovery (app/core/state_manager.py:312-314)

Issue: Recovered games crashed with AttributeError: 'GameState' object has no attribute 'runners'

Root Cause: Logging statement tried to access non-existent state.runners attribute.

Fix:

# Count runners on base using the correct method
runners_on_base = len(state.get_all_runners())
logger.info(f"Rebuilt state for game {state.game_id}: {state.play_count} plays, {runners_on_base} runners")

Impact: Games can now be properly recovered from database after server restart or REPL exit.

Testing Notes

Integration Tests:

  • Known issue: Integration tests in tests/integration/test_game_engine.py must be run individually
  • Reason: Database connection pooling conflicts when running in parallel
  • Workaround: uv run pytest tests/integration/test_game_engine.py::TestClassName::test_method -v
  • All tests pass when run individually

Terminal Client:

  • Best tool for testing game engine optimizations
  • REPL mode maintains persistent state and event loop
  • See terminal_client/CLAUDE.md for usage

Game Models Refactor

Simplified Runner Management (app/models/game_models.py)

Problem: RunnerState class was redundant and created complexity.

  • Had separate List[RunnerState] for tracking runners
  • Required list management operations (add, remove, filter)
  • Didn't match database structure (plays table has on_first_id, on_second_id, on_third_id)
  • Extra layer of indirection

Solution: Direct base references in GameState

# Before (redundant)
runners: List[RunnerState] = Field(default_factory=list)

# After (direct)
on_first: Optional[LineupPlayerState] = None
on_second: Optional[LineupPlayerState] = None
on_third: Optional[LineupPlayerState] = None

Benefits:

  • Matches database structure exactly
  • Simpler state management (direct assignment vs list operations)
  • Better type safety (LineupPlayerState vs generic runner)
  • Easier to work with in game engine
  • Fewer lines of code

Updated Methods:

  • get_runner_at_base(base: int) -> Optional[LineupPlayerState] - Returns direct reference
  • get_all_runners() -> List[Tuple[int, LineupPlayerState]] - Returns list when needed
  • is_runner_on_first/second/third() - Simple is not None checks

Impact: All tests updated and passing. Game engine logic simplified.

Terminal Client Modularization

Problem: Code duplication between CLI (main.py) and REPL (repl.py)

  • Same command logic in two places
  • Hard to maintain consistency
  • Difficult to test

Solution: Modular architecture with shared modules

New Modules Created:

  1. terminal_client/commands.py (10,243 bytes)

    • Shared command functions: submit_defensive_decision, submit_offensive_decision, resolve_play
    • Used by both CLI and REPL
    • Single source of truth for command logic
    • Fully tested independently
  2. terminal_client/arg_parser.py (7,280 bytes)

    • Centralized argument parsing and validation
    • Handles defensive/offensive decision arguments
    • Validates formats (alignment, depths, hold runners, steal attempts)
    • Reusable across both interfaces
  3. terminal_client/completions.py (10,357 bytes)

    • TAB completion support for REPL mode
    • Command completions, option completions
    • Dynamic completions (game IDs, defensive/offensive options)
    • Improves REPL user experience
  4. terminal_client/help_text.py (10,839 bytes)

    • Centralized help text and command documentation
    • Detailed command descriptions and usage examples
    • Consistent help across CLI and REPL
    • Easy to update in one place

Benefits:

  • DRY principle - no code duplication
  • Behavior consistent between CLI and REPL modes
  • Easier to maintain (changes in one place)
  • Better testability (modules tested independently)
  • Clear separation of concerns
  • Improved user experience (completions, better help)

Test Coverage:

  • tests/unit/terminal_client/test_commands.py
  • tests/unit/terminal_client/test_arg_parser.py
  • tests/unit/terminal_client/test_completions.py
  • tests/unit/terminal_client/test_help_text.py

File Structure:

terminal_client/
├── __init__.py
├── main.py              # CLI entry point (simplified)
├── repl.py              # REPL mode (simplified)
├── display.py           # Display formatting
├── config.py            # Configuration
├── commands.py          # NEW - Shared command logic
├── arg_parser.py        # NEW - Argument parsing
├── completions.py       # NEW - TAB completions
└── help_text.py         # NEW - Help documentation

Summary of 2025-10-28 Updates

Total Changes:

  • 36 files modified/created
  • +9,034 lines added
  • -645 lines removed
  • 2 git commits

Major Improvements:

  1. Player Models (Week 6 - 50% Complete)

    • BasePlayer, SbaPlayer, PdPlayer with factory methods
    • 32 comprehensive tests (all passing)
    • Single-layer architecture (simpler than planned two-layer)
    • Ready for API integration
  2. Performance Optimizations

    • 60-70% database query reduction
    • Lineup caching eliminates redundant SELECTs
    • Conditional updates only when state changes
    • ~50-100ms play resolution (was ~150-200ms)
  3. Model Simplification

    • Removed redundant RunnerState class
    • Direct base references match DB structure
    • Cleaner game engine logic
  4. Terminal Client Enhancement

    • Modularized into 4 shared modules
    • DRY principle - no duplication between CLI/REPL
    • TAB completions for better UX
    • Comprehensive test coverage
  5. Bug Fixes

    • outs_before tracking corrected
    • Game recovery AttributeError fixed
    • Enhanced status display with action guidance

Test Status:

  • All existing tests passing
  • 32 new player model tests
  • 4 new terminal client test suites
  • Integration tests verified

Documentation:

  • Player models documented in CLAUDE.md
  • Week 6 status assessment created
  • Terminal client modularization documented
  • Architecture decisions explained

Next Priorities (Week 6 Remaining):

  1. Configuration system (BaseConfig, SbaConfig, PdConfig)
  2. Result charts & PD play resolution with ratings
  3. API client (deferred for now)

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 - Week 6 (Player Models & League Integration)

Completion Status:

  • Phase 1 (Infrastructure): Complete (2025-10-21)
  • Week 4 (State Management): Complete (2025-10-22)
  • Week 5 (Game Logic): Complete (2025-10-26)
    • Game engine orchestration
    • Play resolver with dice system
    • Full at-bat flow working
    • Terminal client for testing
  • 🟡 Week 6 (Player Models & League Features): ~50% Complete (2025-10-28)
    • Player models (BasePlayer, SbaPlayer, PdPlayer)
    • Factory methods for API parsing
    • Comprehensive test coverage (32/32 tests passing)
    • Configuration system (not started)
    • Result charts & PD integration (not started)
    • API client (deferred)

Week 6: League Configuration & Play Outcome System (2025-10-28)

Status: 75% Complete Phase: Phase 2 - Week 6 (League Features & Integration)

Overview

Implemented foundational configuration and outcome systems for both SBA and PD leagues, establishing the framework for card-based play resolution.

Components Implemented

1. League Configuration System

Location: app/config/

Provides immutable, league-specific configuration objects for game rules and API endpoints.

Files Created:

  • app/config/base_config.py - Abstract base configuration
  • app/config/league_configs.py - SBA and PD implementations
  • app/config/__init__.py - Public API
  • tests/unit/config/test_league_configs.py - 28 tests (all passing)

Key Features:

from app.config import get_league_config

# Get league-specific config
config = get_league_config("sba")
api_url = config.get_api_base_url()  # "https://api.sba.manticorum.com"
chart = config.get_result_chart_name()  # "sba_standard_v1"

# Configs are immutable (frozen=True)
# config.innings = 7  # Raises ValidationError

League Differences:

  • SBA: Manual result selection, simple player data
  • PD: Flexible selection (manual or auto), detailed scouting data, cardset validation, advanced analytics

Config Registry:

LEAGUE_CONFIGS = {
    "sba": SbaConfig(),
    "pd": PdConfig()
}

2. PlayOutcome Enum

Location: app/config/result_charts.py

Universal enum defining all possible play outcomes for both leagues.

Files Created:

  • app/config/result_charts.py - PlayOutcome enum with helpers
  • tests/unit/config/test_play_outcome.py - 30 tests (all passing)

Outcome Categories:

from app.config import PlayOutcome

# Standard outcomes
PlayOutcome.STRIKEOUT
PlayOutcome.SINGLE
PlayOutcome.HOMERUN

# Uncapped hits (pitching cards only)
PlayOutcome.SINGLE_UNCAPPED  # si(cf) - triggers advancement decisions
PlayOutcome.DOUBLE_UNCAPPED  # do(cf) - triggers advancement decisions

# Interrupt plays (logged with pa=0)
PlayOutcome.WILD_PITCH       # Play.wp = 1
PlayOutcome.PASSED_BALL      # Play.pb = 1
PlayOutcome.STOLEN_BASE      # Play.sb = 1
PlayOutcome.CAUGHT_STEALING  # Play.cs = 1

# Ballpark power (PD specific)
PlayOutcome.BP_HOMERUN       # Play.bphr = 1
PlayOutcome.BP_SINGLE        # Play.bp1b = 1

Helper Methods:

outcome = PlayOutcome.SINGLE_UNCAPPED

outcome.is_hit()              # True
outcome.is_out()              # False
outcome.is_uncapped()         # True - requires decision tree
outcome.is_interrupt()        # False
outcome.get_bases_advanced()  # 1

3. Card-Based Resolution System

Resolution Mechanics (Both SBA and PD):

  1. Roll 1d6 → determines column (1-3: batter card, 4-6: pitcher card)
  2. Roll 2d6 → selects row 2-12 on that card
  3. Roll 1d20 → resolves split results (e.g., 1-16: HR, 17-20: 2B)
  4. Outcome from card = PlayOutcome enum value

League Differences:

  • PD: Card data digitized in PdBattingRating/PdPitchingRating
    • Can auto-resolve using probabilities
    • OR manual selection by players
  • SBA: Physical cards only (not digitized)
    • Players read physical cards and manually enter outcomes
    • System validates and records the human-selected outcome

Testing

Test Coverage:

  • 28 config tests (league configs, registry, immutability)
  • 30 PlayOutcome tests (helpers, categorization, edge cases)
  • Total: 58 tests, all passing

Test Files:

tests/unit/config/
├── test_league_configs.py    # Config system tests
└── test_play_outcome.py       # Outcome enum tests

Architecture Decisions

1. Immutable Configs

  • Used Pydantic frozen=True to prevent accidental modification
  • Configs are singletons in registry
  • Type-safe with full validation

2. Universal PlayOutcome Enum

  • Single source of truth for all possible outcomes
  • Works for both SBA and PD leagues
  • Helper methods reduce duplicate logic in resolvers

3. No Static Result Charts

  • Originally planned d20 charts for SBA
  • Realized both leagues use same card-based mechanics
  • Charts come from player card data (PD) or manual entry (SBA)

Integration Points

With Game Engine:

from app.config import get_league_config, PlayOutcome

# Get league config
config = get_league_config(state.league_id)

# Resolve outcome (from card or manual entry)
outcome = PlayOutcome.SINGLE_UNCAPPED

# Handle based on outcome type
if outcome.is_uncapped() and state.on_base_code > 0:
    # Trigger advancement decision tree
    present_advancement_options()
elif outcome.is_interrupt():
    # Log interrupt play (pa=0)
    log_interrupt_play(outcome)
else:
    # Standard play resolution
    resolve_standard_play(outcome)

Remaining Work (Week 6)

1. Dice System Update

  • Rename check_d20chaos_d20 in AbRoll
  • Update all references

2. PlayResolver Integration

  • Replace old PlayOutcome enum with new one
  • Use PlayOutcome throughout resolution logic
  • Handle uncapped hit decision trees

3. Play.metadata Support

  • Add JSON metadata field for uncapped hit tracking
  • Log {"uncapped": true} when applicable

Key Files

app/config/
├── __init__.py                    # Public API
├── base_config.py                 # Abstract base config
├── league_configs.py              # SBA/PD implementations
└── result_charts.py               # PlayOutcome enum

tests/unit/config/
├── test_league_configs.py         # 28 tests
└── test_play_outcome.py           # 30 tests

Next Priorities:

  1. Update dice system (chaos_d20)
  2. Integrate PlayOutcome into PlayResolver
  3. Add Play.metadata support for uncapped hits
  4. Complete week 6 remaining work

Python Version: 3.13.3 Database Server: 10.10.0.42:5432

Implementation Status: Week 6 - 75% Complete (Config & PlayOutcome , Integration pending)

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.

Phase 3B: X-Check League Config Tables (2025-11-01)

Implemented complete X-Check resolution table system for defensive play outcomes.

Status: Complete

Components Implemented

  1. Defense Range Tables (app/config/common_x_check_tables.py)

    • Complete 20×5 tables for infield, outfield, and catcher positions
    • Maps d20 roll × defense range (1-5) → result code
    • Result codes: G1-G3, G2#/G3# (holding), SI1-SI2, F1-F3, DO2-DO3, TR3, SPD, FO, PO
  2. Error Charts (3d6 by error rating 0-25)

    • Complete: LF/RF and CF error charts (26 ratings each)
    • Placeholders: P, C, 1B, 2B, 3B, SS (empty dicts awaiting data)
    • Error types: RP (replay), E1-E3 (severity), NO (no error)
  3. Helper Functions

    • get_fielders_holding_runners(runner_bases, batter_handedness) - Complete implementation
      • Tracks all fielders holding runners by position
      • R1: 1B + middle infielder (2B for RHB, SS for LHB)
      • R2: Middle infielder (if not already added)
      • R3: 3B
    • get_error_chart_for_position(position) - Maps all 9 positions to error charts
  4. League Config Integration (app/config/league_configs.py)

    • Both SbaConfig and PdConfig include X-Check tables
    • Attributes: x_check_defense_tables, x_check_error_charts, x_check_holding_runners
    • Shared common tables for both leagues
  5. X-Check Placeholder Functions (app/core/runner_advancement.py)

    • 6 placeholder functions: x_check_g1, x_check_g2, x_check_g3, x_check_f1, x_check_f2, x_check_f3
    • All return valid AdvancementResult structures
    • Ready for Phase 3C implementation

Test Coverage

  • 36 tests for X-Check tables (tests/unit/config/test_x_check_tables.py)
    • Defense table dimensions and valid result codes
    • Error chart structure validation
    • Helper function behavior
    • Integration workflows
  • 9 tests for X-Check placeholders (tests/unit/core/test_runner_advancement.py)
    • Function signatures and return types
    • Error type acceptance
    • On-base code support

Total: 45/45 tests passing

What's Pending

Infield Error Charts - 6 positions awaiting actual data:

  • PITCHER_ERROR_CHART
  • CATCHER_ERROR_CHART
  • FIRST_BASE_ERROR_CHART
  • SECOND_BASE_ERROR_CHART
  • THIRD_BASE_ERROR_CHART
  • SHORTSTOP_ERROR_CHART

Once data is provided, these empty dicts will be populated with the same structure as outfield charts.

Next Phase

COMPLETED - Phase 3C implemented full defensive play resolution


Phase 3C: X-Check Resolution Logic (2025-11-02)

Implemented complete X-Check resolution system in PlayResolver with full integration of Phase 3B tables.

Status: Complete

Components Implemented

  1. Main Resolution Method (_resolve_x_check() in app/core/play_resolver.py)

    • 10-step resolution process from dice rolls to final outcome
    • Rolls 1d20 for defense table + 3d6 for error chart
    • Adjusts range if defender playing in
    • Looks up base result from defense table
    • Applies SPD test if needed (placeholder)
    • Converts G2#/G3# to SI2 based on conditions
    • Looks up error result from error chart
    • Determines final outcome with error overrides
    • Creates XCheckResult audit trail
    • Returns PlayResult with full details
  2. Helper Methods (6 new methods in PlayResolver)

    • _adjust_range_for_defensive_position() - Range +1 if playing in (max 5)
    • _lookup_defense_table() - Maps d20 + range → result code
    • _apply_hash_conversion() - G2#/G3# → SI2 if playing in OR holding runner
    • _lookup_error_chart() - Maps 3d6 + error rating → error type
    • _determine_final_x_check_outcome() - Maps result + error → PlayOutcome
  3. Integration Points

    • Added X_CHECK case to resolve_outcome() method
    • Extended PlayResult dataclass with x_check_details: Optional[XCheckResult]
    • Imported all Phase 3B tables: INFIELD/OUTFIELD/CATCHER defense tables
    • Imported helper functions: get_error_chart_for_position(), get_fielders_holding_runners()

Key Features

Defense Table Lookup:

  • Selects correct table based on position (infield/outfield/catcher)
  • 0-indexed lookup: table[d20_roll - 1][defense_range - 1]
  • Returns result codes: G1-G3, G2#/G3#, F1-F3, SI1-SI2, DO2-DO3, TR3, SPD, FO, PO

Range Adjustment:

  • Corners in: +1 range for 1B, 3B, P, C
  • Infield in: +1 range for 1B, 2B, 3B, SS, P, C
  • Maximum range capped at 5

Hash Conversion Logic:

G2# or G3# → SI2 if:
  a) Playing in (adjusted_range > base_range), OR
  b) Holding runner (position in holding_positions list)
Otherwise: G2# → G2, G3# → G3

Error Chart Lookup:

  • Priority order: RP > E3 > E2 > E1 > NO
  • Uses 3d6 sum (3-18) against defender's error rating
  • Returns: 'RP', 'E3', 'E2', 'E1', or 'NO'

Final Outcome Determination:

If error_result == 'NO':
  outcome = base_outcome, hit_type = "{result}_no_error"

If error_result == 'RP':
  outcome = ERROR, hit_type = "{result}_rare_play"

If error_result in ['E1', 'E2', 'E3']:
  If base_outcome is out:
    outcome = ERROR  # Error overrides
  Else:
    outcome = base_outcome  # Hit + error keeps hit
  hit_type = "{result}_plus_error_{n}"

Placeholders (Future Phases)

  1. Defender Retrieval - Currently uses placeholder ratings (TODO: lineup integration)
  2. SPD Test - Currently defaults to G3 fail (TODO: batter speed rating)
  3. Batter Handedness - Currently hardcoded to 'R' (TODO: player model)
  4. Runner Advancement - Currently returns empty list (TODO Phase 3D: advancement tables)

Testing

Test Coverage:

  • All 9 PlayResolver tests passing
  • All 36 X-Check table tests passing
  • All 51 runner advancement tests passing
  • 325/327 total tests passing (99.4%)
  • ⚠️ 2 pre-existing failures (unrelated: dice history, config URL)

Files Modified

app/core/play_resolver.py  (+397 lines, -2 lines)
  - Added X_CHECK resolution case
  - Added 6 helper methods (397 lines)
  - Extended PlayResult with x_check_details
  - Imported Phase 3B tables and helpers

Next Phase

Phase 3D: X-Check Runner Advancement Tables

  • Implement groundball advancement (G1, G2, G3)
  • Implement flyball advancement (F1, F2, F3)
  • Implement hit advancement with errors (SI1, SI2, DO2, DO3, TR3)
  • Implement out advancement with errors (FO, PO)
  • Fill in placeholder _get_x_check_advancement() method

Phase 3E-Main: Position Ratings Integration (2025-11-03)

Integrated position ratings system enabling X-Check defensive plays to use actual player ratings from PD API with intelligent fallbacks for SBA.

Status: Complete - Live API verified with player 8807

Components Implemented

  1. PD API Client (app/services/pd_api_client.py)

    • Endpoint: GET /api/v2/cardpositions?player_id={id}&position={pos}
    • Async HTTP client using httpx
    • Optional position filtering: get_position_ratings(8807, ['SS', '2B'])
    • Returns List[PositionRating] for all positions
    • Handles both list and dict response formats
    • Comprehensive error handling
  2. Position Rating Service (app/services/position_rating_service.py)

    • In-memory caching (16,601x performance improvement)
    • get_ratings_for_card(card_id, league_id) - All positions
    • get_rating_for_position(card_id, position, league_id) - Specific position
    • Singleton pattern: position_rating_service instance
    • TODO Phase 3E-Final: Upgrade to Redis
  3. GameState Integration (app/models/game_models.py)

    • LineupPlayerState: Added position_rating field (Optional[PositionRating])
    • GameState: Added get_defender_for_position(position, state_manager) method
    • Uses StateManager's lineup cache to find active defender
    • No database lookups during play resolution
  4. League Configuration (app/config/league_configs.py)

    • SbaConfig: supports_position_ratings() → False
    • PdConfig: supports_position_ratings() → True
    • Enables league-specific behavior without hardcoded conditionals
  5. PlayResolver Integration (app/core/play_resolver.py)

    • Added state_manager parameter to constructor
    • _resolve_x_check(): Replaced placeholder ratings with actual lookup
    • Uses league config check: config.supports_position_ratings()
    • Falls back to defaults (range=3, error=15) if unavailable
  6. Game Start Rating Loader (app/core/game_engine.py)

    • _load_position_ratings_for_lineup() method
    • Loads all position ratings at game start for PD league
    • Skips loading for SBA (league config check)
    • Called in start_game() for both teams
    • Logs: "Loaded X/9 position ratings for team Y"

Live API Testing

Verified with Player 8807 (7 positions):

Position   Range    Error    Innings
CF         3        2        372
2B         3        8        212
SS         4        12       159
RF         2        2        74
LF         3        2        62
1B         4        0        46
3B         3        65       34

Performance:

  • API call: 0.214s
  • Cache hit: 0.000s
  • Speedup: 16,601x

X-Check Resolution Flow

  1. Check league config: supports_position_ratings()?
  2. Get defender: state.get_defender_for_position(pos, state_manager)
  3. If PD + defender.position_rating exists: Use actual range/error
  4. Else if defender found: Use defaults (range=3, error=15)
  5. Else: Log warning, use defaults

Testing

Live Integration:

  • Real API: Player 8807 → 7 positions retrieved
  • Caching: 16,601x performance improvement
  • League configs: SBA skips API, PD fetches ratings
  • GameState: Defender lookup working
  • Existing tests: 27/28 config tests passing

Test Files Created:

  • test_pd_api_live.py - Live API integration test
  • test_pd_api_mock.py - Mock test for CI/CD
  • tests/integration/test_position_ratings_api.py - Pytest suite

Files Created/Modified

Created:

app/services/__init__.py              - Package exports
app/services/pd_api_client.py         - PD API client (97 lines)
app/services/position_rating_service.py - Caching service (120 lines)

Modified:

app/models/game_models.py             - Added position_rating field, get_defender_for_position()
app/config/league_configs.py          - Added supports_position_ratings()
app/core/play_resolver.py             - Integrated actual ratings lookup
app/core/game_engine.py                - Load ratings at game start

Key Features

League-Aware Behavior:

  • PD: Fetches ratings from API with caching
  • SBA: Skips API calls, uses defaults

Self-Contained GameState:

  • All X-Check data in memory (no lookups during resolution)
  • Direct access: defender.position_rating.range

Graceful Degradation:

  • API unavailable → Use defaults
  • Player has no rating → Use defaults
  • Defaults: range=3 (average), error=15 (average)

Next Phase

Phase 3E-Final: WebSocket Events & Full Integration

  • WebSocket event handlers for X-Check UI
  • Upgrade to Redis caching
  • Full defensive lineup in GameState (all 9 positions)
  • Manual vs Auto mode workflows

Updated: 2025-11-03 Total Unit Tests: 325 passing (2 pre-existing failures in unrelated systems) Live API: Verified with PD player 8807