strat-gameplay-webapp/backend/CLAUDE.md
Cal Corum 440adf2c26 CLAUDE: Update REPL for new GameState and standardize UV commands
Updated terminal client REPL to work with refactored GameState structure
where current_batter/pitcher/catcher are now LineupPlayerState objects
instead of integer IDs. Also standardized all documentation to properly
show 'uv run' prefixes for Python commands.

REPL Updates:
- terminal_client/display.py: Access lineup_id from LineupPlayerState objects
- terminal_client/repl.py: Fix typos (self.current_game → self.current_game_id)
- tests/unit/terminal_client/test_commands.py: Create proper LineupPlayerState
  objects in test fixtures (2 tests fixed, all 105 terminal client tests passing)

Documentation Updates (100+ command examples):
- CLAUDE.md: Updated pytest examples to use 'uv run' prefix
- terminal_client/CLAUDE.md: Updated ~40 command examples
- tests/CLAUDE.md: Updated all test commands (unit, integration, debugging)
- app/*/CLAUDE.md: Updated test and server startup commands (5 files)

All Python commands now consistently use 'uv run' prefix to align with
project's UV migration, improving developer experience and preventing
confusion about virtual environment activation.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 09:59:13 -06:00

2315 lines
72 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
```python
# 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):
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
**Setting up the project**:
```bash
cd backend
uv sync # Creates .venv and installs all dependencies
```
### Daily Development
```bash
# 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:
```bash
# 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
```bash
# 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/
```
## 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
```python
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:**
```python
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
```python
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:
```python
# ❌ 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**:
```python
# 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:
```ini
[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:
```ini
[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:
```python
# ❌ 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:
```python
# ❌ 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
```python
# 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
```python
# 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
```python
# ❌ 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
```python
# ❌ 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
```python
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:**
```python
@property
def ai_is_batting(self) -> bool:
"""True if batting team is AI-controlled"""
return (self.half == 'top' and self.game.away_team_is_ai) or \
(self.half == 'bot' and self.game.home_team_is_ai)
@property
def ai_is_fielding(self) -> bool:
"""True if fielding team is AI-controlled"""
return not self.ai_is_batting
```
---
#### **Lineup** (`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
---
#### **GameCardsetLink** (`game_cardset_links`)
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)
---
#### **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 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:**
```python
# 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
```python
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
```python
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:**
```python
# 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:**
```python
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:**
```python
# 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:**
```python
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:**
```python
# 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
```python
@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
```python
# 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`:
```bash
# 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):
```bash
# 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**:
```bash
# 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**:
```bash
# Update a specific package
uv add package-name@latest
# Sync dependencies (after pulling pyproject.toml changes)
uv sync
```
**Remove a dependency**:
```bash
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)
```python
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:
```bash
# 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:
```python
# ❌ 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:
```txt
# 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
```bash
# 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**:
```python
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**:
```python
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**:
```python
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**:
```bash
# 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
```python
@field_validator('position')
@classmethod
def validate_position(cls, v: str) -> str:
valid_positions = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH']
if v not in valid_positions:
raise ValueError(f"Position must be one of {valid_positions}")
return v
```
#### 2. Async Database Session Management
```python
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
```python
# 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
```python
# 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):
```ini
[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**:
```python
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**:
```python
# 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**:
```python
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**:
```python
# 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):
```python
# 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**:
```python
# WRONG - calculated after outs were applied
"outs_before": state.outs - result.outs_recorded
```
**Fix**:
```python
# 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**:
```python
# 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
```python
# 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**:
```python
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**:
```python
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**:
```python
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**:
```python
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**:
```python
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_d20` `chaos_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**:
```python
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**:
```python
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