- 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>
946 lines
33 KiB
Python
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()
|