mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 22:58:32 +02:00
Limit payment requests
This commit is contained in:
@@ -327,8 +327,12 @@ const SocketHandlers = {
|
||||
console.log('Payment created:', data);
|
||||
if (typeof AudioAlerts !== 'undefined') { AudioAlerts.playRequestCoin(); }
|
||||
const parts = [];
|
||||
parts.push('💳 <strong>Payment Required</strong>');
|
||||
parts.push('Amount: ' + data.humanAmount + ' ' + data.cryptoType);
|
||||
const reusedCopy = data.reused
|
||||
? '🔁 <strong>Pending payment request still active.</strong> Use the same amount and address below.'
|
||||
: '💳 <strong>Payment Required</strong>';
|
||||
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: <span class="pay-address-full" style="cursor:pointer;" title="Click to toggle full address">' + shortAddr + '</span>' +
|
||||
' <button class="copy-pay-address" data-address="' + data.address + '" style="margin-left:4px;padding:1px 4px;font-size:11px;cursor:pointer;">Copy</button>');
|
||||
|
||||
+39
-14
@@ -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'); }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user