/** * Payment UI Manager * Handles the game mode selection and payment modal interactions */ const PaymentUI = { config: null, userCredits: 0, hasPayoutAddress: false, selectedPackageId: null, init: function() { console.log("Initializing PaymentUI..."); // Bind close button $('#close-payment').on('click', function() { $('#payment-ui').hide(); }); // Bind mode options (use delegation for dynamic content) $('#payment-ui').on('click', '.mode-option', function() { // The "Buy Selected Package" button ALSO carries .mode-option (for styling) but has its // own handler below; without this guard it double-fired here with NO data attrs, defaulting // to single_game, so buying a 10-credit pack ALSO created a spurious 1-credit invoice. if (this.id === 'buy-credits-btn') return; const mode = $(this).data('mode'); const action = $(this).data('action'); const packageId = $(this).data('package-id'); if (!mode && !action) return; // stray click with no intent PaymentUI.handleModeSelection(mode, action, packageId); }); // Bind package radio buttons $('#payment-ui').on('change', 'input[name="credit-package"]', function() { PaymentUI.selectedPackageId = $(this).val(); // Enable buy button $('#buy-credits-btn').prop('disabled', false); }); // Bind buy credits button $('#payment-ui').on('click', '#buy-credits-btn', function() { if (PaymentUI.selectedPackageId) { PaymentUI.handleModeSelection('PAID_CREDITS', 'buy_credits', PaymentUI.selectedPackageId); } }); // Bind check payment button $('#check-payment').on('click', function() { const paymentId = $(this).data('payment-id'); if (paymentId && window.socket) { window.socket.emit('check_payment_status', { paymentId }); $(this).text('Checking...'); setTimeout(() => $('#check-payment').text('🔄 Check Payment'), 1000); } }); // Bind copy button $('#copy-address').on('click', function() { const addr = $('#payment-address').val(); if(addr) { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(addr).then(() => { $('#copy-address').text('Copied!'); setTimeout(() => $('#copy-address').html('📋 Copy Address'), 2000); }); } else { const $temp = $(""); $("body").append($temp); $temp.val(addr).select(); document.execCommand("copy"); $temp.remove(); $('#copy-address').text('Copied!'); setTimeout(() => $('#copy-address').html('📋 Copy Address'), 2000); } } }); // Bind Shop Button $('#shopButton').on('click', function() { PaymentUI.show(); }); // Listen for credits updates if (window.socket) { window.socket.on('credits_update', function(data) { PaymentUI.updateCredits(data.balance); }); } }, updateConfig: function(config) { console.log("PaymentUI config updated:", config); this.config = config; this.render(); }, updateCredits: function(balance) { this.userCredits = balance || 0; this.render(); $('#user-credits-display').text(this.userCredits); }, formatPrice: function(atomicAmount) { const currency = this.config?.cryptoType || 'WOW'; const decimals = currency === 'XMR' ? 12 : 11; const divisor = Math.pow(10, decimals); return (atomicAmount / divisor).toFixed(decimals === 12 ? 4 : 2); }, escapeHtml: function(value) { return String(value == null ? '' : value).replace(/[&<>"']/g, function(ch) { return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]; }); }, grantLabel: function(packId) { const pack = window.RK && window.RK.PACKS ? window.RK.PACKS[packId] : null; const labels = { 'generated-skins': 'Premium skins', 'iso-dungeon': 'Isometric dungeon', 'kenney-3d-characters': '3D characters', 'roguelike-interior': 'Roguelike interior' }; return (pack && pack.label) || labels[packId] || packId; }, grantSummaryHtml: function(grants, options = {}) { if (!grants) return ''; const parts = []; if (options.includeCredits && Number(grants.credits || 0) > 0) { parts.push(`${Number(grants.credits)} credits`); } (grants.packs || []).forEach(packId => parts.push(this.grantLabel(packId))); if (grants.premiumLevel) { parts.push(`${grants.premiumLevel} tier`); } if (parts.length === 0) return ''; return `
Includes: ${this.escapeHtml(parts.join(' • '))}
`; }, payoutSummaryHtml: function(mode, currency) { const outcomes = this.config?.payoutOutcomes?.[mode]; if (!outcomes?.escape || !outcomes?.escapeWithTreasure) { return 'Exact payout terms are unavailable. Reload before entering paid play.'; } const describe = (label, outcome) => { if (!outcome.payable) { const why = outcome.suppressedReason === 'below_minimum' ? ' (below configured minimum)' : ''; return `${label}: no payout${why}`; } const amount = `${this.escapeHtml(outcome.amountFormatted)} ${this.escapeHtml(currency)}`; if (outcome.capApplied) return `${label}: ${amount} (configured cap applied)`; return `${label}: ${this.escapeHtml(outcome.multiplier)}× = ${amount}`; }; return `💰 ${describe('Escape', outcomes.escape)} • ${describe('Treasure', outcomes.escapeWithTreasure)}`; }, render: function() { if (!this.config) return; // Display label (sXMR on stagenet): distinct from cryptoType, which formatPrice // still uses for decimals. const currency = this.config.currencyLabel || this.config.cryptoType || 'WOW'; const hasCredits = this.userCredits >= (this.config.creditsPerGame || 1); const creditPackages = this.config.creditPackages || []; const cosmeticProducts = this.config.cosmeticProducts || []; const $container = $('.game-modes'); $container.empty(); // Reset selected package this.selectedPackageId = null; // Explain the two leaderboards up front when both paid and free are available. if (this.config.paymentsEnabled && this.config.freePlayEnabled) { const paidEntryKinds = []; if (this.config.creditsModeEnabled) paidEntryKinds.push('credits'); if (this.config.directModeEnabled) paidEntryKinds.push('single entry'); const paidEntryCopy = paidEntryKinds.join(' or ') || 'a paid entry'; $container.append( '
' + '🏅 Pay (' + this.escapeHtml(paidEntryCopy) + ') to put your high scores in the Hall of Champions.
' + '🪙 Play free: high scores go to the Pleb leaderboard instead.' + '
' ); } // === SECTION 1: Use existing credits (if available) === if (hasCredits && this.config.creditsModeEnabled) { const creditsPayoutsEnabled = this.config.creditsPayoutsEnabled; let payoutNote = ''; if (creditsPayoutsEnabled) { payoutNote = `${this.payoutSummaryHtml('credits', currency)}
🏅 High scores enter the Hall of Champions`; } else { payoutNote = '🏅 High scores enter the Hall of Champions'; } $container.append(`
🎮 You have ${this.userCredits} credits
${payoutNote}
`); } // === SECTION 2: Direct payment (pay per game) === if (this.config.directModeEnabled) { const price = this.config.singleGamePriceFormatted || this.formatPrice(this.config.singleGamePrice); // Payout-honest: only show multipliers when payouts are actually on (default off, not on). const directPayoutsEnabled = this.config.directPayoutsEnabled != null ? !!this.config.directPayoutsEnabled : !!this.config.payoutsEnabled; let payoutInfo = ''; if (directPayoutsEnabled) { payoutInfo = `${this.payoutSummaryHtml('direct', currency)}
🏅 High scores enter the Hall of Champions`; } else { payoutInfo = '🏅 High scores enter the Hall of Champions'; } $container.append(`
⚡ Single-game entry ${price} ${currency}
${payoutInfo}
Adds one purchase-progress point for unlock thresholds; no spendable credit is added.
`); } // === SECTION 3: Buy credits (bulk discount) === if (this.config.creditsModeEnabled && creditPackages.length > 0) { const singlePrice = this.config.singleGamePrice; const creditsPayoutsEnabled = this.config.creditsPayoutsEnabled; // Build payout info for credits mode let creditsPayoutInfo = ''; if (creditsPayoutsEnabled) { creditsPayoutInfo = `
${this.payoutSummaryHtml('credits', currency)}
`; } else { creditsPayoutInfo = `
🏅 High scores enter the Hall of Champions • bulk discount
`; } let packagesHtml = ''; creditPackages.forEach((pkg, index) => { const totalCredits = pkg.credits + (pkg.bonus || 0); const pricePerCredit = pkg.price / totalCredits; const priceFormatted = pkg.priceFormatted || this.formatPrice(pkg.price); const perGamePrice = this.formatPrice(pricePerCredit); // Calculate discount vs direct play let discountBadge = ''; if (singlePrice > 0) { const directCostForSameGames = singlePrice * totalCredits; const savings = ((directCostForSameGames - pkg.price) / directCostForSameGames * 100).toFixed(0); if (savings > 0) { discountBadge = `Save ${savings}%`; } } const bonusText = pkg.bonus > 0 ? ` +${pkg.bonus} bonus` : ''; const grantSummary = this.grantSummaryHtml(pkg.grants, { indent: 22 }); const checked = index === 0 ? 'checked' : ''; if (index === 0) this.selectedPackageId = pkg.id; packagesHtml += ` `; }); $container.append(`
🎫 Buy Credits Bulk discount • tied to this browser session
${creditsPayoutInfo}
${packagesHtml}
`); } // === SECTION 4: Standalone cosmetic/render products === if (this.config.paymentsEnabled && cosmeticProducts.length > 0) { let productsHtml = ''; cosmeticProducts.forEach(product => { const priceFormatted = product.priceFormatted || this.formatPrice(product.price); const label = this.escapeHtml(product.label || product.id); const grantSummary = this.grantSummaryHtml(product.grants, { includeCredits: true }); productsHtml += `
${label} ${priceFormatted} ${currency}
${grantSummary}
`; }); $container.append(`
Premium Packs Cosmetics and render modes
${productsHtml}
`); } // === Credits balance display (if has some but not enough) === if (this.config.creditsModeEnabled && this.userCredits > 0 && !hasCredits) { $container.prepend(`
⚠️ You have ${this.userCredits} credits (need ${this.config.creditsPerGame || 1} to play)
`); } // === FREE play === shown when the instance is free-only OR offers free as a choice // alongside paid (freePlayEnabled). Free games go to the Pleb leaderboard. if (this.config.freePlayEnabled) { const note = this.config.paymentsEnabled ? '🪙 Free: high scores go to the Pleb leaderboard' : 'No payments required'; $container.append(` `); } }, handleModeSelection: function(mode, action, packageId, acknowledgementReady, legalAcknowledgement) { console.log("Mode selected:", mode, "Action:", action, "Package:", packageId); if (mode === 'FREE') { $('#payment-ui').hide(); if (window.socket && typeof SocketHandlers !== 'undefined' && SocketHandlers.emitFairGameStart) { // play_free records the game as FREE (Pleb leaderboard, no payout) even on // an instance that also sells credits/entry. SocketHandlers.emitFairGameStart('play_free', { free: true }); } return; } // A checkbox in the browser is not a legal/identity verification, but requiring a fresh // per-session statement prevents the UI from silently opening a value-bearing action. if (!acknowledgementReady) { if (typeof CommerceConsent === 'undefined' || !CommerceConsent.require) { $('#payment-status').text('Paid-play disclosures are unavailable. No payment was requested.'); return; } CommerceConsent.require(function(acknowledgement) { PaymentUI.handleModeSelection(mode, action, packageId, true, acknowledgement); }); return; } // If using existing credits, just start the game if (action === 'use_credit' && this.userCredits >= 1) { $('#payment-ui').hide(); if (window.socket && typeof SocketHandlers !== 'undefined' && SocketHandlers.emitFairGameStart) { SocketHandlers.emitFairGameStart('auto_start', { legalAcknowledgement: legalAcknowledgement }, true); } return; } // Request payment with type and optional packageId let paymentType = 'single_game'; if (action === 'buy_credits') { paymentType = 'credits_package'; } else if (action === 'buy_product') { paymentType = 'cosmetic_pack'; } const requestData = { type: paymentType }; if (legalAcknowledgement) requestData.legalAcknowledgement = legalAcknowledgement; if (packageId) { if (paymentType === 'cosmetic_pack') { requestData.productId = packageId; } else { requestData.packageId = packageId; } } if (window.socket) { if (paymentType === 'single_game' && typeof SocketHandlers !== 'undefined' && SocketHandlers.fairnessAttempt) { var fairRequest = SocketHandlers.fairnessAttempt(requestData); if (!fairRequest) return; window.socket.emit('request_payment', fairRequest); } else { window.socket.emit('request_payment', requestData); } // Show loading state $('.game-modes').hide(); $('#payment-details').show(); $('#payment-status').text('Requesting payment address...'); $('#payment-amount').text('...'); $('#payment-address').val('Loading...'); } }, showPaymentRequest: function(data) { $('#payment-ui').show(); $('#payment-details').show(); $('.game-modes').hide(); const isCreditsPurchase = data.paymentType === 'credits_package'; const isProductPurchase = data.paymentType === 'cosmetic_pack'; let headerText = '💳 Payment Required'; if (isCreditsPurchase && data.package) { const bonus = data.package.bonus > 0 ? ` +${data.package.bonus} bonus` : ''; headerText = `🎫 Buy ${data.package.credits}${bonus} Credits`; } else if (isProductPurchase && data.package) { headerText = `Buy ${data.package.label || data.package.id || 'Pack'}`; } $('.payment-header strong').text(headerText); const displayAmount = data.humanAmount || data.amountFormatted || data.amount; $('#payment-amount').text(displayAmount + ' ' + (data.currency || '')); $('#payment-address').val(data.address); $('#check-payment').data('payment-id', data.paymentId || data.id); if (data.reused) { $('#payment-status').html('♻️ Using existing pending payment'); } else { const statusText = isCreditsPurchase ? 'Send payment to receive credits...' : (isProductPurchase ? 'Send payment to unlock this pack...' : 'Waiting for payment...'); $('#payment-status').text(statusText); } }, show: function() { this.render(); // Ensure options are up-to-date with current config $('#payment-ui').show(); $('.game-modes').show(); $('#payment-details').hide(); $('.payment-header strong').text('🎮 Choose How to Play'); } }; // Expose globally window.PaymentUI = PaymentUI; // Initialize when DOM is ready $(document).ready(function() { PaymentUI.init(); });