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: