From 19b35f148be8bbae25b66c72a32912bbcea12378 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 28 Nov 2025 12:38:56 -0600 Subject: [PATCH] CLAUDE: Load play history on mid-game join via game_state_sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend changes: - Modified request_game_state handler to fetch plays from database - Convert Play DB models to frontend-compatible PlayResult dicts - Emit game_state_sync event with state + recent_plays array Frontend changes: - Added deduplication by play_number in addPlayToHistory() - Prevents duplicate plays when game_state_sync is received Field mapping from Play model: - hit_type -> outcome - result_description -> description - batter_id -> batter_lineup_id - batter_final -> batter_result 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- backend/app/websocket/handlers.py | 46 +++++++++++++++++-- .../websocket/test_connection_handlers.py | 39 +++++++++++++--- frontend-sba/store/game.ts | 8 +++- 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/backend/app/websocket/handlers.py b/backend/app/websocket/handlers.py index 978d737..e5b92ac 100644 --- a/backend/app/websocket/handlers.py +++ b/backend/app/websocket/handlers.py @@ -2,6 +2,7 @@ import asyncio import logging from uuid import UUID +import pendulum from pydantic import ValidationError from socketio import AsyncServer from sqlalchemy.exc import SQLAlchemyError @@ -199,11 +200,50 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: state = await state_manager.recover_game(game_id) if state: - # Use mode='json' to serialize UUIDs as strings + # Fetch play history from database for mid-game joins + db_ops = DatabaseOperations() + plays = await db_ops.get_plays(game_id) + + # Convert Play DB models to frontend-compatible PlayResult dicts + # Play model uses: hit_type (outcome), result_description (description), batter_id + recent_plays = [] + for p in plays: + outcome = p.hit_type or "" + # Parse outcome for categorization + try: + outcome_enum = PlayOutcome(outcome) + is_hit = outcome_enum.is_hit() + except ValueError: + is_hit = False + + recent_plays.append({ + "play_number": p.play_number, + "inning": p.inning, + "half": p.half, + "outcome": outcome, + "description": p.result_description or "", + "runs_scored": p.runs_scored or 0, + "outs_recorded": p.outs_recorded or 0, + "batter_lineup_id": p.batter_id, + "runners_advanced": [], # Not stored in Play model + "batter_result": p.batter_final, + "is_hit": is_hit, + "is_out": (p.outs_recorded or 0) > 0, + "is_walk": outcome and "walk" in outcome.lower(), + "is_strikeout": outcome and "strikeout" in outcome.lower(), + }) + + # Emit game_state_sync with state and play history await manager.emit_to_user( - sid, "game_state", state.model_dump(mode="json") + sid, + "game_state_sync", + { + "state": state.model_dump(mode="json"), + "recent_plays": recent_plays, + "timestamp": pendulum.now("UTC").isoformat(), + } ) - logger.info(f"Sent game state for {game_id} to {sid}") + logger.info(f"Sent game state sync with {len(recent_plays)} plays for {game_id} to {sid}") else: await manager.emit_to_user( sid, "error", {"message": f"Game {game_id} not found"} diff --git a/backend/tests/unit/websocket/test_connection_handlers.py b/backend/tests/unit/websocket/test_connection_handlers.py index 63cd6a2..7263ddb 100644 --- a/backend/tests/unit/websocket/test_connection_handlers.py +++ b/backend/tests/unit/websocket/test_connection_handlers.py @@ -585,18 +585,24 @@ class TestRequestGameStateHandler: @pytest.mark.asyncio async def test_request_game_state_from_memory(self, mock_manager, mock_game_state): """ - Verify request_game_state() returns state from memory. + Verify request_game_state() returns state from memory with play history. When game state is in StateManager memory, handler should return it - directly without hitting the database (fast path). + along with play history via game_state_sync event. """ from socketio import AsyncServer sio = AsyncServer() - with patch("app.websocket.handlers.state_manager") as mock_state_mgr: + with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \ + patch("app.websocket.handlers.DatabaseOperations") as mock_db_ops_class: mock_state_mgr.get_state.return_value = mock_game_state + # Mock DatabaseOperations.get_plays() to return empty list + mock_db_ops = AsyncMock() + mock_db_ops.get_plays = AsyncMock(return_value=[]) + mock_db_ops_class.return_value = mock_db_ops + from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) @@ -606,10 +612,16 @@ class TestRequestGameStateHandler: mock_state_mgr.get_state.assert_called_once() mock_state_mgr.recover_game.assert_not_called() + mock_db_ops.get_plays.assert_called_once() mock_manager.emit_to_user.assert_called_once() call_args = mock_manager.emit_to_user.call_args - assert call_args[0][1] == "game_state" + assert call_args[0][1] == "game_state_sync" + # Verify payload structure + payload = call_args[0][2] + assert "state" in payload + assert "recent_plays" in payload + assert "timestamp" in payload @pytest.mark.asyncio async def test_request_game_state_from_db(self, mock_manager, mock_game_state): @@ -617,16 +629,23 @@ class TestRequestGameStateHandler: Verify request_game_state() recovers from database when not in memory. When game state is not in memory, handler should call recover_game() - to load and replay from database (recovery path). + to load and replay from database (recovery path), then return state + with play history via game_state_sync event. """ from socketio import AsyncServer sio = AsyncServer() - with patch("app.websocket.handlers.state_manager") as mock_state_mgr: + with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \ + patch("app.websocket.handlers.DatabaseOperations") as mock_db_ops_class: mock_state_mgr.get_state.return_value = None # Not in memory mock_state_mgr.recover_game = AsyncMock(return_value=mock_game_state) + # Mock DatabaseOperations.get_plays() to return empty list + mock_db_ops = AsyncMock() + mock_db_ops.get_plays = AsyncMock(return_value=[]) + mock_db_ops_class.return_value = mock_db_ops + from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) @@ -636,10 +655,16 @@ class TestRequestGameStateHandler: mock_state_mgr.get_state.assert_called_once() mock_state_mgr.recover_game.assert_called_once() + mock_db_ops.get_plays.assert_called_once() mock_manager.emit_to_user.assert_called_once() call_args = mock_manager.emit_to_user.call_args - assert call_args[0][1] == "game_state" + assert call_args[0][1] == "game_state_sync" + # Verify payload structure + payload = call_args[0][2] + assert "state" in payload + assert "recent_plays" in payload + assert "timestamp" in payload @pytest.mark.asyncio async def test_request_game_state_missing_game_id(self, mock_manager): diff --git a/frontend-sba/store/game.ts b/frontend-sba/store/game.ts index 60e0847..61be00c 100644 --- a/frontend-sba/store/game.ts +++ b/frontend-sba/store/game.ts @@ -178,9 +178,15 @@ export const useGameStore = defineStore('game', () => { } /** - * Add play to history + * Add play to history (with deduplication by play_number) */ function addPlayToHistory(play: PlayResult) { + // Check if play already exists (deduplicate by play_number) + const exists = playHistory.value.some(p => p.play_number === play.play_number) + if (exists) { + return + } + playHistory.value.push(play) // Update game state from play result if provided