feat: Game reconnection/resume support

- Created SuspendedGameManager to preserve game state when users disconnect
- Games are keyed by DB userId (persistent) instead of ephemeral socketId
- handleDisconnect now suspends games for 5 minutes instead of deleting
- handleConnection restores suspended games on reconnect with session token
- Payment monitoring is restored for pending payments on reconnect
- Added hasActiveMonitoring() method to PaymentHandlers
- Queue entries preserved for users with active sessions/payments
- Updated TODO.md with reconnection feature documentation

Users in dungeon or awaiting payment can now close/refresh browser and
seamlessly resume within 5 minutes when reconnecting.
This commit is contained in:
jwinterm
2026-01-13 17:06:35 -05:00
parent ce58a33347
commit 404f5692d8
4 changed files with 443 additions and 19 deletions
+1
View File
@@ -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 ✅
+9
View File
@@ -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)
*/
+215 -19
View File
@@ -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
/**
+218
View File
@@ -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;