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
|
import logging
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
import pendulum
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from socketio import AsyncServer
|
from socketio import AsyncServer
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
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)
|
state = await state_manager.recover_game(game_id)
|
||||||
|
|
||||||
if state:
|
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(
|
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:
|
else:
|
||||||
await manager.emit_to_user(
|
await manager.emit_to_user(
|
||||||
sid, "error", {"message": f"Game {game_id} not found"}
|
sid, "error", {"message": f"Game {game_id} not found"}
|
||||||
|
|||||||
@ -585,18 +585,24 @@ class TestRequestGameStateHandler:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_request_game_state_from_memory(self, mock_manager, mock_game_state):
|
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
|
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
|
from socketio import AsyncServer
|
||||||
|
|
||||||
sio = 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_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
|
from app.websocket.handlers import register_handlers
|
||||||
|
|
||||||
register_handlers(sio, mock_manager)
|
register_handlers(sio, mock_manager)
|
||||||
@ -606,10 +612,16 @@ class TestRequestGameStateHandler:
|
|||||||
|
|
||||||
mock_state_mgr.get_state.assert_called_once()
|
mock_state_mgr.get_state.assert_called_once()
|
||||||
mock_state_mgr.recover_game.assert_not_called()
|
mock_state_mgr.recover_game.assert_not_called()
|
||||||
|
mock_db_ops.get_plays.assert_called_once()
|
||||||
mock_manager.emit_to_user.assert_called_once()
|
mock_manager.emit_to_user.assert_called_once()
|
||||||
|
|
||||||
call_args = mock_manager.emit_to_user.call_args
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_request_game_state_from_db(self, mock_manager, mock_game_state):
|
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.
|
Verify request_game_state() recovers from database when not in memory.
|
||||||
|
|
||||||
When game state is not in memory, handler should call recover_game()
|
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
|
from socketio import AsyncServer
|
||||||
|
|
||||||
sio = 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.get_state.return_value = None # Not in memory
|
||||||
mock_state_mgr.recover_game = AsyncMock(return_value=mock_game_state)
|
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
|
from app.websocket.handlers import register_handlers
|
||||||
|
|
||||||
register_handlers(sio, mock_manager)
|
register_handlers(sio, mock_manager)
|
||||||
@ -636,10 +655,16 @@ class TestRequestGameStateHandler:
|
|||||||
|
|
||||||
mock_state_mgr.get_state.assert_called_once()
|
mock_state_mgr.get_state.assert_called_once()
|
||||||
mock_state_mgr.recover_game.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()
|
mock_manager.emit_to_user.assert_called_once()
|
||||||
|
|
||||||
call_args = mock_manager.emit_to_user.call_args
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_request_game_state_missing_game_id(self, mock_manager):
|
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) {
|
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)
|
playHistory.value.push(play)
|
||||||
|
|
||||||
// Update game state from play result if provided
|
// Update game state from play result if provided
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user