shoehive
Version:
WebSocket-based multiplayer game framework for real-time, event-driven gameplay
421 lines (420 loc) • 17.8 kB
JavaScript
"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;