mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 14:51:26 +02:00
- 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.
1121 lines
46 KiB
JavaScript
1121 lines
46 KiB
JavaScript
/**
|
|
* 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 PaymentHandlers = require('./paymentHandlers');
|
|
const AddressManager = require('./addressManager');
|
|
const SessionManager = require('./sessionManager');
|
|
const RateLimiter = require('./rateLimiter');
|
|
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');
|
|
|
|
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
|
|
|
|
// 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 },
|
|
'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
|
|
});
|
|
|
|
// Legacy WAITING_PLAYERS removed; queueManager owns queue state
|
|
this.mempoolNotified = new Set(); // track payment addresses already queued
|
|
|
|
// Initialize session manager first
|
|
if (this.gameModeManager && this.gameModeManager.db) {
|
|
this.sessionManager = new SessionManager({
|
|
db: this.gameModeManager.db,
|
|
debugManager: this.debugManager,
|
|
gameModeManager: this.gameModeManager
|
|
});
|
|
// 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: (userObj, gameType, options) => this.gameManager.createGameForUser(userObj, gameType, options),
|
|
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
|
|
});
|
|
|
|
// 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 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({
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* Register memory cleanup functions to prevent memory leaks
|
|
*/
|
|
_registerMemoryCleanups() {
|
|
// Cleanup player move timestamps (keep for 5 minutes)
|
|
this.memoryManager.registerCleanup(
|
|
'playerMoveTimestamps',
|
|
() => {
|
|
const now = Date.now();
|
|
const maxAge = 300000; // 5 minutes
|
|
let cleaned = 0;
|
|
const toDelete = [];
|
|
|
|
for (const [socketId, timestamp] of this.playerMoveTimestamps.entries()) {
|
|
if (now - timestamp > maxAge) {
|
|
toDelete.push(socketId);
|
|
}
|
|
}
|
|
|
|
for (const socketId of toDelete) {
|
|
if (this.playerMoveTimestamps.delete(socketId)) {
|
|
cleaned++;
|
|
}
|
|
}
|
|
|
|
return cleaned;
|
|
},
|
|
300000
|
|
);
|
|
|
|
// Cleanup mempool notifications (keep for 10 minutes)
|
|
this.memoryManager.registerCleanup(
|
|
'mempoolNotifications',
|
|
MemoryManager.createSetCleanup(this.mempoolNotified, (addr) => {
|
|
// This is a simple heuristic - in production you'd want to track timestamps
|
|
return Math.random() < 0.1; // Randomly clean ~10% each cycle
|
|
}),
|
|
600000
|
|
);
|
|
|
|
// Let other components register their cleanups
|
|
if (this.rateLimiter) {
|
|
this.memoryManager.registerCleanup(
|
|
'rateLimiterCleanup',
|
|
() => { this.rateLimiter.cleanup(); return 0; },
|
|
60000
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle player movement input from client.
|
|
* Expects moveData: { direction: 'up'|'down'|'left'|'right' }
|
|
*/
|
|
handlePlayerMove(socket, moveData) {
|
|
try {
|
|
if (!moveData || typeof moveData.direction !== 'string') return;
|
|
const now = Date.now();
|
|
const last = this.playerMoveTimestamps.get(socket.id) || 0;
|
|
if (now - last < this.MOVE_COOLDOWN) return; // rate limit moves
|
|
this.playerMoveTimestamps.set(socket.id, now);
|
|
|
|
const game = this.activeGames.get(socket.id);
|
|
if (!game) return; // not in a game
|
|
|
|
// Translate direction to delta
|
|
const dir = moveData.direction;
|
|
let dx = 0, dy = 0;
|
|
switch (dir) {
|
|
case 'up': dy = -1; break;
|
|
case 'down': dy = 1; break;
|
|
case 'left': dx = -1; break;
|
|
case 'right': dx = 1; break;
|
|
default: return; // ignore unknown
|
|
}
|
|
|
|
// Attempt move via game API (assuming game has movePlayer(dx,dy))
|
|
if (typeof game.movePlayer === 'function') {
|
|
game.movePlayer(dx, dy);
|
|
} else if (game.player) {
|
|
// Fallback direct mutation (legacy) with simple bounds check
|
|
const newX = game.player.x + dx;
|
|
const newY = game.player.y + dy;
|
|
if (newX >= 0 && newY >= 0) {
|
|
game.player.x = newX;
|
|
game.player.y = newY;
|
|
}
|
|
}
|
|
|
|
if (typeof game.moveMonster === 'function') {
|
|
game.moveMonster();
|
|
}
|
|
|
|
// Build updated state
|
|
let state;
|
|
if (typeof game.getState === 'function') {
|
|
state = game.getState();
|
|
} else {
|
|
state = { player: game.player };
|
|
}
|
|
|
|
// Add block height context
|
|
if (this.debugManager && typeof this.debugManager.getCurrentBlockHeight === 'function') {
|
|
state.blockHeight = this.debugManager.getCurrentBlockHeight();
|
|
}
|
|
|
|
this.io.to(socket.id).emit('game_update', state);
|
|
|
|
} catch (err) {
|
|
console.error('handlePlayerMove error:', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize socket event handlers for a new connection
|
|
*/
|
|
async handleConnection(socket) {
|
|
const connectionResult = await this.connectionHandler.handleConnection(socket);
|
|
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 (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());
|
|
}
|
|
|
|
// 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
|
|
socket.on('chat message', (msg) => this.chatHandler.handleChatMessage(socket, msg, {
|
|
handleGameQueue: (socket) => this.queueHandler.handleGameQueue(socket, (socketId) => this.connectionHandler.getUserBySocket(socketId)),
|
|
handleCancelEntry: (socket) => this.queueHandler.handleCancelEntry(socket),
|
|
handleStatsRequest: (socket) => this.handleStatsRequest(socket)
|
|
}));
|
|
socket.on('player_move', (moveData) => this.movementManager.handleMove(socket.id, moveData));
|
|
socket.on('disconnect', () => this.handleDisconnect(socket));
|
|
socket.on('debug_ping', (data) => this.handleDebugPing(socket, data));
|
|
socket.on('register_client', (data) => {
|
|
this.connectionHandler.handleRegisterClient(socket, data);
|
|
// Re-send game mode info after client registers handlers (fixes race condition)
|
|
if (this.gameModeManager) {
|
|
socket.emit('game_mode_info', this.gameModeManager.getGameModeInfo());
|
|
}
|
|
});
|
|
socket.on('auto_start', () => this.handleAutoStart(socket)); // New handler for start button
|
|
socket.on('early_entry', () => this.handleEarlyEntry(socket)); // Early entry without waiting for block
|
|
socket.on('address:prompt', () => this.handleAddressPrompt(socket));
|
|
|
|
// Payment system handlers
|
|
socket.on('request_payment', (data) => this.paymentHandlers.handlePaymentRequest(socket, data));
|
|
socket.on('check_payment_status', (data) => this.handleCheckPaymentStatus(socket, data));
|
|
socket.on('get_user_credits', () => this.handleGetUserCredits(socket));
|
|
socket.on('address:update', (data) => this.handleAddressUpdate(socket, data));
|
|
|
|
// Spectator handlers
|
|
socket.on('get_active_games', (options) => this.handleGetActiveGames(socket, options));
|
|
socket.on('spectate_game', (data) => this.handleSpectateGame(socket, data));
|
|
socket.on('leave_spectate', () => this.handleLeaveSpectate(socket));
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
try {
|
|
// Rate limiting for game starts
|
|
const rateLimitResult = await this.rateLimiter.checkLimit(socket.id, 'game:start');
|
|
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' };
|
|
if (this.gameModeManager) {
|
|
// Payout address gating for modes that can payout
|
|
const payoutEligible = (this.gameModeManager.gameMode === 'PAID_SINGLE') || (this.gameModeManager.gameMode === 'PAID_CREDITS' && this.gameModeManager.creditsPayoutEnabled);
|
|
if (payoutEligible) {
|
|
try {
|
|
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;
|
|
}
|
|
}
|
|
|
|
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
|
|
if (canStart.action === 'make_payment' || canStart.action === 'purchase_credits') {
|
|
await this.paymentHandlers.createAndShowPaymentRequest(socket);
|
|
return;
|
|
}
|
|
this.broadcastManager.sendStatusUpdate(socket.id, 'error', canStart.reason || 'Not allowed to start');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Record the game start attempt
|
|
await this.rateLimiter.recordAttempt(socket.id, 'game:start');
|
|
|
|
// Create game immediately
|
|
const blockHeight = this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null;
|
|
memUser.blockRec = blockHeight; // keep legacy timeout logic consistent
|
|
const game = this.gameManager.createGameForUser(memUser, 'standard');
|
|
const state = game.getState();
|
|
state.blockHeight = blockHeight;
|
|
|
|
// Include provably fair commitment
|
|
if (game.getProofCommitment) {
|
|
state.proof = game.getProofCommitment();
|
|
}
|
|
|
|
// Process start (credits deduction / payment link)
|
|
if (this.gameModeManager) {
|
|
const startRes = await this.gameModeManager.processGameStart(socket.id, game.id);
|
|
if (!startRes.success) {
|
|
// Abort game
|
|
this.activeGames.delete(socket.id);
|
|
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 });
|
|
}
|
|
}
|
|
|
|
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 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) {
|
|
try {
|
|
const result = await this.queueHandler.handleEarlyEntry(
|
|
socket,
|
|
this.connectionHandler.getUserBySocket.bind(this.connectionHandler)
|
|
);
|
|
|
|
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 rateLimitResult = await this.rateLimiter.checkLimit(socket.id, 'address:set');
|
|
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(socket.id, 'address:set');
|
|
|
|
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.' });
|
|
}
|
|
}
|
|
|
|
/** 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 (legacy method - now delegates to 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) {
|
|
// Use connection handler for main disconnect logic
|
|
this.connectionHandler.handleDisconnect(socket, async (socket) => {
|
|
const socketId = socket.id;
|
|
|
|
// 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(socketId);
|
|
}
|
|
|
|
// Clean up movement timestamps
|
|
this.playerMoveTimestamps.delete(socketId);
|
|
|
|
// 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);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle debug ping
|
|
*/
|
|
handleDebugPing(socket, data) {
|
|
if (this.debugManager.CONSOLE_LOGGING) {
|
|
console.log(`Debug ping received from ${socket.client.id}`);
|
|
}
|
|
socket.emit('debug_pong', {
|
|
message: "Hello from server!",
|
|
clientTime: data.time,
|
|
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, 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
|
|
|
|
/**
|
|
* 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, gameType = 'standard', options = {}) {
|
|
return this.gameManager.createGameForUser(user, gameType, 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) {
|
|
return await this.queueHandler.startGamesForWaiting(blockHeight);
|
|
}
|
|
|
|
/**
|
|
* Check for game timeouts based on block height
|
|
*/
|
|
checkGamesTimeout(currentHeight) {
|
|
this.activeGames.forEach((game, socketId) => {
|
|
const user = this.connectionHandler.getUserBySocket(socketId);
|
|
|
|
if (user && user.blockRec && currentHeight > user.blockRec) {
|
|
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 };
|
|
this.handleGameOver(fakeSocket, game, 'lost', 'timeout', 'You didn\'t escape before the block time limit!');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Payment system handlers (placeholder for compatibility)
|
|
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
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Shutdown method to clean up all resources and prevent memory leaks
|
|
*/
|
|
async shutdown() {
|
|
if (this.debugManager.CONSOLE_LOGGING) {
|
|
console.log('🛑 SocketHandlers shutting down...');
|
|
}
|
|
|
|
// Shutdown components in reverse order of initialization
|
|
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();
|
|
}
|
|
|
|
// Clear remaining data structures
|
|
this.activeGames.clear();
|
|
this.playerMoveTimestamps.clear();
|
|
this.mempoolNotified.clear();
|
|
|
|
if (this.debugManager.CONSOLE_LOGGING) {
|
|
console.log('✅ SocketHandlers shutdown complete');
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = SocketHandlers;
|