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:
parent
450ef830dc
commit
4d2c905a1c
138
.claude/game-engine-play-tracking-design.md
Normal file
138
.claude/game-engine-play-tracking-design.md
Normal 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
|
||||||
@ -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
|
|
||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user