UNPKG

shoehive

Version:

WebSocket-based multiplayer game framework for real-time, event-driven gameplay

421 lines (420 loc) 17.8 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.WebSocketManager = void 0; const WebSocket = __importStar(require("ws")); const Player_1 = require("./Player"); const Lobby_1 = require("./Lobby"); const EventTypes_1 = require("../events/EventTypes"); const index_1 = require("./commands/index"); class WebSocketManager { constructor(server, eventBus, messageRouter, gameManager, authModule, reconnectionTimeoutMs = 0, lobby, tableFactory) { this.players = new Map(); this.disconnectionTimeouts = new Map(); this.wss = new WebSocket.Server({ server }); this.eventBus = eventBus; this.messageRouter = messageRouter; this.gameManager = gameManager; this.authModule = authModule; this.reconnectionTimeoutMs = reconnectionTimeoutMs; // Create a new Lobby if not provided this.lobby = lobby || new Lobby_1.Lobby(eventBus, gameManager, tableFactory); this.setupConnectionHandler(); this.setupEventListeners(); } /** * Sets up the connection handler for the WebSocket server. * This handler authenticates the connection, creates a new player or reconnects an existing one, * and handles incoming messages. */ setupConnectionHandler() { this.wss.on("connection", async (socket, request) => { try { // Authenticate the connection if an auth provider is available let playerId = null; if (this.authModule) { playerId = await this.authModule.authenticatePlayer(request); if (!playerId) { socket.close(1008, "Authentication failed"); return; } } // Create a new player or reconnect an existing one const player = this.createOrReconnectPlayer(socket, playerId); // Handle messages socket.on("message", (data) => { const message = data.toString(); this.messageRouter.processMessage(player, message); }); // Send initial state to the player this.sendInitialState(player); // Emit player connected event this.eventBus.emit(EventTypes_1.PLAYER_EVENTS.CONNECTED, player); } catch (error) { console.error("Connection error:", error); socket.close(1011, "Internal server error"); } }); } /** * Sets up event listeners for the WebSocket manager. * This listens for lobby state updates and player joined events, * and sends the appropriate messages to all players. */ setupEventListeners() { this.eventBus.on(EventTypes_1.LOBBY_EVENTS.UPDATED, (lobbyState) => { const message = { type: index_1.CLIENT_MESSAGE_TYPES.LOBBY.STATE, data: lobbyState }; // Send to all players this.players.forEach(player => { player.sendMessage(message); }); }); this.eventBus.on(EventTypes_1.TABLE_EVENTS.PLAYER_JOINED, (player, table) => { // Send the full table state to the joining player player.sendMessage({ type: index_1.CLIENT_MESSAGE_TYPES.TABLE.STATE, data: table.getTableState() }); }); // Add listener for playerSeated event this.eventBus.on(EventTypes_1.TABLE_EVENTS.PLAYER_SAT, (player, table, seatIndex) => { // Notify all players at the table about the change table.broadcastTableState(); // Update lobby for all players to see seat changes this.lobby.updateLobbyState(); }); // Add listener for playerUnseated event this.eventBus.on(EventTypes_1.TABLE_EVENTS.PLAYER_STOOD, (player, table, seatIndex) => { // Notify all players at the table about the change table.broadcastTableState(); // Update lobby for all players to see seat changes this.lobby.updateLobbyState(); }); // Handle table state updates this.eventBus.on(EventTypes_1.TABLE_EVENTS.STATE_UPDATED, (table, tableState) => { // No need to broadcast again as the table has already done this // This event can be used by other components }); // Handle player attribute changes this.eventBus.on(EventTypes_1.PLAYER_EVENTS.ATTRIBUTE_CHANGED, (player, key, value) => { // Use the new distribution method this.distributePlayerUpdate(player, key, value); }); // Handle bulk player attribute changes this.eventBus.on(EventTypes_1.PLAYER_EVENTS.ATTRIBUTES_CHANGED, (player, changedKeys, attributes) => { // Use the new bulk distribution method this.distributePlayerUpdates(player, attributes); }); // Handle table attribute changes this.eventBus.on(EventTypes_1.TABLE_EVENTS.ATTRIBUTE_CHANGED, (table, key, value) => { // Broadcast the updated table state to all players at the table table.broadcastTableState(); // Update lobby if this is a metadata attribute that would affect the lobby display const metadataAttributes = ["gameId", "gameName", "options"]; if (metadataAttributes.includes(key)) { this.lobby.updateLobbyState(); } }); // Handle bulk table attribute changes this.eventBus.on(EventTypes_1.TABLE_EVENTS.ATTRIBUTES_CHANGED, (table, changedKeys, attributes) => { // Table will handle broadcasting to its players in most cases // but we need to check if we should update the lobby const metadataAttributes = ["gameId", "gameName", "options"]; const shouldUpdateLobby = changedKeys.some((key) => metadataAttributes.includes(key)); if (shouldUpdateLobby) { this.lobby.updateLobbyState(); } }); } /** * Sends the initial state to a player. * This includes player details and lobby state. * * @param player The player to send the initial state to. */ sendInitialState(player) { // Send player details player.sendMessage({ type: index_1.CLIENT_MESSAGE_TYPES.PLAYER.STATE, id: player.id, attributes: player.getAttributes() }); // Send available games and tables (lobby state) player.sendMessage({ type: index_1.CLIENT_MESSAGE_TYPES.LOBBY.STATE, data: { games: this.gameManager.getAvailableGames(), tables: this.gameManager.getAllTables().map(table => table.getTableMetadata()) } }); // If the player is already at a table, send the full table state const table = player.getTable(); if (table) { player.sendMessage({ type: index_1.CLIENT_MESSAGE_TYPES.TABLE.STATE, data: table.getTableState() }); } } /** * Distribute player updates to relevant players. * This notifies the player about their own changes and also updates * any tables they're part of. * * @param player The player whose state changed * @param key The attribute that changed * @param value The new value * @param updateTableState Whether to update the table state */ distributePlayerUpdate(player, key, value, updateTableState = true) { // Notify the player about their own attribute changes player.sendMessage({ type: index_1.CLIENT_MESSAGE_TYPES.PLAYER.STATE, data: { id: player.id, attributes: player.getAttributes() } }); // If player is at a table and we should update table state const table = player.getTable(); if (table && updateTableState) { // Get the game ID for this table const gameId = table.getAttribute("gameId"); if (!gameId) return; // Get the game definition const gameDefinition = this.gameManager.getGameDefinition(gameId); // Use the game-specific table relevant attributes, or fall back to defaults const tableRelevantAttributes = gameDefinition?.tableRelevantPlayerAttributes || [ "name", "avatar", "chips", "status", "isReady", "role", "team" ]; // Only broadcast if this attribute affects the table display if (tableRelevantAttributes.includes(key)) { table.broadcastTableState(); } } } /** * Distribute multiple player updates to relevant players. * * @param player The player whose state changed * @param attributes The attributes that changed * @param updateTableState Whether to update the table state */ distributePlayerUpdates(player, attributes, updateTableState = true) { // Notify the player about their own attribute changes player.sendMessage({ type: index_1.CLIENT_MESSAGE_TYPES.PLAYER.STATE, data: { id: player.id, attributes: player.getAttributes() } }); // If player is at a table and we should update table state const table = player.getTable(); if (table && updateTableState) { // Get the game ID for this table const gameId = table.getAttribute("gameId"); if (!gameId) return; // Get the game definition const gameDefinition = this.gameManager.getGameDefinition(gameId); // Use the game-specific table relevant attributes, or fall back to defaults const tableRelevantAttributes = gameDefinition?.tableRelevantPlayerAttributes || [ "name", "avatar", "chips", "status", "isReady", "role", "team" ]; // Only broadcast if any of the changed attributes are relevant to the table const relevantChanges = Object.keys(attributes).some(key => tableRelevantAttributes.includes(key)); if (relevantChanges) { table.broadcastTableState(); } } } /** * Creates a new player or reconnects an existing one. * * @param socket The WebSocket connection. * @param playerId The player ID. * @returns The player object. */ createOrReconnectPlayer(socket, playerId) { if (playerId && this.players.has(playerId)) { // Handle reconnection const existingPlayer = this.players.get(playerId); // Disconnect existing socket if any existingPlayer.disconnect(); // Create new player with the existing ID const player = new Player_1.Player(socket, this.eventBus, playerId); this.players.set(playerId, player); // If the player was in a table, reconnect them const previousTable = existingPlayer.getTable(); if (previousTable) { player.setTable(previousTable); } // Clear any existing disconnect timeout for this player if (this.disconnectionTimeouts.has(playerId)) { clearTimeout(this.disconnectionTimeouts.get(playerId)); this.disconnectionTimeouts.delete(playerId); } this.eventBus.emit(EventTypes_1.PLAYER_EVENTS.RECONNECTED, player); return player; } else { // Create new player const player = new Player_1.Player(socket, this.eventBus, playerId || undefined); this.players.set(player.id, player); // Setup disconnect handler for the new player this.setupPlayerDisconnectHandler(player); return player; } } /** * Setup disconnect handler for a player to manage reconnection timeout * * @param player The player to set up disconnect handler for */ setupPlayerDisconnectHandler(player) { player.onDisconnect(() => { // Only set timeout if reconnection timeout is enabled if (this.reconnectionTimeoutMs > 0) { // Mark player as temporarily disconnected player.setAttribute('connectionStatus', 'disconnected'); // Set timeout to remove player if they don't reconnect const timeout = setTimeout(() => { this.removePlayerPermanently(player.id); }, this.reconnectionTimeoutMs); this.disconnectionTimeouts.set(player.id, timeout); } else { // If timeout is disabled, remove player immediately this.removePlayerPermanently(player.id); } }); } /** * Permanently remove a player from the game server * * @param playerId The ID of the player to remove */ removePlayerPermanently(playerId) { const player = this.players.get(playerId); if (!player) return; // If player is at a table, remove them const table = player.getTable(); if (table) { table.removePlayer(playerId); } // Remove player from the game server this.players.delete(playerId); // Clean up any stored timeout if (this.disconnectionTimeouts.has(playerId)) { clearTimeout(this.disconnectionTimeouts.get(playerId)); this.disconnectionTimeouts.delete(playerId); } // Emit a player removed event this.eventBus.emit(EventTypes_1.PLAYER_EVENTS.REMOVED, player); } /** * Gets a player by their ID. * * @param playerId The ID of the player to get. * @returns The player object or undefined if the player does not exist. */ getPlayer(playerId) { return this.players.get(playerId); } /** * Disconnects a player by their ID without waiting for timeout. * This bypasses the reconnection timeout and immediately removes the player. * * @param playerId The ID of the player to disconnect. */ disconnectPlayer(playerId) { const player = this.players.get(playerId); if (player) { // Close the socket connection player.disconnect(); // Remove any timeout and immediately remove the player if (this.disconnectionTimeouts.has(playerId)) { clearTimeout(this.disconnectionTimeouts.get(playerId)); this.disconnectionTimeouts.delete(playerId); } this.removePlayerPermanently(playerId); } } /** * Gets the current reconnection timeout in milliseconds */ getReconnectionTimeout() { return this.reconnectionTimeoutMs; } /** * Sets the reconnection timeout in milliseconds * * @param timeoutMs The timeout in milliseconds (0 to disable reconnection) */ setReconnectionTimeout(timeoutMs) { this.reconnectionTimeoutMs = timeoutMs; } /** * Gets information about temporarily disconnected players. * This is useful for monitoring and debugging connection issues. * * @returns Array of objects containing information about disconnected players */ getDisconnectedPlayers() { const now = Date.now(); const result = []; this.players.forEach(player => { if (player.getAttribute('connectionStatus') === 'disconnected') { const disconnectedAt = player.getAttribute('disconnectedAt'); const reconnectionAvailableUntil = player.getAttribute('reconnectionAvailableUntil'); if (disconnectedAt && reconnectionAvailableUntil) { result.push({ id: player.id, disconnectedAt, reconnectionAvailableUntil, timeLeftMs: Math.max(0, reconnectionAvailableUntil - now) }); } } }); return result; } /** * Gets the current number of connected players. * * @returns The number of connected players */ getConnectedPlayerCount() { return this.players.size; } } exports.WebSocketManager = WebSocketManager;