Comprehensive documentation update covering all changes from 2025-10-28: ## Documentation Additions **Performance Optimizations Section:** - Added conditional game state updates (4th optimization) - Updated performance impact metrics (70% query reduction in low-scoring innings) - Clarified combined effect of all optimizations **Game Models Refactor Section:** - Documented RunnerState removal rationale - Explained direct base reference approach - Listed benefits and updated methods - Included before/after code comparison **Terminal Client Modularization Section:** - Documented problem (code duplication) - Explained modular solution with 4 new modules - Listed benefits of DRY architecture - Included file structure diagram - Referenced test coverage **Summary Section:** - Complete overview of all 2025-10-28 updates - Statistics: 36 files, +9,034 lines, 2 commits - 5 major improvement categories - Test status across all changes - Documentation deliverables - Next priorities for Week 6 ## Changes Made - Added conditional UPDATE optimization details - Documented model simplification (RunnerState removal) - Documented terminal client modularization - Added comprehensive session summary - All sections cross-referenced with code locations **Total Documentation**: ~150 lines added covering: - Performance optimizations (complete) - Bug fixes (complete) - Model refactoring - Terminal client improvements - Session summary with metrics Makes it easy for future developers to understand today's architectural decisions and performance improvements. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
54 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
Optionalunless 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:
- ✅ Is it a SQLAlchemy model attribute? → Use
# type: ignore[assignment] - ✅ Is it in
db_models.pyoroperations.py? → Expected, configured inmypy.ini - ✅ Is it in
config.py(Pydantic Settings)? → Expected, configured inmypy.ini - ❌ 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 systemsleague_id(String): 'sba' or 'pd', determines league-specific behaviorstatus(String): 'pending', 'active', 'completed'game_mode(String): 'ranked', 'friendly', 'practice'visibility(String): 'public', 'private'current_inning,current_half: Current game statehome_score,away_score: Running scores
AI Support:
home_team_is_ai(Boolean): Home team controlled by AIaway_team_is_ai(Boolean): Away team controlled by AIai_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 counterinning,half: Inning stateouts_before: Outs at start of playbatting_order: Current spot in orderaway_score,home_score: Score at play start
Player References (FKs to Lineup):
batter_id,pitcher_id,catcher_id: Required playersdefender_id,runner_id: Optional for specific playson_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 upon_base_code(Integer): Bit field for efficient queries (1=1st, 2=2nd, 4=3rd, 7=loaded)
Strategic Decisions:
defensive_choices(JSON): Alignment, holds, shiftsoffensive_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 resultouts_recorded,runs_scored: Play outcomecheck_pos(String): Defensive position for X-check
Batting Statistics (25+ fields):
pa,ab,hit,double,triple,homerunbb,so,hbp,rbi,sac,ibb,gidpsb,cs: Base stealingwild_pitch,passed_ball,pick_off,balkbphr,bpfo,bp1b,bplo: Ballpark power eventsrun,e_run: Earned/unearned runs
Advanced Analytics:
wpa(Float): Win Probability Addedre24(Float): Run Expectancy 24 base-out states
Game Situation Flags:
is_tied,is_go_ahead,is_new_inning: Context flagsin_pow: Pitcher over workloadcomplete,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/cardposition(String): P, C, 1B, 2B, 3B, SS, LF, CF, RF, DHbatting_order(Integer): 1-9
Substitution Tracking:
is_starter(Boolean): Original lineup vs substituteis_active(Boolean): Currently in gameentered_inning(Integer): When player enteredreplacing_id(Integer): Lineup ID of replaced playerafter_play(Integer): Exact play number of substitution
Pitcher Management:
is_fatigued(Boolean): Triggers bullpen decisions
GameCardsetLink (game_cardset_links)
PD league only - defines legal cardsets for a game.
Key Fields:
game_id,cardset_id: Composite primary keypriority(Integer): 1 = primary, 2+ = backup
Usage:
- SBA games: Empty (no cardset restrictions)
- PD games: Required (validates card eligibility)
RosterLink (roster_links)
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 tablecard_id(Integer, nullable): PD league - card identifierplayer_id(Integer, nullable): SBA league - player identifierteam_id(Integer): Which team owns this entity in this game
Constraints:
roster_link_one_id_required: CHECK constraint ensures exactly one ofcard_idorplayer_idis populated (XOR logic)uq_game_card: UNIQUE constraint on (game_id, card_id) for PDuq_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 Gameconnected_users(JSON): Active WebSocket connectionslast_action_at(DateTime): Last activity timestampstate_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
- Create route in
app/api/routes/ - Define Pydantic request/response models
- Add dependency injection if needed
- Register router in
app/main.py
Adding a New WebSocket Event
- Define event handler in
app/websocket/handlers.py - Register with
@sio.eventdecorator - Validate data with Pydantic
- Add corresponding client handling in frontend
Adding a New Database Model
- Define SQLAlchemy model in
app/models/db_models.py - Create Alembic migration:
alembic revision --autogenerate -m "description" - Apply migration:
alembic upgrade head
Troubleshooting
Import Errors
- Ensure virtual environment is activated
- Check
PYTHONPATHif using custom structure - Verify all
__init__.pyfiles exist
Database Connection Issues
- Verify
DATABASE_URLin.envis 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 trackingLineupPlayerState/TeamLineupState: Lineup managementDefensiveDecision/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 databaseupdate_game_state(): Update inning, score, statuscreate_lineup_entry()/get_active_lineup(): Lineup persistencesave_play()/get_plays(): Play recordingload_game_state(): Complete state loading for recoverycreate_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:
- Dice system (cryptographic d20 rolls)
- Play resolver (result charts and outcome determination)
- Game engine (orchestrate complete at-bat flow)
- Rule validators (enforce baseball rules)
- 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 logicget_positions() -> List[str]: Get all playable positionsget_display_name() -> str: Get formatted name for UI
Common Fields:
id: Player ID (SBA) or Card ID (PD)name: Player display nameimage: 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_1throughpos_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_1throughpos_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
- INSERT INTO plays (necessary)
- SELECT plays with LEFT JOINs (refresh - unnecessary)
- SELECT games (for update - inefficient)
- SELECT lineups team 1 (unnecessary - should use cache)
- SELECT lineups team 2 (unnecessary - should use cache)
After Optimization: 2 queries per play (60% reduction)
- INSERT INTO plays (necessary)
- UPDATE games (necessary, now uses direct UPDATE)
Changes Made:
-
Lineup Caching (
app/core/game_engine.py:384-425)_prepare_next_play()now checksstate_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
-
Removed Unnecessary Refresh (
app/database/operations.py:281-302)save_play()no longer callssession.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
-
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.rowcountto verify game exists - Impact: Cleaner code, slightly faster (was already a simple SELECT)
-
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.outsalready 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.pymust be run individually - Reason: Database connection pooling conflicts when running in parallel
- Workaround:
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.mdfor 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 referenceget_all_runners() -> List[Tuple[int, LineupPlayerState]]- Returns list when neededis_runner_on_first/second/third()- Simpleis not Nonechecks
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:
-
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
- Shared command functions:
-
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
-
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
-
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.pytests/unit/terminal_client/test_arg_parser.pytests/unit/terminal_client/test_completions.pytests/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:
-
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
-
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)
-
Model Simplification
- Removed redundant RunnerState class
- Direct base references match DB structure
- Cleaner game engine logic
-
Terminal Client Enhancement
- Modularized into 4 shared modules
- DRY principle - no duplication between CLI/REPL
- TAB completions for better UX
- Comprehensive test coverage
-
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):
- Configuration system (BaseConfig, SbaConfig, PdConfig)
- Result charts & PD play resolution with ratings
- 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)
Next Priorities:
- League configuration system (BaseConfig, SbaConfig, PdConfig)
- Result charts & PD play resolution with ratings
- API client for live roster data (optional for now)
Python Version: 3.13.3 Database Server: 10.10.0.42:5432
Implementation Status: See ../.claude/implementation/week6-status-assessment.md for detailed Week 6 progress
Database Model Updates (2025-10-21)
Enhanced all database models based on proven Discord game implementation:
Changes from Initial Design:
- ✅ Added
GameCardsetLinkandRosterLinktables for PD league cardset management - ✅ Enhanced
Gamemodel with AI opponent support (home_team_is_ai,away_team_is_ai,ai_difficulty) - ✅ Added 25+ statistic fields to
Playmodel (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_codebit 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
Lineupwith 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_teamfield (supports AI vs AI simulations) - Runner Tracking: Removed JSON fields, using FKs +
on_base_codefor 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_idnullable (PD league) - ✅ Added
player_idnullable (SBA league) - ✅ Added XOR CHECK constraint: exactly one ID must be populated
- ✅ Created league-specific methods:
add_pd_lineup_card()andadd_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.