shoehive
Version:
WebSocket-based multiplayer game framework for real-time, event-driven gameplay
664 lines (663 loc) • 27.7 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: request });
if (!playerId) {
socket.close(1008, 'Authentication failed');
return;
}
}
// Create a new player or reconnect an existing one
const player = this.createOrReconnectPlayer({ socket: socket, playerId: playerId });
// Handle messages
socket.on('message', (data) => {
const message = data.toString();
this.messageRouter.processMessage({ player: player, messageStr: message });
});
// Send initial state to the player
this.sendInitialState({ player: 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({
event: EventTypes_1.LOBBY_EVENTS.UPDATED,
listener: ({ lobbyState }) => {
const message = {
type: index_1.CLIENT_MESSAGE_TYPES.LOBBY.STATE,
data: lobbyState,
};
// Send to all players
this.players.forEach((player) => {
player.sendMessage({ message: message });
});
},
});
// Add listener for lobby state requests
this.eventBus.on({
event: 'request:lobby:state',
listener: ({ player }) => {
player.sendMessage({
message: {
type: index_1.CLIENT_MESSAGE_TYPES.LOBBY.STATE,
data: {
games: this.gameManager.getAvailableGames(),
tables: this.gameManager.getAllTables().map((table) => table.getTableMetadata()),
},
},
});
},
});
// Add listeners for table actions
this.eventBus.on({
event: 'request:table:join',
listener: ({ player, tableId }) => {
const table = this.gameManager.getTableById({ tableId: tableId });
if (!table) {
player.sendMessage({
message: {
type: index_1.CLIENT_MESSAGE_TYPES.ERROR,
message: 'Table not found',
},
});
return;
}
// Add player to table
const success = table.addPlayer({ player: player });
if (success) {
player.setTable({ table: table });
// The table:player:joined event will trigger sending the table state
}
else {
player.sendMessage({
message: {
type: index_1.CLIENT_MESSAGE_TYPES.ERROR,
message: 'Failed to join table',
},
});
}
},
});
this.eventBus.on({
event: 'request:table:leave',
listener: ({ player, tableId }) => {
const table = this.gameManager.getTableById({ tableId: tableId });
if (!table) {
player.sendMessage({
message: {
type: index_1.CLIENT_MESSAGE_TYPES.ERROR,
message: 'Table not found',
},
});
return;
}
// Remove player from table
table.removePlayer({ playerId: player.id });
player.setTable({ table: null });
// Confirm to the player
player.sendMessage({
message: {
type: index_1.CLIENT_MESSAGE_TYPES.PLAYER.STATE,
data: {
id: player.id,
attributes: player.getAttributes(),
},
},
});
// Update lobby state for all players
this.lobby.updateLobbyState();
},
});
this.eventBus.on({
event: 'request:table:create',
listener: ({ player, gameId, options = {}, }) => {
try {
// Create a new table
const table = this.lobby.createTable({ gameId: gameId, options: options });
if (!table) {
player.sendMessage({
message: {
type: index_1.CLIENT_MESSAGE_TYPES.ERROR,
message: 'Failed to create table',
},
});
return;
}
// Automatically join the player to their new table
table.addPlayer({ player: player });
player.setTable({ table: table });
// Notify everyone about the new table (via lobby update)
this.lobby.updateLobbyState();
}
catch (error) {
console.error('Error creating table:', error);
player.sendMessage({
message: {
type: index_1.CLIENT_MESSAGE_TYPES.ERROR,
message: 'Failed to create table: ' +
(error instanceof Error ? error.message : 'unknown error'),
},
});
}
},
});
this.eventBus.on({
event: 'request:table:seat:sit',
listener: ({ player, tableId, seatIndex, }) => {
const table = player.getTable();
if (!table) {
player.sendMessage({
message: {
type: index_1.CLIENT_MESSAGE_TYPES.ERROR,
message: 'Table not found',
},
});
return;
}
// Validate seatIndex to ensure it's a valid number
if (seatIndex === undefined || seatIndex === null || typeof seatIndex !== 'number') {
player.sendMessage({
message: {
type: index_1.CLIENT_MESSAGE_TYPES.ERROR,
message: 'Invalid seat index',
},
});
return;
}
try {
// Emit a table event for seating the player and let the table handle it internally
this.eventBus.emit(EventTypes_1.TABLE_EVENTS.PLAYER_SIT_REQUEST, { player, table, seatIndex });
// The response will be handled by the TABLE_EVENTS.PLAYER_SAT event listener
}
catch (error) {
console.error('Error seating player:', error);
player.sendMessage({
message: {
type: index_1.CLIENT_MESSAGE_TYPES.ERROR,
message: 'Failed to sit at seat: ' +
(error instanceof Error ? error.message : 'unknown error'),
},
});
}
},
});
this.eventBus.on({
event: 'request:table:seat:stand',
listener: ({ player, tableId }) => {
const table = this.gameManager.getTableById({ tableId: tableId });
if (!table) {
player.sendMessage({
message: {
type: index_1.CLIENT_MESSAGE_TYPES.ERROR,
message: 'Table not found',
},
});
return;
}
try {
// Emit a table event for unseating the player and let the table handle it internally
this.eventBus.emit(EventTypes_1.TABLE_EVENTS.PLAYER_STAND_REQUEST, { player, table });
// The response will be handled by the TABLE_EVENTS.PLAYER_STOOD event listener
}
catch (error) {
console.error('Error unseating player:', error);
player.sendMessage({
message: {
type: index_1.CLIENT_MESSAGE_TYPES.ERROR,
message: 'Failed to stand from seat: ' +
(error instanceof Error ? error.message : 'unknown error'),
},
});
}
},
});
this.eventBus.on({
event: EventTypes_1.TABLE_EVENTS.PLAYER_JOINED,
listener: ({ player, table }) => {
// Send the full table state to the joining player
player.sendMessage({
message: {
type: index_1.CLIENT_MESSAGE_TYPES.TABLE.STATE,
data: table.getTableState(),
},
});
},
});
// Add listener for playerSeated event
this.eventBus.on({
event: EventTypes_1.TABLE_EVENTS.PLAYER_SAT,
listener: ({ 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({
event: EventTypes_1.TABLE_EVENTS.PLAYER_STOOD,
listener: ({ 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({
event: EventTypes_1.TABLE_EVENTS.STATE_UPDATED,
listener: ({ table, state }) => {
// 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({
event: EventTypes_1.PLAYER_EVENTS.ATTRIBUTE_CHANGED,
listener: ({ player, key, value }) => {
// Use the new distribution method
this.distributePlayerUpdate({ player: player, key: key, value: value });
},
});
// Handle bulk player attribute changes
this.eventBus.on({
event: EventTypes_1.PLAYER_EVENTS.ATTRIBUTES_CHANGED,
listener: ({ player, changedKeys, attributes, }) => {
// Use the new bulk distribution method
this.distributePlayerUpdates({ player: player, attributes: attributes });
},
});
// Handle table attribute changes
this.eventBus.on({
event: EventTypes_1.TABLE_EVENTS.ATTRIBUTE_CHANGED,
listener: ({ table, key, value }) => {
// Broadcast is handled by ATTRIBUTES_CHANGED batch event to avoid duplicates.
// 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({
event: EventTypes_1.TABLE_EVENTS.ATTRIBUTES_CHANGED,
listener: ({ table, changedKeys, attributes, }) => {
const gameId = table.getAttribute({ key: 'gameId' });
const gameDefinition = gameId
? this.gameManager.getGameDefinition({ gameId: gameId })
: null;
const relevantTableAttributes = gameDefinition?.relevantTableAttributes || [
'status',
'turn',
'pot',
'board',
'phase',
'winner',
];
// Broadcast if any relevant attributes were changed
if (changedKeys.some((key) => relevantTableAttributes.includes(key))) {
table.broadcastTableState();
}
// 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({
message: {
type: index_1.CLIENT_MESSAGE_TYPES.PLAYER.STATE,
data: {
id: player.id,
attributes: player.getAttributes(),
},
},
});
// Send available games and tables (lobby state)
player.sendMessage({
message: {
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({
message: {
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({
message: {
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({ key: 'gameId' });
if (!gameId)
return;
// Get the game definition
const gameDefinition = this.gameManager.getGameDefinition({ gameId: 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({
message: {
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({ key: 'gameId' });
if (!gameId)
return;
// Get the game definition
const gameDefinition = this.gameManager.getGameDefinition({ gameId: 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();
// Re-bind the socket connection directly entirely bypassing new object creation
existingPlayer.setSocket({ socket: socket });
// 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, { existingPlayer });
return existingPlayer;
}
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: 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({
callback: () => {
// Only set timeout if reconnection timeout is enabled
if (this.reconnectionTimeoutMs > 0) {
// Mark player as temporarily disconnected
player.setAttribute({ key: 'connectionStatus', value: 'disconnected' });
// Set timeout to remove player if they don't reconnect
const timeout = setTimeout(() => {
this.removePlayerPermanently({ playerId: player.id });
}, this.reconnectionTimeoutMs);
this.disconnectionTimeouts.set(player.id, timeout);
}
else {
// If timeout is disabled, remove player immediately
this.removePlayerPermanently({ playerId: 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: 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: 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({ key: 'connectionStatus' }) === 'disconnected') {
const disconnectedAt = player.getAttribute({ key: 'disconnectedAt' });
const reconnectionAvailableUntil = player.getAttribute({
key: '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;