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:
parent
57121b62bd
commit
19b35f148b
@ -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"}
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user