UNPKG

shoehive

Version:

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

667 lines (666 loc) 26.1 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Table = exports.TableState = void 0; const TableEvents_1 = require("../events/TableEvents"); const index_1 = require("./card/index"); const Seat_1 = require("./Seat"); const crypto_1 = __importDefault(require("crypto")); const index_2 = require("./commands/index"); var TableState; (function (TableState) { TableState["WAITING"] = "waiting"; TableState["ACTIVE"] = "active"; TableState["ENDED"] = "ended"; })(TableState || (exports.TableState = TableState = {})); /** * Represents a game table with players, seats, and game state. * * ✅ Attribute Support * * The Table class manages a group of players and seats for a specific game. * It emits various events to notify other components about changes in the table state. * * This class is responsible for: * - Managing player connections and disconnections * - Handling player messages and commands * - Distributing game events to all players * - Maintaining the game state and rules */ class Table { constructor(eventBus, totalSeats, maxSeatsPerPlayer, id, gameId, options = {}) { this.players = new Map(); this.state = TableState.WAITING; this.attributes = new Map(); this.deck = null; // Cached listener references for proper cleanup and memory leak prevention this.handleSitRequest = ({ player, table, seatIndex, }) => { if (table.id !== this.id) return; try { const success = this.sitPlayerAtSeat({ playerId: player.id, seatIndex }); if (!success) { player.sendMessage({ message: { type: index_2.CLIENT_MESSAGE_TYPES.ERROR, message: 'Failed to sit at seat' }, }); } } catch (error) { console.error('Error handling sit request:', error); player.sendMessage({ message: { type: index_2.CLIENT_MESSAGE_TYPES.ERROR, message: 'Failed to sit at seat: ' + (error instanceof Error ? error.message : 'unknown error'), }, }); } }; this.handleStandRequest = ({ player, table }) => { if (table.id !== this.id) return; try { const success = this.standPlayerUp({ playerId: player.id }); if (!success) { player.sendMessage({ message: { type: index_2.CLIENT_MESSAGE_TYPES.ERROR, message: 'Failed to stand from seat' }, }); } } catch (error) { console.error('Error handling stand request:', error); player.sendMessage({ message: { type: index_2.CLIENT_MESSAGE_TYPES.ERROR, message: 'Failed to stand from seat: ' + (error instanceof Error ? error.message : 'unknown error'), }, }); } }; this.id = id || crypto_1.default.randomUUID(); this.eventBus = eventBus; this.totalSeats = totalSeats; this.maxSeatsPerPlayer = maxSeatsPerPlayer; this.gameId = gameId || 'default'; this.options = options; // Initialize seats with Seat objects this.seats = new Array(totalSeats).fill(null).map(() => new Seat_1.Seat()); // Listen for player sit and stand request events this.setupEventListeners(); // Set initial attributes without emitting events yet this.attributes.set('gameId', this.gameId); if (this.options && Object.keys(this.options).length > 0) { this.attributes.set('options', this.options); } // Emit table created event this.eventBus.emit(TableEvents_1.TABLE_EVENTS.CREATED, { table: this }); } /** * Set up event listeners for this table */ setupEventListeners() { this.eventBus.on({ event: TableEvents_1.TABLE_EVENTS.PLAYER_SIT_REQUEST, listener: this.handleSitRequest }); this.eventBus.on({ event: TableEvents_1.TABLE_EVENTS.PLAYER_STAND_REQUEST, listener: this.handleStandRequest, }); } /** * Destroys the table by safely unbinding all event listeners to prevent severe memory leaks. */ destroy() { this.eventBus.off({ event: TableEvents_1.TABLE_EVENTS.PLAYER_SIT_REQUEST, listener: this.handleSitRequest }); this.eventBus.off({ event: TableEvents_1.TABLE_EVENTS.PLAYER_STAND_REQUEST, listener: this.handleStandRequest, }); } /* * Card and deck related methods */ /** * Creates a new deck for the table. Emits TABLE_EVENTS.DECK_CREATED when the deck is created. * @param numberOfDecks - The number of decks to create. */ createDeck({ numberOfDecks = 1 } = {}) { this.deck = new index_1.Deck(numberOfDecks); this.eventBus.emit(TableEvents_1.TABLE_EVENTS.DECK_CREATED, { table: this, numberOfDecks }); } /** * Gets the current deck. * @returns The deck object or null if no deck has been created. */ getDeck() { return this.deck; } /** * Shuffles the current deck. Emits TABLE_EVENTS.DECK_SHUFFLED when the deck is shuffled. * @returns True if the deck was shuffled, false if no deck exists. */ shuffleDeck() { if (!this.deck) return false; this.deck.shuffle(); this.eventBus.emit(TableEvents_1.TABLE_EVENTS.DECK_SHUFFLED, { table: this }); return true; } /** * Draws a card from the deck. Emits TABLE_EVENTS.DECK_CARD_DRAWN when a card is drawn. * @param isVisible - Whether the card should be visible to the player. * @returns The drawn card or null if no deck exists. */ drawCard({ isVisible = true } = {}) { if (!this.deck) return null; const card = this.deck.drawCard({ isVisible }); if (card) { this.eventBus.emit(TableEvents_1.TABLE_EVENTS.DECK_CARD_DRAWN, { table: this, card }); } return card; } /** * Deals a card to a seat. Emits TABLE_EVENTS.CARD_DEALT when a card is dealt. * @param seatIndex - The index of the seat to deal the card to. * @param isVisible - Whether the card should be visible to the player. * @param handId - The ID of the hand to deal the card to. * @returns True if the card was dealt, false if no deck exists. */ dealCardToSeat({ seatIndex, isVisible = true, handId = 'main', }) { if (!this.deck) return false; if (seatIndex < 0 || seatIndex >= this.totalSeats) { return false; } const card = this.deck.drawCard({ isVisible }); if (!card) return false; const seat = this.getSeat({ seatIndex }); if (!seat) return false; // Create the hand if it doesn't exist if (!seat.getHand({ handId })) { seat.addHand({ handId }); } const hand = seat.getHand({ handId }); if (!hand) return false; hand.addCard({ card }); this.eventBus.emit(TableEvents_1.TABLE_EVENTS.CARD_DEALT, { table: this, seatIndex, card, handId }); return true; } /** * Gets a hand at a specific seat. * @param seatIndex - The index of the seat to get the hand from. * @param handId - The ID of the hand to get. * @returns The hand object or null if no hand exists. */ getHandAtSeat({ seatIndex, handId = 'main', }) { const seat = this.getSeat({ seatIndex }); if (!seat) return null; return seat.getHand({ handId }); } /** * Deals a card to a hand. Emits TABLE_EVENTS.CARD_DEALT when a card is dealt. * @param hand <Hand> - The hand to deal the card to. * @returns True if the card was dealt, false if no seat or hand exists. */ dealCardToHand({ hand }) { if (!this.deck) return false; const card = this.deck.drawCard({ isVisible: true }); if (!card) return false; // Find the seat index for this hand let seatIndex = -1; for (let i = 0; i < this.seats.length; i++) { const seat = this.getSeat({ seatIndex: i }); if (seat) { // Check all hands at this seat for (const [_, h] of seat.getAllHands()) { if (h === hand) { seatIndex = i; break; } } } if (seatIndex !== -1) break; } hand.addCard({ card }); this.eventBus.emit(TableEvents_1.TABLE_EVENTS.CARD_DEALT, { table: this, seatIndex, card, handId: hand.getId(), }); return true; } /** * Gets all hands at a specific seat. * @param seatIndex - The index of the seat to get the hands from. * @returns A map of hand IDs to hand objects or null if no seat exists. */ getAllHandsAtSeat({ seatIndex }) { if (seatIndex < 0 || seatIndex >= this.totalSeats) { return null; } const seat = this.getSeat({ seatIndex }); if (!seat) return null; return seat.getAllHands(); } /** * Clears a hand at a specific seat. Emits TABLE_EVENTS.SEAT_HAND_CLEARED when a hand is cleared. * @param seatIndex - The index of the seat to clear the hand from. * @param handId - The ID of the hand to clear. * @returns True if the hand was cleared, false if no seat exists. */ clearHandAtSeat({ seatIndex, handId = 'main', }) { const seat = this.getSeat({ seatIndex }); if (!seat) return false; const result = seat.clearHand({ handId }); if (result) { this.eventBus.emit(TableEvents_1.TABLE_EVENTS.SEAT_HAND_CLEARED, { table: this, seatIndex, handId }); } return result; } /** * Clears all hands at the table. Emits TABLE_EVENTS.SEATS_HANDS_CLEARED when all hands are cleared. */ clearAllHands() { for (let i = 0; i < this.totalSeats; i++) { const seat = this.getSeat({ seatIndex: i }); if (seat) { seat.clearAllHands(); } } this.eventBus.emit(TableEvents_1.TABLE_EVENTS.SEATS_HANDS_CLEARED, { table: this }); } /** * Adds a hand to a seat. Emits TABLE_EVENTS.SEAT_HAND_ADDED when a hand is added to a seat. * @param seatIndex - The index of the seat to add the hand to. * @param handId - The ID of the hand to add. * @returns True if the hand was added, false if no seat exists. */ addHandToSeat({ seatIndex, handId }) { const seat = this.getSeat({ seatIndex }); if (!seat) return false; const result = seat.addHand({ handId }); if (result) { this.eventBus.emit(TableEvents_1.TABLE_EVENTS.SEAT_HAND_ADDED, { table: this, seatIndex, handId }); } return result; } /** * Removes a hand from a seat. Emits TABLE_EVENTS.SEAT_HAND_REMOVED when a hand is removed from a seat. * @param seatIndex - The index of the seat to remove the hand from. * @param handId - The ID of the hand to remove. * @returns True if the hand was removed, false if no seat exists. */ removeHandFromSeat({ seatIndex, handId }) { const seat = this.getSeat({ seatIndex }); if (!seat) return false; const result = seat.removeHand({ handId }); if (result) { this.eventBus.emit(TableEvents_1.TABLE_EVENTS.SEAT_HAND_REMOVED, { table: this, seatIndex, handId }); } return result; } /** * Adds a player to the table. Emits TABLE_EVENTS.PLAYER_JOINED when a player joins the table. * @param player - The player to add. * @returns True if the player was added, false if the player is already at a table. */ addPlayer({ player }) { // Check if player is already at a table if (player.getTable()) { return false; } this.players.set(player.id, player); player.setTable({ table: this }); this.eventBus.emit(TableEvents_1.TABLE_EVENTS.PLAYER_JOINED, { player, table: this }); return true; } /** * Removes a player from the table. Emits TABLE_EVENTS.PLAYER_LEFT when a player leaves the table. * @param playerId - The ID of the player to remove. * @returns True if the player was removed, false if the player is not at the table. */ removePlayer({ playerId }) { const player = this.players.get(playerId); if (!player) return false; // Remove player from all seats for (let i = 0; i < this.seats.length; i++) { const seat = this.getSeat({ seatIndex: i }); if (seat?.getPlayer()?.id === playerId) { seat.setPlayer({ player: null }); } } this.players.delete(playerId); player.setTable({ table: null }); this.eventBus.emit(TableEvents_1.TABLE_EVENTS.PLAYER_LEFT, { player, table: this }); // Check if table is empty if (this.players.size === 0) { this.eventBus.emit(TableEvents_1.TABLE_EVENTS.EMPTY, { table: this }); } return true; } /** * Sits a player at a specific seat. Emits TABLE_EVENTS.PLAYER_SAT when a player sits at a seat. * @param playerId - The ID of the player to sit. * @param seatIndex - The index of the seat to sit the player at. * @returns True if the player was seated, false if the seat is invalid or already taken. */ sitPlayerAtSeat({ playerId, seatIndex, }) { const seat = this.getSeat({ seatIndex }); if (!seat) return false; const player = this.players.get(playerId); if (!player) return false; if (player) { if (this.getPlayerAtSeat({ seatIndex })) return false; } // Check if player is already seated at too many seats const playerSeats = this.getPlayerSeatCount({ playerId }); if (playerSeats >= this.maxSeatsPerPlayer) { return false; } seat.setPlayer({ player }); this.eventBus.emit(TableEvents_1.TABLE_EVENTS.PLAYER_SAT, { player, table: this, seatIndex }); return true; } /** * Removes a player from a seat. Emits TABLE_EVENTS.PLAYER_STOOD when a player stands up from a seat. * @param seatIndex - The index of the seat to remove the player from. * @returns True if the player was removed from the seat, false if the seat is invalid or no player is seated. */ removePlayerFromSeat({ seatIndex }) { const seat = this.getSeat({ seatIndex }); if (!seat) return false; const player = seat.getPlayer(); if (!player) return false; seat.setPlayer({ player: null }); this.eventBus.emit(TableEvents_1.TABLE_EVENTS.PLAYER_STOOD, { player, table: this, seatIndex }); return true; } /** * Gets the number of seats a player is seated at. * @param playerId - The ID of the player to get the seat count for. * @returns The number of seats the player is seated at. */ getPlayerSeatCount({ playerId }) { let count = 0; for (const seat of this.seats) { // Skip null or undefined seats if (!seat) continue; try { const player = seat.getPlayer(); if (player && player.id === playerId) { count++; } } catch (error) { console.error(`Error checking player in seat: ${error}`); // Continue with the loop even if one seat has an error } } return count; } /** * Gets the current state of the table. * @returns The current state of the table. */ getState() { return this.state; } /** * Sets the state of the table. Emits TABLE_EVENTS.STATE_UPDATED when the state is updated. * @param state - The new state of the table. */ setState({ state }) { this.state = state; this.eventBus.emit(TableEvents_1.TABLE_EVENTS.STATE_UPDATED, { table: this, state }); // Also broadcast the full table state to all players when state enum changes this.broadcastTableState(); } /** * Gets the number of players at the table. * @returns The number of players at the table. */ getPlayerCount() { return this.players.size; } /** * Gets all players at the table. * @returns An array of all players at the table. */ getPlayers() { return Array.from(this.players.values()); } /** * Gets a seat at a specific index. * @param seatIndex - The index of the seat to get. * @returns The seat object or null if the index is invalid. */ getSeat({ seatIndex }) { if (seatIndex < 0 || seatIndex >= this.totalSeats) { return null; } // Ensure seat exists at this index if (!this.seats[seatIndex] || typeof this.seats[seatIndex] !== 'object') { this.seats[seatIndex] = new Seat_1.Seat(); } return this.seats[seatIndex]; } /** * Gets all seats at the table. * @returns An array of all seats at the table. */ getSeats() { return [...this.seats]; } /** * Gets the player at a specific seat. * @param seatIndex - The index of the seat to get the player from. * @returns The player object or null if the seat is invalid. */ getPlayerAtSeat({ seatIndex }) { const seat = this.getSeat({ seatIndex }); if (!seat?.getPlayer()) return null; return seat.getPlayer(); } /** * Broadcasts a message to all players at the table. * @param message - The message to broadcast. */ broadcastMessage({ message }) { const players = Array.from(this.players.values()); for (const player of players) { player.sendMessage({ message }); } } /** * Broadcasts the current table state to all players at the table. * This includes all game-specific state and is only meant for players at this table. * Broadcasts a TABLE_EVENTS.STATE_UPDATED event that can be used by other components. */ broadcastTableState() { // Generate specialized payloads dynamically so players can view their own proprietary hands for (const player of this.players.values()) { const personalTableState = this.getTableState({ playerId: player.id }); player.sendMessage({ message: { type: index_2.CLIENT_MESSAGE_TYPES.TABLE.STATE, data: personalTableState, }, }); } // Emit a generic visibility state that can be used by other non-player components const generalTableState = this.getTableState(); this.eventBus.emit(TableEvents_1.TABLE_EVENTS.STATE_UPDATED, { table: this, state: generalTableState }); } /** * Gets the complete table state including all attributes and game state. * Modifies output arrays to reveal hidden cards strictly to their seated owner. * * @param playerId An optional playerId. If matched against a seat owner, returns proprietary hidden cards. * @returns The localized complete table state. */ getTableState({ playerId } = {}) { return { id: this.id, state: this.state, seats: this.seats.map((seat) => ({ player: seat.getPlayer() ? { id: seat.getPlayer().id, attributes: seat.getPlayer().getAttributes(), } : null, hands: Array.from(seat.getAllHands() || new Map()).reduce((obj, [key, hand]) => { // If the requester owns the seat, map full visibility array if (playerId && seat.getPlayer()?.id === playerId) { obj[key] = hand.getFullState(); } else { obj[key] = hand.getVisibleState(); } return obj; }, {}), })), attributes: Object.fromEntries(this.attributes.entries()), playerCount: this.players.size, }; } /** * Gets the table metadata for lobby display. * This includes only the essential information needed to display in the lobby. * * @returns The table metadata. */ getTableMetadata() { return { id: this.id, state: this.state, seats: this.seats.map((seat) => seat.getPlayer()?.id || null), playerCount: this.players.size, gameId: this.getAttribute({ key: 'gameId' }), gameName: this.getAttribute({ key: 'gameName' }), options: this.getAttribute({ key: 'options' }), }; } /** * Sets an attribute on the table. Emits TABLE_EVENTS.ATTRIBUTE_CHANGED when the attribute is updated. * @param key - The key of the attribute to set. * @param value - The value of the attribute to set. */ setAttribute({ key, value }) { this.setAttributes({ attributes: { [key]: value } }); } /** * Gets an attribute from the table. * @param key - The key of the attribute to get. * @returns The value of the attribute or null if the attribute does not exist. */ getAttribute({ key }) { return this.attributes.get(key); } /** * Checks if the table has an attribute. * @param key - The key of the attribute to check. * @returns True if the attribute exists, false otherwise. */ hasAttribute({ key }) { return this.attributes.has(key); } /** * Gets all attributes from the table. * @returns An object containing all attributes. */ getAttributes() { return Object.fromEntries(this.attributes.entries()); } /** * Sets multiple attributes on the table. Emits TABLE_EVENTS.ATTRIBUTES_CHANGED when any attributes are updated. * @param attributes - An object containing key-value pairs of attributes to set. * @param broadcast - Whether to broadcast the table state after updating the attributes. * @returns True if any attributes were changed, false otherwise. */ setAttributes({ attributes, broadcast = false, }) { let shouldUpdateLobby = false; const metadataAttributes = ['gameId', 'gameName', 'options']; const changedKeys = []; // Set all attributes for (const [key, value] of Object.entries(attributes)) { this.attributes.set(key, value); changedKeys.push(key); // Check if any metadata attributes were changed if (metadataAttributes.includes(key)) { shouldUpdateLobby = true; } // Emit individual events for backwards compatibility this.eventBus.emit(TableEvents_1.TABLE_EVENTS.ATTRIBUTE_CHANGED, { table: this, key, value }); } // Emit a bulk event if any attributes were changed if (changedKeys.length > 0) { this.eventBus.emit(TableEvents_1.TABLE_EVENTS.ATTRIBUTES_CHANGED, { table: this, changedKeys, attributes }); } // Broadcast the table state if requested if (broadcast && changedKeys.length > 0) { this.broadcastTableState(); } return shouldUpdateLobby; } /** * Updates the attributes of the table. Emits TABLE_EVENTS.ATTRIBUTES_CHANGED when any attributes are updated. * @param attributes - An object containing key-value pairs of attributes to set. * @returns True if any attributes were changed, false otherwise. */ updateAttributes({ attributes }) { return this.setAttributes({ attributes, broadcast: true }); } /** * Removes an attribute from the table. Emits TABLE_EVENTS.ATTRIBUTE_CHANGED when the attribute is removed. * @param key - The key of the attribute to remove. */ removeAttribute({ key }) { this.attributes.delete(key); this.eventBus.emit(TableEvents_1.TABLE_EVENTS.ATTRIBUTE_CHANGED, { table: this, key, value: undefined }); } /** * Makes a player stand up from all seats they occupy. * @param playerId - The ID of the player to stand up. * @returns True if the player was removed from at least one seat, false otherwise. */ standPlayerUp({ playerId }) { let success = false; // Find all seats the player is sitting at for (let i = 0; i < this.seats.length; i++) { const seat = this.getSeat({ seatIndex: i }); if (!seat) continue; const player = seat.getPlayer(); if (player && player.id === playerId) { // Remove player from this seat if (this.removePlayerFromSeat({ seatIndex: i })) { success = true; } } } return success; } } exports.Table = Table;