mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 14:51:26 +02:00
Spent outputs (including change) lock for ~10 blocks on Monero. When a payout batch has no unlocked balance to cover it, attempting transfer_split fails noisily and, for batches, gets parked in needs_review for an operator. Add a pre-flight unlocked-balance check that reverts the batch to 'pending' (re-checked every new-block tick and batch interval) so it sends as soon as outputs unlock — no manual intervention. Also classify insufficient-funds RPC errors as pre-broadcast/safe-to-retry: single -> pending (not failed), batch -> pending without an operator alert (not needs_review), since no tx went on-chain and there's no double-pay risk.
1546 lines
71 KiB
JavaScript
1546 lines
71 KiB
JavaScript
/**
|
|
* 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');
|
|
|
|
// 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) {
|
|
this.db = databaseManager;
|
|
this.walletService = walletRPCService;
|
|
this.debugManager = debugManager;
|
|
this.paymentConfigManager = paymentConfigManager || null;
|
|
|
|
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
|
|
};
|
|
}
|
|
|
|
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 processCreditsPackageConfirmation(socketId, paymentId, packageInfo = 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 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;
|
|
|
|
// Determine credits to add from package info or payment description
|
|
let creditsToAdd = 10; // Default fallback
|
|
if (packageInfo && packageInfo.credits) {
|
|
creditsToAdd = Number(packageInfo.credits) + (Number(packageInfo.bonus) || 0);
|
|
} else {
|
|
const desc = paymentLookup.rows[0].description || '';
|
|
const match = desc.match(/(\d+)\s*credits?/i);
|
|
if (match) {
|
|
creditsToAdd = parseInt(match[1], 10) || 10;
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
WHERE id = $2 AND status = 'pending'
|
|
RETURNING id
|
|
`, [creditsToAdd, paymentId, receivedAmount]);
|
|
|
|
if (paymentUpdateResult.rows.length === 0) {
|
|
// Already processed by another instance - not an error
|
|
return { alreadyProcessed: true };
|
|
}
|
|
|
|
// Step 2: Add credits 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,
|
|
updated_at = NOW()
|
|
WHERE id = $2
|
|
RETURNING credits
|
|
`, [creditsToAdd, userId]);
|
|
|
|
const newBalance = updateResult.rows[0]?.credits ?? creditsToAdd;
|
|
|
|
// Step 3: Record credit transaction for audit trail
|
|
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]);
|
|
|
|
return { newBalance };
|
|
});
|
|
|
|
if (result.alreadyProcessed) {
|
|
console.log(`Payment ${paymentId} already confirmed (atomic check), skipping duplicate`);
|
|
return { success: false, alreadyProcessed: true, reason: 'Payment already processed' };
|
|
}
|
|
|
|
console.log(`Credits package confirmed: +${creditsToAdd} credits for user ${userId}, new balance: ${result.newBalance}`);
|
|
|
|
return {
|
|
success: true,
|
|
creditsAdded: creditsToAdd,
|
|
newBalance: result.newBalance
|
|
};
|
|
} catch (error) {
|
|
const normalized = normalizeError(error, 'Failed to process credits package confirmation');
|
|
console.error('Error processing credits package:', normalized.message);
|
|
return {
|
|
success: false,
|
|
reason: normalized.safeMessage
|
|
};
|
|
}
|
|
}
|
|
|
|
async _findReusablePayment(userId, paymentType) {
|
|
if (!userId) return null;
|
|
const result = await this.db.query(`
|
|
SELECT id, subaddress, expected_amount, payment_type, status, created_at, expires_at, description
|
|
FROM payments
|
|
WHERE user_id = $1
|
|
AND payment_type = $2
|
|
AND status = 'pending'
|
|
AND (expires_at IS NULL OR expires_at > NOW())
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
`, [userId, paymentType]);
|
|
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
|
|
};
|
|
}
|
|
|
|
_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,
|
|
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)
|
|
*/
|
|
async processGameStart(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
|
|
`, [creditsToSpend, user.id]);
|
|
|
|
if (updateRes.rows.length === 0) {
|
|
return { insufficient: true };
|
|
}
|
|
|
|
const remainingCredits = updateRes.rows[0].credits;
|
|
|
|
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 };
|
|
});
|
|
|
|
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,
|
|
creditsSpent: creditsToSpend,
|
|
effectiveMode: 'PAID_CREDITS'
|
|
};
|
|
}
|
|
|
|
async _processGameStartWithPayment(user, payment, gameId) {
|
|
const snap = this._computePayoutSnapshot('PAID_SINGLE');
|
|
await this.db.withTransaction(async (client) => {
|
|
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]);
|
|
});
|
|
|
|
console.log(`Linked game ${gameId} to payment ${payment.id}`);
|
|
return {
|
|
success: true,
|
|
paymentId: payment.id,
|
|
effectiveMode: 'PAID_SINGLE'
|
|
};
|
|
}
|
|
|
|
// 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;
|
|
|
|
switch (paymentType) {
|
|
case 'single_game': {
|
|
amount = this.singleGamePrice;
|
|
description = `${this.gameName} single game entry (${this.currencyLabel})`;
|
|
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;
|
|
const creditCount = selectedPackage?.credits ?? 10;
|
|
const bonusCredits = selectedPackage?.bonus ?? 0;
|
|
const bonusText = bonusCredits > 0 ? ` (+${bonusCredits} bonus)` : '';
|
|
description = `${this.gameName} ${creditCount}${bonusText} credits package (${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);
|
|
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 payment request using wallet RPC with correct parameters
|
|
const 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)
|
|
VALUES ($1, $2, $3, $4, $5, 'pending', $6, NOW(), $7, $8)
|
|
RETURNING id, expires_at
|
|
`, [user.id, socketId, paymentResult.address, amount, paymentType, description, expiresAt, paymentResult.addressIndex ?? null]);
|
|
|
|
const insertedRow = insertResult.rows[0];
|
|
|
|
return {
|
|
id: insertedRow?.id,
|
|
address: paymentResult.address,
|
|
amount: amount,
|
|
amountFormatted: this.formatAtomicHuman(amount, 4),
|
|
currency: this.cryptoType,
|
|
expiresAt: insertedRow?.expires_at || expiresAt,
|
|
package: this._serializePackageInfo(packageInfo),
|
|
paymentType,
|
|
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, ipAddress = null) {
|
|
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];
|
|
}
|
|
|
|
// 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, ipAddress]);
|
|
|
|
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}`);
|
|
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,
|
|
credits: pkg.credits,
|
|
price: typeof pkg.price === 'bigint' ? Number(pkg.price) : pkg.price,
|
|
bonus: pkg.bonus || 0,
|
|
priceFormatted: this.formatAtomicHuman(
|
|
typeof pkg.price === 'bigint' ? Number(pkg.price) : pkg.price,
|
|
2
|
|
)
|
|
}));
|
|
|
|
return {
|
|
mode: this.gameMode,
|
|
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,
|
|
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
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
let recordedPaymentMode = this.gameMode; // fallback to global
|
|
let gameDbId = null;
|
|
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, game_mode, payout_address,
|
|
payout_escape_amount, payout_treasure_amount,
|
|
payout_escape_mult, payout_treasure_mult
|
|
FROM games
|
|
WHERE dungeon_seed = $1 AND socket_id = $2
|
|
LIMIT 1
|
|
`, [gameId, socketId]);
|
|
if (gameRecord.rows.length > 0) {
|
|
gameDbId = gameRecord.rows[0].id;
|
|
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 AND socket_id = $6
|
|
`, [
|
|
won ? 'won' : 'lost',
|
|
treasureFound,
|
|
metrics.moves ?? null,
|
|
metrics.durationSeconds ?? null,
|
|
gameId,
|
|
socketId
|
|
]);
|
|
} 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) {
|
|
// 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;
|
|
}
|
|
|
|
// Check if payout was already processed for this game (prevent duplicates)
|
|
if (gameDbId) {
|
|
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 };
|
|
}
|
|
}
|
|
|
|
// Use payout address locked at game start, fall back to current user address
|
|
const userResult = await this.db.query(`SELECT * FROM users WHERE socket_id = $1 LIMIT 1`, [socketId]);
|
|
const userRow = userResult.rows[0];
|
|
const payoutAddress = lockedPayoutAddress || (userRow && 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). Other failures -> 'failed' (retry service,
|
|
// which re-checks the chain before re-sending).
|
|
const status = isInsufficientFundsError(err.message) ? 'pending' : 'failed';
|
|
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(() => {});
|
|
}
|
|
} 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: `<p>A batch of ${pending.rows.length} payout(s) failed and was marked <b>needs_review</b> to avoid a possible double-payout.</p>`
|
|
+ `<p>Payout IDs: ${ids.join(', ')}</p><p>Error: ${err.message}</p>`
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('❌ _processPendingPayouts error:', err.message);
|
|
} finally {
|
|
this._isBatchProcessing = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = GameModeManager;
|