From fa61d60bc0022f924534d84844a9b695035ad15c Mon Sep 17 00:00:00 2001 From: jwinterm Date: Tue, 30 Sep 2025 07:03:41 -0400 Subject: [PATCH] Limit payment requests --- html/js/network/socketHandlers.js | 8 +++- html/js/ui/audioAlerts.js | 53 ++++++++++++++++------- src/game/gameModeManager.js | 70 +++++++++++++++++++++++++++---- src/network/paymentHandlers.js | 64 +++++++++++++++++----------- 4 files changed, 146 insertions(+), 49 deletions(-) diff --git a/html/js/network/socketHandlers.js b/html/js/network/socketHandlers.js index 4f451d9..1895a8b 100644 --- a/html/js/network/socketHandlers.js +++ b/html/js/network/socketHandlers.js @@ -327,8 +327,12 @@ const SocketHandlers = { console.log('Payment created:', data); if (typeof AudioAlerts !== 'undefined') { AudioAlerts.playRequestCoin(); } const parts = []; - parts.push('šŸ’³ Payment Required'); - parts.push('Amount: ' + data.humanAmount + ' ' + data.cryptoType); + const reusedCopy = data.reused + ? 'šŸ” Pending payment request still active. Use the same amount and address below.' + : 'šŸ’³ Payment Required'; + parts.push(reusedCopy); + const displayAmount = data.humanAmount || data.amountFormatted || data.amount; + parts.push('Amount: ' + displayAmount + ' ' + (data.cryptoType || '')); const shortAddr = data.address.substring(0, 10) + '…' + data.address.slice(-6); parts.push('Address: ' + shortAddr + '' + ' '); diff --git a/html/js/ui/audioAlerts.js b/html/js/ui/audioAlerts.js index d55ddcd..784b78e 100644 --- a/html/js/ui/audioAlerts.js +++ b/html/js/ui/audioAlerts.js @@ -13,6 +13,9 @@ const AudioAlerts = { _lastPlay: 0, _cooldownMs: 1500, _audioTags: {}, // cache HTMLAudioElements + _audioDurations: {}, + _sequentialBufferMs: 600, + _minGameStartDelayMs: 2800, _gameStartHoldUntil: 0, _pendingGameStartTimer: null, _fileMap: { @@ -130,25 +133,33 @@ const AudioAlerts = { if (!src) return null; const el = new Audio(src); el.preload = 'auto'; + el.addEventListener('loadedmetadata', () => { + if (isFinite(el.duration) && el.duration > 0) { + this._audioDurations[tag] = Math.ceil(el.duration * 1000); + } + }, { once: true }); this._audioTags[tag] = el; } return this._audioTags[tag]; }, - playFile(tag) { + playFile(tag, options = {}) { if (!this._enabled) return null; + const force = !!options.force; const now = Date.now(); // Per-tag cooldown so different event types can fire back-to-back (e.g. payment_confirmed -> game_start) if (!this._audioTagTimes) this._audioTagTimes = {}; // tag -> last play timestamp const lastForTag = this._audioTagTimes[tag] || 0; const elapsed = now - lastForTag; - const allow = elapsed >= this._cooldownMs || (tag === 'game_start' && this._lastTag !== 'game_start'); + const allow = force || elapsed >= this._cooldownMs || (tag === 'game_start' && this._lastTag !== 'game_start'); if (!allow) return null; this._audioTagTimes[tag] = now; this._lastTag = tag; const el = this._getAudio(tag); if (!el) return null; try { + el.pause(); el.currentTime = 0; + el.volume = this._volume; el.play().catch(()=>{}); } catch(_) {} return el; @@ -156,11 +167,14 @@ const AudioAlerts = { playPaymentConfirmed() { if (!this._enabled) return; const el = this.playFile('payment_confirmed'); - const fallback = 2200; - let holdMs = fallback; - if (el && isFinite(el.duration) && el.duration > 0.2) { - holdMs = Math.ceil(el.duration * 1000); - } + const storedDuration = this._audioDurations?.payment_confirmed || 0; + const measuredMs = (el && isFinite(el.duration) && el.duration > 0.2) + ? Math.ceil(el.duration * 1000) + : storedDuration; + const holdMs = Math.max( + this._minGameStartDelayMs, + (measuredMs || 0) + this._sequentialBufferMs + ); this._gameStartHoldUntil = Date.now() + holdMs; }, playGameStart() { @@ -183,25 +197,36 @@ const AudioAlerts = { }, playGameOverSound(gameOverData) { if (!this._enabled) return; - if (!gameOverData || !gameOverData.reason) return; - + if (!gameOverData) return; + + let reason = (gameOverData.reason || '').toLowerCase(); let soundTag = ''; - switch (gameOverData.reason) { + switch (reason) { case 'escaped': - // Check if they escaped with treasure soundTag = gameOverData.treasure ? 'game_escaped_treasure' : 'game_escaped'; break; + case 'treasure_escape': + soundTag = 'game_escaped_treasure'; + break; case 'timeout': soundTag = 'game_timeout'; break; case 'monster': + case 'monster_caught': + case 'caught': soundTag = 'game_monster'; break; default: - return; // Unknown reason, no audio + if (gameOverData.status === 'won') { + soundTag = gameOverData.treasure ? 'game_escaped_treasure' : 'game_escaped'; + } else if (gameOverData.status === 'lost') { + soundTag = reason === 'timeout' ? 'game_timeout' : 'game_monster'; + } + } + + if (soundTag) { + this.playFile(soundTag, { force: true }); } - - this.playFile(soundTag); }, // Public helper to be invoked when server denies start due to payment/credits playRequestCoin() { this.playFile('request_coin'); } diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index d95851d..e355aff 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -239,6 +239,38 @@ class GameModeManager { return { amount, multiplier, base }; } + async _findReusablePayment(userId, paymentType) { + if (!userId) return null; + const result = await this.db.query(` + SELECT id, subaddress, expected_amount, payment_type, status, created_at, expires_at, description + FROM payments + WHERE user_id = $1 + AND payment_type = $2 + AND status = 'pending' + AND (expires_at IS NULL OR expires_at > NOW()) + ORDER BY created_at DESC + LIMIT 1 + `, [userId, paymentType]); + return result.rows[0] || null; + } + + _mapPaymentRowToRequest(row, paymentType, packageInfo) { + if (!row) return null; + const amount = Number(row.expected_amount); + return { + id: row.id, + address: row.subaddress, + amount, + amountFormatted: this.formatAtomicHuman(amount, 4), + currency: this.cryptoType, + expiresAt: row.expires_at, + paymentType, + description: row.description, + package: packageInfo || null, + reused: true + }; + } + logConfiguration(context = 'initialized') { console.log(`šŸŽ® Game Mode Manager ${context}: ${this.gameMode} mode`); console.log(`šŸ’° Currency: ${this.cryptoType} (decimals: ${this.currencyDecimals})`); @@ -465,10 +497,11 @@ class GameModeManager { /** * Create payment request */ - async createPaymentRequest(socketId, paymentType) { + async createPaymentRequest(socketId, paymentType, options = {}) { try { const user = await this.getOrCreateUser(socketId); - + const reuseExisting = options.reuseExisting !== false; + let amount; let description; let packageInfo = null; @@ -493,6 +526,17 @@ class GameModeManager { safeMessage: 'Unsupported payment type requested.' }); } + + if (reuseExisting) { + const existingRow = await this._findReusablePayment(user.id, paymentType); + if (existingRow) { + const existing = this._mapPaymentRowToRequest(existingRow, paymentType, packageInfo); + if (existing && !existing.description) { + existing.description = description; + } + return existing; + } + } // Create payment request using wallet RPC with correct parameters const paymentResult = await this.walletService.createPaymentRequest( @@ -502,20 +546,28 @@ class GameModeManager { socketId ); + const expiresAt = paymentResult.expiresAt || new Date(Date.now() + 30 * 60 * 1000); + // Store payment info in database - await this.db.query(` - INSERT INTO payments (user_id, socket_id, subaddress, expected_amount, payment_type, status, created_at) - VALUES ($1, $2, $3, $4, $5, 'pending', NOW()) - `, [user.id, socketId, paymentResult.address, amount, paymentType]); + const insertResult = await this.db.query(` + INSERT INTO payments (user_id, socket_id, subaddress, expected_amount, payment_type, status, description, created_at, expires_at) + VALUES ($1, $2, $3, $4, $5, 'pending', $6, NOW(), $7) + RETURNING id, expires_at + `, [user.id, socketId, paymentResult.address, amount, paymentType, description, expiresAt]); + + const insertedRow = insertResult.rows[0]; return { - id: paymentResult.id, + id: insertedRow?.id, address: paymentResult.address, amount: amount, amountFormatted: this.formatAtomicHuman(amount, 4), currency: this.cryptoType, - expiresAt: paymentResult.expiresAt, - package: packageInfo + expiresAt: insertedRow?.expires_at || expiresAt, + package: packageInfo, + paymentType, + description, + reused: false }; } catch (error) { diff --git a/src/network/paymentHandlers.js b/src/network/paymentHandlers.js index cb268a9..a167917 100644 --- a/src/network/paymentHandlers.js +++ b/src/network/paymentHandlers.js @@ -16,8 +16,8 @@ class PaymentHandlers { this.broadcastManager = broadcastManager; this.mempoolNotified = new Set(); this.paymentMonitors = new Map(); - // Track active monitored address per socket so we can stop on disconnect/expiry - this.socketAddressMap = new Map(); + // Track pending payment metadata per socket (may reuse existing) + this.socketPaymentMap = new Map(); // socketId -> { address, paymentId, amount, cryptoType, createdAt } // Track paymentIds already confirmed (avoid duplicate emits / queue actions) this.confirmedPayments = new Set(); // Periodic cleanup (confirmed payment IDs older than retention window) @@ -108,7 +108,8 @@ class PaymentHandlers { return; } - const paymentRequest = await this.gameModeManager.createPaymentRequest(socket.id, paymentType); + const paymentRequest = await this.gameModeManager.createPaymentRequest(socket.id, paymentType, { reuseExisting: true }); + const reused = !!paymentRequest.reused; const formattedAmount = paymentRequest.amountFormatted ?? this.gameModeManager.formatAtomicHuman(amount, 3); let qrDataUrl = null; try { @@ -127,21 +128,31 @@ class PaymentHandlers { address: paymentRequest.address, amount: paymentRequest.amount, amountFormatted: formattedAmount, + humanAmount: formattedAmount, paymentType, gameMode, cryptoType, description, expiresAt: paymentRequest.expiresAt, qr: qrDataUrl, - package: paymentRequest.package + package: paymentRequest.package, + reused }); + const statusHeader = reused + ? 'šŸ” Existing payment request still pending. Use the details below to pay.' + : `šŸ’³ PAYMENT REQUIRED (${description})`; + + const statusBody = `\n\nAmount: ${formattedAmount} ${cryptoType}\nAddress: ${paymentRequest.address}\n\nāš ļø Send EXACTLY ${formattedAmount} ${cryptoType}.\nšŸ”„ Added to queue once mempool seen.\nā° Expires in 30 minutes.`; + this.broadcastManager.sendStatusUpdate( socket.id, 'payment', - `šŸ’³ PAYMENT REQUIRED (${description})\n\nAmount: ${formattedAmount} ${cryptoType}\nAddress: ${paymentRequest.address}\n\nāš ļø Send EXACTLY ${formattedAmount} ${cryptoType}.\nšŸ”„ Added to queue once mempool seen.\nā° Expires in 30 minutes.` + `${statusHeader}${statusBody}` ); + // If we reused an existing request, ensure we refresh monitoring to avoid duplicate watchers + this.stopMonitoringForSocket(socket.id); this._monitorAddress(socket, paymentRequest, paymentRequest.amount, cryptoType, currentUser); } catch (e) { const err = normalizeError(e, 'Failed to create payment request'); @@ -151,8 +162,14 @@ class PaymentHandlers { } _monitorAddress(socket, paymentRequest, amount, cryptoType, currentUser) { - // Record mapping so we can stop later - this.socketAddressMap.set(socket.id, paymentRequest.address); + // Record mapping so we can stop later (replace existing entry) + this.socketPaymentMap.set(socket.id, { + address: paymentRequest.address, + paymentId: paymentRequest.id, + amount: paymentRequest.amount, + cryptoType, + createdAt: Date.now() + }); this.walletService.startPaymentMonitoring(paymentRequest.address, async (status) => { if (status.in_mempool && !status.confirmed) { @@ -186,15 +203,14 @@ class PaymentHandlers { // Expire request after 30 minutes if not confirmed const expiryTimeout = setTimeout(() => { - const address = this.socketAddressMap.get(socket.id); - if (address === paymentRequest.address) { // still active and not replaced + const mapping = this.socketPaymentMap.get(socket.id); + if (mapping && mapping.address === paymentRequest.address) { // still active and not replaced this.stopMonitoringForSocket(socket.id); this.broadcastManager.sendStatusUpdate(socket.id, 'warning', 'Payment request expired. Type \'enter\' again to create a new payment request.'); } }, 30 * 60 * 1000); // Store & unref so tests / process can exit this._expiryTimeouts.set(socket.id, expiryTimeout); - if (expiryTimeout.unref) expiryTimeout.unref(); } async handlePaymentDetected(socketId, paymentRequest, paymentStatus) { @@ -212,15 +228,15 @@ class PaymentHandlers { } else if (paymentStatus.confirmed) { this.broadcastManager.sendStatusUpdate(socketId, 'success', `šŸ’° PAYMENT CONFIRMED IN BLOCK\n\nāœ… Added to queue.\nšŸ•’ Starts at block ${nextBlock}.\nšŸ“¦ Current block: ${currentBlock}`); } - const newBalRes = await this.db.query('SELECT credits FROM users WHERE id = $1', [currentUser.id]); - const remaining = newBalRes.rows[0] ? newBalRes.rows[0].credits : currentUser.credits; - if (this.io) { - this.io.to(socketId).emit('credits_update', { balance: remaining }); - } - if (!this.confirmedPayments.has(paymentRequest.id)) { - this.confirmedPayments.add(paymentRequest.id); - this.io.to(socketId).emit('payment_confirmed', { paymentId: paymentRequest.id, status: paymentStatus, nextBlock, currentBlock }); - this._confirmedTimestamps.set(paymentRequest.id, Date.now()); + const newBalRes = await this.db.query('SELECT credits FROM users WHERE id = $1', [currentUser.id]); + const remaining = newBalRes.rows[0] ? newBalRes.rows[0].credits : currentUser.credits; + if (this.io) { + this.io.to(socketId).emit('credits_update', { balance: remaining }); + } + if (!this.confirmedPayments.has(paymentRequest.id)) { + this.confirmedPayments.add(paymentRequest.id); + this.io.to(socketId).emit('payment_confirmed', { paymentId: paymentRequest.id, status: paymentStatus, nextBlock, currentBlock }); + this._confirmedTimestamps.set(paymentRequest.id, Date.now()); } } @@ -228,10 +244,10 @@ class PaymentHandlers { * Stop monitoring (if any) for a given socket (disconnect/expiry) */ stopMonitoringForSocket(socketId) { - const address = this.socketAddressMap.get(socketId); - if (address) { - this.walletService.stopPaymentMonitoring(address); - this.socketAddressMap.delete(socketId); + const mapping = this.socketPaymentMap.get(socketId); + if (mapping) { + this.walletService.stopPaymentMonitoring(mapping.address); + this.socketPaymentMap.delete(socketId); if (this.debugManager.CONSOLE_LOGGING) { console.log(`šŸ›‘ Stopped monitoring for socket ${socketId}`); } @@ -251,7 +267,7 @@ class PaymentHandlers { clearInterval(this._cleanupInterval); this._cleanupInterval = null; } - for (const [socketId] of this.socketAddressMap.entries()) { + for (const socketId of Array.from(this.socketPaymentMap.keys())) { this.stopMonitoringForSocket(socketId); } for (const [socketId, to] of this._expiryTimeouts.entries()) {