mantimon-tcg/backend/app/socketio/game_namespace.py
Cal Corum f452e69999 Complete Phase 4 implementation files
- TurnTimeoutService with percentage-based warnings (35 tests)
- ConnectionManager enhancements for spectators and reconnection
- GameService with timer integration, spectator support, handle_timeout
- GameNamespace with spectate/leave_spectate handlers, reconnection
- WebSocket message schemas for spectator events
- WinConditionsConfig additions for turn timer thresholds
- 83 GameService tests, 37 ConnectionManager tests, 37 GameNamespace tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 08:03:43 -06:00

946 lines
33 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
- Automatic reconnection to active games
Architecture:
Socket.IO Events -> GameNamespaceHandler -> GameService
-> ConnectionManager (for routing)
-> GameStateManager (for active game lookup)
-> 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 uuid import UUID
from pydantic import ValidationError
from app.core.models.actions import parse_action
from app.core.visibility import get_spectator_state, 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 (
CannotSpectateOwnGameError,
ForcedActionRequiredError,
GameAlreadyEndedError,
GameNotFoundError,
GameService,
InvalidActionError,
NotPlayerTurnError,
PlayerNotInGameError,
game_service,
)
from app.services.game_state_manager import GameStateManager, game_state_manager
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.
_state_manager: GameStateManager for active game lookup.
"""
def __init__(
self,
game_svc: GameService | None = None,
conn_manager: ConnectionManager | None = None,
state_manager: GameStateManager | 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.
state_manager: GameStateManager instance. Uses global if not provided.
"""
self._game_service = game_svc or game_service
self._connection_manager = conn_manager or connection_manager
self._state_manager = state_manager or game_state_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,
}
# Include turn timer info if enabled
if result.turn_timeout_seconds is not None:
response["turn_timeout_seconds"] = result.turn_timeout_seconds
response["turn_deadline"] = result.turn_deadline
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,
}
# Include turn timer info if enabled and turn changed
if result.turn_timeout_seconds is not None:
response["turn_timeout_seconds"] = result.turn_timeout_seconds
response["turn_deadline"] = result.turn_deadline
# 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.
Note: In production, consider debouncing disconnect notifications
to handle rapid disconnect/reconnect cycles (e.g., network hiccups).
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
# Check if spectating (game_id format: "spectating:{actual_game_id}")
if game_id.startswith("spectating:"):
actual_game_id = game_id[len("spectating:") :]
# Unregister spectator and broadcast updated count
await self._connection_manager.unregister_spectator(sid, actual_game_id)
await self._broadcast_spectator_count(sio, actual_game_id)
logger.info(f"Spectator {user_id} left game {actual_game_id}")
return
# 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}")
async def handle_spectate(
self,
sio: "socketio.AsyncServer",
sid: str,
user_id: str,
data: dict[str, Any],
) -> dict[str, Any]:
"""Handle game:spectate event.
Allows a user to spectate a game they are not participating in.
Spectators receive a filtered view with no hands visible.
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 success status and spectator state or error.
"""
game_id = data.get("game_id")
message_id = data.get("message_id", "")
if not game_id:
logger.warning(f"game:spectate missing game_id from {sid}")
return self._error_response(
WSErrorCode.INVALID_MESSAGE,
"game_id is required",
message_id,
)
try:
# Get spectator view via GameService
result = await self._game_service.spectate_game(
game_id=game_id,
user_id=user_id,
)
if not result.success:
return self._error_response(
WSErrorCode.INTERNAL_ERROR,
result.message,
message_id,
)
# Register this connection as a spectator
await self._connection_manager.register_spectator(sid, user_id, game_id)
# Join the spectators room for this game
await sio.enter_room(sid, f"spectators:{game_id}", namespace="/game")
# Also join the game room to receive general updates
await sio.enter_room(sid, f"game:{game_id}", namespace="/game")
# Broadcast updated spectator count to players
await self._broadcast_spectator_count(sio, game_id)
# Build response with spectator state
response: dict[str, Any] = {
"success": True,
"game_id": game_id,
"game_over": result.game_over,
"spectator_count": await self._connection_manager.get_spectator_count(game_id),
}
if result.visible_state:
response["state"] = result.visible_state.model_dump(mode="json")
logger.info(f"User {user_id} started spectating game {game_id}")
return response
except GameNotFoundError:
return self._error_response(
WSErrorCode.GAME_NOT_FOUND,
f"Game {game_id} not found",
message_id,
)
except CannotSpectateOwnGameError:
return self._error_response(
WSErrorCode.ACTION_NOT_ALLOWED,
"You cannot spectate a game you are playing in",
message_id,
)
except Exception as e:
logger.exception(f"Error spectating game {game_id}: {e}")
return self._error_response(
WSErrorCode.INTERNAL_ERROR,
"Failed to spectate game",
message_id,
)
async def handle_leave_spectate(
self,
sio: "socketio.AsyncServer",
sid: str,
user_id: str,
data: dict[str, Any],
) -> dict[str, Any]:
"""Handle game:leave_spectate event.
Allows a spectator to stop watching a 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 success status.
"""
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,
)
# Unregister spectator
await self._connection_manager.unregister_spectator(sid, game_id)
# Leave the rooms
await sio.leave_room(sid, f"spectators:{game_id}", namespace="/game")
await sio.leave_room(sid, f"game:{game_id}", namespace="/game")
# Broadcast updated spectator count to players
await self._broadcast_spectator_count(sio, game_id)
logger.info(f"User {user_id} stopped spectating game {game_id}")
return {
"success": True,
"game_id": game_id,
}
async def handle_reconnect(
self,
sio: "socketio.AsyncServer",
sid: str,
user_id: str,
) -> dict[str, Any] | None:
"""Handle automatic reconnection to active games on connect.
Called after successful authentication to check if the user has
an active game and automatically rejoin them.
This method:
1. Queries for active games via GameStateManager
2. If found, auto-joins the game room
3. Sends full game state to reconnecting player
4. Extends turn timer if it's their turn
5. Notifies opponent of reconnection
Args:
sio: Socket.IO server instance.
sid: Socket session ID.
user_id: Authenticated user's ID (as string, may be UUID).
Returns:
Dict with reconnection info if rejoined, None if no active game.
The dict contains:
- game_id: The game that was rejoined
- is_your_turn: Whether it's the player's turn
- state: The visible game state
- pending_forced_action: Any pending forced action
- turn_timeout_seconds: Remaining turn time if applicable
- turn_deadline: Unix timestamp when turn expires
"""
# Try to get active games from database
try:
player_uuid = UUID(user_id)
except ValueError:
# User ID is not a valid UUID (e.g., NPC) - no active games
return None
active_games = await self._state_manager.get_player_active_games(player_uuid)
if not active_games:
logger.debug(f"No active games for user {user_id}")
return None
# If multiple active games, use the most recent one
# (sorted by last_action_at descending)
active_games.sort(key=lambda g: g.last_action_at or g.started_at, reverse=True)
active_game = active_games[0]
game_id = str(active_game.id)
logger.info(f"Auto-rejoining user {user_id} to game {game_id}")
# Join the game via GameService (handles timer extension)
result = await self._game_service.join_game(
game_id=game_id,
player_id=user_id,
last_event_id=None, # Full state refresh on reconnect
)
if not result.success:
logger.warning(f"Failed to auto-rejoin game {game_id}: {result.message}")
return None
# Register connection with game
await self._connection_manager.join_game(sid, game_id)
# Join the Socket.IO room
await sio.enter_room(sid, f"game:{game_id}", namespace="/game")
# Notify opponent of reconnection
await self._notify_opponent_status(sio, sid, game_id, user_id, ConnectionStatus.CONNECTED)
# Build reconnection response
response: dict[str, Any] = {
"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,
}
if result.turn_timeout_seconds is not None:
response["turn_timeout_seconds"] = result.turn_timeout_seconds
response["turn_deadline"] = result.turn_deadline
logger.info(f"Player {user_id} auto-rejoined game {game_id}")
return response
# =========================================================================
# Broadcast Helpers
# =========================================================================
async def _broadcast_game_state(
self,
sio: "socketio.AsyncServer",
game_id: str,
) -> None:
"""Broadcast filtered game state to all participants and spectators.
Each player receives their own visibility-filtered view of the game.
Spectators receive a view with no hands visible.
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 spectator count for inclusion in player messages
spectator_count = await self._connection_manager.get_spectator_count(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,
spectator_count=spectator_count,
)
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
# Send spectator state to all spectators
spectator_sids = await self._connection_manager.get_game_spectators(game_id)
if spectator_sids:
spectator_state = get_spectator_state(state)
spectator_message = GameStateMessage(
game_id=game_id,
state=spectator_state,
spectator_count=spectator_count,
)
for spectator_sid in spectator_sids:
await sio.emit(
"game:state",
spectator_message.model_dump(mode="json"),
to=spectator_sid,
namespace="/game",
)
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 and spectators.
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
# Send to spectators
spectator_sids = await self._connection_manager.get_game_spectators(game_id)
if spectator_sids:
spectator_state = get_spectator_state(state)
spectator_message = GameOverMessage(
game_id=game_id,
winner_id=winner_id,
end_reason=end_reason,
final_state=spectator_state,
)
for spectator_sid in spectator_sids:
await sio.emit(
"game:game_over",
spectator_message.model_dump(mode="json"),
to=spectator_sid,
namespace="/game",
)
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}")
async def _broadcast_spectator_count(
self,
sio: "socketio.AsyncServer",
game_id: str,
) -> None:
"""Broadcast spectator count to all players in a game.
Called when spectators join or leave to keep players informed.
Args:
sio: Socket.IO server instance.
game_id: The game ID.
"""
try:
spectator_count = await self._connection_manager.get_spectator_count(game_id)
user_sids = await self._connection_manager.get_game_user_sids(game_id)
for player_sid in user_sids.values():
await sio.emit(
"game:spectator_count",
{"game_id": game_id, "spectator_count": spectator_count},
to=player_sid,
namespace="/game",
)
logger.debug(f"Broadcast spectator count for game {game_id}: {spectator_count}")
except Exception as e:
logger.exception(f"Error broadcasting spectator count for {game_id}: {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()