mantimon-tcg/backend/app/socketio/game_namespace.py
Cal Corum 154d466ff1 Implement /game namespace event handlers (WS-005, WS-006)
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>
2026-01-29 20:40:06 -06:00

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()