Add GameNamespaceHandler with full event handling for real-time gameplay: - handle_join: Join/rejoin games with visibility-filtered state - handle_action: Execute actions and broadcast state to participants - handle_resign: Process resignation and end game - handle_disconnect: Notify opponent of disconnection - Broadcast helpers for state, game over, and opponent status Includes 28 unit tests covering all handler methods. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
607 lines
20 KiB
Python
607 lines
20 KiB
Python
"""Game namespace event handlers for WebSocket communication.
|
|
|
|
This module implements all /game namespace event handling for real-time
|
|
game communication. It orchestrates between the WebSocket layer and
|
|
GameService to handle:
|
|
- Game joining and state synchronization
|
|
- Action execution and result broadcasting
|
|
- Resignation handling
|
|
- Disconnect notifications to opponents
|
|
|
|
Architecture:
|
|
Socket.IO Events -> GameNamespaceHandler -> GameService
|
|
-> ConnectionManager (for routing)
|
|
-> Socket.IO Emits (responses)
|
|
|
|
The handler is designed with dependency injection for testability.
|
|
All game logic is delegated to GameService; this module only handles
|
|
the WebSocket communication layer.
|
|
|
|
Example:
|
|
from app.socketio.game_namespace import game_namespace_handler
|
|
|
|
@sio.on("game:join", namespace="/game")
|
|
async def on_game_join(sid: str, data: dict) -> dict:
|
|
return await game_namespace_handler.handle_join(sio, sid, data)
|
|
"""
|
|
|
|
import logging
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from pydantic import ValidationError
|
|
|
|
from app.core.models.actions import parse_action
|
|
from app.core.visibility import get_visible_state
|
|
from app.schemas.ws_messages import (
|
|
ConnectionStatus,
|
|
GameOverMessage,
|
|
GameStateMessage,
|
|
OpponentStatusMessage,
|
|
WSErrorCode,
|
|
)
|
|
from app.services.connection_manager import ConnectionManager, connection_manager
|
|
from app.services.game_service import (
|
|
ForcedActionRequiredError,
|
|
GameAlreadyEndedError,
|
|
GameNotFoundError,
|
|
GameService,
|
|
InvalidActionError,
|
|
NotPlayerTurnError,
|
|
PlayerNotInGameError,
|
|
game_service,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
import socketio
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class GameNamespaceHandler:
|
|
"""Handler for /game namespace WebSocket events.
|
|
|
|
This class encapsulates all game-related event handling logic with
|
|
dependencies injected via constructor for testability.
|
|
|
|
Attributes:
|
|
_game_service: GameService for game operations.
|
|
_connection_manager: ConnectionManager for connection tracking.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
game_svc: GameService | None = None,
|
|
conn_manager: ConnectionManager | None = None,
|
|
) -> None:
|
|
"""Initialize the GameNamespaceHandler.
|
|
|
|
Args:
|
|
game_svc: GameService instance. Uses global if not provided.
|
|
conn_manager: ConnectionManager instance. Uses global if not provided.
|
|
"""
|
|
self._game_service = game_svc or game_service
|
|
self._connection_manager = conn_manager or connection_manager
|
|
|
|
# =========================================================================
|
|
# Event Handlers
|
|
# =========================================================================
|
|
|
|
async def handle_join(
|
|
self,
|
|
sio: "socketio.AsyncServer",
|
|
sid: str,
|
|
user_id: str,
|
|
data: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
"""Handle game:join event.
|
|
|
|
Joins or rejoins a player to a game session. On success, the player
|
|
is added to the game room and receives the current game state.
|
|
|
|
Args:
|
|
sio: Socket.IO server instance.
|
|
sid: Socket session ID.
|
|
user_id: Authenticated user's ID.
|
|
data: Message data with game_id and optional last_event_id.
|
|
|
|
Returns:
|
|
Response dict with success status and game state or error.
|
|
"""
|
|
game_id = data.get("game_id")
|
|
last_event_id = data.get("last_event_id")
|
|
message_id = data.get("message_id", "")
|
|
|
|
if not game_id:
|
|
logger.warning(f"game:join missing game_id from {sid}")
|
|
return self._error_response(
|
|
WSErrorCode.INVALID_MESSAGE,
|
|
"game_id is required",
|
|
message_id,
|
|
)
|
|
|
|
try:
|
|
# Join the game via GameService
|
|
result = await self._game_service.join_game(
|
|
game_id=game_id,
|
|
player_id=user_id,
|
|
last_event_id=last_event_id,
|
|
)
|
|
|
|
if not result.success:
|
|
error_code = WSErrorCode.GAME_NOT_FOUND
|
|
if "not a participant" in result.message:
|
|
error_code = WSErrorCode.NOT_IN_GAME
|
|
return self._error_response(error_code, result.message, message_id)
|
|
|
|
# Register this connection with the game
|
|
await self._connection_manager.join_game(sid, game_id)
|
|
|
|
# Join the Socket.IO room for this game
|
|
await sio.enter_room(sid, f"game:{game_id}", namespace="/game")
|
|
|
|
# Notify opponent that player connected
|
|
await self._notify_opponent_status(
|
|
sio, sid, game_id, user_id, ConnectionStatus.CONNECTED
|
|
)
|
|
|
|
# Build response with game state
|
|
response: dict[str, Any] = {
|
|
"success": True,
|
|
"game_id": game_id,
|
|
"is_your_turn": result.is_your_turn,
|
|
"game_over": result.game_over,
|
|
}
|
|
|
|
if result.visible_state:
|
|
response["state"] = result.visible_state.model_dump(mode="json")
|
|
|
|
if result.pending_forced_action:
|
|
response["pending_forced_action"] = {
|
|
"player_id": result.pending_forced_action.player_id,
|
|
"action_type": result.pending_forced_action.action_type,
|
|
"reason": result.pending_forced_action.reason,
|
|
"params": result.pending_forced_action.params,
|
|
}
|
|
|
|
logger.info(f"Player {user_id} joined game {game_id}")
|
|
return response
|
|
|
|
except Exception as e:
|
|
logger.exception(f"Error joining game {game_id}: {e}")
|
|
return self._error_response(
|
|
WSErrorCode.INTERNAL_ERROR,
|
|
"Failed to join game",
|
|
message_id,
|
|
)
|
|
|
|
async def handle_action(
|
|
self,
|
|
sio: "socketio.AsyncServer",
|
|
sid: str,
|
|
user_id: str,
|
|
data: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
"""Handle game:action event.
|
|
|
|
Executes a game action and broadcasts results to all participants.
|
|
|
|
Args:
|
|
sio: Socket.IO server instance.
|
|
sid: Socket session ID.
|
|
user_id: Authenticated user's ID.
|
|
data: Message data with game_id and action.
|
|
|
|
Returns:
|
|
Response dict with action result or error.
|
|
"""
|
|
game_id = data.get("game_id")
|
|
action_data = data.get("action")
|
|
message_id = data.get("message_id", "")
|
|
|
|
if not game_id:
|
|
return self._error_response(
|
|
WSErrorCode.INVALID_MESSAGE,
|
|
"game_id is required",
|
|
message_id,
|
|
)
|
|
|
|
if not action_data:
|
|
return self._error_response(
|
|
WSErrorCode.INVALID_MESSAGE,
|
|
"action is required",
|
|
message_id,
|
|
)
|
|
|
|
# Parse the action
|
|
try:
|
|
action = parse_action(action_data)
|
|
except (ValidationError, ValueError) as e:
|
|
logger.warning(f"Invalid action from {sid}: {e}")
|
|
return self._error_response(
|
|
WSErrorCode.INVALID_ACTION,
|
|
f"Invalid action format: {e}",
|
|
message_id,
|
|
)
|
|
|
|
# Execute the action
|
|
try:
|
|
result = await self._game_service.execute_action(
|
|
game_id=game_id,
|
|
player_id=user_id,
|
|
action=action,
|
|
)
|
|
|
|
# Broadcast state to all participants
|
|
await self._broadcast_game_state(sio, game_id)
|
|
|
|
# Build response
|
|
response: dict[str, Any] = {
|
|
"success": True,
|
|
"game_id": game_id,
|
|
"action_type": result.action_type,
|
|
"message": result.message,
|
|
"turn_changed": result.turn_changed,
|
|
"current_player_id": result.current_player_id,
|
|
}
|
|
|
|
if result.state_changes:
|
|
response["changes"] = result.state_changes
|
|
|
|
if result.pending_forced_action:
|
|
response["pending_forced_action"] = {
|
|
"player_id": result.pending_forced_action.player_id,
|
|
"action_type": result.pending_forced_action.action_type,
|
|
"reason": result.pending_forced_action.reason,
|
|
"params": result.pending_forced_action.params,
|
|
}
|
|
|
|
# Handle game over
|
|
if result.game_over:
|
|
response["game_over"] = True
|
|
response["winner_id"] = result.winner_id
|
|
response["end_reason"] = result.end_reason.value if result.end_reason else None
|
|
|
|
# End the game and archive to history
|
|
if result.end_reason:
|
|
await self._game_service.end_game(
|
|
game_id=game_id,
|
|
winner_id=result.winner_id,
|
|
end_reason=result.end_reason,
|
|
)
|
|
|
|
# Broadcast game over to room
|
|
await self._broadcast_game_over(sio, game_id, result.winner_id, result.end_reason)
|
|
|
|
logger.debug(f"Action executed: game={game_id}, player={user_id}, type={action.type}")
|
|
return response
|
|
|
|
except GameNotFoundError:
|
|
return self._error_response(
|
|
WSErrorCode.GAME_NOT_FOUND,
|
|
f"Game {game_id} not found",
|
|
message_id,
|
|
)
|
|
except PlayerNotInGameError:
|
|
return self._error_response(
|
|
WSErrorCode.NOT_IN_GAME,
|
|
"You are not a participant in this game",
|
|
message_id,
|
|
)
|
|
except GameAlreadyEndedError:
|
|
return self._error_response(
|
|
WSErrorCode.GAME_ENDED,
|
|
"Game has already ended",
|
|
message_id,
|
|
)
|
|
except NotPlayerTurnError as e:
|
|
return self._error_response(
|
|
WSErrorCode.NOT_YOUR_TURN,
|
|
f"Not your turn. Current player: {e.current_player_id}",
|
|
message_id,
|
|
)
|
|
except ForcedActionRequiredError as e:
|
|
return self._error_response(
|
|
WSErrorCode.ACTION_NOT_ALLOWED,
|
|
f"Forced action required: {e.required_action_type}",
|
|
message_id,
|
|
)
|
|
except InvalidActionError as e:
|
|
return self._error_response(
|
|
WSErrorCode.INVALID_ACTION,
|
|
e.reason,
|
|
message_id,
|
|
)
|
|
except Exception as e:
|
|
logger.exception(f"Error executing action in game {game_id}: {e}")
|
|
return self._error_response(
|
|
WSErrorCode.INTERNAL_ERROR,
|
|
"Failed to execute action",
|
|
message_id,
|
|
)
|
|
|
|
async def handle_resign(
|
|
self,
|
|
sio: "socketio.AsyncServer",
|
|
sid: str,
|
|
user_id: str,
|
|
data: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
"""Handle game:resign event.
|
|
|
|
Processes a player's resignation from the game.
|
|
|
|
Args:
|
|
sio: Socket.IO server instance.
|
|
sid: Socket session ID.
|
|
user_id: Authenticated user's ID.
|
|
data: Message data with game_id.
|
|
|
|
Returns:
|
|
Response dict with resignation result or error.
|
|
"""
|
|
game_id = data.get("game_id")
|
|
message_id = data.get("message_id", "")
|
|
|
|
if not game_id:
|
|
return self._error_response(
|
|
WSErrorCode.INVALID_MESSAGE,
|
|
"game_id is required",
|
|
message_id,
|
|
)
|
|
|
|
try:
|
|
result = await self._game_service.resign_game(
|
|
game_id=game_id,
|
|
player_id=user_id,
|
|
)
|
|
|
|
# End the game and archive
|
|
if result.game_over and result.end_reason:
|
|
await self._game_service.end_game(
|
|
game_id=game_id,
|
|
winner_id=result.winner_id,
|
|
end_reason=result.end_reason,
|
|
)
|
|
|
|
# Broadcast game over
|
|
await self._broadcast_game_over(sio, game_id, result.winner_id, result.end_reason)
|
|
|
|
logger.info(f"Player {user_id} resigned from game {game_id}")
|
|
|
|
return {
|
|
"success": True,
|
|
"game_id": game_id,
|
|
"game_over": True,
|
|
"winner_id": result.winner_id,
|
|
"end_reason": result.end_reason.value if result.end_reason else None,
|
|
}
|
|
|
|
except GameNotFoundError:
|
|
return self._error_response(
|
|
WSErrorCode.GAME_NOT_FOUND,
|
|
f"Game {game_id} not found",
|
|
message_id,
|
|
)
|
|
except PlayerNotInGameError:
|
|
return self._error_response(
|
|
WSErrorCode.NOT_IN_GAME,
|
|
"You are not a participant in this game",
|
|
message_id,
|
|
)
|
|
except GameAlreadyEndedError:
|
|
return self._error_response(
|
|
WSErrorCode.GAME_ENDED,
|
|
"Game has already ended",
|
|
message_id,
|
|
)
|
|
except Exception as e:
|
|
logger.exception(f"Error resigning from game {game_id}: {e}")
|
|
return self._error_response(
|
|
WSErrorCode.INTERNAL_ERROR,
|
|
"Failed to resign from game",
|
|
message_id,
|
|
)
|
|
|
|
async def handle_disconnect(
|
|
self,
|
|
sio: "socketio.AsyncServer",
|
|
sid: str,
|
|
user_id: str,
|
|
) -> None:
|
|
"""Handle disconnect event.
|
|
|
|
Notifies opponents in any active game that the player disconnected.
|
|
|
|
Args:
|
|
sio: Socket.IO server instance.
|
|
sid: Socket session ID.
|
|
user_id: Authenticated user's ID.
|
|
"""
|
|
# Get connection info to find active game
|
|
conn_info = await self._connection_manager.get_connection(sid)
|
|
if conn_info is None or conn_info.game_id is None:
|
|
return
|
|
|
|
game_id = conn_info.game_id
|
|
|
|
# Notify opponent of disconnect
|
|
await self._notify_opponent_status(
|
|
sio, sid, game_id, user_id, ConnectionStatus.DISCONNECTED
|
|
)
|
|
|
|
logger.info(f"Player {user_id} disconnected from game {game_id}")
|
|
|
|
# =========================================================================
|
|
# Broadcast Helpers
|
|
# =========================================================================
|
|
|
|
async def _broadcast_game_state(
|
|
self,
|
|
sio: "socketio.AsyncServer",
|
|
game_id: str,
|
|
) -> None:
|
|
"""Broadcast filtered game state to all participants.
|
|
|
|
Each player receives their own visibility-filtered view of the game.
|
|
|
|
Args:
|
|
sio: Socket.IO server instance.
|
|
game_id: The game to broadcast.
|
|
"""
|
|
try:
|
|
# Get full game state
|
|
state = await self._game_service.get_game_state(game_id)
|
|
|
|
# Get all connected players for this game
|
|
user_sids = await self._connection_manager.get_game_user_sids(game_id)
|
|
|
|
# Send filtered state to each player
|
|
for player_id, player_sid in user_sids.items():
|
|
try:
|
|
visible_state = get_visible_state(state, player_id)
|
|
message = GameStateMessage(
|
|
game_id=game_id,
|
|
state=visible_state,
|
|
)
|
|
await sio.emit(
|
|
"game:state",
|
|
message.model_dump(mode="json"),
|
|
to=player_sid,
|
|
namespace="/game",
|
|
)
|
|
except ValueError:
|
|
# Player not in game - skip
|
|
logger.warning(f"Player {player_id} not in game {game_id}")
|
|
continue
|
|
|
|
except GameNotFoundError:
|
|
logger.warning(f"Cannot broadcast state: game {game_id} not found")
|
|
except Exception as e:
|
|
logger.exception(f"Error broadcasting state for game {game_id}: {e}")
|
|
|
|
async def _broadcast_game_over(
|
|
self,
|
|
sio: "socketio.AsyncServer",
|
|
game_id: str,
|
|
winner_id: str | None,
|
|
end_reason: Any,
|
|
) -> None:
|
|
"""Broadcast game over notification to all participants.
|
|
|
|
Args:
|
|
sio: Socket.IO server instance.
|
|
game_id: The game that ended.
|
|
winner_id: The winner's player ID, or None for draw.
|
|
end_reason: The GameEndReason for why the game ended.
|
|
"""
|
|
try:
|
|
# Get final state for each player
|
|
state = await self._game_service.get_game_state(game_id)
|
|
user_sids = await self._connection_manager.get_game_user_sids(game_id)
|
|
|
|
for player_id, player_sid in user_sids.items():
|
|
try:
|
|
visible_state = get_visible_state(state, player_id)
|
|
message = GameOverMessage(
|
|
game_id=game_id,
|
|
winner_id=winner_id,
|
|
end_reason=end_reason,
|
|
final_state=visible_state,
|
|
)
|
|
await sio.emit(
|
|
"game:game_over",
|
|
message.model_dump(mode="json"),
|
|
to=player_sid,
|
|
namespace="/game",
|
|
)
|
|
except ValueError:
|
|
continue
|
|
|
|
except GameNotFoundError:
|
|
# Game already archived - just emit to room without state
|
|
message = GameOverMessage(
|
|
game_id=game_id,
|
|
winner_id=winner_id,
|
|
end_reason=end_reason,
|
|
final_state=None, # type: ignore[arg-type]
|
|
)
|
|
await sio.emit(
|
|
"game:game_over",
|
|
{"game_id": game_id, "winner_id": winner_id, "end_reason": end_reason.value},
|
|
room=f"game:{game_id}",
|
|
namespace="/game",
|
|
)
|
|
except Exception as e:
|
|
logger.exception(f"Error broadcasting game over for {game_id}: {e}")
|
|
|
|
async def _notify_opponent_status(
|
|
self,
|
|
sio: "socketio.AsyncServer",
|
|
sender_sid: str,
|
|
game_id: str,
|
|
user_id: str,
|
|
status: ConnectionStatus,
|
|
) -> None:
|
|
"""Notify opponent of connection status change.
|
|
|
|
Args:
|
|
sio: Socket.IO server instance.
|
|
sender_sid: The sid of the player whose status changed.
|
|
game_id: The game ID.
|
|
user_id: The player whose status changed.
|
|
status: The new connection status.
|
|
"""
|
|
try:
|
|
# Find opponent's sid
|
|
opponent_sid = await self._connection_manager.get_opponent_sid(game_id, user_id)
|
|
|
|
if opponent_sid:
|
|
message = OpponentStatusMessage(
|
|
game_id=game_id,
|
|
opponent_id=user_id,
|
|
status=status,
|
|
)
|
|
await sio.emit(
|
|
"game:opponent_status",
|
|
message.model_dump(mode="json"),
|
|
to=opponent_sid,
|
|
namespace="/game",
|
|
)
|
|
logger.debug(f"Notified opponent of {user_id} status: {status}")
|
|
|
|
except Exception as e:
|
|
logger.exception(f"Error notifying opponent status: {e}")
|
|
|
|
# =========================================================================
|
|
# Helper Methods
|
|
# =========================================================================
|
|
|
|
def _error_response(
|
|
self,
|
|
code: WSErrorCode,
|
|
message: str,
|
|
request_message_id: str = "",
|
|
) -> dict[str, Any]:
|
|
"""Create a standardized error response.
|
|
|
|
Args:
|
|
code: The error code.
|
|
message: Human-readable error message.
|
|
request_message_id: The original request's message_id.
|
|
|
|
Returns:
|
|
Dict with error information.
|
|
"""
|
|
return {
|
|
"success": False,
|
|
"error": {
|
|
"code": code.value,
|
|
"message": message,
|
|
},
|
|
"request_message_id": request_message_id,
|
|
}
|
|
|
|
|
|
# Global singleton instance
|
|
game_namespace_handler = GameNamespaceHandler()
|