/** * Game Mode Manager * Handles different game modes (FREE, PAID_SINGLE, PAID_CREDITS) */ const { DEFAULT_DECIMALS, parseAtomicEnvValue, inferCurrencyDecimals: inferCurrencyDecimalsForSymbol, formatAtomic, formatAtomicHuman, getDecimalDivisor, gameNameFor, currencyLabelFor } = require('./helpers/gameModeUtils'); const { ValidationError, normalizeError } = require('../utils/errors'); const paymentConfig = require('../config/paymentConfig'); const money = require('../money/atomic'); const Entitlements = require('../multiplayer/entitlements'); const ProductGrants = require('../payments/productGrants'); const { buildProviderRegistry } = require('../payments/providers'); // A wallet "not enough (unlocked) money" error is raised BEFORE the tx is broadcast, so it // is SAFE to retry (no double-pay risk) — unlike an ambiguous post-broadcast failure. Monero // locks spent outputs (incl. change) for ~10 blocks, so this is the expected error when all // outputs are temporarily locked. function isInsufficientFundsError(message) { return /not enough (unlocked )?(money|balance|outputs)|insufficient|no unlocked|unlocked balance/i.test(String(message || '')); } const DEFAULT_SINGLE_GAME_PRICE = 5000000000; // 0.005 XMR or 0.05 WOW depending on currency decimals const DEFAULT_CREDITS_PACKAGE_PRICE = 50000000000; class GameModeManager { constructor(databaseManager, walletRPCService, debugManager, paymentConfigManager = null, paymentProviders = null) { this.db = databaseManager; this.walletService = walletRPCService; this.debugManager = debugManager; this.paymentConfigManager = paymentConfigManager || null; // Modular payment providers (Pillar 3). Defaults to a registry whose only member is the // native Monero/Wownero provider wrapping this walletService — so with no gateway env set // every chain routes to the existing wallet-RPC path and behavior is unchanged. Injectable // for tests. See src/payments/providers/index.js + the btcpay-infra-topology memo. this.paymentProviders = paymentProviders || buildProviderRegistry({ walletService: walletRPCService }); this.cryptoType = process.env.CRYPTO_TYPE || 'XMR'; this.currencyDecimals = this.inferCurrencyDecimals(this.cryptoType); // Network configuration (mainnet/stagenet/testnet) - only applies to Monero // Wownero only has mainnet, so this is ignored for WOW this.network = (process.env.MONERO_NETWORK || 'mainnet').toLowerCase(); this.isTestNetwork = this.network === 'stagenet' || this.network === 'testnet'; this.singleGamePrice = DEFAULT_SINGLE_GAME_PRICE; this.creditsPackagePrice = DEFAULT_CREDITS_PACKAGE_PRICE; this.creditsPayoutEnabled = false; this.creditsPayoutBaseValue = DEFAULT_SINGLE_GAME_PRICE; this.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 }; this.creditPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 }; this.creditsPerGameCost = 1; this.preferCreditsFirst = true; this.paymentsEnabled = false; this.directModeEnabled = false; this.creditsModeEnabled = false; this.configSnapshot = null; this.applyLegacyEnvConfig(); if (this.paymentConfigManager) { const config = this.paymentConfigManager.getConfig(); this.applyConfigSnapshot(config, { emitLog: false }); this.setLegacyGameMode(this.paymentConfigManager.getLegacyGameMode()); if (this.paymentConfigManager.eventBus && typeof this.paymentConfigManager.eventBus.on === 'function') { this.paymentConfigManager.eventBus.on('paymentConfig:update', (updatedConfig) => { this.applyConfigSnapshot(updatedConfig, { emitLog: true, context: 'updated' }); this.setLegacyGameMode(this.paymentConfigManager.getLegacyGameMode()); }); } } this.logConfiguration('initialized'); } inferCurrencyDecimals(symbol) { return inferCurrencyDecimalsForSymbol(symbol); } // Product name (Monerogue/Wownerogue) and network-aware currency label (sXMR on // stagenet, XMR on mainnet, WOW for Wownero) — derived so they always track config. get gameName() { return gameNameFor(this.cryptoType); } get currencyLabel() { return currencyLabelFor(this.cryptoType, this.network); } applyLegacyEnvConfig() { this.setLegacyGameMode(process.env.GAME_MODE || 'FREE'); this.cryptoType = process.env.CRYPTO_TYPE || this.cryptoType; this.currencyDecimals = this.inferCurrencyDecimals(this.cryptoType); // Support both DIRECT_GAME_PRICE and legacy SINGLE_GAME_PRICE this.singleGamePrice = parseAtomicEnvValue(process.env.DIRECT_GAME_PRICE || process.env.SINGLE_GAME_PRICE, this.singleGamePrice); this.creditsPackagePrice = parseAtomicEnvValue(process.env.CREDITS_PACKAGE_PRICE, this.creditsPackagePrice); this.creditsPerGameCost = parseAtomicEnvValue(process.env.CREDITS_PER_GAME, 1) || 1; this.creditsPayoutEnabled = /^true$/i.test(process.env.CREDITS_PAYOUTS_ENABLED || process.env.CREDITS_PAYOUT_ENABLED || 'false'); // Direct (per-game / entry-fee) payouts must be independently gateable, like credits. // Default true for backward compatibility; set DIRECT_PAYOUTS_ENABLED=false for a // no-payout instance (sell entry/credits for prestige only, e.g. mainnet legitimacy). this.directPayoutEnabled = !/^false$/i.test(process.env.DIRECT_PAYOUTS_ENABLED || 'true'); this.creditsPayoutBaseValue = parseAtomicEnvValue(process.env.CREDITS_PAYOUT_BASE, this.singleGamePrice); process.env.CREDITS_PER_GAME = String(this.creditsPerGameCost); const directEscape = Number(process.env.DIRECT_PAYOUT_ESCAPE); if (Number.isFinite(directEscape) && directEscape > 0) { this.directPayoutMultipliers.escape = directEscape; } const directTreasure = Number(process.env.DIRECT_PAYOUT_TREASURE); if (Number.isFinite(directTreasure) && directTreasure > 0) { this.directPayoutMultipliers.escapeWithTreasure = directTreasure; } const creditsEscape = Number(process.env.CREDITS_PAYOUT_ESCAPE); if (Number.isFinite(creditsEscape) && creditsEscape > 0) { this.creditPayoutMultipliers.escape = creditsEscape; } const creditsTreasure = Number(process.env.CREDITS_PAYOUT_TREASURE); if (Number.isFinite(creditsTreasure) && creditsTreasure > 0) { this.creditPayoutMultipliers.escapeWithTreasure = creditsTreasure; } if (process.env.PREFER_CREDITS_FIRST) { this.preferCreditsFirst = /^true$/i.test(process.env.PREFER_CREDITS_FIRST); } this.paymentsEnabled = this.gameMode !== 'FREE'; this.directModeEnabled = this.gameMode === 'PAID_SINGLE'; this.creditsModeEnabled = this.gameMode === 'PAID_CREDITS'; // FREE_PLAY_ENABLED lets players CHOOSE free play even on an instance that also // sells credits/entry (their score goes to the Pleb board; paid games go to the // Hall of Champions). Always true when payments are off (free is the only option). this.freePlayEnabled = !this.paymentsEnabled || /^true$/i.test(process.env.FREE_PLAY_ENABLED || 'false'); } applyConfigSnapshot(config, options = {}) { if (!config || typeof config !== 'object') { return; } this.configSnapshot = config; this.paymentsEnabled = !!config.paymentsEnabled; if (config.currency) { if (config.currency.symbol) { this.cryptoType = config.currency.symbol; } if (config.currency.decimals !== undefined) { this.currencyDecimals = Number(config.currency.decimals); } else { this.currencyDecimals = this.inferCurrencyDecimals(this.cryptoType); } } if (config.modes && config.modes.direct) { const { price, enabled } = config.modes.direct; if (price !== undefined && price !== null) { this.singleGamePrice = Number(price); } this.directModeEnabled = !!enabled; } if (config.modes && config.modes.credits) { const creditsMode = config.modes.credits; if (creditsMode.packages && creditsMode.packages.length > 0) { const primaryPackage = creditsMode.packages[0]; if (primaryPackage.price !== undefined && primaryPackage.price !== null) { this.creditsPackagePrice = Number(primaryPackage.price); } if (primaryPackage.credits) { process.env.CREDITS_PER_PACKAGE = String(primaryPackage.credits); } } if (creditsMode.creditsPerGame !== undefined) { this.creditsPerGameCost = Number(creditsMode.creditsPerGame) || 1; } this.creditsModeEnabled = !!creditsMode.enabled; } if (config.payouts && config.payouts.rules) { const directRule = config.payouts.rules.direct || {}; const creditsRule = config.payouts.rules.credits || {}; if (directRule.enabled !== undefined) { this.directPayoutEnabled = !!directRule.enabled; } if (directRule.multipliers) { if (directRule.multipliers.escape !== undefined) { this.directPayoutMultipliers.escape = Number(directRule.multipliers.escape); } if (directRule.multipliers.escapeWithTreasure !== undefined) { this.directPayoutMultipliers.escapeWithTreasure = Number(directRule.multipliers.escapeWithTreasure); } } if (creditsRule.multipliers) { if (creditsRule.multipliers.escape !== undefined) { this.creditPayoutMultipliers.escape = Number(creditsRule.multipliers.escape); } if (creditsRule.multipliers.escapeWithTreasure !== undefined) { this.creditPayoutMultipliers.escapeWithTreasure = Number(creditsRule.multipliers.escapeWithTreasure); } } if (creditsRule.baseValue !== undefined) { this.creditsPayoutBaseValue = Number(creditsRule.baseValue); } if (creditsRule.enabled !== undefined) { this.creditsPayoutEnabled = !!creditsRule.enabled; } } if (config.preferences) { if (config.preferences.preferCreditsFirst !== undefined) { this.preferCreditsFirst = !!config.preferences.preferCreditsFirst; } } process.env.SINGLE_GAME_PRICE = String(this.singleGamePrice); process.env.CREDITS_PACKAGE_PRICE = String(this.creditsPackagePrice); process.env.CREDITS_PER_GAME = String(this.creditsPerGameCost); process.env.CREDITS_PAYOUT_ENABLED = this.creditsPayoutEnabled ? 'true' : 'false'; process.env.CREDITS_PAYOUTS_ENABLED = process.env.CREDITS_PAYOUT_ENABLED; if (options.emitLog) { this.logConfiguration(options.context || 'updated'); } } setLegacyGameMode(mode) { this.gameMode = (mode || 'FREE').toUpperCase(); process.env.GAME_MODE = this.gameMode; } formatAtomic(value) { return formatAtomic({ value, decimals: Number.isFinite(this.currencyDecimals) ? this.currencyDecimals : DEFAULT_DECIMALS }); } getDecimalDivisor() { return getDecimalDivisor(Number.isFinite(this.currencyDecimals) ? this.currencyDecimals : DEFAULT_DECIMALS); } formatAtomicHuman(value, digits = 3) { return formatAtomicHuman({ value, decimals: Number.isFinite(this.currencyDecimals) ? this.currencyDecimals : DEFAULT_DECIMALS, digits }); } getPrimaryCreditPackage() { const packages = this.configSnapshot?.modes?.credits?.packages; if (Array.isArray(packages) && packages.length > 0) { return packages[0]; } return { id: 'default', credits: 10, price: this.creditsPackagePrice, bonus: 0 }; } _getMatchEconomies() { const out = { free: true }; if (this.creditsModeEnabled || this.freePlayEnabled) { out.credits_prestige = true; } if (process.env.MATCH_CRYPTO_RACE_ENABLED === 'true' && (this.directModeEnabled || this.creditsModeEnabled)) { out.crypto_race = true; } return out; } getCosmeticProducts() { const products = this.configSnapshot?.products?.cosmetic; return Array.isArray(products) ? products : []; } getCosmeticProduct(productId) { const products = this.getCosmeticProducts(); if (!productId) return products[0] || null; return products.find(p => p.id === productId) || null; } calculatePayout(mode, { treasureFound = false } = {}) { const normalizedMode = (mode || this.gameMode || 'FREE').toUpperCase(); const usingCredits = normalizedMode === 'PAID_CREDITS'; const base = usingCredits ? (this.creditsPayoutBaseValue || this.singleGamePrice) : this.singleGamePrice; const multipliers = usingCredits ? this.creditPayoutMultipliers : this.directPayoutMultipliers; const multiplier = treasureFound ? (multipliers.escapeWithTreasure ?? multipliers.escape ?? 0) : (multipliers.escape ?? 0); // Exact integer math: base (atomic units) * decimal multiplier via BigInt, then // narrow back to a number only when exactly representable. Avoids float precision // loss on large atomic amounts (the old `Math.round(base * multiplier)`). const amount = money.toSafe(money.mulByDecimal(base, multiplier)); console.log(`🧮 calculatePayout: mode=${normalizedMode}, usingCredits=${usingCredits}, treasureFound=${treasureFound}`); console.log(` base=${base}, multipliers=${JSON.stringify(multipliers)}, chosen multiplier=${multiplier}`); console.log(` final amount=${amount} (base * multiplier = ${base} * ${multiplier})`); return { amount, multiplier, base }; } /** * Process a confirmed credits package payment - add credits to user * SECURITY: Uses atomic check-and-update to prevent double-crediting on server restart * @param {string} socketId - Socket ID of the user * @param {number} paymentId - Payment record ID * @param {object} packageInfo - Package info (credits, bonus, etc.) * @returns {object} Result with success, creditsAdded, newBalance, alreadyProcessed */ async processProductPaymentConfirmation(socketId, paymentId, productInfo = null, receivedAmount = null) { try { // Look up user from payment record's user_id (stable across socket reconnects) // This avoids the bug where socket ID changes if user refreshes during confirmation const paymentLookup = await this.db.query(` SELECT user_id, description, status, payment_type, product_id, product_grants FROM payments WHERE id = $1 `, [paymentId]); if (paymentLookup.rows.length === 0) { console.error(`Payment ${paymentId} not found`); return { success: false, reason: 'Payment not found' }; } // Fast-fail if already confirmed (avoids unnecessary transaction) if (paymentLookup.rows[0].status === 'confirmed') { console.log(`Payment ${paymentId} already confirmed, skipping duplicate processing`); return { success: false, alreadyProcessed: true, reason: 'Payment already processed' }; } const userId = paymentLookup.rows[0].user_id; const paymentRow = paymentLookup.rows[0]; // Determine credits to add from package info or payment description const paymentType = paymentRow.payment_type || 'credits_package'; let fallbackCredits = paymentType === 'credits_package' ? 10 : 0; if (productInfo && productInfo.credits) { fallbackCredits = Number(productInfo.credits) + (Number(productInfo.bonus) || 0); } else { const desc = paymentLookup.rows[0].description || ''; const match = desc.match(/(\d+)\s*credits?/i); if (match) { fallbackCredits = parseInt(match[1], 10) || fallbackCredits; } } const durableProduct = { id: paymentRow.product_id, grants: paymentRow.product_grants || undefined }; const product = productInfo || durableProduct; const productGrants = ProductGrants.normalizeProductGrants(product, { credits: fallbackCredits }); const creditsToAdd = productGrants.credits; const productId = product?.id || paymentRow.product_id || paymentType; const grantJson = ProductGrants.serializeProductGrants(productGrants); // CRITICAL: Wrap all three operations in a transaction. // If any step fails, the payment stays 'pending' and can be recovered. const result = await this.db.withTransaction(async (client) => { // Step 1: Atomically mark payment as confirmed (prevents double-crediting) const paymentUpdateResult = await client.query(` UPDATE payments SET status = 'confirmed', credits_purchased = $1, confirmed_at = NOW(), received_amount = COALESCE($3, received_amount), product_id = COALESCE(product_id, $4), product_grants = $5::jsonb WHERE id = $2 AND status = 'pending' RETURNING id `, [creditsToAdd, paymentId, receivedAmount, productId, JSON.stringify(grantJson)]); if (paymentUpdateResult.rows.length === 0) { // Already processed by another instance - not an error return { alreadyProcessed: true }; } // Step 2: Add credits / premium tier to user (using payment's user_id, not socket lookup) const updateResult = await client.query(` UPDATE users SET credits = credits + $1, total_credits_purchased = COALESCE(total_credits_purchased, 0) + $1, premium_level = CASE WHEN $3::text IS NULL THEN premium_level ELSE $3::text END, updated_at = NOW() WHERE id = $2 RETURNING id, credits, total_credits_purchased, premium_level `, [creditsToAdd, userId, productGrants.premiumLevel]); const newBalance = updateResult.rows[0]?.credits ?? creditsToAdd; const totalCreditsPurchased = updateResult.rows[0]?.total_credits_purchased ?? creditsToAdd; const premiumLevel = updateResult.rows[0]?.premium_level || 'free'; // Step 3: Record credit transaction for audit trail when credits were granted. if (creditsToAdd > 0) { await client.query(` INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type) VALUES ($1, $2, 'package_purchase', $3, 'purchase') `, [userId, creditsToAdd, newBalance]); } // Step 4: Grant race entry tickets if the product includes them. const raceEntriesToAdd = productGrants.raceEntries || 0; if (raceEntriesToAdd > 0) { const raceEntryUpdate = await client.query(` UPDATE users SET race_entries = race_entries + $1 WHERE id = $2 RETURNING race_entries `, [raceEntriesToAdd, userId]); await client.query(` INSERT INTO race_entry_transactions (user_id, delta, balance_after, reason, payment_id, metadata) VALUES ($1, $2, $3, 'purchase', $4, $5::jsonb) `, [userId, raceEntriesToAdd, raceEntryUpdate.rows[0].race_entries, paymentId, JSON.stringify({ productId })]); } // Step 5: Grant cosmetic/render packs. Idempotent so retrying a confirmed // product cannot duplicate entitlements. for (const pack of productGrants.packs) { await client.query(` INSERT INTO user_pack_entitlements (user_id, pack_id, source, expires_at, metadata) VALUES ($1, $2, $3, $4, $5::jsonb) ON CONFLICT (user_id, pack_id) DO UPDATE SET source = EXCLUDED.source, expires_at = EXCLUDED.expires_at, metadata = user_pack_entitlements.metadata || EXCLUDED.metadata `, [ userId, pack.id, pack.source || 'product_purchase', pack.expiresAt || null, JSON.stringify({ productId, paymentId }) ]); } const grantRows = await client.query(` SELECT pack_id FROM user_pack_entitlements WHERE user_id = $1 AND (expires_at IS NULL OR expires_at > NOW()) `, [userId]); return { newBalance, totalCreditsPurchased, premiumLevel, packGrants: grantRows.rows || [] }; }); if (result.alreadyProcessed) { console.log(`Payment ${paymentId} already confirmed (atomic check), skipping duplicate`); return { success: false, alreadyProcessed: true, reason: 'Payment already processed' }; } const entitlements = Entitlements.snapshotForUser({ id: userId, credits: result.newBalance, total_credits_purchased: result.totalCreditsPurchased, premium_level: result.premiumLevel }, result.packGrants); console.log(`Product payment confirmed: ${productId} (+${creditsToAdd} credits) for user ${userId}, new balance: ${result.newBalance}`); return { success: true, creditsAdded: creditsToAdd, newBalance: result.newBalance, totalCreditsPurchased: result.totalCreditsPurchased, grantsApplied: grantJson, entitlements }; } catch (error) { const normalized = normalizeError(error, 'Failed to process product payment confirmation'); console.error('Error processing product payment:', normalized.message); return { success: false, reason: normalized.safeMessage }; } } async processCreditsPackageConfirmation(socketId, paymentId, packageInfo = null, receivedAmount = null) { return this.processProductPaymentConfirmation(socketId, paymentId, packageInfo, receivedAmount); } /** * Record a direct/single_game entry as "buy 1 credit and immediately spend it on this game": * the balance nets to zero but total_credits_purchased advances, so a direct payment unlocks the * SAME tier/threshold cosmetics as buying credits (the first step of unifying everything to * credits). Idempotent by the caller — the single_game confirmation runs once per payment via * its status='pending' -> 'confirmed' guard. Returns { totalCreditsPurchased, balance, * entitlements } or null. */ async recordDirectEntryPurchase(socketId) { try { const user = await this.getOrCreateUser(socketId, { create: false }); if (!user || user.id == null) return null; let totalCreditsPurchased = 0; let balance = 0; let premiumLevel = null; await this.db.withTransaction(async (client) => { const upd = await client.query( `UPDATE users SET total_credits_purchased = COALESCE(total_credits_purchased, 0) + 1 WHERE id = $1 RETURNING total_credits_purchased, credits, premium_level`, [user.id] ); if (!upd.rows.length) return; totalCreditsPurchased = Number(upd.rows[0].total_credits_purchased || 0); balance = Number(upd.rows[0].credits || 0); premiumLevel = upd.rows[0].premium_level || null; // Audit trail: bought 1 credit + spent it on this game (net 0 balance). await client.query( `INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type) VALUES ($1, 1, 'direct_entry', $2, 'purchase'), ($1, -1, 'game_entry', $2, 'spend')`, [user.id, balance] ); }); let packGrants = []; try { const g = await this.db.query( `SELECT pack_id, source, granted_at, expires_at FROM user_pack_entitlements WHERE user_id = $1 AND (expires_at IS NULL OR expires_at > NOW())`, [user.id] ); packGrants = g.rows || []; } catch (_) { packGrants = []; } const entitlements = Entitlements.snapshotForUser({ id: user.id, credits: balance, total_credits_purchased: totalCreditsPurchased, premium_level: premiumLevel }, packGrants); return { totalCreditsPurchased, balance, entitlements }; } catch (err) { const normalized = normalizeError(err, 'Failed to record direct entry purchase'); console.error('❌ recordDirectEntryPurchase error:', normalized.message); return null; } } async _findReusablePayment(userId, paymentType, productId = null) { if (!userId) return null; const result = await this.db.query(` SELECT id, subaddress, expected_amount, payment_type, status, created_at, expires_at, description, product_id, product_grants FROM payments WHERE user_id = $1 AND payment_type = $2 AND ($3::text IS NULL OR product_id = $3) AND status = 'pending' AND (expires_at IS NULL OR expires_at > NOW()) ORDER BY created_at DESC LIMIT 1 `, [userId, paymentType, productId || null]); return result.rows[0] || null; } /** * Convert package info to JSON-safe format (BigInt price -> Number) */ _serializePackageInfo(packageInfo) { if (!packageInfo) return null; return { ...packageInfo, price: typeof packageInfo.price === 'bigint' ? Number(packageInfo.price) : packageInfo.price, grants: ProductGrants.publicGrantSummary(ProductGrants.normalizeProductGrants(packageInfo, { credits: Number(packageInfo.credits || 0) + Number(packageInfo.bonus || 0) })) }; } _mapPaymentRowToRequest(row, paymentType, packageInfo) { if (!row) return null; const amount = Number(row.expected_amount); return { id: row.id, address: row.subaddress, amount, amountFormatted: this.formatAtomicHuman(amount, 4), currency: this.cryptoType, expiresAt: row.expires_at, paymentType, description: row.description, productId: row.product_id || null, grants: row.product_grants || null, package: this._serializePackageInfo(packageInfo), reused: true }; } logConfiguration(context = 'initialized') { console.log(`🎮 Game Mode Manager ${context}: ${this.gameMode} mode`); console.log(`💰 Currency: ${this.cryptoType} (decimals: ${this.currencyDecimals})`); console.log(`💵 Single game price: ${this.singleGamePrice} atomic units (~${this.formatAtomic(this.singleGamePrice)} ${this.cryptoType})`); console.log(`💵 Credits payout base: ${this.creditsPayoutBaseValue} atomic units (~${this.formatAtomic(this.creditsPayoutBaseValue)} ${this.cryptoType})`); console.log(`🎫 Credits package price: ${this.creditsPackagePrice} atomic units (~${this.formatAtomic(this.creditsPackagePrice)} ${this.cryptoType})`); console.log(`🎯 Credits per game cost: ${this.creditsPerGameCost}`); console.log(`🧮 Payout multipliers - direct: ${JSON.stringify(this.directPayoutMultipliers)}, credits: ${JSON.stringify(this.creditPayoutMultipliers)}`); console.log(`🔁 Mode availability - direct: ${this.directModeEnabled}, credits: ${this.creditsModeEnabled}, preferCreditsFirst: ${this.preferCreditsFirst}`); if (this.creditsPayoutEnabled) { console.log(`🎁 Credits payout mode ENABLED - base value: ${this.creditsPayoutBaseValue} atomic (~${this.formatAtomic(this.creditsPayoutBaseValue)} ${this.cryptoType})`); } console.log(`⚙️ Payments enabled: ${this.paymentsEnabled}`); } /** * Get effective game mode for a specific user, considering: * - Whether both modes are enabled (mixed mode) * - User's credit balance * - preferCreditsFirst setting * @param {object} user - User object with credits field * @returns {object} { mode, canUseCredits, canUseDirect, hasCredits, creditsBalance } */ getEffectiveModeForUser(user) { const hasCredits = (user?.credits || 0) >= this.creditsPerGameCost; const bothModesEnabled = this.directModeEnabled && this.creditsModeEnabled; // Determine available options const canUseCredits = this.creditsModeEnabled && hasCredits; const canUseDirect = this.directModeEnabled; // Determine effective mode let effectiveMode; if (!this.paymentsEnabled) { effectiveMode = 'FREE'; } else if (bothModesEnabled) { // Mixed mode: prefer based on config and availability if (this.preferCreditsFirst && hasCredits) { effectiveMode = 'PAID_CREDITS'; } else if (hasCredits) { effectiveMode = 'PAID_CREDITS'; // Has credits, can use them } else { effectiveMode = 'PAID_SINGLE'; // No credits, must pay } } else if (this.creditsModeEnabled) { effectiveMode = 'PAID_CREDITS'; } else if (this.directModeEnabled) { effectiveMode = 'PAID_SINGLE'; } else { effectiveMode = 'FREE'; } return { mode: effectiveMode, canUseCredits, canUseDirect, hasCredits, creditsBalance: user?.credits || 0, creditsRequired: this.creditsPerGameCost, bothModesEnabled, preferCreditsFirst: this.preferCreditsFirst, creditsPayoutsEnabled: this.creditsPayoutEnabled, directPayoutsEnabled: this.directPayoutEnabled && this.directPayoutMultipliers.escape > 0 }; } /** * Get available payment options for a user * @param {string} socketId - Socket ID * @returns {object} Available options for the user */ async getPaymentOptionsForUser(socketId) { try { const user = await this.getOrCreateUser(socketId); const effective = this.getEffectiveModeForUser(user); const options = []; // Free play (Pleb board, no payout) — offered alongside paid options when enabled. if (this.freePlayEnabled && this.paymentsEnabled) { options.push({ type: 'play_free', label: 'Play Free (Pleb leaderboard)', mode: 'FREE', cost: 0, costDisplay: 'Free', payoutEligible: false, recommended: false }); } if (effective.canUseCredits) { options.push({ type: 'use_credit', label: `Use 1 Credit (${effective.creditsBalance} available)`, mode: 'PAID_CREDITS', cost: 0, costDisplay: '1 credit', payoutEligible: effective.creditsPayoutsEnabled, recommended: effective.preferCreditsFirst }); } if (effective.canUseDirect) { options.push({ type: 'pay_direct', label: `Pay ${this.formatAtomicHuman(this.singleGamePrice, 2)} ${this.cryptoType}`, mode: 'PAID_SINGLE', cost: this.singleGamePrice, costDisplay: `${this.formatAtomicHuman(this.singleGamePrice, 2)} ${this.cryptoType}`, payoutEligible: effective.directPayoutsEnabled, recommended: !effective.preferCreditsFirst || !effective.hasCredits }); } if (this.creditsModeEnabled) { const pkg = this.getPrimaryCreditPackage(); options.push({ type: 'buy_credits', label: `Buy ${pkg.credits} Credits`, mode: 'PURCHASE', cost: Number(pkg.price), costDisplay: `${this.formatAtomicHuman(pkg.price, 2)} ${this.cryptoType}`, credits: pkg.credits + (pkg.bonus || 0), payoutEligible: false, recommended: false }); } return { user: { credits: effective.creditsBalance, hasPayoutAddress: !!user.payout_address }, effective, options }; } catch (error) { const normalized = normalizeError(error, 'Failed to get payment options'); console.error('❌ Error getting payment options:', normalized.message); return { user: { credits: 0, hasPayoutAddress: false }, effective: { mode: 'FREE' }, options: [] }; } } /** * Check if user can start a game */ async canUserStartGame(socketId) { try { const user = await this.getOrCreateUser(socketId); const effective = this.getEffectiveModeForUser(user); // FREE mode or payments disabled if (effective.mode === 'FREE') { return { allowed: true, reason: 'Free mode' }; } // Mixed mode: check if user can play with credits OR has confirmed payment if (effective.bothModesEnabled) { // Option 1: User has credits if (effective.hasCredits) { return { allowed: true, reason: `${effective.creditsBalance} credits available`, useCredits: true, creditsRequired: this.creditsPerGameCost, balance: effective.creditsBalance, effectiveMode: 'PAID_CREDITS' }; } // Option 2: User has confirmed single_game payment const pendingPayment = await this.db.query(` SELECT * FROM payments WHERE socket_id = $1 AND status = 'confirmed' AND payment_type = 'single_game' AND NOT EXISTS ( SELECT 1 FROM games WHERE games.payment_id = payments.id ) ORDER BY confirmed_at DESC LIMIT 1 `, [socketId]); if (pendingPayment.rows.length > 0) { return { allowed: true, reason: 'Payment confirmed', paymentId: pendingPayment.rows[0].id, effectiveMode: 'PAID_SINGLE' }; } // Neither credits nor payment available return { allowed: false, reason: 'Payment or credits required', action: 'choose_payment', options: await this.getPaymentOptionsForUser(socketId) }; } // Single mode logic (backwards compatible) switch (effective.mode) { case 'PAID_CREDITS': if (effective.hasCredits) { return { allowed: true, reason: `${effective.creditsBalance} credits remaining`, creditsRequired: this.creditsPerGameCost, effectiveMode: 'PAID_CREDITS' }; } return { allowed: false, reason: 'Insufficient credits', action: 'purchase_credits', creditsRequired: this.creditsPerGameCost, balance: effective.creditsBalance }; case 'PAID_SINGLE': const payment = await this.db.query(` SELECT * FROM payments WHERE socket_id = $1 AND status = 'confirmed' AND payment_type = 'single_game' AND NOT EXISTS ( SELECT 1 FROM games WHERE games.payment_id = payments.id ) ORDER BY confirmed_at DESC LIMIT 1 `, [socketId]); if (payment.rows.length > 0) { return { allowed: true, reason: 'Payment confirmed', paymentId: payment.rows[0].id, effectiveMode: 'PAID_SINGLE' }; } return { allowed: false, reason: 'Payment required', action: 'make_payment' }; default: return { allowed: false, reason: 'Invalid game mode' }; } } catch (error) { const normalized = normalizeError(error, 'Unable to verify game eligibility'); console.error('❌ Error checking user game eligibility:', normalized.message); return { allowed: false, reason: normalized.safeMessage }; } } /** * Process game start (deduct credits or link payment). * * C7: `options` is the optional trailing opts bag and may carry a `clientSeed`. The * dungeon's effective RNG seed is derived at game CREATION via * provablyFair.deriveSeed(serverSeed, clientSeed); this start entry point accepts and * forwards the (already-validated) client seed so the flow stays in sync and the value * is surfaced on the start result. Non-breaking: clientSeed is optional (defaults to ''). */ async processGameStart(socketId, gameId, options = {}) { const clientSeed = typeof options.clientSeed === 'string' ? options.clientSeed : ''; const result = await this._dispatchGameStart(socketId, gameId, options); if (result && typeof result === 'object' && clientSeed && result.clientSeed === undefined) { result.clientSeed = clientSeed; } return result; } async _dispatchGameStart(socketId, gameId, options = {}) { try { const user = await this.getOrCreateUser(socketId); // The player explicitly chose FREE play (even on an instance that also sells // credits/entry). Record this game as FREE so it lands on the Pleb leaderboard, // with no payment and no payout. if (options.forceFree) { return await this._processGameStartFree(user, gameId); } const effective = this.getEffectiveModeForUser(user); // FREE mode (instance is free-only) if (effective.mode === 'FREE') { return { success: true, effectiveMode: 'FREE' }; } // Mixed mode: determine which method to use if (effective.bothModesEnabled) { // Prefer credits if available and preferCreditsFirst is true if (effective.hasCredits && this.preferCreditsFirst) { return await this._processGameStartWithCredits(user, socketId, gameId); } // Check for confirmed direct payment const payment = await this.db.query(` SELECT * FROM payments WHERE socket_id = $1 AND status = 'confirmed' AND payment_type = 'single_game' AND NOT EXISTS ( SELECT 1 FROM games WHERE games.payment_id = payments.id ) ORDER BY confirmed_at DESC LIMIT 1 `, [socketId]); if (payment.rows.length > 0) { return await this._processGameStartWithPayment(user, payment.rows[0], gameId); } // Fall back to credits if available if (effective.hasCredits) { return await this._processGameStartWithCredits(user, socketId, gameId); } return { success: false, reason: 'No valid payment or credits found' }; } // Single mode logic switch (effective.mode) { case 'PAID_CREDITS': return await this._processGameStartWithCredits(user, socketId, gameId); case 'PAID_SINGLE': const payment = await this.db.query(` SELECT * FROM payments WHERE socket_id = $1 AND status = 'confirmed' AND payment_type = 'single_game' AND NOT EXISTS ( SELECT 1 FROM games WHERE games.payment_id = payments.id ) ORDER BY confirmed_at DESC LIMIT 1 `, [socketId]); if (payment.rows.length === 0) { return { success: false, reason: 'No valid payment found' }; } return await this._processGameStartWithPayment(user, payment.rows[0], gameId); default: return { success: false, reason: 'Invalid game mode' }; } } catch (error) { const normalized = normalizeError(error, 'Failed to process game start'); console.error('❌ Error processing game start:', normalized.message); return { success: false, reason: normalized.safeMessage }; } } /** * Compute the payout terms to snapshot on a game at start, so an admin changing * config mid-game can't alter an in-flight game's payout. Returns atomic amounts as * strings (for the BIGINT columns) plus the multipliers for the audit trail. */ _computePayoutSnapshot(mode) { const escape = this.calculatePayout(mode, { treasureFound: false }); const treasure = this.calculatePayout(mode, { treasureFound: true }); return { escapeAmount: String(escape.amount), treasureAmount: String(treasure.amount), escapeMult: escape.multiplier, treasureMult: treasure.multiplier }; } /** * Start a game the player explicitly chose to play for FREE. Records game_mode='FREE' * on the game (overriding the instance default, so it goes to the Pleb leaderboard) and * counts the game. No payment, no payout. */ async _processGameStartFree(user, gameId) { await this.db.withTransaction(async (client) => { await client.query(`UPDATE games SET game_mode = 'FREE' WHERE dungeon_seed = $1`, [gameId]); await client.query(` UPDATE users SET total_games_played = total_games_played + 1, updated_at = NOW() WHERE id = $1 `, [user.id]); }); return { success: true, effectiveMode: 'FREE' }; } async _processGameStartWithCredits(user, socketId, gameId) { const creditsToSpend = this.creditsPerGameCost; const snap = this._computePayoutSnapshot('PAID_CREDITS'); const result = await this.db.withTransaction(async (client) => { // Conditional update prevents credits going negative const updateRes = await client.query(` UPDATE users SET credits = credits - $1, total_games_played = total_games_played + 1, updated_at = NOW() WHERE id = $2 AND credits >= $1 RETURNING credits, total_credits_purchased `, [creditsToSpend, user.id]); if (updateRes.rows.length === 0) { return { insufficient: true }; } const remainingCredits = updateRes.rows[0].credits; const totalCreditsPurchased = updateRes.rows[0].total_credits_purchased || user.total_credits_purchased || 0; await client.query(` UPDATE games SET game_mode = 'PAID_CREDITS', payout_address = $2, payout_escape_amount = $3, payout_treasure_amount = $4, payout_escape_mult = $5, payout_treasure_mult = $6 WHERE dungeon_seed = $1 `, [gameId, user.payout_address || null, snap.escapeAmount, snap.treasureAmount, snap.escapeMult, snap.treasureMult]); await client.query(` INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type) VALUES ($1, $2, 'game_entry', $3, 'spend') `, [user.id, -creditsToSpend, remainingCredits]); return { remainingCredits, totalCreditsPurchased }; }); if (result.insufficient) { return { success: false, reason: 'Insufficient credits', creditsRequired: creditsToSpend, creditsAvailable: user.credits }; } console.log(`Deducted ${creditsToSpend} credit(s) from user ${user.id}, ${result.remainingCredits} remaining`); return { success: true, creditsRemaining: result.remainingCredits, totalCreditsPurchased: result.totalCreditsPurchased, creditsSpent: creditsToSpend, effectiveMode: 'PAID_CREDITS' }; } async _processGameStartWithPayment(user, payment, gameId) { const snap = this._computePayoutSnapshot('PAID_SINGLE'); // M3: claiming a confirmed single_game payment must be atomic. Re-verify the payment // is still unclaimed and lock it FOR UPDATE inside the transaction, so two concurrent // starts can't both consume the same payment. The unique index on games.payment_id // (migration 023) is the final backstop; a 23505 there is treated as "already consumed". try { const result = await this.db.withTransaction(async (client) => { const claim = await client.query(` SELECT id FROM payments WHERE id = $1 AND status = 'confirmed' AND payment_type = 'single_game' AND NOT EXISTS (SELECT 1 FROM games WHERE games.payment_id = payments.id) FOR UPDATE `, [payment.id]); if (claim.rows.length === 0) { // Locked+re-checked: a concurrent start already linked this payment (or it is // no longer claimable). Abort WITHOUT linking or creating a duplicate game. return { alreadyConsumed: true }; } await client.query(` UPDATE games SET payment_id = $1, game_mode = 'PAID_SINGLE', payout_address = $3, payout_escape_amount = $4, payout_treasure_amount = $5, payout_escape_mult = $6, payout_treasure_mult = $7 WHERE dungeon_seed = $2 `, [payment.id, gameId, user.payout_address || null, snap.escapeAmount, snap.treasureAmount, snap.escapeMult, snap.treasureMult]); await client.query(` UPDATE users SET total_games_played = total_games_played + 1, updated_at = NOW() WHERE id = $1 `, [user.id]); return { linked: true }; }); if (result.alreadyConsumed) { console.warn(`⚠️ Payment ${payment.id} already consumed by a concurrent start; aborting duplicate for game ${gameId}.`); return { success: false, reason: 'Payment already consumed', alreadyConsumed: true }; } console.log(`Linked game ${gameId} to payment ${payment.id}`); return { success: true, paymentId: payment.id, effectiveMode: 'PAID_SINGLE' }; } catch (error) { // 23505 = unique_violation on games.payment_id: a concurrent start linked this // payment first. Treat as already-consumed, never an uncaught throw / duplicate. if (error && error.code === '23505') { console.warn(`⚠️ payment_id unique violation linking game ${gameId} to payment ${payment.id}; treating as already consumed.`); return { success: false, reason: 'Payment already consumed', alreadyConsumed: true }; } throw error; } } // processGameCompletion() removed — dead code, never called. // Only completeGame() (below) is used for game completion/payouts. /** * Create payment request */ async createPaymentRequest(socketId, paymentType, options = {}) { try { // Prefer session-resolved userId (stable across reconnects) over socket_id lookup let user; if (options.userId) { const result = await this.db.query('SELECT * FROM users WHERE id = $1', [options.userId]); user = result.rows[0] || await this.getOrCreateUser(socketId); } else { user = await this.getOrCreateUser(socketId); } const reuseExisting = options.reuseExisting !== false; const requestedPackageId = options.packageId; let amount; let description; let packageInfo = null; let productId = null; let productGrants = ProductGrants.normalizeProductGrants({}, { credits: 0 }); switch (paymentType) { case 'single_game': { amount = this.singleGamePrice; description = `${this.gameName} single game entry (${this.currencyLabel})`; productId = 'single_game'; break; } case 'credits_package': { // Find the requested package, or fall back to primary const packages = this.configSnapshot?.modes?.credits?.packages || []; let selectedPackage = null; if (requestedPackageId && packages.length > 0) { selectedPackage = packages.find(p => p.id === requestedPackageId); } // Fall back to first package if not found if (!selectedPackage) { selectedPackage = this.getPrimaryCreditPackage(); } const packagePrice = selectedPackage?.price ?? this.creditsPackagePrice; amount = typeof packagePrice === 'bigint' ? Number(packagePrice) : Number(packagePrice); packageInfo = selectedPackage; productId = selectedPackage?.id || 'credits_package'; const creditCount = selectedPackage?.credits ?? 10; const bonusCredits = selectedPackage?.bonus ?? 0; productGrants = ProductGrants.normalizeProductGrants(selectedPackage, { credits: Number(creditCount) + Number(bonusCredits || 0) }); const bonusText = bonusCredits > 0 ? ` (+${bonusCredits} bonus)` : ''; description = `${this.gameName} ${creditCount}${bonusText} credits package (${this.currencyLabel})`; break; } case 'cosmetic_pack': { const product = this.getCosmeticProduct(options.productId || options.packageId); if (!product) { throw new ValidationError('Unknown cosmetic product requested', { safeMessage: 'Unsupported product requested.' }); } const price = product.price ?? 0; amount = typeof price === 'bigint' ? Number(price) : Number(price); packageInfo = product; productId = product.id; productGrants = ProductGrants.normalizeProductGrants(product, { credits: 0 }); description = `${this.gameName} ${product.label || product.id} (${this.currencyLabel})`; break; } default: throw new ValidationError(`Invalid payment type requested: ${paymentType}`, { safeMessage: 'Unsupported payment type requested.' }); } if (reuseExisting) { const existingRow = await this._findReusablePayment(user.id, paymentType, productId); if (existingRow) { const existing = this._mapPaymentRowToRequest(existingRow, paymentType, packageInfo); if (existing && !existing.description) { existing.description = description; } return existing; } } // Expire any stale pending payments for this user before creating a new one. // A user should only have one active pending payment at a time. const expiredResult = await this.db.query(` UPDATE payments SET status = 'expired' WHERE user_id = $1 AND status = 'pending' RETURNING subaddress `, [user.id]); const expiredAddresses = expiredResult.rows.map(r => r.subaddress); // Create the payment request through the routed provider. The native provider (default // for every chain when no gateway env is set) delegates to walletService.createPaymentRequest, // so this is byte-for-byte the legacy path for the shipped single-chain config — same // subaddress, same walletService monitoring maps. A BTCPay/xmrcheckout/wowcheckout gateway // takes over only when the operator routes this.cryptoType to it. const provider = this.paymentProviders && this.paymentProviders.getProvider(this.cryptoType); let paymentResult; if (provider) { const invoice = await provider.createInvoice({ chain: this.cryptoType, amountAtomic: amount, description, userId: user.id, orderId: socketId }); paymentResult = { address: invoice.address, addressIndex: invoice.addressIndex ?? (invoice.raw && invoice.raw.addressIndex) ?? null, expiresAt: invoice.expiresAt, invoiceId: invoice.invoiceId || invoice.address }; } else { // Defensive fallback: registry somehow empty -> exact legacy call. paymentResult = await this.walletService.createPaymentRequest(amount, description, user.id, socketId); } const expiresAt = paymentResult.expiresAt || new Date(Date.now() + 30 * 60 * 1000); // Store payment info in database (address_index enables monitoring restoration after restart) const insertResult = await this.db.query(` INSERT INTO payments (user_id, socket_id, subaddress, expected_amount, payment_type, status, description, created_at, expires_at, address_index, product_id, product_grants) VALUES ($1, $2, $3, $4, $5, 'pending', $6, NOW(), $7, $8, $9, $10::jsonb) RETURNING id, expires_at `, [ user.id, socketId, paymentResult.address, amount, paymentType, description, expiresAt, paymentResult.addressIndex ?? null, productId, JSON.stringify(ProductGrants.serializeProductGrants(productGrants)) ]); const insertedRow = insertResult.rows[0]; return { id: insertedRow?.id, address: paymentResult.address, invoiceId: paymentResult.invoiceId || paymentResult.address, amount: amount, amountFormatted: this.formatAtomicHuman(amount, 4), currency: this.cryptoType, expiresAt: insertedRow?.expires_at || expiresAt, package: this._serializePackageInfo(packageInfo), paymentType, productId, grants: ProductGrants.publicGrantSummary(productGrants), description, reused: false, expiredAddresses }; } catch (error) { const normalized = normalizeError(error, 'Failed to create payment request'); console.error('❌ Error creating payment request:', normalized.message); throw normalized; } } /** * Get or create user record */ /** * Resolve the database user for a socket. * * IDENTITY (Phase 2.1): The stable identity is `users.id`, established from the * client's `anon_token` by SessionManager at connection time. This method resolves * through that session identity FIRST, so every money/credit/payout path operates on * the same stable row. It only falls back to the legacy `socket_id` lookup-or-create * when no session exists (e.g. SessionManager not wired, or a REST call for a socket * with no live session) — `socket_id` is mutable and non-unique and must never be the * primary money key. */ async getOrCreateUser(socketId, { create = true } = {}) { try { // 1. Prefer the stable identity established by SessionManager (anon_token -> id). if (this.sessionManager && typeof this.sessionManager.getBySocket === 'function') { try { const sessionUser = await this.sessionManager.getBySocket(socketId); if (sessionUser && sessionUser.id != null) { // Return a FRESH row by stable id (the cached session row may be stale). const fresh = await this.db.query(`SELECT * FROM users WHERE id = $1`, [sessionUser.id]); if (fresh.rows.length > 0) { await this.db.query(`UPDATE users SET last_active = NOW() WHERE id = $1`, [fresh.rows[0].id]); return fresh.rows[0]; } } } catch (sessErr) { // Fall through to legacy resolution on any session-lookup error. } } // 2. Legacy fallback: look up by socket_id. let userResult = await this.db.query(` SELECT * FROM users WHERE socket_id = $1 `, [socketId]); if (userResult.rows.length > 0) { await this.db.query(` UPDATE users SET last_active = NOW() WHERE id = $1 `, [userResult.rows[0].id]); return userResult.rows[0]; } // Read-only callers (REST reads) pass { create: false }: never mint an orphan // row for a socket that has no user yet — return null and let them 404/handle it. if (!create) { return null; } // 3. Last resort: create a new user. This only fires when no session was // established for the socket (abnormal in normal flow, since every connection // creates a session) — log it so orphan-row creation is visible. userResult = await this.db.query(` INSERT INTO users (socket_id, ip_address) VALUES ($1, $2) RETURNING * `, [socketId, null]); console.warn(`👤 Created new user without a session (socket ${socketId}) — no anon_token identity resolved.`); return userResult.rows[0]; } catch (error) { const normalized = normalizeError(error, 'Failed to load user'); console.error('❌ Error getting/creating user:', normalized.message); throw normalized; } } /** * Set user payout address */ async setUserPayoutAddress(socketId, payoutAddress) { try { const user = await this.getOrCreateUser(socketId); await this.db.query(` UPDATE users SET payout_address = $1, updated_at = NOW() WHERE id = $2 `, [payoutAddress, user.id]); console.log(`💰 Set payout address for user ${user.id}: ${payoutAddress}`); // Reconcile any claimable match winnings this user earned with no address on file. // A match winner without a payout address is recorded as a durable 'needs_review' // liability (reason 'match_winner_no_address', sentinel address 'PENDING_NO_ADDRESS'); // now that a real address exists, convert those to sendable 'pending' payouts and kick // the batcher. The reason filter is deliberately narrow so this NEVER touches the // ambiguous-broadcast 'needs_review' rows from the single-payout path (which have a real // address and a different reason) — converting those could double-pay. if (typeof payoutAddress === 'string' && payoutAddress.trim().length > 0) { try { const reconciled = await this.db.query(` UPDATE payouts SET payout_address = $1, status = 'pending', last_error = NULL WHERE user_id = $2 AND status = 'needs_review' AND reason = 'match_winner_no_address' AND payout_address = 'PENDING_NO_ADDRESS' RETURNING id `, [payoutAddress.trim(), user.id]); if (reconciled.rowCount > 0) { console.log(`💰 Reconciled ${reconciled.rowCount} claimable match payout(s) for user ${user.id} now that an address is set`); if (typeof this._scheduleBatchPayout === 'function') this._scheduleBatchPayout(); } } catch (reconErr) { // Never let reconciliation failure break address-setting. const n = normalizeError(reconErr, 'Failed to reconcile claimable match payouts'); console.error('⚠️ Payout reconciliation error (address still saved):', n.message); } } return true; } catch (error) { const normalized = normalizeError(error, 'Failed to update payout address'); console.error('❌ Error setting payout address:', normalized.message); return false; } } /** * Get user statistics */ async getUserStats(socketId) { try { const result = await this.db.query(` SELECT * FROM get_user_stats($1) `, [socketId]); if (result.rows.length > 0) { return result.rows[0]; } return { total_games: 0, games_won: 0, win_rate: 0, total_paid: 0, total_won: 0, net_profit: 0, credits_remaining: 0 }; } catch (error) { const normalized = normalizeError(error, 'Failed to load user stats'); console.error('❌ Error getting user stats:', normalized.message); return null; } } /** * Get game mode info for frontend */ getGameModeInfo() { // Debug: Log credits payout base value being sent console.log(`📤 getGameModeInfo: creditsPayoutBaseValue = ${this.creditsPayoutBaseValue} (${this.formatAtomic(this.creditsPayoutBaseValue)} ${this.cryptoType})`); // Determine if we should show a testnet warning // Only for XMR on stagenet/testnet - WOW only has mainnet const showTestnetWarning = this.cryptoType === 'XMR' && this.isTestNetwork; const testnetWarning = showTestnetWarning ? `⚠️ STAGENET MODE: This server is using ${this.network} XMR. Do NOT send real mainnet XMR! Only ${this.network} XMR will be accepted.` : null; // Serialize all credit packages (convert BigInt prices to Number) const packages = this.configSnapshot?.modes?.credits?.packages || []; const creditPackages = packages.map(pkg => ({ id: pkg.id, label: pkg.label || pkg.id, credits: pkg.credits, price: typeof pkg.price === 'bigint' ? Number(pkg.price) : pkg.price, bonus: pkg.bonus || 0, grants: ProductGrants.publicGrantSummary(ProductGrants.normalizeProductGrants(pkg, { credits: Number(pkg.credits || 0) + Number(pkg.bonus || 0) })), priceFormatted: this.formatAtomicHuman( typeof pkg.price === 'bigint' ? Number(pkg.price) : pkg.price, 2 ) })); const cosmeticProducts = this.getCosmeticProducts().map(product => ({ id: product.id, label: product.label || product.id, price: typeof product.price === 'bigint' ? Number(product.price) : product.price, grants: ProductGrants.publicGrantSummary(ProductGrants.normalizeProductGrants(product, { credits: 0 })), priceFormatted: this.formatAtomicHuman( typeof product.price === 'bigint' ? Number(product.price) : product.price, 2 ) })); return { mode: this.gameMode, freePlayEnabled: !!this.freePlayEnabled, // players may choose free play even on a paid instance cryptoType: this.cryptoType, currencyLabel: this.currencyLabel, // sXMR on stagenet, XMR on mainnet, WOW for Wownero gameName: this.gameName, // Monerogue / Wownerogue network: this.network, isTestNetwork: this.isTestNetwork, testnetWarning: testnetWarning, singleGamePrice: this.singleGamePrice, singleGamePriceFormatted: this.formatAtomicHuman(this.singleGamePrice, 2), creditsPackagePrice: this.creditsPackagePrice, creditsPerGame: this.creditsPerGameCost, creditPackages: creditPackages, cosmeticProducts: cosmeticProducts, creditsPayoutBaseValue: this.creditsPayoutBaseValue, paymentsEnabled: this.paymentsEnabled, freePlayEnabled: this.freePlayEnabled, directModeEnabled: this.directModeEnabled, creditsModeEnabled: this.creditsModeEnabled, directPayoutsEnabled: this.directPayoutEnabled && this.directPayoutMultipliers.escape > 0, creditsPayoutsEnabled: this.creditsPayoutEnabled, payoutMultipliers: { direct: this.directPayoutMultipliers, credits: this.creditPayoutMultipliers }, features: { paymentRequired: this.paymentsEnabled, creditsSystem: this.creditsModeEnabled, payouts: this.directPayoutMultipliers.escape > 0 || this.creditPayoutMultipliers.escape > 0 }, earlyEntry: { enabled: this.configSnapshot?.earlyEntry?.enabled ?? false, allowInFreeMode: this.configSnapshot?.earlyEntry?.allowInFreeMode ?? false, allowInCreditsMode: this.configSnapshot?.earlyEntry?.allowInCreditsMode ?? false }, // Smirk wallet integration. Smirk only works on mainnet, so it is forced off on // any test network (stagenet/testnet) regardless of SMIRK_ENABLED. smirkEnabled: process.env.SMIRK_ENABLED !== 'false' && !this.isTestNetwork, explorerTxUrl: process.env.EXPLORER_TX_URL || null, // Which top-level modes this instance offers. Solo (the single-player dungeon) is // on unless explicitly disabled; Tavern and Multiplayer are opt-in. The client uses // this to show/hide entry points, so any single mode can run on its own. modes: { solo: process.env.SOLO_ENABLED !== 'false', tavern: process.env.TAVERN_ENABLED === 'true', multiplayer: process.env.MULTIPLAYER_ENABLED === 'true', match: { enabled: process.env.MATCH_ENABLED === 'true', economies: this._getMatchEconomies(), maxPlayers: parseInt(process.env.MATCH_MAX_PLAYERS, 10) || 4 } } }; } /** Fire-and-forget operator alert (no-op when no alertService is wired). */ _alert(type, subject, html) { if (this.alertService && typeof this.alertService.sendAlert === 'function') { this.alertService.sendAlert(type, { subject, html }).catch(() => {}); } } /** * Resolve the configured payout min/max (atomic BigInt) for a recorded game mode from * the runtime config snapshot, or null when unavailable (older config / no snapshot). */ _payoutBoundsForMode(recordedPaymentMode) { const rules = this.configSnapshot?.payouts?.rules; if (!rules) return null; const rule = recordedPaymentMode === 'PAID_CREDITS' ? rules.credits : rules.direct; if (!rule) return null; const toBigOrNull = (v) => { if (v === undefined || v === null) return null; try { return money.toBig(v); } catch { return null; } }; return { min: toBigOrNull(rule.minPayout), max: toBigOrNull(rule.maxPayout) }; } /** * Complete a game (called from socket handlers when game ends) * @param {string} socketId - Player's socket ID * @param {string} gameId - Game UUID * @param {boolean} won - Whether the player won (escaped) * @param {boolean} treasureFound - Whether treasure was found * @returns {object} payout / completion info */ async completeGame(socketId, gameId, won, treasureFound, metrics = {}) { // For FREE mode we just return basic info if (this.gameMode === 'FREE') { return { success: true, mode: 'FREE', payout: null, score: metrics.score ?? null }; } try { // Retrieve the game record to get the actual payment_mode used at game start. // M4/C4: match by dungeon_seed ALONE (the UUID is unique) — never by the mutable // socket_id — and carry the stable users.id (games.user_id) so the paying user is // resolved by identity, not by the socket that happened to finish the game. let recordedPaymentMode = this.gameMode; // fallback to global let gameDbId = null; let gameUserId = null; // stable users.id carried on the game row let lockedPayoutAddress = null; // address locked at game start let payoutSnapshot = null; // payout terms snapshotted at game start try { const gameRecord = await this.db.query(` SELECT id, user_id, game_mode, payout_address, payout_escape_amount, payout_treasure_amount, payout_escape_mult, payout_treasure_mult FROM games WHERE dungeon_seed = $1 LIMIT 1 `, [gameId]); if (gameRecord.rows.length > 0) { gameDbId = gameRecord.rows[0].id; gameUserId = gameRecord.rows[0].user_id ?? null; recordedPaymentMode = (gameRecord.rows[0].game_mode || this.gameMode).toUpperCase(); lockedPayoutAddress = gameRecord.rows[0].payout_address || null; const r = gameRecord.rows[0]; if (r.payout_escape_amount != null || r.payout_treasure_amount != null) { payoutSnapshot = { escapeAmount: r.payout_escape_amount, treasureAmount: r.payout_treasure_amount, escapeMult: r.payout_escape_mult, treasureMult: r.payout_treasure_mult }; } } } catch (e) { // Non-fatal, use fallback if (process.env.NODE_ENV === 'development') { console.warn('Game record lookup warning:', e.message); } } // Update/record game completion in DB try { await this.db.query(` UPDATE games SET status = $1, treasure_found = $2, moves_made = COALESCE($3, moves_made), duration_seconds = COALESCE($4, duration_seconds), completed_at = NOW() WHERE dungeon_seed = $5 `, [ won ? 'won' : 'lost', treasureFound, metrics.moves ?? null, metrics.durationSeconds ?? null, gameId ]); } catch (e) { // Non-fatal if games table differs during early dev. if (process.env.NODE_ENV === 'development') { console.warn('Game completion update warning:', e.message); } } // Handle payouts based on the actual recorded payment mode, not the current global // setting. Each mode's payout is gated by its own enable flag, so a no-payout // instance (DIRECT_PAYOUTS_ENABLED=false / CREDITS_PAYOUTS_ENABLED=false) never sends. const payoutEligibleStartMode = (recordedPaymentMode === 'PAID_SINGLE' && this.directPayoutEnabled) || (recordedPaymentMode === 'PAID_CREDITS' && this.creditsPayoutEnabled); if (payoutEligibleStartMode && won) { // M4/C4: never insert a payout without a resolved game row — a null game_id // would defeat the payouts dedup (game_id) guard and could double-pay. if (!gameDbId) { console.error(`❌ completeGame: no game row for dungeon_seed ${gameId}; refusing to insert a payout.`); this._alert('payout_unresolved_game', '⚠️ Payout skipped — game row not found', `
completeGame could not resolve a games row for dungeon_seed ${gameId} (socket ${socketId}). No payout was inserted.
`); return { success: true, mode: recordedPaymentMode, payout: null, reason: 'Game row not resolved', score: metrics.score ?? null }; } // Use the payout terms snapshotted at game start (so a mid-game config change // can't alter an in-flight payout). Fall back to live calculation only for // older games that predate the snapshot columns. let payoutAmount; let multiplier; if (payoutSnapshot) { payoutAmount = treasureFound ? payoutSnapshot.treasureAmount : payoutSnapshot.escapeAmount; multiplier = treasureFound ? payoutSnapshot.treasureMult : payoutSnapshot.escapeMult; } else { const live = this.calculatePayout(recordedPaymentMode, { treasureFound }); payoutAmount = live.amount; multiplier = live.multiplier; } // M5: clamp/validate the payout against the configured min/max for this mode. // Over-max -> cap + alert (never send more than policy allows). Under-min -> // skip entirely (dust not worth an on-chain fee). Bounds are atomic BigInt. const bounds = this._payoutBoundsForMode(recordedPaymentMode); if (bounds) { let amtBig = null; try { amtBig = money.toBig(payoutAmount); } catch { amtBig = null; } if (amtBig !== null) { if (bounds.max !== null && amtBig > bounds.max) { console.warn(`⚠️ Payout ${amtBig} for game ${gameId} exceeds configured max ${bounds.max}; capping.`); this._alert('payout_over_max', '⚠️ Payout capped at configured maximum', `Computed payout ${amtBig} for game ${gameId} exceeded the configured max ${bounds.max} and was capped. Mode: ${recordedPaymentMode}.
`); amtBig = bounds.max; payoutAmount = amtBig.toString(); } if (bounds.min !== null && amtBig < bounds.min) { console.log(`Payout ${amtBig} for game ${gameId} below configured min ${bounds.min}; skipping payout.`); return { success: true, mode: recordedPaymentMode, payout: null, reason: 'Below minimum payout', score: metrics.score ?? null }; } } } // Check if payout was already processed for this game (prevent duplicates) const existingPayout = await this.db.query(` SELECT id FROM payouts WHERE game_id = $1 AND status IN ('pending', 'processing', 'completed') `, [gameDbId]); if (existingPayout.rows.length > 0) { console.log(`⚠️ Payout already exists for game ${gameId}, skipping duplicate`); return { success: true, mode: recordedPaymentMode, payout: null, reason: 'Payout already processed', score: metrics.score ?? null }; } // M4/C4: resolve the paying user by the STABLE users.id carried on the game // row (games.user_id), never by the mutable socket that finished the game. let userRow = null; if (gameUserId != null) { const userResult = await this.db.query(`SELECT * FROM users WHERE id = $1 LIMIT 1`, [gameUserId]); userRow = userResult.rows[0] || null; } if (!userRow) { console.error(`❌ completeGame: could not resolve paying user (game user_id=${gameUserId}) for game ${gameId}; refusing payout.`); this._alert('payout_unresolved_user', '⚠️ Payout skipped — paying user not resolved', `completeGame could not resolve the paying user (games.user_id=${gameUserId}) for game ${gameId}. No payout was inserted.
`); return { success: true, mode: recordedPaymentMode, payout: null, reason: 'Paying user not resolved', score: metrics.score ?? null }; } // Use payout address locked at game start, fall back to the user's address const payoutAddress = lockedPayoutAddress || userRow.payout_address; if (userRow && payoutAddress) { // Create pending payout record, then schedule batch processing. // Multiple wins within 5 seconds are batched into one transfer_split call, // saving outputs and preventing locked-output failures. try { const insertResult = await this.db.query(` INSERT INTO payouts (user_id, game_id, payout_address, amount, multiplier, reason, status, created_at) VALUES ($1, $2, $3, $4, $5, $6, 'pending', NOW()) RETURNING id `, [ userRow.id, gameDbId, payoutAddress, payoutAmount, multiplier, treasureFound ? 'escape_with_treasure' : 'escape' ]); const payoutId = insertResult.rows[0].id; // Schedule batch processing (5-second debounce collects concurrent wins) this._scheduleBatchPayout(); return { success: true, mode: recordedPaymentMode, payout: { status: 'queued', payoutId, amount: payoutAmount, multiplier, treasure: treasureFound }, score: metrics.score ?? null }; } catch (payoutErr) { const normalizedPayout = normalizeError(payoutErr, 'Failed to create payout record'); console.error('❌ Error creating payout record:', normalizedPayout.message); return { success: true, mode: recordedPaymentMode, payout: null, payoutError: normalizedPayout.safeMessage, score: metrics.score ?? null }; } } return { success: true, mode: recordedPaymentMode, payout: null, reason: 'No payout address', score: metrics.score ?? null }; } // Credits mode: decrement nothing here (already handled start). Optionally could award stats. return { success: true, mode: recordedPaymentMode, payout: null, score: metrics.score ?? null }; } catch (err) { const normalized = normalizeError(err, 'Failed to complete game'); console.error('❌ completeGame error:', normalized.message); return { success: false, error: normalized.safeMessage }; } } // ---- Batch Payout Processing ---- /** * Schedule batch payout processing with a short debounce. * Multiple wins within the debounce window (5s) are batched into one transfer. * Also called on new block events as a safety net. */ _scheduleBatchPayout() { if (this._batchPayoutTimer) clearTimeout(this._batchPayoutTimer); this._batchPayoutTimer = setTimeout(() => { this._processPendingPayouts().catch(err => { console.error('❌ Batch payout processing error:', err.message); }); }, 5000); } /** * Process all pending payouts that haven't been sent yet. * Batches them into a single transfer_split call for efficiency. * Called by debounce timer and on new block events. */ async _processPendingPayouts() { // Prevent concurrent batch processing if (this._isBatchProcessing) return; this._isBatchProcessing = true; try { // Gather pending payouts inside a transaction with row-level locks. // FOR UPDATE SKIP LOCKED prevents the retry service from grabbing the same rows. const pending = await this.db.withTransaction(async (client) => { const result = await client.query(` SELECT id, user_id, game_id, payout_address, amount, multiplier, reason FROM payouts WHERE status = 'pending' AND tx_hash IS NULL ORDER BY created_at ASC FOR UPDATE SKIP LOCKED `); // Mark as 'processing' so retry service won't touch them even after this tx commits if (result.rows.length > 0) { const ids = result.rows.map(r => r.id); await client.query( `UPDATE payouts SET status = 'processing' WHERE id = ANY($1)`, [ids] ); } return result; }); if (pending.rows.length === 0) return; // MONERO OUTPUT LOCKING: a spent output (incl. the change output) is locked for // ~10 blocks. If we don't have enough UNLOCKED balance to cover this batch right // now, DEFER it (revert to pending) instead of attempting a transfer that would // fail — the next batch interval and every new-block tick re-check, so it sends as // soon as outputs unlock. Batching already minimises how many change outputs we // create; this handles the "all outputs currently locked" window. try { const totalNeeded = money.sum(pending.rows.map(p => p.amount)); // BigInt atomic const bal = await this.walletService.getBalance(); // unlocked_balance arrives as a JS number from the RPC; coerce to integer BigInt. const unlocked = (bal && !bal.error) ? BigInt(Math.round(Number(bal.unlocked_balance) || 0)) : null; if (unlocked !== null && unlocked < totalNeeded) { const ids = pending.rows.map(p => p.id); await this.db.query( `UPDATE payouts SET status = 'pending', last_error = $2, last_retry_at = NOW() WHERE id = ANY($1)`, [ids, 'Deferred: insufficient unlocked balance (Monero outputs locked ~10 blocks)'] ).catch(() => {}); console.warn(`⏳ Deferring ${pending.rows.length} payout(s): unlocked ${unlocked} < needed ${totalNeeded}. Outputs likely locked; will retry next block/interval.`); return; } } catch (balErr) { // If the balance check itself fails, fall through and let the transfer try. console.error('Payout balance pre-check failed, proceeding:', balErr.message); } console.log(`📦 Processing batch of ${pending.rows.length} pending payout(s)`); // Build destinations for transfer_split const destinations = pending.rows.map(p => ({ amount: p.amount, address: p.payout_address })); // Generate batch_id to link payouts processed together const batchId = require('uuid').v4(); if (pending.rows.length === 1) { // Single payout — use processPayout for simpler flow const p = pending.rows[0]; try { const result = await this.walletService.processPayout({ userId: p.user_id, gameId: p.game_id, address: p.payout_address, amount: p.amount, multiplier: p.multiplier }); // Store tx_hash + status + user stats atomically in one transaction await this.db.withTransaction(async (client) => { await client.query( `UPDATE payouts SET tx_hash = $1, fee = $2, batch_id = $3, status = 'completed', processed_at = NOW() WHERE id = $4`, [result.txHash || null, result.fee || null, batchId, p.id] ); await client.query( `UPDATE users SET total_amount_won = total_amount_won + $1, total_payouts_received = COALESCE(total_payouts_received, 0) + 1 WHERE id = $2`, [p.amount, p.user_id] ); }); console.log(`💸 Single payout ${p.id} completed: ${result.txHash}`); } catch (err) { // Insufficient-unlocked-funds is a pre-broadcast error -> retry on the next // batch run (revert to pending). ANY OTHER error is ambiguous: processPayout // (transfer_split) may have broadcast on-chain even though the RPC response // errored, and this row has a null tx_hash so the retry service's blockchain // guard can't protect it — an auto-retry could DOUBLE-PAY. Mark 'needs_review' // (retry service skips it) + alert, mirroring the batch path below. const fundsIssue = isInsufficientFundsError(err.message); const status = fundsIssue ? 'pending' : 'needs_review'; console.error(`❌ Single payout ${p.id} failed -> ${status}:`, err.message); await this.db.query( `UPDATE payouts SET status = $1, last_error = $2, last_retry_at = NOW() WHERE id = $3`, [status, err.message, p.id] ).catch(() => {}); if (!fundsIssue && this.alertService && typeof this.alertService.sendAlert === 'function') { this.alertService.sendAlert('single_payout_failed', { subject: '⚠️ Payout failed — manual review required', html: `Single payout ${p.id} failed and was marked needs_review to avoid a possible double-payout.
` + `Amount: ${p.amount} → ${p.payout_address}
Error: ${err.message}
` }).catch(() => {}); } } } else { // Multiple payouts — batch via transfer_split with multiple destinations const ids = pending.rows.map(p => p.id); try { const result = await this.walletService.processBatchPayout(destinations); // transfer_split sends ONE on-chain tx to all destinations; every payout // row in this batch legitimately shares that tx_hash (see migration 015). const txHash = (result.tx_hash_list && result.tx_hash_list[0]) || null; const feePerPayout = result.totalFee ? Math.ceil(result.totalFee / pending.rows.length) : null; // Mark the ENTIRE batch completed in a SINGLE transaction so we never // leave some rows completed and others stranded mid-batch (which the old // per-row loop did when the shared tx_hash hit the unique index). await this.db.withTransaction(async (client) => { await client.query( `UPDATE payouts SET tx_hash = $1, fee = $2, batch_id = $3, status = 'completed', processed_at = NOW() WHERE id = ANY($4)`, [txHash, feePerPayout, batchId, ids] ); // Per-row stat increments inside the same transaction. Amounts are passed // as DB values so Postgres does the BIGINT arithmetic (no JS float). for (const p of pending.rows) { await client.query( `UPDATE users SET total_amount_won = total_amount_won + $1::bigint, total_payouts_received = COALESCE(total_payouts_received, 0) + 1 WHERE id = $2`, [p.amount, p.user_id] ); } }); console.log(`💸 Batch payout completed: ${pending.rows.length} payouts in tx ${txHash}`); } catch (err) { // An insufficient-unlocked-funds error is raised BEFORE broadcast (no tx // went out), so it's safe to retry: revert to 'pending' and let the next // run send it once outputs unlock — no operator alert needed (transient). // // ANY OTHER error is ambiguous: transfer_split may have broadcast on-chain // even though the RPC response errored. Those rows have no tx_hash, so the // retry service's blockchain guard can't protect them and an auto-retry // could DOUBLE-PAY. Mark 'needs_review' (retry service skips it) + alert. const fundsIssue = isInsufficientFundsError(err.message); const status = fundsIssue ? 'pending' : 'needs_review'; console.error(`❌ Batch payout failed (${pending.rows.length} payouts) -> ${status}:`, err.message); await this.db.query( `UPDATE payouts SET status = $1, last_error = $2, last_retry_at = NOW() WHERE id = ANY($3)`, [status, String(err.message).slice(0, 500), ids] ).catch(() => {}); if (!fundsIssue && this.alertService && typeof this.alertService.sendAlert === 'function') { this.alertService.sendAlert('batch_payout_failed', { subject: '⚠️ Batch payout failed — manual review required', html: `A batch of ${pending.rows.length} payout(s) failed and was marked needs_review to avoid a possible double-payout.
` + `Payout IDs: ${ids.join(', ')}
Error: ${err.message}
` }).catch(() => {}); } } } } catch (err) { console.error('❌ _processPendingPayouts error:', err.message); } finally { this._isBatchProcessing = false; } } } module.exports = GameModeManager;