From 320d1a3b1fd3ca1bbfd4d1eb3929694066c5f0fd Mon Sep 17 00:00:00 2001 From: jwinterm Date: Tue, 17 Feb 2026 20:02:22 -0500 Subject: [PATCH] fix admin panel and credits package --- html/admin.html | 20 +-- html/js/network/socketHandlers.js | 27 +++- src/game/gameModeManager.js | 231 ++++++++++++++++-------------- src/index.js | 5 + src/network/sessionManager.js | 77 ++++++++-- 5 files changed, 226 insertions(+), 134 deletions(-) diff --git a/html/admin.html b/html/admin.html index 4aef34e..bab6db8 100644 --- a/html/admin.html +++ b/html/admin.html @@ -653,20 +653,20 @@ return Math.floor(sec / 3600) + 'h ' + Math.floor((sec % 3600) / 60) + 'm'; }; - return \` - - \${entry.position} - \${entry.serverId.substring(0, 12)}... - \${formatWait(entry.waitingSeconds)} - \${entry.paymentId || '-'} - \${statusText} + return ` + + ${entry.position} + ${entry.serverId.substring(0, 12)}... + ${formatWait(entry.waitingSeconds)} + ${entry.paymentId || '-'} + ${statusText} - - \`; + `; }).join(''); } catch (e) { console.error('Failed to load queue:', e); @@ -679,7 +679,7 @@ } async function removeQueueEntry(serverId) { - if (!confirm(\`Remove queue entry for \${serverId.substring(0, 12)}...? This will cancel their queue position.\`)) return; + if (!confirm(`Remove queue entry for ${serverId.substring(0, 12)}...? This will cancel their queue position.`)) return; const reason = prompt('Reason for removal (optional):') || 'Admin removal'; diff --git a/html/js/network/socketHandlers.js b/html/js/network/socketHandlers.js index 92b39cb..f37e473 100644 --- a/html/js/network/socketHandlers.js +++ b/html/js/network/socketHandlers.js @@ -1331,7 +1331,7 @@ const SocketHandlers = { animation: earlyEntryPulse 2s infinite; ">⚡ ENTER NOW (RISKY!) ⚡ `); - + // Add CSS animation if (!$('#earlyEntryStyles').length) { $('head').append(` @@ -1365,22 +1365,39 @@ const SocketHandlers = { // Show/hide based on whether we're queued and early entry is allowed if (this._isQueued && this.isEarlyEntryAllowed()) { + // Update button text to show credit cost when in paid mode + var mode = this._gameMode; + if (mode === 'PAID_CREDITS' || mode === 'MIXED') { + var cost = this._creditsPerGame || 1; + $btn.text('⚡ ENTER NOW (' + cost + ' credit, RISKY!) ⚡'); + } else { + $btn.text('⚡ ENTER NOW (RISKY!) ⚡'); + } $btn.show(); } else { $btn.hide(); } }, - + /** * Request early entry from the server */ requestEarlyEntry: function() { if (!window.socket) return; - + + // Confirm before spending credits + var mode = this._gameMode; + var msg = 'Enter the dungeon NOW?\n\nYou will die when the next block is found!'; + if (mode === 'PAID_CREDITS' || mode === 'MIXED') { + var cost = this._creditsPerGame || 1; + msg = 'Use ' + cost + ' credit to enter the dungeon NOW?\n\nYou will die when the next block is found!'; + } + if (!confirm(msg)) return; + // Disable button to prevent double-clicks - const $btn = $('#earlyEntryButton'); + var $btn = $('#earlyEntryButton'); $btn.prop('disabled', true).text('⏳ Entering...'); - + socket.emit('early_entry'); }, diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index 8d7f319..78843e5 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -262,85 +262,91 @@ class GameModeManager { */ async processCreditsPackageConfirmation(socketId, paymentId, packageInfo = null) { try { - const user = await this.getOrCreateUser(socketId); + // Look up user from payment record's user_id (stable across socket reconnects) + // This avoids the bug where socket ID changes if user refreshes during confirmation + const paymentLookup = await this.db.query(` + SELECT user_id, description, status FROM payments WHERE id = $1 + `, [paymentId]); - // Determine credits to add from package or fallback + if (paymentLookup.rows.length === 0) { + console.error(`Payment ${paymentId} not found`); + return { success: false, reason: 'Payment not found' }; + } + + // Fast-fail if already confirmed (avoids unnecessary transaction) + if (paymentLookup.rows[0].status === 'confirmed') { + console.log(`Payment ${paymentId} already confirmed, skipping duplicate processing`); + return { success: false, alreadyProcessed: true, reason: 'Payment already processed' }; + } + + const userId = paymentLookup.rows[0].user_id; + + // Determine credits to add from package info or payment description let creditsToAdd = 10; // Default fallback if (packageInfo && packageInfo.credits) { creditsToAdd = Number(packageInfo.credits) + (Number(packageInfo.bonus) || 0); } else { - // Try to get from payment record description - const paymentResult = await this.db.query(` - SELECT description, status FROM payments WHERE id = $1 - `, [paymentId]); - if (paymentResult.rows.length > 0) { - // SECURITY: Check if already confirmed (prevents double-crediting) - if (paymentResult.rows[0].status === 'confirmed') { - console.log(`⚠️ Payment ${paymentId} already confirmed, skipping duplicate processing`); - return { - success: false, - alreadyProcessed: true, - reason: 'Payment already processed' - }; - } - const desc = paymentResult.rows[0].description || ''; - const match = desc.match(/(\d+)\s*credits?/i); - if (match) { - creditsToAdd = parseInt(match[1], 10) || 10; - } + const desc = paymentLookup.rows[0].description || ''; + const match = desc.match(/(\d+)\s*credits?/i); + if (match) { + creditsToAdd = parseInt(match[1], 10) || 10; } } - // SECURITY: Atomically update payment status from 'pending' to 'confirmed' - // Only proceeds if the payment was actually pending (prevents double-crediting) - const paymentUpdateResult = await this.db.query(` - UPDATE payments - SET status = 'confirmed', - credits_purchased = $1, - confirmed_at = NOW() - WHERE id = $2 AND status = 'pending' - RETURNING id - `, [creditsToAdd, paymentId]); + // CRITICAL: Wrap all three operations in a transaction. + // If any step fails, the payment stays 'pending' and can be recovered. + const result = await this.db.withTransaction(async (client) => { + // Step 1: Atomically mark payment as confirmed (prevents double-crediting) + const paymentUpdateResult = await client.query(` + UPDATE payments + SET status = 'confirmed', + credits_purchased = $1, + confirmed_at = NOW() + WHERE id = $2 AND status = 'pending' + RETURNING id + `, [creditsToAdd, paymentId]); - // If no rows were updated, payment was already processed - if (paymentUpdateResult.rows.length === 0) { - console.log(`⚠️ Payment ${paymentId} already confirmed (atomic check), skipping duplicate`); - return { - success: false, - alreadyProcessed: true, - reason: 'Payment already processed' - }; + if (paymentUpdateResult.rows.length === 0) { + // Already processed by another instance - not an error + return { alreadyProcessed: true }; + } + + // Step 2: Add credits to user (using payment's user_id, not socket lookup) + const updateResult = await client.query(` + UPDATE users + SET credits = credits + $1, + total_credits_purchased = COALESCE(total_credits_purchased, 0) + $1, + updated_at = NOW() + WHERE id = $2 + RETURNING credits + `, [creditsToAdd, userId]); + + const newBalance = updateResult.rows[0]?.credits ?? creditsToAdd; + + // Step 3: Record credit transaction for audit trail + await client.query(` + INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type) + VALUES ($1, $2, 'package_purchase', $3, 'purchase') + `, [userId, creditsToAdd, newBalance]); + + return { newBalance }; + }); + + if (result.alreadyProcessed) { + console.log(`Payment ${paymentId} already confirmed (atomic check), skipping duplicate`); + return { success: false, alreadyProcessed: true, reason: 'Payment already processed' }; } - // Payment successfully marked as confirmed - now add credits - // Use atomic increment to prevent race conditions - const updateResult = await this.db.query(` - UPDATE users - SET credits = credits + $1, - total_credits_purchased = COALESCE(total_credits_purchased, 0) + $1, - updated_at = NOW() - WHERE id = $2 - RETURNING credits - `, [creditsToAdd, user.id]); - - const newBalance = updateResult.rows[0]?.credits ?? (user.credits + creditsToAdd); - - // Record credit transaction - await this.db.query(` - INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type) - VALUES ($1, $2, 'package_purchase', $3, 'purchase') - `, [user.id, creditsToAdd, newBalance]); - - console.log(`💰 Credits package confirmed: +${creditsToAdd} credits for user ${user.id}, new balance: ${newBalance}`); + console.log(`Credits package confirmed: +${creditsToAdd} credits for user ${userId}, new balance: ${result.newBalance}`); return { success: true, creditsAdded: creditsToAdd, - newBalance: newBalance + newBalance: result.newBalance }; } catch (error) { const normalized = normalizeError(error, 'Failed to process credits package confirmation'); - console.error('❌ Error processing credits package:', normalized.message); + console.error('Error processing credits package:', normalized.message); return { success: false, reason: normalized.safeMessage @@ -722,65 +728,74 @@ class GameModeManager { async _processGameStartWithCredits(user, socketId, gameId) { const creditsToSpend = this.creditsPerGameCost; - // Use a conditional update to prevent credits going negative - const updateRes = await this.db.query(` - UPDATE users - SET credits = credits - $1, - total_games_played = total_games_played + 1, - updated_at = NOW() - WHERE id = $2 AND credits >= $1 - RETURNING credits - `, [creditsToSpend, user.id]); - - // If no rows were updated, user doesn't have enough credits - if (updateRes.rows.length === 0) { - return { - success: false, + + const result = await this.db.withTransaction(async (client) => { + // Conditional update prevents credits going negative + const updateRes = await client.query(` + UPDATE users + SET credits = credits - $1, + total_games_played = total_games_played + 1, + updated_at = NOW() + WHERE id = $2 AND credits >= $1 + RETURNING credits + `, [creditsToSpend, user.id]); + + if (updateRes.rows.length === 0) { + return { insufficient: true }; + } + + const remainingCredits = updateRes.rows[0].credits; + + await client.query(` + UPDATE games SET payment_mode = 'credits' WHERE id = $1 + `, [gameId]); + + await client.query(` + INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type) + VALUES ($1, $2, 'game_entry', $3, 'spend') + `, [user.id, -creditsToSpend, remainingCredits]); + + return { remainingCredits }; + }); + + if (result.insufficient) { + return { + success: false, reason: 'Insufficient credits', creditsRequired: creditsToSpend, creditsAvailable: user.credits }; } - const remainingCredits = updateRes.rows[0] ? updateRes.rows[0].credits : (user.credits - creditsToSpend); - - // Record the game mode used - await this.db.query(` - UPDATE games SET payment_mode = 'credits' WHERE id = $1 - `, [gameId]); - - // Record credit transaction - await this.db.query(` - INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type) - VALUES ($1, $2, 'game_entry', $3, 'spend') - `, [user.id, -creditsToSpend, remainingCredits]); - console.log(`🎫 Deducted ${creditsToSpend} credit(s) from user ${user.id}, ${remainingCredits} remaining`); - - return { - success: true, - creditsRemaining: remainingCredits, + console.log(`Deducted ${creditsToSpend} credit(s) from user ${user.id}, ${result.remainingCredits} remaining`); + + return { + success: true, + creditsRemaining: result.remainingCredits, creditsSpent: creditsToSpend, effectiveMode: 'PAID_CREDITS' }; } async _processGameStartWithPayment(user, payment, gameId) { - await this.db.query(` - UPDATE games - SET payment_id = $1 - WHERE id = $2 - `, [payment.id, gameId]); - - await this.db.query(` - UPDATE users - SET total_games_played = total_games_played + 1, - updated_at = NOW() - WHERE id = $1 - `, [user.id]); - - console.log(`💳 Linked game ${gameId} to payment ${payment.id}`); - return { - success: true, + await this.db.withTransaction(async (client) => { + await client.query(` + UPDATE games + SET payment_id = $1 + WHERE id = $2 + `, [payment.id, gameId]); + + await client.query(` + UPDATE users + SET total_games_played = total_games_played + 1, + updated_at = NOW() + WHERE id = $1 + `, [user.id]); + }); + + console.log(`Linked game ${gameId} to payment ${payment.id}`); + return { + success: true, paymentId: payment.id, effectiveMode: 'PAID_SINGLE' }; diff --git a/src/index.js b/src/index.js index eff98ec..cac92e9 100644 --- a/src/index.js +++ b/src/index.js @@ -61,6 +61,11 @@ app.get('/', function(req, res) { res.sendFile('index.html', { root: htmlPath }); }); +// Serve admin panel +app.get('/admin', function(req, res) { + res.sendFile('admin.html', { root: htmlPath }); +}); + // Debug endpoint to receive client-side debug info app.post('/debug', (req, res) => { if (debugManager.CONSOLE_LOGGING) { diff --git a/src/network/sessionManager.js b/src/network/sessionManager.js index fbecd0d..19c4b58 100644 --- a/src/network/sessionManager.js +++ b/src/network/sessionManager.js @@ -162,28 +162,65 @@ class SessionManager { AND p.status = 'confirmed' AND p.payment_type = 'credits_package' AND (p.credits_purchased IS NULL OR p.credits_purchased = 0) - AND p.confirmed_at > NOW() - INTERVAL '24 hours' + AND p.confirmed_at > NOW() - INTERVAL '7 days' ORDER BY p.confirmed_at ASC `, [userId]); + // Also find payments where credits_purchased was set but credits were never + // actually added to the user (e.g., crash/disconnect between payment update + // and user credit update before transaction wrapping was added) + const orphanedPayments = await this.db.query(` + SELECT p.id, p.credits_purchased, p.confirmed_at + FROM payments p + WHERE p.user_id = $1 + AND p.status = 'confirmed' + AND p.payment_type = 'credits_package' + AND p.credits_purchased > 0 + AND p.confirmed_at > NOW() - INTERVAL '7 days' + AND NOT EXISTS ( + SELECT 1 FROM credit_transactions ct + WHERE ct.user_id = p.user_id + AND ct.amount = p.credits_purchased + AND ct.reason IN ('package_purchase', 'package_purchase_recovered') + AND ct.created_at >= p.confirmed_at - INTERVAL '1 minute' + ) + ORDER BY p.confirmed_at ASC + `, [userId]); + + // Merge orphaned payments into unprocessed list (they need recovery too) + for (const orphan of orphanedPayments.rows) { + unprocessedPayments.rows.push({ + id: orphan.id, + payment_type: 'credits_package', + description: null, + expected_amount: null, + confirmed_at: orphan.confirmed_at, + _creditsFromRecord: orphan.credits_purchased // use stored value + }); + } + if (unprocessedPayments.rows.length === 0) { return result; } for (const payment of unprocessedPayments.rows) { try { - // Parse credits from description (e.g., "Wowngeon 10 credits package (WOW)") - let creditsToAdd = 10; // Default fallback - const desc = payment.description || ''; - const match = desc.match(/(\d+)\s*credits?/i); - if (match) { - creditsToAdd = parseInt(match[1], 10) || 10; + // Determine credits: use stored value from orphaned record, or parse from description + let creditsToAdd = payment._creditsFromRecord || 0; + if (!creditsToAdd) { + creditsToAdd = 10; // Default fallback + const desc = payment.description || ''; + const match = desc.match(/(\d+)\s*credits?/i); + if (match) { + creditsToAdd = parseInt(match[1], 10) || 10; + } } + const isOrphaned = !!payment._creditsFromRecord; // CRITICAL: Use transaction with row lock to prevent double-credit race condition // This ensures only one instance can process a payment at a time const recovered = await this.db.withTransaction(async (client) => { - // Lock the payment row and re-check if it's still unprocessed + // Lock the payment row and re-check status const lockResult = await client.query(` SELECT id, credits_purchased FROM payments @@ -191,9 +228,27 @@ class SessionManager { FOR UPDATE `, [payment.id]); - // If already processed (by another instance), skip - if (!lockResult.rows[0] || (lockResult.rows[0].credits_purchased && lockResult.rows[0].credits_purchased > 0)) { - return null; // Already processed + if (!lockResult.rows[0]) return null; + + // For normal recovery: skip if credits_purchased already set + // For orphaned recovery: skip if a credit_transaction already exists + if (!isOrphaned) { + if (lockResult.rows[0].credits_purchased && lockResult.rows[0].credits_purchased > 0) { + return null; // Already processed + } + } else { + // Double-check: does a credit_transaction already exist for this? + const txCheck = await client.query(` + SELECT 1 FROM credit_transactions + WHERE user_id = $1 + AND amount = $2 + AND reason IN ('package_purchase', 'package_purchase_recovered') + AND created_at >= $3::timestamp - INTERVAL '1 minute' + LIMIT 1 + `, [userId, creditsToAdd, payment.confirmed_at]); + if (txCheck.rows.length > 0) { + return null; // Already recovered + } } // Add credits to user