mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-21 16:18:33 +02:00
Begin payment logic and tests
This commit is contained in:
+27
-6
@@ -7,21 +7,28 @@
|
||||
GAME_MODE=PAID_SINGLE
|
||||
|
||||
# Cryptocurrency Configuration
|
||||
CRYPTO_TYPE=XMR
|
||||
CRYPTO_TYPE=WOW
|
||||
# Payment amounts in atomic units (piconeros for XMR, tacoshis for WOW)
|
||||
# XMR: 0.005 XMR = 5000000000 piconeros
|
||||
# WOW: 1 WOW = 100000000000 tacoshis
|
||||
SINGLE_GAME_PRICE=5000000000
|
||||
CREDITS_PACKAGE_PRICE=50000000000
|
||||
# You can use plain integers, scientific notation (e.g. 5e9), or underscores (e.g. 5_000_000_000)
|
||||
# The parser will coerce to an integer atomic unit amount.
|
||||
SINGLE_GAME_PRICE=1e11 # 1 WOW
|
||||
CREDITS_PACKAGE_PRICE=5e11 # 5 WOW
|
||||
# SINGLE_GAME_PRICE=5e9 # 0.005 XMR (was 5000000000)
|
||||
# CREDITS_PACKAGE_PRICE=5e10 # 0.05 XMR (was 50000000000)
|
||||
# Alternative readable style:
|
||||
# SINGLE_GAME_PRICE=5_000_000_000
|
||||
# CREDITS_PACKAGE_PRICE=50_000_000_000
|
||||
|
||||
# Wallet RPC Configuration (replaces MoneroPay)
|
||||
# Wallet RPC Configuration
|
||||
PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:34570
|
||||
# WALLET_RPC_USER=user
|
||||
# WALLET_RPC_PASSWORD=password
|
||||
|
||||
# Blockchain RPC Configuration
|
||||
PRIMARY_RPC_ENDPOINT=http://jw-nodes:18081
|
||||
FALLBACK_RPC_ENDPOINT=http://jw-nodes:18081
|
||||
PRIMARY_RPC_ENDPOINT=http://127.0.0.1:34568
|
||||
FALLBACK_RPC_ENDPOINT=http://127.0.0.1:34568
|
||||
RPC_POLL_INTERVAL=2000
|
||||
|
||||
# Database Configuration
|
||||
@@ -35,6 +42,20 @@ DB_PASSWORD=jw
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
|
||||
# Block Height Source & Debug Controls
|
||||
# BLOCK_SOURCE=daemon -> always use real blockchain height (default for paid modes)
|
||||
# BLOCK_SOURCE=simulated -> force simulated blocks (development)
|
||||
# SIMULATED_BLOCKS=true/false overrides automatic detection (if set)
|
||||
# FORCE_SIMULATED_BLOCKS=true will force simulation even in paid modes (for local testing)
|
||||
# Examples:
|
||||
# BLOCK_SOURCE=daemon
|
||||
# BLOCK_SOURCE=simulated
|
||||
# SIMULATED_BLOCKS=false
|
||||
# FORCE_SIMULATED_BLOCKS=true
|
||||
BLOCK_SOURCE=daemon
|
||||
# SIMULATED_BLOCKS=false
|
||||
# FORCE_SIMULATED_BLOCKS=false
|
||||
|
||||
# Payout Configuration
|
||||
PAYOUT_BATCH_INTERVAL=300000
|
||||
MAX_PAYOUT_BATCH_SIZE=50
|
||||
|
||||
@@ -63,6 +63,9 @@ class DatabaseManager {
|
||||
*/
|
||||
async runMigrations() {
|
||||
try {
|
||||
// Simple migration ledger
|
||||
await this.pool.query(`CREATE TABLE IF NOT EXISTS schema_migrations (filename TEXT PRIMARY KEY, applied_at TIMESTAMP DEFAULT NOW())`);
|
||||
|
||||
const migrationsPath = path.join(__dirname, '../migrations');
|
||||
const files = await fs.readdir(migrationsPath);
|
||||
const sqlFiles = files.filter(file => file.endsWith('.sql')).sort();
|
||||
@@ -70,15 +73,22 @@ class DatabaseManager {
|
||||
console.log(`📁 Found ${sqlFiles.length} migration files`);
|
||||
|
||||
for (const file of sqlFiles) {
|
||||
// Skip if already applied
|
||||
const existing = await this.pool.query(`SELECT 1 FROM schema_migrations WHERE filename = $1`, [file]);
|
||||
if (existing.rowCount > 0) {
|
||||
console.log(`⏩ Skipping already applied migration: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = path.join(migrationsPath, file);
|
||||
const sql = await fs.readFile(filePath, 'utf8');
|
||||
|
||||
console.log(`🔄 Running migration: ${file}`);
|
||||
await this.pool.query(sql);
|
||||
await this.pool.query(`INSERT INTO schema_migrations (filename) VALUES ($1)`, [file]);
|
||||
console.log(`✅ Migration completed: ${file}`);
|
||||
}
|
||||
|
||||
console.log('✅ All migrations completed successfully');
|
||||
console.log('✅ Migration phase complete');
|
||||
} catch (error) {
|
||||
console.error('❌ Migration failed:', error.message);
|
||||
throw error;
|
||||
|
||||
+30
-13
@@ -17,14 +17,24 @@ class DebugManager {
|
||||
this.IS_DEBUG = nodeEnv === 'debug';
|
||||
this.IS_DEVELOPMENT = nodeEnv === 'development';
|
||||
|
||||
// Debug mode is enabled in development or debug environments
|
||||
this.DEBUG_MODE = this.IS_DEBUG || this.IS_DEVELOPMENT;
|
||||
// Debug mode is enabled in development or debug environments
|
||||
this.DEBUG_MODE = this.IS_DEBUG || this.IS_DEVELOPMENT;
|
||||
|
||||
// Configuration from package.json
|
||||
const config = this.IS_PRODUCTION ? packageConfig.config.production : packageConfig.config.debug;
|
||||
this.CONSOLE_LOGGING = config.console_logging || this.DEBUG_MODE;
|
||||
this.DEBUG_HOTKEYS = config.debug_hotkeys || this.DEBUG_MODE;
|
||||
this.SIMULATED_BLOCKS = config.simulated_blocks || this.DEBUG_MODE;
|
||||
// Configuration precedence:
|
||||
// 1. Explicit env overrides
|
||||
// 2. Package.json config
|
||||
const config = this.IS_PRODUCTION ? packageConfig.config.production : packageConfig.config.debug;
|
||||
this.CONSOLE_LOGGING = process.env.CONSOLE_LOGGING ? process.env.CONSOLE_LOGGING === 'true' : (config.console_logging || this.DEBUG_MODE);
|
||||
this.DEBUG_HOTKEYS = process.env.DEBUG_HOTKEYS ? process.env.DEBUG_HOTKEYS === 'true' : (config.debug_hotkeys || this.DEBUG_MODE);
|
||||
// Simulation can be force-disabled by BLOCK_SOURCE=daemon or GAME_MODE paid modes
|
||||
const forceSimFlag = process.env.FORCE_SIMULATED_BLOCKS === 'true';
|
||||
const blockSource = (process.env.BLOCK_SOURCE || '').toLowerCase(); // 'daemon' | 'simulated'
|
||||
let simulatedDefault = config.simulated_blocks || this.DEBUG_MODE;
|
||||
if (blockSource === 'daemon') simulatedDefault = false;
|
||||
// In paid modes always use real daemon unless explicitly forced (for test)
|
||||
const paidMode = ['PAID_SINGLE','PAID_CREDITS'].includes(process.env.GAME_MODE);
|
||||
if (paidMode && !forceSimFlag) simulatedDefault = false;
|
||||
this.SIMULATED_BLOCKS = process.env.SIMULATED_BLOCKS ? process.env.SIMULATED_BLOCKS === 'true' : simulatedDefault;
|
||||
|
||||
this.debugBlockHeight = 1;
|
||||
this.debugInterval = null;
|
||||
@@ -48,6 +58,9 @@ class DebugManager {
|
||||
console.log(` CONSOLE_LOGGING: ${this.CONSOLE_LOGGING}`);
|
||||
console.log(` DEBUG_HOTKEYS: ${this.DEBUG_HOTKEYS}`);
|
||||
console.log(` SIMULATED_BLOCKS: ${this.SIMULATED_BLOCKS}`);
|
||||
if (process.env.GAME_MODE && ['PAID_SINGLE','PAID_CREDITS'].includes(process.env.GAME_MODE)) {
|
||||
console.log(` GAME_MODE: ${process.env.GAME_MODE} (simulation ${this.SIMULATED_BLOCKS ? 'ENABLED' : 'DISABLED'})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +70,7 @@ class DebugManager {
|
||||
* Initialize debug mode or production mode
|
||||
*/
|
||||
initialize() {
|
||||
if (this.DEBUG_MODE && this.SIMULATED_BLOCKS) {
|
||||
if (this.SIMULATED_BLOCKS) {
|
||||
this.initializeDebugMode();
|
||||
} else {
|
||||
this.initializeProductionMode();
|
||||
@@ -72,8 +85,8 @@ class DebugManager {
|
||||
console.log("🐛 DEBUG MODE ENABLED - Simulating blocks every 30 seconds");
|
||||
}
|
||||
|
||||
// Initial debug block broadcast
|
||||
this.broadcastManager.broadcastBlockHeight(this.debugBlockHeight);
|
||||
// Initial debug block broadcast
|
||||
this.broadcastManager.broadcastBlockHeight(this.debugBlockHeight);
|
||||
|
||||
// Debug block height simulator - advances every 30 seconds
|
||||
this.debugInterval = setInterval(() => {
|
||||
@@ -108,7 +121,7 @@ class DebugManager {
|
||||
this.rpcService = new RpcService();
|
||||
|
||||
// Real blockchain monitoring with new RPC service
|
||||
this.debugInterval = setInterval(async () => {
|
||||
const poll = async () => {
|
||||
try {
|
||||
const currentHeight = await this.rpcService.getBlockHeight();
|
||||
|
||||
@@ -151,7 +164,10 @@ class DebugManager {
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 2000); // Every 2 seconds as specified in .env
|
||||
};
|
||||
// immediate first poll so UI shows real height quickly
|
||||
poll();
|
||||
this.debugInterval = setInterval(poll, 2000); // Every 2 seconds
|
||||
}
|
||||
|
||||
// ====== BLOCK HEIGHT MANAGEMENT ======
|
||||
@@ -161,7 +177,8 @@ class DebugManager {
|
||||
* @returns {number} Current block height
|
||||
*/
|
||||
getCurrentBlockHeight() {
|
||||
return this.DEBUG_MODE ? this.debugBlockHeight : this.lastProductionBlockHeight;
|
||||
if (this.SIMULATED_BLOCKS) return this.debugBlockHeight;
|
||||
return this.lastProductionBlockHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,13 +10,25 @@ class GameModeManager {
|
||||
this.debugManager = debugManager;
|
||||
this.gameMode = process.env.GAME_MODE || 'FREE';
|
||||
this.cryptoType = process.env.CRYPTO_TYPE || 'XMR';
|
||||
this.singleGamePrice = parseInt(process.env.SINGLE_GAME_PRICE) || 5000000000; // 0.005 XMR
|
||||
this.creditsPackagePrice = parseInt(process.env.CREDITS_PACKAGE_PRICE) || 50000000000; // 0.05 XMR
|
||||
// Flexible parsing: supports plain integers, scientific notation (e.g. 5e9), and underscores (e.g. 5_000_000_000)
|
||||
const parseAtomic = (val, fallback) => {
|
||||
if (val === undefined || val === null || val === '') return fallback;
|
||||
if (typeof val !== 'string') return Math.trunc(Number(val)) || fallback;
|
||||
const cleaned = val.replace(/_/g, '').trim();
|
||||
const num = Number(cleaned);
|
||||
if (!Number.isFinite(num) || num < 0) return fallback;
|
||||
// Ensure integer atomic units
|
||||
return Math.trunc(num);
|
||||
};
|
||||
|
||||
this.singleGamePrice = parseAtomic(process.env.SINGLE_GAME_PRICE, 5000000000); // default 0.005 XMR
|
||||
this.creditsPackagePrice = parseAtomic(process.env.CREDITS_PACKAGE_PRICE, 50000000000); // default 0.05 XMR
|
||||
|
||||
console.log(`🎮 Game Mode Manager initialized: ${this.gameMode} mode`);
|
||||
console.log(`💰 Currency: ${this.cryptoType}`);
|
||||
console.log(`💵 Single game price: ${this.singleGamePrice} atomic units`);
|
||||
console.log(`🎫 Credits package price: ${this.creditsPackagePrice} atomic units`);
|
||||
const toXMR = (atomic) => (atomic / 1e12).toFixed(6).replace(/0+$/,'').replace(/\.$/,'');
|
||||
console.log(`💵 Single game price: ${this.singleGamePrice} atomic units (~${toXMR(this.singleGamePrice)} ${this.cryptoType})`);
|
||||
console.log(`🎫 Credits package price: ${this.creditsPackagePrice} atomic units (~${toXMR(this.creditsPackagePrice)} ${this.cryptoType})`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,9 +268,9 @@ class GameModeManager {
|
||||
|
||||
// Store payment info in database
|
||||
await this.db.query(`
|
||||
INSERT INTO payments (user_id, subaddress, expected_amount, payment_type, status, created_at)
|
||||
VALUES ($1, $2, $3, $4, 'pending', NOW())
|
||||
`, [user.id, paymentResult.address, amount, paymentType]);
|
||||
INSERT INTO payments (user_id, socket_id, subaddress, expected_amount, payment_type, status, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, 'pending', NOW())
|
||||
`, [user.id, socketId, paymentResult.address, amount, paymentType]);
|
||||
|
||||
return {
|
||||
id: paymentResult.id,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* MovementManager
|
||||
* Handles rate limiting and applying player movement to games.
|
||||
*/
|
||||
class MovementManager {
|
||||
constructor({ activeGames, io, debugManager, moveCooldown = 100, postMoveHook = null }) {
|
||||
this.activeGames = activeGames; // Map socketId -> Game
|
||||
this.io = io;
|
||||
this.debugManager = debugManager;
|
||||
this.moveCooldown = moveCooldown;
|
||||
this._lastMove = new Map(); // socketId -> timestamp
|
||||
this.postMoveHook = typeof postMoveHook === 'function' ? postMoveHook : null;
|
||||
}
|
||||
|
||||
handleMove(socketId, moveData) {
|
||||
if (!moveData || typeof moveData.direction !== 'string') return;
|
||||
const now = Date.now();
|
||||
const last = this._lastMove.get(socketId) || 0;
|
||||
if (now - last < this.moveCooldown) return; // rate limit
|
||||
this._lastMove.set(socketId, now);
|
||||
|
||||
const game = this.activeGames.get(socketId);
|
||||
if (!game) return; // not in a game
|
||||
|
||||
const dir = moveData.direction;
|
||||
let dx = 0, dy = 0;
|
||||
switch (dir) {
|
||||
case 'up': dy = -1; break;
|
||||
case 'down': dy = 1; break;
|
||||
case 'left': dx = -1; break;
|
||||
case 'right': dx = 1; break;
|
||||
default: return;
|
||||
}
|
||||
|
||||
let moveResult = null;
|
||||
if (typeof game.movePlayer === 'function') {
|
||||
moveResult = game.movePlayer(dx, dy);
|
||||
}
|
||||
|
||||
// Allow hook to perform monster movement or other side-effects BEFORE we snapshot state
|
||||
if (moveResult && moveResult.status === 'moved' && this.postMoveHook) {
|
||||
try {
|
||||
this.postMoveHook({ socketId, game, moveResult });
|
||||
} catch (e) {
|
||||
console.error('postMoveHook error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Build state after player + potential monster move
|
||||
let state;
|
||||
if (typeof game.getState === 'function') {
|
||||
state = game.getState();
|
||||
} else {
|
||||
state = { player: game.player };
|
||||
}
|
||||
if (this.debugManager && typeof this.debugManager.getCurrentBlockHeight === 'function') {
|
||||
state.blockHeight = this.debugManager.getCurrentBlockHeight();
|
||||
}
|
||||
|
||||
this.io.to(socketId).emit('game_update', state);
|
||||
|
||||
// Handle special events from moveResult (escape / treasure)
|
||||
if (moveResult && moveResult.event) {
|
||||
this.io.to(socketId).emit('game_event', { event: moveResult.event });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MovementManager;
|
||||
+4
-3
@@ -162,9 +162,10 @@ function debugRegisteredUsers() {
|
||||
console.log("📊 REGISTERED USERS DEBUG:");
|
||||
const allUsers = require('./db/user').getAllUsers();
|
||||
console.log(`Total registered users: ${allUsers.length}`);
|
||||
allUsers.forEach(user => {
|
||||
console.log(` - User ID: ${user.id}, Socket: ${user.socketId}, Client: ${user.clientId}`);
|
||||
});
|
||||
allUsers.forEach(u => {
|
||||
// u.id is the socket/server id; there is no separate socketId property on User
|
||||
console.log(` - User ID: ${u.id}, Socket: ${u.id}, Client: ${u.clientId}`);
|
||||
});
|
||||
console.log(`Active games: ${activeGames.size}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
// Run tests in Node environment
|
||||
testEnvironment: 'node',
|
||||
// Move Jest's logical root to the project root (one level above this config file)
|
||||
rootDir: '..',
|
||||
// Look for tests inside the existing top-level test directory
|
||||
roots: ['<rootDir>/test'],
|
||||
collectCoverage: false,
|
||||
moduleFileExtensions: ['js','json'],
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Migration 002: Increase address field lengths for Wownero compatibility and add socket_id to payments inserts going forward
|
||||
-- Wownero (WOW) standard & subaddresses are typically 97 chars (vs Monero 95); allow buffer.
|
||||
|
||||
ALTER TABLE payments
|
||||
ALTER COLUMN subaddress TYPE VARCHAR(110);
|
||||
|
||||
ALTER TABLE users
|
||||
ALTER COLUMN payout_address TYPE VARCHAR(110);
|
||||
|
||||
-- Optional: index remains valid; no changes needed.
|
||||
|
||||
-- End 002
|
||||
@@ -7,6 +7,7 @@
|
||||
class BroadcastManager {
|
||||
constructor(io) {
|
||||
this.io = io;
|
||||
this._lastBroadcastBlock = null;
|
||||
}
|
||||
|
||||
// ====== GLOBAL BROADCASTS (All Clients) ======
|
||||
@@ -16,8 +17,12 @@ class BroadcastManager {
|
||||
* @param {number} blockHeight - Current block height
|
||||
*/
|
||||
broadcastBlockHeight(blockHeight) {
|
||||
console.log(`📡 Broadcasting block height ${blockHeight} to all clients`);
|
||||
this.io.emit('blockheight', blockHeight);
|
||||
// Only log when the height actually changes to avoid per-client spam
|
||||
if (blockHeight !== this._lastBroadcastBlock) {
|
||||
console.log(`📡 Broadcasting block height ${blockHeight} to all clients`);
|
||||
this._lastBroadcastBlock = blockHeight;
|
||||
}
|
||||
this.io.emit('blockheight', { blockHeight });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Payment Handlers
|
||||
* Encapsulates payment request creation, monitoring, mempool detection,
|
||||
* confirmation handling, and queue integration.
|
||||
*/
|
||||
class PaymentHandlers {
|
||||
constructor({ io, gameModeManager, walletService, debugManager, queueManager, broadcastManager }) {
|
||||
this.io = io;
|
||||
this.gameModeManager = gameModeManager;
|
||||
this.walletService = walletService;
|
||||
this.debugManager = debugManager;
|
||||
this.queueManager = queueManager;
|
||||
this.broadcastManager = broadcastManager;
|
||||
this.mempoolNotified = new Set();
|
||||
this.paymentMonitors = new Map();
|
||||
// Track active monitored address per socket so we can stop on disconnect/expiry
|
||||
this.socketAddressMap = new Map();
|
||||
// Track paymentIds already confirmed (avoid duplicate emits / queue actions)
|
||||
this.confirmedPayments = new Set();
|
||||
// Periodic cleanup (confirmed payment IDs older than retention window)
|
||||
this._confirmedTimestamps = new Map();
|
||||
this._confirmedRetentionMs = 6 * 60 * 60 * 1000; // 6 hours
|
||||
// Track expiry timeouts so they can be cleared / unref'd to avoid keeping process open
|
||||
this._expiryTimeouts = new Map(); // socketId -> timeout
|
||||
// Keep reference so tests can dispose / and unref so it doesn't keep event loop alive
|
||||
this._cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [pid, ts] of this._confirmedTimestamps.entries()) {
|
||||
if (now - ts > this._confirmedRetentionMs) {
|
||||
this._confirmedTimestamps.delete(pid);
|
||||
this.confirmedPayments.delete(pid);
|
||||
}
|
||||
}
|
||||
}, 30 * 60 * 1000); // sweep every 30 min
|
||||
if (this._cleanupInterval && this._cleanupInterval.unref) {
|
||||
// Allow process (including Jest) to exit naturally without waiting for this interval
|
||||
this._cleanupInterval.unref();
|
||||
}
|
||||
}
|
||||
|
||||
async handlePaymentRequest(socket, data) {
|
||||
if (!this.gameModeManager) {
|
||||
this.io.to(socket.id).emit('payment_error', { error: 'Payment system not available' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { gameMode } = data;
|
||||
const paymentRequest = await this.gameModeManager.createPaymentRequest(socket.id, gameMode);
|
||||
this.io.to(socket.id).emit('payment_created', {
|
||||
paymentId: paymentRequest.id,
|
||||
address: paymentRequest.address,
|
||||
amount: paymentRequest.amount,
|
||||
gameMode: gameMode
|
||||
});
|
||||
if (this.debugManager.CONSOLE_LOGGING) console.log(`💳 Payment request created for ${socket.id}: ${paymentRequest.amount}`);
|
||||
} catch (e) {
|
||||
console.error('Error creating payment request:', e);
|
||||
this.io.to(socket.id).emit('payment_error', { error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
async createAndShowPaymentRequest(socket) {
|
||||
if (!this.gameModeManager) return;
|
||||
try {
|
||||
const currentUser = socket.id && this.queueManager.getUserBySocket ? this.queueManager.getUserBySocket(socket.id) : null;
|
||||
// caller (SocketHandlers) will handle user existence; keep method generic
|
||||
const gameMode = this.gameModeManager.gameMode;
|
||||
const cryptoType = this.gameModeManager.cryptoType;
|
||||
let paymentType, amount, description;
|
||||
if (gameMode === 'PAID_SINGLE') { paymentType = 'single_game'; amount = this.gameModeManager.singleGamePrice; description = 'Single game entry'; }
|
||||
else if (gameMode === 'PAID_CREDITS') { paymentType = 'credits_package'; amount = this.gameModeManager.creditsPackagePrice; description = '10 game credits package'; }
|
||||
else { this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Invalid game mode configuration.'); return; }
|
||||
|
||||
const paymentRequest = await this.gameModeManager.createPaymentRequest(socket.id, paymentType);
|
||||
let qrDataUrl = null; try { const { generatePaymentQR } = require('../payments/qrService'); qrDataUrl = await generatePaymentQR(paymentRequest.address, amount, cryptoType, description); } catch(e) {}
|
||||
const humanAmount = (amount / 1000000000000).toFixed(3);
|
||||
this.io.to(socket.id).emit('payment_created', { paymentId: paymentRequest.id, address: paymentRequest.address, amount: paymentRequest.amount, paymentType, gameMode, cryptoType, humanAmount, description, expiresAt: paymentRequest.expiresAt, qr: qrDataUrl });
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'payment', `💳 PAYMENT REQUIRED (${description})\n\nAmount: ${humanAmount} ${cryptoType}\nAddress: ${paymentRequest.address}\n\n⚠️ Send EXACTLY ${humanAmount} ${cryptoType}.\n🔄 Added to queue once mempool seen.\n⏰ Expires in 30 minutes.`);
|
||||
this._monitorAddress(socket, paymentRequest, amount, cryptoType, currentUser);
|
||||
} catch (e) {
|
||||
console.error('Error creating payment request:', e);
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Failed to create payment request.');
|
||||
}
|
||||
}
|
||||
|
||||
_monitorAddress(socket, paymentRequest, amount, cryptoType, currentUser) {
|
||||
// Record mapping so we can stop later
|
||||
this.socketAddressMap.set(socket.id, paymentRequest.address);
|
||||
|
||||
this.walletService.startPaymentMonitoring(paymentRequest.address, async (status) => {
|
||||
if (status.in_mempool && !status.confirmed) {
|
||||
if (this.mempoolNotified.has(paymentRequest.address)) return;
|
||||
this.mempoolNotified.add(paymentRequest.address);
|
||||
socket.emit('payment_detected', { paymentId: paymentRequest.id, message: 'Payment detected in mempool! Adding you to the game queue...', amount: status.amount, confirmations: 0 });
|
||||
const existingIdx = this.queueManager.getPlayerIndex(socket.id);
|
||||
if (existingIdx === -1) {
|
||||
this.queueManager.addPlayer({ serverId: socket.id, clientId: currentUser ? currentUser.clientId : null, paymentId: paymentRequest.id, requiresConfirmation: true, confirmed: false });
|
||||
}
|
||||
socket.emit('queue_joined', { position: (existingIdx === -1 ? this.queueManager.getQueueLength() : existingIdx + 1), message: 'Payment received! Waiting for next block to start game...', currentBlock: this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null, nextBlock: this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() + 1 : null });
|
||||
} else if (status.confirmed) {
|
||||
if (!this.confirmedPayments.has(paymentRequest.id)) {
|
||||
this.confirmedPayments.add(paymentRequest.id);
|
||||
socket.emit('payment_confirmed', { paymentId: paymentRequest.id, message: 'Payment confirmed in block!', confirmations: status.confirmations });
|
||||
this._confirmedTimestamps.set(paymentRequest.id, Date.now());
|
||||
this.queueManager.markConfirmed(socket.id);
|
||||
// Attempt immediate game start so user doesn't wait another full block
|
||||
const currentBlock = this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null;
|
||||
if (currentBlock !== null) {
|
||||
const started = this.queueManager.startGameImmediately(socket.id, currentBlock);
|
||||
if (!started && this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`[PaymentHandlers] Immediate start skipped (not queued or still unconfirmed) for ${socket.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clean up monitoring for this socket (even if duplicate)
|
||||
this.stopMonitoringForSocket(socket.id);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
// Expire request after 30 minutes if not confirmed
|
||||
const expiryTimeout = setTimeout(() => {
|
||||
const address = this.socketAddressMap.get(socket.id);
|
||||
if (address === paymentRequest.address) { // still active and not replaced
|
||||
this.stopMonitoringForSocket(socket.id);
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning', 'Payment request expired. Type \'enter\' again to create a new payment request.');
|
||||
}
|
||||
}, 30 * 60 * 1000);
|
||||
// Store & unref so tests / process can exit
|
||||
this._expiryTimeouts.set(socket.id, expiryTimeout);
|
||||
if (expiryTimeout.unref) expiryTimeout.unref();
|
||||
}
|
||||
|
||||
async handlePaymentDetected(socketId, paymentRequest, paymentStatus) {
|
||||
const currentUser = this.queueManager.getUserBySocket ? this.queueManager.getUserBySocket(socketId) : null;
|
||||
if (!currentUser) return;
|
||||
const existingIndex = this.queueManager.getPlayerIndex(socketId);
|
||||
if (existingIndex !== -1) { this.broadcastManager.sendStatusUpdate(socketId, 'info', 'Payment confirmed, already queued!'); return; }
|
||||
if (this.queueManager.activeGames && this.queueManager.activeGames.has(socketId)) { this.broadcastManager.sendStatusUpdate(socketId, 'info', 'Payment confirmed, but you are already in a game!'); return; }
|
||||
this.queueManager.addPlayer({ serverId: socketId, clientId: currentUser.clientId, paymentId: paymentRequest.id, requiresConfirmation: paymentStatus.in_mempool && !paymentStatus.confirmed, confirmed: paymentStatus.confirmed });
|
||||
const currentBlock = this.debugManager.getCurrentBlockHeight();
|
||||
const nextBlock = currentBlock + 1;
|
||||
if (paymentStatus.in_mempool && !paymentStatus.confirmed) {
|
||||
this.broadcastManager.sendStatusUpdate(socketId, 'success', `💰 PAYMENT DETECTED (MEMPOOL)\n\n✅ Added to queue.\n🕒 Starts at block ${nextBlock}.\n📦 Current block: ${currentBlock}`);
|
||||
this.broadcastManager.sendStatusUpdate(socketId, 'info', 'Waiting for block confirmation...');
|
||||
} else if (paymentStatus.confirmed) {
|
||||
this.broadcastManager.sendStatusUpdate(socketId, 'success', `💰 PAYMENT CONFIRMED IN BLOCK\n\n✅ Added to queue.\n🕒 Starts at block ${nextBlock}.\n📦 Current block: ${currentBlock}`);
|
||||
}
|
||||
if (!this.confirmedPayments.has(paymentRequest.id)) {
|
||||
this.confirmedPayments.add(paymentRequest.id);
|
||||
this.io.to(socketId).emit('payment_confirmed', { paymentId: paymentRequest.id, status: paymentStatus, nextBlock, currentBlock });
|
||||
this._confirmedTimestamps.set(paymentRequest.id, Date.now());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop monitoring (if any) for a given socket (disconnect/expiry)
|
||||
*/
|
||||
stopMonitoringForSocket(socketId) {
|
||||
const address = this.socketAddressMap.get(socketId);
|
||||
if (address) {
|
||||
this.walletService.stopPaymentMonitoring(address);
|
||||
this.socketAddressMap.delete(socketId);
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`🛑 Stopped monitoring for socket ${socketId}`);
|
||||
}
|
||||
}
|
||||
const expiry = this._expiryTimeouts.get(socketId);
|
||||
if (expiry) {
|
||||
clearTimeout(expiry);
|
||||
this._expiryTimeouts.delete(socketId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose resources (intervals, monitors) - useful for tests / graceful shutdown.
|
||||
*/
|
||||
dispose() {
|
||||
if (this._cleanupInterval) {
|
||||
clearInterval(this._cleanupInterval);
|
||||
this._cleanupInterval = null;
|
||||
}
|
||||
for (const [socketId] of this.socketAddressMap.entries()) {
|
||||
this.stopMonitoringForSocket(socketId);
|
||||
}
|
||||
for (const [socketId, to] of this._expiryTimeouts.entries()) {
|
||||
clearTimeout(to);
|
||||
this._expiryTimeouts.delete(socketId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PaymentHandlers;
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Queue Manager
|
||||
* Encapsulates logic for players waiting for the next block to start a game.
|
||||
* Responsible for:
|
||||
* - Managing queue entries (add/remove/confirm)
|
||||
* - Starting games when a new block arrives
|
||||
* - Preserving unconfirmed (mempool-only) entries across blocks
|
||||
*/
|
||||
class QueueManager {
|
||||
constructor({ debugManager, broadcastManager, io, createGameForUser, getUserBySocket, activeGames, gameModeManager, consoleLogging }) {
|
||||
this.debugManager = debugManager;
|
||||
this.broadcastManager = broadcastManager;
|
||||
this.io = io;
|
||||
this.createGameForUser = createGameForUser; // (user, gameType, options) => Game
|
||||
this.getUserBySocket = getUserBySocket; // (socketId) => User
|
||||
this.activeGames = activeGames; // Map socketId -> Game
|
||||
this.gameModeManager = gameModeManager;
|
||||
this.CONSOLE_LOGGING = !!consoleLogging;
|
||||
this._waitingPlayers = []; // internal queue
|
||||
}
|
||||
|
||||
addPlayer({ serverId, clientId, entryTime = Date.now(), paymentId = null, requiresConfirmation = false, confirmed = true }) {
|
||||
if (this.getPlayerIndex(serverId) !== -1) return; // already queued
|
||||
this._waitingPlayers.push({ serverId, clientId, entryTime, paymentId, requiresConfirmation, confirmed });
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log(`[QueueManager] Added player ${serverId} (confirmed=${confirmed}, requiresConfirmation=${requiresConfirmation}). Queue length: ${this._waitingPlayers.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
removePlayer(serverId) {
|
||||
const idx = this.getPlayerIndex(serverId);
|
||||
if (idx !== -1) {
|
||||
this._waitingPlayers.splice(idx, 1);
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log(`[QueueManager] Removed player ${serverId}. Queue length: ${this._waitingPlayers.length}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
markConfirmed(serverId) {
|
||||
const entry = this._waitingPlayers.find(p => p.serverId === serverId);
|
||||
if (entry) {
|
||||
entry.confirmed = true;
|
||||
entry.requiresConfirmation = false;
|
||||
if (this.CONSOLE_LOGGING) console.log(`[QueueManager] Marked player ${serverId} confirmed.`);
|
||||
}
|
||||
}
|
||||
|
||||
getPlayerIndex(serverId) { return this._waitingPlayers.findIndex(p => p.serverId === serverId); }
|
||||
isPlayerQueued(serverId) { return this.getPlayerIndex(serverId) !== -1; }
|
||||
getQueueLength() { return this._waitingPlayers.length; }
|
||||
getQueuePosition(serverId) { const i = this.getPlayerIndex(serverId); return i === -1 ? -1 : i + 1; }
|
||||
|
||||
startGamesForWaiting(blockHeight) {
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log(`[QueueManager] Starting games for ${this._waitingPlayers.length} waiting players at block ${blockHeight}`);
|
||||
}
|
||||
const remainingQueue = [];
|
||||
while (this._waitingPlayers.length > 0) {
|
||||
const entry = this._waitingPlayers.shift();
|
||||
const serverId = entry.serverId;
|
||||
|
||||
if (this.CONSOLE_LOGGING) console.log(`[QueueManager] Processing queued player ${serverId}`);
|
||||
const currentUser = this.getUserBySocket(serverId);
|
||||
if (!currentUser) {
|
||||
if (this.CONSOLE_LOGGING) console.warn(`[QueueManager] User not found for ${serverId}, skipping.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keep unconfirmed paid entries in queue
|
||||
if (entry.requiresConfirmation && !entry.confirmed) {
|
||||
remainingQueue.push(entry);
|
||||
if (this.CONSOLE_LOGGING) console.log(`[QueueManager] Skipping unconfirmed paid entry ${serverId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
currentUser.blockRec = blockHeight;
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log(`[QueueManager] Player ${serverId} enters on block ${blockHeight} (dies after ${blockHeight + 1})`);
|
||||
}
|
||||
try {
|
||||
const game = this.createGameForUser(currentUser, 'standard');
|
||||
const gameState = game.getState();
|
||||
gameState.blockHeight = blockHeight;
|
||||
this.io.to(serverId).emit('game_start', gameState);
|
||||
if (this.CONSOLE_LOGGING) console.log(`[QueueManager] Game started for ${serverId}`);
|
||||
} catch (error) {
|
||||
console.error('[QueueManager] Error creating game:', error);
|
||||
this.io.to(serverId).emit('message', 'Error starting game: ' + error.message);
|
||||
}
|
||||
}
|
||||
if (remainingQueue.length > 0) {
|
||||
this._waitingPlayers = remainingQueue.concat(this._waitingPlayers);
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log(`[QueueManager] Re-queued ${remainingQueue.length} unconfirmed entries.`);
|
||||
remainingQueue.forEach(e => {
|
||||
console.log(`[QueueManager] -> ${e.serverId} confirmed=${e.confirmed} requiresConfirmation=${e.requiresConfirmation}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugDumpQueue() {
|
||||
if (!this.CONSOLE_LOGGING) return;
|
||||
console.log('[QueueManager] Current queue dump:');
|
||||
this._waitingPlayers.forEach((e,i) => {
|
||||
console.log(` [${i}] ${e.serverId} confirmed=${e.confirmed} requiresConfirmation=${e.requiresConfirmation}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately start a game for a single confirmed player (e.g. payment confirmed AFTER
|
||||
* the block tick already processed). This prevents an additional full-block wait.
|
||||
* Returns true if a game was started.
|
||||
*/
|
||||
startGameImmediately(serverId, blockHeight) {
|
||||
const idx = this.getPlayerIndex(serverId);
|
||||
if (idx === -1) return false; // not queued
|
||||
const entry = this._waitingPlayers[idx];
|
||||
if (entry.requiresConfirmation && !entry.confirmed) {
|
||||
// Still not confirmed; cannot start
|
||||
return false;
|
||||
}
|
||||
// Remove from queue
|
||||
this._waitingPlayers.splice(idx, 1);
|
||||
const currentUser = this.getUserBySocket(serverId);
|
||||
if (!currentUser) return false;
|
||||
currentUser.blockRec = blockHeight; // lifetime until next block
|
||||
try {
|
||||
const game = this.createGameForUser(currentUser, 'standard');
|
||||
const gameState = game.getState();
|
||||
gameState.blockHeight = blockHeight;
|
||||
this.io.to(serverId).emit('game_start', gameState);
|
||||
if (this.CONSOLE_LOGGING) console.log(`[QueueManager] (immediate) Game started for ${serverId} at block ${blockHeight}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[QueueManager] Error starting immediate game:', err);
|
||||
this.io.to(serverId).emit('message', 'Error starting game: ' + err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = QueueManager;
|
||||
+161
-748
File diff suppressed because it is too large
Load Diff
Generated
+3521
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -7,7 +7,7 @@
|
||||
"start": "NODE_ENV=production node index.js",
|
||||
"dev": "NODE_ENV=development node index.js",
|
||||
"debug": "NODE_ENV=debug node index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "jest --runInBand"
|
||||
},
|
||||
"config": {
|
||||
"debug": {
|
||||
@@ -35,5 +35,9 @@
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"uuid": "^9.0.0"
|
||||
,"qrcode": "^1.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^29.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// qrService.js - Generates QR codes for payment URIs
|
||||
// Uses the 'qrcode' package if available; degrades gracefully if not.
|
||||
|
||||
let QR;
|
||||
try {
|
||||
QR = require('qrcode');
|
||||
} catch (e) {
|
||||
QR = null;
|
||||
}
|
||||
|
||||
function formatAmount(atomic) {
|
||||
if (!atomic || atomic <= 0) return '0';
|
||||
return (atomic / 1e12).toFixed(12).replace(/0+$/,'').replace(/\.$/,'');
|
||||
}
|
||||
|
||||
async function generatePaymentQR(address, amountAtomic, cryptoType, description) {
|
||||
if (!QR) return null; // Library not installed
|
||||
try {
|
||||
const amountStr = formatAmount(amountAtomic);
|
||||
const scheme = cryptoType && cryptoType.toUpperCase() === 'WOW' ? 'wownero' : 'monero';
|
||||
// Basic URI; wallets usually understand amount (tx_amount) & description
|
||||
const params = new URLSearchParams();
|
||||
if (amountAtomic) params.set('tx_amount', amountStr);
|
||||
if (description) params.set('tx_description', description.substring(0, 64));
|
||||
const uri = `${scheme}:${address}${params.toString() ? '?' + params.toString() : ''}`;
|
||||
return await QR.toDataURL(uri, { errorCorrectionLevel: 'M', scale: 6, margin: 1 });
|
||||
} catch (err) {
|
||||
console.error('QR generation failed:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { generatePaymentQR };
|
||||
@@ -157,7 +157,7 @@ class WalletRPCService {
|
||||
if (!userInfo.detected) {
|
||||
userInfo.detected = true;
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`🎯 Payment detected in mempool:`, {
|
||||
console.log(`🎯 Payment detected (mempool):`, {
|
||||
address: address.substring(0, 10) + '...',
|
||||
amount: tx.amount,
|
||||
txid: txHash
|
||||
@@ -183,7 +183,7 @@ class WalletRPCService {
|
||||
if (!userInfo.confirmed) {
|
||||
userInfo.confirmed = true;
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`✅ Payment confirmed:`, {
|
||||
console.log(`✅ Payment included in block (confirmed):`, {
|
||||
address: address.substring(0, 10) + '...',
|
||||
confirmations: confirmations,
|
||||
txid: txHash
|
||||
@@ -234,7 +234,9 @@ class WalletRPCService {
|
||||
}
|
||||
}, interval);
|
||||
|
||||
this.paymentWatchers.set(address, watcher);
|
||||
this.paymentWatchers.set(address, watcher);
|
||||
// Allow process to exit if this is the only remaining handle (useful for tests)
|
||||
if (watcher.unref) watcher.unref();
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`👁️ Started monitoring payments for:`, address.substring(0, 10) + '...');
|
||||
@@ -253,6 +255,17 @@ class WalletRPCService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder for future batch payout aggregation.
|
||||
* Currently returns early to avoid runtime errors where it's scheduled.
|
||||
*/
|
||||
async processBatchPayouts() {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log('⚙️ processBatchPayouts stub invoked (no payouts processed).');
|
||||
}
|
||||
return { processed: 0 };
|
||||
}
|
||||
|
||||
async processPayout(recipientAddress, amount, description) {
|
||||
try {
|
||||
// Send payment to user
|
||||
@@ -289,47 +302,6 @@ class WalletRPCService {
|
||||
}
|
||||
}
|
||||
|
||||
startPaymentMonitoring(address, callback, interval = 2000) {
|
||||
if (this.paymentWatchers.has(address)) {
|
||||
return; // Already monitoring
|
||||
}
|
||||
|
||||
const watcher = setInterval(async () => {
|
||||
try {
|
||||
const status = await this.checkPaymentStatus(address);
|
||||
|
||||
if (status.in_mempool || status.confirmed) {
|
||||
callback(status);
|
||||
|
||||
// Stop monitoring if payment is confirmed
|
||||
if (status.confirmed && status.complete) {
|
||||
this.stopPaymentMonitoring(address);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error monitoring payment ${address.substring(0, 10)}...:`, error.message);
|
||||
}
|
||||
}, interval);
|
||||
|
||||
this.paymentWatchers.set(address, watcher);
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`👁️ Started monitoring payments for:`, address.substring(0, 10) + '...');
|
||||
}
|
||||
}
|
||||
|
||||
stopPaymentMonitoring(address) {
|
||||
const watcher = this.paymentWatchers.get(address);
|
||||
if (watcher) {
|
||||
clearInterval(watcher);
|
||||
this.paymentWatchers.delete(address);
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`🛑 Stopped monitoring:`, address.substring(0, 10) + '...');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cleanupUserPayments(userId) {
|
||||
for (const [address, userInfo] of this.addressToUser.entries()) {
|
||||
if (userInfo.userId === userId || userInfo.socketId === userId) {
|
||||
|
||||
Reference in New Issue
Block a user