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