Begin payment logic and tests

This commit is contained in:
jwinterm
2025-09-27 08:45:22 -04:00
parent 4ad063f735
commit e3915dcde2
29 changed files with 564835 additions and 853 deletions
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 18 KiB

+7
View File
@@ -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!");
}
+8 -1
View File
@@ -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)
+7 -7
View File
@@ -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.");
},
+47 -5
View File
@@ -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
};
+179 -14
View File
@@ -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');
}
}
};
+104
View File
@@ -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
View File
@@ -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
+12 -2
View File
@@ -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
View File
@@ -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;
}
/**
+19 -7
View File
@@ -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,
+69
View File
@@ -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
View File
@@ -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}`);
}
}
+10
View File
@@ -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 -2
View File
@@ -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 });
}
/**
+192
View File
@@ -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;
+146
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+3521
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -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"
}
}
+33
View File
@@ -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 };
+16 -44
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -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);
});
});
+33
View File
@@ -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);
});
});
+42
View File
@@ -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();
}
});
});
+117
View File
@@ -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);
});
});
+23
View File
@@ -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 });
});
});