From cbaca7d45b0eea405700795587ca4f9a1b9d6859 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Thu, 23 Jul 2026 10:44:32 -0400 Subject: [PATCH] fix: enforce operated product boundaries --- README.md | 1 + docs/MATCH_MODE.md | 7 +- docs/TODO.md | 4 +- html/js/matchClient.js | 14 +- html/js/network/socketHandlers.js | 19 ++- html/js/ui/helpModal.js | 2 +- html/js/ui/leaderboard.js | 24 ++- html/js/ui/paymentUI.js | 10 +- html/match.html | 4 +- html/tavern.html | 2 +- src/.env.mainnet.example | 1 + src/.env.stagenet.example | 1 + src/config/operatedProductProfiles.js | 3 + src/game/gameModeManager.js | 2 + src/index.js | 66 ++------- src/network/prestigeLeaderboard.js | 98 +++++++++++- src/routes/leaderboard.js | 59 ++++++++ src/scripts/stagenet-financial-canary.js | 59 ++++++-- src/views/legalPages.js | 35 ++++- test/commerceDisclosurePolicy.test.js | 1 + test/environmentValidator.test.js | 12 ++ test/gameModeManager.test.js | 14 ++ test/leaderboardRoute.test.js | 180 +++++++++++++++++++++++ test/legalPages.test.js | 20 ++- test/operatedProductTemplates.test.js | 6 + test/prestigeLeaderboard.test.js | 57 ++++++- test/pvpUiPolish.test.js | 31 ++++ test/stagenetFinancialCanary.test.js | 100 ++++++++++++- 28 files changed, 736 insertions(+), 96 deletions(-) create mode 100644 src/routes/leaderboard.js create mode 100644 test/leaderboardRoute.test.js diff --git a/README.md b/README.md index 3dc9cad..d625504 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ A warning appears if localStorage is unavailable. | `GET /health/live` | Process liveness probe | | `GET /health/ready` | Dependency readiness probe (503 while degraded) | | `GET /api/game-modes` | Current modes and pricing | +| `GET /api/leaderboard?board=pleb\|champions\|prestige&period=all\|week\|month` | One explicitly separated score board; an omitted `board` means `pleb`, while legacy `all`, blank, and unknown board values return 400 | | `GET /api/user/:socketId/credits` | User credit balance | | `GET /api/user/:socketId/payments` | Payment history (paginated) | | `GET /api/user/:socketId/payouts` | Payout history (paginated) | diff --git a/docs/MATCH_MODE.md b/docs/MATCH_MODE.md index 1c7fb91..9b7bc24 100644 --- a/docs/MATCH_MODE.md +++ b/docs/MATCH_MODE.md @@ -194,7 +194,12 @@ Client sends: - **Credits/prestige competitive matches** are served from authoritative `matches` and `match_entrants` rows by `GET /api/leaderboard?board=prestige`. - **Crypto competitive matches** create synthetic `games` rows with `game_mode = 'PAID_CREDITS'` - for the Hall of Champions board. + for the generic self-hosted Hall of Champions board. Such Software's two operated profiles keep + this economy disabled and exclude all match-generated rows from Champions. + +The public leaderboard endpoint requires `board=pleb|champions|prestige`; omitting `board` safely +defaults to `pleb`. The removed mixed `board=all` behavior and unknown or blank values return 400 +instead of silently combining or translating economies. An individual win requires the durable match winner and placement #1 to agree; reaching the exit alone is never treated as a win in last-alive or score-attack. `coop-escape` is collective, records diff --git a/docs/TODO.md b/docs/TODO.md index 5ecaea1..6d21543 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -127,7 +127,9 @@ Pending: ## Scoring & Leaderboard (Feb 2026) - [x] **Scoring System**: New formula (max 800 pts) — base escape + treasure bonus + speed bonus + efficiency bonus - [x] **Score Persistence**: `score` column on games table, `high_score` on users (migration 014) -- [x] **Leaderboard API**: `GET /api/leaderboard?period=all|week|month` with display name support +- [x] **Leaderboard API**: `GET /api/leaderboard?board=pleb|champions|prestige&period=all|week|month` + with display-name support and no mixed-economy fallback (`board` omitted → Pleb; invalid/legacy + `all` → 400) - [x] **Leaderboard UI**: Modal with rank, name, best score, wins, games. Period tabs (All/Week/Month) - [x] **Display Names**: `/nick` chat command stores `display_name` on users table - [x] **Real-time Updates**: `leaderboard_update` broadcast on game wins, auto-refreshes open modal diff --git a/html/js/matchClient.js b/html/js/matchClient.js index c5022d1..9a3bd11 100644 --- a/html/js/matchClient.js +++ b/html/js/matchClient.js @@ -88,7 +88,7 @@ els.objectiveHint.textContent = 'Arrow keys / WASD · ' + activeRuleset.objective + ' · drag or pinch the camera'; els.economyHelp.textContent = activeRuleset.cooperative ? 'Co-op is a team result and does not create an individual leaderboard winner.' - : 'Free, Prestige, and Champions results remain on separate boards.'; + : 'Each available entry mode keeps its results on a separate leaderboard.'; } function renderRulesets() { @@ -119,20 +119,18 @@ var availableKeys = Object.keys(ECONOMIES).filter(function (key) { return !!available[key]; }); if (!available[selectedEconomy] && availableKeys.length) selectedEconomy = availableKeys[0]; els.economies.innerHTML = ''; - Object.keys(ECONOMIES).forEach(function (key) { + availableKeys.forEach(function (key) { var def = ECONOMIES[key]; - var locked = !available[key]; var button = document.createElement('button'); button.type = 'button'; - button.className = 'economy' + (key === selectedEconomy && !locked ? ' active' : '') + (locked ? ' locked' : ''); + button.className = 'economy' + (key === selectedEconomy ? ' active' : ''); button.dataset.economy = key; - button.disabled = locked; - button.setAttribute('aria-pressed', key === selectedEconomy && !locked ? 'true' : 'false'); + button.setAttribute('aria-pressed', key === selectedEconomy ? 'true' : 'false'); var title = document.createElement('strong'); title.textContent = def.label; var desc = document.createElement('small'); desc.textContent = def.description; - var avail = document.createElement('small'); avail.className = 'availability'; avail.textContent = locked ? 'NOT ENABLED' : def.available; + var avail = document.createElement('small'); avail.className = 'availability'; avail.textContent = def.available; button.appendChild(title); button.appendChild(desc); button.appendChild(avail); - if (!locked) button.addEventListener('click', function () { + button.addEventListener('click', function () { selectedEconomy = key; renderEconomies(); }); diff --git a/html/js/network/socketHandlers.js b/html/js/network/socketHandlers.js index 1aee9d7..3836b55 100644 --- a/html/js/network/socketHandlers.js +++ b/html/js/network/socketHandlers.js @@ -16,6 +16,7 @@ const SocketHandlers = { _gameMode: null, // set from the server's game-modes event; was previously declared // twice in this object literal (the later `null` silently won) + _directModeEnabled: false, _directPayoutsEnabled: false, _paymentsEnabled: false, _isSmirkExplicitlyEnabled: function(data) { @@ -897,12 +898,16 @@ const SocketHandlers = { SocketHandlers._freePlayEnabled = !!data.freePlayEnabled; SocketHandlers._earlyEntryConfig = data.earlyEntry || { enabled: false }; SocketHandlers._creditsPayoutsEnabled = !!data.creditsPayoutsEnabled; + SocketHandlers._directModeEnabled = !!data.directModeEnabled; SocketHandlers._directPayoutsEnabled = !!data.directPayoutsEnabled; SocketHandlers._paymentsEnabled = !!data.paymentsEnabled; SocketHandlers._smirkEnabled = SocketHandlers._isSmirkExplicitlyEnabled(data); SocketHandlers._cryptoType = data.cryptoType || 'WOW'; SocketHandlers._currencyLabel = data.currencyLabel || data.cryptoType || 'WOW'; // sXMR on stagenet SocketHandlers._explorerTxUrl = data.explorerTxUrl || null; + if (typeof Leaderboard !== 'undefined' && Leaderboard.updateConfig) { + Leaderboard.updateConfig(data); + } // Do not ask prestige-only users for a payout address or imply that mainnet paid // entries award crypto. Match crypto is exposed only when its economy is admitted. var matchCryptoPayouts = !!(data.modes && data.modes.match && data.modes.match.economies @@ -1907,7 +1912,7 @@ const SocketHandlers = { // Entry Choice Modal // ===================== - // ONE consolidated entry modal: timing (when) + stakes (free vs ranked) in a single dialog, so + // ONE consolidated entry modal: timing (when) + entry (free vs ranked) in a single dialog, so // the player never hits the old timing-modal-then-payment-modal double. Each card emits the // FINAL intent — Free sends {free:true} (server skips its options modal), Ranked uses a credit // or opens the real payment UI. Nothing starts until a card is clicked. @@ -1918,6 +1923,10 @@ const SocketHandlers = { var hasCredits = !!opts.hasCredits; var cost = this._creditsPerGame || 1; var currency = this._currencyLabel || 'WOW'; + var creditEntry = cost + (cost === 1 ? ' credit' : ' credits'); + var buyRankedEntry = SocketHandlers._directModeEnabled + ? 'Buy a single entry or credits with ' + currency + : 'Buy credits with ' + currency; var timing = 'wait'; // safe default var $overlay = $('
').css({ @@ -1940,7 +1949,7 @@ const SocketHandlers = { '' + t + '' + '' + d + ''; } - function stake(id, k, t, s, accent) { + function entryCard(id, k, t, s, accent) { return '
' + '' + k + '' + @@ -1975,9 +1984,9 @@ const SocketHandlers = { seg('ecWait', timing === 'wait', '🛡 Next block', 'Start fresh · blocks land at random', false) + seg('ecNow', timing === 'now', '⚡ Right now', 'Jump in · race the time left', true) + '
' + - '
2 · Choose your stakes
' + - (freeAvailable ? stake('ecFree', '🆓', 'Free Play', 'No cost · Pleb leaderboard', '#123a17') : '') + - stake('ecRank', '💰', 'Ranked', (hasCredits ? '1 credit' : ('Pay ' + cost + ' ' + currency)) + ' · Hall of Champions', '#2a1a4a') + + '
2 · Choose your entry
' + + (freeAvailable ? entryCard('ecFree', '🆓', 'Free Play', 'No cost · Pleb leaderboard', '#123a17') : '') + + entryCard('ecRank', '💰', 'Ranked', (hasCredits ? creditEntry : buyRankedEntry) + ' · Hall of Champions', '#2a1a4a') + '
+ Buy credits (bulk discount)
' + '
' ); diff --git a/html/js/ui/helpModal.js b/html/js/ui/helpModal.js index 96bd674..f747553 100644 --- a/html/js/ui/helpModal.js +++ b/html/js/ui/helpModal.js @@ -104,7 +104,7 @@ const HelpModal = { } const freeLine = config.freePlayEnabled - ? `
Prefer no stakes? Free runs are available too (Pleb leaderboard).
` + ? `
Prefer a free entry? Free runs are available too (Pleb leaderboard).
` : ''; $container.html(` diff --git a/html/js/ui/leaderboard.js b/html/js/ui/leaderboard.js index cb41b14..06c7ba5 100644 --- a/html/js/ui/leaderboard.js +++ b/html/js/ui/leaderboard.js @@ -5,7 +5,17 @@ const Leaderboard = { _isLoading: false, _currentPeriod: 'all', - _currentBoard: 'champions', // solo paid | solo/free match | credits-prestige PvP + _currentBoard: 'champions', // paid solo (and generic opt-in crypto match) | free | prestige PvP + _profileId: null, + _cryptoMatchResultsEnabled: false, + + updateConfig: function(config) { + config = config || {}; + this._profileId = config.operatedProductProfileId || null; + this._cryptoMatchResultsEnabled = !!(config.modes && config.modes.match + && config.modes.match.economies && config.modes.match.economies.crypto_race); + if ($('#leaderboard-panel').length) this._updateTabs(); + }, init: function() { $('#leaderboardButton').on('click', function() { @@ -94,10 +104,20 @@ const Leaderboard = { $(this).css({ background: '#1a1a2e', color: '#aaa', borderColor: '#555' }); } }); + var championsNote; + if (this._profileId === 'such-play-wow-prestige') { + championsNote = 'Credit-entry solo scores only; no prizes, payouts, cash-out, or crypto-match results. Never mixed with Free or Prestige.'; + } else if (this._profileId === 'such-monerogue-stagenet') { + championsNote = 'Direct-entry and credit-entry solo scores only; no crypto-match results. Never mixed with Free or Prestige.'; + } else if (this._cryptoMatchResultsEnabled) { + championsNote = 'Paid solo and explicitly enabled crypto-match results; never mixed with Free or Prestige.'; + } else { + championsNote = 'Paid solo scores plus any historical crypto-match results recorded while that mode was enabled; never mixed with Free or Prestige.'; + } var boardMeta = { champions: { title: '🏅 Hall of Champions', - note: 'Paid solo and configured crypto-stakes competitive results; never mixed with Free or Prestige.' + note: championsNote }, prestige: { title: '✨ PvP Prestige', diff --git a/html/js/ui/paymentUI.js b/html/js/ui/paymentUI.js index 6b4f16c..a0135e0 100644 --- a/html/js/ui/paymentUI.js +++ b/html/js/ui/paymentUI.js @@ -178,9 +178,13 @@ const PaymentUI = { // Explain the two leaderboards up front when both paid and free are available. if (this.config.paymentsEnabled && this.config.freePlayEnabled) { + const paidEntryKinds = []; + if (this.config.creditsModeEnabled) paidEntryKinds.push('credits'); + if (this.config.directModeEnabled) paidEntryKinds.push('single entry'); + const paidEntryCopy = paidEntryKinds.join(' or ') || 'a paid entry'; $container.append( '
' + - '🏅 Pay (credits or single entry) to put your high scores in the Hall of Champions.
' + + '🏅 Pay (' + this.escapeHtml(paidEntryCopy) + ') to put your high scores in the Hall of Champions.
' + '🪙 Play free — high scores go to the Pleb leaderboard instead.' + '
' ); @@ -231,13 +235,13 @@ const PaymentUI = { $container.append(`
- ⚡ Single game · 1 credit + ⚡ Single-game entry ${price} ${currency}
${payoutInfo}
-
Counts as 1 credit toward cosmetic unlocks.
+
Adds one purchase-progress point for unlock thresholds; no spendable credit is added.