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