diff --git a/docs/DEPLOY_INSTANCES.md b/docs/DEPLOY_INSTANCES.md index 2ae5d65..5988b84 100644 --- a/docs/DEPLOY_INSTANCES.md +++ b/docs/DEPLOY_INSTANCES.md @@ -51,6 +51,7 @@ OPERATOR_NAME=Such Software OPERATOR_CONTACT_URL=mailto:apps@such.software OPERATOR_CONTACT_LABEL=apps@such.software FREE_PLAY_ENABLED=true # offer free play as a choice (Pleb board) +GAME_MODE=PAID_CREDITS # exact operated-profile legacy/socket identity PAYMENT_MODES=credits # only purchased-credit prestige is operated here DIRECT_PAYMENT_ENABLED=false # per-run payment is outside the operated scope CREDITS_ENABLED=true @@ -74,7 +75,8 @@ service while the wallet/preflight and fleet activation gates remain open: ```bash sudo systemctl restart wownerogue.service journalctl -u wownerogue.service -n 50 --no-pager # verify: "Payment system: ENABLED", payouts disabled -curl -s localhost:3000/api/game-modes | jq '{freePlayEnabled, PAID_SINGLE, PAID_CREDITS}' +curl -fsS localhost:3000/api/game-modes \ + | jq -e '.FREE.enabled == true and .PAID_CREDITS.enabled == true and (has("PAID_SINGLE") | not)' ``` **MAINNET WALLET NO-GO:** this application release does not authorize changing, copying, stopping, @@ -150,6 +152,7 @@ DIFFICULTY_PRESET=casino # --- payments + free play --- PAYMENTS_ENABLED=true +GAME_MODE=PAID_SINGLE PAYMENT_MODES=direct FREE_PLAY_ENABLED=true # free and direct solo boards available @@ -244,6 +247,9 @@ domains, websocket upgrades, and local readiness probes still work. ## Step 6 — Verify end to end - `monerogue.app` loads; `play.wowne.ro` still healthy. +- Each public modes document contains only its operated paid solo product: + Wownero has `PAID_CREDITS` and no `PAID_SINGLE`; Monerogue has `PAID_SINGLE` and no + `PAID_CREDITS`. - After wallet promotion and the separately approved bounded canary: buy credits / pay entry with a stagenet wallet → game starts → qualifying solo outcome → 2×/3× test-coin payout arrives. - After the corresponding gates clear, both boards populate: free game → Pleb, paid game → Hall of diff --git a/src/.env.mainnet.example b/src/.env.mainnet.example index 94a9809..b56d933 100644 --- a/src/.env.mainnet.example +++ b/src/.env.mainnet.example @@ -39,6 +39,7 @@ PAID_ACKNOWLEDGEMENT_REQUIRED=true # Free runs rank on the Pleb board; purchased credits rank on the paid prestige board. PAYMENTS_ENABLED=true FREE_PLAY_ENABLED=true +GAME_MODE=PAID_CREDITS PAYMENT_MODES=credits ALLOW_MIXED_MODE=false PREFER_CREDITS_FIRST=true diff --git a/src/.env.stagenet.example b/src/.env.stagenet.example index 3baa5a4..c299789 100644 --- a/src/.env.stagenet.example +++ b/src/.env.stagenet.example @@ -40,6 +40,7 @@ PAID_ACKNOWLEDGEMENT_REQUIRED=true # Purchased-credit entry is outside this operated profile and stays fail-closed. PAYMENTS_ENABLED=true FREE_PLAY_ENABLED=true +GAME_MODE=PAID_SINGLE PAYMENT_MODES=direct ALLOW_MIXED_MODE=false PREFER_CREDITS_FIRST=false diff --git a/src/config/environmentValidator.js b/src/config/environmentValidator.js index e19142d..edecc2a 100644 --- a/src/config/environmentValidator.js +++ b/src/config/environmentValidator.js @@ -9,7 +9,10 @@ const { isSupported, familyFor } = require('../chain/chainProfile'); const { resolveMatchRuleset } = require('../game/rulesets'); const money = require('../money/atomic'); -const { validateOperatedProductProfile } = require('./operatedProductProfiles'); +const { + getOperatedProductProfile, + validateOperatedProductProfile +} = require('./operatedProductProfiles'); const { PG_BIGINT_MAX, cryptoRulesetSupported, @@ -133,7 +136,10 @@ class EnvironmentValidator { const financialSinkUrl = String(env.FINANCIAL_EVENT_SINK_URL || '').trim(); const financialSinkToken = String(env.FINANCIAL_EVENT_SINK_TOKEN || '').trim(); - if (env.GAME_MODE && env.PAYMENT_MODES) { + const operatedProfile = getOperatedProductProfile(env); + const exactOperatedLegacyMode = operatedProfile + && String(env.GAME_MODE || '').trim() === operatedProfile.gameMode; + if (env.GAME_MODE && env.PAYMENT_MODES && !exactOperatedLegacyMode) { warnings.push('Both GAME_MODE and PAYMENT_MODES are set. PAYMENT_MODES takes precedence.'); } diff --git a/src/config/operatedProductProfiles.js b/src/config/operatedProductProfiles.js index 91ab0cd..eab2c65 100644 --- a/src/config/operatedProductProfiles.js +++ b/src/config/operatedProductProfiles.js @@ -28,6 +28,7 @@ const SOURCE_SOFTWARE = Object.freeze({ const PROFILES = Object.freeze({ [OPERATED_PRODUCT_PROFILE_IDS.WOW_PRESTIGE]: Object.freeze({ id: OPERATED_PRODUCT_PROFILE_IDS.WOW_PRESTIGE, + gameMode: 'PAID_CREDITS', hostname: 'play.wowne.ro', publicUrl: 'https://play.wowne.ro', operatorName: 'Such Software', @@ -41,6 +42,7 @@ const PROFILES = Object.freeze({ }), [OPERATED_PRODUCT_PROFILE_IDS.XMR_STAGENET]: Object.freeze({ id: OPERATED_PRODUCT_PROFILE_IDS.XMR_STAGENET, + gameMode: 'PAID_SINGLE', hostname: 'monerogue.app', publicUrl: 'https://monerogue.app', operatorName: 'Such Software', @@ -73,6 +75,16 @@ function getOperatedProductProfile(env = process.env) { return PROFILES[selectedProfileId(env)] || null; } +function selectPublicPaidModeDescriptors(env = process.env, descriptors = {}) { + const profile = getOperatedProductProfile(env); + if (!profile) { + return { ...descriptors }; + } + + const descriptor = descriptors[profile.gameMode]; + return descriptor === undefined ? {} : { [profile.gameMode]: descriptor }; +} + function isExactPublicRoot(value, expected) { try { const actual = new URL(String(value || '')); @@ -134,6 +146,8 @@ function validateOperatedProductProfile(env = process.env, config = {}) { `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}`); requireContract(isExplicitTrue(env.PAID_ACKNOWLEDGEMENT_REQUIRED), 'PAID_ACKNOWLEDGEMENT_REQUIRED=true'); requireContract(isExplicitTrue(env.FREE_PLAY_ENABLED), 'FREE_PLAY_ENABLED=true'); @@ -190,6 +204,7 @@ module.exports = { OPERATED_PRODUCT_PROFILE_IDS, SOURCE_SOFTWARE, getOperatedProductProfile, + selectPublicPaidModeDescriptors, selectedProfileId, validateOperatedProductProfile }; diff --git a/src/config/paymentConfig.js b/src/config/paymentConfig.js index fc81705..620431b 100644 --- a/src/config/paymentConfig.js +++ b/src/config/paymentConfig.js @@ -386,11 +386,18 @@ class PaymentConfigManager { 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; + const legacyOverride = String(this.legacyOverride || '').trim().toUpperCase(); + // GAME_MODE is legacy compatibility input, not authority over the parsed payment + // policy. Honor it only when the selected mode is actually enabled; contradictory + // PAYMENT_MODES/per-mode switches must never be misreported to sockets or health. + if (legacyOverride === 'PAID_SINGLE' && directEnabled) { + return 'PAID_SINGLE'; + } + if (legacyOverride === 'PAID_CREDITS' && creditsEnabled) { + return 'PAID_CREDITS'; + } if (directEnabled && !creditsEnabled) { return 'PAID_SINGLE'; } diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index 0f0b637..97bd226 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -1258,6 +1258,27 @@ class GameModeManager { }; } + getPublicPreferredPaymentMode(user) { + if (!this.paymentsEnabled) { + return 'free'; + } + + const preferred = String(user?.preferred_payment_mode || '').trim().toLowerCase(); + if (preferred === 'direct' && this.directModeEnabled) { + return 'direct'; + } + if (preferred === 'credits' && this.creditsModeEnabled) { + return 'credits'; + } + if (this.creditsModeEnabled) { + return 'credits'; + } + if (this.directModeEnabled) { + return 'direct'; + } + return 'free'; + } + /** * Get available payment options for a user * @param {string} socketId - Socket ID diff --git a/src/index.js b/src/index.js index f4c55d6..1946e87 100644 --- a/src/index.js +++ b/src/index.js @@ -51,6 +51,7 @@ const { attachDungeonVerification } = require('./game/fairnessVerifier'); const { renderVerifyPage } = require('./views/verifyPage'); const { renderPrivacy, renderResponsiblePlay, renderTerms } = require('./views/legalPages'); const { buildCommerceDisclosure } = require('./config/commerceDisclosurePolicy'); +const { selectPublicPaidModeDescriptors } = require('./config/operatedProductProfiles'); const createAdminRoutes = require('./routes/admin'); const createAuthRoutes = require('./routes/auth'); const { createLeaderboardHandler } = require('./routes/leaderboard'); @@ -399,7 +400,7 @@ app.get('/api/user/:socketId/mode', requireSessionOwnership(401), asyncHandler(a } res.json({ socketId, - preferredPaymentMode: user.preferred_payment_mode || 'direct', + preferredPaymentMode: gameModeManager.getPublicPreferredPaymentMode(user), hasPayoutAddress: !!user.payout_address, paymentsEnabled: gameModeManager.paymentsEnabled, directModeEnabled: gameModeManager.directModeEnabled, @@ -789,19 +790,7 @@ app.get('/api/game-modes', (req, res) => { } } - res.json({ - operatedProductProfileId: disclosure.operatedProduct?.id || null, - cryptoMatchPayoutsEnabled: disclosure.service.cryptoMatchPayoutsEnabled, - soloEnabled: publicModeInfo.modes?.solo === true, - // Free play is available when the instance is free-only OR when free play is offered - // as a choice alongside paid options (FREE_PLAY_ENABLED). - freePlayEnabled: !!gameModeManager.freePlayEnabled, - FREE: { - name: 'Free Play', - cost: 0, - payoutMultiplier: 0, - enabled: !!gameModeManager.freePlayEnabled - }, + const paidModeDescriptors = selectPublicPaidModeDescriptors(process.env, { PAID_SINGLE: { name: 'Paid Single Game', cost: toDisplay(directMode.price), @@ -818,7 +807,23 @@ app.get('/api/game-modes', (req, res) => { payoutMultiplier: isPayoutProcessingEnabled() && creditsMode.enabled && config.payouts.rules.credits.enabled ? gameModeManager.getImplementedPayoutMultipliersForMode('PAID_CREDITS') : 0 + } + }); + + res.json({ + operatedProductProfileId: disclosure.operatedProduct?.id || null, + cryptoMatchPayoutsEnabled: disclosure.service.cryptoMatchPayoutsEnabled, + soloEnabled: publicModeInfo.modes?.solo === true, + // Free play is available when the instance is free-only OR when free play is offered + // as a choice alongside paid options (FREE_PLAY_ENABLED). + freePlayEnabled: !!gameModeManager.freePlayEnabled, + FREE: { + name: 'Free Play', + cost: 0, + payoutMultiplier: 0, + enabled: !!gameModeManager.freePlayEnabled }, + ...paidModeDescriptors, match: { enabled: publicModeInfo.modes?.match?.enabled === true, economies: publicModeInfo.modes?.match?.economies || {} diff --git a/src/scripts/stagenet-financial-canary.js b/src/scripts/stagenet-financial-canary.js index c3b77eb..b34a5e0 100644 --- a/src/scripts/stagenet-financial-canary.js +++ b/src/scripts/stagenet-financial-canary.js @@ -236,9 +236,8 @@ function assertPublicModeContract(modes) { assert(modes?.soloEnabled === true && modes?.FREE?.enabled === true, 'the original solo mode and free solo entry must be enabled'); assert(modes?.PAID_SINGLE?.enabled === true - && modes?.PAID_CREDITS?.enabled === false - && Number(modes?.PAID_CREDITS?.payoutMultiplier) === 0, - 'direct solo must be enabled and purchased-credit solo must be disabled'); + && !Object.prototype.hasOwnProperty.call(modes || {}, 'PAID_CREDITS'), + 'direct solo must be enabled and purchased-credit solo must be omitted'); assert(modes?.match?.enabled === true && JSON.stringify(enabledEconomyIds(modes?.match?.economies)) === JSON.stringify(['credits_prestige', 'free']), diff --git a/test/environmentValidator.test.js b/test/environmentValidator.test.js index 8371eea..10c98a3 100644 --- a/test/environmentValidator.test.js +++ b/test/environmentValidator.test.js @@ -192,6 +192,7 @@ describe('EnvironmentValidator production launch gate', () => { CRYPTO_TYPE: 'WOW', MONERO_NETWORK: 'mainnet', FREE_PLAY_ENABLED: 'true', + GAME_MODE: 'PAID_CREDITS', PAYMENT_MODES: 'credits', DIRECT_PAYMENT_ENABLED: 'false', CREDITS_ENABLED: 'true', @@ -219,6 +220,16 @@ describe('EnvironmentValidator production launch gate', () => { expect(new EnvironmentValidator({ env, logger: silentLogger }) .assertValid(operatedConfig).errors).toEqual([]); + for (const gameMode of [undefined, 'PAID_SINGLE']) { + const driftedEnv = { ...env }; + if (gameMode === undefined) delete driftedEnv.GAME_MODE; + else driftedEnv.GAME_MODE = gameMode; + expect(new EnvironmentValidator({ env: driftedEnv, logger: silentLogger }) + .validate(operatedConfig).errors).toContain( + 'OPERATED_PRODUCT_PROFILE=such-play-wow-prestige requires GAME_MODE=PAID_CREDITS.' + ); + } + expect(new EnvironmentValidator({ env, logger: silentLogger }).validate({ ...operatedConfig, products: { cosmetic: [{ id: 'unrelated-pack', price: 1n }] } @@ -237,6 +248,7 @@ describe('EnvironmentValidator production launch gate', () => { LEGAL_POLICY_VERSION: '2026-07-23-v2', TERMS_EFFECTIVE_DATE: '2026-07-23', FREE_PLAY_ENABLED: 'true', + GAME_MODE: 'PAID_SINGLE', PAYMENT_MODES: 'direct', DIRECT_PAYMENT_ENABLED: 'true', CREDITS_ENABLED: 'false', @@ -312,6 +324,7 @@ describe('EnvironmentValidator production launch gate', () => { CRYPTO_TYPE: 'WOW', MONERO_NETWORK: 'mainnet', FREE_PLAY_ENABLED: 'true', + GAME_MODE: 'PAID_CREDITS', PAYMENT_MODES: 'direct,credits', DIRECT_PAYMENT_ENABLED: 'true', CREDITS_ENABLED: 'true', @@ -345,6 +358,7 @@ describe('EnvironmentValidator production launch gate', () => { CRYPTO_TYPE: 'XMR', MONERO_NETWORK: 'mainnet', FREE_PLAY_ENABLED: 'true', + GAME_MODE: 'PAID_CREDITS', PAYMENT_MODES: 'direct,credits', DIRECT_PAYMENT_ENABLED: 'true', CREDITS_ENABLED: 'true', @@ -376,6 +390,7 @@ describe('EnvironmentValidator production launch gate', () => { expect.stringContaining('requires LEGAL_POLICY_VERSION=2026-07-23-v2'), expect.stringContaining('requires TERMS_EFFECTIVE_DATE=2026-07-23'), expect.stringContaining('requires MONERO_NETWORK=stagenet'), + expect.stringContaining('requires GAME_MODE=PAID_SINGLE'), expect.stringContaining('requires PAYMENT_MODES=direct'), expect.stringContaining('requires CREDITS_ENABLED=false'), expect.stringContaining('requires CREDITS_PAYOUTS_ENABLED=false'), diff --git a/test/operatedProductTemplates.test.js b/test/operatedProductTemplates.test.js index 29e9de8..b8a4a4c 100644 --- a/test/operatedProductTemplates.test.js +++ b/test/operatedProductTemplates.test.js @@ -47,6 +47,7 @@ describe('operated product environment templates', () => { CRYPTO_TYPE: 'WOW', MONERO_NETWORK: 'mainnet', FREE_PLAY_ENABLED: 'true', + GAME_MODE: 'PAID_CREDITS', PAYMENT_MODES: 'credits', DIRECT_PAYMENT_ENABLED: 'false', CREDITS_ENABLED: 'true', @@ -78,6 +79,7 @@ describe('operated product environment templates', () => { CRYPTO_TYPE: 'XMR', MONERO_NETWORK: 'stagenet', FREE_PLAY_ENABLED: 'true', + GAME_MODE: 'PAID_SINGLE', PAYMENT_MODES: 'direct', DIRECT_PAYMENT_ENABLED: 'true', CREDITS_ENABLED: 'false', diff --git a/test/operatedPublicModes.test.js b/test/operatedPublicModes.test.js new file mode 100644 index 0000000..1a6c828 --- /dev/null +++ b/test/operatedPublicModes.test.js @@ -0,0 +1,35 @@ +const { + selectPublicPaidModeDescriptors +} = require('../src/config/operatedProductProfiles'); + +const descriptors = Object.freeze({ + PAID_SINGLE: Object.freeze({ enabled: true, name: 'Paid Single Game' }), + PAID_CREDITS: Object.freeze({ enabled: true, name: 'Credits Package' }) +}); + +describe('operated-product public paid-mode contract', () => { + test('play.wowne.ro publishes credits and omits direct entry', () => { + const result = selectPublicPaidModeDescriptors({ + OPERATED_PRODUCT_PROFILE: 'such-play-wow-prestige' + }, descriptors); + + expect(result).toEqual({ PAID_CREDITS: descriptors.PAID_CREDITS }); + expect(result).not.toHaveProperty('PAID_SINGLE'); + }); + + test('monerogue.app publishes direct entry and omits purchased credits', () => { + const result = selectPublicPaidModeDescriptors({ + OPERATED_PRODUCT_PROFILE: 'such-monerogue-stagenet' + }, descriptors); + + expect(result).toEqual({ PAID_SINGLE: descriptors.PAID_SINGLE }); + expect(result).not.toHaveProperty('PAID_CREDITS'); + }); + + test('generic MIT deployments retain both legacy API descriptors', () => { + const result = selectPublicPaidModeDescriptors({}, descriptors); + + expect(result).toEqual(descriptors); + expect(result).not.toBe(descriptors); + }); +}); diff --git a/test/paymentPolicy_ultra.test.js b/test/paymentPolicy_ultra.test.js index 5bffef9..278ff7b 100644 --- a/test/paymentPolicy_ultra.test.js +++ b/test/paymentPolicy_ultra.test.js @@ -105,6 +105,38 @@ describe('production payment policy wiring', () => { expect(wallet.createPaymentRequest).not.toHaveBeenCalled(); }); + test('a contradictory legacy GAME_MODE cannot misreport the enabled payment policy', () => { + process.env.PAYMENTS_ENABLED = 'true'; + process.env.GAME_MODE = 'PAID_SINGLE'; + process.env.PAYMENT_MODES = 'credits'; + process.env.DIRECT_PAYMENT_ENABLED = 'false'; + process.env.CREDITS_ENABLED = 'true'; + process.env.PAYOUTS_ENABLED = 'false'; + process.env.DIRECT_PAYOUTS_ENABLED = 'false'; + process.env.CREDITS_PAYOUTS_ENABLED = 'false'; + + const manager = new PaymentConfigManager({ logger: { warn() {}, info() {} } }); + const gameModes = new GameModeManager( + { query: jest.fn(), withTransaction: jest.fn() }, + { validateAddress: jest.fn() }, + { CONSOLE_LOGGING: false }, + manager + ); + + expect(manager.getConfig().modes.direct.enabled).toBe(false); + expect(manager.getConfig().modes.credits.enabled).toBe(true); + expect(manager.getLegacyGameMode()).toBe('PAID_CREDITS'); + expect(gameModes.getGameModeInfo()).toEqual(expect.objectContaining({ + mode: 'PAID_CREDITS', + directModeEnabled: false, + creditsModeEnabled: true, + payoutsEnabled: false + })); + expect(gameModes.getPublicPreferredPaymentMode({ + preferred_payment_mode: 'direct' + })).toBe('credits'); + }); + test('global per-game payout cap is an outer bound for direct and credit wins', () => { process.env.PAYMENT_MODES = 'direct,credits'; process.env.PAYOUT_MAX_PER_GAME = '123456'; diff --git a/test/stagenetFinancialCanary.test.js b/test/stagenetFinancialCanary.test.js index 5be63d5..30d7d56 100644 --- a/test/stagenetFinancialCanary.test.js +++ b/test/stagenetFinancialCanary.test.js @@ -185,10 +185,6 @@ describe('XMR stagenet financial canary safety contract', () => { enabled: true, payoutMultiplier: { escape: 2, escapeWithTreasure: 3 } }, - PAID_CREDITS: { - enabled: false, - payoutMultiplier: 0 - }, match: { enabled: true, economies: {