strat-gameplay-webapp/.claude/status-2025-10-24-1430.md
Cal Corum 54092a8117 CLAUDE: Add refactor planning and session documentation
Added comprehensive documentation for the GameEngine refactor:
- refactor_overview.md: Detailed plan for forward-looking play tracking
- status-2025-10-24-1430.md: Session summary from Phase 2 implementation

These documents capture the architectural design decisions and
implementation roadmap that guided the refactor completed in commit 13e924a.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-25 22:19:59 -05:00

23 KiB

Session Summary: GameEngine Implementation & Runner Tracking Design

Date: October 24, 2025 Duration: ~3 hours Branch: implement-phase-2 Status: GameEngine Complete, 🔄 Runner Tracking Refactor Planned


Session Overview

Primary Objectives

  1. Implement GameEngine orchestration for complete at-bat flow
  2. Build PlayResolver with simplified result charts
  3. Create GameValidator for rule enforcement
  4. Test end-to-end gameplay with 50+ at-bats
  5. 🔄 Design proper runner tracking architecture (plan approved, implementation pending)

Technologies Involved

  • Python 3.13 (FastAPI backend)
  • PostgreSQL (asyncpg driver)
  • SQLAlchemy 2.0 async ORM
  • Pydantic v2 for models
  • pytest for testing

Overall Outcome

Phase 2 GameEngine is production-ready with successful test execution of 50 at-bats across 6 innings. Identified architectural improvement needed for runner tracking, designed comprehensive refactor plan that avoids legacy implementation pitfalls.


Current State

Active Todos

  • 🔄 In Progress: Refactor GameEngine for forward-looking play tracking
  • Pending: Update GameState model with batter indices and play snapshot
  • Pending: Implement _prepare_next_play() method
  • Pending: Refactor resolve_play() orchestration
  • Pending: Add state recovery from last play
  • Pending: Update test script to verify new architecture

Git Status

Branch: implement-phase-2 Recent Commits:

  • 0542723 - Fix GameEngine lineup integration and add test script
  • 0d7ddbe - Implement GameEngine, PlayResolver, and GameValidator
  • 874e24d - Implement comprehensive dice roll system with persistence

Modified Files (uncommitted):

  • None (clean working directory, plan mode active)

Key Files

  • backend/app/core/game_engine.py (334 lines) - Main orchestration
  • backend/app/core/play_resolver.py (372 lines) - Play resolution logic
  • backend/app/core/validators.py (115 lines) - Rule validation
  • backend/app/core/dice.py (441 lines) - Dice system
  • backend/app/core/roll_types.py (233 lines) - Roll dataclasses
  • backend/app/models/game_models.py (492 lines) - Pydantic state models
  • backend/scripts/test_game_flow.py (260 lines) - End-to-end test script

Changes Made This Session

Files Created

  1. app/core/validators.py (115 lines)

    • GameValidator class with static validation methods
    • Rule enforcement: outs (0-2), inning validation, decision validation
    • Game-over logic for 9 innings + extras
    • Key methods: validate_game_active(), validate_defensive_decision(), validate_offensive_decision(), is_game_over()
  2. app/core/play_resolver.py (372 lines)

    • PlayResolver class using advanced AbRoll system
    • SimplifiedResultChart for MVP outcome determination
    • 12 play outcomes: strikeout, groundout, flyout, walk, single, double, triple, HR, WP, PB, etc.
    • Runner advancement logic: _advance_on_walk(), _advance_on_single(), _advance_on_double()
    • Key methods: resolve_play(), _resolve_outcome()
  3. app/core/game_engine.py (334 lines)

    • GameEngine orchestration class
    • Game lifecycle: start_game(), submit_defensive_decision(), submit_offensive_decision(), resolve_play()
    • Batch roll saving: _batch_save_inning_rolls() called at half-inning boundaries
    • Play persistence: _save_play_to_db() with lineup integration
    • State management: _apply_play_result(), _advance_inning()
  4. scripts/test_game_flow.py (260 lines)

    • Comprehensive end-to-end test script
    • test_single_at_bat() - validates one complete play
    • test_full_inning() - runs 50+ plays across multiple innings
    • Creates dummy lineups for both teams (9 players each)
    • Test Results: 50 at-bats, 6 innings, Score: Away 5 - Home 2

Files Modified

  1. app/core/roll_types.py

    • Shortened AbRoll.__str__() from 70+ chars to <50 chars
    • Before: "AB Roll: 4, 9 (6+3), d20=12 (split=10)"
    • After: "AB 4,9(6+3) d20=12/10"
    • Reason: Database VARCHAR(50) constraint on plays.dice_roll
  2. app/core/game_engine.py (multiple iterations)

    • Added real lineup integration in _save_play_to_db():271
    • Fetches active lineups from database
    • Extracts batter_id, pitcher_id, catcher_id by position
    • Fixed: No more hardcoded placeholder IDs

Key Code References

GameEngine Start Flow

# app/core/game_engine.py:38-70
async def start_game(self, game_id: UUID) -> GameState:
    """Transitions pending -> active, initializes inning 1 top"""
    # Validates state, marks active, persists to DB
    # TODO: Will add lineup validation + _prepare_next_play()

Play Resolution Orchestration

# app/core/game_engine.py:119-157
async def resolve_play(self, game_id: UUID) -> PlayResult:
    """
    Current flow:
    1. Resolve play (dice + outcome)
    2. Track roll for batch save
    3. Apply result to state

    Planned refactor:
    1. Resolve play
    2. Save play (with snapshot)
    3. Apply result
    4. Update DB
    5. Check inning change
    6. Prepare next play
    """

Lineup Integration

# app/core/game_engine.py:275-285
# Fetches active lineups
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)

# Extracts by position
batter_id = batting_lineup[0].id if batting_lineup else None
pitcher_id = next((p.id for p in fielding_lineup if p.position == "P"), None)

Key Decisions & Discoveries

Architectural Decisions

  1. Forward-Looking Play Tracking (APPROVED PLAN)

    • Problem: Current implementation does awkward "lookbacks" to determine runner positions
    • Solution: Enrich GameState with "current play snapshot" that captures state BEFORE each play
    • Pattern: prepare_next_play() → resolve_play() → save_play() → apply_result() → prepare_next_play()
    • Benefit: Clean, forward-looking flow that avoids legacy implementation pitfalls
  2. Separate Batter Indices Per Team

    • Decision: Store away_team_batter_idx and home_team_batter_idx separately
    • Rationale: Saves 18+ database queries per game (one per half-inning)
    • Trade-off: One extra integer in memory (negligible cost)
    • Location: Will be added to GameState in game_models.py
  3. State Recovery from Last Play (Not Replay)

    • Decision: Rebuild GameState from most recent completed Play record
    • Query: SELECT * FROM plays WHERE game_id=X AND complete=true ORDER BY play_number DESC LIMIT 1
    • Benefit: Single-query recovery, no play-by-play replay needed
    • Edge Case: If no plays exist, initialize with defaults (indices=0, no runners)
  4. Lineup Validation at Game Start

    • Decision: HARD REQUIREMENT - both teams must have complete 9-player lineups before game starts
    • Enforcement: start_game() will throw ValidationError if lineups incomplete
    • Rationale: Prevents the "first play error" that plagued legacy implementation
  5. Explicit Orchestration in resolve_play()

    • Pattern: Explicit sequence of operations, no hidden side effects
    • Flow: resolve → save → apply → update_db → check_inning → prepare_next
    • Benefit: Easy to understand, test, and debug

Patterns Established

  1. Singleton Pattern for Core Services

    • dice_system, play_resolver, game_validator, game_engine
    • Single shared instance per module
    • Stateless or minimal state management
  2. Batch Operations at Boundaries

    • Roll saving batched at half-inning boundaries
    • Reduces database writes (from 30+ per inning to 1)
    • Stored in _rolls_this_inning dict keyed by game_id
  3. Pydantic Models for All Data Transfer

    • DefensiveDecision, OffensiveDecision, PlayResult
    • Automatic validation and serialization
    • Type-safe throughout the stack

Important Discoveries

  1. AbRoll String Length Issue

    • Database column: plays.dice_roll VARCHAR(50)
    • Original format exceeded limit: "AB Roll: Passed Ball Check (check=2, resolution=20)"
    • Solution: Compressed format: "PB 2/20", "AB 6,9(4+5) d20=12/10"
  2. Lineup Foreign Key Requirements

    • plays table requires valid lineup.id values for batter/pitcher/catcher
    • Cannot save plays without lineups created first
    • Test script creates dummy 9-player lineups per team
  3. GameState.runners Already Has lineup_id

    • RunnerState dataclass includes lineup_id field
    • Can use directly for on_first_id, on_second_id, on_third_id
    • No additional tracking needed
  4. on_base_code Bit Field Pattern

    • Efficient database querying: WHERE on_base_code = 7 (bases loaded)
    • Bit flags: 1=first (0b001), 2=second (0b010), 4=third (0b100)
    • Combined: 7=loaded (0b111), 3=1st+2nd, 5=1st+3rd, 6=2nd+3rd

Problems & Solutions

Problem 1: Foreign Key Constraint on plays.batter_id

Error:

asyncpg.exceptions.ForeignKeyViolationError: insert or update on table "plays"
violates foreign key constraint "plays_batter_id_fkey"
DETAIL: Key (batter_id)=(1) is not present in table "lineups".

Root Cause: GameEngine was using hardcoded placeholder IDs (batter_id=1, pitcher_id=1, catcher_id=1)

Solution:

  • Updated _save_play_to_db() to fetch real lineup records
  • Query active lineups from database
  • Extract IDs by position (P, C, first batter)
  • Test script now creates 18 dummy lineup entries (9 per team)

Files Changed:

  • backend/app/core/game_engine.py:271-285
  • backend/scripts/test_game_flow.py:50-70, 165-185

Problem 2: String Too Long for VARCHAR(50)

Error:

asyncpg.exceptions.StringDataRightTruncationError: value too long for type character varying(50)

Root Cause: AbRoll.__str__() returned 70+ character strings like:

  • "AB Roll: Wild Pitch Check (check=2, resolution=20)"
  • "AB Roll: Passed Ball Check (check=2, resolution=20)"

Solution: Compressed string format

  • Wild Pitch: "WP 2/20" (8 chars)
  • Passed Ball: "PB 2/20" (8 chars)
  • Normal: "AB 4,9(6+3) d20=12/10" (~22 chars)

Files Changed: backend/app/core/roll_types.py:97-103

Problem 3: Missing Runner Tracking in Play Records

Issue: Play records not capturing:

  • on_first_id, on_second_id, on_third_id (who was on base)
  • on_first_final, on_second_final, on_third_final (where they ended up)
  • batter_final (where batter ended up)
  • on_base_code (bit field for queries)

Root Cause: Awkward "lookback" architecture - trying to infer state after the fact

Solution (APPROVED PLAN): Forward-looking play tracking

  • Capture snapshot BEFORE play in GameState
  • Use snapshot when saving Play record
  • No lookbacks needed

Status: Plan designed and approved, implementation pending


Technology Context

System Components

  • FastAPI Backend: Python 3.13, running at http://localhost:8000
  • PostgreSQL Database: 10.10.0.42:5432, database paperdynasty_dev
  • State Management: Hybrid in-memory (dict) + PostgreSQL persistence
  • Dice System: Cryptographically secure using secrets module
  • Testing: pytest with pytest-asyncio for async test support

Database Schema (Relevant Tables)

games table

id UUID PRIMARY KEY
status VARCHAR  -- 'pending', 'active', 'completed'
current_inning INTEGER
current_half VARCHAR  -- 'top', 'bottom'
home_score INTEGER
away_score INTEGER
-- TODO: Will add for state recovery:
--   current_runners JSON
--   away_team_batter_idx INTEGER
--   home_team_batter_idx INTEGER

lineups table

id SERIAL PRIMARY KEY
game_id UUID REFERENCES games
team_id INTEGER
player_id INTEGER (nullable, for SBA)
card_id INTEGER (nullable, for PD)
position VARCHAR  -- P, C, 1B, 2B, 3B, SS, LF, CF, RF, DH
batting_order INTEGER  -- 1-9
is_active BOOLEAN

plays table

id SERIAL PRIMARY KEY
game_id UUID REFERENCES games
play_number INTEGER
batter_id INTEGER REFERENCES lineups  -- ✅ Now populated
pitcher_id INTEGER REFERENCES lineups  -- ✅ Now populated
catcher_id INTEGER REFERENCES lineups  -- ✅ Now populated
on_first_id INTEGER REFERENCES lineups  -- ❌ TODO
on_second_id INTEGER REFERENCES lineups  -- ❌ TODO
on_third_id INTEGER REFERENCES lineups  -- ❌ TODO
on_first_final INTEGER  -- ❌ TODO
on_second_final INTEGER  -- ❌ TODO
on_third_final INTEGER  -- ❌ TODO
batter_final INTEGER  -- ❌ TODO
on_base_code INTEGER  -- ❌ TODO (bit field: 1|2|4)
dice_roll VARCHAR(50)  -- ✅ Fixed length issue
result_description TEXT
outs_recorded INTEGER
runs_scored INTEGER
complete BOOLEAN

rolls table

roll_id VARCHAR PRIMARY KEY  -- Unique from secrets.token_hex()
game_id UUID REFERENCES games
roll_type VARCHAR  -- 'ab', 'jump', 'fielding', 'd20'
roll_data JSONB  -- Complete roll details
context JSONB  -- Game context (inning, outs, etc.)
timestamp TIMESTAMP

Dependencies

  • SQLAlchemy 2.0.36 - Async ORM
  • asyncpg 0.30.0 - PostgreSQL async driver
  • Pydantic 2.10.6 - Data validation
  • Pendulum 3.0.0 - DateTime handling (replaces datetime)
  • pytest-asyncio 0.25.2 - Async test support

Performance Characteristics

  • At-bat resolution: ~200ms (includes DB writes)
  • State access: O(1) dictionary lookup in memory
  • Roll batch save: 1 write per half-inning vs 30+ individual writes
  • Test run: 50 at-bats across 6 innings in ~24 seconds

Next Steps

Immediate Actions (Approved Plan)

  1. Update GameState Model (backend/app/models/game_models.py)

    • Add away_team_batter_idx: int = 0
    • Add home_team_batter_idx: int = 0
    • Add current_batter_lineup_id: Optional[int] = None
    • Add current_pitcher_lineup_id: Optional[int] = None
    • Add current_catcher_lineup_id: Optional[int] = None
    • Add current_on_base_code: int = 0
  2. Refactor start_game() (backend/app/core/game_engine.py:38)

    • Add lineup validation (both teams, 9 players minimum)
    • Call _prepare_next_play() before returning
    • Throw ValidationError if lineups incomplete
  3. Create _prepare_next_play() Method (new in game_engine.py)

    • Determine current batter index based on half
    • Advance appropriate team's batter_idx (% 9 wrap)
    • Fetch active lineups
    • Set current_batter/pitcher/catcher_lineup_id
    • Calculate on_base_code from state.runners
  4. Refactor resolve_play() (backend/app/core/game_engine.py:119)

    • Explicit orchestration sequence:
      1. Resolve play
      2. Save play (with snapshot)
      3. Apply result
      4. Update DB
      5. Check inning change → update DB again
      6. Prepare next play
  5. Update _save_play_to_db() (backend/app/core/game_engine.py:271)

    • Use snapshot from GameState (no queries for batter/pitcher/catcher)
    • Extract runner IDs from state.runners
    • Calculate finals from result.runners_advanced
    • Set on_base_code from snapshot
  6. Add State Recovery (backend/app/core/state_manager.py)

    • New method: recover_game_from_last_play(game_id)
    • Query last play, extract runner positions and batter indices
    • Rebuild GameState
    • Call _prepare_next_play()

Testing Requirements

  • Test start_game() fails with incomplete lineups
  • Verify on_base_code calculation (bit field 1|2|4)
  • Verify runner lineup_ids in Play records
  • Test batting order cycles 0-8 per team
  • Test state recovery from last play

Follow-up Work (Post-MVP)

  • Player substitutions (pinch hitter, defensive replacement)
  • Pitcher fatigue tracking
  • Detailed result charts (load from config files)
  • Advanced decision logic (steal success rates, etc.)
  • WebSocket integration for real-time updates
  • Frontend game interface

Reference Information

Key File Paths

/mnt/NV2/Development/strat-gameplay-webapp/backend/
├── app/
│   ├── core/
│   │   ├── game_engine.py        # Main orchestration (334 lines)
│   │   ├── play_resolver.py      # Play resolution (372 lines)
│   │   ├── validators.py         # Rule validation (115 lines)
│   │   ├── dice.py               # Dice system (441 lines)
│   │   ├── roll_types.py         # Roll dataclasses (233 lines)
│   │   └── state_manager.py      # In-memory state (296 lines)
│   │
│   ├── models/
│   │   ├── game_models.py        # Pydantic state models (492 lines)
│   │   └── db_models.py          # SQLAlchemy ORM models
│   │
│   └── database/
│       └── operations.py         # DB operations (362 lines)
│
├── scripts/
│   └── test_game_flow.py         # End-to-end test (260 lines)
│
└── tests/
    ├── unit/core/
    │   ├── test_dice.py          # 35 tests (34/35 passing)
    │   └── test_roll_types.py    # 27 tests (27/27 passing)
    └── integration/
        └── database/
            └── test_roll_persistence.py  # 16 tests (work individually)

Common Commands

# Activate virtual environment
cd /mnt/NV2/Development/strat-gameplay-webapp/backend
source venv/bin/activate

# Run end-to-end test
python scripts/test_game_flow.py

# Run unit tests
pytest tests/unit/core/ -v

# Run specific test
pytest tests/unit/core/test_dice.py -v

# Check git status
git status
git log --oneline -10

# Start development server
python -m app.main  # http://localhost:8000

Important URLs

Git Branches

  • main - Production branch
  • implement-phase-2 - Current development (GameEngine work)

Session Cost

  • Total: $17.15
  • Duration: 35m 34s (API time), 22h 35m 33s (wall time)
  • Code changes: 2,020 lines added, 346 lines removed
  • Model: Claude Sonnet (13.7k input, 68.1k output)

Architecture Diagram

┌─────────────────────────────────────────────────────────────┐
│                      GameEngine                             │
│                                                             │
│  start_game()                                               │
│    ├─ Validate lineups (HARD REQUIREMENT)                  │
│    ├─ Transition: pending → active                         │
│    └─ Call _prepare_next_play() [NEW]                      │
│                                                             │
│  resolve_play()                                             │
│    ├─ 1. Resolve (PlayResolver + DiceSystem)               │
│    ├─ 2. Save play (uses GameState snapshot)               │
│    ├─ 3. Apply result                                      │
│    ├─ 4. Update DB                                         │
│    ├─ 5. Check inning → _advance_inning() → Update DB      │
│    └─ 6. _prepare_next_play() [NEW]                        │
│                                                             │
│  _prepare_next_play() [NEW METHOD]                         │
│    ├─ Advance batter index (per team, wrap 0-8)            │
│    ├─ Fetch active lineups                                 │
│    ├─ Set current_batter/pitcher/catcher_lineup_id         │
│    └─ Calculate on_base_code from runners                  │
│                                                             │
└─────────────────────────────────────────────────────────────┘
                            │
                            ↓
┌─────────────────────────────────────────────────────────────┐
│                      GameState (Enhanced)                   │
│                                                             │
│  Existing Fields:                                           │
│    - game_id, inning, half, outs                            │
│    - home_score, away_score                                 │
│    - runners: List[RunnerState]  (has lineup_id!)          │
│    - status, play_count, etc.                               │
│                                                             │
│  NEW Fields (Approved Plan):                                │
│    - away_team_batter_idx: int = 0  (0-8)                   │
│    - home_team_batter_idx: int = 0  (0-8)                   │
│    - current_batter_lineup_id: Optional[int]                │
│    - current_pitcher_lineup_id: Optional[int]               │
│    - current_catcher_lineup_id: Optional[int]               │
│    - current_on_base_code: int = 0  (bit field)             │
│                                                             │
└─────────────────────────────────────────────────────────────┘
                            │
                            ↓
┌─────────────────────────────────────────────────────────────┐
│                      Play Record (DB)                       │
│                                                             │
│  Snapshot (from GameState):                                 │
│    - batter_id ← current_batter_lineup_id                   │
│    - pitcher_id ← current_pitcher_lineup_id                 │
│    - catcher_id ← current_catcher_lineup_id                 │
│    - on_base_code ← current_on_base_code                    │
│    - on_first_id ← state.runners (lineup_id where base=1)   │
│    - on_second_id ← state.runners (lineup_id where base=2)  │
│    - on_third_id ← state.runners (lineup_id where base=3)   │
│                                                             │
│  Result (from PlayResult):                                  │
│    - on_first_final ← runners_advanced                      │
│    - on_second_final ← runners_advanced                     │
│    - on_third_final ← runners_advanced                      │
│    - batter_final ← batter_result                           │
│    - outs_recorded, runs_scored, description                │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Status: Ready for Implementation

All architectural decisions made and approved. Next agent can proceed with implementation following the approved plan above.

Estimated Implementation Time: 2-3 hours Priority: High (core functionality) Risk: Low (well-defined scope, clear plan)