 ⎿  Refactor GameEngine for Forward-Looking Play Tracking

    Problem

    Current implementation does awkward "lookbacks" to determine runner positions. Need clean "prepare → execute → save" pattern that was error-prone in legacy implementation.

    Solution Overview

    Enrich GameState with current play snapshot. Use start_game() to validate lineups and prepare first play. Each resolve_play() explicitly orchestrates the sequence. Add state
    recovery from last completed play.

    Changes Required

    1. Update GameState Model (game_models.py)

    Add fields to track current play snapshot:
    # Batting order tracking (per team)
    away_team_batter_idx: int = 0  # 0-8, wraps for MVP (no subs yet)
    home_team_batter_idx: int = 0  # 0-8

    # Current play snapshot (set by _prepare_next_play)
    current_batter_lineup_id: Optional[int] = None
    current_pitcher_lineup_id: Optional[int] = None
    current_catcher_lineup_id: Optional[int] = None
    current_on_base_code: int = 0  # Bit field (1=1st, 2=2nd, 4=3rd, 7=loaded)

    2. Refactor start_game() in GameEngine

    - Validate BOTH lineups complete (minimum 9 players each) - HARD REQUIREMENT
    - Throw ValidationError if lineups incomplete or missing positions
    - After transitioning to active, call _prepare_next_play()
    - Return state with first play ready to execute

    3. Create _prepare_next_play() Method

    # Determine current batter and advance index
    if state.half == "top":
        current_idx = state.away_team_batter_idx
        state.away_team_batter_idx = (current_idx + 1) % 9
    else:
        current_idx = state.home_team_batter_idx
        state.home_team_batter_idx = (current_idx + 1) % 9

    # Fetch active lineups, set snapshot
    state.current_batter_lineup_id = batting_lineup[current_idx].id
    state.current_pitcher_lineup_id = next(p for p in fielding if p.position == "P").id
    state.current_catcher_lineup_id = next(p for p in fielding if p.position == "C").id

    # Calculate on_base_code from state.runners
    state.current_on_base_code = 0
    for runner in state.runners:
        if runner.on_base == 1: state.current_on_base_code |= 1
        if runner.on_base == 2: state.current_on_base_code |= 2
        if runner.on_base == 3: state.current_on_base_code |= 4

    4. Refactor resolve_play() Orchestration

    Explicit sequence (no hidden side effects):
    1. Resolve play with dice rolls
    2. Save play to DB (uses snapshot from GameState)
    3. Apply result to state (outs, score, runners)
    4. Update game state in DB
    5. If outs >= 3:
       - Advance inning (clear bases, reset outs, increment, batch save rolls)
       - Update game state in DB again
    6. Prepare next play (always last step)

    5. Update _save_play_to_db()

    Use snapshot from GameState (NO lookbacks):
    # From snapshot
    batter_id = state.current_batter_lineup_id
    pitcher_id = state.current_pitcher_lineup_id
    catcher_id = state.current_catcher_lineup_id
    on_base_code = state.current_on_base_code

    # Runners on base BEFORE play (from state.runners)
    on_first_id = next((r.lineup_id for r in state.runners if r.on_base == 1), None)
    on_second_id = next((r.lineup_id for r in state.runners if r.on_base == 2), None)
    on_third_id = next((r.lineup_id for r in state.runners if r.on_base == 3), None)

    # Runners AFTER play (from result.runners_advanced)
    # Build dict of from_base -> to_base (RunnerAdvancementData is a dataclass)
    finals = {adv.from_base: adv.to_base for adv in result.runners_advanced}
    on_first_final = finals.get(1)  # None if out/scored, 1-4 if moved
    on_second_final = finals.get(2)
    on_third_final = finals.get(3)

    # Batter result
    batter_final = result.batter_result  # None=out, 1-4=base reached

    6. Keep _apply_play_result() Focused

    Only update in-memory state (NO database writes):
    - Update outs, score, runners, play_count
    - Database writes handled by orchestration layer

    7. Keep _advance_inning() Focused

    Only handle inning transition:
    - Clear bases, reset outs, increment inning/half
    - Check game over, batch save rolls
    - NO prepare_next_play (orchestration handles)
    - NO database writes (orchestration handles)

    8. Add State Recovery (state_manager.py)

    New method: recover_game_from_last_play(game_id)
    1. Load games table → basic state
    2. Query last completed play:
       SELECT * FROM plays WHERE game_id=X AND complete=true
       ORDER BY play_number DESC LIMIT 1
    3. If play exists:
       - Runners: on_first_final, on_second_final, on_third_final (use lineup_ids)
       - Batting indices: derive from batting_order and team
    4. If no play (just started):
       - Initialize: indices=0, no runners
    5. Reconstruct GameState in memory
    6. Call _prepare_next_play() → ready to resume

    Benefits

    ✅ No special case for first play
    ✅ No awkward lookbacks
    ✅ Clean validation (can't start without lineups)
    ✅ Single source of truth (GameState)
    ✅ Explicit orchestration (easy to understand)
    ✅ Fast state recovery (one query, no replay)
    ✅ Separate batter indices (18+ queries saved per game)

    Testing Updates

    Update test script to verify:
    - start_game() fails with incomplete lineups
    - on_base_code calculated correctly (bit field 1|2|4)
    - Runner lineup_ids tracked in Play records
    - Batting order cycles 0-8 per team independently
    - State recovery from last play works
