mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 22:31:26 +02:00
Begin payment logic and tests
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 18 KiB |
@@ -29,6 +29,7 @@
|
||||
|
||||
<!-- Other modules -->
|
||||
<script src="js/ui/ui.js"></script>
|
||||
<script src="js/ui/audioAlerts.js"></script>
|
||||
<script src="js/network/socketHandlers.js"></script>
|
||||
<script src="js/input/inputHandler.js"></script>
|
||||
</head>
|
||||
@@ -51,6 +52,11 @@
|
||||
<button id="animationToggleButton" style="background: #5c5c0a; color: #ff0; border: 2px solid #aa0; padding: 6px 12px; font-weight: bold; cursor: pointer; margin-top: 5px; margin-left: 10px; display: none;">
|
||||
🎬 DISABLE ANIMATION
|
||||
</button>
|
||||
<div style="margin-top:6px; font-size:11px; color:#ccc; display:flex; align-items:center; gap:4px;">
|
||||
<label style="cursor:pointer; display:flex; align-items:center; gap:4px;">
|
||||
<input type="checkbox" id="audioAlertsToggle" style="cursor:pointer;"> Audio Alerts
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment System UI -->
|
||||
@@ -160,6 +166,7 @@
|
||||
if (typeof SocketHandlers !== 'undefined') {
|
||||
console.log("Initializing SocketHandlers...");
|
||||
SocketHandlers.init();
|
||||
if (typeof AudioAlerts !== 'undefined') { AudioAlerts.init(); }
|
||||
} else {
|
||||
console.error("SocketHandlers object not found!");
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ var Game = {
|
||||
_maxLogMessages: 5,
|
||||
_unconfirmedPayment: false,
|
||||
_unconfirmedPaymentInfo: null,
|
||||
_awaitingPayment: false,
|
||||
|
||||
init: function() {
|
||||
// Update screen dimensions from options
|
||||
@@ -178,7 +179,7 @@ var Game = {
|
||||
const baseY = Math.floor(ScreenManager._screenHeight / 2) + 5;
|
||||
ScreenManager.drawCenteredText(baseY, `Score: ${score}`);
|
||||
if (treasure) {
|
||||
ScreenManager.drawCenteredText(baseY + 1, 'You secured the treasure!');
|
||||
ScreenManager.drawCenteredText(baseY + 1, 'Treasure secured!');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,11 +342,17 @@ var Game = {
|
||||
Game._pendingPaymentDetected = function(info) {
|
||||
this._unconfirmedPayment = true;
|
||||
this._unconfirmedPaymentInfo = info || {};
|
||||
this._awaitingPayment = false; // no longer just awaiting creation; we've seen a tx
|
||||
};
|
||||
|
||||
Game._pendingPaymentConfirmed = function() {
|
||||
this._unconfirmedPayment = false;
|
||||
this._unconfirmedPaymentInfo = null;
|
||||
this._awaitingPayment = false;
|
||||
};
|
||||
|
||||
Game._paymentRequested = function() {
|
||||
this._awaitingPayment = true;
|
||||
};
|
||||
|
||||
// Debug utility functions (keep as-is for compatibility)
|
||||
|
||||
@@ -202,26 +202,26 @@ var ScreenManager = {
|
||||
this.drawCenteredText(y, "CONGRATULATIONS!");
|
||||
|
||||
if (hasTreasure) {
|
||||
this.drawCenteredText(y + 2, "You escaped with the treasure!");
|
||||
this.drawCenteredText(y + 2, "You got the treasure!");
|
||||
} else {
|
||||
this.drawCenteredText(y + 2, "You escaped the dungeon!");
|
||||
this.drawCenteredText(y + 2, "You escaped alive!");
|
||||
}
|
||||
this.drawCenteredText(y + 3, "A true hero of Wownero!");
|
||||
this.drawCenteredText(y + 3, "Hero of Wownero!");
|
||||
},
|
||||
|
||||
drawLoseScreen: function(reason) {
|
||||
if (!DisplayManager.ensureDisplay()) return;
|
||||
DisplayManager.clearDisplay();
|
||||
|
||||
let y = Math.floor(this._screenHeight / 3);
|
||||
let y = Math.floor(this._screenHeight / 3);
|
||||
this.drawCenteredText(y, "YOU HAVE PERISHED");
|
||||
|
||||
if (reason === 'monster') {
|
||||
this.drawCenteredText(y + 2, "Slain by a fearsome beast.");
|
||||
this.drawCenteredText(y + 2, "Slain by a beast.");
|
||||
} else if (reason === 'timeout') {
|
||||
this.drawCenteredText(y + 2, "The dungeon claimed you before the next block.");
|
||||
this.drawCenteredText(y + 2, "Time ran out.");
|
||||
} else {
|
||||
this.drawCenteredText(y + 2, "Your adventure ends here.");
|
||||
this.drawCenteredText(y + 2, "Your adventure ends.");
|
||||
}
|
||||
this.drawCenteredText(y + 3, "Better luck next time.");
|
||||
},
|
||||
|
||||
@@ -76,7 +76,11 @@ var WaitingScreenAnimator = {
|
||||
|
||||
// Fixed-position animated text
|
||||
let y = Math.floor(screenHeight / 2) - 8;
|
||||
const baseText = "Awaiting next block";
|
||||
let baseText = "Awaiting next block";
|
||||
if (typeof Game !== 'undefined') {
|
||||
if (Game._awaitingPayment) baseText = 'Awaiting payment';
|
||||
else if (Game._unconfirmedPayment) baseText = 'Awaiting next block'; // mempool seen
|
||||
}
|
||||
const dots = ".".repeat((Math.floor(Date.now() / 500) % 4));
|
||||
const paddedDots = dots.padEnd(3, " ");
|
||||
drawCenteredTextFn(y, `${baseText}${paddedDots}`);
|
||||
@@ -364,15 +368,53 @@ var WaitingScreenAnimator = {
|
||||
},
|
||||
|
||||
resetAnimation: function() {
|
||||
// Track cycle count for variation
|
||||
if (typeof this._cycleCount === 'undefined') this._cycleCount = 0;
|
||||
this._cycleCount++;
|
||||
|
||||
// Random variant selection after first loop
|
||||
const variant = this._cycleCount === 1 ? 'classic' : (Math.random() < 0.33 ? 'reverse' : (Math.random() < 0.5 ? 'race' : 'ambush'));
|
||||
const basePlayerX = 8;
|
||||
const baseMonsterX = 5;
|
||||
let playerStart = basePlayerX;
|
||||
let monsterStart = baseMonsterX;
|
||||
let treasureX = 35;
|
||||
let initialPhase = 'enter';
|
||||
|
||||
switch(variant) {
|
||||
case 'reverse':
|
||||
// Swap sides: player closer to treasure, monster further back
|
||||
playerStart = 25 + Math.floor(Math.random()*4);
|
||||
monsterStart = playerStart - 10;
|
||||
treasureX = playerStart + 5 + Math.floor(Math.random()*3);
|
||||
break;
|
||||
case 'race':
|
||||
// Both start near entrance racing for treasure
|
||||
playerStart = 6;
|
||||
monsterStart = 4;
|
||||
treasureX = 40 + Math.floor(Math.random()*3);
|
||||
break;
|
||||
case 'ambush':
|
||||
// Monster waits near treasure
|
||||
playerStart = 8;
|
||||
monsterStart = 30 + Math.floor(Math.random()*3);
|
||||
treasureX = monsterStart + 3;
|
||||
initialPhase = 'chase'; // jump into chase faster
|
||||
break;
|
||||
default:
|
||||
// classic
|
||||
break;
|
||||
}
|
||||
|
||||
this._waitingAnimation = {
|
||||
frame: 0,
|
||||
playerX: 8, // Start near entrance
|
||||
playerX: playerStart, // variant start
|
||||
playerY: 12,
|
||||
monsterX: 5, // Start at entrance
|
||||
monsterX: monsterStart, // variant start
|
||||
monsterY: 12,
|
||||
treasureX: 35, // Treasure near exit
|
||||
treasureX: treasureX, // variant treasure
|
||||
treasureY: 12,
|
||||
phase: 'enter',
|
||||
phase: initialPhase,
|
||||
frameCount: 0,
|
||||
direction: 1
|
||||
};
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
*/
|
||||
const SocketHandlers = {
|
||||
_initialized: false,
|
||||
_didConnect: false,
|
||||
_setBannerStatus: function(text, color) {
|
||||
const el = $('#statusValue');
|
||||
if (!el.length) return;
|
||||
el.text(text);
|
||||
if (color) el.css('color', color);
|
||||
},
|
||||
|
||||
init: function() {
|
||||
if (this._initialized) {
|
||||
@@ -16,6 +23,11 @@ const SocketHandlers = {
|
||||
|
||||
this.registerEventHandlers();
|
||||
this._initialized = true;
|
||||
|
||||
// If the low-level socket connected before handlers were registered, emulate onConnect.
|
||||
if (window.socket && window.socket.connected) {
|
||||
this.onConnect();
|
||||
}
|
||||
},
|
||||
|
||||
registerEventHandlers: function() {
|
||||
@@ -39,17 +51,21 @@ const SocketHandlers = {
|
||||
socket.on('address_confirmed', this.onAddressConfirmed);
|
||||
socket.on('payment_created', this.onPaymentCreated);
|
||||
socket.on('payment_confirmed', this.onPaymentConfirmed);
|
||||
socket.on('payment_detected', this.onPaymentDetected);
|
||||
socket.on('payment_detected', this.onPaymentDetected);
|
||||
|
||||
// Block height handler
|
||||
socket.on('blockheight', this.onBlockHeight);
|
||||
},
|
||||
|
||||
onConnect: function() {
|
||||
if (SocketHandlers._didConnect) return; // prevent duplicate registration emission
|
||||
SocketHandlers._didConnect = true;
|
||||
socket.emit('register_client', {
|
||||
clientId: socket.id,
|
||||
userAgent: navigator.userAgent
|
||||
});
|
||||
// Update small status banner immediately
|
||||
SocketHandlers._setBannerStatus('Connected', '#0f0');
|
||||
},
|
||||
|
||||
onWelcome: function(msg) {
|
||||
@@ -71,6 +87,20 @@ const SocketHandlers = {
|
||||
|
||||
$('#messages').append($('<li class="' + statusClass + '" style="color: ' + statusColor + '; white-space: pre-line;">').text(data.message));
|
||||
UI.scrollChat();
|
||||
|
||||
// Mirror concise status in header banner for key lifecycle types
|
||||
if (data.type === 'connection') {
|
||||
SocketHandlers._setBannerStatus('Ready', '#0f0');
|
||||
} else if (data.type === 'queue') {
|
||||
SocketHandlers._setBannerStatus('Queued', '#0af');
|
||||
} else if (data.type === 'payment') {
|
||||
// Generic payment message (more granular handlers override later)
|
||||
SocketHandlers._setBannerStatus('Payment', '#0af');
|
||||
} else if (data.type === 'error') {
|
||||
SocketHandlers._setBannerStatus('Error', '#f00');
|
||||
} else if (data.type === 'success') {
|
||||
SocketHandlers._setBannerStatus('Success', '#0f0');
|
||||
}
|
||||
},
|
||||
|
||||
onChatBroadcast: function(data) {
|
||||
@@ -97,16 +127,25 @@ const SocketHandlers = {
|
||||
|
||||
onQueueJoined: function(data) {
|
||||
console.log('Queue joined:', data);
|
||||
|
||||
const currentBlockFallback = (typeof data.currentBlock === 'number' && data.currentBlock > 0)
|
||||
? data.currentBlock
|
||||
: (typeof UI !== 'undefined' && typeof UI._currentBlockHeight === 'number' && UI._currentBlockHeight > 0
|
||||
? UI._currentBlockHeight
|
||||
: '?');
|
||||
const nextBlockFallback = (typeof data.nextBlock === 'number' && data.nextBlock > 0)
|
||||
? data.nextBlock
|
||||
: (currentBlockFallback === '?' ? '?' : currentBlockFallback + 1);
|
||||
|
||||
$('#messages').append($('<li class="queue-info" style="color:#0f0;">').html(
|
||||
'�� <strong>Queue Joined!</strong> Position: ' + data.position + '<br>' +
|
||||
'📦 Current block: ' + data.currentBlock + ', Next: ' + data.nextBlock
|
||||
'⏳ <strong>Queue Joined!</strong> Position: ' + data.position + '<br>' +
|
||||
'📦 Current block: ' + currentBlockFallback + ', Next: ' + nextBlockFallback
|
||||
));
|
||||
UI.scrollChat();
|
||||
|
||||
if (typeof Game !== 'undefined' && Game.drawWaitingScreen) {
|
||||
Game.drawWaitingScreen();
|
||||
}
|
||||
SocketHandlers._setBannerStatus('Queued', '#0af');
|
||||
},
|
||||
|
||||
onGameStart: function(data) {
|
||||
@@ -140,6 +179,7 @@ const SocketHandlers = {
|
||||
}
|
||||
|
||||
UI.scrollChat();
|
||||
SocketHandlers._setBannerStatus('In Game', '#0f0');
|
||||
},
|
||||
|
||||
onGameUpdate: function(data) {
|
||||
@@ -170,6 +210,12 @@ const SocketHandlers = {
|
||||
}
|
||||
|
||||
UI.scrollChat();
|
||||
const won = data && (data.status === 'won' || data.reason === 'escaped');
|
||||
if (won) {
|
||||
SocketHandlers._setBannerStatus('Won', '#0f0');
|
||||
} else {
|
||||
SocketHandlers._setBannerStatus('Lost', '#f00');
|
||||
}
|
||||
},
|
||||
|
||||
onQueueCancelled: function(data) {
|
||||
@@ -180,6 +226,7 @@ const SocketHandlers = {
|
||||
}
|
||||
|
||||
UI.scrollChat();
|
||||
SocketHandlers._setBannerStatus('Ready', '#0f0');
|
||||
},
|
||||
|
||||
onAddressDetected: function(data) {
|
||||
@@ -196,49 +243,167 @@ const SocketHandlers = {
|
||||
|
||||
onPaymentCreated: function(data) {
|
||||
console.log('Payment created:', data);
|
||||
|
||||
$('#messages').append($('<li class="payment-info">').html(
|
||||
'💳 <strong>Payment Required:</strong> ' + data.humanAmount + ' ' + data.cryptoType
|
||||
));
|
||||
const parts = [];
|
||||
parts.push('💳 <strong>Payment Required</strong>');
|
||||
parts.push('Amount: ' + data.humanAmount + ' ' + 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>');
|
||||
// (Removed inline QR image to avoid duplication; dedicated pinned sidebar QR is used instead.)
|
||||
const $li = $('<li class="payment-info" style="white-space:normal;">').html(parts.join('<br>'));
|
||||
$('#messages').append($li);
|
||||
|
||||
// Attach copy handler (delegated inside this LI to avoid duplicates)
|
||||
const fullAddress = data.address;
|
||||
$li.on('click', '.copy-pay-address', function(e) {
|
||||
e.preventDefault();
|
||||
const addr = $(this).data('address');
|
||||
const doCopy = async () => {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(addr);
|
||||
} else {
|
||||
// Fallback
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = addr; document.body.appendChild(ta); ta.select();
|
||||
document.execCommand('copy'); document.body.removeChild(ta);
|
||||
}
|
||||
$(this).text('Copied!');
|
||||
setTimeout(() => { $(this).text('Copy'); }, 2000);
|
||||
} catch (err) {
|
||||
console.error('Copy failed', err);
|
||||
$(this).text('Error');
|
||||
setTimeout(() => { $(this).text('Copy'); }, 2500);
|
||||
}
|
||||
};
|
||||
doCopy();
|
||||
});
|
||||
|
||||
// Toggle short/full address on span click
|
||||
let showingFull = false;
|
||||
$li.on('click', '.pay-address-full', function() {
|
||||
showingFull = !showingFull;
|
||||
$(this).text(showingFull ? fullAddress : shortAddr);
|
||||
});
|
||||
UI.scrollChat();
|
||||
|
||||
setTimeout(function() {
|
||||
$('#chatInput').focus();
|
||||
}, 100);
|
||||
SocketHandlers._setBannerStatus('Pay', '#0af');
|
||||
if (typeof Game !== 'undefined' && Game._paymentRequested) Game._paymentRequested();
|
||||
|
||||
// Sidebar QR (persistent) - create/update separate from chat scroll
|
||||
if (data.qr) {
|
||||
let qrHolder = document.getElementById('paymentQRContainer');
|
||||
if (!qrHolder) {
|
||||
const statusDiv = document.querySelector('.status');
|
||||
if (statusDiv) {
|
||||
qrHolder = document.createElement('div');
|
||||
qrHolder.id = 'paymentQRContainer';
|
||||
qrHolder.style.cssText = 'margin-top:6px;padding:4px;border:1px solid #0f0;background:#000;display:inline-block;width:150px;text-align:center;';
|
||||
statusDiv.appendChild(qrHolder);
|
||||
}
|
||||
}
|
||||
if (qrHolder) {
|
||||
qrHolder.innerHTML = '<img style="image-rendering:pixelated;width:140px;height:140px;display:block;margin:0 auto;" src="' + data.qr + '" alt="Payment QR" />';
|
||||
}
|
||||
} else {
|
||||
console.warn('Payment created but no QR data supplied by server.');
|
||||
let qrHolder = document.getElementById('paymentQRContainer');
|
||||
if (!qrHolder) {
|
||||
const statusDiv = document.querySelector('.status');
|
||||
if (statusDiv) {
|
||||
qrHolder = document.createElement('div');
|
||||
qrHolder.id = 'paymentQRContainer';
|
||||
qrHolder.style.cssText = 'margin-top:6px;padding:4px;border:1px solid #f80;background:#000;display:inline-block;color:#f80;font-size:11px;max-width:150px;';
|
||||
statusDiv.appendChild(qrHolder);
|
||||
}
|
||||
}
|
||||
if (qrHolder) {
|
||||
qrHolder.textContent = 'QR unavailable';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_lastDisplayedConfirmation: null,
|
||||
_confirmationTimestamps: {}, // paymentId -> ts
|
||||
onPaymentConfirmed: function(data) {
|
||||
console.log('Payment confirmed:', data);
|
||||
|
||||
console.log('Payment confirmed in block:', data);
|
||||
if (!data || !data.paymentId) return; // ignore malformed legacy event
|
||||
// Expire old confirmations (6h default) to prevent unbounded memory
|
||||
SocketHandlers._expireOldClientPaymentMarkers();
|
||||
if (SocketHandlers._lastDisplayedConfirmation === data.paymentId) return;
|
||||
SocketHandlers._lastDisplayedConfirmation = data.paymentId;
|
||||
SocketHandlers._confirmationTimestamps[data.paymentId] = Date.now();
|
||||
|
||||
if (typeof Game !== 'undefined' && Game.drawWaitingScreen) {
|
||||
Game.drawWaitingScreen();
|
||||
}
|
||||
if (typeof Game !== 'undefined') {
|
||||
Game._pendingPaymentConfirmed();
|
||||
}
|
||||
|
||||
$('#messages').append($('<li class="payment-success">').html(
|
||||
'✅ <strong>Payment confirmed!</strong> You are in the game queue.'
|
||||
|
||||
$('#messages').append($('<li class="payment-success" style="color:#0f0;">').html(
|
||||
'✅ <strong>Payment confirmed in block.</strong> You are in the game queue.'
|
||||
));
|
||||
UI.scrollChat();
|
||||
SocketHandlers._setBannerStatus('Confirmed', '#0f0');
|
||||
},
|
||||
|
||||
_mempoolShownForPayment: new Set(),
|
||||
_mempoolTimestamps: {}, // paymentId -> ts
|
||||
onPaymentDetected: function(data) {
|
||||
console.log('Payment detected (mempool):', data);
|
||||
if (!data || !data.paymentId) return; // require paymentId for dedupe
|
||||
SocketHandlers._expireOldClientPaymentMarkers();
|
||||
if (SocketHandlers._mempoolShownForPayment.has(data.paymentId)) return;
|
||||
SocketHandlers._mempoolShownForPayment.add(data.paymentId);
|
||||
SocketHandlers._mempoolTimestamps[data.paymentId] = Date.now();
|
||||
if (typeof Game !== 'undefined') {
|
||||
Game._pendingPaymentDetected(data);
|
||||
if (Game.drawWaitingScreen) Game.drawWaitingScreen();
|
||||
}
|
||||
$('#messages').append($('<li class="payment-mempool" style="color:#0af;">').html(
|
||||
'🌀 <strong>Payment seen in mempool</strong> – awaiting block confirmation...'
|
||||
'🌀 <strong>Payment detected (mempool)</strong> – awaiting block confirmation...'
|
||||
));
|
||||
UI.scrollChat();
|
||||
SocketHandlers._setBannerStatus('Mempool', '#0af');
|
||||
},
|
||||
|
||||
// Internal: periodic cleanup of client-side payment marker sets (invoked opportunistically)
|
||||
_expireOldClientPaymentMarkers: function() {
|
||||
const TTL = 6 * 60 * 60 * 1000; // 6 hours
|
||||
const now = Date.now();
|
||||
// Mempool markers
|
||||
for (const pid of Array.from(SocketHandlers._mempoolShownForPayment)) {
|
||||
const ts = SocketHandlers._mempoolTimestamps[pid];
|
||||
if (!ts || now - ts > TTL) {
|
||||
SocketHandlers._mempoolShownForPayment.delete(pid);
|
||||
delete SocketHandlers._mempoolTimestamps[pid];
|
||||
}
|
||||
}
|
||||
// Confirmation marker (single id tracking + map of timestamps)
|
||||
if (SocketHandlers._lastDisplayedConfirmation) {
|
||||
const lastId = SocketHandlers._lastDisplayedConfirmation;
|
||||
const ts = SocketHandlers._confirmationTimestamps[lastId];
|
||||
if (ts && now - ts > TTL) {
|
||||
delete SocketHandlers._confirmationTimestamps[lastId];
|
||||
// Allow new confirmation message for same id after TTL (rare, but cleans memory)
|
||||
SocketHandlers._lastDisplayedConfirmation = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onBlockHeight: function(data) {
|
||||
if (typeof UI !== 'undefined' && UI.updateBlockHeight) {
|
||||
UI.updateBlockHeight(data.blockHeight);
|
||||
}
|
||||
// Fallback: if status banner still says Connecting..., upgrade it
|
||||
const current = $('#statusValue').text();
|
||||
if (current === 'Connecting...') {
|
||||
SocketHandlers._setBannerStatus('Ready', '#0f0');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* AudioAlerts: optional user-enabled audio notifications for key events
|
||||
* - Payment confirmed (game about to start soon / queued)
|
||||
* - Game start (focus attention)
|
||||
* Persists preference in localStorage and uses Web Audio or HTMLAudio fallback.
|
||||
*/
|
||||
const AudioAlerts = {
|
||||
_enabled: false,
|
||||
_initialized: false,
|
||||
_ctx: null,
|
||||
_gain: null,
|
||||
_volume: 0.4,
|
||||
_lastPlay: 0,
|
||||
_cooldownMs: 1500,
|
||||
|
||||
init() {
|
||||
if (this._initialized) return;
|
||||
this._initialized = true;
|
||||
// Load preference
|
||||
try {
|
||||
const saved = localStorage.getItem('wow_audioAlerts');
|
||||
this._enabled = saved === '1';
|
||||
} catch(_) {}
|
||||
const toggle = document.getElementById('audioAlertsToggle');
|
||||
if (toggle) {
|
||||
toggle.checked = this._enabled;
|
||||
toggle.addEventListener('change', () => {
|
||||
this._enabled = !!toggle.checked;
|
||||
try { localStorage.setItem('wow_audioAlerts', this._enabled ? '1':'0'); } catch(_) {}
|
||||
if (this._enabled) this._lazyInitContext();
|
||||
});
|
||||
}
|
||||
|
||||
// Hook into socket events (if SocketHandlers already patched later)
|
||||
if (typeof SocketHandlers !== 'undefined') {
|
||||
this._patchSocketHandlers();
|
||||
}
|
||||
},
|
||||
|
||||
_patchSocketHandlers() {
|
||||
if (SocketHandlers._audioAlertsPatched) return; // idempotent
|
||||
SocketHandlers._audioAlertsPatched = true;
|
||||
const origPaymentConfirmed = SocketHandlers.onPaymentConfirmed;
|
||||
const origGameStart = SocketHandlers.onGameStart;
|
||||
SocketHandlers.onPaymentConfirmed = function(data) {
|
||||
if (origPaymentConfirmed) origPaymentConfirmed.call(SocketHandlers, data);
|
||||
AudioAlerts.playPattern('payment');
|
||||
};
|
||||
SocketHandlers.onGameStart = function(data) {
|
||||
if (origGameStart) origGameStart.call(SocketHandlers, data);
|
||||
AudioAlerts.playPattern('start');
|
||||
};
|
||||
},
|
||||
|
||||
_lazyInitContext() {
|
||||
if (this._ctx) return;
|
||||
try {
|
||||
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioCtx) return;
|
||||
this._ctx = new AudioCtx();
|
||||
this._gain = this._ctx.createGain();
|
||||
this._gain.gain.value = this._volume;
|
||||
this._gain.connect(this._ctx.destination);
|
||||
} catch (e) {
|
||||
console.warn('Audio context init failed:', e);
|
||||
}
|
||||
},
|
||||
|
||||
playPattern(type) {
|
||||
if (!this._enabled) return;
|
||||
const now = Date.now();
|
||||
if (now - this._lastPlay < this._cooldownMs) return; // basic cooldown
|
||||
this._lastPlay = now;
|
||||
this._lazyInitContext();
|
||||
if (!this._ctx) return;
|
||||
|
||||
if (this._ctx.state === 'suspended') {
|
||||
// Attempt resume on user gesture contexts; safe to call
|
||||
this._ctx.resume().catch(()=>{});
|
||||
}
|
||||
|
||||
// Simple beep patterns
|
||||
const pattern = type === 'start' ? [440, 660, 880] : [440, 660];
|
||||
const dur = 0.18;
|
||||
let t = this._ctx.currentTime + 0.01;
|
||||
pattern.forEach((freq) => {
|
||||
const osc = this._ctx.createOscillator();
|
||||
const gainNode = this._ctx.createGain();
|
||||
osc.frequency.value = freq;
|
||||
osc.type = 'sine';
|
||||
gainNode.gain.setValueAtTime(0.0001, t);
|
||||
gainNode.gain.linearRampToValueAtTime(this._volume, t + 0.01);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||
osc.connect(gainNode).connect(this._gain || this._ctx.destination);
|
||||
osc.start(t);
|
||||
osc.stop(t + dur + 0.02);
|
||||
t += dur * 0.5; // slight overlap for chord-like effect
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = AudioAlerts;
|
||||
}
|
||||
+27
-6
@@ -7,21 +7,28 @@
|
||||
GAME_MODE=PAID_SINGLE
|
||||
|
||||
# Cryptocurrency Configuration
|
||||
CRYPTO_TYPE=XMR
|
||||
CRYPTO_TYPE=WOW
|
||||
# Payment amounts in atomic units (piconeros for XMR, tacoshis for WOW)
|
||||
# XMR: 0.005 XMR = 5000000000 piconeros
|
||||
# WOW: 1 WOW = 100000000000 tacoshis
|
||||
SINGLE_GAME_PRICE=5000000000
|
||||
CREDITS_PACKAGE_PRICE=50000000000
|
||||
# You can use plain integers, scientific notation (e.g. 5e9), or underscores (e.g. 5_000_000_000)
|
||||
# The parser will coerce to an integer atomic unit amount.
|
||||
SINGLE_GAME_PRICE=1e11 # 1 WOW
|
||||
CREDITS_PACKAGE_PRICE=5e11 # 5 WOW
|
||||
# SINGLE_GAME_PRICE=5e9 # 0.005 XMR (was 5000000000)
|
||||
# CREDITS_PACKAGE_PRICE=5e10 # 0.05 XMR (was 50000000000)
|
||||
# Alternative readable style:
|
||||
# SINGLE_GAME_PRICE=5_000_000_000
|
||||
# CREDITS_PACKAGE_PRICE=50_000_000_000
|
||||
|
||||
# Wallet RPC Configuration (replaces MoneroPay)
|
||||
# Wallet RPC Configuration
|
||||
PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:34570
|
||||
# WALLET_RPC_USER=user
|
||||
# WALLET_RPC_PASSWORD=password
|
||||
|
||||
# Blockchain RPC Configuration
|
||||
PRIMARY_RPC_ENDPOINT=http://jw-nodes:18081
|
||||
FALLBACK_RPC_ENDPOINT=http://jw-nodes:18081
|
||||
PRIMARY_RPC_ENDPOINT=http://127.0.0.1:34568
|
||||
FALLBACK_RPC_ENDPOINT=http://127.0.0.1:34568
|
||||
RPC_POLL_INTERVAL=2000
|
||||
|
||||
# Database Configuration
|
||||
@@ -35,6 +42,20 @@ DB_PASSWORD=jw
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
|
||||
# Block Height Source & Debug Controls
|
||||
# BLOCK_SOURCE=daemon -> always use real blockchain height (default for paid modes)
|
||||
# BLOCK_SOURCE=simulated -> force simulated blocks (development)
|
||||
# SIMULATED_BLOCKS=true/false overrides automatic detection (if set)
|
||||
# FORCE_SIMULATED_BLOCKS=true will force simulation even in paid modes (for local testing)
|
||||
# Examples:
|
||||
# BLOCK_SOURCE=daemon
|
||||
# BLOCK_SOURCE=simulated
|
||||
# SIMULATED_BLOCKS=false
|
||||
# FORCE_SIMULATED_BLOCKS=true
|
||||
BLOCK_SOURCE=daemon
|
||||
# SIMULATED_BLOCKS=false
|
||||
# FORCE_SIMULATED_BLOCKS=false
|
||||
|
||||
# Payout Configuration
|
||||
PAYOUT_BATCH_INTERVAL=300000
|
||||
MAX_PAYOUT_BATCH_SIZE=50
|
||||
|
||||
@@ -63,6 +63,9 @@ class DatabaseManager {
|
||||
*/
|
||||
async runMigrations() {
|
||||
try {
|
||||
// Simple migration ledger
|
||||
await this.pool.query(`CREATE TABLE IF NOT EXISTS schema_migrations (filename TEXT PRIMARY KEY, applied_at TIMESTAMP DEFAULT NOW())`);
|
||||
|
||||
const migrationsPath = path.join(__dirname, '../migrations');
|
||||
const files = await fs.readdir(migrationsPath);
|
||||
const sqlFiles = files.filter(file => file.endsWith('.sql')).sort();
|
||||
@@ -70,15 +73,22 @@ class DatabaseManager {
|
||||
console.log(`📁 Found ${sqlFiles.length} migration files`);
|
||||
|
||||
for (const file of sqlFiles) {
|
||||
// Skip if already applied
|
||||
const existing = await this.pool.query(`SELECT 1 FROM schema_migrations WHERE filename = $1`, [file]);
|
||||
if (existing.rowCount > 0) {
|
||||
console.log(`⏩ Skipping already applied migration: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = path.join(migrationsPath, file);
|
||||
const sql = await fs.readFile(filePath, 'utf8');
|
||||
|
||||
console.log(`🔄 Running migration: ${file}`);
|
||||
await this.pool.query(sql);
|
||||
await this.pool.query(`INSERT INTO schema_migrations (filename) VALUES ($1)`, [file]);
|
||||
console.log(`✅ Migration completed: ${file}`);
|
||||
}
|
||||
|
||||
console.log('✅ All migrations completed successfully');
|
||||
console.log('✅ Migration phase complete');
|
||||
} catch (error) {
|
||||
console.error('❌ Migration failed:', error.message);
|
||||
throw error;
|
||||
|
||||
+30
-13
@@ -17,14 +17,24 @@ class DebugManager {
|
||||
this.IS_DEBUG = nodeEnv === 'debug';
|
||||
this.IS_DEVELOPMENT = nodeEnv === 'development';
|
||||
|
||||
// Debug mode is enabled in development or debug environments
|
||||
this.DEBUG_MODE = this.IS_DEBUG || this.IS_DEVELOPMENT;
|
||||
// Debug mode is enabled in development or debug environments
|
||||
this.DEBUG_MODE = this.IS_DEBUG || this.IS_DEVELOPMENT;
|
||||
|
||||
// Configuration from package.json
|
||||
const config = this.IS_PRODUCTION ? packageConfig.config.production : packageConfig.config.debug;
|
||||
this.CONSOLE_LOGGING = config.console_logging || this.DEBUG_MODE;
|
||||
this.DEBUG_HOTKEYS = config.debug_hotkeys || this.DEBUG_MODE;
|
||||
this.SIMULATED_BLOCKS = config.simulated_blocks || this.DEBUG_MODE;
|
||||
// Configuration precedence:
|
||||
// 1. Explicit env overrides
|
||||
// 2. Package.json config
|
||||
const config = this.IS_PRODUCTION ? packageConfig.config.production : packageConfig.config.debug;
|
||||
this.CONSOLE_LOGGING = process.env.CONSOLE_LOGGING ? process.env.CONSOLE_LOGGING === 'true' : (config.console_logging || this.DEBUG_MODE);
|
||||
this.DEBUG_HOTKEYS = process.env.DEBUG_HOTKEYS ? process.env.DEBUG_HOTKEYS === 'true' : (config.debug_hotkeys || this.DEBUG_MODE);
|
||||
// Simulation can be force-disabled by BLOCK_SOURCE=daemon or GAME_MODE paid modes
|
||||
const forceSimFlag = process.env.FORCE_SIMULATED_BLOCKS === 'true';
|
||||
const blockSource = (process.env.BLOCK_SOURCE || '').toLowerCase(); // 'daemon' | 'simulated'
|
||||
let simulatedDefault = config.simulated_blocks || this.DEBUG_MODE;
|
||||
if (blockSource === 'daemon') simulatedDefault = false;
|
||||
// In paid modes always use real daemon unless explicitly forced (for test)
|
||||
const paidMode = ['PAID_SINGLE','PAID_CREDITS'].includes(process.env.GAME_MODE);
|
||||
if (paidMode && !forceSimFlag) simulatedDefault = false;
|
||||
this.SIMULATED_BLOCKS = process.env.SIMULATED_BLOCKS ? process.env.SIMULATED_BLOCKS === 'true' : simulatedDefault;
|
||||
|
||||
this.debugBlockHeight = 1;
|
||||
this.debugInterval = null;
|
||||
@@ -48,6 +58,9 @@ class DebugManager {
|
||||
console.log(` CONSOLE_LOGGING: ${this.CONSOLE_LOGGING}`);
|
||||
console.log(` DEBUG_HOTKEYS: ${this.DEBUG_HOTKEYS}`);
|
||||
console.log(` SIMULATED_BLOCKS: ${this.SIMULATED_BLOCKS}`);
|
||||
if (process.env.GAME_MODE && ['PAID_SINGLE','PAID_CREDITS'].includes(process.env.GAME_MODE)) {
|
||||
console.log(` GAME_MODE: ${process.env.GAME_MODE} (simulation ${this.SIMULATED_BLOCKS ? 'ENABLED' : 'DISABLED'})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +70,7 @@ class DebugManager {
|
||||
* Initialize debug mode or production mode
|
||||
*/
|
||||
initialize() {
|
||||
if (this.DEBUG_MODE && this.SIMULATED_BLOCKS) {
|
||||
if (this.SIMULATED_BLOCKS) {
|
||||
this.initializeDebugMode();
|
||||
} else {
|
||||
this.initializeProductionMode();
|
||||
@@ -72,8 +85,8 @@ class DebugManager {
|
||||
console.log("🐛 DEBUG MODE ENABLED - Simulating blocks every 30 seconds");
|
||||
}
|
||||
|
||||
// Initial debug block broadcast
|
||||
this.broadcastManager.broadcastBlockHeight(this.debugBlockHeight);
|
||||
// Initial debug block broadcast
|
||||
this.broadcastManager.broadcastBlockHeight(this.debugBlockHeight);
|
||||
|
||||
// Debug block height simulator - advances every 30 seconds
|
||||
this.debugInterval = setInterval(() => {
|
||||
@@ -108,7 +121,7 @@ class DebugManager {
|
||||
this.rpcService = new RpcService();
|
||||
|
||||
// Real blockchain monitoring with new RPC service
|
||||
this.debugInterval = setInterval(async () => {
|
||||
const poll = async () => {
|
||||
try {
|
||||
const currentHeight = await this.rpcService.getBlockHeight();
|
||||
|
||||
@@ -151,7 +164,10 @@ class DebugManager {
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 2000); // Every 2 seconds as specified in .env
|
||||
};
|
||||
// immediate first poll so UI shows real height quickly
|
||||
poll();
|
||||
this.debugInterval = setInterval(poll, 2000); // Every 2 seconds
|
||||
}
|
||||
|
||||
// ====== BLOCK HEIGHT MANAGEMENT ======
|
||||
@@ -161,7 +177,8 @@ class DebugManager {
|
||||
* @returns {number} Current block height
|
||||
*/
|
||||
getCurrentBlockHeight() {
|
||||
return this.DEBUG_MODE ? this.debugBlockHeight : this.lastProductionBlockHeight;
|
||||
if (this.SIMULATED_BLOCKS) return this.debugBlockHeight;
|
||||
return this.lastProductionBlockHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,13 +10,25 @@ class GameModeManager {
|
||||
this.debugManager = debugManager;
|
||||
this.gameMode = process.env.GAME_MODE || 'FREE';
|
||||
this.cryptoType = process.env.CRYPTO_TYPE || 'XMR';
|
||||
this.singleGamePrice = parseInt(process.env.SINGLE_GAME_PRICE) || 5000000000; // 0.005 XMR
|
||||
this.creditsPackagePrice = parseInt(process.env.CREDITS_PACKAGE_PRICE) || 50000000000; // 0.05 XMR
|
||||
// Flexible parsing: supports plain integers, scientific notation (e.g. 5e9), and underscores (e.g. 5_000_000_000)
|
||||
const parseAtomic = (val, fallback) => {
|
||||
if (val === undefined || val === null || val === '') return fallback;
|
||||
if (typeof val !== 'string') return Math.trunc(Number(val)) || fallback;
|
||||
const cleaned = val.replace(/_/g, '').trim();
|
||||
const num = Number(cleaned);
|
||||
if (!Number.isFinite(num) || num < 0) return fallback;
|
||||
// Ensure integer atomic units
|
||||
return Math.trunc(num);
|
||||
};
|
||||
|
||||
this.singleGamePrice = parseAtomic(process.env.SINGLE_GAME_PRICE, 5000000000); // default 0.005 XMR
|
||||
this.creditsPackagePrice = parseAtomic(process.env.CREDITS_PACKAGE_PRICE, 50000000000); // default 0.05 XMR
|
||||
|
||||
console.log(`🎮 Game Mode Manager initialized: ${this.gameMode} mode`);
|
||||
console.log(`💰 Currency: ${this.cryptoType}`);
|
||||
console.log(`💵 Single game price: ${this.singleGamePrice} atomic units`);
|
||||
console.log(`🎫 Credits package price: ${this.creditsPackagePrice} atomic units`);
|
||||
const toXMR = (atomic) => (atomic / 1e12).toFixed(6).replace(/0+$/,'').replace(/\.$/,'');
|
||||
console.log(`💵 Single game price: ${this.singleGamePrice} atomic units (~${toXMR(this.singleGamePrice)} ${this.cryptoType})`);
|
||||
console.log(`🎫 Credits package price: ${this.creditsPackagePrice} atomic units (~${toXMR(this.creditsPackagePrice)} ${this.cryptoType})`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,9 +268,9 @@ class GameModeManager {
|
||||
|
||||
// Store payment info in database
|
||||
await this.db.query(`
|
||||
INSERT INTO payments (user_id, subaddress, expected_amount, payment_type, status, created_at)
|
||||
VALUES ($1, $2, $3, $4, 'pending', NOW())
|
||||
`, [user.id, paymentResult.address, amount, paymentType]);
|
||||
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]);
|
||||
|
||||
return {
|
||||
id: paymentResult.id,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* MovementManager
|
||||
* Handles rate limiting and applying player movement to games.
|
||||
*/
|
||||
class MovementManager {
|
||||
constructor({ activeGames, io, debugManager, moveCooldown = 100, postMoveHook = null }) {
|
||||
this.activeGames = activeGames; // Map socketId -> Game
|
||||
this.io = io;
|
||||
this.debugManager = debugManager;
|
||||
this.moveCooldown = moveCooldown;
|
||||
this._lastMove = new Map(); // socketId -> timestamp
|
||||
this.postMoveHook = typeof postMoveHook === 'function' ? postMoveHook : null;
|
||||
}
|
||||
|
||||
handleMove(socketId, moveData) {
|
||||
if (!moveData || typeof moveData.direction !== 'string') return;
|
||||
const now = Date.now();
|
||||
const last = this._lastMove.get(socketId) || 0;
|
||||
if (now - last < this.moveCooldown) return; // rate limit
|
||||
this._lastMove.set(socketId, now);
|
||||
|
||||
const game = this.activeGames.get(socketId);
|
||||
if (!game) return; // not in a game
|
||||
|
||||
const dir = moveData.direction;
|
||||
let dx = 0, dy = 0;
|
||||
switch (dir) {
|
||||
case 'up': dy = -1; break;
|
||||
case 'down': dy = 1; break;
|
||||
case 'left': dx = -1; break;
|
||||
case 'right': dx = 1; break;
|
||||
default: return;
|
||||
}
|
||||
|
||||
let moveResult = null;
|
||||
if (typeof game.movePlayer === 'function') {
|
||||
moveResult = game.movePlayer(dx, dy);
|
||||
}
|
||||
|
||||
// Allow hook to perform monster movement or other side-effects BEFORE we snapshot state
|
||||
if (moveResult && moveResult.status === 'moved' && this.postMoveHook) {
|
||||
try {
|
||||
this.postMoveHook({ socketId, game, moveResult });
|
||||
} catch (e) {
|
||||
console.error('postMoveHook error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Build state after player + potential monster move
|
||||
let state;
|
||||
if (typeof game.getState === 'function') {
|
||||
state = game.getState();
|
||||
} else {
|
||||
state = { player: game.player };
|
||||
}
|
||||
if (this.debugManager && typeof this.debugManager.getCurrentBlockHeight === 'function') {
|
||||
state.blockHeight = this.debugManager.getCurrentBlockHeight();
|
||||
}
|
||||
|
||||
this.io.to(socketId).emit('game_update', state);
|
||||
|
||||
// Handle special events from moveResult (escape / treasure)
|
||||
if (moveResult && moveResult.event) {
|
||||
this.io.to(socketId).emit('game_event', { event: moveResult.event });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MovementManager;
|
||||
+4
-3
@@ -162,9 +162,10 @@ function debugRegisteredUsers() {
|
||||
console.log("📊 REGISTERED USERS DEBUG:");
|
||||
const allUsers = require('./db/user').getAllUsers();
|
||||
console.log(`Total registered users: ${allUsers.length}`);
|
||||
allUsers.forEach(user => {
|
||||
console.log(` - User ID: ${user.id}, Socket: ${user.socketId}, Client: ${user.clientId}`);
|
||||
});
|
||||
allUsers.forEach(u => {
|
||||
// u.id is the socket/server id; there is no separate socketId property on User
|
||||
console.log(` - User ID: ${u.id}, Socket: ${u.id}, Client: ${u.clientId}`);
|
||||
});
|
||||
console.log(`Active games: ${activeGames.size}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
// Run tests in Node environment
|
||||
testEnvironment: 'node',
|
||||
// Move Jest's logical root to the project root (one level above this config file)
|
||||
rootDir: '..',
|
||||
// Look for tests inside the existing top-level test directory
|
||||
roots: ['<rootDir>/test'],
|
||||
collectCoverage: false,
|
||||
moduleFileExtensions: ['js','json'],
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Migration 002: Increase address field lengths for Wownero compatibility and add socket_id to payments inserts going forward
|
||||
-- Wownero (WOW) standard & subaddresses are typically 97 chars (vs Monero 95); allow buffer.
|
||||
|
||||
ALTER TABLE payments
|
||||
ALTER COLUMN subaddress TYPE VARCHAR(110);
|
||||
|
||||
ALTER TABLE users
|
||||
ALTER COLUMN payout_address TYPE VARCHAR(110);
|
||||
|
||||
-- Optional: index remains valid; no changes needed.
|
||||
|
||||
-- End 002
|
||||
@@ -7,6 +7,7 @@
|
||||
class BroadcastManager {
|
||||
constructor(io) {
|
||||
this.io = io;
|
||||
this._lastBroadcastBlock = null;
|
||||
}
|
||||
|
||||
// ====== GLOBAL BROADCASTS (All Clients) ======
|
||||
@@ -16,8 +17,12 @@ class BroadcastManager {
|
||||
* @param {number} blockHeight - Current block height
|
||||
*/
|
||||
broadcastBlockHeight(blockHeight) {
|
||||
console.log(`📡 Broadcasting block height ${blockHeight} to all clients`);
|
||||
this.io.emit('blockheight', blockHeight);
|
||||
// Only log when the height actually changes to avoid per-client spam
|
||||
if (blockHeight !== this._lastBroadcastBlock) {
|
||||
console.log(`📡 Broadcasting block height ${blockHeight} to all clients`);
|
||||
this._lastBroadcastBlock = blockHeight;
|
||||
}
|
||||
this.io.emit('blockheight', { blockHeight });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Payment Handlers
|
||||
* Encapsulates payment request creation, monitoring, mempool detection,
|
||||
* confirmation handling, and queue integration.
|
||||
*/
|
||||
class PaymentHandlers {
|
||||
constructor({ io, gameModeManager, walletService, debugManager, queueManager, broadcastManager }) {
|
||||
this.io = io;
|
||||
this.gameModeManager = gameModeManager;
|
||||
this.walletService = walletService;
|
||||
this.debugManager = debugManager;
|
||||
this.queueManager = queueManager;
|
||||
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 paymentIds already confirmed (avoid duplicate emits / queue actions)
|
||||
this.confirmedPayments = new Set();
|
||||
// Periodic cleanup (confirmed payment IDs older than retention window)
|
||||
this._confirmedTimestamps = new Map();
|
||||
this._confirmedRetentionMs = 6 * 60 * 60 * 1000; // 6 hours
|
||||
// Track expiry timeouts so they can be cleared / unref'd to avoid keeping process open
|
||||
this._expiryTimeouts = new Map(); // socketId -> timeout
|
||||
// Keep reference so tests can dispose / and unref so it doesn't keep event loop alive
|
||||
this._cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [pid, ts] of this._confirmedTimestamps.entries()) {
|
||||
if (now - ts > this._confirmedRetentionMs) {
|
||||
this._confirmedTimestamps.delete(pid);
|
||||
this.confirmedPayments.delete(pid);
|
||||
}
|
||||
}
|
||||
}, 30 * 60 * 1000); // sweep every 30 min
|
||||
if (this._cleanupInterval && this._cleanupInterval.unref) {
|
||||
// Allow process (including Jest) to exit naturally without waiting for this interval
|
||||
this._cleanupInterval.unref();
|
||||
}
|
||||
}
|
||||
|
||||
async handlePaymentRequest(socket, data) {
|
||||
if (!this.gameModeManager) {
|
||||
this.io.to(socket.id).emit('payment_error', { error: 'Payment system not available' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { gameMode } = data;
|
||||
const paymentRequest = await this.gameModeManager.createPaymentRequest(socket.id, gameMode);
|
||||
this.io.to(socket.id).emit('payment_created', {
|
||||
paymentId: paymentRequest.id,
|
||||
address: paymentRequest.address,
|
||||
amount: paymentRequest.amount,
|
||||
gameMode: gameMode
|
||||
});
|
||||
if (this.debugManager.CONSOLE_LOGGING) console.log(`💳 Payment request created for ${socket.id}: ${paymentRequest.amount}`);
|
||||
} catch (e) {
|
||||
console.error('Error creating payment request:', e);
|
||||
this.io.to(socket.id).emit('payment_error', { error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
async createAndShowPaymentRequest(socket) {
|
||||
if (!this.gameModeManager) return;
|
||||
try {
|
||||
const currentUser = socket.id && this.queueManager.getUserBySocket ? this.queueManager.getUserBySocket(socket.id) : null;
|
||||
// caller (SocketHandlers) will handle user existence; keep method generic
|
||||
const gameMode = this.gameModeManager.gameMode;
|
||||
const cryptoType = this.gameModeManager.cryptoType;
|
||||
let paymentType, amount, description;
|
||||
if (gameMode === 'PAID_SINGLE') { paymentType = 'single_game'; amount = this.gameModeManager.singleGamePrice; description = 'Single game entry'; }
|
||||
else if (gameMode === 'PAID_CREDITS') { paymentType = 'credits_package'; amount = this.gameModeManager.creditsPackagePrice; description = '10 game credits package'; }
|
||||
else { this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Invalid game mode configuration.'); return; }
|
||||
|
||||
const paymentRequest = await this.gameModeManager.createPaymentRequest(socket.id, paymentType);
|
||||
let qrDataUrl = null; try { const { generatePaymentQR } = require('../payments/qrService'); qrDataUrl = await generatePaymentQR(paymentRequest.address, amount, cryptoType, description); } catch(e) {}
|
||||
const humanAmount = (amount / 1000000000000).toFixed(3);
|
||||
this.io.to(socket.id).emit('payment_created', { paymentId: paymentRequest.id, address: paymentRequest.address, amount: paymentRequest.amount, paymentType, gameMode, cryptoType, humanAmount, description, expiresAt: paymentRequest.expiresAt, qr: qrDataUrl });
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'payment', `💳 PAYMENT REQUIRED (${description})\n\nAmount: ${humanAmount} ${cryptoType}\nAddress: ${paymentRequest.address}\n\n⚠️ Send EXACTLY ${humanAmount} ${cryptoType}.\n🔄 Added to queue once mempool seen.\n⏰ Expires in 30 minutes.`);
|
||||
this._monitorAddress(socket, paymentRequest, amount, cryptoType, currentUser);
|
||||
} catch (e) {
|
||||
console.error('Error creating payment request:', e);
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Failed to create payment request.');
|
||||
}
|
||||
}
|
||||
|
||||
_monitorAddress(socket, paymentRequest, amount, cryptoType, currentUser) {
|
||||
// Record mapping so we can stop later
|
||||
this.socketAddressMap.set(socket.id, paymentRequest.address);
|
||||
|
||||
this.walletService.startPaymentMonitoring(paymentRequest.address, async (status) => {
|
||||
if (status.in_mempool && !status.confirmed) {
|
||||
if (this.mempoolNotified.has(paymentRequest.address)) return;
|
||||
this.mempoolNotified.add(paymentRequest.address);
|
||||
socket.emit('payment_detected', { paymentId: paymentRequest.id, message: 'Payment detected in mempool! Adding you to the game queue...', amount: status.amount, confirmations: 0 });
|
||||
const existingIdx = this.queueManager.getPlayerIndex(socket.id);
|
||||
if (existingIdx === -1) {
|
||||
this.queueManager.addPlayer({ serverId: socket.id, clientId: currentUser ? currentUser.clientId : null, paymentId: paymentRequest.id, requiresConfirmation: true, confirmed: false });
|
||||
}
|
||||
socket.emit('queue_joined', { position: (existingIdx === -1 ? this.queueManager.getQueueLength() : existingIdx + 1), message: 'Payment received! Waiting for next block to start game...', currentBlock: this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null, nextBlock: this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() + 1 : null });
|
||||
} else if (status.confirmed) {
|
||||
if (!this.confirmedPayments.has(paymentRequest.id)) {
|
||||
this.confirmedPayments.add(paymentRequest.id);
|
||||
socket.emit('payment_confirmed', { paymentId: paymentRequest.id, message: 'Payment confirmed in block!', confirmations: status.confirmations });
|
||||
this._confirmedTimestamps.set(paymentRequest.id, Date.now());
|
||||
this.queueManager.markConfirmed(socket.id);
|
||||
// Attempt immediate game start so user doesn't wait another full block
|
||||
const currentBlock = this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null;
|
||||
if (currentBlock !== null) {
|
||||
const started = this.queueManager.startGameImmediately(socket.id, currentBlock);
|
||||
if (!started && this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`[PaymentHandlers] Immediate start skipped (not queued or still unconfirmed) for ${socket.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clean up monitoring for this socket (even if duplicate)
|
||||
this.stopMonitoringForSocket(socket.id);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
// 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
|
||||
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) {
|
||||
const currentUser = this.queueManager.getUserBySocket ? this.queueManager.getUserBySocket(socketId) : null;
|
||||
if (!currentUser) return;
|
||||
const existingIndex = this.queueManager.getPlayerIndex(socketId);
|
||||
if (existingIndex !== -1) { this.broadcastManager.sendStatusUpdate(socketId, 'info', 'Payment confirmed, already queued!'); return; }
|
||||
if (this.queueManager.activeGames && this.queueManager.activeGames.has(socketId)) { this.broadcastManager.sendStatusUpdate(socketId, 'info', 'Payment confirmed, but you are already in a game!'); return; }
|
||||
this.queueManager.addPlayer({ serverId: socketId, clientId: currentUser.clientId, paymentId: paymentRequest.id, requiresConfirmation: paymentStatus.in_mempool && !paymentStatus.confirmed, confirmed: paymentStatus.confirmed });
|
||||
const currentBlock = this.debugManager.getCurrentBlockHeight();
|
||||
const nextBlock = currentBlock + 1;
|
||||
if (paymentStatus.in_mempool && !paymentStatus.confirmed) {
|
||||
this.broadcastManager.sendStatusUpdate(socketId, 'success', `💰 PAYMENT DETECTED (MEMPOOL)\n\n✅ Added to queue.\n🕒 Starts at block ${nextBlock}.\n📦 Current block: ${currentBlock}`);
|
||||
this.broadcastManager.sendStatusUpdate(socketId, 'info', 'Waiting for block confirmation...');
|
||||
} 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}`);
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`🛑 Stopped monitoring for socket ${socketId}`);
|
||||
}
|
||||
}
|
||||
const expiry = this._expiryTimeouts.get(socketId);
|
||||
if (expiry) {
|
||||
clearTimeout(expiry);
|
||||
this._expiryTimeouts.delete(socketId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose resources (intervals, monitors) - useful for tests / graceful shutdown.
|
||||
*/
|
||||
dispose() {
|
||||
if (this._cleanupInterval) {
|
||||
clearInterval(this._cleanupInterval);
|
||||
this._cleanupInterval = null;
|
||||
}
|
||||
for (const [socketId] of this.socketAddressMap.entries()) {
|
||||
this.stopMonitoringForSocket(socketId);
|
||||
}
|
||||
for (const [socketId, to] of this._expiryTimeouts.entries()) {
|
||||
clearTimeout(to);
|
||||
this._expiryTimeouts.delete(socketId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PaymentHandlers;
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Queue Manager
|
||||
* Encapsulates logic for players waiting for the next block to start a game.
|
||||
* Responsible for:
|
||||
* - Managing queue entries (add/remove/confirm)
|
||||
* - Starting games when a new block arrives
|
||||
* - Preserving unconfirmed (mempool-only) entries across blocks
|
||||
*/
|
||||
class QueueManager {
|
||||
constructor({ debugManager, broadcastManager, io, createGameForUser, getUserBySocket, activeGames, gameModeManager, consoleLogging }) {
|
||||
this.debugManager = debugManager;
|
||||
this.broadcastManager = broadcastManager;
|
||||
this.io = io;
|
||||
this.createGameForUser = createGameForUser; // (user, gameType, options) => Game
|
||||
this.getUserBySocket = getUserBySocket; // (socketId) => User
|
||||
this.activeGames = activeGames; // Map socketId -> Game
|
||||
this.gameModeManager = gameModeManager;
|
||||
this.CONSOLE_LOGGING = !!consoleLogging;
|
||||
this._waitingPlayers = []; // internal queue
|
||||
}
|
||||
|
||||
addPlayer({ serverId, clientId, entryTime = Date.now(), paymentId = null, requiresConfirmation = false, confirmed = true }) {
|
||||
if (this.getPlayerIndex(serverId) !== -1) return; // already queued
|
||||
this._waitingPlayers.push({ serverId, clientId, entryTime, paymentId, requiresConfirmation, confirmed });
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log(`[QueueManager] Added player ${serverId} (confirmed=${confirmed}, requiresConfirmation=${requiresConfirmation}). Queue length: ${this._waitingPlayers.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
removePlayer(serverId) {
|
||||
const idx = this.getPlayerIndex(serverId);
|
||||
if (idx !== -1) {
|
||||
this._waitingPlayers.splice(idx, 1);
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log(`[QueueManager] Removed player ${serverId}. Queue length: ${this._waitingPlayers.length}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
markConfirmed(serverId) {
|
||||
const entry = this._waitingPlayers.find(p => p.serverId === serverId);
|
||||
if (entry) {
|
||||
entry.confirmed = true;
|
||||
entry.requiresConfirmation = false;
|
||||
if (this.CONSOLE_LOGGING) console.log(`[QueueManager] Marked player ${serverId} confirmed.`);
|
||||
}
|
||||
}
|
||||
|
||||
getPlayerIndex(serverId) { return this._waitingPlayers.findIndex(p => p.serverId === serverId); }
|
||||
isPlayerQueued(serverId) { return this.getPlayerIndex(serverId) !== -1; }
|
||||
getQueueLength() { return this._waitingPlayers.length; }
|
||||
getQueuePosition(serverId) { const i = this.getPlayerIndex(serverId); return i === -1 ? -1 : i + 1; }
|
||||
|
||||
startGamesForWaiting(blockHeight) {
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log(`[QueueManager] Starting games for ${this._waitingPlayers.length} waiting players at block ${blockHeight}`);
|
||||
}
|
||||
const remainingQueue = [];
|
||||
while (this._waitingPlayers.length > 0) {
|
||||
const entry = this._waitingPlayers.shift();
|
||||
const serverId = entry.serverId;
|
||||
|
||||
if (this.CONSOLE_LOGGING) console.log(`[QueueManager] Processing queued player ${serverId}`);
|
||||
const currentUser = this.getUserBySocket(serverId);
|
||||
if (!currentUser) {
|
||||
if (this.CONSOLE_LOGGING) console.warn(`[QueueManager] User not found for ${serverId}, skipping.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keep unconfirmed paid entries in queue
|
||||
if (entry.requiresConfirmation && !entry.confirmed) {
|
||||
remainingQueue.push(entry);
|
||||
if (this.CONSOLE_LOGGING) console.log(`[QueueManager] Skipping unconfirmed paid entry ${serverId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
currentUser.blockRec = blockHeight;
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log(`[QueueManager] Player ${serverId} enters on block ${blockHeight} (dies after ${blockHeight + 1})`);
|
||||
}
|
||||
try {
|
||||
const game = this.createGameForUser(currentUser, 'standard');
|
||||
const gameState = game.getState();
|
||||
gameState.blockHeight = blockHeight;
|
||||
this.io.to(serverId).emit('game_start', gameState);
|
||||
if (this.CONSOLE_LOGGING) console.log(`[QueueManager] Game started for ${serverId}`);
|
||||
} catch (error) {
|
||||
console.error('[QueueManager] Error creating game:', error);
|
||||
this.io.to(serverId).emit('message', 'Error starting game: ' + error.message);
|
||||
}
|
||||
}
|
||||
if (remainingQueue.length > 0) {
|
||||
this._waitingPlayers = remainingQueue.concat(this._waitingPlayers);
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log(`[QueueManager] Re-queued ${remainingQueue.length} unconfirmed entries.`);
|
||||
remainingQueue.forEach(e => {
|
||||
console.log(`[QueueManager] -> ${e.serverId} confirmed=${e.confirmed} requiresConfirmation=${e.requiresConfirmation}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugDumpQueue() {
|
||||
if (!this.CONSOLE_LOGGING) return;
|
||||
console.log('[QueueManager] Current queue dump:');
|
||||
this._waitingPlayers.forEach((e,i) => {
|
||||
console.log(` [${i}] ${e.serverId} confirmed=${e.confirmed} requiresConfirmation=${e.requiresConfirmation}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately start a game for a single confirmed player (e.g. payment confirmed AFTER
|
||||
* the block tick already processed). This prevents an additional full-block wait.
|
||||
* Returns true if a game was started.
|
||||
*/
|
||||
startGameImmediately(serverId, blockHeight) {
|
||||
const idx = this.getPlayerIndex(serverId);
|
||||
if (idx === -1) return false; // not queued
|
||||
const entry = this._waitingPlayers[idx];
|
||||
if (entry.requiresConfirmation && !entry.confirmed) {
|
||||
// Still not confirmed; cannot start
|
||||
return false;
|
||||
}
|
||||
// Remove from queue
|
||||
this._waitingPlayers.splice(idx, 1);
|
||||
const currentUser = this.getUserBySocket(serverId);
|
||||
if (!currentUser) return false;
|
||||
currentUser.blockRec = blockHeight; // lifetime until next block
|
||||
try {
|
||||
const game = this.createGameForUser(currentUser, 'standard');
|
||||
const gameState = game.getState();
|
||||
gameState.blockHeight = blockHeight;
|
||||
this.io.to(serverId).emit('game_start', gameState);
|
||||
if (this.CONSOLE_LOGGING) console.log(`[QueueManager] (immediate) Game started for ${serverId} at block ${blockHeight}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[QueueManager] Error starting immediate game:', err);
|
||||
this.io.to(serverId).emit('message', 'Error starting game: ' + err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = QueueManager;
|
||||
+161
-748
File diff suppressed because it is too large
Load Diff
Generated
+3521
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -7,7 +7,7 @@
|
||||
"start": "NODE_ENV=production node index.js",
|
||||
"dev": "NODE_ENV=development node index.js",
|
||||
"debug": "NODE_ENV=debug node index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "jest --runInBand"
|
||||
},
|
||||
"config": {
|
||||
"debug": {
|
||||
@@ -35,5 +35,9 @@
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"uuid": "^9.0.0"
|
||||
,"qrcode": "^1.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^29.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// qrService.js - Generates QR codes for payment URIs
|
||||
// Uses the 'qrcode' package if available; degrades gracefully if not.
|
||||
|
||||
let QR;
|
||||
try {
|
||||
QR = require('qrcode');
|
||||
} catch (e) {
|
||||
QR = null;
|
||||
}
|
||||
|
||||
function formatAmount(atomic) {
|
||||
if (!atomic || atomic <= 0) return '0';
|
||||
return (atomic / 1e12).toFixed(12).replace(/0+$/,'').replace(/\.$/,'');
|
||||
}
|
||||
|
||||
async function generatePaymentQR(address, amountAtomic, cryptoType, description) {
|
||||
if (!QR) return null; // Library not installed
|
||||
try {
|
||||
const amountStr = formatAmount(amountAtomic);
|
||||
const scheme = cryptoType && cryptoType.toUpperCase() === 'WOW' ? 'wownero' : 'monero';
|
||||
// Basic URI; wallets usually understand amount (tx_amount) & description
|
||||
const params = new URLSearchParams();
|
||||
if (amountAtomic) params.set('tx_amount', amountStr);
|
||||
if (description) params.set('tx_description', description.substring(0, 64));
|
||||
const uri = `${scheme}:${address}${params.toString() ? '?' + params.toString() : ''}`;
|
||||
return await QR.toDataURL(uri, { errorCorrectionLevel: 'M', scale: 6, margin: 1 });
|
||||
} catch (err) {
|
||||
console.error('QR generation failed:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { generatePaymentQR };
|
||||
@@ -157,7 +157,7 @@ class WalletRPCService {
|
||||
if (!userInfo.detected) {
|
||||
userInfo.detected = true;
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`🎯 Payment detected in mempool:`, {
|
||||
console.log(`🎯 Payment detected (mempool):`, {
|
||||
address: address.substring(0, 10) + '...',
|
||||
amount: tx.amount,
|
||||
txid: txHash
|
||||
@@ -183,7 +183,7 @@ class WalletRPCService {
|
||||
if (!userInfo.confirmed) {
|
||||
userInfo.confirmed = true;
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`✅ Payment confirmed:`, {
|
||||
console.log(`✅ Payment included in block (confirmed):`, {
|
||||
address: address.substring(0, 10) + '...',
|
||||
confirmations: confirmations,
|
||||
txid: txHash
|
||||
@@ -234,7 +234,9 @@ class WalletRPCService {
|
||||
}
|
||||
}, interval);
|
||||
|
||||
this.paymentWatchers.set(address, watcher);
|
||||
this.paymentWatchers.set(address, watcher);
|
||||
// Allow process to exit if this is the only remaining handle (useful for tests)
|
||||
if (watcher.unref) watcher.unref();
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`👁️ Started monitoring payments for:`, address.substring(0, 10) + '...');
|
||||
@@ -253,6 +255,17 @@ class WalletRPCService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder for future batch payout aggregation.
|
||||
* Currently returns early to avoid runtime errors where it's scheduled.
|
||||
*/
|
||||
async processBatchPayouts() {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log('⚙️ processBatchPayouts stub invoked (no payouts processed).');
|
||||
}
|
||||
return { processed: 0 };
|
||||
}
|
||||
|
||||
async processPayout(recipientAddress, amount, description) {
|
||||
try {
|
||||
// Send payment to user
|
||||
@@ -289,47 +302,6 @@ class WalletRPCService {
|
||||
}
|
||||
}
|
||||
|
||||
startPaymentMonitoring(address, callback, interval = 2000) {
|
||||
if (this.paymentWatchers.has(address)) {
|
||||
return; // Already monitoring
|
||||
}
|
||||
|
||||
const watcher = setInterval(async () => {
|
||||
try {
|
||||
const status = await this.checkPaymentStatus(address);
|
||||
|
||||
if (status.in_mempool || status.confirmed) {
|
||||
callback(status);
|
||||
|
||||
// Stop monitoring if payment is confirmed
|
||||
if (status.confirmed && status.complete) {
|
||||
this.stopPaymentMonitoring(address);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error monitoring payment ${address.substring(0, 10)}...:`, error.message);
|
||||
}
|
||||
}, interval);
|
||||
|
||||
this.paymentWatchers.set(address, watcher);
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`👁️ Started monitoring payments for:`, address.substring(0, 10) + '...');
|
||||
}
|
||||
}
|
||||
|
||||
stopPaymentMonitoring(address) {
|
||||
const watcher = this.paymentWatchers.get(address);
|
||||
if (watcher) {
|
||||
clearInterval(watcher);
|
||||
this.paymentWatchers.delete(address);
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`🛑 Stopped monitoring:`, address.substring(0, 10) + '...');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cleanupUserPayments(userId) {
|
||||
for (const [address, userInfo] of this.addressToUser.entries()) {
|
||||
if (userInfo.userId === userId || userInfo.socketId === userId) {
|
||||
|
||||
+559963
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
const MovementManager = require('../src/game/movementManager');
|
||||
|
||||
function makeChaseGame() {
|
||||
// Minimal game stub with player + monster and deterministic chase
|
||||
return {
|
||||
player: { x: 1, y: 1, getState() { return { x: this.x, y: this.y }; } },
|
||||
monster: { x: 5, y: 1, getState() { return { x: this.x, y: this.y }; } },
|
||||
movePlayer(dx, dy) {
|
||||
this.player.x += dx; this.player.y += dy; return { status: 'moved' };
|
||||
},
|
||||
moveMonster() { // simple horizontal chase
|
||||
if (this.monster.x > this.player.x) this.monster.x--; else if (this.monster.x < this.player.x) this.monster.x++;
|
||||
if (this.monster.y > this.player.y) this.monster.y--; else if (this.monster.y < this.player.y) this.monster.y++;
|
||||
},
|
||||
getState() { return { player: { x: this.player.x, y: this.player.y }, monster: { x: this.monster.x, y: this.monster.y } }; }
|
||||
};
|
||||
}
|
||||
|
||||
describe('Monster chase integration (MovementManager hook)', () => {
|
||||
test('monster advances toward player each move', () => {
|
||||
const activeGames = new Map();
|
||||
const socketId = 'sock-monster';
|
||||
const game = makeChaseGame();
|
||||
activeGames.set(socketId, game);
|
||||
const emitted = [];
|
||||
const io = { to: () => ({ emit: (evt, data) => emitted.push({ evt, data }) }) };
|
||||
const debugManager = { getCurrentBlockHeight: () => 999 };
|
||||
// Provide postMoveHook similar to server wiring
|
||||
const mm = new MovementManager({ activeGames, io, debugManager, moveCooldown: 0, postMoveHook: ({ game }) => game.moveMonster() });
|
||||
|
||||
// Player moves right 3 times; monster should close distance
|
||||
const startDistance = game.monster.x - game.player.x;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
mm.handleMove(socketId, { direction: 'right' });
|
||||
}
|
||||
const endDistance = game.monster.x - game.player.x;
|
||||
expect(endDistance).toBeLessThan(startDistance); // monster got closer
|
||||
// Ensure we emitted 3 updates
|
||||
expect(emitted.filter(e => e.evt === 'game_update').length).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
const MovementManager = require('../src/game/movementManager');
|
||||
|
||||
// Minimal fake game implementing movePlayer & getState
|
||||
function makeGame() {
|
||||
return {
|
||||
player: { x: 5, y: 5, getState() { return { x: this.x, y: this.y }; } },
|
||||
movePlayer(dx, dy) {
|
||||
this.player.x += dx;
|
||||
this.player.y += dy;
|
||||
return { status: 'moved' };
|
||||
},
|
||||
getState() { return { player: { x: this.player.x, y: this.player.y } }; }
|
||||
};
|
||||
}
|
||||
|
||||
describe('MovementManager', () => {
|
||||
test('applies movement and rate limits subsequent rapid moves', () => {
|
||||
const activeGames = new Map();
|
||||
const socketId = 'sock1';
|
||||
const game = makeGame();
|
||||
activeGames.set(socketId, game);
|
||||
const emitted = [];
|
||||
const io = { to: () => ({ emit: (evt, data) => emitted.push({ evt, data }) }) };
|
||||
const debugManager = { getCurrentBlockHeight: () => 123 };
|
||||
const mm = new MovementManager({ activeGames, io, debugManager, moveCooldown: 100 });
|
||||
|
||||
mm.handleMove(socketId, { direction: 'right' });
|
||||
mm.handleMove(socketId, { direction: 'right' }); // should be rate-limited
|
||||
|
||||
expect(game.player.x).toBe(6); // only first move applied
|
||||
expect(emitted.filter(e => e.evt === 'game_update').length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
const PaymentHandlers = require('../src/network/paymentHandlers');
|
||||
|
||||
function makeMocks() {
|
||||
return {
|
||||
io: { to: () => ({ emit: () => {} }) },
|
||||
gameModeManager: {
|
||||
gameMode: 'PAID_SINGLE',
|
||||
cryptoType: 'WOW',
|
||||
singleGamePrice: 1000,
|
||||
createPaymentRequest: async () => ({ id: 'pid123', address: 'addr', amount: 1000, expiresAt: new Date() })
|
||||
},
|
||||
walletService: {
|
||||
startPaymentMonitoring: (addr, cb) => { /* simulate nothing */ },
|
||||
stopPaymentMonitoring: () => {}
|
||||
},
|
||||
debugManager: { getCurrentBlockHeight: () => 123, CONSOLE_LOGGING: false },
|
||||
queueManager: {
|
||||
getUserBySocket: () => ({ clientId: 'client1' }),
|
||||
getPlayerIndex: () => -1,
|
||||
addPlayer: () => {},
|
||||
markConfirmed: () => {}
|
||||
},
|
||||
broadcastManager: { sendStatusUpdate: () => {} }
|
||||
};
|
||||
}
|
||||
|
||||
describe('PaymentHandlers basic', () => {
|
||||
test('createAndShowPaymentRequest emits payment_created', async () => {
|
||||
const mocks = makeMocks();
|
||||
const emitted = {};
|
||||
mocks.io.to = () => ({ emit: (event, data) => { emitted[event] = data; } });
|
||||
ph = new PaymentHandlers(mocks);
|
||||
await ph.createAndShowPaymentRequest({ id: 'sock1' });
|
||||
expect(emitted.payment_created).toBeDefined();
|
||||
expect(emitted.payment_created.paymentId).toBe('pid123');
|
||||
});
|
||||
afterAll(() => {
|
||||
if (ph && typeof ph.dispose === 'function') {
|
||||
ph.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
const QueueManager = require('../src/network/queueManager');
|
||||
const PaymentHandlers = require('../src/network/paymentHandlers');
|
||||
|
||||
/**
|
||||
* Integration-style test to ensure a payment that confirms AFTER a block event
|
||||
* (where the entry was still unconfirmed) results in an immediate game start
|
||||
* without waiting for the next block.
|
||||
*/
|
||||
|
||||
describe('Payment flow integration: confirm-after-block immediate start', () => {
|
||||
test('unconfirmed at block tick then confirmed triggers immediate game', async () => {
|
||||
const events = [];
|
||||
const activeGames = new Map();
|
||||
const socketId = 'sock-int';
|
||||
const blockHeight = 100;
|
||||
|
||||
// Mock IO emitter
|
||||
const io = {
|
||||
to: () => ({
|
||||
emit: (event, data) => {
|
||||
events.push({ event, data });
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// Minimal broadcast manager stub
|
||||
const broadcastManager = {
|
||||
sendStatusUpdate: () => {},
|
||||
broadcastChatMessage: () => {}
|
||||
};
|
||||
|
||||
// Debug manager mock
|
||||
const debugManager = {
|
||||
CONSOLE_LOGGING: false,
|
||||
getCurrentBlockHeight: () => blockHeight
|
||||
};
|
||||
|
||||
// User lookup
|
||||
const userObj = { id: socketId, clientId: 'client-int' };
|
||||
const getUserBySocket = (id) => id === socketId ? userObj : null;
|
||||
|
||||
// createGameForUser mock (records active game & returns state)
|
||||
const createGameForUser = (user) => {
|
||||
const game = {
|
||||
getState: () => ({ player: { x:0, y:0 }, lighting: {}, torches: [], started: true })
|
||||
};
|
||||
activeGames.set(user.id, game);
|
||||
return game;
|
||||
};
|
||||
|
||||
// Game mode manager mock (paid single game mode)
|
||||
const gameModeManager = {
|
||||
gameMode: 'PAID_SINGLE',
|
||||
cryptoType: 'WOW',
|
||||
singleGamePrice: 1000,
|
||||
createPaymentRequest: async () => ({ id: 'pay-1', address: 'addr-1', amount: 1000, expiresAt: new Date(Date.now()+60000) })
|
||||
};
|
||||
|
||||
// Wallet service mock with manual callback control
|
||||
let monitorCallback;
|
||||
const walletService = {
|
||||
startPaymentMonitoring: (address, cb) => { monitorCallback = cb; },
|
||||
stopPaymentMonitoring: () => {}
|
||||
};
|
||||
|
||||
// Instantiate real QueueManager & PaymentHandlers
|
||||
const queueManager = new QueueManager({
|
||||
debugManager,
|
||||
broadcastManager,
|
||||
io,
|
||||
createGameForUser,
|
||||
getUserBySocket,
|
||||
activeGames,
|
||||
gameModeManager,
|
||||
consoleLogging: false
|
||||
});
|
||||
|
||||
const paymentHandlers = new PaymentHandlers({
|
||||
io,
|
||||
gameModeManager,
|
||||
walletService,
|
||||
debugManager,
|
||||
queueManager,
|
||||
broadcastManager
|
||||
});
|
||||
|
||||
// Fake socket object with id
|
||||
const fakeSocket = { id: socketId, emit: (event, data) => events.push({ event, data }) };
|
||||
|
||||
// Create payment request (does NOT queue yet) and start monitoring
|
||||
await paymentHandlers.createAndShowPaymentRequest(fakeSocket);
|
||||
|
||||
expect(monitorCallback).toBeDefined();
|
||||
|
||||
// Simulate mempool detection first
|
||||
monitorCallback({ in_mempool: true, confirmed: false, amount: 1000, confirmations: 0 });
|
||||
// Should have queued the player but not started a game
|
||||
expect(queueManager.getQueueLength()).toBe(1);
|
||||
expect(events.filter(e => e.event === 'game_start').length).toBe(0);
|
||||
|
||||
// Simulate block tick BEFORE confirmation (should skip unconfirmed)
|
||||
queueManager.startGamesForWaiting(blockHeight);
|
||||
expect(events.filter(e => e.event === 'game_start').length).toBe(0);
|
||||
// Entry should still be in queue (re-queued)
|
||||
expect(queueManager.getQueueLength()).toBe(1);
|
||||
|
||||
// Now simulate confirmation after the block
|
||||
monitorCallback({ in_mempool: true, confirmed: true, amount: 1000, confirmations: 1 });
|
||||
|
||||
// Immediate game start expected
|
||||
const gameStarts = events.filter(e => e.event === 'game_start');
|
||||
expect(gameStarts.length).toBe(1);
|
||||
expect(gameStarts[0].data.blockHeight).toBe(blockHeight);
|
||||
// Queue should now be empty
|
||||
expect(queueManager.getQueueLength()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
const WalletRPCService = require('../src/payments/walletRPCService');
|
||||
|
||||
// Minimal debugManager mock
|
||||
const debugManagerMock = { CONSOLE_LOGGING: false };
|
||||
|
||||
describe('WalletRPCService interface', () => {
|
||||
test('exposes expected methods', () => {
|
||||
const svc = new WalletRPCService(debugManagerMock);
|
||||
expect(typeof svc.initialize).toBe('function');
|
||||
expect(typeof svc.rpcCall).toBe('function');
|
||||
expect(typeof svc.createPaymentRequest).toBe('function');
|
||||
expect(typeof svc.checkPaymentStatus).toBe('function');
|
||||
expect(typeof svc.startPaymentMonitoring).toBe('function');
|
||||
expect(typeof svc.stopPaymentMonitoring).toBe('function');
|
||||
expect(typeof svc.processBatchPayouts).toBe('function'); // newly added stub
|
||||
});
|
||||
|
||||
test('processBatchPayouts stub returns processed:0', async () => {
|
||||
const svc = new WalletRPCService(debugManagerMock);
|
||||
const res = await svc.processBatchPayouts();
|
||||
expect(res).toEqual({ processed: 0 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user