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
|
||||
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
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user