From 4d2c905a1cd80db69d990194e1a62bd0e4c63813 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 28 Nov 2025 23:11:26 -0600 Subject: [PATCH] CLAUDE: Fix RunnerAdvancementData tuple unpacking regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After changing runners_advanced from list[tuple] to list[RunnerAdvancementData], three locations were still trying to unpack as tuples causing "cannot unpack non-iterable RunnerAdvancementData object" error on triples/doubles with runners. Fixed locations: - app/core/game_engine.py:772 - _apply_play_result() advancement map - app/core/game_engine.py:1081 - _save_play() finals lookup - terminal_client/display.py:150 - Runner movement display Also renamed refactor_overview.md to game-engine-play-tracking-design.md for clarity and updated the example code to use dataclass access pattern. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .claude/game-engine-play-tracking-design.md | 138 ++++++++++++++++++++ .claude/refactor_overview.md | 138 -------------------- backend/app/core/game_engine.py | 4 +- backend/terminal_client/display.py | 8 +- 4 files changed, 144 insertions(+), 144 deletions(-) create mode 100644 .claude/game-engine-play-tracking-design.md delete mode 100644 .claude/refactor_overview.md diff --git a/.claude/game-engine-play-tracking-design.md b/.claude/game-engine-play-tracking-design.md new file mode 100644 index 0000000..a940cea --- /dev/null +++ b/.claude/game-engine-play-tracking-design.md @@ -0,0 +1,138 @@ + ⎿ 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 diff --git a/.claude/refactor_overview.md b/.claude/refactor_overview.md deleted file mode 100644 index 815e597..0000000 --- a/.claude/refactor_overview.md +++ /dev/null @@ -1,138 +0,0 @@ - ⎿ Β 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 - finals = {from_base: to_base for from_base, to_base 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 diff --git a/backend/app/core/game_engine.py b/backend/app/core/game_engine.py index 5d8b8a2..baf3778 100644 --- a/backend/app/core/game_engine.py +++ b/backend/app/core/game_engine.py @@ -769,7 +769,7 @@ class GameEngine: # Build advancement lookup advancement_map = { - from_base: to_base for from_base, to_base in result.runners_advanced + adv.from_base: adv.to_base for adv in result.runners_advanced } # Create temporary storage for new runner positions @@ -1078,7 +1078,7 @@ class GameEngine: # Runners AFTER play (from result.runners_advanced) # Build dict of from_base -> to_base for quick lookup - finals = {from_base: to_base for from_base, to_base in result.runners_advanced} + 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 advanced on_second_final = finals.get(2) # None if out/scored, 1-4 if advanced on_third_final = finals.get(3) # None if out/scored, 1-4 if advanced diff --git a/backend/terminal_client/display.py b/backend/terminal_client/display.py index 31f61fa..c930617 100644 --- a/backend/terminal_client/display.py +++ b/backend/terminal_client/display.py @@ -147,11 +147,11 @@ def display_play_result(result: PlayResult, state: GameState) -> None: # Runner advancement if result.runners_advanced: result_text.append(f"\nRunner Movement:\n", style="bold") - for from_base, to_base in result.runners_advanced: - if to_base == 4: - result_text.append(f" {from_base}B β†’ SCORES\n", style="green") + for adv in result.runners_advanced: + if adv.to_base == 4: + result_text.append(f" {adv.from_base}B β†’ SCORES\n", style="green") else: - result_text.append(f" {from_base}B β†’ {to_base}B\n") + result_text.append(f" {adv.from_base}B β†’ {adv.to_base}B\n") # Batter result if result.batter_result: