/** * Socket Event Handlers Module * Handles all socket.io event processing for the Wownerogue game server */ const user = require('../db/user'); const GameManager = require('../game/gameManager'); const MovementManager = require('../game/movementManager'); const QueueManager = require('./queueManager'); const MatchQueue = require('./matchQueue'); const MatchScheduler = require('./matchScheduler'); const MatchManager = require('./matchManager'); const TavernMatchBridge = require('./tavernMatchBridge'); const PaymentHandlers = require('./paymentHandlers'); const AddressManager = require('./addressManager'); const SessionManager = require('./sessionManager'); const RateLimiter = require('./rateLimiter'); const { clientIp, stableId } = require('./rateLimitContext'); const ConnectionHandler = require('./connectionHandler'); const ChatHandler = require('./chatHandler'); const QueueHandler = require('./queueHandler'); const SpectatorManager = require('./spectatorManager'); const TavernManager = require('./tavernManager'); const IdentityService = require('./identityService'); const SuspendedGameManager = require('./suspendedGameManager'); const MemoryManager = require('../utils/memoryManager'); const FairnessOfferManager = require('../game/fairnessOfferManager'); const { SNAPSHOT_VERSION: SOLO_RESTART_SNAPSHOT_VERSION, captureSoloRestartSnapshot, restoreSoloRestartSnapshot } = require('../game/soloRestartSnapshot'); const { normalizeError } = require('../utils/errors'); const money = require('../money/atomic'); const { buildCommerceDisclosure, validatePaidAcknowledgement } = require('../config/commerceDisclosurePolicy'); function requirePaidActionAcknowledgement(context, socket, raw, errorEvent = null) { const disclosure = buildCommerceDisclosure(context.gameModeManager, process.env); // Every call site represents a value-bearing action. This action-level requirement must not // disappear when invoice intake is paused: already-owned credits and race tickets still have // value and may still be consumed. const result = validatePaidAcknowledgement(raw, disclosure, { required: true }); if (result.ok) return true; const payload = { message: result.message, error: result.message, code: result.code, policyVersion: disclosure.policyVersion }; socket.emit('commerce_ack_required', payload); if (errorEvent) socket.emit(errorEvent, payload); context.broadcastManager?.sendStatusUpdate?.(socket.id, 'warning', result.message); return false; } class SocketHandlers { constructor(io, activeGames, broadcastManager, debugManager, gameModeManager = null, walletService = null) { this.io = io; this.activeGames = activeGames; this.broadcastManager = broadcastManager; this.debugManager = debugManager; this.gameModeManager = gameModeManager; this.walletService = walletService; this.MOVE_COOLDOWN = 100; // Minimum 100ms between moves this.fairnessOffers = new FairnessOfferManager(); this._isShuttingDown = false; this._admissionsInFlight = new Set(); // Initialize rate limiter with debug mode matching debugManager this.rateLimiter = new RateLimiter({ debugMode: this.debugManager.CONSOLE_LOGGING, limits: { 'payment:create': { window: 60000, max: 3 }, 'game:start': { window: 60000, max: 15 }, 'game:queue': { window: 30000, max: 5 }, 'chat:message': { window: 10000, max: 12 }, 'address:set': { window: 300000, max: 3 }, 'identity:update': { window: 10000, max: 8 }, 'connection:new': { window: 60000, max: 10 } } }); // Initialize memory manager for cleanup this.memoryManager = new MemoryManager({ debugMode: this.debugManager.CONSOLE_LOGGING, cleanupInterval: 300000 // 5 minutes }); // Initialize game manager this.gameManager = new GameManager({ activeGames: this.activeGames, io: this.io, broadcastManager: this.broadcastManager, debugManager: this.debugManager, gameModeManager: this.gameModeManager }); // queueManager owns all queue state // Initialize session manager first if (this.gameModeManager && this.gameModeManager.db) { this.sessionManager = new SessionManager({ db: this.gameModeManager.db, debugManager: this.debugManager, gameModeManager: this.gameModeManager, io: this.io }); // GameModeManager needs the session manager so getOrCreateUser resolves through the // stable anon_token identity instead of the mutable socket_id. this.gameModeManager.sessionManager = this.sessionManager; // Initialize cleanup timers this.sessionManager.initialize().catch(err => { console.error('Failed to initialize SessionManager:', err); }); } // Initialize connection handler this.connectionHandler = new ConnectionHandler({ io: this.io, broadcastManager: this.broadcastManager, debugManager: this.debugManager, sessionManager: this.sessionManager, rateLimiter: this.rateLimiter }); this.queueManager = new QueueManager({ debugManager: this.debugManager, broadcastManager: this.broadcastManager, io: this.io, createGameForUser: async (userObj, options) => { const game = await this.gameManager.createGameForUser(userObj, options); // Stamp the stable DB user id onto the game at creation. Suspend/restore keys // on this id; capturing it here, where the session is reliably available, keeps // the disconnect path free of a failure-prone DB lookup, so any game, paid or // free, is reconnectable. try { const sid = userObj?.id; const dbUid = sid != null ? this.sessionManager?.sessions?.get(sid)?.id : null; if (game && dbUid != null) game.dbUserId = dbUid; } catch (_) {} return game; }, getUserBySocket: (socketId) => this.connectionHandler.getUserBySocket(socketId), activeGames: this.activeGames, gameModeManager: this.gameModeManager, consoleLogging: this.debugManager.CONSOLE_LOGGING }); this.paymentHandlers = new PaymentHandlers({ io: this.io, gameModeManager: this.gameModeManager, walletService: this.walletService, debugManager: this.debugManager, queueManager: this.queueManager, broadcastManager: this.broadcastManager, sessionManager: this.sessionManager }); this.identityService = new IdentityService({ db: this.gameModeManager?.db, gameModeManager: this.gameModeManager, sessionManager: this.sessionManager, debugManager: this.debugManager }); // Address / payout handling encapsulated in AddressManager this.addressManager = new AddressManager({ gameModeManager: this.gameModeManager, broadcastManager: this.broadcastManager, io: this.io, debugManager: this.debugManager, onConfirmed: (socketId, accepted) => { if (accepted) { // Automatically proceed to payment creation after address confirmation const sock = this._getLiveSocket(socketId); if (sock) { this.paymentHandlers.createAndShowPaymentRequest(sock); } if (this.chatHandler) { this.chatHandler.clearAddressConfirmation(socketId); } } } }); // Initialize chat handler with database for persistent history this.chatHandler = new ChatHandler({ io: this.io, broadcastManager: this.broadcastManager, debugManager: this.debugManager, addressManager: this.addressManager, paymentHandlers: this.paymentHandlers, queueManager: this.queueManager, gameModeManager: this.gameModeManager, rateLimiter: this.rateLimiter, db: this.gameModeManager?.db }); // Initialize chat history (async, non-blocking) this.chatHandler.initialize().catch(err => { console.error('Failed to initialize chat handler:', err.message); }); // Initialize queue handler this.queueHandler = new QueueHandler({ queueManager: this.queueManager, gameModeManager: this.gameModeManager, paymentHandlers: this.paymentHandlers, activeGames: this.activeGames, broadcastManager: this.broadcastManager, debugManager: this.debugManager, rateLimiter: this.rateLimiter }); // Initialize spectator manager for live game viewing this.spectatorManager = new SpectatorManager({ io: this.io, activeGames: this.activeGames, broadcastManager: this.broadcastManager, debugManager: this.debugManager, queueManager: this.queueManager // For pending games list }); this.spectatorManager.initialize(); // Wire spectator manager to game manager (for game over notifications) this.gameManager.setSpectatorManager(this.spectatorManager); // Initialize tavern manager (social hangout mode). Inert unless TAVERN_ENABLED=true. // Load the designed tavern room (imported .tmx); fall back to the procedural map if absent. let tavernRoomData = null; const tavernRoomUrl = 'assets/kenney/tavern_room.json'; // The imported .tmx room renders as a block-scatter in the browser (the client falls back to // the walkable grid), so the default is the hand-built procedural tavern (tavernMap.js), // which renders reliably through the theme atlas. TAVERN_DESIGNED_ROOM=true opts into the .tmx. if (process.env.TAVERN_DESIGNED_ROOM === 'true') { try { const roomPath = require('path').join(__dirname, '../../html', tavernRoomUrl); tavernRoomData = JSON.parse(require('fs').readFileSync(roomPath, 'utf8')); } catch (e) { if (this.debugManager.CONSOLE_LOGGING) console.log('Tavern room JSON not found; using default map:', e.message); } } this.tavernManager = new TavernManager({ io: this.io, debugManager: this.debugManager, roomData: tavernRoomData, roomUrl: tavernRoomData ? tavernRoomUrl : null, entitlementProvider: async (socket) => this._entitlementsForSocket(socket), // Share the lobby's global chat provider so the tavern joins one global chat with // persistent history (and nostr fan-out when enabled), instead of ephemeral room chat. globalChatProvider: this.chatHandler.chatProvider, // Publishing into GLOBAL chat must carry the same guards the lobby path applies. // Without them `tavern_chat` is an unauthenticated bypass of the chat ban list, the // reconnect-proof rate limiter, and user_id attribution, reaching every connected // client and the persisted history under a name the client picked. chatModeration: { rateLimiter: this.rateLimiter, sessionManager: this.gameModeManager?.sessionManager || null, chatHistory: this.chatHandler?.chatHistory || null, getOrCreateUser: (socketId) => this.gameModeManager?.getOrCreateUser(socketId) } }); this.tavernManager.initialize(); // Initialize match mode queue, scheduler, and manager. Inert unless MATCH_ENABLED=true. this.matchQueue = new MatchQueue({ db: this.gameModeManager?.db || null, gameModeManager: this.gameModeManager, debugManager: this.debugManager, isFinancialRecoveryReady: () => this.matchManager?.financialRecoveryReady === true }); this.matchManager = new MatchManager({ io: this.io, db: this.gameModeManager?.db || null, debugManager: this.debugManager, identityService: this.identityService, gameModeManager: this.gameModeManager }); this.matchScheduler = new MatchScheduler({ matchQueue: this.matchQueue, matchManager: this.matchManager, debugManager: this.debugManager }); // Database connection + migrations happen later in startServer(), so the durable queue // cannot be initialized here. initializeMatchMode() is awaited after // DatabaseManager.initialize() instead. this._matchInitializePromise = null; // Initialize suspended game manager for reconnection support this.suspendedGameManager = new SuspendedGameManager({ debugManager: this.debugManager, activeGames: this.activeGames, cleanupTimeoutMs: 300000 // 5 minutes to reconnect before game is lost }); this.gameManager.setSuspendedGameManager(this.suspendedGameManager); // Movement manager abstraction (owns its own move-cooldown state) this.movementManager = new MovementManager({ activeGames: this.activeGames, io: this.io, debugManager: this.debugManager, moveCooldown: this.MOVE_COOLDOWN, postMoveHook: ({ socketId, game, moveResult }) => this.afterPlayerMove(socketId, game, moveResult), spectatorManager: this.spectatorManager // Pass for spectator broadcasts }); // Register memory cleanup functions this._registerMemoryCleanups(); } /** Initialize durable match state only after the database is connected and migrated. */ async initializeMatchMode() { if (this._matchInitializePromise) return this._matchInitializePromise; this._matchInitializePromise = (async () => { await this.matchQueue.initialize(); await this.matchManager.initialize?.(); this.tavernMatchBridge = new TavernMatchBridge({ matchManager: this.matchManager, tavernManager: this.tavernManager, io: this.io, debugManager: this.debugManager }); this.tavernMatchBridge.initialize(); })(); try { await this._matchInitializePromise; } catch (error) { this._matchInitializePromise = null; throw error; } } /** Stop new solo movement before taking the graceful-shutdown settlement snapshot. */ beginShutdown() { // This flag is the synchronous boundary: every admission wrapper and lower-level game // creator observes it before any shutdown await can yield back to a client callback. this._isShuttingDown = true; this.gameManager?.beginShutdown?.(); this.gameModeManager?.beginGameAdmissionShutdown?.(); this.paymentHandlers?.beginShutdown?.(); this.movementManager?.shutdown?.(); } _emitShutdownAdmissionRefusal(socket, kind = 'admission') { const payload = { code: 'SERVER_SHUTTING_DOWN', message: 'The server is restarting; new entries are temporarily closed.' }; try { socket?.emit?.('server_restarting', { ...payload, kind }); } catch (_) {} try { this.broadcastManager?.sendStatusUpdate?.(socket?.id, 'warning', payload.message); } catch (_) {} return payload; } _rejectAdmissionDuringShutdown(socket, kind) { if (!this._isShuttingDown) return false; this._emitShutdownAdmissionRefusal(socket, kind); return true; } /** * Refuse admission while no blockchain node is reachable. * * A run's lifetime is measured in blocks, and its entry block comes from the last SUCCESSFUL * poll. With every node unreachable that value silently goes stale, so a game admitted during * an outage is anchored to an already-past block and `checkGamesTimeout` kills it as a timeout * on the first recovered poll, costing the player a run (and in paid mode, an entry). Failing * the entry up front is the honest outcome: nothing is charged and the player is told why. * * Deliberately conservative: it only fires when the health check explicitly reports false. * Simulated blocks report healthy, and a runtime without the probe is left alone. */ _rejectAdmissionWhenNodeUnreachable(socket, kind) { if (this.debugManager?.isChainHealthy?.() !== false) return false; const chain = String(process.env.CRYPTO_TYPE || 'WOW').toUpperCase().slice(0, 8); const message = `Can't reach a ${chain} node right now, so entries are paused. ` + 'Nothing was charged; please try again shortly.'; try { socket?.emit?.('chain_unavailable', { code: 'NODE_UNREACHABLE', kind, message }); } catch (_) {} try { this.broadcastManager?.sendStatusUpdate?.(socket?.id, 'error', message); } catch (_) {} if (this.debugManager?.CONSOLE_LOGGING) { console.warn(`[SocketHandlers] '${kind}' refused: no daemon reachable.`); } return true; } /** * Refuse PAID intake while the wallet is unreachable. * * Deliberately NOT a failover. Two `wallet-rpc` processes serving one wallet file can build * transactions from the same outputs and corrupt the wallet cache, so a standby wallet is an * operator decision about custody, not something this code may take on its own. Daemons are * read-only and interchangeable; wallets are neither. The safe behaviour when the wallet is * down is therefore to decline the invoice cleanly rather than to route around it. * * Only gates kinds that actually need the wallet: credits/free entry runs off the database and * must keep working through a wallet outage. */ _rejectPaidIntakeWhenWalletUnreachable(socket, kind) { if (kind !== 'request_payment') return false; // Only meaningful once a wallet has been wired up and has reported at least once. if (!this.walletService || this.walletService.isHealthy !== false) return false; const message = 'Payments are temporarily unavailable: the wallet is unreachable. ' + 'Nothing was charged. Credits and free play still work.'; try { socket?.emit?.('payment_unavailable', { code: 'WALLET_UNREACHABLE', kind, message }); } catch (_) {} try { this.broadcastManager?.sendStatusUpdate?.(socket?.id, 'error', message); } catch (_) {} if (this.debugManager?.CONSOLE_LOGGING) { console.warn(`[SocketHandlers] '${kind}' refused: wallet unreachable.`); } return true; } _runAdmission(socket, kind, task) { if (this._rejectAdmissionDuringShutdown(socket, kind)) return Promise.resolve(null); if (this._rejectAdmissionWhenNodeUnreachable(socket, kind)) return Promise.resolve(null); if (this._rejectPaidIntakeWhenWalletUnreachable(socket, kind)) return Promise.resolve(null); const token = {}; this._admissionsInFlight.add(token); let work; try { work = Promise.resolve(task()); } catch (error) { work = Promise.reject(error); } token.work = work; return work.finally(() => this._admissionsInFlight.delete(token)); } async drainAdmissionHandlers() { while (this._admissionsInFlight.size > 0) { await Promise.allSettled(Array.from(this._admissionsInFlight, token => token.work)); } return { pending: 0 }; } /** * Drain every operation that could still create a game or install a terminal solo result. * This must finish before GameManager.shutdown() snapshots/retries its settlement map: an * already-running block timeout pass is itself an admission producer and can otherwise resume * after an await and add a new settlement behind the drain. */ async drainShutdownProducers() { await this.drainAdmissionHandlers(); await this.paymentHandlers?.drainShutdownWork?.(); await this.gameModeManager?.drainGameStartAdmissions?.(); await this.gameManager?.drainAdmissions?.(); return { pending: 0 }; } /** * Persist every nonterminal solo run that currently exists only as a live Game object. * Movement is already frozen by beginShutdown(), and the caller drains terminal settlement * intents before invoking this method. One transaction prevents a partial restart boundary: * either every eligible run has a guarded snapshot or none of the new snapshots commit. */ async persistSoloRestartSnapshots() { const db = this.gameModeManager?.db; if (!db || typeof db.withTransaction !== 'function') { throw new Error('Solo restart snapshots require a transactional database handle'); } // Work that crossed the synchronous gate is allowed to reach a stable DB/in-memory state; // nothing new can enter behind it. Only then enumerate the complete snapshot set. await this.drainShutdownProducers(); const captures = new Map(); const addGame = (game, socketId, paymentMonitoringActive) => { if (!game) return; if (game.settlementPending || game.settlementCommitted || game.gameState === 'ended') { throw new Error(`Refusing to snapshot terminal solo game ${game.id || 'unknown'}`); } // A snapshot rehydrated earlier in this same process remains durable until its owner // claims it; it does not need (and must not receive) a second write on shutdown. const captured = captureSoloRestartSnapshot(game, { paymentMonitoringActive }); const previous = captures.get(captured.gameId); if (previous && previous.dungeonSeed !== captured.dungeonSeed) { throw new Error(`Conflicting restart snapshot identity for game row ${captured.gameId}`); } captures.set(captured.gameId, captured); }; for (const [socketId, game] of this.activeGames.entries()) { addGame(game, socketId, this.paymentHandlers?.hasActiveMonitoring?.(socketId) === true); } for (const suspended of this.suspendedGameManager?.suspendedGames?.values?.() || []) { if (suspended?.durableRestartSnapshot === true) continue; addGame(suspended?.game, suspended?.originalSocketId, suspended?.paymentMonitoringActive === true); } if (captures.size === 0) return { captured: 0 }; await db.withTransaction(async (client) => { for (const snapshot of captures.values()) { const result = await client.query(` INSERT INTO solo_restart_snapshots ( game_id, user_id, dungeon_seed, snapshot_version, original_socket_id, payment_monitoring_active, state, created_at ) SELECT g.id, g.user_id, g.dungeon_seed, $4, $5, $6, $7::jsonb, NOW() FROM games g WHERE g.id = $1 AND g.user_id = $2 AND g.dungeon_seed = $3 AND g.status = 'active' AND g.completed_at IS NULL ON CONFLICT (game_id) DO UPDATE SET snapshot_version = EXCLUDED.snapshot_version, original_socket_id = EXCLUDED.original_socket_id, payment_monitoring_active = EXCLUDED.payment_monitoring_active, state = EXCLUDED.state, created_at = EXCLUDED.created_at WHERE solo_restart_snapshots.user_id = EXCLUDED.user_id AND solo_restart_snapshots.dungeon_seed = EXCLUDED.dungeon_seed RETURNING game_id `, [ snapshot.gameId, snapshot.userId, snapshot.dungeonSeed, snapshot.snapshotVersion, snapshot.originalSocketId, snapshot.paymentMonitoringActive, JSON.stringify(snapshot.state) ]); if (result.rowCount !== 1) { throw new Error(`Active solo game ${snapshot.gameId} changed during restart snapshot`); } } }); return { captured: captures.size }; } /** Reconstruct durable graceful-restart snapshots before orphan recovery or admission. */ async rehydrateSoloRestartSnapshots() { const db = this.gameModeManager?.db; if (!db || typeof db.query !== 'function') { throw new Error('Solo restart rehydration requires a database handle'); } const result = await db.query(` SELECT s.game_id, s.user_id, s.dungeon_seed, s.snapshot_version, s.original_socket_id, s.payment_monitoring_active, s.state, s.created_at, g.id AS joined_game_id, g.user_id AS game_user_id, g.dungeon_seed AS game_dungeon_seed, g.status, g.completed_at, g.game_mode, g.start_block_height, g.proof_version, g.fairness_offer_id, g.fairness_offer_issued_at, g.proof_commitment, g.server_seed, g.client_seed, g.effective_seed, g.layout_fingerprint, g.layout_fingerprints, g.generator_version, g.proof_context FROM solo_restart_snapshots s LEFT JOIN games g ON g.id = s.game_id AND g.user_id = s.user_id AND g.dungeon_seed = s.dungeon_seed ORDER BY s.game_id ASC `, []); let restored = 0; for (const row of result.rows || []) { const state = restoreSoloRestartSnapshot(row, { db }); if (this.suspendedGameManager.hasSuspendedGame(state.userId)) { throw new Error(`Duplicate suspended solo identity ${state.userId}`); } const accepted = this.suspendedGameManager.suspendGame( state.userId, state.originalSocketId, state.game, { paymentMonitoringActive: state.paymentMonitoringActive, durableRestartSnapshot: true, snapshotVersion: SOLO_RESTART_SNAPSHOT_VERSION, blockRec: state.game.blockRec } ); if (!accepted) throw new Error(`Failed to rehydrate solo game ${state.game.id}`); restored += 1; } return { restored }; } /** Dynamic financial-recovery gate used by readiness and paid admission. */ isFinancialRecoveryReady() { return this.matchQueue?.initialized === true && this.matchManager?.financialRecoveryReady === true; } /** * Register memory cleanup functions to prevent memory leaks */ _registerMemoryCleanups() { // Mempool-notification dedup + TTL eviction lives in PaymentHandlers, which owns the // dedup set. if (this.rateLimiter) { this.memoryManager.registerCleanup( 'rateLimiterCleanup', () => { this.rateLimiter.cleanup(); return 0; }, 60000 ); } } /** * Called after a successful player move (before update is emitted) via MovementManager postMoveHook. * Handles monster chasing logic and immediate game over if monster catches player. */ afterPlayerMove(socketId, game, moveResult) { // Check if player walked into monster (death by walking into monster) if (moveResult && moveResult.event === 'monster_caught') { const fakeSocket = { id: socketId }; this.handleGameOver(fakeSocket, game, 'lost', 'monster', 'You walked into the monster!', 0); return; } // If player escaped, handle via game manager if (moveResult && moveResult.event === 'escaped') { const fakeSocket = { id: socketId }; this.handleGameOver(fakeSocket, game, 'won', 'escaped', 'You escaped the dungeon!', 0); return; } // Player took the stairs down to a deeper level. The fresh level is generated in // movePlayer and rides out in the normal game_update, so don't end the game and don't // advance the new level's monster on the arrival turn. if (moveResult && moveResult.event === 'descend') { return; } let monsterResult = null; // Move monster one step toward player each player action if (game && typeof game.moveMonster === 'function') { try { monsterResult = game.moveMonster(); } catch (e) { console.error('Monster move error:', e); } } // If the monster caught the player after moving, end the game immediately if (monsterResult && monsterResult.event === 'monster_caught') { const fakeSocket = { id: socketId }; this.handleGameOver(fakeSocket, game, 'lost', 'monster', 'You were caught by the monster!', 0); return; } // Re-emit state if treasure collected without auto game_update (fallback) if (moveResult && moveResult.event === 'treasure_found') { this.movementManager.emitGameUpdate(socketId); } } _emitFairnessOffer(socket) { if (!socket || !this.fairnessOffers) return null; const offer = this.fairnessOffers.ensureOffer(socket.id); socket.emit('fairness_offer', offer); return offer; } /** * Atomically consume the precommit echoed by a start request. The replacement offer is * published immediately for the next attempt, but the returned private proof input remains * bound to the consumed commitment and is the only value passed into Game. */ _consumeFairnessAttempt(socket, opts = {}) { const result = this.fairnessOffers.consume(socket.id, { offerId: typeof opts.fairnessOfferId === 'string' ? opts.fairnessOfferId : null, clientSeed: typeof opts.clientSeed === 'string' ? opts.clientSeed : '' }); if (!result.success) { socket.emit('fairness_error', { code: result.code, message: result.reason }); this._emitFairnessOffer(socket); return null; } this._emitFairnessOffer(socket); return result.proofInput; } /** Refuse value-bearing actions unless the player echoed the current public disclosures. */ _requirePaidActionAcknowledgement(socket, raw, errorEvent = null) { return requirePaidActionAcknowledgement(this, socket, raw, errorEvent); } /** * Initialize socket event handlers for a new connection */ async handleConnection(socket) { if (this._isShuttingDown) { this._emitShutdownAdmissionRefusal(socket, 'connection'); socket.disconnect(true); return; } const connectionResult = await this.connectionHandler.handleConnection(socket); if (!connectionResult) return; // Connection was rejected or failed // A connection may have entered just before beginShutdown() and awaited its DB session. // Recheck before restoring state or registering any mutating event handlers. if (this._isShuttingDown) { this._emitShutdownAdmissionRefusal(socket, 'connection'); socket.disconnect(true); return; } // Publish the server commitment before the client can submit a client seed. register_client // re-emits this same offer (never swaps it) in case the browser attached listeners late. this._emitFairnessOffer(socket); const { sessionInfo } = connectionResult; const dbUserId = sessionInfo?.user?.id; // Check for suspended game to restore (must happen BEFORE queue restoration) let restoredGame = null; if (sessionInfo && sessionInfo.resumed && dbUserId && this.suspendedGameManager) { if (this.suspendedGameManager.hasSuspendedGame(dbUserId)) { const memUser = this.connectionHandler.getUserBySocket(socket.id); let restored; try { restored = await this.suspendedGameManager.restoreGame(dbUserId, socket.id, memUser); } catch (error) { // Never let a failed durable claim fall through to queue/new-game admission: // the database still owns an active run for this identity. A reconnect can // retry after the transient fault or an operator can resolve the snapshot. console.error(`[handleConnection] Suspended game restore failed for user ${dbUserId}:`, normalizeError(error).message); socket.emit('session_restore_failed', { code: 'ACTIVE_GAME_RESTORE_FAILED', message: 'Your active game could not be restored safely. Please reconnect shortly.' }); socket.disconnect(true); return; } if (restored) { restoredGame = restored.game; const suspendedState = restored.suspendedState; // Resume the block-timeout clock from the ORIGINAL entry block, not the // reconnect height. suspendedGameManager persists the entry block and hands // it back here; restoring it onto the in-memory user lets checkGamesTimeout // continue counting from where it left off. When it is missing, blockRec // stays unset and checkGamesTimeout records the first observed height // rather than treating the game as instant-death. const entryBlock = (restored.blockRec ?? suspendedState?.blockRec ?? null); if (memUser && entryBlock != null) { memUser.blockRec = entryBlock; } if (this.debugManager.CONSOLE_LOGGING) { console.log(`🔄 [handleConnection] Restored game ${restoredGame.id} for user ${dbUserId}`); } if (restoredGame.settlementPending) { // A terminal run is retained solely to retry its atomic DB completion. Do // not render it as playable after reconnect or reveal a second result. socket.emit('game_settlement_pending', { gameId: restoredGame.id, code: 'GAME_FINISH_NOT_DURABLE', retrying: true }); this.broadcastManager.sendStatusUpdate(socket.id, 'warning', 'Your finished game is being saved. A new game will be available when settlement completes.'); } else { // Re-emit the game state to the reconnecting client const gameState = restoredGame.getState(); gameState.restored = true; gameState.restoredMessage = 'Game restored! Continue playing.'; // Include provably fair commitment if (restoredGame.getProofCommitment) { gameState.proof = restoredGame.getProofCommitment(); } // Send game_start to resume the game on client socket.emit('game_start', gameState); // Also send a status message this.broadcastManager.sendStatusUpdate(socket.id, 'info', 'Welcome back! Your game has been restored.'); } // Restore payment monitoring if it was active if (suspendedState.paymentMonitoringActive && this.paymentHandlers) { this._restorePaymentMonitoring(socket, dbUserId); } } } } // Restore queue status if session resumed (only if no game was restored) if (!restoredGame && sessionInfo && sessionInfo.resumed && sessionInfo.user && this.queueManager) { const wasUpdated = this.queueManager.updateSocketId(sessionInfo.user.id, socket.id); if (wasUpdated) { const position = this.queueManager.getQueuePosition(socket.id); if (position !== -1) { this.broadcastManager.sendStatusUpdate(socket.id, 'queue', `Welcome back! You are still in the queue at position ${position}.`); socket.emit('queue_joined', { position: position, message: `Welcome back! You are still in queue.`, currentBlock: this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null, nextBlock: (this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : 0) + 1 }); // Check for pending payment and restore monitoring this._restorePaymentMonitoring(socket, dbUserId); } } } // Send game mode info to client if (this.gameModeManager) { socket.emit('game_mode_info', this.gameModeManager.getGameModeInfo()); } this._emitIdentity(socket).catch(err => { if (this.debugManager?.CONSOLE_LOGGING) console.warn('Failed to send identity snapshot:', err.message); }); // Send chat history to new user if (this.chatHandler && typeof this.chatHandler.sendChatHistoryToUser === 'function') { this.chatHandler.sendChatHistoryToUser(socket.id).catch(err => { console.error('Failed to send chat history:', err.message); }); } // Join the lobby room for game list broadcasts if (this.spectatorManager) { this.spectatorManager.joinLobby(socket.id); // Send initial game list const gameList = this.spectatorManager.getActiveGamesList({ page: 1, pageSize: 20 }); socket.emit('active_games', gameList); } // Register event handlers. Admission wrappers remain live for the life of this socket and // consult the dynamic shutdown flag, so existing connections cannot race the snapshot. const runAdmission = (kind, task) => this._runAdmission(socket, kind, task); // SAFE DISPATCH: every listener below is registered through `on`, never `socket.on`. // // Socket.IO invokes listeners without a try/catch, and this process treats // `uncaughtException` and `unhandledRejection` as fatal (see the handlers in src/index.js, // which call gracefulShutdown and exit). That combination makes ONE unguarded throw in ANY // listener an unauthenticated remote kill switch: a client sends a malformed, or entirely // absent, payload and the whole server exits, taking live games and payment monitoring // with it. Containing the fault to the offending socket is the invariant; individual // handlers hardening their own inputs is defence in depth, not a substitute. const on = (event, handler) => { socket.on(event, (...args) => { let result; try { result = handler(...args); } catch (err) { this._onHandlerFault(socket, event, err); return; } if (result && typeof result.then === 'function') { result.catch((err) => this._onHandlerFault(socket, event, err)); } }); }; on('chat message', (msg) => this.chatHandler.handleChatMessage(socket, msg, { // Legacy text command has no client contribution, but still consumes the already // published one-time server offer with an empty clientSeed. handleGameQueue: (commandSocket) => runAdmission('chat_game_queue', () => this.handleJoinQueue(commandSocket, {})), handleCancelEntry: (socket) => this.queueHandler.handleCancelEntry(socket), handleStatsRequest: (socket) => this.handleStatsRequest(socket) })); // Client-signed global chat event (posts under the player's own Smirk npub). on('chat_signed', (payload) => this.chatHandler.handleSignedChatMessage(socket, payload)); on('player_move', (moveData) => this.movementManager.handleMove(socket.id, moveData)); on('disconnect', () => this.handleDisconnect(socket)); on('debug_ping', (data) => this.handleDebugPing(socket, data)); on('register_client', async (data) => { this.connectionHandler.handleRegisterClient(socket, data); this._emitFairnessOffer(socket); // Re-send after the client registers its handlers: the connection-time emit can // arrive before those listeners exist. if (this.gameModeManager) { socket.emit('game_mode_info', this.gameModeManager.getGameModeInfo()); } // Re-sent for the same reason as game_mode_info above. if (this.sessionManager) { try { const sessionUser = await this.sessionManager.getBySocket(socket.id); if (sessionUser) { socket.emit('credits_update', { balance: sessionUser.credits || 0, totalCreditsPurchased: sessionUser.total_credits_purchased || 0, creditsPerGame: this.gameModeManager?.creditsPerGameCost || 1 }); } } catch (e) { /* credits refresh on the next action */ } } this._emitIdentity(socket).catch(err => { if (this.debugManager?.CONSOLE_LOGGING) console.warn('Failed to re-send identity snapshot:', err.message); }); }); on('auto_start', (data) => runAdmission('auto_start', () => this.handleAutoStart(socket, (data && typeof data === 'object') ? data : {}))); on('play_free', (data) => runAdmission('play_free', () => this.handleAutoStart(socket, { ...((data && typeof data === 'object') ? data : {}), free: true }))); // { free: true } selects Pleb-board free play. on('join_queue', (data) => runAdmission('join_queue', () => this.handleJoinQueue(socket, (data && typeof data === 'object') ? data : {}))); // Entry without waiting for the next block. on('early_entry', (data) => runAdmission('early_entry', () => this.handleEarlyEntry(socket, (data && typeof data === 'object') ? data : {}))); on('fairness_offer_request', () => this._emitFairnessOffer(socket)); on('address:prompt', () => this._withIdentityReadLimit(socket, () => this.handleAddressPrompt(socket))); // Payment system handlers on('request_payment', (data) => runAdmission('request_payment', () => this.handlePaymentRequest(socket, data))); on('check_payment_status', (data) => this.handleCheckPaymentStatus(socket, data)); on('get_user_credits', () => this.handleGetUserCredits(socket)); on('address:update', (data) => this.handleAddressUpdate(socket, data)); on('identity:get', () => this._withIdentityReadLimit(socket, () => this.handleIdentityGet(socket))); on('identity:update', (data) => this.handleIdentityUpdate(socket, data)); // Spectator handlers on('get_active_games', (options) => this.handleGetActiveGames(socket, options)); on('spectate_game', (data) => this.handleSpectateGame(socket, data)); on('leave_spectate', () => this.handleLeaveSpectate(socket)); // Tavern handlers (social hangout mode; refused server-side unless enabled) on('tavern_join', (data) => { Promise.resolve(this.tavernManager.join(socket, data)).catch(err => { console.error('Tavern join failed:', err.message); socket.emit('tavern_error', { message: 'Could not enter the tavern.' }); }); }); on('tavern_move', (data) => this.tavernManager.move(socket, data)); on('tavern_chat', (data) => this.tavernManager.chat(socket, data)); on('tavern_leave', () => this.tavernManager.leave(socket)); // Match mode handlers. Every match listener is wrapped so a match-layer fault can never // crash the connection or leak a stack to the client; it logs and emits a benign // 'match_error' instead. Identity is always resolved from the CONNECTION, never from the // client payload. on('match_queue', (data) => runAdmission('match_queue', () => this._handleMatchQueue(socket, data))); on('match_move', (data) => { try { this.matchManager.move(socket, data); } catch (err) { if (this.debugManager?.CONSOLE_LOGGING) console.error('[SocketHandlers] match_move error:', err.message); socket.emit('match_error', { message: 'Move could not be processed.' }); } }); on('match_leave', () => { try { this.matchManager.leave(socket); } catch (err) { if (this.debugManager?.CONSOLE_LOGGING) console.error('[SocketHandlers] match_leave error:', err.message); socket.emit('match_error', { message: 'Could not leave the match.' }); } }); on('match_reconnect', (data) => this._handleMatchReconnect(socket, data)); // Tavern match spectator bridge on('tavern_match_list', () => { const list = this.tavernMatchBridge ? this.tavernMatchBridge.getActiveMatches() : []; socket.emit('tavern_match_list', list); }); } async handlePaymentRequest(socket, data = {}) { if (this._isShuttingDown === true) { this._rejectAdmissionDuringShutdown?.(socket, 'request_payment'); return; } const request = (data && typeof data === 'object') ? data : {}; const rlId = stableId(socket, this.sessionManager); const rlIp = clientIp(socket); const rateLimitResult = await this.rateLimiter.checkLimit(rlId, 'payment:create', rlIp); if (!rateLimitResult.allowed) { socket.emit('payment_error', { error: 'Too many payment requests. Please wait and try again.', code: 'RATE_LIMITED', retryAfterMs: rateLimitResult.retryAfter }); return; } // Count every accepted attempt before any wallet/DB work so invalid product ids or // fairness payloads cannot be used to bypass the IP + stable-user bucket. await this.rateLimiter.recordAttempt(rlId, 'payment:create', rlIp); // Check before consuming a one-time fairness offer or touching wallet/DB state. if (!requirePaidActionAcknowledgement( this, socket, request.legalAcknowledgement, 'payment_error' )) return; const paymentType = request.type || request.gameMode || 'single_game'; if (typeof this.gameModeManager?.isPaymentIntakeEnabled === 'function' && !this.gameModeManager.isPaymentIntakeEnabled(paymentType)) { socket.emit('payment_error', { error: 'That paid product is not available on this server.', code: 'PAYMENT_INTAKE_DISABLED' }); return; } if (paymentType !== 'single_game') { return this.paymentHandlers.handlePaymentRequest(socket, request); } const fairnessProof = this._consumeFairnessAttempt(socket, request); if (!fairnessProof) return; return this.paymentHandlers.handlePaymentRequest(socket, { ...request, fairnessProof }); } /** * Handle immediate start button (auto_start) * Applies payment eligibility + payout address gating (for payout-eligible modes) * If eligible, starts game immediately (bypassing block queue) and processes game start (credit deduction / payment linkage) */ async handleAutoStart(socket, opts = {}) { if (this._isShuttingDown === true) { this._rejectAdmissionDuringShutdown?.(socket, 'solo_start'); return; } try { const wantsExplicitFree = opts.free === true && this.gameModeManager?.freePlayEnabled; if (!wantsExplicitFree && !requirePaidActionAcknowledgement(this, socket, opts.legalAcknowledgement)) return; const fairnessProof = this._consumeFairnessAttempt(socket, opts); if (!fairnessProof) return; let gameFairnessProof = fairnessProof; // The player explicitly chose FREE play (Pleb board, no payment/payout). Only // honoured when the instance allows free play; otherwise fall through to paid. const wantsFree = wantsExplicitFree; // Rate limiting for game starts, keyed on stable identity + IP so reconnecting // (new socket.id) can't reset the limit. const rlId = stableId(socket, this.sessionManager); const rlIp = clientIp(socket); const rateLimitResult = await this.rateLimiter.checkLimit(rlId, 'game:start', rlIp); if (!rateLimitResult.allowed) { this.broadcastManager.sendStatusUpdate(socket.id, 'warning', `Please wait ${Math.ceil(rateLimitResult.retryAfter / 1000)} seconds before starting another game. (${rateLimitResult.remaining} attempts remaining)`); return; } const existingGame = this.activeGames.get(socket.id); if (existingGame) { this.broadcastManager.sendStatusUpdate(socket.id, 'info', 'You are already in a game!'); return; } const memUser = this.connectionHandler.getUserBySocket(socket.id); if (!memUser) { this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Could not start game (user not found). Please reconnect.'); return; } let canStart = { allowed: true, reason: 'Free mode' }; // Free play skips all payment/credits/payout-address gating below. if (this.gameModeManager && !wantsFree) { try { canStart = await this.gameModeManager.canUserStartGame(socket.id); } catch (e) { console.error('Eligibility check failed:', e.message); this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Eligibility check failed.'); return; } if (!canStart.allowed) { // Trigger payment request automatically for any payment-related action if (canStart.action === 'make_payment' || canStart.action === 'purchase_credits' || canStart.action === 'choose_payment') { await this.paymentHandlers.createAndShowPaymentRequest(socket, { fairnessProof, legalAcknowledgement: opts.legalAcknowledgement }); return; } this.broadcastManager.sendStatusUpdate(socket.id, 'error', canStart.reason || 'Not allowed to start'); return; } // Address-gate the effective entry mode, not the instance's legacy/global mode. // This matters on mixed direct+credits servers and when the master payout switch // is off (no payout means no address should be required). const effectiveMode = canStart.effectiveMode || this.gameModeManager.gameMode; if (effectiveMode === 'PAID_SINGLE' && canStart.paymentId) { // A direct invoice is already bound to the offer that was published before // its address was shown. Never replace it with the fresh socket offer chosen // after payment/reconnect. gameFairnessProof = canStart.fairnessProof || null; if (this.gameModeManager._requiresPaidFairnessV2?.() && !gameFairnessProof) { this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'This paid entry has no durable fairness binding and requires support review.'); return; } } const needsAddress = typeof this.gameModeManager.requiresPayoutAddressForMode === 'function' ? this.gameModeManager.requiresPayoutAddressForMode(effectiveMode) : ((effectiveMode === 'PAID_SINGLE') || (effectiveMode === 'PAID_CREDITS' && this.gameModeManager.creditsPayoutEnabled)); if (needsAddress) { try { // Always do fresh DB lookup for payout address (cache may be stale after address update) const dbUser = await this.gameModeManager.getOrCreateUser(socket.id); if (!dbUser.payout_address) { this.broadcastManager.sendStatusUpdate(socket.id, 'payment', '⚠️ Use the "Manage Payout Address" button or paste your payout address here and type confirm. Payment request will appear automatically.'); return; } } catch (e) { console.error('Payout address pre-check failed:', e.message); this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Address check failed, try again.'); return; } } } // Record the game start attempt (same stable id + IP as the check above) await this.rateLimiter.recordAttempt(rlId, 'game:start', rlIp); // Create game immediately const blockHeight = this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null; memUser.blockRec = blockHeight; // keep legacy timeout logic consistent const game = await this.gameManager.createGameForUser(memUser, { fairnessProof: gameFairnessProof }); const state = game.getState(); state.blockHeight = blockHeight; // Include provably fair commitment if (game.getProofCommitment) { state.proof = game.getProofCommitment(); } // Process start (free / credits deduction / payment link) if (this.gameModeManager) { const startRes = await this.gameModeManager.processGameStart(socket.id, game.id, { forceFree: wantsFree }); if (!startRes.success) { // Abort game this.activeGames.delete(socket.id); if (game.dbId && this.gameModeManager?.db) { await this.gameModeManager.db.query(` UPDATE games SET status = 'expired', outcome = 'aborted', completed_at = NOW() WHERE id = $1 AND entry_consumed_at IS NULL AND status = 'active' `, [game.dbId]); } this.broadcastManager.sendStatusUpdate(socket.id, 'error', startRes.reason || 'Failed to start game.'); return; } // Emit credits_update if credits were spent if (startRes.creditsRemaining !== undefined) { this.io.to(socket.id).emit('credits_update', { balance: startRes.creditsRemaining, totalCreditsPurchased: startRes.totalCreditsPurchased || 0 }); } } this.io.to(socket.id).emit('game_start', state); this.broadcastManager.sendStatusUpdate(socket.id, 'success', 'Game started! Escape before the next block!'); } catch (err) { console.error('handleAutoStart error:', err); this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Unexpected error starting game.'); } } /** * Handle join_queue request: adds the player to the block queue instead of starting immediately */ async handleJoinQueue(socket, opts = {}) { if (this._isShuttingDown === true) { this._rejectAdmissionDuringShutdown?.(socket, 'solo_queue'); return; } try { const rlId = stableId(socket, this.sessionManager); const rlIp = clientIp(socket); const rateLimitResult = await this.rateLimiter.checkLimit(rlId, 'game:queue', rlIp); if (!rateLimitResult.allowed) { socket.emit('queue_error', { message: 'Too many queue attempts. Please wait and try again.', code: 'RATE_LIMITED', retryAfterMs: rateLimitResult.retryAfter }); return; } await this.rateLimiter.recordAttempt(rlId, 'game:queue', rlIp); const wantsExplicitFree = opts.free === true && this.gameModeManager?.freePlayEnabled; if (!wantsExplicitFree && !requirePaidActionAcknowledgement(this, socket, opts.legalAcknowledgement)) return; const fairnessProof = this._consumeFairnessAttempt(socket, opts); if (!fairnessProof) return; await this.queueHandler.handleGameQueue( socket, this.connectionHandler.getUserBySocket.bind(this.connectionHandler), { ...opts, fairnessProof } ); // queueHandler already emits queue status and adds to queue // Client receives queue_joined event from broadcastManager const pos = this.queueManager.getPlayerIndex(socket.id); if (pos !== -1) { const currentBlock = this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : 0; socket.emit('queue_joined', { position: pos + 1, currentBlock: currentBlock, nextBlock: currentBlock + 1 }); } } catch (err) { console.error('handleJoinQueue error:', err); this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Failed to join queue.'); } } /** * Handle early entry request - start game immediately without waiting for block * Only available for free mode and credits mode (not direct payment mode) */ async handleEarlyEntry(socket, opts = {}) { if (this._isShuttingDown === true) { this._rejectAdmissionDuringShutdown?.(socket, 'early_entry'); return; } try { // Early entry CREATES A GAME and may consume a credit, so it carries the same // reconnect-proof admission budget as auto_start / join_queue / request_payment. // The downstream limiter keys on the raw, volatile socket.id with no IP bucket, so // it alone would let a reconnect reset the allowance. const rlId = stableId(socket, this.sessionManager); const rlIp = clientIp(socket); const rateLimitResult = await this.rateLimiter.checkLimit(rlId, 'game:queue', rlIp); if (!rateLimitResult.allowed) { this.broadcastManager.sendStatusUpdate(socket.id, 'warning', `Slow down; try entering again in ${Math.ceil(rateLimitResult.retryAfter / 1000)}s.`); return; } await this.rateLimiter.recordAttempt(rlId, 'game:queue', rlIp); // Early entry is free only on a free-only instance. Mixed/credits entry may consume a // credit, so treat ambiguous mixed-mode requests as paid and fail closed. if (this.gameModeManager?.gameMode !== 'FREE' && !requirePaidActionAcknowledgement(this, socket, opts.legalAcknowledgement, 'early_entry_error')) return; const fairnessProof = this._consumeFairnessAttempt(socket, opts); if (!fairnessProof) return; const result = await this.queueHandler.handleEarlyEntry( socket, this.connectionHandler.getUserBySocket.bind(this.connectionHandler), { fairnessProof } ); if (result.success) { if (this.debugManager.CONSOLE_LOGGING) { console.log(`⚡ Early entry successful for ${socket.id}`); } } } catch (err) { console.error('handleEarlyEntry error:', err); this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Unexpected error with early entry.'); } } async handleAddressPrompt(socket) { try { if (this.chatHandler && typeof this.chatHandler.promptAddress === 'function') { await this.chatHandler.promptAddress(socket); return; } let existingAddress = null; if (this.gameModeManager) { try { const userRow = await this.gameModeManager.getOrCreateUser(socket.id); existingAddress = userRow?.payout_address || null; } catch (err) { if (this.debugManager?.CONSOLE_LOGGING) { console.warn('Address prompt lookup failed:', err.message); } } } this.io.to(socket.id).emit('address_prompt', { existingAddress, message: existingAddress ? 'Update your payout address anytime.' : 'Add a payout address to receive rewards.' }); } catch (error) { const normalized = normalizeError(error, 'Unable to open address manager'); this.broadcastManager.sendStatusUpdate(socket.id, 'error', normalized.safeMessage || 'Address manager unavailable.'); } } async handleAddressUpdate(socket, data) { try { const address = typeof data?.address === 'string' ? data.address.trim() : ''; if (!address) { this.broadcastManager.sendStatusUpdate(socket.id, 'warning', 'Please enter a payout address before saving.'); this.io.to(socket.id).emit('address_update_error', { message: 'Please enter a payout address before saving.' }); return; } const rlId = stableId(socket, this.sessionManager); const rlIp = clientIp(socket); const rateLimitResult = await this.rateLimiter.checkLimit(rlId, 'address:set', rlIp); if (!rateLimitResult.allowed) { this.broadcastManager.sendStatusUpdate(socket.id, 'warning', `Address changes are rate limited. Try again in ${Math.ceil(rateLimitResult.retryAfter / 1000)} seconds.`); this.io.to(socket.id).emit('address_update_error', { message: 'Address changes are temporarily rate limited.' }); return; } await this.rateLimiter.recordAttempt(rlId, 'address:set', rlIp); await this.addressManager.saveAddress(socket.id, address); } catch (err) { const normalized = normalizeError?.(err, 'Failed to update payout address') || err; this.broadcastManager.sendStatusUpdate(socket.id, 'error', normalized.safeMessage || 'Failed to update payout address.'); this.io.to(socket.id).emit('address_update_error', { message: normalized.safeMessage || 'Failed to update payout address.' }); } } /** * Resolve the stable match session/identity for a connection. Identity is derived ONLY * from the CONNECTION (the session cache, then a DB lookup by socket), never from any * client-supplied payload, so a client can't act on another user's behalf. Mirrors how * other handlers resolve identity via sessionManager. * @returns {Promise<{userId:number, socketId:string, sessionToken:(string|null), user:Object}|null>} */ async _resolveMatchSession(socket) { if (!this.sessionManager || !socket) return null; let sessionUser = null; try { sessionUser = this.sessionManager.sessions?.get(socket.id) || null; } catch (_) { sessionUser = null; } if (!sessionUser) { try { sessionUser = await this.sessionManager.getBySocket(socket.id); } catch (_) { sessionUser = null; } } if (!sessionUser || sessionUser.id == null) return null; return { userId: sessionUser.id, socketId: socket.id, sessionToken: sessionUser.anon_token || null, user: sessionUser }; } /** * Handle a match-mode queue join/leave. The economy comes from the payload but the * IDENTITY is resolved from the connection, never from data. Never throws: any fault * is logged and reported to the client via 'match_error'. If match mode is disabled / * the queue is absent, respond benignly and return. * @param {Object} socket * @param {{action?:('join'|'leave'), economy?:string}} data */ async _handleMatchQueue(socket, data = {}) { try { // Match disabled or queue not wired → benign no-op. if (!this.matchQueue || (typeof this.matchQueue.isEnabled === 'function' && !this.matchQueue.isEnabled())) { return; } const action = (data && data.action === 'leave') ? 'leave' : 'join'; if (action === 'join' && this._isShuttingDown === true) { this._rejectAdmissionDuringShutdown?.(socket, 'match_queue'); return; } if (action === 'join' && this.activeGames?.has(socket.id)) { const soloGame = this.activeGames.get(socket.id); socket.emit('match_error', { message: soloGame?.settlementPending ? 'Your finished solo game is still being saved. Wait for settlement before joining a match.' : 'Finish your solo game before joining a match.', code: soloGame?.settlementPending ? 'SOLO_SETTLEMENT_PENDING' : 'SOLO_GAME_ACTIVE' }); return; } // A leave is never throttled: users must always be able to release queued escrow. // Joins share the reconnect-proof solo/match queue bucket to bound DB churn. if (action === 'join') { const rlId = stableId(socket, this.sessionManager); const rlIp = clientIp(socket); const rateLimitResult = await this.rateLimiter.checkLimit(rlId, 'game:queue', rlIp); if (!rateLimitResult.allowed) { socket.emit('match_error', { message: 'Too many queue attempts. Please wait and try again.', code: 'RATE_LIMITED', retryAfterMs: rateLimitResult.retryAfter }); return; } await this.rateLimiter.recordAttempt(rlId, 'game:queue', rlIp); } const session = await this._resolveMatchSession(socket); if (!session) { socket.emit('match_error', { message: 'Please reconnect before joining a match.' }); return; } const economy = (data && typeof data.economy === 'string') ? data.economy : undefined; // Carry economy on the session too, so a leave(session) that keys on economy and // an enqueue(session,{economy}) that keys on the explicit arg both stay correct. const matchSession = { ...session, economy }; if (action === 'leave') { const result = await this.matchQueue.leave(matchSession); socket.emit('match_queue_left', { ...(result || { success: false }), economy }); } else { if ((economy === 'credits_prestige' || economy === 'crypto_race') && !requirePaidActionAcknowledgement(this, socket, data.legalAcknowledgement, 'match_error')) return; const result = await this.matchQueue.enqueue(matchSession, { economy }); socket.emit('match_queue_joined', { ...(result || { success: false }), economy }); } } catch (err) { if (this.debugManager?.CONSOLE_LOGGING) console.error('[SocketHandlers] _handleMatchQueue error:', err.message); socket.emit('match_error', { message: 'Could not update the match queue. Please try again.' }); } } /** * Handle a match-mode reconnect. Resolves identity from the connection and hands the * live socket + session to the match manager. Same never-throw discipline as the other * match listeners; benign no-op when match mode is disabled. */ async _handleMatchReconnect(socket, data = {}) { try { if (!this.matchManager || this.matchManager.enabled === false) { return; } const session = await this._resolveMatchSession(socket); if (!session) return; await this.matchManager.handleReconnect(socket, session); } catch (err) { if (this.debugManager?.CONSOLE_LOGGING) console.error('[SocketHandlers] match_reconnect error:', err.message); socket.emit('match_error', { message: 'Could not rejoin the match.' }); } } /** Return the active socket instance by id (io.sockets.sockets Map) */ _getLiveSocket(socketId) { try { return this.io.sockets.sockets.get(socketId) || null; } catch(_) { return null; } } /** * Handle game entry request (delegates to the queue) */ handleGameEntry(socket) { return this.handleGameQueue(socket); } /** * Handle game queue request (delegates to QueueHandler) */ async handleGameQueue(socket) { return this.queueHandler.handleGameQueue(socket, (socketId) => this.connectionHandler.getUserBySocket(socketId)); } /** * Handle queue cancellation (delegates to QueueHandler) */ handleCancelEntry(socket) { return this.queueHandler.handleCancelEntry(socket); } /** * Handle stats request */ async handleStatsRequest(socket) { try { const stats = { rateLimiter: this.rateLimiter.getStats(), memoryManager: this.memoryManager.getStats(), connections: this.connectionHandler.getStats(), chat: this.chatHandler.getStats(), activeGames: this.activeGames.size, queueLength: this.queueManager.getQueueLength() }; this.broadcastManager.sendStatusUpdate(socket.id, 'info', `Server Stats: ${stats.activeGames} active games, ${stats.queueLength} in queue, ${stats.connections.clientSocketMappings} connections`); } catch (error) { console.error('handleStatsRequest error:', error); this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Failed to get stats.'); } } /** * Handle game over scenarios (delegates to GameManager) */ async handleGameOver(socket, game, status, reason, message, score = 0) { return this.gameManager.handleGameOver(socket, game, status, reason, message, score); } /** * Handle client disconnection with proper cleanup * Preserves game state for reconnection when user has a valid session */ handleDisconnect(socket) { if (this.fairnessOffers) this.fairnessOffers.discardSocket(socket.id); if (this.paymentHandlers?.clearPendingCommerceAcknowledgement) { this.paymentHandlers.clearPendingCommerceAcknowledgement(socket.id); } if (this.paymentHandlers?.pendingEntryFairness) { this.paymentHandlers.pendingEntryFairness.delete(socket.id); } // Use connection handler for main disconnect logic this.connectionHandler.handleDisconnect(socket, async (socket) => { const socketId = socket.id; const activeGame = this.activeGames.get(socketId); // Resolve the stable DB user id that suspend/restore keys on. Preference order: // 1. the id stamped on the game at creation (always present for any game // started with a session; no lookup, can't fail), // 2. the in-memory session cache, // 3. a DB lookup as a last resort. // Every game, paid or free, is preserved and reconnectable as long as a stable // id resolves, which it does whenever a session was ever established. let dbUserId = activeGame?.dbUserId || null; if (!dbUserId && this.sessionManager) { try { dbUserId = this.sessionManager.sessions?.get(socketId)?.id || null; } catch (_) {} if (!dbUserId) { try { const u = await this.sessionManager.getBySocket(socketId); dbUserId = u?.id || null; } catch (_) {} } } // A retry can commit while the async identity lookup above is in flight. Re-check the // retained object immediately before suspension so a completed terminal game cannot // be resurrected as a reconnectable/"playable" game. if (activeGame?.settlementCommitted) { this.activeGames.delete(socketId); } else if (activeGame && dbUserId && this.suspendedGameManager) { // Suspend the game so it can be restored on reconnect (keyed by dbUserId). const suspended = this.suspendedGameManager.suspendGame(dbUserId, socketId, activeGame, { paymentMonitoringActive: this.paymentHandlers?.hasActiveMonitoring?.(socketId) || false }); this.activeGames.delete(socketId); if (suspended) { if (this.debugManager.CONSOLE_LOGGING) { console.log(`[SocketHandlers] Game suspended for user ${dbUserId} (socket: ${socketId})`); } } else { console.error(`[SocketHandlers] suspendGame returned false for user ${dbUserId} (socket ${socketId}); game ${activeGame.id || 'unknown'} not restorable.`); } } else { if (activeGame) { // Reachable only when no stable identity resolved at all, i.e. a session // was never established (e.g. DB unavailable at connect), so there is // genuinely no id to restore the game under. Record it for auditing. console.error(`[SocketHandlers] Active game dropped without suspension, no stable user id ` + `(socket=${socketId}, mode=${activeGame.paymentMode || activeGame.gameMode || 'unknown'}, ` + `gameId=${activeGame.id || 'unknown'}). Cannot restore a game without a session identity.`); } this.activeGames.delete(socketId); } // Release anyone watching this run. Suspension and the no-identity drop above both // remove the game from activeGames without going through the settlement publisher that // normally calls this, so without it those spectators are stranded in a dead room. if (activeGame?.id && this.spectatorManager?.notifyGameEnded) { try { this.spectatorManager.notifyGameEnded(activeGame.id, { status: 'interrupted', message: 'The player disconnected.' }, 'player_disconnected'); } catch (err) { if (this.debugManager?.CONSOLE_LOGGING) { console.error('[SocketHandlers] spectator release failed:', err.message); } } } // Clean up payment monitoring (but remember it was active for restoration) if (this.paymentHandlers && typeof this.paymentHandlers.stopMonitoringForSocket === 'function') { this.paymentHandlers.stopMonitoringForSocket(socketId); } // NOTE: Do NOT call walletService.cleanupUserPayments() here. // addressToUser entries must persist so checkPaymentStatus() can query the wallet // RPC after session resumption. They get cleaned up when payments are confirmed or expired. // Preserve queue entry for users with session/payment // The queue entry's socketId will be updated on reconnection if (this.queueManager && typeof this.queueManager.removePlayer === 'function') { if (this.queueManager.isValuableEntry && !this.queueManager.isValuableEntry(socketId)) { this.queueManager.removePlayer(socketId); } else if (this.debugManager.CONSOLE_LOGGING && this.queueManager.isValuableEntry && this.queueManager.isValuableEntry(socketId)) { console.log(`[SocketHandlers] Preserving queue entry for ${socketId} (user has session/payment)`); } else if (!this.queueManager.isValuableEntry) { // Fallback for safety if method not found this.queueManager.removePlayer(socketId); } } // Clear any pending address confirmations if (this.chatHandler) { this.chatHandler.clearAddressConfirmation(socketId); } // Clean up spectator state if (this.spectatorManager) { this.spectatorManager.handleDisconnect(socketId); } // Per-socket bookkeeping keyed on the VOLATILE socket id. Nothing else ever removes // these, so without an explicit disconnect sweep each one accrues a permanent entry per // socket that ever moved / ever triggered address detection. if (this.movementManager?.forgetSocket) this.movementManager.forgetSocket(socketId); if (this.addressManager?.forgetSocket) this.addressManager.forgetSocket(socketId); // Clean up tavern presence if (this.tavernManager) { this.tavernManager.handleDisconnect(socketId); } // Clean up match presence (starts the AFK grace timer). Takes the live socket. // Guarded so a match-layer fault can't abort disconnect cleanup. if (this.matchManager) { try { this.matchManager.handleDisconnect(socket); } catch (err) { if (this.debugManager?.CONSOLE_LOGGING) console.error('[SocketHandlers] match handleDisconnect error:', err.message); } } // Evict the cached session row LAST (after the suspend logic above read it), // so the sessions map doesn't grow unbounded with every socket ever seen. if (this.sessionManager?.removeSocket) { this.sessionManager.removeSocket(socketId); } }); } async _emitIdentity(socket, extra = {}) { if (!this.identityService || !socket) return null; const snapshot = await this.identityService.identityForSocket(socket); socket.emit('identity_update', { ...snapshot, ...extra }); return snapshot; } async _entitlementsForSocket(socket) { if (!this.identityService || !socket) return { premium: false, level: 'free', packs: {}, totalCreditsPurchased: 0 }; return this.identityService.entitlementsForSocket(socket); } async handleIdentityGet(socket) { try { await this._emitIdentity(socket); } catch (err) { socket.emit('identity_error', { message: 'Could not load character identity.' }); } } async handleIdentityUpdate(socket, data = {}) { try { const rlId = stableId(socket, this.sessionManager); const rlIp = clientIp(socket); const rateLimitResult = await this.rateLimiter.checkLimit(rlId, 'identity:update', rlIp); if (!rateLimitResult.allowed) { socket.emit('identity_error', { message: 'Character changes are temporarily rate limited.' }); return; } await this.rateLimiter.recordAttempt(rlId, 'identity:update', rlIp); const input = data && data.appearance ? data.appearance : data; const snapshot = await this.identityService.saveAppearanceForSocket(socket, input); socket.emit('identity_update', { ...snapshot, saved: true }); } catch (err) { const normalized = normalizeError(err, 'Failed to update character identity'); socket.emit('identity_error', { message: normalized.safeMessage || 'Could not save character identity.' }); } } /** * Contain a socket-listener fault to the socket that caused it. * * Never rethrows and never touches process state: the entire point is that a malformed client * payload must not reach the fatal `uncaughtException` / `unhandledRejection` handlers. */ _onHandlerFault(socket, event, err) { console.error(`❌ Socket handler '${event}' faulted:`, (err && err.message) || err); if (err && err.stack && this.debugManager?.CONSOLE_LOGGING) console.error(err.stack); try { // Deliberately generic: a client must never learn where in the server it broke. socket.emit('error_message', { message: 'That request could not be processed.' }); } catch (_) { /* socket already gone */ } } /** * Gate a cheap-looking read that actually hits the database. * * `identity:get` and `address:prompt` each run getOrCreateUser (a users SELECT plus an * UPDATE ... SET last_active) and an entitlements SELECT, so without a limit a single socket * can pipeline them at packet rate. Uses the reconnect-proof stable id + IP buckets, so * dropping and re-opening the socket does not reset the budget. */ async _withIdentityReadLimit(socket, task) { const rlId = stableId(socket, this.sessionManager); const rlIp = clientIp(socket); const verdict = await this.rateLimiter.checkLimit(rlId, 'identity:read', rlIp); if (!verdict.allowed) return; await this.rateLimiter.recordAttempt(rlId, 'identity:read', rlIp); return task(); } /** * Handle debug ping. * * `data` is fully attacker-controlled and may be absent or a non-object: `socket.emit('debug_ping')` * sends no argument at all, and `emit('debug_ping', null)` defeats a default parameter. */ handleDebugPing(socket, data) { if (this.debugManager.CONSOLE_LOGGING) { console.log(`Debug ping received from ${socket.client.id}`); } const clientTime = (data && typeof data === 'object' && data.time != null) ? data.time : null; socket.emit('debug_pong', { message: "Hello from server!", clientTime, serverTime: Date.now(), socketId: socket.client.id }); } /** * Restore payment monitoring for a reconnecting user. * Queries DB for pending payments and restarts monitoring if found. * @param {Object} socket - The socket connection * @param {number} dbUserId - The DB user ID */ async _restorePaymentMonitoring(socket, dbUserId) { if (!dbUserId || !this.paymentHandlers || !this.gameModeManager) { return; } try { // Query for pending (unpaid) payment requests for this user const pendingResult = await this.gameModeManager.db.query(` SELECT id, subaddress, expected_amount, payment_type, description, expires_at, address_index, fairness_proof_version, fairness_offer_id, fairness_offer_issued_at, fairness_commitment, fairness_server_seed, fairness_client_seed, fairness_bound_at, fairness_consumed_at FROM payments WHERE user_id = $1 AND status = 'pending' AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1 `, [dbUserId]); if (pendingResult.rows.length === 0) { // No pending payment to restore if (this.debugManager.CONSOLE_LOGGING) { console.log(`[RestorePayment] No pending payment for user ${dbUserId}`); } return; } const payment = pendingResult.rows[0]; const fairnessProof = payment.payment_type === 'single_game' && typeof this.gameModeManager._paymentFairnessProofFromRow === 'function' ? this.gameModeManager._paymentFairnessProofFromRow(payment) : null; if (payment.payment_type === 'single_game' && typeof this.gameModeManager._requiresPaidFairnessV2 === 'function' && this.gameModeManager._requiresPaidFairnessV2() && (!fairnessProof || payment.fairness_consumed_at != null)) { console.error(`[RestorePayment] Refusing to monitor unbound paid entry ${payment.id}`); this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'This paid entry has no durable fairness binding and requires support review.'); return; } const cryptoType = this.gameModeManager.cryptoType; const formattedAmount = this.gameModeManager.formatAtomicHuman ? this.gameModeManager.formatAtomicHuman(payment.expected_amount, 3) : payment.expected_amount; // Generate QR code let qrDataUrl = null; try { const { generatePaymentQR } = require('../payments/qrService'); qrDataUrl = await generatePaymentQR( payment.subaddress, payment.expected_amount, cryptoType, payment.description || 'Restored payment', this.gameModeManager.currencyDecimals ); } catch (qrErr) { console.warn('QR generation failed during restore:', qrErr.message); } // Re-emit payment_created to the client socket.emit('payment_created', { paymentId: payment.id, address: payment.subaddress, amount: payment.expected_amount, amountFormatted: formattedAmount, humanAmount: formattedAmount, paymentType: payment.payment_type, cryptoType: cryptoType, description: payment.description, expiresAt: payment.expires_at, qr: qrDataUrl, restored: true, fairness: fairnessProof ? { proofVersion: fairnessProof.proofVersion, offerId: fairnessProof.offerId, offerIssuedAt: fairnessProof.offerIssuedAt, commitment: fairnessProof.commitment, clientSeed: fairnessProof.clientSeed } : null }); // Get current user object for queue operations const currentUser = this.connectionHandler.getUserBySocket(socket.id) || { serverId: socket.id }; // Create a mock paymentRequest object for _monitorAddress const paymentRequest = { id: payment.id, address: payment.subaddress, amount: payment.expected_amount, amountFormatted: formattedAmount, package: null, fairnessProof }; // Re-register address in wallet service so checkPaymentStatus() can query the wallet RPC. // This is essential after server restart or if the entry was lost during disconnect. if (this.walletService && payment.subaddress) { this.walletService.addressToUser.set(payment.subaddress, { userId: String(dbUserId), socketId: socket.id, amount: money.toSafe(money.toBig(payment.expected_amount)), addressIndex: payment.address_index ?? 0, accountIndex: 0, detected: false, confirmed: false, status: 'pending' }); } // Clear any monitoring already attached to this socket before restarting it. this.paymentHandlers.stopMonitoringForSocket(socket.id); if (typeof this.paymentHandlers._monitorAddress === 'function') { this.paymentHandlers._monitorAddress( socket, paymentRequest, payment.expected_amount, cryptoType, currentUser, payment.payment_type, fairnessProof ); } // Send status update this.broadcastManager.sendStatusUpdate( socket.id, 'payment', `🔁 Restored pending payment request.\n\nAmount: ${formattedAmount} ${cryptoType}\nAddress: ${payment.subaddress}` ); if (this.debugManager.CONSOLE_LOGGING) { console.log(`💳 [RestorePayment] Restored monitoring for user ${dbUserId}, payment ${payment.id}`); } } catch (error) { console.error(`[RestorePayment] Error restoring payment for user ${dbUserId}:`, error.message); } } // Helper methods /** * Get user by socket ID with fallback mapping (delegates to connection handler) */ getUserBySocket(socketId) { return this.connectionHandler.getUserBySocket(socketId); } // Helper methods (delegated to other managers) /** * Create a new game for a user (delegates to GameManager) */ createGameForUser(user, options = {}) { return this.gameManager.createGameForUser(user, options); } /** * Check if monster caught player (delegates to GameManager) */ checkMonsterKill(player, monster) { return this.gameManager.checkMonsterKill(player, monster); } /** * Log game update debug information (delegates to GameManager) */ logGameUpdate(socketId, gameState) { return this.gameManager.logGameUpdate(socketId, gameState); } // Queue management methods (delegates to QueueHandler) /** * Start games for waiting players when a new block is found */ async startGamesForWaiting(blockHeight) { if (this._isShuttingDown) return null; return this._runAdmission(null, 'block_queue_start', async () => { // Start queued single-player games first and capture the result to return. const result = await this.queueHandler.startGamesForWaiting(blockHeight); // Keep scheduler work inside the tracked admission boundary. A match-layer fault still // cannot fail solo processing, but shutdown now knows when the pre-barrier block tick // has reached a stable state. if (this.matchScheduler) { await this.matchScheduler.onBlock(blockHeight).catch(err => { if (this.debugManager.CONSOLE_LOGGING) console.error('[SocketHandlers] Match scheduler block error:', err); }); } return result; }); } /** * Check for game timeouts based on block height */ async checkGamesTimeout(currentHeight) { if (this._isShuttingDown) return; return this._runAdmission(null, 'block_game_timeout', async () => { // Snapshot the entries first: handleGameOver mutates activeGames (it deletes the // entry), so iterating the live Map could skip games. Process sequentially and // await each game-over rather than firing them all off concurrently on one tick. // Anti-instant-death guard ONLY (default 2s, set 0 to disable). Random block timing // is the game's core mechanic and is deliberately preserved; this is not a fairness // floor. It only avoids the degenerate case where a block lands in the same instant // the game starts and the player dies before the dungeon even renders / before their // first move is possible (100ms move cooldown). const graceMs = (() => { const value = parseInt(process.env.GAME_START_GRACE_MS, 10); return Number.isFinite(value) ? value : 2000; })(); const now = Date.now(); const snapshot = Array.from(this.activeGames.entries()); for (const [socketId, game] of snapshot) { // A pass can have crossed the admission gate before shutdown and then yielded // while settling an earlier game. Never let it install a later terminal intent // after the shutdown producer drain has begun. if (this._isShuttingDown) break; // The snapshot can go stale while awaiting a previous game-over. If this game // was concurrently won or otherwise ended (removed from activeGames on another // async path), never re-end it here as a loss/timeout. if (!this.activeGames.has(socketId)) continue; // Terminal games deliberately remain mapped until their DB transaction succeeds. // Never race that frozen result with a later block-timeout loss. if (game?.settlementPending || game?.settlementCommitted || game?.gameState === 'ended') continue; const user = this.connectionHandler.getUserBySocket(socketId); if (!user) continue; // Only end when the entry block is DEFINED and a later block has arrived. With no // entry block recorded for this player, the current height becomes the first // observation, so a missing value means neither immortal (never times out) nor // instant-death (times out on the first tick). if (user.blockRec == null) { user.blockRec = currentHeight; continue; } const elapsedMs = now - (game.startedAt || now); if (currentHeight > user.blockRec && elapsedMs >= graceMs) { // Keep this check immediately adjacent to terminal mutation. The synchronous // beginShutdown flag is the authoritative cut between snapshot/restart work. if (this._isShuttingDown) break; if (this.debugManager.CONSOLE_LOGGING) { console.log(`💀 GAME TIMEOUT for player ${socketId}: entered on block ${user.blockRec}, died on block ${currentHeight}`); } game.gameState = 'lost'; const fakeSocket = { id: socketId }; try { await this.handleGameOver(fakeSocket, game, 'lost', 'timeout', 'You didn\'t escape before the block time limit!'); } catch (e) { console.error(`checkGamesTimeout: handleGameOver failed for ${socketId}:`, e.message); } } } }); } // Payment system handlers (delegated to PaymentHandlers when present) async handleCheckPaymentStatus(socket, data) { if (this.paymentHandlers && typeof this.paymentHandlers.handleCheckPaymentStatus === 'function') { return this.paymentHandlers.handleCheckPaymentStatus(socket, data); } } async handleGetUserCredits(socket) { if (this.paymentHandlers && typeof this.paymentHandlers.handleGetUserCredits === 'function') { return this.paymentHandlers.handleGetUserCredits(socket); } } // ====== SPECTATOR HANDLERS ====== /** * Handle request for active games list */ handleGetActiveGames(socket, options = {}) { try { const gameList = this.spectatorManager.getActiveGamesList(options); socket.emit('active_games', gameList); } catch (err) { console.error('handleGetActiveGames error:', err); socket.emit('error', { message: 'Failed to get games list' }); } } /** * Handle request to spectate a specific game */ handleSpectateGame(socket, data) { try { if (!data || !data.gameId) { socket.emit('spectate_error', { message: 'Game ID is required' }); return; } // Check if user is currently in a game (can't spectate while playing) if (this.activeGames.has(socket.id)) { socket.emit('spectate_error', { message: 'Cannot spectate while in a game' }); return; } // Leave lobby when starting to spectate this.spectatorManager.leaveLobby(socket.id); const result = this.spectatorManager.addSpectator(socket.id, data.gameId); if (result.success) { socket.emit('spectate_start', { gameId: result.gameId, playerId: result.playerSocketId?.substring(0, 6), initialState: result.initialState, spectatorCount: result.spectatorCount }); this.broadcastManager.sendStatusUpdate(socket.id, 'info', `👁️ Now spectating game by player ${result.playerSocketId?.substring(0, 6)}`); } else { socket.emit('spectate_error', { message: result.reason }); // Rejoin lobby if spectating failed this.spectatorManager.joinLobby(socket.id); } } catch (err) { console.error('handleSpectateGame error:', err); socket.emit('spectate_error', { message: 'Failed to join game as spectator' }); } } /** * Handle request to leave spectating */ handleLeaveSpectate(socket) { try { const wasSpectating = this.spectatorManager.removeSpectator(socket.id); if (wasSpectating) { socket.emit('spectate_ended', { reason: 'user_left' }); this.broadcastManager.sendStatusUpdate(socket.id, 'info', '👁️ Left spectator mode'); } // Rejoin lobby this.spectatorManager.joinLobby(socket.id); // Send updated games list const gameList = this.spectatorManager.getActiveGamesList({ page: 1, pageSize: 20 }); socket.emit('active_games', gameList); } catch (err) { console.error('handleLeaveSpectate error:', err); } } /** * Get comprehensive statistics */ getStats() { return { activeGames: this.activeGames.size, rateLimiter: this.rateLimiter.getStats(), memoryManager: this.memoryManager.getStats(), connections: this.connectionHandler.getStats(), chat: this.chatHandler.getStats(), games: this.gameManager.getStats(), queue: this.queueHandler.getStats(), spectators: this.spectatorManager ? this.spectatorManager.getStats() : null, tavern: this.tavernManager ? this.tavernManager.getStats() : null }; } /** * Shutdown method to clean up all resources and prevent memory leaks */ async shutdown() { this.beginShutdown(); if (this.debugManager.CONSOLE_LOGGING) { console.log('🛑 SocketHandlers shutting down...'); } // Let work that crossed the synchronous admission boundary reach a stable state before // freezing the settlement set. This includes a block-timeout pass paused on an earlier // game's durable completion. await this.drainShutdownProducers(); // Settle terminal solo runs while the DB pool is still open. This is idempotent if the // top-level graceful-shutdown path already drained them. if (this.gameManager?.shutdown) { const soloDrain = await this.gameManager.shutdown(); if (soloDrain.pending > 0) { console.error(`[SocketHandlers] ${soloDrain.pending} solo settlement(s) remain pending at shutdown.`); } } // Shutdown components in reverse order of initialization if (this.tavernManager) { this.tavernManager.shutdown(); } if (this.matchScheduler) { await this.matchScheduler.shutdown(); } if (this.matchQueue) { await this.matchQueue.shutdown().catch(() => {}); } if (this.matchManager) { this.matchManager.shutdown(); } if (this.spectatorManager) { this.spectatorManager.shutdown(); } if (this.chatHandler) { this.chatHandler.shutdown(); } if (this.connectionHandler) { this.connectionHandler.shutdown(); } if (this.rateLimiter) { this.rateLimiter.shutdown(); } if (this.memoryManager) { // Force final cleanup before shutdown await this.memoryManager.forceCleanup(); this.memoryManager.shutdown(); } // Dispose components whose timers/maps would otherwise leak on shutdown. if (this.sessionManager?.dispose) this.sessionManager.dispose(); if (this.suspendedGameManager?.cleanup) this.suspendedGameManager.cleanup(); if (this.paymentHandlers?.dispose) this.paymentHandlers.dispose(); // Clear remaining data structures this.activeGames.clear(); if (this.debugManager.CONSOLE_LOGGING) { console.log('✅ SocketHandlers shutdown complete'); } } } module.exports = SocketHandlers;