diff --git a/src/.env.mainnet.example b/src/.env.mainnet.example index 5f3f12a..0b55a67 100644 --- a/src/.env.mainnet.example +++ b/src/.env.mainnet.example @@ -37,6 +37,10 @@ PAID_ACKNOWLEDGEMENT_REQUIRED=true # RESTRICTED_LOCATIONS_NOTICE= # Free runs rank on the Pleb board; purchased credits rank on the paid prestige board. +# To pause the official service's financial intake and credit play, explicitly set +# PAYMENTS_ENABLED=false, GAME_MODE=FREE, PAYMENT_MODES= (empty), CREDITS_ENABLED=false, +# EARLY_ENTRY_CREDITS_MODE=false, and leave both financial-event sink fields empty. +# Keep every payout and paid-match selector false. Production preflight checks the full contract. PAYMENTS_ENABLED=true FREE_PLAY_ENABLED=true GAME_MODE=PAID_CREDITS diff --git a/src/config/commerceDisclosurePolicy.js b/src/config/commerceDisclosurePolicy.js index 1c9f013..c5d3bcb 100644 --- a/src/config/commerceDisclosurePolicy.js +++ b/src/config/commerceDisclosurePolicy.js @@ -8,7 +8,8 @@ const { SOURCE_SOFTWARE, - getOperatedProductProfile + getOperatedProductProfile, + isWowPaymentsPaused } = require('./operatedProductProfiles'); const DEFAULT_POLICY_VERSION = '2026-07-22'; @@ -21,6 +22,7 @@ const ACKNOWLEDGEMENT_KEYS = new Set([ 'riskAccepted', 'testnetUnderstood' ]); +const WOW_PAYMENTS_PAUSED_NOTICE = 'Payments and prestige credit entry are currently unavailable. Free play remains available.'; function isTrue(value) { return ['true', '1', 'yes', 'on'].includes(String(value || '').trim().toLowerCase()); @@ -78,12 +80,20 @@ function paidAcknowledgementRequired(env, gameModeManager) { } function buildCommerceDisclosure(gameModeManager, env = process.env) { - const operatedProduct = getOperatedProductProfile(env); + const profile = getOperatedProductProfile(env); + const pausedWow = isWowPaymentsPaused(env); + const operatedProduct = pausedWow + ? Object.freeze({ + ...profile, + commerceSummary: WOW_PAYMENTS_PAUSED_NOTICE, + availabilityNotice: WOW_PAYMENTS_PAUSED_NOTICE + }) + : profile; const cryptoType = String(gameModeManager?.cryptoType || env.CRYPTO_TYPE || 'WOW').toUpperCase(); const network = String(gameModeManager?.network || env.MONERO_NETWORK || 'mainnet').toLowerCase(); const isTestNetwork = Boolean(gameModeManager?.isTestNetwork) || (cryptoType === 'XMR' && network !== 'mainnet'); - const paymentsEnabled = Boolean(gameModeManager?.paymentsEnabled); + const paymentsEnabled = !pausedWow && Boolean(gameModeManager?.paymentsEnabled); const directEnabled = paymentsEnabled && Boolean(gameModeManager?.directModeEnabled); const creditsEnabled = paymentsEnabled && Boolean(gameModeManager?.creditsModeEnabled); // A stale subordinate payout flag for a disabled product must not be advertised as an active @@ -92,7 +102,7 @@ function buildCommerceDisclosure(gameModeManager, env = process.env) { && Boolean(gameModeManager?.isPayoutEnabledForMode?.('PAID_SINGLE')); const creditsPayouts = creditsEnabled && Boolean(gameModeManager?.isPayoutEnabledForMode?.('PAID_CREDITS')); - const cryptoMatchPayouts = isTrue(env.MATCH_ENABLED) + const cryptoMatchPayouts = !pausedWow && isTrue(env.MATCH_ENABLED) && isTrue(env.MATCH_CRYPTO_RACE_ENABLED) && isTrue(env.MATCH_PAYOUTS_ENABLED) && Boolean(gameModeManager?.payoutsEnabled); diff --git a/src/config/operatedProductProfiles.js b/src/config/operatedProductProfiles.js index aeb5033..b21642b 100644 --- a/src/config/operatedProductProfiles.js +++ b/src/config/operatedProductProfiles.js @@ -75,12 +75,19 @@ function getOperatedProductProfile(env = process.env) { return PROFILES[selectedProfileId(env)] || null; } +function isWowPaymentsPaused(env = process.env) { + return selectedProfileId(env) === OPERATED_PRODUCT_PROFILE_IDS.WOW_PRESTIGE + && isExplicitFalse(env.PAYMENTS_ENABLED); +} + function selectPublicPaidModeDescriptors(env = process.env, descriptors = {}) { const profile = getOperatedProductProfile(env); if (!profile) { return { ...descriptors }; } + if (isWowPaymentsPaused(env)) return {}; + const descriptor = descriptors[profile.gameMode]; return descriptor === undefined ? {} : { [profile.gameMode]: descriptor }; } @@ -144,10 +151,13 @@ function validateOperatedProductProfile(env = process.env, config = {}) { `LEGAL_POLICY_VERSION=${profile.legalPolicyVersion}`); requireContract(String(env.TERMS_EFFECTIVE_DATE || '').trim() === profile.termsEffectiveDate, `TERMS_EFFECTIVE_DATE=${profile.termsEffectiveDate}`); - requireContract(isExplicitTrue(env.PAYMENTS_ENABLED) && config?.paymentsEnabled === true, - 'PAYMENTS_ENABLED=true'); - requireContract(String(env.GAME_MODE || '').trim() === profile.gameMode, - `GAME_MODE=${profile.gameMode}`); + const pausedWow = isWowPaymentsPaused(env); + requireContract(pausedWow + ? config?.paymentsEnabled === false + : isExplicitTrue(env.PAYMENTS_ENABLED) && config?.paymentsEnabled === true, + pausedWow ? 'PAYMENTS_ENABLED=false with payments disabled' : 'PAYMENTS_ENABLED=true'); + requireContract(String(env.GAME_MODE || '').trim() === (pausedWow ? 'FREE' : profile.gameMode), + `GAME_MODE=${pausedWow ? 'FREE' : profile.gameMode}`); requireContract(isExplicitTrue(env.PAID_ACKNOWLEDGEMENT_REQUIRED), 'PAID_ACKNOWLEDGEMENT_REQUIRED=true'); requireContract(isExplicitTrue(env.FREE_PLAY_ENABLED), 'FREE_PLAY_ENABLED=true'); @@ -162,12 +172,27 @@ function validateOperatedProductProfile(env = process.env, config = {}) { if (id === OPERATED_PRODUCT_PROFILE_IDS.WOW_PRESTIGE) { requireContract(cryptoType === 'WOW', 'CRYPTO_TYPE=WOW'); requireContract(network === 'mainnet', 'MONERO_NETWORK=mainnet'); - requireContract(String(env.PAYMENT_MODES || '').trim().toLowerCase() === 'credits', - 'PAYMENT_MODES=credits'); + if (pausedWow) { + requireContract(Object.prototype.hasOwnProperty.call(env, 'PAYMENT_MODES') + && String(env.PAYMENT_MODES).trim() === '', 'explicit empty PAYMENT_MODES'); + requireContract(isExplicitFalse(env.CREDITS_ENABLED) + && config?.modes?.credits?.enabled === false, 'CREDITS_ENABLED=false'); + requireContract(!env.CREDITS_PAYOUT_ENABLED || isExplicitFalse(env.CREDITS_PAYOUT_ENABLED), + 'CREDITS_PAYOUT_ENABLED=false when set'); + requireContract(isExplicitFalse(env.EARLY_ENTRY_CREDITS_MODE), + 'EARLY_ENTRY_CREDITS_MODE=false'); + requireContract(String(env.FINANCIAL_EVENT_SINK_URL || '').trim() === '', + 'empty FINANCIAL_EVENT_SINK_URL'); + requireContract(String(env.FINANCIAL_EVENT_SINK_TOKEN || '').trim() === '', + 'empty FINANCIAL_EVENT_SINK_TOKEN'); + } else { + requireContract(String(env.PAYMENT_MODES || '').trim().toLowerCase() === 'credits', + 'PAYMENT_MODES=credits'); + requireContract(isExplicitTrue(env.CREDITS_ENABLED) + && config?.modes?.credits?.enabled === true, 'CREDITS_ENABLED=true'); + } requireContract(isExplicitFalse(env.DIRECT_PAYMENT_ENABLED) && config?.modes?.direct?.enabled === false, 'DIRECT_PAYMENT_ENABLED=false'); - requireContract(isExplicitTrue(env.CREDITS_ENABLED) - && config?.modes?.credits?.enabled === true, 'CREDITS_ENABLED=true'); requireContract(isExplicitFalse(env.PAYOUTS_ENABLED) && config?.payouts?.enabled === false, 'PAYOUTS_ENABLED=false'); requireContract(isExplicitFalse(env.DIRECT_PAYOUTS_ENABLED) @@ -204,6 +229,7 @@ module.exports = { OPERATED_PRODUCT_PROFILE_IDS, SOURCE_SOFTWARE, getOperatedProductProfile, + isWowPaymentsPaused, selectPublicPaidModeDescriptors, selectedProfileId, validateOperatedProductProfile diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index 9c9f4fb..af0f8d9 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -24,7 +24,7 @@ const { listMatchRulesets, resolveMatchRuleset } = require('./rulesets'); const { matchPayoutAdmissionPolicy } = require('../network/matchEconomyPolicy'); const { isSmirkEnabled } = require('../auth/smirkPolicy'); const { reservePayoutCapacity } = require('../services/payoutAdmissionService'); -const { getOperatedProductProfile } = require('../config/operatedProductProfiles'); +const { getOperatedProductProfile, isWowPaymentsPaused } = require('../config/operatedProductProfiles'); // 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 @@ -350,6 +350,7 @@ class GameModeManager { * enforce it again in createPaymentRequest for non-Socket.IO callers. */ isPaymentIntakeEnabled(paymentType) { + if (isWowPaymentsPaused(process.env)) return false; if (!this._isFinancialAdmissionAllowed()) return false; if (!this.paymentsEnabled) return false; if (paymentType === 'single_game') return Boolean(this.directModeEnabled); @@ -662,6 +663,7 @@ class GameModeManager { _getMatchEconomies() { const out = { free: true }; + if (isWowPaymentsPaused(process.env)) return out; if (this.creditsModeEnabled) { out.credits_prestige = true; } @@ -1232,16 +1234,17 @@ class GameModeManager { * @returns {object} { mode, canUseCredits, canUseDirect, hasCredits, creditsBalance } */ getEffectiveModeForUser(user) { + const pausedWow = isWowPaymentsPaused(process.env); const hasCredits = (user?.credits || 0) >= this.creditsPerGameCost; - const bothModesEnabled = this.directModeEnabled && this.creditsModeEnabled; + const bothModesEnabled = !pausedWow && this.directModeEnabled && this.creditsModeEnabled; // Determine available options - const canUseCredits = this.creditsModeEnabled && hasCredits; - const canUseDirect = this.directModeEnabled; + const canUseCredits = !pausedWow && this.creditsModeEnabled && hasCredits; + const canUseDirect = !pausedWow && this.directModeEnabled; // Determine effective mode let effectiveMode; - if (!this.paymentsEnabled) { + if (!this.paymentsEnabled || pausedWow) { effectiveMode = 'FREE'; } else if (bothModesEnabled) { // Mixed mode: prefer based on config and availability @@ -1269,13 +1272,13 @@ class GameModeManager { creditsRequired: this.creditsPerGameCost, bothModesEnabled, preferCreditsFirst: this.preferCreditsFirst, - creditsPayoutsEnabled: this.isPayoutEnabledForMode('PAID_CREDITS'), - directPayoutsEnabled: this.isPayoutEnabledForMode('PAID_SINGLE') + creditsPayoutsEnabled: !pausedWow && this.isPayoutEnabledForMode('PAID_CREDITS'), + directPayoutsEnabled: !pausedWow && this.isPayoutEnabledForMode('PAID_SINGLE') }; } getPublicPreferredPaymentMode(user) { - if (!this.paymentsEnabled) { + if (!this.paymentsEnabled || isWowPaymentsPaused(process.env)) { return 'free'; } @@ -1344,7 +1347,7 @@ class GameModeManager { }); } - if (this.creditsModeEnabled) { + if (this.creditsModeEnabled && !isWowPaymentsPaused(process.env)) { const pkg = this.getPrimaryCreditPackage(); options.push({ type: 'buy_credits', @@ -2420,7 +2423,8 @@ class GameModeManager { : null; // Serialize atomic prices as exact numbers when safe, decimal strings otherwise. - const packages = this.configSnapshot?.modes?.credits?.packages || []; + const pausedWow = isWowPaymentsPaused(process.env); + const packages = pausedWow ? [] : (this.configSnapshot?.modes?.credits?.packages || []); const creditPackages = packages.map(pkg => ({ id: pkg.id, label: pkg.label || pkg.id, @@ -2435,7 +2439,7 @@ class GameModeManager { 2 ) })); - const cosmeticProducts = this.getCosmeticProducts().map(product => ({ + const cosmeticProducts = (pausedWow ? [] : this.getCosmeticProducts()).map(product => ({ id: product.id, label: product.label || product.id, price: money.toSafe(money.toBig(product.price)), @@ -2453,7 +2457,7 @@ class GameModeManager { commit: null }, operatedProductProfileId: getOperatedProductProfile(process.env)?.id || null, - mode: this.gameMode, + mode: pausedWow ? 'FREE' : this.gameMode, freePlayEnabled: !!this.freePlayEnabled, // players may choose free play even on a paid instance cryptoType: this.cryptoType, currencyLabel: this.currencyLabel, // sXMR on stagenet, XMR on mainnet, WOW for Wownero @@ -2461,40 +2465,41 @@ class GameModeManager { network: this.network, isTestNetwork: this.isTestNetwork, testnetWarning: testnetWarning, - singleGamePrice: this.singleGamePrice, - singleGamePriceFormatted: this.formatAtomicHuman(this.singleGamePrice, 2), - creditsPackagePrice: this.creditsPackagePrice, - creditsPerGame: this.creditsPerGameCost, + singleGamePrice: pausedWow ? 0 : this.singleGamePrice, + singleGamePriceFormatted: pausedWow ? null : this.formatAtomicHuman(this.singleGamePrice, 2), + creditsPackagePrice: pausedWow ? 0 : this.creditsPackagePrice, + creditsPerGame: pausedWow ? 0 : this.creditsPerGameCost, creditPackages: creditPackages, cosmeticProducts: cosmeticProducts, - creditsPayoutBaseValue: this.creditsPayoutBaseValue, - paymentsEnabled: this.paymentsEnabled, + creditsPayoutBaseValue: pausedWow ? 0 : this.creditsPayoutBaseValue, + paymentsEnabled: pausedWow ? false : this.paymentsEnabled, freePlayEnabled: this.freePlayEnabled, - directModeEnabled: this.directModeEnabled, - creditsModeEnabled: this.creditsModeEnabled, - payoutsEnabled: this.payoutsEnabled, - directPayoutsEnabled: this.isPayoutEnabledForMode('PAID_SINGLE'), - creditsPayoutsEnabled: this.isPayoutEnabledForMode('PAID_CREDITS'), - payoutMultipliers: { + directModeEnabled: pausedWow ? false : this.directModeEnabled, + creditsModeEnabled: pausedWow ? false : this.creditsModeEnabled, + payoutsEnabled: pausedWow ? false : this.payoutsEnabled, + directPayoutsEnabled: pausedWow ? false : this.isPayoutEnabledForMode('PAID_SINGLE'), + creditsPayoutsEnabled: pausedWow ? false : this.isPayoutEnabledForMode('PAID_CREDITS'), + payoutMultipliers: pausedWow ? { direct: null, credits: null } : { direct: this.getImplementedPayoutMultipliersForMode('PAID_SINGLE'), credits: this.getImplementedPayoutMultipliersForMode('PAID_CREDITS') }, // Exact server-calculated amounts. The browser must never recreate money promises // with floating point multiplier arithmetic. - payoutOutcomes: { + payoutOutcomes: pausedWow ? { direct: null, credits: null } : { direct: this._publicPayoutOutcomes('PAID_SINGLE'), credits: this._publicPayoutOutcomes('PAID_CREDITS') }, features: { - paymentRequired: this.paymentsEnabled, - creditsSystem: this.creditsModeEnabled, - payouts: this.isPayoutEnabledForMode('PAID_SINGLE') + paymentRequired: pausedWow ? false : this.paymentsEnabled, + creditsSystem: pausedWow ? false : this.creditsModeEnabled, + payouts: !pausedWow && (this.isPayoutEnabledForMode('PAID_SINGLE') || this.isPayoutEnabledForMode('PAID_CREDITS') + ) }, earlyEntry: { enabled: this.configSnapshot?.earlyEntry?.enabled ?? false, allowInFreeMode: this.configSnapshot?.earlyEntry?.allowInFreeMode ?? false, - allowInCreditsMode: this.configSnapshot?.earlyEntry?.allowInCreditsMode ?? false + allowInCreditsMode: pausedWow ? false : (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. diff --git a/src/network/chatHandler.js b/src/network/chatHandler.js index d581f1c..83c770d 100644 --- a/src/network/chatHandler.js +++ b/src/network/chatHandler.js @@ -25,6 +25,7 @@ class ChatHandler { this.chatHistory = new ChatHistoryManager({ db: db || (gameModeManager?.db), debugManager: debugManager, + gameModeManager, maxHistoryMessages: 50 }); @@ -453,4 +454,4 @@ class ChatHandler { } } -module.exports = ChatHandler; \ No newline at end of file +module.exports = ChatHandler; diff --git a/src/network/chatHistoryManager.js b/src/network/chatHistoryManager.js index eb89b4b..7177e35 100644 --- a/src/network/chatHistoryManager.js +++ b/src/network/chatHistoryManager.js @@ -3,10 +3,16 @@ * Handles persistent storage and retrieval of chat messages */ +const { isWowPaymentsPaused } = require('../config/operatedProductProfiles'); + +const LEGACY_PAYOUT_TIP = 'Escape the dungeon to win. Collect treasure for bonus payout!'; +const NO_PAYOUT_TIP = 'Escape the dungeon to win. Treasure can improve your score.'; + class ChatHistoryManager { - constructor({ db, debugManager, maxHistoryMessages = 50, inMemoryFallbackSize = 100 }) { + constructor({ db, debugManager, gameModeManager = null, maxHistoryMessages = 50, inMemoryFallbackSize = 100 }) { this.db = db; this.debugManager = debugManager; + this.gameModeManager = gameModeManager; this.maxHistoryMessages = maxHistoryMessages; // In-memory fallback for when DB is not available @@ -23,6 +29,25 @@ class ChatHistoryManager { this._maxMessageAge = 7 * 24 * 60 * 60 * 1000; // 7 days in ms } + _presentMessage(message) { + // Migration 011 seeded this one system tip before free/prestige-only policy existed. + // Keep the stored row intact and leave player-authored copies untouched. + if (message.socketId !== 'system' || message.username !== 'SERVER' + || message.type !== 'system' || message.message !== LEGACY_PAYOUT_TIP) return message; + const manager = this.gameModeManager; + if (!manager || typeof manager.isPayoutEnabledForMode !== 'function') return message; + let payoutAvailable = false; + try { + payoutAvailable = !isWowPaymentsPaused(process.env) && manager.paymentsEnabled && ( + (manager.directModeEnabled && manager.isPayoutEnabledForMode('PAID_SINGLE')) + || (manager.creditsModeEnabled && manager.isPayoutEnabledForMode('PAID_CREDITS')) + ); + } catch (_) { + // A failed policy read cannot justify advertising an old payout promise. + } + return payoutAvailable ? message : { ...message, message: NO_PAYOUT_TIP }; + } + /** * Initialize the manager - load recent messages into cache */ @@ -206,7 +231,7 @@ class ChatHistoryManager { LIMIT $1 `, [maxCount, minCount]); - return result.rows.map(row => ({ + return result.rows.map(row => this._presentMessage({ id: row.id, socketId: row.socket_id, username: row.username, @@ -225,13 +250,13 @@ class ChatHistoryManager { if (this._cacheInitialized && this._recentMessagesCache.length > 0) { const activeMessages = this._recentMessagesCache.filter(m => !m.deletedAt); - return activeMessages.slice(-limit); + return activeMessages.slice(-limit).map(message => this._presentMessage(message)); } // Final fallback to in-memory if (this._inMemoryHistory.length > 0) { const activeMessages = this._inMemoryHistory.filter(m => !m.deletedAt); - return activeMessages.slice(-limit); + return activeMessages.slice(-limit).map(message => this._presentMessage(message)); } return []; diff --git a/src/network/matchQueue.js b/src/network/matchQueue.js index 50324c1..709f8d7 100644 --- a/src/network/matchQueue.js +++ b/src/network/matchQueue.js @@ -18,6 +18,7 @@ const { normalizeError } = require('../utils/errors'); const { createFinancialRecoveryError } = require('../utils/financialRecoveryError'); const { matchPayoutAdmissionPolicy } = require('./matchEconomyPolicy'); +const { isWowPaymentsPaused } = require('../config/operatedProductProfiles'); const crypto = require('crypto'); const { PAID_FREEZE_VERSION, buildPaidEntrantFreeze } = require('./matchFairness'); @@ -373,6 +374,7 @@ class MatchQueue { _isEconomyAvailable(economy) { if (!this.enabled || !this._validateEconomy(economy)) return false; + if (economy !== 'free' && isWowPaymentsPaused(process.env)) return false; if (economy === 'crypto_race') { // Never let an injected/legacy queue bypass the crypto admission contract. This is // evaluated on every join/drain so a hot kill-switch takes effect immediately. diff --git a/src/views/legalPages.js b/src/views/legalPages.js index 5537ce7..9b99a34 100644 --- a/src/views/legalPages.js +++ b/src/views/legalPages.js @@ -68,7 +68,9 @@ function operatorAndSoftwareStatus(disclosure) { const operated = disclosure.operatedProduct; const software = disclosure.software || {}; const relationship = operated - ? `

Operated-product scope: ${escapeHtml(operated.scopeNotice)}

${operated.noRealValueNotice + ? `

Operated-product scope: ${escapeHtml(operated.scopeNotice)}

${operated.availabilityNotice + ? `

Current availability: ${escapeHtml(operated.availabilityNotice)}

` + : ''}${operated.noRealValueNotice ? `

${escapeHtml(operated.noRealValueNotice)}

` : ''}

${escapeHtml(software.operatedBoundaryNotice)}

${escapeHtml(software.thirdPartyNotice)}

` : `

Independent deployment: ${escapeHtml(software.thirdPartyNotice)}

${escapeHtml(software.operatedBoundaryNotice)}

`; diff --git a/test/chatHistorySeedPresentation.test.js b/test/chatHistorySeedPresentation.test.js new file mode 100644 index 0000000..c8ee57d --- /dev/null +++ b/test/chatHistorySeedPresentation.test.js @@ -0,0 +1,88 @@ +const ChatHistoryManager = require('../src/network/chatHistoryManager'); + +const seededTip = 'Escape the dungeon to win. Collect treasure for bonus payout!'; +const seededRow = Object.freeze({ + id: 1, socket_id: 'system', username: 'SERVER', player_id: 'SERVER', + message_type: 'system', message: seededTip, created_at: new Date() +}); +const userRow = Object.freeze({ + ...seededRow, id: 2, socket_id: 'real-player-socket', username: 'Player', + player_id: 'Player', message_type: 'chat' +}); + +function manager({ payoutsAvailable, db }) { + const gameModeManager = { + paymentsEnabled: true, + directModeEnabled: true, + creditsModeEnabled: false, + isPayoutEnabledForMode: () => payoutsAvailable + }; + return new ChatHistoryManager({ db, gameModeManager }); +} + +describe('historical system chat tip presentation', () => { + test('the read path corrects only the seeded system tip when paid solo payouts are unavailable', async () => { + const rows = [seededRow, userRow]; + const db = { query: jest.fn().mockResolvedValue({ rows }) }; + const history = manager({ payoutsAvailable: false, db }); + + const messages = await history.getRecentMessages(); + + expect(messages[0].message).not.toMatch(/bonus payout/i); + expect(messages[0].message).toMatch(/score/i); + expect(messages[1].message).toBe(seededTip); + expect(rows[0].message).toBe(seededTip); + expect(db.query).toHaveBeenCalledTimes(1); + expect(db.query.mock.calls[0][0]).toMatch(/SELECT/); + }); + + test('cache fallback makes the same correction without changing cached bytes', async () => { + const db = { query: jest.fn() + .mockResolvedValueOnce({ rows: [userRow, seededRow] }) + .mockRejectedValueOnce(new Error('disposable database unavailable')) }; + const history = manager({ payoutsAvailable: false, db }); + const errorLog = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + await history._refreshCache(); + history._cacheInitialized = true; + const messages = await history.getRecentMessages(); + expect(messages[0].message).not.toMatch(/bonus payout/i); + expect(messages[1].message).toBe(seededTip); + expect(history._recentMessagesCache[0].message).toBe(seededTip); + } finally { + errorLog.mockRestore(); + } + }); + + test('the original system tip remains when a paid solo payout is actually available', async () => { + const db = { query: jest.fn().mockResolvedValue({ rows: [seededRow] }) }; + const history = manager({ payoutsAvailable: true, db }); + expect((await history.getRecentMessages())[0].message).toBe(seededTip); + }); + + test('the official WOW pause suppresses the old promise even with stale manager flags', async () => { + const previous = { + OPERATED_PRODUCT_PROFILE: process.env.OPERATED_PRODUCT_PROFILE, + PAYMENTS_ENABLED: process.env.PAYMENTS_ENABLED + }; + try { + process.env.OPERATED_PRODUCT_PROFILE = 'such-play-wow-prestige'; + process.env.PAYMENTS_ENABLED = 'false'; + const db = { query: jest.fn().mockResolvedValue({ rows: [seededRow] }) }; + const history = manager({ payoutsAvailable: true, db }); + expect((await history.getRecentMessages())[0].message).not.toMatch(/bonus payout/i); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); + + test('a system-looking row without the seed provenance is not rewritten', async () => { + const row = { ...seededRow, username: 'Player' }; + const db = { query: jest.fn().mockResolvedValue({ rows: [row] }) }; + const history = manager({ payoutsAvailable: false, db }); + expect((await history.getRecentMessages())[0].message).toBe(seededTip); + }); +}); diff --git a/test/wowPausedProfile.test.js b/test/wowPausedProfile.test.js new file mode 100644 index 0000000..0f72630 --- /dev/null +++ b/test/wowPausedProfile.test.js @@ -0,0 +1,163 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { spawnSync } = require('child_process'); +const appRequire = require('./helpers/appRequire'); +const { selectPublicPaidModeDescriptors } = require('../src/config/operatedProductProfiles'); +const { buildCommerceDisclosure } = require('../src/config/commerceDisclosurePolicy'); +const { renderTerms } = require('../src/views/legalPages'); +const GameModeManager = require('../src/game/gameModeManager'); +const MatchQueue = require('../src/network/matchQueue'); + +const root = path.join(__dirname, '..'); +const template = appRequire('dotenv').parse(fs.readFileSync(path.join(root, 'src/.env.mainnet.example'))); + +function closedEnv() { + return { + ...template, + PAYMENTS_ENABLED: 'false', + GAME_MODE: 'FREE', + PAYMENT_MODES: '', + CREDITS_ENABLED: 'false', + EARLY_ENTRY_CREDITS_MODE: 'false', + FINANCIAL_EVENT_SINK_URL: '', + FINANCIAL_EVENT_SINK_TOKEN: '', + DB_PASSWORD: crypto.randomBytes(32).toString('hex'), + ADMIN_API_KEY: crypto.randomBytes(32).toString('hex'), + WALLET_RPC_PASSWORD: crypto.randomBytes(32).toString('hex') + }; +} + +function preflight(env) { + return spawnSync(process.execPath, [path.join(root, 'src/scripts/preflight.js')], { + cwd: root, env, encoding: 'utf8', timeout: 10000 + }); +} + +describe('official WOW payments pause', () => { + test('the exact closed contract passes real production preflight without exposing secrets', () => { + const env = closedEnv(); + const result = preflight(env); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(result.stdout).toContain('paid modes: none'); + expect(result.stdout).toContain('free play: enabled'); + expect(result.stdout).toContain('payout dispatch: disabled'); + for (const key of ['DB_PASSWORD', 'ADMIN_API_KEY', 'WALLET_RPC_PASSWORD']) { + expect((result.stdout + result.stderr).includes(env[key])).toBe(false); + } + }); + + test.each([ + ['PAYMENTS_ENABLED', undefined], + ['PAYMENTS_ENABLED', 'disabled'], + ['GAME_MODE', undefined], + ['GAME_MODE', 'PAID_CREDITS'], + ['PAYMENT_MODES', undefined], + ['PAYMENT_MODES', 'credits'], + ['DIRECT_PAYMENT_ENABLED', 'true'], + ['CREDITS_ENABLED', 'true'], + ['PAYOUTS_ENABLED', 'true'], + ['DIRECT_PAYOUTS_ENABLED', 'true'], + ['CREDITS_PAYOUTS_ENABLED', 'true'], + ['CREDITS_PAYOUT_ENABLED', 'true'], + ['EARLY_ENTRY_CREDITS_MODE', 'true'], + ['EARLY_ENTRY_CREDITS_MODE', undefined], + ['MATCH_CRYPTO_RACE_ENABLED', 'true'], + ['MATCH_PAYOUTS_ENABLED', 'true'], + ['HOSTED_BY', 'https://other.invalid'], + ['MONERO_NETWORK', 'stagenet'], + ['FREE_PLAY_ENABLED', 'false'], + ['TAVERN_ENABLED', 'false'], + ['MATCH_ENABLED', 'false'], + ['FINANCIAL_EVENT_SINK_URL', 'https://ledger.invalid/events'], + ['FINANCIAL_EVENT_SINK_TOKEN', () => crypto.randomBytes(32).toString('hex')] + ])('refuses a drifted closed contract at %s', (key, candidate) => { + const env = closedEnv(); + const value = typeof candidate === 'function' ? candidate() : candidate; + if (value === undefined) delete env[key]; + else env[key] = value; + const result = preflight(env); + expect(result.error).toBeUndefined(); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain(key); + for (const secretKey of ['DB_PASSWORD', 'ADMIN_API_KEY', 'WALLET_RPC_PASSWORD']) { + expect((result.stdout + result.stderr).includes(env[secretKey])).toBe(false); + } + if (key === 'FINANCIAL_EVENT_SINK_TOKEN') { + expect((result.stdout + result.stderr).includes(value)).toBe(false); + } + }); + + test('public descriptors and disclosures describe only currently available free play', () => { + const env = closedEnv(); + expect(selectPublicPaidModeDescriptors(env, { + PAID_CREDITS: { enabled: true, cost: 1 } + })).toEqual({}); + env.MATCH_CRYPTO_RACE_ENABLED = 'true'; + env.MATCH_PAYOUTS_ENABLED = 'true'; + const disclosure = buildCommerceDisclosure({ + cryptoType: 'WOW', network: 'mainnet', freePlayEnabled: true, + paymentsEnabled: true, directModeEnabled: true, creditsModeEnabled: true, + payoutsEnabled: true + }, env); + expect(disclosure.operatedProduct.availabilityNotice).toMatch(/free play/i); + expect(disclosure.operatedProduct.commerceSummary).toMatch(/unavailable/i); + expect(renderTerms(disclosure)).toContain('Current availability'); + expect(disclosure.service.paidCreditsEnabled).toBe(false); + expect(disclosure.service.directPaidEntryEnabled).toBe(false); + expect(disclosure.service.cryptoMatchPayoutsEnabled).toBe(false); + }); + + test('a stale credits flag cannot advertise or admit a prestige queue', async () => { + const keys = ['OPERATED_PRODUCT_PROFILE', 'PAYMENTS_ENABLED', 'MATCH_ENABLED', + 'MATCH_CRYPTO_RACE_ENABLED', 'MATCH_PAYOUTS_ENABLED', 'CREDITS_PER_GAME', + 'CREDITS_PER_PACKAGE', 'SINGLE_GAME_PRICE', 'CREDITS_PACKAGE_PRICE', + 'CREDITS_PAYOUT_ENABLED', 'CREDITS_PAYOUTS_ENABLED', 'GAME_MODE']; + const original = Object.fromEntries(keys.map(key => [key, process.env[key]])); + const log = jest.spyOn(console, 'log').mockImplementation(() => {}); + try { + Object.assign(process.env, { + OPERATED_PRODUCT_PROFILE: 'such-play-wow-prestige', + PAYMENTS_ENABLED: 'false', MATCH_ENABLED: 'true', + MATCH_CRYPTO_RACE_ENABLED: 'false', MATCH_PAYOUTS_ENABLED: 'false' + }); + const manager = new GameModeManager(null, null, { CONSOLE_LOGGING: false }); + manager.paymentsEnabled = true; + manager.directModeEnabled = true; + manager.creditsModeEnabled = true; + expect(manager._getMatchEconomies()).toEqual({ free: true }); + expect(manager.getGameModeInfo()).toMatchObject({ + mode: 'FREE', paymentsEnabled: false, creditsModeEnabled: false, + creditPackages: [], cosmeticProducts: [], + modes: { match: { economies: { free: true } } } + }); + expect(manager.getPublicPreferredPaymentMode({ preferred_payment_mode: 'credits' })).toBe('free'); + expect(manager.getEffectiveModeForUser({ credits: 100 })).toMatchObject({ + mode: 'FREE', canUseCredits: false, canUseDirect: false + }); + expect(manager.isPaymentIntakeEnabled('credits_package')).toBe(false); + manager.getOrCreateUser = jest.fn().mockResolvedValue({ id: 1, credits: 100 }); + const options = await manager.getPaymentOptionsForUser('s'); + expect(options.options.length).toBeGreaterThan(0); + expect(options.options.every(option => option.mode === 'FREE')).toBe(true); + expect(await manager.processGameStart('s', 'game')).toMatchObject({ + success: true, effectiveMode: 'FREE' + }); + const db = { query: jest.fn(), withTransaction: jest.fn() }; + const queue = new MatchQueue({ db, gameModeManager: manager }); + queue.initialized = true; + expect(queue.isEconomyAvailable('free')).toBe(true); + const result = await queue.join({ userId: 1, socketId: 's', sessionToken: 't', economy: 'credits_prestige' }); + expect(result).toMatchObject({ success: false, reason: 'economy_disabled' }); + expect(db.query).not.toHaveBeenCalled(); + expect(db.withTransaction).not.toHaveBeenCalled(); + } finally { + log.mockRestore(); + for (const key of keys) { + if (original[key] === undefined) delete process.env[key]; + else process.env[key] = original[key]; + } + } + }); +});