CLAUDE: Fix game recovery and add required field validation for plays

Fixed critical bugs in game recovery and play persistence:

1. Terminal REPL Auto-Recovery:
   - Added _ensure_game_loaded() helper to auto-recover games from database
   - Calls state_manager.recover_game() when game not in memory
   - Calls _prepare_next_play() after recovery to populate snapshot fields
   - Enables seamless continuation of games across REPL sessions

2. Play Validation:
   - Added verification in _save_play_to_db() for required fields
   - Ensures batter_id, pitcher_id, catcher_id are never NULL
   - Raises ValueError with clear error message if fields missing
   - Prevents database constraint violations

3. Updated Commands:
   - All REPL commands now call _ensure_game_loaded()
   - Commands: defensive, offensive, resolve, status, quick_play, box_score
   - Fixes "Game state not found" errors on recovered games

Root Cause:
- state_manager.recover_game() rebuilds GameState from database
- But didn't populate snapshot fields (current_batter_lineup_id, etc.)
- _save_play_to_db() requires these fields to save plays
- Solution: Call _prepare_next_play() after recovery

Files Modified:
- app/core/game_engine.py - Added verification in _save_play_to_db()
- terminal_client/repl.py - Added _ensure_game_loaded() and integrated

Testing: Successfully recovered game, submitted decisions, and resolved plays

🚀 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-10-26 13:14:12 -05:00
parent 918beadf24
commit 05fc037f2b
2 changed files with 58 additions and 1 deletions

View File

@ -425,6 +425,9 @@ class GameEngine:
Save play to database using snapshot from GameState.
Uses the pre-calculated snapshot fields (no database lookbacks).
Raises:
ValueError: If required player IDs are missing
"""
# Use snapshot from GameState (set by _prepare_next_play)
batter_id = state.current_batter_lineup_id
@ -432,6 +435,23 @@ class GameEngine:
catcher_id = state.current_catcher_lineup_id
on_base_code = state.current_on_base_code
# VERIFY required fields are present
if batter_id is None:
raise ValueError(
f"Cannot save play: batter_id is None. "
f"Game {state.game_id} may need _prepare_next_play() called after recovery."
)
if pitcher_id is None:
raise ValueError(
f"Cannot save play: pitcher_id is None. "
f"Game {state.game_id} may need _prepare_next_play() called after recovery."
)
if catcher_id is None:
raise ValueError(
f"Cannot save play: catcher_id is None. "
f"Game {state.game_id} may need _prepare_next_play() called after recovery."
)
# 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)

View File

@ -71,6 +71,36 @@ Note: Use underscores in command names (e.g., 'new_game' not 'new-game')
raise ValueError("No current game")
return self.current_game_id
async def _ensure_game_loaded(self, game_id: UUID) -> None:
"""
Ensure game is loaded in state_manager.
If game exists in database but not in memory, recover it using
state_manager.recover_game() which replays plays to rebuild state,
then prepare the next play to populate snapshot fields.
"""
# Check if already in memory
state = state_manager.get_state(game_id)
if state is not None:
return # Already loaded
# Try to recover from database
try:
display.print_info(f"Loading game {game_id} from database...")
recovered_state = await state_manager.recover_game(game_id)
if recovered_state and recovered_state.status == "active":
# Call _prepare_next_play to populate snapshot fields
# (batter_id, pitcher_id, catcher_id, on_base_code)
await game_engine._prepare_next_play(recovered_state)
logger.debug(f"Prepared snapshot for recovered game {game_id}")
display.print_success("Game loaded successfully")
except Exception as e:
display.print_error(f"Failed to load game: {e}")
logger.error(f"Game recovery failed for {game_id}: {e}", exc_info=True)
raise ValueError(f"Game {game_id} not found")
def _run_async(self, coro):
"""
Helper to run async functions using persistent event loop.
@ -201,6 +231,7 @@ Note: Use underscores in command names (e.g., 'new_game' not 'new-game')
async def _defensive():
try:
gid = self._ensure_game()
await self._ensure_game_loaded(gid)
# Parse arguments
args = arg.split()
@ -265,6 +296,7 @@ Note: Use underscores in command names (e.g., 'new_game' not 'new-game')
async def _offensive():
try:
gid = self._ensure_game()
await self._ensure_game_loaded(gid)
# Parse arguments
args = arg.split()
@ -320,6 +352,7 @@ Note: Use underscores in command names (e.g., 'new_game' not 'new-game')
async def _resolve():
try:
gid = self._ensure_game()
await self._ensure_game_loaded(gid)
result = await game_engine.resolve_play(gid)
state = await game_engine.get_game_state(gid)
@ -345,8 +378,9 @@ Note: Use underscores in command names (e.g., 'new_game' not 'new-game')
async def _status():
try:
gid = self._ensure_game()
state = await game_engine.get_game_state(gid)
await self._ensure_game_loaded(gid)
state = await game_engine.get_game_state(gid)
if state:
display.display_game_state(state)
else:
@ -373,6 +407,8 @@ Note: Use underscores in command names (e.g., 'new_game' not 'new-game')
async def _quick_play():
try:
gid = self._ensure_game()
await self._ensure_game_loaded(gid)
count = int(arg) if arg.strip() else 1
for i in range(count):
@ -420,6 +456,7 @@ Note: Use underscores in command names (e.g., 'new_game' not 'new-game')
async def _box_score():
try:
gid = self._ensure_game()
await self._ensure_game_loaded(gid)
state = await game_engine.get_game_state(gid)
if state: