From 05fc037f2b0ba86bfd737b1f465bb26456602ba1 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sun, 26 Oct 2025 13:14:12 -0500 Subject: [PATCH] CLAUDE: Fix game recovery and add required field validation for plays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/core/game_engine.py | 20 +++++++++++++++++ backend/terminal_client/repl.py | 39 ++++++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/backend/app/core/game_engine.py b/backend/app/core/game_engine.py index 9511b16..aba99cc 100644 --- a/backend/app/core/game_engine.py +++ b/backend/app/core/game_engine.py @@ -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) diff --git a/backend/terminal_client/repl.py b/backend/terminal_client/repl.py index f327297..14a5af6 100644 --- a/backend/terminal_client/repl.py +++ b/backend/terminal_client/repl.py @@ -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: