CLAUDE: Fix RunnerAdvancementData tuple unpacking regression

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 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-11-28 23:11:26 -06:00
parent 450ef830dc
commit 4d2c905a1c
4 changed files with 144 additions and 144 deletions

View File

@ -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

View File

@ -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

View File

@ -769,7 +769,7 @@ class GameEngine:
# Build advancement lookup # Build advancement lookup
advancement_map = { 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 # Create temporary storage for new runner positions
@ -1078,7 +1078,7 @@ class GameEngine:
# Runners AFTER play (from result.runners_advanced) # Runners AFTER play (from result.runners_advanced)
# Build dict of from_base -> to_base for quick lookup # 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_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_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 on_third_final = finals.get(3) # None if out/scored, 1-4 if advanced

View File

@ -147,11 +147,11 @@ def display_play_result(result: PlayResult, state: GameState) -> None:
# Runner advancement # Runner advancement
if result.runners_advanced: if result.runners_advanced:
result_text.append(f"\nRunner Movement:\n", style="bold") result_text.append(f"\nRunner Movement:\n", style="bold")
for from_base, to_base in result.runners_advanced: for adv in result.runners_advanced:
if to_base == 4: if adv.to_base == 4:
result_text.append(f" {from_base}B → SCORES\n", style="green") result_text.append(f" {adv.from_base}B → SCORES\n", style="green")
else: 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 # Batter result
if result.batter_result: if result.batter_result: