# 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 ` 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