UnifyPayments (megachange)

This commit is contained in:
jwinterm
2025-09-29 08:49:27 -04:00
parent 9a4911f410
commit c7cbdff021
10 changed files with 1950 additions and 338 deletions
+59 -27
View File
@@ -1,25 +1,66 @@
# Wowgue Game Configuration
# Game Mode Selection (required)
# PAID_SINGLE: Users pay per game, get 2x for escape, 3x with treasure
# PAID_CREDITS: Users buy 10 credits, play 10 times, no payouts
# FREE: Free play mode, no payments required
GAME_MODE=PAID_SINGLE
# -----------------------------------------------------------------------------
# Unified Payment System (recommended configuration)
# -----------------------------------------------------------------------------
PAYMENTS_ENABLED=true
# Cryptocurrency Configuration
# Enable one or both modes (comma separated list: direct,credits)
PAYMENT_MODES=direct,credits
# Direct mode settings
DIRECT_PAYMENT_ENABLED=true
DIRECT_GAME_PRICE=100000000000 # 1 WOW in atomic units
DIRECT_REQUIRES_ADDRESS=true
DIRECT_ALLOW_GUEST_PLAY=false
DIRECT_PAYOUTS_ENABLED=true
DIRECT_PAYOUT_ESCAPE=2.0
DIRECT_PAYOUT_TREASURE=3.0
# Credits mode settings
CREDITS_ENABLED=true
CREDITS_PER_GAME=1
CREDITS_REQUIRES_ADDRESS=true
ALLOW_MIXED_MODE=true
PREFER_CREDITS_FIRST=true
CREDITS_PAYOUTS_ENABLED=false
CREDITS_PAYOUT_BASE=50000000000 # 0.5 WOW per credit payout baseline
CREDITS_PAYOUT_ESCAPE=1.5
CREDITS_PAYOUT_TREASURE=2.0
CREDITS_PACKAGES='[
{"id":"small","credits":10,"price":"500000000000","bonus":0},
{"id":"medium","credits":25,"price":"1000000000000","bonus":2},
{"id":"large","credits":100,"price":"3500000000000","bonus":15}
]'
# Global payouts / limits
PAYOUTS_ENABLED=true
PAYOUT_MIN_AMOUNT=10000000000 # 0.1 WOW minimum payout
PAYOUT_MAX_PER_GAME=10000000000000
PAYOUT_BATCH_INTERVAL=300 # seconds (5 minutes)
PAYOUT_MAX_RETRIES=3
MAX_PAYOUT_BATCH_SIZE=50
# Anti-abuse limits
MAX_GAMES_PER_HOUR=60
MAX_PAYOUTS_PER_DAY=100
MAX_CREDIT_PURCHASE_PER_DAY=100000000000000
GAME_COOLDOWN_SECONDS=5
# -----------------------------------------------------------------------------
# Legacy overrides (optional). These mirror the old configuration style and
# are primarily kept for backwards compatibility or quick testing.
# -----------------------------------------------------------------------------
# GAME_MODE accepts FREE, PAID_SINGLE, PAID_CREDITS. When set it overrides
# PAYMENT_MODES. Leave unset to rely on unified config above.
GAME_MODE=
# Legacy per-mode prices (still read as fallbacks if PAYMENT_MODES unset)
CRYPTO_TYPE=WOW
# Payment amounts in atomic units (piconeros for XMR, tacoshis for WOW)
# XMR: 0.005 XMR = 5000000000 piconeros
# WOW: 1 WOW = 100000000000 tacoshis
# You can use plain integers, scientific notation (e.g. 5e9), or underscores (e.g. 5_000_000_000)
# The parser will coerce to an integer atomic unit amount.
SINGLE_GAME_PRICE=1e11 # 1 WOW
CREDITS_PACKAGE_PRICE=5e11 # 5 WOW
# SINGLE_GAME_PRICE=5e9 # 0.005 XMR (was 5000000000)
# CREDITS_PACKAGE_PRICE=5e10 # 0.05 XMR (was 50000000000)
# Alternative readable style:
# SINGLE_GAME_PRICE=5_000_000_000
# CREDITS_PACKAGE_PRICE=50_000_000_000
SINGLE_GAME_PRICE=1e11 # 1 WOW (atomic units)
CREDITS_PACKAGE_PRICE=5e11 # 5 WOW (atomic units)
# SINGLE_GAME_PRICE=5e9 # 0.005 XMR (5000000000)
# CREDITS_PACKAGE_PRICE=5e10 # 0.05 XMR (50000000000)
# Wallet RPC Configuration
PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:34570
@@ -55,12 +96,3 @@ NODE_ENV=production
BLOCK_SOURCE=daemon
# SIMULATED_BLOCKS=false
# FORCE_SIMULATED_BLOCKS=false
# Payout Configuration
PAYOUT_BATCH_INTERVAL=300000
MAX_PAYOUT_BATCH_SIZE=50
# Credits Mode Optional Payouts
# Set to true to enable payouts (same multipliers as single mode) when using PAID_CREDITS
# Default is false meaning credits mode is purely consumptive / no rewards.
CREDITS_PAYOUT_ENABLED=false
+55
View File
@@ -0,0 +1,55 @@
/**
* Environment Validator
* Emits friendly warnings when payment related environment variables look suspicious.
*/
class EnvironmentValidator {
constructor(options = {}) {
this.logger = options.logger || console;
}
validate(config) {
const warnings = [];
const errors = [];
if (process.env.GAME_MODE && process.env.PAYMENT_MODES) {
warnings.push('Both GAME_MODE and PAYMENT_MODES are set. PAYMENT_MODES takes precedence.');
}
if (config.paymentsEnabled && !process.env.PRIMARY_WALLET_ENDPOINT) {
warnings.push('paymentsEnabled is true but PRIMARY_WALLET_ENDPOINT is not set. Wallet RPC may fail.');
}
if (config.paymentsEnabled && !process.env.PRIMARY_RPC_ENDPOINT) {
warnings.push('paymentsEnabled is true but PRIMARY_RPC_ENDPOINT is missing. Blockchain RPC is required.');
}
if (config.modes.direct.enabled && config.modes.direct.price <= 0n) {
errors.push('Direct payment mode is enabled but direct price is not positive.');
}
if (config.modes.credits.enabled && !config.modes.credits.packages.length) {
errors.push('Credits mode is enabled but no credit packages are defined.');
}
if (config.modes.credits.enabled && !process.env.CREDITS_PACKAGES) {
this.logger.info?.('ℹ️ Credits mode enabled with default packages. Consider defining CREDITS_PACKAGES for production.');
}
if (!config.paymentsEnabled) {
this.logger.info?.('ℹ️ Payments are currently disabled. Server will run in FREE mode.');
}
if (warnings.length) {
warnings.forEach(msg => this.logger.warn?.(`⚠️ ${msg}`));
}
if (errors.length) {
errors.forEach(msg => this.logger.error?.(`❌ ${msg}`));
}
return { warnings, errors };
}
}
module.exports = EnvironmentValidator;
+429
View File
@@ -0,0 +1,429 @@
/**
* Payment Configuration Manager
* Loads unified payment settings from defaults, environment variables, and (future) database overrides.
*/
const { EventEmitter } = require('events');
const DEFAULT_CONFIG = Object.freeze({
paymentsEnabled: true,
currency: {
symbol: 'WOW',
decimals: 11,
minPayment: 1000000000n, // 0.01 WOW in atomic units
maxPayment: 100000000000000n // 1,000 WOW in atomic units
},
modes: {
direct: {
enabled: true,
price: 100000000000n, // 1 WOW per game
requiresAddress: true,
allowGuestPlay: false
},
credits: {
enabled: false,
creditsPerGame: 1,
requiresAddress: true,
allowMixedMode: true,
packages: [
{ id: 'small', credits: 10, price: 500000000000n, bonus: 0 },
{ id: 'medium', credits: 25, price: 1000000000000n, bonus: 2 },
{ id: 'large', credits: 100, price: 3500000000000n, bonus: 15 }
]
}
},
payouts: {
enabled: true,
requiresKYC: false,
rules: {
direct: {
enabled: true,
multipliers: {
escape: 2.0,
escapeWithTreasure: 3.0,
perfectRun: 5.0
},
minPayout: 1000000000n,
maxPayout: 10000000000000n
},
credits: {
enabled: false,
multipliers: {
escape: 1.5,
escapeWithTreasure: 2.0,
perfectRun: 3.0
},
baseValue: 50000000000n,
minPayout: 1000000000n,
maxPayout: 5000000000000n
}
},
processing: {
automatic: true,
batchingEnabled: true,
batchInterval: 300,
confirmations: 1,
maxRetries: 3
}
},
promotions: {
enabled: false,
freeCredits: {
onSignup: 0,
onFirstPurchase: 0,
referralBonus: 0
},
discounts: {
enabled: false,
bulkDiscount: 0,
happyHour: null
}
},
limits: {
maxGamesPerHour: 60,
maxPayoutsPerDay: 100,
maxCreditPurchasePerDay: 100000000000000n,
cooldownBetweenGames: 5
},
preferences: {
allowMixedMode: true,
preferCreditsFirst: true
}
});
function cloneConfig(value) {
if (Array.isArray(value)) {
return value.map(cloneConfig);
}
if (value && typeof value === 'object' && !(value instanceof EventEmitter)) {
return Object.entries(value).reduce((acc, [key, val]) => {
acc[key] = cloneConfig(val);
return acc;
}, {});
}
return value;
}
function parseBoolean(value, fallback) {
if (value === undefined || value === null || value === '') {
return fallback;
}
if (typeof value === 'boolean') {
return value;
}
const normalized = String(value).trim().toLowerCase();
if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) {
return true;
}
if (['false', '0', 'no', 'n', 'off'].includes(normalized)) {
return false;
}
return fallback;
}
function parseInteger(value, fallback) {
if (value === undefined || value === null || value === '') {
return fallback;
}
if (Number.isInteger(value)) {
return value;
}
const normalized = String(value).trim().toLowerCase().replace(/_/g, '');
if (normalized === '') {
return fallback;
}
const num = Number(normalized);
if (!Number.isFinite(num)) {
return fallback;
}
return Math.trunc(num);
}
function parseAtomicValue(value, fallback) {
if (value === undefined || value === null || value === '') {
return fallback;
}
if (typeof value === 'bigint') {
return value;
}
if (typeof value === 'number') {
if (!Number.isFinite(value)) {
return fallback;
}
return BigInt(Math.round(value));
}
const normalized = String(value).trim().toLowerCase().replace(/_/g, '');
if (normalized === '') {
return fallback;
}
if (normalized.startsWith('0x')) {
try {
return BigInt(normalized);
} catch (error) {
return fallback;
}
}
const numeric = Number(normalized);
if (Number.isFinite(numeric)) {
return BigInt(Math.round(numeric));
}
try {
return BigInt(normalized);
} catch (error) {
return fallback;
}
}
function parseFloatValue(value, fallback) {
if (value === undefined || value === null || value === '') {
return fallback;
}
if (typeof value === 'number') {
return value;
}
const normalized = String(value).trim();
const num = Number(normalized);
return Number.isFinite(num) ? num : fallback;
}
function safeParseJson(value, fallback) {
if (!value) {
return fallback;
}
try {
return JSON.parse(value);
} catch (error) {
return fallback;
}
}
class PaymentConfigManager {
constructor(options = {}) {
this.logger = options.logger || console;
this.eventBus = options.eventBus || new EventEmitter();
this.persistenceAdapter = options.persistenceAdapter || null;
this.config = this.loadConfig();
this.validateConfig();
this.legacyOverride = process.env.GAME_MODE || null;
}
loadConfig() {
const config = cloneConfig(DEFAULT_CONFIG);
config.paymentsEnabled = parseBoolean(process.env.PAYMENTS_ENABLED, config.paymentsEnabled);
const gameModeOverride = process.env.GAME_MODE ? process.env.GAME_MODE.toUpperCase() : null;
if (gameModeOverride === 'FREE') {
config.paymentsEnabled = false;
config.modes.direct.enabled = false;
config.modes.credits.enabled = false;
} else if (gameModeOverride === 'PAID_SINGLE') {
config.paymentsEnabled = true;
config.modes.direct.enabled = true;
config.modes.credits.enabled = false;
} else if (gameModeOverride === 'PAID_CREDITS') {
config.paymentsEnabled = true;
config.modes.direct.enabled = false;
config.modes.credits.enabled = true;
}
if (process.env.PAYMENT_MODES) {
const modes = process.env.PAYMENT_MODES.split(',').map(m => m.trim().toLowerCase()).filter(Boolean);
config.modes.direct.enabled = modes.includes('direct');
config.modes.credits.enabled = modes.includes('credits');
config.paymentsEnabled = config.modes.direct.enabled || config.modes.credits.enabled;
}
config.currency.symbol = process.env.CRYPTO_TYPE || config.currency.symbol;
config.currency.minPayment = parseAtomicValue(process.env.PAYOUT_MIN_AMOUNT, config.currency.minPayment);
config.currency.maxPayment = parseAtomicValue(process.env.MAX_CREDIT_PURCHASE_PER_DAY, config.currency.maxPayment);
const directPriceEnv = process.env.DIRECT_GAME_PRICE ?? process.env.SINGLE_GAME_PRICE;
config.modes.direct.price = parseAtomicValue(directPriceEnv, config.modes.direct.price);
config.modes.direct.enabled = parseBoolean(process.env.DIRECT_PAYMENT_ENABLED, config.modes.direct.enabled);
config.modes.direct.requiresAddress = parseBoolean(process.env.DIRECT_REQUIRES_ADDRESS, config.modes.direct.requiresAddress);
config.modes.direct.allowGuestPlay = parseBoolean(process.env.DIRECT_ALLOW_GUEST_PLAY, config.modes.direct.allowGuestPlay);
const creditsEnabled = parseBoolean(process.env.CREDITS_ENABLED, undefined);
if (creditsEnabled !== undefined) {
config.modes.credits.enabled = creditsEnabled;
}
config.modes.credits.creditsPerGame = parseInteger(process.env.CREDITS_PER_GAME, config.modes.credits.creditsPerGame);
config.modes.credits.requiresAddress = parseBoolean(process.env.CREDITS_REQUIRES_ADDRESS, config.modes.credits.requiresAddress);
config.modes.credits.allowMixedMode = parseBoolean(process.env.ALLOW_MIXED_MODE, config.modes.credits.allowMixedMode);
const packageJson = safeParseJson(process.env.CREDITS_PACKAGES, null);
if (Array.isArray(packageJson) && packageJson.length > 0) {
config.modes.credits.packages = packageJson.map(pkg => ({
id: pkg.id,
credits: parseInteger(pkg.credits, 0),
price: parseAtomicValue(pkg.price, 0n),
bonus: parseInteger(pkg.bonus || 0, 0)
})).filter(pkg => pkg.id);
} else if (process.env.CREDITS_PACKAGE_PRICE) {
const overridePrice = parseAtomicValue(process.env.CREDITS_PACKAGE_PRICE, null);
if (overridePrice !== null) {
config.modes.credits.packages = config.modes.credits.packages.map((pkg, index) =>
index === 0 ? { ...pkg, price: overridePrice } : pkg
);
}
}
config.payouts.enabled = parseBoolean(process.env.PAYOUTS_ENABLED, config.payouts.enabled);
config.payouts.rules.direct.enabled = parseBoolean(process.env.DIRECT_PAYOUTS_ENABLED, config.payouts.rules.direct.enabled);
config.payouts.rules.direct.multipliers.escape = parseFloatValue(process.env.DIRECT_PAYOUT_ESCAPE, config.payouts.rules.direct.multipliers.escape);
config.payouts.rules.direct.multipliers.escapeWithTreasure = parseFloatValue(process.env.DIRECT_PAYOUT_TREASURE, config.payouts.rules.direct.multipliers.escapeWithTreasure);
config.payouts.rules.direct.minPayout = parseAtomicValue(process.env.PAYOUT_MIN_AMOUNT, config.payouts.rules.direct.minPayout);
config.payouts.rules.direct.maxPayout = parseAtomicValue(process.env.PAYOUT_MAX_PER_GAME, config.payouts.rules.direct.maxPayout);
const creditsPayoutEnabled = parseBoolean(process.env.CREDITS_PAYOUTS_ENABLED ?? process.env.CREDITS_PAYOUT_ENABLED, undefined);
if (creditsPayoutEnabled !== undefined) {
config.payouts.rules.credits.enabled = creditsPayoutEnabled;
}
config.payouts.rules.credits.multipliers.escape = parseFloatValue(process.env.CREDITS_PAYOUT_ESCAPE, config.payouts.rules.credits.multipliers.escape);
config.payouts.rules.credits.multipliers.escapeWithTreasure = parseFloatValue(process.env.CREDITS_PAYOUT_TREASURE, config.payouts.rules.credits.multipliers.escapeWithTreasure);
config.payouts.rules.credits.baseValue = parseAtomicValue(process.env.CREDITS_PAYOUT_BASE, config.payouts.rules.credits.baseValue);
config.payouts.processing.batchInterval = parseInteger(process.env.PAYOUT_BATCH_INTERVAL, config.payouts.processing.batchInterval);
config.payouts.processing.maxRetries = parseInteger(process.env.PAYOUT_MAX_RETRIES, config.payouts.processing.maxRetries);
config.limits.maxGamesPerHour = parseInteger(process.env.MAX_GAMES_PER_HOUR, config.limits.maxGamesPerHour);
config.limits.maxPayoutsPerDay = parseInteger(process.env.MAX_PAYOUTS_PER_DAY, config.limits.maxPayoutsPerDay);
config.limits.maxCreditPurchasePerDay = parseAtomicValue(process.env.MAX_CREDIT_PURCHASE_PER_DAY, config.limits.maxCreditPurchasePerDay);
config.limits.cooldownBetweenGames = parseInteger(process.env.GAME_COOLDOWN_SECONDS, config.limits.cooldownBetweenGames);
config.preferences.allowMixedMode = config.modes.credits.allowMixedMode;
config.preferences.preferCreditsFirst = parseBoolean(process.env.PREFER_CREDITS_FIRST, config.preferences.preferCreditsFirst);
return config;
}
getConfig() {
return cloneConfig(this.config);
}
getLegacyGameMode() {
if (!this.config.paymentsEnabled) {
return 'FREE';
}
if (this.legacyOverride) {
return this.legacyOverride.toUpperCase();
}
const directEnabled = !!this.config.modes.direct.enabled;
const creditsEnabled = !!this.config.modes.credits.enabled;
if (directEnabled && !creditsEnabled) {
return 'PAID_SINGLE';
}
if (!directEnabled && creditsEnabled) {
return 'PAID_CREDITS';
}
if (!directEnabled && !creditsEnabled) {
return 'FREE';
}
return this.config.preferences.preferCreditsFirst ? 'PAID_CREDITS' : 'PAID_SINGLE';
}
isModeEnabled(mode) {
return Boolean(this.config.modes[mode]?.enabled);
}
validateConfig() {
const directEnabled = this.config.modes.direct.enabled;
const creditsEnabled = this.config.modes.credits.enabled;
if (!this.config.paymentsEnabled && (directEnabled || creditsEnabled)) {
this.logger.warn?.('⚠️ paymentsEnabled is false but some payment modes are enabled. Payments will remain disabled.');
}
if (!directEnabled && !creditsEnabled && this.config.paymentsEnabled) {
this.logger.warn?.('⚠️ Payments enabled but no payment modes active. Falling back to FREE mode.');
}
this.validatePrices();
return true;
}
validatePrices() {
const { direct, credits } = this.config.modes;
if (direct.enabled && direct.price <= 0n) {
throw new Error('Direct game price must be a positive value');
}
if (credits.enabled) {
for (const pkg of credits.packages) {
if (!pkg.id) {
throw new Error('Each credit package requires an id');
}
if (pkg.price <= 0n) {
throw new Error(`Credit package ${pkg.id} has invalid price`);
}
if (pkg.credits <= 0) {
throw new Error(`Credit package ${pkg.id} has invalid credit count`);
}
}
}
}
async updateConfig(updates) {
const newConfig = cloneConfig(this.config);
Object.keys(updates || {}).forEach(key => {
newConfig[key] = updates[key];
});
const testManager = new PaymentConfigManager({
logger: this.logger,
eventBus: this.eventBus,
persistenceAdapter: this.persistenceAdapter
});
testManager.config = cloneConfig(newConfig);
testManager.validateConfig();
this.config = newConfig;
if (this.persistenceAdapter && typeof this.persistenceAdapter.save === 'function') {
await this.persistenceAdapter.save(newConfig);
}
this.broadcastConfigUpdate();
return this.getConfig();
}
refresh() {
this.config = this.loadConfig();
this.validateConfig();
this.broadcastConfigUpdate();
return this.getConfig();
}
broadcastConfigUpdate() {
if (this.eventBus && typeof this.eventBus.emit === 'function') {
this.eventBus.emit('paymentConfig:update', this.getConfig());
}
}
setPersistenceAdapter(adapter) {
this.persistenceAdapter = adapter;
}
setEventBus(eventBus) {
this.eventBus = eventBus;
}
summarize() {
return {
paymentsEnabled: this.config.paymentsEnabled,
legacyMode: this.getLegacyGameMode(),
directEnabled: this.config.modes.direct.enabled,
creditsEnabled: this.config.modes.credits.enabled,
directPrice: this.config.modes.direct.price,
creditPackages: this.config.modes.credits.packages.length
};
}
}
PaymentConfigManager.DEFAULT_CONFIG = DEFAULT_CONFIG;
module.exports = PaymentConfigManager;
+317 -63
View File
@@ -3,36 +3,276 @@
* Handles different game modes (FREE, PAID_SINGLE, PAID_CREDITS)
*/
const DEFAULT_SINGLE_GAME_PRICE = 5000000000; // 0.005 XMR or 0.05 WOW depending on currency decimals
const DEFAULT_CREDITS_PACKAGE_PRICE = 50000000000;
const DEFAULT_DECIMALS = 12;
const parseAtomicEnvValue = (val, fallback) => {
if (val === undefined || val === null || val === '') return fallback;
if (typeof val !== 'string') {
const numVal = Number(val);
return Number.isFinite(numVal) ? Math.trunc(numVal) : fallback;
}
const cleaned = val.replace(/_/g, '').trim();
if (!cleaned) {
return fallback;
}
const num = Number(cleaned);
if (!Number.isFinite(num) || num < 0) {
return fallback;
}
return Math.trunc(num);
};
class GameModeManager {
constructor(databaseManager, walletRPCService, debugManager) {
constructor(databaseManager, walletRPCService, debugManager, paymentConfigManager = null) {
this.db = databaseManager;
this.walletService = walletRPCService; // Changed from moneroPayService
this.debugManager = debugManager;
this.gameMode = process.env.GAME_MODE || 'FREE';
this.cryptoType = process.env.CRYPTO_TYPE || 'XMR';
// Flexible parsing: supports plain integers, scientific notation (e.g. 5e9), and underscores (e.g. 5_000_000_000)
const parseAtomic = (val, fallback) => {
if (val === undefined || val === null || val === '') return fallback;
if (typeof val !== 'string') return Math.trunc(Number(val)) || fallback;
const cleaned = val.replace(/_/g, '').trim();
const num = Number(cleaned);
if (!Number.isFinite(num) || num < 0) return fallback;
// Ensure integer atomic units
return Math.trunc(num);
};
this.paymentConfigManager = paymentConfigManager || null;
this.singleGamePrice = parseAtomic(process.env.SINGLE_GAME_PRICE, 5000000000); // default 0.005 XMR
this.creditsPackagePrice = parseAtomic(process.env.CREDITS_PACKAGE_PRICE, 50000000000); // default 0.05 XMR
this.creditsPayoutEnabled = /^true$/i.test(process.env.CREDITS_PAYOUT_ENABLED || 'false');
console.log(`🎮 Game Mode Manager initialized: ${this.gameMode} mode`);
console.log(`💰 Currency: ${this.cryptoType}`);
const toXMR = (atomic) => (atomic / 1e12).toFixed(6).replace(/0+$/,'').replace(/\.$/,'');
console.log(`💵 Single game price: ${this.singleGamePrice} atomic units (~${toXMR(this.singleGamePrice)} ${this.cryptoType})`);
console.log(`🎫 Credits package price: ${this.creditsPackagePrice} atomic units (~${toXMR(this.creditsPackagePrice)} ${this.cryptoType})`);
this.cryptoType = process.env.CRYPTO_TYPE || 'XMR';
this.currencyDecimals = this.inferCurrencyDecimals(this.cryptoType);
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) {
if (!symbol) return DEFAULT_DECIMALS;
const normalized = symbol.toUpperCase();
if (normalized === 'WOW') return 11;
if (normalized === 'XMR') return 12;
return DEFAULT_DECIMALS;
}
applyLegacyEnvConfig() {
this.setLegacyGameMode(process.env.GAME_MODE || 'FREE');
this.cryptoType = process.env.CRYPTO_TYPE || this.cryptoType;
this.currencyDecimals = this.inferCurrencyDecimals(this.cryptoType);
this.singleGamePrice = parseAtomicEnvValue(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_PAYOUT_ENABLED || 'false');
this.creditsPayoutBaseValue = 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';
}
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.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) {
if (value === undefined || value === null) {
return '0';
}
const decimals = Number.isFinite(this.currencyDecimals) ? this.currencyDecimals : DEFAULT_DECIMALS;
const divisor = Math.pow(10, decimals);
const quotient = Number(value) / divisor;
return Number.isFinite(quotient)
? quotient.toFixed(6).replace(/0+$/, '').replace(/\.$/, '')
: value.toString();
}
getDecimalDivisor() {
const decimals = Number.isFinite(this.currencyDecimals) ? this.currencyDecimals : DEFAULT_DECIMALS;
return Math.pow(10, decimals);
}
formatAtomicHuman(value, digits = 3) {
if (value === undefined || value === null) {
return '0';
}
const divisor = this.getDecimalDivisor();
const quotient = Number(value) / divisor;
if (!Number.isFinite(quotient)) {
return value.toString();
}
return quotient.toFixed(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);
const amount = Math.round(base * multiplier);
return { amount, multiplier, base };
}
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 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 (will pay rewards in PAID_CREDITS).');
}
console.log(`⚙️ Payments enabled: ${this.paymentsEnabled}`);
}
/**
@@ -47,13 +287,19 @@ class GameModeManager {
return { allowed: true, reason: 'Free mode' };
case 'PAID_CREDITS':
if (user.credits > 0) {
return { allowed: true, reason: `${user.credits} credits remaining` };
if (user.credits >= this.creditsPerGameCost) {
return {
allowed: true,
reason: `${user.credits} credits remaining`,
creditsRequired: this.creditsPerGameCost
};
}
return {
allowed: false,
reason: 'No credits remaining',
action: 'purchase_credits'
reason: 'Insufficient credits',
action: 'purchase_credits',
creditsRequired: this.creditsPerGameCost,
balance: user.credits
};
case 'PAID_SINGLE':
@@ -106,20 +352,21 @@ class GameModeManager {
return { success: true };
case 'PAID_CREDITS':
// Deduct one credit
// Deduct required credits for this mode
const creditsToSpend = this.creditsPerGameCost;
const updateRes = await this.db.query(`
UPDATE users
SET credits = credits - 1,
SET credits = credits - $1,
total_games_played = total_games_played + 1,
updated_at = NOW()
WHERE id = $1
WHERE id = $2
RETURNING credits
`, [user.id]);
const remainingCredits = updateRes.rows[0] ? updateRes.rows[0].credits : (user.credits - 1);
`, [creditsToSpend, user.id]);
const remainingCredits = updateRes.rows[0] ? updateRes.rows[0].credits : (user.credits - creditsToSpend);
// Emit credits update asynchronously if an io ref was injected later (pattern: attach externally)
try { this.io && this.io.to(socketId).emit('credits_update', { balance: remainingCredits }); } catch(_) {}
console.log(`🎫 Deducted 1 credit from user ${user.id}, ${remainingCredits} remaining`);
return { success: true, creditsRemaining: remainingCredits };
console.log(`🎫 Deducted ${creditsToSpend} credit(s) from user ${user.id}, ${remainingCredits} remaining`);
return { success: true, creditsRemaining: remainingCredits, creditsSpent: creditsToSpend };
case 'PAID_SINGLE':
// Link game to payment
@@ -193,22 +440,20 @@ class GameModeManager {
WHERE id = $4
`, [outcome === 'escaped' ? 'won' : 'lost', outcome, treasureFound, gameId]);
// Handle payouts for PAID_SINGLE or (optionally) PAID_CREDITS mode
const payoutEligibleMode = (this.gameMode === 'PAID_SINGLE') || (this.gameMode === 'PAID_CREDITS' && this.creditsPayoutEnabled);
const recordedMode = (game.payment_mode || game.game_mode || this.gameMode || 'FREE').toUpperCase();
const payoutEligibleMode = (recordedMode === 'PAID_SINGLE') || (recordedMode === 'PAID_CREDITS' && this.creditsPayoutEnabled);
if (payoutEligibleMode && outcome === 'escaped' && game.payout_address) {
let payoutAmount;
const base = (this.gameMode === 'PAID_CREDITS') ? this.singleGamePrice : this.singleGamePrice; // could make distinct; reuse singleGamePrice as base
payoutAmount = treasureFound ? base * 3 : base * 2;
const { amount: payoutAmount, multiplier } = this.calculatePayout(recordedMode, { treasureFound });
if (payoutAmount > 0) {
await this.walletService.processPayout(
game.user_id,
gameId,
game.payout_address,
payoutAmount
);
// Create payout record
await this.walletService.processPayout(
game.user_id,
gameId,
game.payout_address,
payoutAmount
);
console.log(`💸 Created payout: ${payoutAmount} atomic units for game ${gameId}`);
console.log(`💸 Created payout: ${payoutAmount} atomic units for game ${gameId} (multiplier ${multiplier}x)`);
}
}
// Update user statistics
@@ -241,19 +486,25 @@ class GameModeManager {
try {
const user = await this.getOrCreateUser(socketId);
let amount, description;
let amount;
let description;
let packageInfo = null;
switch (paymentType) {
case 'single_game':
case 'single_game': {
amount = this.singleGamePrice;
description = `Wowngeon single game entry (${this.cryptoType})`;
break;
case 'credits_package':
amount = this.creditsPackagePrice;
description = `Wowngeon 10 credits package (${this.cryptoType})`;
}
case 'credits_package': {
const primaryPackage = this.getPrimaryCreditPackage();
const packagePrice = primaryPackage?.price ?? this.creditsPackagePrice;
amount = Number(packagePrice);
packageInfo = primaryPackage;
const creditCount = primaryPackage?.credits ?? 10;
description = `Wowngeon ${creditCount} credits package (${this.cryptoType})`;
break;
}
default:
throw new Error('Invalid payment type');
}
@@ -280,9 +531,10 @@ class GameModeManager {
id: paymentResult.id,
address: paymentResult.address,
amount: amount,
amountFormatted: (amount / 1e12).toFixed(4), // Convert from atomic units
amountFormatted: this.formatAtomicHuman(amount, 4),
currency: this.cryptoType,
expiresAt: paymentResult.expiresAt
expiresAt: paymentResult.expiresAt,
package: packageInfo
};
} catch (error) {
@@ -389,10 +641,14 @@ class GameModeManager {
cryptoType: this.cryptoType,
singleGamePrice: this.singleGamePrice,
creditsPackagePrice: this.creditsPackagePrice,
creditsPerGame: this.creditsPerGameCost,
paymentsEnabled: this.paymentsEnabled,
directModeEnabled: this.directModeEnabled,
creditsModeEnabled: this.creditsModeEnabled,
features: {
paymentRequired: this.gameMode !== 'FREE',
creditsSystem: this.gameMode === 'PAID_CREDITS',
payouts: this.gameMode === 'PAID_SINGLE'
paymentRequired: this.paymentsEnabled,
creditsSystem: this.creditsModeEnabled,
payouts: this.directPayoutMultipliers.escape > 0 || this.creditPayoutMultipliers.escape > 0
}
};
}
@@ -429,9 +685,7 @@ class GameModeManager {
// Handle payouts only in PAID_SINGLE mode and only on win (escaped)
const payoutEligibleStartMode = (this.gameMode === 'PAID_SINGLE') || (this.gameMode === 'PAID_CREDITS' && this.creditsPayoutEnabled);
if (payoutEligibleStartMode && won) {
// Derive payout multiplier
const multiplier = treasureFound ? 3 : 2;
const payoutAmount = this.singleGamePrice * multiplier;
const { amount: payoutAmount, multiplier } = this.calculatePayout(this.gameMode, { treasureFound });
// Look up user record for payout address
const userResult = await this.db.query(`SELECT * FROM users WHERE socket_id = $1 LIMIT 1`, [socketId]);
+67 -14
View File
@@ -13,12 +13,20 @@ const DatabaseManager = require('./db/databaseManager');
const WalletRPCService = require('./payments/walletRPCService');
const GameModeManager = require('./game/gameModeManager');
const RpcService = require('./rpc/rpcService');
const PaymentConfigManager = require('./config/paymentConfig');
const EnvironmentValidator = require('./config/environmentValidator');
// Import modular components
const BroadcastManager = require('./network/broadcastManager');
const DebugManager = require('./debug/debugManager');
const SocketHandlers = require('./network/socketHandlers');
// Initialize payment configuration
const paymentConfigManager = new PaymentConfigManager({ logger: console });
const environmentValidator = new EnvironmentValidator({ logger: console });
environmentValidator.validate(paymentConfigManager.getConfig());
process.env.GAME_MODE = paymentConfigManager.getLegacyGameMode();
// Initialize modular components first
const broadcastManager = new BroadcastManager(io);
const debugManager = new DebugManager(broadcastManager);
@@ -27,7 +35,7 @@ const debugManager = new DebugManager(broadcastManager);
const databaseManager = new DatabaseManager();
const rpcService = new RpcService();
const walletRPCService = new WalletRPCService(debugManager);
const gameModeManager = new GameModeManager(databaseManager, walletRPCService, debugManager);
const gameModeManager = new GameModeManager(databaseManager, walletRPCService, debugManager, paymentConfigManager);
// Provide io reference so GameModeManager can emit events (e.g., credits_update)
gameModeManager.io = io;
@@ -99,18 +107,41 @@ app.get('/api/user/:userId/credits', async (req, res) => {
});
app.get('/api/game-modes', (req, res) => {
const config = paymentConfigManager.getConfig();
const decimals = Number(config.currency?.decimals ?? 12);
const divisor = Number.isFinite(decimals) ? Math.pow(10, decimals) : 1;
const toDisplay = (value) => {
if (value === null || value === undefined) {
return 0;
}
const numeric = typeof value === 'bigint' ? Number(value) : Number(value);
return Number.isFinite(numeric) ? numeric / divisor : 0;
};
const directMode = config.modes.direct;
const creditsMode = config.modes.credits;
res.json({
FREE: { name: 'Free Play', cost: 0, payoutMultiplier: 0 },
PAID_SINGLE: {
name: 'Paid Single Game',
cost: parseFloat(process.env.SINGLE_GAME_COST || '0.005'),
payoutMultiplier: { escape: 2, treasure: 3 }
FREE: {
name: 'Free Play',
cost: 0,
payoutMultiplier: 0,
enabled: !config.paymentsEnabled
},
PAID_CREDITS: {
name: 'Credits Package',
cost: parseFloat(process.env.CREDITS_PACKAGE_COST || '0.03'),
credits: parseInt(process.env.CREDITS_PER_PACKAGE || '10'),
payoutMultiplier: 0
PAID_SINGLE: {
name: 'Paid Single Game',
cost: toDisplay(directMode.price),
enabled: !!directMode.enabled,
payoutMultiplier: config.payouts.rules.direct.multipliers
},
PAID_CREDITS: {
name: 'Credits Package',
cost: toDisplay(creditsMode.packages?.[0]?.price ?? 0),
credits: creditsMode.packages?.[0]?.credits ?? creditsMode.creditsPerGame,
enabled: !!creditsMode.enabled,
payoutMultiplier: creditsMode.enabled && config.payouts.rules.credits.enabled
? config.payouts.rules.credits.multipliers
: 0
}
});
});
@@ -131,6 +162,11 @@ async function initializePaymentSystem() {
if (!walletInitialized) {
console.log('⚠️ Wallet RPC not available - falling back to FREE mode');
process.env.GAME_MODE = 'FREE';
paymentConfigManager.refresh();
gameModeManager.setLegacyGameMode('FREE');
gameModeManager.paymentsEnabled = false;
gameModeManager.directModeEnabled = false;
gameModeManager.creditsModeEnabled = false;
}
// Initialize database connection and run migrations
@@ -186,18 +222,35 @@ async function startServer() {
console.log('🚀 Wowngeon server listening on *:3000');
console.log(`🐛 Debug mode: ${debugManager.getDebugStatus().debugMode ? 'ENABLED' : 'DISABLED'}`);
console.log(`💰 Payment system: ${paymentSystemReady ? 'ENABLED' : 'FREE MODE ONLY'}`);
console.log(`🎮 Available game modes: ${paymentSystemReady ? 'FREE, PAID_SINGLE, PAID_CREDITS' : 'FREE ONLY'}`);
const summary = paymentConfigManager.summarize();
const enabledModes = [];
if (!summary.paymentsEnabled || !paymentSystemReady) {
enabledModes.push('FREE');
}
if (summary.directEnabled && paymentSystemReady) {
enabledModes.push('PAID_SINGLE');
}
if (summary.creditsEnabled && paymentSystemReady) {
enabledModes.push('PAID_CREDITS');
}
if (enabledModes.length === 0) {
enabledModes.push(summary.legacyMode || 'FREE');
}
console.log(`🎮 Available game modes: ${enabledModes.join(', ')}`);
});
// Start batch payout processing if payment system is ready
if (paymentSystemReady) {
if (paymentSystemReady) {
const payoutConfig = paymentConfigManager.getConfig().payouts?.processing || {};
const payoutIntervalSeconds = Math.max(1, Number(payoutConfig.batchInterval || 300));
const payoutIntervalMs = payoutIntervalSeconds * 1000;
setInterval(async () => {
try {
await walletRPCService.processBatchPayouts();
} catch (error) {
console.error('Error in batch payout processing:', error);
}
}, 300000); // Every 5 minutes
}, payoutIntervalMs);
}
} catch (error) {
@@ -0,0 +1,149 @@
-- Migration 005: Unified payment system foundation
-- Extend payments table to track unified payment metadata
ALTER TABLE payments
ADD COLUMN IF NOT EXISTS payment_mode VARCHAR(20);
ALTER TABLE payments
ALTER COLUMN payment_mode SET DEFAULT 'direct';
UPDATE payments
SET payment_mode = 'direct'
WHERE payment_mode IS NULL;
ALTER TABLE payments
ADD COLUMN IF NOT EXISTS credit_package_id VARCHAR(20);
ALTER TABLE payments
ADD COLUMN IF NOT EXISTS credits_purchased INTEGER DEFAULT 0;
UPDATE payments
SET credits_purchased = 0
WHERE credits_purchased IS NULL;
-- Extend user profile with payment preferences and stats
ALTER TABLE users
ADD COLUMN IF NOT EXISTS preferred_payment_mode VARCHAR(20) DEFAULT 'direct';
UPDATE users
SET preferred_payment_mode = 'direct'
WHERE preferred_payment_mode IS NULL;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS total_credits_purchased INTEGER DEFAULT 0;
UPDATE users
SET total_credits_purchased = 0
WHERE total_credits_purchased IS NULL;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS total_payouts_received BIGINT DEFAULT 0;
UPDATE users
SET total_payouts_received = 0
WHERE total_payouts_received IS NULL;
-- Ensure total_games_played always has a sensible default
ALTER TABLE users
ALTER COLUMN total_games_played SET DEFAULT 0;
UPDATE users
SET total_games_played = 0
WHERE total_games_played IS NULL;
-- Ensure credit_transactions table exists for installs that missed migration 004
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'credit_transactions'
) THEN
CREATE TABLE credit_transactions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
amount INTEGER NOT NULL,
reason VARCHAR(100) NOT NULL,
balance_after INTEGER NOT NULL,
transaction_type VARCHAR(20) DEFAULT 'legacy',
created_at TIMESTAMP DEFAULT NOW(),
metadata JSONB
);
END IF;
END $$;
-- Tag credit transactions with a transaction type
ALTER TABLE credit_transactions
ADD COLUMN IF NOT EXISTS transaction_type VARCHAR(20);
UPDATE credit_transactions
SET transaction_type = COALESCE(transaction_type, 'legacy');
ALTER TABLE credit_transactions
ALTER COLUMN transaction_type SET DEFAULT 'legacy';
CREATE INDEX IF NOT EXISTS idx_credit_transactions_user_id ON credit_transactions(user_id);
CREATE INDEX IF NOT EXISTS idx_credit_transactions_created_at ON credit_transactions(created_at);
-- Create payouts table if it was dropped in the past (fresh installs)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'payouts'
) THEN
CREATE TABLE payouts (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
game_id INTEGER REFERENCES games(id) ON DELETE CASCADE,
payout_address VARCHAR(110) NOT NULL,
amount BIGINT NOT NULL,
multiplier DECIMAL(3,1) DEFAULT 0,
reason VARCHAR(50) DEFAULT 'legacy' NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
batch_id VARCHAR(50),
tx_hash VARCHAR(64),
fee BIGINT,
created_at TIMESTAMP DEFAULT NOW(),
processed_at TIMESTAMP,
error_message TEXT
);
END IF;
END $$;
-- Add new payout tracking columns for existing installs
ALTER TABLE payouts
ADD COLUMN IF NOT EXISTS multiplier DECIMAL(3,1) DEFAULT 0;
UPDATE payouts
SET multiplier = 0
WHERE multiplier IS NULL;
ALTER TABLE payouts
ALTER COLUMN multiplier SET NOT NULL;
ALTER TABLE payouts
ADD COLUMN IF NOT EXISTS reason VARCHAR(50) DEFAULT 'legacy';
UPDATE payouts
SET reason = 'legacy'
WHERE reason IS NULL;
ALTER TABLE payouts
ALTER COLUMN reason SET NOT NULL;
ALTER TABLE payouts
ADD COLUMN IF NOT EXISTS error_message TEXT;
-- Align payout address length with Wownero expectations
DROP VIEW IF EXISTS pending_payouts;
ALTER TABLE payouts
ALTER COLUMN payout_address TYPE VARCHAR(110);
CREATE OR REPLACE VIEW pending_payouts AS
SELECT
p.id,
p.user_id,
u.username,
p.payout_address,
p.amount,
p.status,
p.created_at,
g.outcome AS game_outcome
FROM payouts p
JOIN users u ON p.user_id = u.id
LEFT JOIN games g ON p.game_id = g.id
WHERE p.status = 'pending'
ORDER BY p.created_at ASC;
-- Add helpful indexes for new queries
CREATE INDEX IF NOT EXISTS idx_payouts_status ON payouts(status);
CREATE INDEX IF NOT EXISTS idx_payouts_user_id ON payouts(user_id);
CREATE INDEX IF NOT EXISTS idx_payments_payment_mode ON payments(payment_mode);
+55 -9
View File
@@ -50,6 +50,9 @@ class PaymentHandlers {
paymentId: paymentRequest.id,
address: paymentRequest.address,
amount: paymentRequest.amount,
amountFormatted: paymentRequest.amountFormatted,
currency: this.gameModeManager.cryptoType,
package: paymentRequest.package,
gameMode: gameMode
});
if (this.debugManager.CONSOLE_LOGGING) console.log(`💳 Payment request created for ${socket.id}: ${paymentRequest.amount}`);
@@ -81,17 +84,60 @@ class PaymentHandlers {
// caller (SocketHandlers) will handle user existence; keep method generic
const gameMode = this.gameModeManager.gameMode;
const cryptoType = this.gameModeManager.cryptoType;
let paymentType, amount, description;
if (gameMode === 'PAID_SINGLE') { paymentType = 'single_game'; amount = this.gameModeManager.singleGamePrice; description = 'Single game entry'; }
else if (gameMode === 'PAID_CREDITS') { paymentType = 'credits_package'; amount = this.gameModeManager.creditsPackagePrice; description = '10 game credits package'; }
else { this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Invalid game mode configuration.'); return; }
let paymentType;
let amount;
let description;
if (gameMode === 'PAID_SINGLE') {
paymentType = 'single_game';
amount = this.gameModeManager.singleGamePrice;
description = 'Single game entry';
} else if (gameMode === 'PAID_CREDITS') {
paymentType = 'credits_package';
const primaryPackage = this.gameModeManager.getPrimaryCreditPackage();
amount = Number(primaryPackage?.price ?? this.gameModeManager.creditsPackagePrice);
const credits = primaryPackage?.credits ?? this.gameModeManager.creditsPerGameCost * 10;
description = `${credits} credit package`;
} else {
this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Invalid game mode configuration.');
return;
}
const paymentRequest = await this.gameModeManager.createPaymentRequest(socket.id, paymentType);
let qrDataUrl = null; try { const { generatePaymentQR } = require('../payments/qrService'); qrDataUrl = await generatePaymentQR(paymentRequest.address, amount, cryptoType, description); } catch(e) {}
const humanAmount = (amount / 1000000000000).toFixed(3);
this.io.to(socket.id).emit('payment_created', { paymentId: paymentRequest.id, address: paymentRequest.address, amount: paymentRequest.amount, paymentType, gameMode, cryptoType, humanAmount, description, expiresAt: paymentRequest.expiresAt, qr: qrDataUrl });
this.broadcastManager.sendStatusUpdate(socket.id, 'payment', `💳 PAYMENT REQUIRED (${description})\n\nAmount: ${humanAmount} ${cryptoType}\nAddress: ${paymentRequest.address}\n\n⚠️ Send EXACTLY ${humanAmount} ${cryptoType}.\n🔄 Added to queue once mempool seen.\n⏰ Expires in 30 minutes.`);
this._monitorAddress(socket, paymentRequest, amount, cryptoType, currentUser);
const formattedAmount = paymentRequest.amountFormatted ?? this.gameModeManager.formatAtomicHuman(amount, 3);
let qrDataUrl = null;
try {
const { generatePaymentQR } = require('../payments/qrService');
qrDataUrl = await generatePaymentQR(
paymentRequest.address,
paymentRequest.amount,
cryptoType,
description,
this.gameModeManager.currencyDecimals
);
} catch (e) {}
this.io.to(socket.id).emit('payment_created', {
paymentId: paymentRequest.id,
address: paymentRequest.address,
amount: paymentRequest.amount,
amountFormatted: formattedAmount,
paymentType,
gameMode,
cryptoType,
description,
expiresAt: paymentRequest.expiresAt,
qr: qrDataUrl,
package: paymentRequest.package
});
this.broadcastManager.sendStatusUpdate(
socket.id,
'payment',
`💳 PAYMENT REQUIRED (${description})\n\nAmount: ${formattedAmount} ${cryptoType}\nAddress: ${paymentRequest.address}\n\n⚠️ Send EXACTLY ${formattedAmount} ${cryptoType}.\n🔄 Added to queue once mempool seen.\n⏰ Expires in 30 minutes.`
);
this._monitorAddress(socket, paymentRequest, paymentRequest.amount, cryptoType, currentUser);
} catch (e) {
console.error('Error creating payment request:', e);
this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Failed to create payment request.');
+7 -4
View File
@@ -8,15 +8,18 @@ try {
QR = null;
}
function formatAmount(atomic) {
function formatAmount(atomic, decimals = 12) {
if (!atomic || atomic <= 0) return '0';
return (atomic / 1e12).toFixed(12).replace(/0+$/,'').replace(/\.$/,'');
const divisor = Math.pow(10, decimals);
return (Number(atomic) / divisor).toFixed(decimals)
.replace(/0+$/, '')
.replace(/\.$/, '');
}
async function generatePaymentQR(address, amountAtomic, cryptoType, description) {
async function generatePaymentQR(address, amountAtomic, cryptoType, description, decimals = 12) {
if (!QR) return null; // Library not installed
try {
const amountStr = formatAmount(amountAtomic);
const amountStr = formatAmount(amountAtomic, decimals);
const scheme = cryptoType && cryptoType.toUpperCase() === 'WOW' ? 'wownero' : 'monero';
// Basic URI; wallets usually understand amount (tx_amount) & description
const params = new URLSearchParams();