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>
2315 lines
72 KiB
Markdown
2315 lines
72 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
|
||
|
||
### 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 |