CLAUDE: Load play history on mid-game join via game_state_sync

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 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-11-28 12:38:56 -06:00
parent 57121b62bd
commit 19b35f148b
3 changed files with 82 additions and 11 deletions

View File

@ -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"}

View File

@ -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):

View File

@ -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