"""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 # Emit game:state event to the client if result.visible_state: await sio.emit( "game:state", { "game_id": game_id, "state": response["state"], "is_your_turn": response["is_your_turn"], "game_over": response["game_over"], }, to=sid, namespace="/game", ) 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()