strat-gameplay-webapp/backend/CLAUDE.md
Cal Corum 5d5c13f2b8 CLAUDE: Implement Week 6 league configuration and play outcome systems
Week 6 Progress: 75% Complete

## Components Implemented

### 1. League Configuration System 
- Created BaseGameConfig abstract class for league-agnostic rules
- Implemented SbaConfig and PdConfig with league-specific settings
- Immutable configs (frozen=True) with singleton registry
- 28 unit tests, all passing

Files:
- backend/app/config/base_config.py
- backend/app/config/league_configs.py
- backend/tests/unit/config/test_league_configs.py

### 2. PlayOutcome Enum 
- Universal enum for all play outcomes (both SBA and PD)
- Helper methods: is_hit(), is_out(), is_uncapped(), is_interrupt()
- Supports standard hits, uncapped hits, interrupt plays, ballpark power
- 30 unit tests, all passing

Files:
- backend/app/config/result_charts.py
- backend/tests/unit/config/test_play_outcome.py

### 3. Player Model Refinements 
- Fixed PdPlayer.id field mapping (player_id → id)
- Improved field docstrings for image types
- Fixed position checking logic in SBA helper methods
- Added safety checks for missing image data

Files:
- backend/app/models/player_models.py (updated)

### 4. Documentation 
- Updated backend/CLAUDE.md with Week 6 section
- Documented card-based resolution mechanics
- Detailed config system and PlayOutcome usage

## Architecture Decisions

1. **Card-Based Resolution**: Both SBA and PD use same mechanics
   - 1d6 (column) + 2d6 (row) + 1d20 (split resolution)
   - PD: Digitized cards with auto-resolution
   - SBA: Manual entry from physical cards

2. **Immutable Configs**: Prevent accidental modification using Pydantic frozen

3. **Universal PlayOutcome**: Single enum for both leagues reduces duplication

## Testing
- Total: 58 tests, all passing
- Config tests: 28
- PlayOutcome tests: 30

## Remaining Work (25%)
- Update dice system (check_d20 → chaos_d20)
- Integrate PlayOutcome into PlayResolver
- Add Play.metadata support for uncapped hits

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 22:46:12 -05:00

1951 lines
59 KiB
Markdown

# 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
### Daily Development
```bash
# 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:
```bash
# 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
```bash
# 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
```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)
- **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)
```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
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
```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: `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.