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()) {