From bf5938c3668e202ac44770690b75a6393e51ffa1 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Wed, 18 Feb 2026 06:35:32 -0500 Subject: [PATCH] fix game entry and payout bugs --- html/admin.html | 10 +- src/game/gameManager.js | 66 +++++---- src/game/gameModeManager.js | 193 +++------------------------ src/migrations/012_payout_safety.sql | 11 ++ src/network/queueManager.js | 28 +++- 5 files changed, 93 insertions(+), 215 deletions(-) create mode 100644 src/migrations/012_payout_safety.sql diff --git a/html/admin.html b/html/admin.html index d30e737..72d565d 100644 --- a/html/admin.html +++ b/html/admin.html @@ -983,12 +983,12 @@ return Math.floor(seconds / 86400) + 'd ago'; } - // Auto-refresh every 30 seconds + // Auto-refresh every 15 seconds for active tabs setInterval(() => { - if (apiKey && currentTab === 'overview') { - loadDashboard(); - } - }, 30000); + if (!apiKey) return; + if (currentTab === 'overview') loadDashboard(); + else if (currentTab === 'queue') loadQueue(); + }, 15000); diff --git a/src/game/gameManager.js b/src/game/gameManager.js index 000486d..49f4930 100644 --- a/src/game/gameManager.js +++ b/src/game/gameManager.js @@ -30,23 +30,23 @@ class GameManager { * @param {Object} options - Additional game options * @returns {Object} Created game instance */ - createGameForUser(user, gameType = 'standard', options = {}) { + async createGameForUser(user, gameType = 'standard', options = {}) { let game; - + if (gameType === 'legacy') { game = Game.createLegacyGame(user.id, user, options); } else { game = Game.createStandardGame(user.id, user, options); } - + user.joinGame(game); this.activeGames.set(user.id, game); - // Insert a DB record for this game (best effort) - this._insertGameRecord(game, user); - + // Insert DB record and wait for it (needed for processGameStart to find the row) + await this._insertGameRecord(game, user); + if (this.debugManager.CONSOLE_LOGGING) { - console.log(`[createGameForUser] Created ${gameType} game ${game.id} for user ${user.id}`); + console.log(`[createGameForUser] Created ${gameType} game ${game.id} for user ${user.id} (dbId: ${game.dbId || 'none'})`); } return game; } @@ -64,6 +64,11 @@ class GameManager { try { const socketId = socket.id || socket; + // IMMEDIATELY remove from active games to prevent double-processing. + // If escape + block timeout fire in the same event loop tick, the second + // call would find the game still here during the first call's await. + this.activeGames.delete(socketId); + game.gameState = status; const moves = game.moveCount || 0; const durationSeconds = game.startedAt ? Math.max(0, Math.round((Date.now() - game.startedAt) / 1000)) : null; @@ -76,19 +81,19 @@ class GameManager { durationSeconds }; game.endGame(status, gameStats); - + // Process game completion with payment system let payoutInfo = null; if (this.gameModeManager) { try { payoutInfo = await this.gameModeManager.completeGame( - socketId, - game.id, - status === 'won', + socketId, + game.id, + status === 'won', game.player.hasTreasure || false, { moves, durationSeconds, score: finalScore } ); - + if (this.debugManager.CONSOLE_LOGGING && payoutInfo) { console.log(`💰 Payout processed for ${socketId}:`, payoutInfo); } @@ -96,10 +101,10 @@ class GameManager { console.error('Error processing game completion:', error); } } - + // Emit game over event with provably fair reveal const proofReveal = game.getProofReveal ? game.getProofReveal() : null; - + this.io.to(socketId).emit('game_over', { status: status, reason: reason, @@ -114,10 +119,6 @@ class GameManager { // Persist completion details if DB available await this._updateGameRecord(game, socketId, status, reason, moves, durationSeconds); - - // Clean up game from active games BEFORE notifying spectators - // This ensures the game list broadcast won't include this game - this.activeGames.delete(socketId); // Notify spectators that the game has ended and broadcast updated list if (this.spectatorManager) { @@ -131,16 +132,17 @@ class GameManager { treasure: game.player.hasTreasure || false }); } - + if (this.debugManager.CONSOLE_LOGGING) { console.log(`🎮 Game ${game.id} ended for ${socketId}: ${status} (${reason})`); } - + } catch (error) { console.error('GameManager.handleGameOver error:', error); // Still try to clean up even if there was an error - if (socket && socket.id) { - this.activeGames.delete(socket.id); + const socketId = socket?.id || socket; + if (socketId) { + this.activeGames.delete(socketId); } } } @@ -256,18 +258,24 @@ class GameManager { * @param {Object} game - Game instance * @param {Object} user - User object */ - _insertGameRecord(game, user) { + async _insertGameRecord(game, user) { if (this.gameModeManager && this.gameModeManager.db) { const db = this.gameModeManager.db; const gameMode = this.gameModeManager.gameMode || 'FREE'; const blockHeight = this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null; const socketId = user.id; // user.id is the socket id string - - db.query(` - INSERT INTO games (user_id, socket_id, game_mode, status, start_block_height, dungeon_seed, created_at) - VALUES ((SELECT id FROM users WHERE socket_id = $1), $2, $3, 'active', $4, $5, NOW()) - `, [socketId, socketId, gameMode, blockHeight, game.id]) - .catch(err => console.error('Game insert failed:', err.message)); + + try { + const result = await db.query(` + INSERT INTO games (user_id, socket_id, game_mode, status, start_block_height, dungeon_seed, created_at) + VALUES ((SELECT id FROM users WHERE socket_id = $1), $2, $3, 'active', $4, $5, NOW()) + RETURNING id + `, [socketId, socketId, gameMode, blockHeight, game.id]); + game.dbId = result.rows[0]?.id || null; + } catch (err) { + console.error('Game insert failed:', err.message); + game.dbId = null; + } } } diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index 78843e5..f86d8c0 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -747,8 +747,9 @@ class GameModeManager { const remainingCredits = updateRes.rows[0].credits; await client.query(` - UPDATE games SET payment_mode = 'credits' WHERE id = $1 - `, [gameId]); + UPDATE games SET game_mode = 'PAID_CREDITS', payout_address = $2 + WHERE dungeon_seed = $1 + `, [gameId, user.payout_address || null]); await client.query(` INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type) @@ -781,9 +782,9 @@ class GameModeManager { await this.db.withTransaction(async (client) => { await client.query(` UPDATE games - SET payment_id = $1 - WHERE id = $2 - `, [payment.id, gameId]); + SET payment_id = $1, game_mode = 'PAID_SINGLE', payout_address = $3 + WHERE dungeon_seed = $2 + `, [payment.id, gameId, user.payout_address || null]); await client.query(` UPDATE users @@ -801,166 +802,8 @@ class GameModeManager { }; } - /** - * Process game completion (handle payouts) - */ - async processGameCompletion(gameId, outcome, treasureFound = false) { - try { - // Get game details - const gameResult = await this.db.query(` - SELECT g.*, u.payout_address, u.id as user_id - FROM games g - JOIN users u ON g.user_id = u.id - WHERE g.id = $1 - `, [gameId]); - - if (gameResult.rows.length === 0) { - return { success: false, reason: 'Game not found' }; - } - - const game = gameResult.rows[0]; - - // Update game status - await this.db.query(` - UPDATE games - SET status = $1, - outcome = $2, - treasure_found = $3, - completed_at = NOW() - WHERE id = $4 - `, [outcome === 'escaped' ? 'won' : 'lost', outcome, treasureFound, gameId]); - - const recordedMode = (game.payment_mode || game.game_mode || this.gameMode || 'FREE').toUpperCase(); - const payoutEligibleMode = (recordedMode === 'PAID_SINGLE') || (recordedMode === 'PAID_CREDITS' && this.creditsPayoutEnabled); - if (payoutEligibleMode && outcome === 'escaped' && game.payout_address) { - const { amount: payoutAmount, multiplier } = this.calculatePayout(recordedMode, { treasureFound }); - if (payoutAmount > 0) { - const reason = treasureFound ? 'escape_with_treasure' : 'escape'; - let payoutId = null; - - try { - // Step 1: Create pending payout record FIRST (ensures we have a record even if RPC succeeds but server crashes) - const insertResult = await this.db.query(` - INSERT INTO payouts (user_id, game_id, payout_address, amount, multiplier, reason, status, created_at) - VALUES ($1, $2, $3, $4, $5, $6, 'pending', NOW()) - RETURNING id - `, [ - game.user_id, - gameId, - game.payout_address, - payoutAmount, - multiplier, - reason - ]); - payoutId = insertResult.rows[0].id; - - // Step 2: Attempt wallet RPC transfer - const payoutResult = await this.walletService.processPayout({ - userId: game.user_id, - gameId, - address: game.payout_address, - amount: payoutAmount, - multiplier, - description: `Game ${gameId} payout` - }); - - // Step 3: IMMEDIATELY store tx_hash if RPC succeeded (BEFORE transaction) - // This ensures we never lose the tx_hash even if subsequent DB operations fail - // The retry service can then check blockchain status using this hash - if (payoutResult.success && payoutResult.txHash) { - await this.db.query(` - UPDATE payouts - SET tx_hash = $1, fee = $2 - WHERE id = $3 - `, [payoutResult.txHash, payoutResult.fee || null, payoutId]); - } - - // Step 4: Update status and user stats (within transaction for atomicity) - await this.db.withTransaction(async (client) => { - await client.query(` - UPDATE payouts - SET status = $1, processed_at = NOW() - WHERE id = $2 - `, [ - payoutResult.success ? 'completed' : 'failed', - payoutId - ]); - - // Update user's total_amount_won if successful - if (payoutResult.success) { - await client.query(` - UPDATE users - SET total_amount_won = COALESCE(total_amount_won, 0) + $1, - total_payouts_received = COALESCE(total_payouts_received, 0) + 1 - WHERE id = $2 - `, [payoutAmount, game.user_id]); - } - }); - - console.log(`💸 Payout ${payoutResult.success ? 'sent' : 'failed'}: ${payoutAmount} atomic units for game ${gameId} (multiplier ${multiplier}x)`); - } catch (payoutError) { - console.error(`❌ Payout processing failed for game ${gameId}:`, payoutError.message); - // Update payout record with failure (if it exists) - if (payoutId) { - try { - await this.db.query(` - UPDATE payouts - SET status = 'failed', - error_message = $1, - retry_count = COALESCE(retry_count, 0), - last_error = $1, - last_retry_at = NOW() - WHERE id = $2 - `, [payoutError.message, payoutId]); - } catch (dbErr) { - console.error('Failed to update payout error:', dbErr.message); - } - } else { - // Payout record wasn't created, insert failed record - try { - await this.db.query(` - INSERT INTO payouts (user_id, game_id, payout_address, amount, multiplier, reason, status, error_message, created_at) - VALUES ($1, $2, $3, $4, $5, $6, 'failed', $7, NOW()) - `, [ - game.user_id, - gameId, - game.payout_address, - payoutAmount, - multiplier, - reason, - payoutError.message - ]); - } catch (dbErr) { - console.error('Failed to record payout error:', dbErr.message); - } - } - } - } - } - - // Update user statistics - if (outcome === 'escaped') { - await this.db.query(` - UPDATE users - SET total_games_won = total_games_won + 1, - updated_at = NOW() - WHERE id = $1 - `, [game.user_id]); - } - - return { - success: true, - outcome, - treasureFound, - payoutCreated: payoutEligibleMode && outcome === 'escaped' - }; - - } catch (error) { - const normalized = normalizeError(error, 'Failed to process game completion'); - console.error('❌ Error processing game completion:', normalized.message); - return { success: false, reason: normalized.safeMessage }; - } - } + // processGameCompletion() removed — dead code, never called. + // Only completeGame() (below) is used for game completion/payouts. /** * Create payment request @@ -1233,18 +1076,17 @@ class GameModeManager { // Retrieve the game record to get the actual payment_mode used at game start let recordedPaymentMode = this.gameMode; // fallback to global let gameDbId = null; + let lockedPayoutAddress = null; // address locked at game start try { const gameRecord = await this.db.query(` - SELECT id, payment_mode FROM games + SELECT id, game_mode, payout_address FROM games WHERE dungeon_seed = $1 AND socket_id = $2 LIMIT 1 `, [gameId, socketId]); if (gameRecord.rows.length > 0) { gameDbId = gameRecord.rows[0].id; - recordedPaymentMode = (gameRecord.rows[0].payment_mode || this.gameMode).toUpperCase(); - // Normalize to expected values - if (recordedPaymentMode === 'DIRECT') recordedPaymentMode = 'PAID_SINGLE'; - if (recordedPaymentMode === 'CREDITS') recordedPaymentMode = 'PAID_CREDITS'; + recordedPaymentMode = (gameRecord.rows[0].game_mode || this.gameMode).toUpperCase(); + lockedPayoutAddress = gameRecord.rows[0].payout_address || null; } } catch (e) { // Non-fatal, use fallback @@ -1289,10 +1131,11 @@ class GameModeManager { } } - // Look up user record for payout address + // Use payout address locked at game start, fall back to current user address const userResult = await this.db.query(`SELECT * FROM users WHERE socket_id = $1 LIMIT 1`, [socketId]); const userRow = userResult.rows[0]; - if (userRow && userRow.payout_address) { + const payoutAddress = lockedPayoutAddress || (userRow && userRow.payout_address); + if (userRow && payoutAddress) { // SECURITY: Use safe payout pattern - create pending record FIRST, then RPC, then store tx_hash let payoutId = null; try { @@ -1304,18 +1147,18 @@ class GameModeManager { `, [ userRow.id, gameDbId, // Use DB id if available - userRow.payout_address, + payoutAddress, payoutAmount, multiplier, treasureFound ? 'escape_with_treasure' : 'escape' ]); payoutId = insertResult.rows[0].id; - // Step 2: Attempt wallet RPC transfer + // Step 2: Attempt wallet RPC transfer (using locked address) const payoutResult = await this.walletService.processPayout({ userId: userRow.id, gameId, - address: userRow.payout_address, + address: payoutAddress, amount: payoutAmount, multiplier, description: `Game ${gameId} payout` diff --git a/src/migrations/012_payout_safety.sql b/src/migrations/012_payout_safety.sql new file mode 100644 index 0000000..5a8b3ea --- /dev/null +++ b/src/migrations/012_payout_safety.sql @@ -0,0 +1,11 @@ +-- Migration 012: Payout safety constraints +-- Prevents double payouts and locks payout address at game start + +-- Only one active (pending/completed) payout per game +CREATE UNIQUE INDEX IF NOT EXISTS idx_payouts_one_per_game +ON payouts(game_id) +WHERE game_id IS NOT NULL AND status IN ('pending', 'completed'); + +-- Store the payout address at game start so it can't be changed mid-game +ALTER TABLE games + ADD COLUMN IF NOT EXISTS payout_address VARCHAR(200); diff --git a/src/network/queueManager.js b/src/network/queueManager.js index baf9b70..a6c8eda 100644 --- a/src/network/queueManager.js +++ b/src/network/queueManager.js @@ -109,7 +109,7 @@ class QueueManager { console.log(`[QueueManager] Player ${serverId} enters on block ${blockHeight} (dies after ${blockHeight + 1})`); } try { - const game = this.createGameForUser(currentUser, 'standard'); + const game = await this.createGameForUser(currentUser, 'standard'); const gameState = game.getState(); gameState.blockHeight = blockHeight; // Include provably fair commitment @@ -121,8 +121,9 @@ class QueueManager { if (this.gameModeManager) { const startRes = await this.gameModeManager.processGameStart(serverId, game.id); if (!startRes.success) { - // Abort game + // Abort game + clean up orphaned DB record if (this.activeGames) this.activeGames.delete(serverId); + this._cleanupOrphanedGame(game); this.io.to(serverId).emit('message', 'Error starting game: ' + (startRes.reason || 'Payment processing failed')); continue; } @@ -178,7 +179,7 @@ class QueueManager { if (!currentUser) return false; currentUser.blockRec = blockHeight; // lifetime until next block try { - const game = this.createGameForUser(currentUser, 'standard'); + const game = await this.createGameForUser(currentUser, 'standard'); const gameState = game.getState(); gameState.blockHeight = blockHeight; // Include provably fair commitment @@ -190,8 +191,9 @@ class QueueManager { if (this.gameModeManager) { const startRes = await this.gameModeManager.processGameStart(serverId, game.id); if (!startRes.success) { - // Abort game + // Abort game + clean up orphaned DB record if (this.activeGames) this.activeGames.delete(serverId); + this._cleanupOrphanedGame(game); this.io.to(serverId).emit('message', 'Error starting game: ' + (startRes.reason || 'Payment processing failed')); return false; } @@ -235,7 +237,7 @@ class QueueManager { currentUser.isEarlyEntry = true; // Mark as early entry for potential special handling try { - const game = this.createGameForUser(currentUser, 'standard', { earlyEntry: true }); + const game = await this.createGameForUser(currentUser, 'standard', { earlyEntry: true }); const gameState = game.getState(); gameState.blockHeight = blockHeight; gameState.isEarlyEntry = true; @@ -250,8 +252,9 @@ class QueueManager { if (this.gameModeManager) { const startRes = await this.gameModeManager.processGameStart(serverId, game.id); if (!startRes.success) { - // Abort game + // Abort game + clean up orphaned DB record if (this.activeGames) this.activeGames.delete(serverId); + this._cleanupOrphanedGame(game); return { success: false, reason: startRes.reason || 'Payment processing failed' }; } // Emit credits_update if credits were spent @@ -275,6 +278,19 @@ class QueueManager { } } + /** + * Clean up a game record from the DB when processGameStart fails. + * Prevents orphaned "active" records that never complete. + */ + _cleanupOrphanedGame(game) { + if (game && game.dbId && this.gameModeManager && this.gameModeManager.db) { + this.gameModeManager.db.query( + `UPDATE games SET status = 'expired', outcome = 'aborted', completed_at = NOW() WHERE id = $1`, + [game.dbId] + ).catch(err => console.error('[QueueManager] Failed to clean up orphaned game:', err.message)); + } + } + /** * Get list of pending games (players waiting in queue) * Used by spectator system to show upcoming games