mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 14:51:26 +02:00
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:
@@ -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 ✅
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user