diff --git a/TODO.md b/TODO.md index 4aaffb5..677b6c6 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,7 @@ - [x] **Payout Mode Bug**: Fixed `completeGame` using global `gameMode` instead of recorded `payment_mode` from game record ✅ - [x] **Duplicate Payout Prevention**: Added check for existing payout before processing to prevent double-payouts ✅ - [x] **Dead Code Cleanup**: Removed unused MoneroPayService (was never used, WalletRPCService is the actual service) ✅ +- [x] **Game Reconnection/Resume**: Added `SuspendedGameManager` to preserve and restore games when users disconnect and reconnect. Users in dungeon or awaiting payment can now resume seamlessly ✅ ## New Features (Just Added) - [x] **Chat History Persistence**: `ChatHistoryManager` stores messages in PostgreSQL with 30-day retention ✅ diff --git a/src/network/paymentHandlers.js b/src/network/paymentHandlers.js index 639ea54..8bf0247 100644 --- a/src/network/paymentHandlers.js +++ b/src/network/paymentHandlers.js @@ -368,6 +368,15 @@ class PaymentHandlers { this._expiryTimeouts.set(socket.id, expiryTimeout); } + /** + * Check if a socket has active payment monitoring + * @param {string} socketId - The socket ID to check + * @returns {boolean} True if monitoring is active + */ + hasActiveMonitoring(socketId) { + return this.socketPaymentMap.has(socketId); + } + /** * Stop monitoring (if any) for a given socket (disconnect/expiry) */ diff --git a/src/network/socketHandlers.js b/src/network/socketHandlers.js index a415d4a..9916375 100644 --- a/src/network/socketHandlers.js +++ b/src/network/socketHandlers.js @@ -15,6 +15,7 @@ const ConnectionHandler = require('./connectionHandler'); const ChatHandler = require('./chatHandler'); const QueueHandler = require('./queueHandler'); const SpectatorManager = require('./spectatorManager'); +const SuspendedGameManager = require('./suspendedGameManager'); const MemoryManager = require('../utils/memoryManager'); const { normalizeError } = require('../utils/errors'); @@ -164,6 +165,13 @@ class SocketHandlers { // Wire spectator manager to game manager (for game over notifications) this.gameManager.setSpectatorManager(this.spectatorManager); + // 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 + }); + // Movement manager abstraction with memory cleanup this.playerMoveTimestamps = new Map(); this.movementManager = new MovementManager({ @@ -343,9 +351,50 @@ class SocketHandlers { if (!connectionResult) return; // Connection was rejected or failed 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); + const restored = this.suspendedGameManager.restoreGame(dbUserId, socket.id, memUser); + + if (restored) { + restoredGame = restored.game; + const suspendedState = restored.suspendedState; + + if (this.debugManager.CONSOLE_LOGGING) { + console.log(`🔄 [handleConnection] Restored game ${restoredGame.id} for user ${dbUserId}`); + } + + // 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 - if (sessionInfo && sessionInfo.resumed && sessionInfo.user && this.queueManager) { + // 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); @@ -358,6 +407,9 @@ class SocketHandlers { 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); } } } @@ -653,44 +705,77 @@ class SocketHandlers { /** * Handle client disconnection with proper cleanup + * Preserves game state for reconnection when user has a valid session */ handleDisconnect(socket) { // Use connection handler for main disconnect logic - this.connectionHandler.handleDisconnect(socket, (socket) => { - // Additional cleanup specific to socket handlers + this.connectionHandler.handleDisconnect(socket, async (socket) => { + const socketId = socket.id; - // Clean up payment monitoring + // Try to get DB user ID for session-based preservation + let dbUserId = null; + if (this.sessionManager) { + try { + const user = await this.sessionManager.getBySocket(socketId); + dbUserId = user?.id; + } catch (e) { + // Non-fatal, proceed without DB user + } + } + + // Check if user has an active game that should be suspended + const activeGame = this.activeGames.get(socketId); + if (activeGame && dbUserId && this.suspendedGameManager) { + // Suspend the game for potential reconnection + const suspended = this.suspendedGameManager.suspendGame(dbUserId, socketId, activeGame, { + paymentMonitoringActive: this.paymentHandlers?.hasActiveMonitoring?.(socketId) || false + }); + + if (suspended) { + // Remove from activeGames (now in suspended state) + this.activeGames.delete(socketId); + + if (this.debugManager.CONSOLE_LOGGING) { + console.log(`[SocketHandlers] Game suspended for user ${dbUserId} (socket: ${socketId})`); + } + } else { + // Couldn't suspend, just delete + this.activeGames.delete(socketId); + } + } else { + // No DB user or no game, just clean up + this.activeGames.delete(socketId); + } + + // Clean up payment monitoring (but remember it was active for restoration) if (this.paymentHandlers && typeof this.paymentHandlers.stopMonitoringForSocket === 'function') { - this.paymentHandlers.stopMonitoringForSocket(socket.id); + this.paymentHandlers.stopMonitoringForSocket(socketId); } // Clean up movement timestamps - this.playerMoveTimestamps.delete(socket.id); + this.playerMoveTimestamps.delete(socketId); - // Clean up active games - this.activeGames.delete(socket.id); - - // Remove from queue manager if not a "valuable" entry (persistent session or payment) - // This allows users who paid but disconnected to stay in the queue for session resumption + // 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(socket.id)) { - this.queueManager.removePlayer(socket.id); - } else if (this.debugManager.CONSOLE_LOGGING && this.queueManager.isValuableEntry && this.queueManager.isValuableEntry(socket.id)) { - console.log(`[SocketHandlers] Preserving queue entry for ${socket.id} (user has session/payment)`); + 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(socket.id); + this.queueManager.removePlayer(socketId); } } // Clear any pending address confirmations if (this.chatHandler) { - this.chatHandler.clearAddressConfirmation(socket.id); + this.chatHandler.clearAddressConfirmation(socketId); } // Clean up spectator state if (this.spectatorManager) { - this.spectatorManager.handleDisconnect(socket.id); + this.spectatorManager.handleDisconnect(socketId); } }); } @@ -710,6 +795,117 @@ class SocketHandlers { }); } + /** + * 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, address, expected_amount, payment_type, description, expires_at, + crypto_type + 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 cryptoType = payment.crypto_type || 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.address, + 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.address, + 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 + }); + + // 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.address, + amount: payment.expected_amount, + amountFormatted: formattedAmount, + package: null + }; + + // Restart payment monitoring + // First stop any existing monitoring (unlikely but safe) + this.paymentHandlers.stopMonitoringForSocket(socket.id); + + // Restart monitoring via paymentHandlers internal method + if (typeof this.paymentHandlers._monitorAddress === 'function') { + this.paymentHandlers._monitorAddress( + socket, + paymentRequest, + payment.expected_amount, + cryptoType, + currentUser, + payment.payment_type + ); + } + + // Send status update + this.broadcastManager.sendStatusUpdate( + socket.id, + 'payment', + `🔁 Restored pending payment request.\n\nAmount: ${formattedAmount} ${cryptoType}\nAddress: ${payment.address}` + ); + + 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 /** diff --git a/src/network/suspendedGameManager.js b/src/network/suspendedGameManager.js new file mode 100644 index 0000000..9f3be0b --- /dev/null +++ b/src/network/suspendedGameManager.js @@ -0,0 +1,218 @@ +/** + * Suspended Game Manager + * Handles preservation and restoration of game states when users disconnect and reconnect. + * + * This allows users who disconnect during: + * - Active gameplay in the dungeon + * - Waiting in queue for next block + * - Awaiting payment confirmation + * + * to seamlessly resume their session when they reconnect. + */ + +const { normalizeError } = require('../utils/errors'); + +class SuspendedGameManager { + constructor({ debugManager, activeGames, cleanupTimeoutMs = 300000 }) { + this.debugManager = debugManager; + this.activeGames = activeGames; + + // Map of DB userId -> suspended game state + this.suspendedGames = new Map(); + + // Map of DB userId -> cleanup timeout handle + this.cleanupTimers = new Map(); + + // How long to keep suspended games before cleanup (default 5 minutes) + this.cleanupTimeoutMs = cleanupTimeoutMs; + + // Statistics + this.stats = { + gamesSuspended: 0, + gamesRestored: 0, + gamesExpired: 0 + }; + } + + /** + * Suspend a game when user disconnects + * @param {number} dbUserId - Database user ID + * @param {string} oldSocketId - The disconnecting socket ID + * @param {Object} game - The game instance + * @param {Object} additionalState - Additional state to preserve + * @returns {boolean} True if game was suspended + */ + suspendGame(dbUserId, oldSocketId, game, additionalState = {}) { + if (!dbUserId || !game) { + return false; + } + + // Store the suspended game state + const suspendedState = { + game: game, + originalSocketId: oldSocketId, + suspendedAt: Date.now(), + gameId: game.id, + playerPosition: game.player ? { x: game.player.x, y: game.player.y } : null, + hasTreasure: game.player?.hasTreasure || false, + moveCount: game.moveCount || 0, + blockHeight: game.blockHeight || null, + ...additionalState + }; + + this.suspendedGames.set(dbUserId, suspendedState); + this.stats.gamesSuspended++; + + // Set cleanup timer + this._scheduleCleanup(dbUserId); + + if (this.debugManager?.CONSOLE_LOGGING) { + console.log(`🔄 [SuspendedGameManager] Suspended game ${game.id} for user ${dbUserId}`); + console.log(` Position: (${suspendedState.playerPosition?.x}, ${suspendedState.playerPosition?.y})`); + console.log(` Has treasure: ${suspendedState.hasTreasure}`); + console.log(` Moves: ${suspendedState.moveCount}`); + } + + return true; + } + + /** + * Check if a user has a suspended game + * @param {number} dbUserId - Database user ID + * @returns {boolean} True if user has suspended game + */ + hasSuspendedGame(dbUserId) { + return this.suspendedGames.has(dbUserId); + } + + /** + * Get suspended game state without removing it + * @param {number} dbUserId - Database user ID + * @returns {Object|null} Suspended game state or null + */ + getSuspendedState(dbUserId) { + return this.suspendedGames.get(dbUserId) || null; + } + + /** + * Restore a suspended game for a reconnecting user + * @param {number} dbUserId - Database user ID + * @param {string} newSocketId - The new socket ID + * @param {Object} newUser - The new User object + * @returns {Object|null} The restored game or null if no suspended game + */ + restoreGame(dbUserId, newSocketId, newUser) { + const suspended = this.suspendedGames.get(dbUserId); + if (!suspended) { + return null; + } + + // Cancel cleanup timer + this._cancelCleanup(dbUserId); + + const game = suspended.game; + + // Update game with new socket ID + game.socketId = newSocketId; + + // Update the user reference if provided + if (newUser) { + game.user = newUser; + newUser.joinGame(game); + } + + // Re-add to active games with new socket ID + this.activeGames.set(newSocketId, game); + + // Remove from suspended + this.suspendedGames.delete(dbUserId); + this.stats.gamesRestored++; + + if (this.debugManager?.CONSOLE_LOGGING) { + console.log(`✅ [SuspendedGameManager] Restored game ${game.id} for user ${dbUserId}`); + console.log(` Old socket: ${suspended.originalSocketId} -> New socket: ${newSocketId}`); + console.log(` Suspended for: ${Math.round((Date.now() - suspended.suspendedAt) / 1000)}s`); + } + + return { + game, + suspendedState: suspended + }; + } + + /** + * Remove a suspended game without restoring it + * @param {number} dbUserId - Database user ID + * @returns {boolean} True if game was removed + */ + removeSuspendedGame(dbUserId) { + this._cancelCleanup(dbUserId); + const had = this.suspendedGames.delete(dbUserId); + if (had) { + this.stats.gamesExpired++; + } + return had; + } + + /** + * Get statistics + * @returns {Object} Statistics object + */ + getStats() { + return { + ...this.stats, + currentSuspended: this.suspendedGames.size + }; + } + + /** + * Schedule cleanup for a suspended game + * @private + */ + _scheduleCleanup(dbUserId) { + // Cancel any existing timer + this._cancelCleanup(dbUserId); + + const timer = setTimeout(() => { + const suspended = this.suspendedGames.get(dbUserId); + if (suspended) { + if (this.debugManager?.CONSOLE_LOGGING) { + console.log(`⏰ [SuspendedGameManager] Game ${suspended.gameId} expired for user ${dbUserId}`); + } + this.suspendedGames.delete(dbUserId); + this.cleanupTimers.delete(dbUserId); + this.stats.gamesExpired++; + } + }, this.cleanupTimeoutMs); + + // Don't prevent process exit + if (timer.unref) timer.unref(); + + this.cleanupTimers.set(dbUserId, timer); + } + + /** + * Cancel cleanup timer for a user + * @private + */ + _cancelCleanup(dbUserId) { + const timer = this.cleanupTimers.get(dbUserId); + if (timer) { + clearTimeout(timer); + this.cleanupTimers.delete(dbUserId); + } + } + + /** + * Cleanup all suspended games (for graceful shutdown) + */ + cleanup() { + for (const [userId, timer] of this.cleanupTimers) { + clearTimeout(timer); + } + this.cleanupTimers.clear(); + this.suspendedGames.clear(); + } +} + +module.exports = SuspendedGameManager;