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>
1951 lines
59 KiB
Markdown
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. |