diff --git a/README.md b/README.md index 223f1bf..e15cb8a 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,9 @@ See [`docs/MATCH_MODE.md`](docs/MATCH_MODE.md) for architecture, configuration, [`docs/RULESETS.md`](docs/RULESETS.md) for the Escape Race, Last Alive, Score Attack, and Co-op Escape rulesets. +The [showcase qualification guide](docs/SHOWCASE_QUALIFICATION.md) defines the Tavern, graphics, +Escape Race, and Smirk acceptance checks. + For deterministic multi-bot playtesting and 16:9/9:16 ad footage, use the loopback-only, free-only capture harness documented in [`docs/PVP_CAPTURE.md`](docs/PVP_CAPTURE.md). It drives the real match engine over Socket.IO but cannot connect to a deployment, database, wallet, or payout path. diff --git a/docs/MATCH_MODE.md b/docs/MATCH_MODE.md index 0a54485..a13a5f0 100644 --- a/docs/MATCH_MODE.md +++ b/docs/MATCH_MODE.md @@ -41,6 +41,10 @@ value-bearing queues start differently on purpose. - If a queue is below the minimum player count, its members carry over and may leave until a freeze claims their row. - If a queue is empty, nothing happens. +- A reconnecting session reattaches its own still-queued rows to the new socket, preserving queue + order and any held entry. The client receives `match_queue_joined` with `restored: true` for each + restored economy. An active match reconnect takes precedence; matched rows cannot be reclaimed + as queue entries. A match ends when: @@ -216,7 +220,9 @@ Crypto races additionally require: Server emits: - `game_mode_info` includes `modes.match { enabled, economies, maxPlayers, activeRuleset, rulesets }`. -- `match_queue_joined` and `match_queue_left` are queue responses. +- `match_queue_joined` and `match_queue_left` are queue responses. A restored queue response includes + the authoritative `economy`, `position`, and `restored: true`; restoration never joins or charges + again. - `match_joined`, `match_start`, `match_tick`, `match_end` are the race lifecycle for players. - `match_rejoined` restores a reconnecting player; `match_settlement_pending` reports a result whose payout is still settling. diff --git a/docs/RELEASE_ARTIFACT.md b/docs/RELEASE_ARTIFACT.md index e596b11..900ae78 100644 --- a/docs/RELEASE_ARTIFACT.md +++ b/docs/RELEASE_ARTIFACT.md @@ -14,7 +14,7 @@ Everything else in the commit is excluded on purpose: `docs/`, `test/`, the root (service units, firewall and backup jobs, the hash-pinned financial audit SQL, the wallet output splitter), environment examples, `src/sim`, `src/jest.config.js`, and the operator-only commands `src/scripts/pvp-capture.js`, `src/scripts/stagenet-financial-canary.js`, `src/scripts/setup_db.js`, -and `src/scripts/smoke.js`. Tests and operator tooling are source and control-plane inputs; they are +`src/scripts/smoke.js`, and `src/scripts/showcase-browser-smoke.js`. Tests and operator tooling are source and control-plane inputs; they are not production runtime files, and sharing a Git commit with the runtime does not make them part of it. diff --git a/docs/SHOWCASE_QUALIFICATION.md b/docs/SHOWCASE_QUALIFICATION.md new file mode 100644 index 0000000..b9729c8 --- /dev/null +++ b/docs/SHOWCASE_QUALIFICATION.md @@ -0,0 +1,82 @@ +--- +title: Wowngeon showcase qualification +status: active +updated: 2026-10-21 +owner: Wowngeon +type: testing-guide +--- + +# Showcase qualification + +The first multiplayer showcase is the existing Escape Race: players share a dungeon and the first +exit wins. Combat PvP and a new cooperative game mode are separate product work. The initial visual +baseline is Tavern and Escape Race in Tiled, ASCII, and Iso. Three.js has separate runtime delivery +requirements and is not qualified by these checks. + +## Fundless source checks + +Install the locked graph and run the full source suite from `src`: + +```sh +npm ci --ignore-scripts --no-audit --no-fund +npm test +``` + +Release artifact tests require GNU build tools on Linux. A macOS run without those tools cannot +qualify the release builder. A passing source suite does not prove a deployed game or installed +wallet works. + +Run `npm run test:showcase-browser` from `src` with the cached Playwright Chromium available. The harness +uses the real Tavern manager and two fresh browser clients on a loopback-only server. It verifies +movement, chat, keyboard focus, mobile controls, missing-sheet failure, and Tiled/ASCII/Iso drawing. +Its synthetic Iso grant is a fixture, not evidence of purchased or production entitlements. Browser +output belongs under `~/Build/wowngeon`. No wallet extension, application database, or chain node is +used. See the script's usage for the output location. + +For authoritative multiplayer Escape Race captures, follow [PVP_CAPTURE.md](PVP_CAPTURE.md). Run +`race` with each of `tiles`, `ascii`, and `iso`; require a decisive first exit and matching final +state hashes for the same seed. The capture imports the actual match engine but uses synthetic +players. It does not qualify the deployed scheduler, queue UI, account restoration, or Tavern-to-match +navigation. + +## Smirk checkout contract + +Invoice delivery and reconnect restoration never open a wallet prompt. The user first sees the +invoice, then chooses **Pay with Smirk**. The game requires an explicitly enabled WOW mainnet +capability, an authenticated invoice owner, a valid expiry, and an exact decimal amount matching the +atomic accounting amount. Wallet review receives `memo`, not an invented description parameter. + +Browser storage and Web Locks retain one submission attempt per invoice across tabs and reloads. +A positive wallet response with a transaction ID means submitted, not confirmed. False, missing, +malformed, and transport-failure results never grant credits. Unknown submission must be reconciled +against wallet history and backend invoice status before further payment. Backend receipt evidence +alone grants credits. See [PAYMENTS.md](PAYMENTS.md). + +Existing Nostr identity remains the login method when available. Login failures never silently +switch to a different identity proof. WOW payment permission is explicit and separate from the +backend account link. Unknown networks and Monero stagenet refuse Smirk checkout. + +## Installed-artifact and live acceptance + +Before promoting the site as a wallet showcase, record the exact game release commit and installed +Smirk extension artifact. Use an explicitly authorized bounded purchase in a dedicated demonstration +wallet. Verify first connection, denied permission, returning-account recovery, subaddress payment, +backend-confirmed credit grant, and a reconnect without duplicate payment. Mock wallet tests do not +satisfy this acceptance. + +Then qualify two real clients through Tavern, queue, countdown, Escape Race, result, and return to +Tavern. Exercise queue reconnect and active-match reconnect, with keyboard and touch controls. Verify +premium visual grants using actual entitlement evidence. Keep free and prestige-credit play within +the reviewed product policy; do not enable cash-out, direct-entry payments, or crypto-race payouts. + +Production activation belongs to Fleet through a reviewed plan, source gate, and check/diff. A source +merge or local capture is not live deployment evidence. + +## Acceptance checklist + +- [ ] Full source suite passed on the supported Linux toolchain. +- [ ] Tavern browser smoke passed against the candidate source. +- [ ] Escape Race completed in Tiled, ASCII, and Iso with consistent authoritative results. +- [ ] Exact installed Smirk artifact passed account recovery and bounded payment acceptance. +- [ ] Two-client queue, reconnect, match, and return journey passed on the deployed candidate. +- [ ] Live source binding and Fleet activation evidence recorded separately. diff --git a/docs/TAVERN_AND_MULTIPLAYER.md b/docs/TAVERN_AND_MULTIPLAYER.md index 30d0228..861f3fe 100644 --- a/docs/TAVERN_AND_MULTIPLAYER.md +++ b/docs/TAVERN_AND_MULTIPLAYER.md @@ -336,3 +336,12 @@ applicable; premium modes are marked and gated by `RK.entitlements`. Set `TAVERN_ENABLED=true` (optionally `TAVERN_TICK_MS`), start the server, and open `/tavern.html` in two browser tabs. Add `MATCH_ENABLED=true` and a `MATCH_RULESET_ID` to exercise match entry from the Tavern. + +For the isolated, gameplay-only Tavern browser smoke, run `npm run test:showcase-browser` from +`src`. It starts a fresh loopback server and two anonymous browser contexts, with no database, +wallet, paid entitlement, or queue/race flow. Its synthetic Iso entitlement is a visual fixture, +not a purchase. Screenshots and a JSON result go to a unique directory under +`~/Build/wowngeon/showcase-browser`; pass `-- --output-root ` to choose another output +root. The browser blocks requests outside its loopback server. A local browser route injects a 404 +for the generated Wownero skin sheet so its readable failed-asset state is checked consistently, +whether or not that ignored asset is present on the workstation. diff --git a/html/index.html b/html/index.html index 7f222c6..1a78202 100644 --- a/html/index.html +++ b/html/index.html @@ -117,6 +117,7 @@ + diff --git a/html/js/matchClient.js b/html/js/matchClient.js index 9a3bd11..30db990 100644 --- a/html/js/matchClient.js +++ b/html/js/matchClient.js @@ -117,7 +117,7 @@ var available = info.economies || {}; var availableKeys = Object.keys(ECONOMIES).filter(function (key) { return !!available[key]; }); - if (!available[selectedEconomy] && availableKeys.length) selectedEconomy = availableKeys[0]; + if (!inQueue && !available[selectedEconomy] && availableKeys.length) selectedEconomy = availableKeys[0]; els.economies.innerHTML = ''; availableKeys.forEach(function (key) { var def = ECONOMIES[key]; @@ -151,6 +151,7 @@ b.type = 'button'; b.textContent = meta.label + (!availability.runtimeAvailable ? ' ⛔' : (meta.premium ? ' ★' : '')); b.className = (meta.id === mode ? 'active ' : '') + (usable ? '' : 'locked'); + b.setAttribute('aria-pressed', meta.id === mode ? 'true' : 'false'); b.disabled = !usable; if (!availability.runtimeAvailable) { b.title = 'Unavailable on this server: optional renderer dependency disabled.'; @@ -272,10 +273,17 @@ socket.on('match_queue_joined', function (res) { els.queueBtn.disabled = false; if (res && res.success) { + if (res.economy && ECONOMIES[res.economy]) selectedEconomy = res.economy; inQueue = true; els.queueBtn.classList.add('hidden'); els.leaveBtn.classList.remove('hidden'); - setStatus('Queued for ' + economyLabel(selectedEconomy) + ' · position ' + (res.position || '?') + ' · waiting for the next block.'); + Array.prototype.forEach.call(els.economies.querySelectorAll('[data-economy]'), function (button) { + var selected = button.dataset.economy === selectedEconomy; + button.classList.toggle('active', selected); + button.setAttribute('aria-pressed', selected ? 'true' : 'false'); + }); + setStatus((res.restored ? 'Queue restored for ' : 'Queued for ') + economyLabel(selectedEconomy) + + ' · position ' + (res.position || '?') + ' · waiting for the next block.'); } else { setStatus('Queue failed: ' + ((res && (res.reason || res.message)) || 'unknown error')); } @@ -285,6 +293,7 @@ inQueue = false; els.queueBtn.classList.remove('hidden'); els.leaveBtn.classList.add('hidden'); + renderEconomies(); setStatus(res && res.success ? 'Left the queue. Any held entry was returned.' : 'Could not leave: ' + ((res && res.reason) || 'unknown error')); }); @@ -404,7 +413,7 @@ else if (dir === 'right') sendMove(1, 0); } window.addEventListener('keydown', function (e) { - if (e.target && /INPUT|TEXTAREA|SELECT/.test(e.target.tagName)) return; + if (e.target && (/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName) || e.target.isContentEditable)) return; if (keyState[e.key]) return; keyState[e.key] = true; if (['ArrowUp', 'w', 'W'].indexOf(e.key) !== -1) { e.preventDefault(); directionMove('up'); } diff --git a/html/js/network/smirkAuth.js b/html/js/network/smirkAuth.js index f6fe0ba..da695e9 100644 --- a/html/js/network/smirkAuth.js +++ b/html/js/network/smirkAuth.js @@ -5,11 +5,11 @@ * Uses the Smirk browser extension API (window.smirk) for wallet interactions. * * Flow: - * 1. Get challenge from server - * 2. Connect to Smirk extension to get public keys + * 1. Acquire explicit WOW permission from Smirk + * 2. Get challenge from server * 3. Sign challenge with wallet * 4. Verify signature with backend - * 5. Get wallet addresses via getAddresses() and auto-set payout address + * 5. Read wallet addresses; update a payout address only in payout-enabled modes */ const SmirkAuth = { @@ -24,8 +24,34 @@ const SmirkAuth = { * Current link status */ _isLinked: false, + _wowReady: false, _walletAddress: null, + _sessionEpoch() { + return typeof SocketHandlers !== 'undefined' ? SocketHandlers._paymentSessionEpoch : null; + }, + + async requestWowAccess() { + const epoch = this._sessionEpoch(); + this._wowReady = false; + const keys = await window.smirk.connect(['wow']); + if (epoch !== this._sessionEpoch()) throw new Error('Game session changed. Reconnect Smirk for the current account.'); + if (!keys || !keys.wow) throw new Error('Smirk did not grant WOW access.'); + this._wowReady = true; + return keys; + }, + + async checkWowAccess() { + const epoch = this._sessionEpoch(); + this._wowReady = false; + try { + const keys = await window.smirk.getPublicKeys(); + if (epoch !== this._sessionEpoch()) return false; + this._wowReady = !!keys?.wow; + } catch (_) { /* Missing permission or provider is not a grant. */ } + return this._wowReady; + }, + /** * Detect whether the connected Smirk wallet supports NIP-98 (nostr) signing. */ @@ -37,10 +63,8 @@ const SmirkAuth = { /** * Main login flow. * - * Prefers NIP-98 (nostr kind:27235 HTTP auth event) when the wallet - * advertises the capability, and gracefully falls back to the legacy - * Ed25519 challenge-signing path otherwise (or if NIP-98 signing fails - * mid-flow). + * Keeps NIP-98 identity when supported. Errors never switch credential methods. + * WOW asset consent is separate from Nostr signing permission. */ async login() { if (!this.isAvailable()) { @@ -58,6 +82,10 @@ const SmirkAuth = { throw new Error('Your game session is still being established. Please retry in a moment.'); } + // An explicit click grants only the asset this game supports. Nostr-only grants do + // not authorize WOW payments. A denial ends this attempt without another popup. + const keys = await this.requestWowAccess(); + // Step 1: Get challenge from server const challengeRes = await fetch('/api/auth/smirk/challenge', { method: 'POST', @@ -80,26 +108,11 @@ const SmirkAuth = { let provenPublicKey = null; if (this._supportsNip98()) { - try { - const result = await this._verifyNip98(socketId, challenge, sessionToken); - verifyData = result.verifyData; - provenPublicKey = result.pubkey; - } catch (nip98Err) { - // Fall back to the legacy connect() path UNLESS the user explicitly cancelled the - // popup. This gives connect() a chance to open the unlock prompt when a locked - // signNostrEvent threw LOCKED instead of prompting (partial/stale builds), and - // covers wallets that lack NIP-98 entirely. Only an explicit cancel is not retried. - const code = nip98Err && nip98Err.code; - const userCancelled = code === 'USER_REJECTED' - || /reject|cancel|denied|declined/i.test((nip98Err && nip98Err.message) || ''); - if (userCancelled) throw nip98Err; - console.warn('Smirk NIP-98 failed, trying legacy connect():', nip98Err && nip98Err.message); - const result = await this._verifyLegacy(socketId, challenge, sessionToken); - verifyData = result.verifyData; - provenPublicKey = result.pubkey; - } + const result = await this._verifyNip98(socketId, challenge, sessionToken); + verifyData = result.verifyData; + provenPublicKey = result.pubkey; } else { - const result = await this._verifyLegacy(socketId, challenge, sessionToken); + const result = await this._verifyLegacy(socketId, challenge, sessionToken, keys); verifyData = result.verifyData; provenPublicKey = result.pubkey; } @@ -108,22 +121,26 @@ const SmirkAuth = { // us into it and returned that account's session token. Persist it and reload so the whole // session re-establishes as that account (credits, address, history). if (verifyData && verifyData.adopted && verifyData.sessionToken) { + if (typeof SocketHandlers !== 'undefined') { + SocketHandlers._invalidatePaymentSession(); + } try { localStorage.setItem('wownerogue_token', verifyData.sessionToken); } catch (_) { /* ignore */ } $('#messages').append($('
  • ').text('🔑 Signed in to your wallet-linked account. Reloading…')); setTimeout(function () { window.location.reload(); }, 700); return { success: true, linked: true, adopted: true, address: verifyData.address || null }; } - // Step 3: Get wallet addresses and auto-set payout address - // Note: the proven public key is a pubkey (hex), NOT an address. - // We need to call getAddresses() to get the actual WOW address. + // Receiving addresses are independent of the proven login identity. let walletAddress = null; try { const addresses = await window.smirk.getAddresses(); if (addresses && addresses.wow && window.socket) { - window.socket.emit('address:update', { address: addresses.wow }); + // Prestige-only play never needs a payout address. + if (typeof SocketHandlers !== 'undefined' && + (SocketHandlers._directPayoutsEnabled || SocketHandlers._creditsPayoutsEnabled)) { + window.socket.emit('address:update', { address: addresses.wow }); + } walletAddress = addresses.wow; - console.log('Smirk WOW address set:', addresses.wow.substring(0, 20) + '...'); } } catch (addrErr) { console.warn('Could not get Smirk addresses:', addrErr.message); @@ -169,10 +186,10 @@ const SmirkAuth = { try { signed = await window.smirk.signNostrEvent(evt); } catch (scopeErr) { - const needsScope = (scopeErr && scopeErr.code === 'NOT_AUTHORIZED') - || /nostr scope|getNostrPublicKey/i.test((scopeErr && scopeErr.message) || ''); + const needsScope = scopeErr && scopeErr.code === 'NOT_AUTHORIZED'; if (!needsScope) throw scopeErr; - await window.smirk.getNostrPublicKey(); // grant the Nostr scope (one-time per origin) + const granted = await window.smirk.getNostrPublicKey(); + if (!granted) throw new Error('Smirk Nostr permission was declined.'); signed = await window.smirk.signNostrEvent(evt); } if (!signed || typeof signed !== 'object') { @@ -203,17 +220,7 @@ const SmirkAuth = { * * @returns {Promise<{verifyData: object, pubkey: string|null}>} */ - async _verifyLegacy(socketId, challenge, sessionToken) { - // Connect to Smirk extension - let keys; - try { - keys = await window.smirk.connect(); - } catch (smirkError) { - const e = new Error('Failed to connect to Smirk wallet: ' + (smirkError.message || 'Unknown error')); - e.code = smirkError && smirkError.code; // preserve SmirkRpcError code ('LOCKED', 'USER_REJECTED', …) - throw e; - } - + async _verifyLegacy(socketId, challenge, sessionToken, keys) { if (!keys || !keys.wow) { throw new Error('Smirk wallet did not return WOW public key'); } @@ -266,23 +273,44 @@ const SmirkAuth = { return { linked: false, hasPayoutAddress: false }; } + const epoch = this._sessionEpoch(); + const socketId = window.socket.id; + const requestId = this._statusRequestId = (this._statusRequestId || 0) + 1; + let token = ''; + try { token = localStorage.getItem('wownerogue_token') || ''; } catch (_) {} + const current = () => { + let activeToken = ''; + try { activeToken = localStorage.getItem('wownerogue_token') || ''; } catch (_) {} + return epoch === this._sessionEpoch() && socketId === window.socket?.id && + token === activeToken && requestId === this._statusRequestId; + }; try { // /status is session-gated (BOLA fix): send the session token so a linked user // is recognised; without it the endpoint returns 401 and we fall through to unlinked. - var token = ''; - try { token = localStorage.getItem('wownerogue_token') || ''; } catch (_) { token = ''; } const res = await fetch(`/api/auth/smirk/status?socketId=${encodeURIComponent(window.socket.id)}`, { headers: token ? { 'X-Session-Token': token } : {} }); if (res.ok) { const data = await res.json(); - this._isLinked = data.linked; + if (!current()) return { linked: false, hasPayoutAddress: false }; + await this.checkWowAccess(); + if (!current()) return { linked: false, hasPayoutAddress: false }; + this._isLinked = data.linked === true; + this._updateButton($('#smirkLoginBtn'), data.linked ? (this._wowReady ? 'connected' : 'linked') : 'ready'); + if (typeof SocketHandlers !== 'undefined' && SocketHandlers._refreshSmirkPaymentButton) { + SocketHandlers._refreshSmirkPaymentButton(); + } return data; } } catch (e) { console.warn('Failed to check Smirk status:', e.message); } + if (current()) { + this._isLinked = false; + this._wowReady = false; + this._updateButton($('#smirkLoginBtn'), 'ready'); + } return { linked: false, hasPayoutAddress: false }; }, @@ -318,9 +346,14 @@ const SmirkAuth = { .text('Connecting...'); break; case 'connected': - btn.prop('disabled', true) - .css({ 'background': '#0a5c0a', 'border-color': '#0a8c0a', 'cursor': 'default' }) - .text('Smirk Connected'); + btn.prop('disabled', false) + .css({ 'background': '#0a5c0a', 'border-color': '#0a8c0a', 'cursor': 'pointer' }) + .text('Smirk connected · Recheck WOW access'); + break; + case 'linked': + btn.prop('disabled', false) + .css({ 'background': '#1a1a4d', 'cursor': 'pointer' }) + .text('Enable WOW payments'); break; case 'error': btn.prop('disabled', false) @@ -341,15 +374,18 @@ const SmirkAuth = { this._updateButton(btn, 'connecting'); try { - const result = await this.login(); + const result = this._isLinked + ? (await this.requestWowAccess(), { success: true, linked: true, address: this._walletAddress }) + : await this.login(); this._updateButton(btn, 'connected'); + if (typeof SocketHandlers !== 'undefined' && SocketHandlers._refreshSmirkPaymentButton) { + SocketHandlers._refreshSmirkPaymentButton(); + } // Show success message in chat const addressSet = result.address != null; - const successMsg = addressSet - ? 'Smirk wallet connected! Payout address set.' - : 'Smirk wallet authenticated! Please set your payout address manually.'; + const successMsg = 'Smirk account connected. WOW payment permission granted.'; // .text() sets textContent, so the message is safe from injection. $('#messages').append($('
  • ').text(successMsg)); @@ -362,7 +398,6 @@ const SmirkAuth = { return result; } catch (err) { - console.error('Smirk login failed:', err); this._updateButton(btn, 'error'); // button label becomes "Retry Smirk Connect" // A locked wallet is an action, not a red error. Current Smirk builds auto-open the @@ -415,11 +450,7 @@ const SmirkAuth = { container.append(btn); // Check if already linked on page load - this.checkStatus().then(status => { - if (status.linked) { - this._updateButton(btn, 'connected'); - } - }); + this.checkStatus(); console.log('Smirk extension detected, connect button shown'); } else { @@ -483,7 +514,7 @@ if (typeof window.socket !== 'undefined') { SmirkAuth.checkStatus().then(status => { const btn = $('#smirkLoginBtn'); if (btn.length > 0 && status.linked) { - SmirkAuth._updateButton(btn, 'connected'); + SmirkAuth._updateButton(btn, SmirkAuth._wowReady ? 'connected' : 'linked'); } }); } diff --git a/html/js/network/smirkPaymentAdapter.js b/html/js/network/smirkPaymentAdapter.js new file mode 100644 index 0000000..fbf5758 --- /dev/null +++ b/html/js/network/smirkPaymentAdapter.js @@ -0,0 +1,120 @@ +/** Exact WOW invoices and conservative, once-only wallet submission. Backend receipts grant credits. */ +(function(root, factory) { + const api = factory(); + if (typeof module !== 'undefined' && module.exports) module.exports = api; + else root.SmirkPayments = api; +})(typeof globalThis !== 'undefined' ? globalThis : this, function() { + const ATTEMPT_STORAGE = 'wowngeon_smirk_attempts_v1'; + const MAX_ATTEMPTS = 128; + const STATES = new Set(['unknown', 'submitted', 'failed', 'cancelled', 'unavailable']); + + function paymentRequest(invoice, now = Date.now()) { + if (!invoice || invoice.asset !== 'wow' || invoice.network !== 'mainnet') { + throw new Error('Smirk requires an explicitly identified WOW mainnet invoice.'); + } + if (!/^[1-9]\d*$/.test(String(invoice.paymentId || '')) || + !/^[1-9]\d*$/.test(String(invoice.ownerUserId || ''))) { + throw new Error('Payment identity or account ownership is missing.'); + } + const expiry = new Date(invoice.expiresAt).getTime(); + if (!invoice.expiresAt || !Number.isFinite(expiry) || expiry <= now) { + throw new Error('This payment request is expired or has no valid expiry.'); + } + // Wallet RPC owns checksum/nettype validation. Standard WOW addresses and RPC + // subaddresses are both 97-character Cryptonote base58, including the WW family. + if (typeof invoice.address !== 'string' || !/^W[1-9A-HJ-NP-Za-km-z]{96}$/.test(invoice.address)) { + throw new Error('This invoice has no valid WOW recipient.'); + } + // Never interpret an atomic-unit amount or rounded display label as whole WOW. + const amount = invoice.humanAmount; + if (typeof amount !== 'string' || !/^(0|[1-9]\d*)(\.\d{1,11})?$/.test(amount)) { + throw new Error('This invoice has no exact WOW decimal amount.'); + } + const [whole, fraction = ''] = amount.split('.'); + const atomic = BigInt(whole) * 100000000000n + BigInt(fraction.padEnd(11, '0')); + if (atomic <= 0n || !/^\d+$/.test(String(invoice.amount)) || + (typeof invoice.amount === 'number' && !Number.isSafeInteger(invoice.amount)) || + atomic !== BigInt(invoice.amount)) { + throw new Error('The invoice decimal amount does not match its accounting amount.'); + } + const memo = invoice.memo; + if (typeof memo !== 'string' || !memo.trim() || memo.length > 256) { + throw new Error('The invoice product description is missing or invalid.'); + } + return { asset: 'wow', amount, address: invoice.address, memo }; + } + + function createAdapter(options = {}) { + const now = options.now || (() => Date.now()); + const storage = options.storage; + const withLock = options.withLock; + const crypto = options.crypto || globalThis.crypto; + function read() { + if (!storage) throw new Error('Browser payment history is unavailable. Use manual payment before any wallet submission.'); + const raw = storage.getItem(ATTEMPT_STORAGE); + const entries = raw ? JSON.parse(raw) : []; + if (!Array.isArray(entries) || entries.some(entry => !entry || + !STATES.has(entry.state) || !Number.isFinite(entry.expiresAt) || + typeof entry.fingerprint !== 'string' || !/^[1-9]\d*$/.test(entry.paymentId))) { + throw new Error('Payment history cannot be read safely. Check invoice status before paying.'); + } + return entries.filter(entry => entry.expiresAt > now()); + } + function save(entries) { + const serialized = JSON.stringify(entries); + storage.setItem(ATTEMPT_STORAGE, serialized); + if (storage.getItem(ATTEMPT_STORAGE) !== serialized) { + throw new Error('Payment history could not be retained. No wallet payment was requested.'); + } + } + function status(invoice) { + return read().find(entry => entry.paymentId === String(invoice.paymentId)) || null; + } + async function pay(invoice, wallet, admit = () => {}) { + const request = paymentRequest(invoice, now()); + if (typeof wallet?.requestPayment !== 'function') throw new Error('This wallet does not support payment requests.'); + if (typeof withLock !== 'function' || !crypto?.subtle) { + throw new Error('This browser cannot safely coordinate wallet payments across tabs. Use manual payment before any wallet submission.'); + } + // Retain a digest, never an address, session token, signature or wallet key. + const contract = JSON.stringify([String(invoice.ownerUserId), invoice.asset, invoice.network, + request.amount, request.address, request.memo, invoice.expiresAt]); + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(contract)); + const fingerprint = Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''); + return withLock(ATTEMPT_STORAGE, async () => { + // Recheck expiry after waiting for another tab or wallet approval. + paymentRequest(invoice, now()); + admit(); + const entries = read(); + const previous = entries.find(entry => entry.paymentId === String(invoice.paymentId)); + if (previous) { + if (previous.fingerprint !== fingerprint) throw new Error('This invoice changed. Check its status before paying.'); + return { state: previous.state, repeated: true }; + } + if (entries.length >= MAX_ATTEMPTS) throw new Error('Too many active payment attempts. Check their status before paying again.'); + const entry = { paymentId: String(invoice.paymentId), expiresAt: new Date(invoice.expiresAt).getTime(), + fingerprint, state: 'unknown' }; + entries.push(entry); + // Intent is durable before the call. A closed tab cannot turn ambiguity into retry. + save(entries); + try { + const result = await wallet.requestPayment(request); + if (result && typeof result === 'object' && result.success === true && + typeof result.txid === 'string' && /^[0-9a-f]{64}$/i.test(result.txid)) { + entry.state = 'submitted'; + } else if (result && typeof result === 'object' && result.success === false && !result.txid) { + entry.state = 'failed'; + } + } catch (error) { + if (error?.code === 'USER_REJECTED') entry.state = 'cancelled'; + else if (['NOT_AUTHORIZED', 'UNSUPPORTED_ASSET', 'LOCKED'].includes(error?.code)) entry.state = 'unavailable'; + // Transport errors can occur after submission. Never offer an automatic retry. + } + try { save(entries); } catch (_) { return { state: 'unknown' }; } + return { state: entry.state }; + }); + } + return { pay, status }; + } + return { createAdapter, paymentRequest }; +}); diff --git a/html/js/network/socketHandlers.js b/html/js/network/socketHandlers.js index 922deb2..70abd3c 100644 --- a/html/js/network/socketHandlers.js +++ b/html/js/network/socketHandlers.js @@ -182,6 +182,7 @@ const SocketHandlers = { // Connection handlers socket.on('connect', this.onConnect); socket.on('disconnect', function() { + SocketHandlers._invalidatePaymentSession(); if (typeof CommerceConsent !== 'undefined' && CommerceConsent.clear) { CommerceConsent.clear(); } @@ -311,6 +312,8 @@ const SocketHandlers = { onSessionToken: function(data) { if (data && data.token) { + SocketHandlers._invalidatePaymentSession(); + SocketHandlers._sessionUserId = data.userId || null; try { localStorage.setItem('wownerogue_token', data.token); } catch(e) {} $('#messages').append($('
  • ').text('New session established. Token stored.')); UI.scrollChat(); @@ -332,6 +335,8 @@ const SocketHandlers = { onSessionResumed: function(data) { if (data && data.token) { + SocketHandlers._invalidatePaymentSession(); + SocketHandlers._sessionUserId = data.userId || null; try { localStorage.setItem('wownerogue_token', data.token); } catch(e) {} $('#messages').append($('
  • ').text('Session resumed.')); UI.scrollChat(); @@ -899,6 +904,7 @@ const SocketHandlers = { SocketHandlers._paymentsEnabled = !!data.paymentsEnabled; SocketHandlers._smirkEnabled = SocketHandlers._isSmirkExplicitlyEnabled(data); SocketHandlers._cryptoType = data.cryptoType || 'WOW'; + SocketHandlers._network = data.network || null; SocketHandlers._currencyLabel = data.currencyLabel || data.cryptoType || 'WOW'; // e.g. sXMR on stagenet SocketHandlers._explorerTxUrl = data.explorerTxUrl || null; if (typeof Leaderboard !== 'undefined' && Leaderboard.updateConfig) { @@ -978,83 +984,116 @@ const SocketHandlers = { }, onPaymentCreated: function(data) { - console.log('Payment created:', data); if (typeof AudioAlerts !== 'undefined') { AudioAlerts.playRequestCoin(); } - - // Native Smirk payment is only possible while the user is linked through the extension. - if (typeof SmirkAuth !== 'undefined' && SmirkAuth._isLinked && SmirkAuth.isAvailable() && - typeof window.smirk !== 'undefined' && window.smirk.requestPayment) { - SocketHandlers._trySmirkPayment(data); - return; - } - + // Invoice delivery, replay and reconnect are never wallet consent. SocketHandlers._showPaymentFlow(data); }, - /** - * Attempt payment via Smirk wallet extension. - * Falls back to normal address/QR flow on rejection or error. - */ - _trySmirkPayment: async function(data) { - $('#messages').append($('
  • ').text('Opening Smirk wallet for payment...')); - UI.scrollChat(); - - try { - // Smirk expects a human-readable amount (e.g. "1"), not atomic units. - var payAmount = data.humanAmount || data.amountFormatted || String(data.amount); - var description = 'Single game entry'; - if (data.paymentType === 'credits_package') { - description = 'Buy ' + (data.package ? data.package.credits : '') + ' credits'; - } else if (data.paymentType === 'cosmetic_pack') { - description = 'Unlock ' + (data.package ? (data.package.label || data.package.id || 'premium pack') : 'premium pack'); - } - - await window.smirk.requestPayment({ - address: data.address, - amount: payAmount, - asset: (SocketHandlers._cryptoType || 'WOW').toLowerCase(), - description: description + _smirkPaymentAdapter: null, + _getSmirkPaymentAdapter: function() { + if (!SocketHandlers._smirkPaymentAdapter) { + let storage = null; + try { storage = window.localStorage; } catch (_) {} + SocketHandlers._smirkPaymentAdapter = SmirkPayments.createAdapter({ + storage, + withLock: window.navigator?.locks + ? (name, work) => window.navigator.locks.request(name, work) : null }); + } + return SocketHandlers._smirkPaymentAdapter; + }, - // The user confirmed in Smirk and the transaction is submitted; server-side monitoring - // handles confirmation from here. - $('#messages').append($('
  • ').text( - 'Payment sent via Smirk! Waiting for confirmation...' - )); - UI.scrollChat(); + _smirkPaymentCopy: function(state) { + return { + submitted: 'Smirk reported transaction submission. Waiting for backend confirmation. Do not pay again.', + unknown: 'Payment submission is uncertain. Check payment status and wallet history before any further payment. Do not pay again.', + failed: 'Smirk reported a failed payment. Check wallet history and invoice status before choosing any further payment.', + cancelled: 'Smirk payment cancelled. No automatic retry will be made.', + unavailable: 'Smirk payment permission is unavailable. Reconnect the wallet deliberately before creating another payment request.' + }[state]; + }, - if (typeof PaymentUI !== 'undefined') { - PaymentUI.showPaymentRequest(data); - $('#payment-status').html('Payment sent via Smirk, awaiting confirmation...'); + _invalidatePaymentSession: function() { + SocketHandlers._paymentSessionEpoch = (SocketHandlers._paymentSessionEpoch || 0) + 1; + SocketHandlers._activePaymentId = null; + SocketHandlers._activePaymentData = null; + SocketHandlers._sessionUserId = null; + if (typeof SmirkAuth !== 'undefined') { + SmirkAuth._isLinked = false; + SmirkAuth._wowReady = false; + } + $('#smirkPayButton, .payment-info').remove(); + $('#payment-ui, #payment-details').hide(); + $('#payment-address').val(''); + $('#payment-amount').text(''); + $('#check-payment').removeData('payment-id').prop('disabled', true); + $('#copy-address').prop('disabled', true); + SocketHandlers.hidePaymentQR(); + }, + + _trySmirkPayment: async function(data, button, consentEpoch = SocketHandlers._paymentSessionEpoch) { + if (button) button.prop('disabled', true); + try { + const admit = function() { + if (!SocketHandlers._smirkEnabled || SocketHandlers._cryptoType !== 'WOW' || + SocketHandlers._network !== 'mainnet' || !SmirkAuth._isLinked || !SmirkAuth._wowReady || + consentEpoch !== SocketHandlers._paymentSessionEpoch || + !SocketHandlers._sessionUserId || String(SocketHandlers._sessionUserId) !== String(data.ownerUserId) || + String(SocketHandlers._activePaymentId) !== String(data.paymentId)) { + throw new Error('This WOW invoice is no longer active for the connected account. Check payment status first.'); + } + }; + admit(); + if (!await SmirkAuth.checkWowAccess()) { + SmirkAuth._updateButton($('#smirkLoginBtn'), 'linked'); + throw new Error('WOW permission is missing. Click Enable WOW payments to reconnect deliberately.'); } - SocketHandlers._setBannerStatus('Pay', '#0af'); - if (typeof Game !== 'undefined' && Game._paymentRequested) Game._paymentRequested(); - if (typeof Game !== 'undefined' && Game.drawWaitingScreen) Game.drawWaitingScreen(); - + const result = await SocketHandlers._getSmirkPaymentAdapter().pay(data, window.smirk, admit); + if (consentEpoch !== SocketHandlers._paymentSessionEpoch || + String(SocketHandlers._activePaymentId) !== String(data.paymentId)) return; + const message = SocketHandlers._smirkPaymentCopy(result.state); + $('#payment-status').text(message); + $('#messages').append($('
  • ').text(message)); + SocketHandlers._protectSubmittedPayment(result.state); } catch (err) { - console.log('Smirk payment declined/failed, falling back to manual:', err); - var errMsg = String(err.message || err || '').toLowerCase(); + if (consentEpoch !== SocketHandlers._paymentSessionEpoch || + String(SocketHandlers._activePaymentId) !== String(data.paymentId)) return; + $('#payment-status').text(err.message); + $('#messages').append($('
  • ').text(err.message)); + } + UI.scrollChat(); + }, - // Distinguish a user-initiated denial from an extension error. - var isUserDenied = errMsg.indexOf('denied') !== -1 || - errMsg.indexOf('rejected') !== -1 || - errMsg.indexOf('cancelled') !== -1 || - errMsg.indexOf('user') !== -1; + _protectSubmittedPayment: function(state) { + if (state !== 'unknown' && state !== 'submitted') return; + $('#copy-address, .copy-pay-address').prop('disabled', true); + SocketHandlers.hidePaymentQR(); + }, - if (isUserDenied) { - $('#messages').append($('
  • ').text( - 'Smirk payment cancelled. Use the address below.' - )); - } else { - // Extension error: invalidated context, service worker issue, and similar. - $('#messages').append($('
  • ').text( - 'Smirk payment unavailable, using manual payment. Refresh the page to restore Smirk.' - )); - SmirkAuth._isLinked = false; + _refreshSmirkPaymentButton: function() { + if (SocketHandlers._activePaymentData) SocketHandlers._showSmirkPaymentButton(SocketHandlers._activePaymentData); + }, + + _showSmirkPaymentButton: function(data) { + $('#smirkPayButton').remove(); + if (typeof SmirkPayments === 'undefined' || typeof SmirkAuth === 'undefined' || + !SmirkAuth._isLinked || !SmirkAuth.isAvailable() || !SocketHandlers._smirkEnabled || + SocketHandlers._cryptoType !== 'WOW' || SocketHandlers._network !== 'mainnet' || + !SocketHandlers._sessionUserId || String(SocketHandlers._sessionUserId) !== String(data.ownerUserId)) return; + try { + SmirkPayments.paymentRequest(data); + const adapter = SocketHandlers._getSmirkPaymentAdapter(); + const previous = adapter.status(data); + if (previous) { + $('#payment-status').text(SocketHandlers._smirkPaymentCopy(previous.state)); + return; } - UI.scrollChat(); - - SocketHandlers._showPaymentFlow(data); + const button = $(' @@ -420,6 +420,7 @@ var b = document.createElement('button'); b.className = 'packbtn' + (p.id === activeId ? ' active' : ''); b.textContent = p.label; + b.setAttribute('aria-pressed', p.id === activeId ? 'true' : 'false'); b.addEventListener('click', function () { if (RK.setActivePack(p.id)) { buildPackbar(); setMode(currentMode); } }); @@ -428,7 +429,9 @@ } function updateModeButtons() { Array.prototype.forEach.call(modebar.children, function (b) { - b.classList.toggle('active', b.dataset.mode === currentMode); + var active = b.dataset.mode === currentMode; + b.classList.toggle('active', active); + b.setAttribute('aria-pressed', active ? 'true' : 'false'); }); } @@ -635,7 +638,7 @@ if (!spectatorKind && state.you) socket.emit('tavern_move', { dir: dir }); } window.addEventListener('keydown', function (e) { - if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return; + if (e.target && (/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName) || e.target.isContentEditable)) return; var dir = KEYS[e.key]; if (!dir || (!state.you && spectatorKind !== 'player-match')) return; e.preventDefault(); diff --git a/scripts/deploy/build-release-artifact.sh b/scripts/deploy/build-release-artifact.sh index e411ea7..05ee945 100755 --- a/scripts/deploy/build-release-artifact.sh +++ b/scripts/deploy/build-release-artifact.sh @@ -180,6 +180,7 @@ runtime_pathspecs=( 'src' ':(exclude)src/.env*' ':(glob,exclude)src/**/.env*' + ':(exclude)src/scripts/showcase-browser-smoke.js' ':(exclude)src/scripts/pvp-capture.js' ':(exclude)src/scripts/stagenet-financial-canary.js' ':(exclude)src/scripts/setup_db.js' diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index 10c87a1..187fe17 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -1148,7 +1148,7 @@ class GameModeManager { async _findReusablePayment(userId, paymentType, productId = null) { if (!userId) return null; const result = await this.db.query(` - SELECT id, subaddress, expected_amount, payment_type, status, created_at, expires_at, + SELECT id, user_id, subaddress, expected_amount, payment_type, status, created_at, expires_at, description, product_id, product_grants, fairness_proof_version, fairness_offer_id, fairness_offer_issued_at, fairness_commitment, fairness_server_seed, fairness_client_seed, @@ -1189,6 +1189,7 @@ class GameModeManager { const amount = money.toSafe(money.toBig(row.expected_amount)); return { id: row.id, + ownerUserId: row.user_id, address: row.subaddress, amount, amountFormatted: this.formatAtomicHuman(amount, 4), @@ -2205,6 +2206,7 @@ class GameModeManager { return { id: insertedRow?.id, + ownerUserId: user.id, address: paymentResult.address, invoiceId: paymentResult.invoiceId || paymentResult.address, amount: money.toSafe(money.toBig(amount)), diff --git a/src/network/connectionHandler.js b/src/network/connectionHandler.js index 669841b..2544e03 100644 --- a/src/network/connectionHandler.js +++ b/src/network/connectionHandler.js @@ -276,12 +276,13 @@ class ConnectionHandler { if (sessionInfo.resumed) { this.io.to(socket.id).emit('session_resumed', { token: sessionInfo.token, + userId: sessionInfo.user.id, payoutAddress: sessionInfo.user.payout_address || null, credits: sessionInfo.user.credits || 0, totalCreditsPurchased: sessionInfo.user.total_credits_purchased || 0 }); } else { - this.io.to(socket.id).emit('session_token', { token: sessionInfo.token }); + this.io.to(socket.id).emit('session_token', { token: sessionInfo.token, userId: sessionInfo.user.id }); // Only new sessions need address_confirmed; resumed ones carry payoutAddress // in session_resumed. diff --git a/src/network/paymentHandlers.js b/src/network/paymentHandlers.js index f648312..05644d5 100644 --- a/src/network/paymentHandlers.js +++ b/src/network/paymentHandlers.js @@ -304,10 +304,15 @@ class PaymentHandlers { this.io.to(socket.id).emit('payment_created', { paymentId: paymentRequest.id, + ownerUserId: paymentRequest.ownerUserId, + asset: String(cryptoType).toLowerCase(), + network: this.gameModeManager.network, + memo: paymentRequest.description, address: paymentRequest.address, amount: paymentRequest.amount, amountFormatted: paymentRequest.amountFormatted, - humanAmount: paymentRequest.amountFormatted, + humanAmount: money.format(paymentRequest.amount, this.gameModeManager.currencyDecimals), + expiresAt: paymentRequest.expiresAt, currency: cryptoType, cryptoType: cryptoType, package: paymentRequest.package, @@ -485,10 +490,14 @@ class PaymentHandlers { this.io.to(socket.id).emit('payment_created', { paymentId: paymentRequest.id, + ownerUserId: paymentRequest.ownerUserId, + asset: String(cryptoType).toLowerCase(), + network: this.gameModeManager.network, + memo: paymentRequest.description, address: paymentRequest.address, amount: paymentRequest.amount, amountFormatted: formattedAmount, - humanAmount: formattedAmount, + humanAmount: money.format(paymentRequest.amount, this.gameModeManager.currencyDecimals), paymentType, productId: paymentRequest.productId, grants: paymentRequest.grants, diff --git a/src/network/socketHandlers.js b/src/network/socketHandlers.js index f5a6ebb..31f3811 100644 --- a/src/network/socketHandlers.js +++ b/src/network/socketHandlers.js @@ -1427,7 +1427,25 @@ class SocketHandlers { } const session = await this._resolveMatchSession(socket); if (!session) return; - await this.matchManager.handleReconnect(socket, session); + // An active room owns the player until its result is settled. A queued entrant has + // no room yet, so restore the durable queue row to this authenticated socket without + // joining again or touching its held credits/tickets. + const inMatch = await this.matchManager.handleReconnect(socket, session); + if (inMatch || !this.matchQueue?.reattach) return; + for (const economy of ['free', 'credits_prestige', 'crypto_race']) { + const restored = await this.matchQueue.reattach( + session.userId, economy, socket.id, session.sessionToken + ); + if (restored?.inQueue) { + socket.emit('match_queue_joined', { + success: true, + economy, + position: restored.position, + alreadyQueued: true, + restored: true + }); + } + } } catch (err) { if (this.debugManager?.CONSOLE_LOGGING) console.error('[SocketHandlers] match_reconnect error:', err.message); socket.emit('match_error', { message: 'Could not rejoin the match.' }); @@ -1793,10 +1811,14 @@ class SocketHandlers { // Re-emit payment_created to the client socket.emit('payment_created', { paymentId: payment.id, + ownerUserId: dbUserId, + asset: String(cryptoType).toLowerCase(), + network: this.gameModeManager.network, + memo: payment.description, address: payment.subaddress, amount: payment.expected_amount, amountFormatted: formattedAmount, - humanAmount: formattedAmount, + humanAmount: money.format(payment.expected_amount, this.gameModeManager.currencyDecimals), paymentType: payment.payment_type, cryptoType: cryptoType, description: payment.description, diff --git a/src/package.json b/src/package.json index c23d0bb..32fa874 100644 --- a/src/package.json +++ b/src/package.json @@ -9,6 +9,7 @@ "dev": "NODE_ENV=development node index.js", "debug": "NODE_ENV=debug node index.js", "test": "jest --runInBand", + "test:showcase-browser": "node scripts/showcase-browser-smoke.js", "capture:pvp": "node scripts/pvp-capture.js", "canary:stagenet": "node scripts/stagenet-financial-canary.js", "db:migrate:clone": "node scripts/migrate-disposable-clone.js", diff --git a/src/scripts/showcase-browser-smoke.js b/src/scripts/showcase-browser-smoke.js new file mode 100644 index 0000000..e114e93 --- /dev/null +++ b/src/scripts/showcase-browser-smoke.js @@ -0,0 +1,348 @@ +'use strict'; + +// Gameplay-only browser check. This deliberately avoids the application entrypoint, database, +// wallet, payment handlers, and queue/race transitions. The Iso grant below is a visible fixture. +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const express = require('express'); +const { Server } = require('socket.io'); +const { chromium } = require('playwright-core'); +const TavernManager = require('../network/tavernManager'); + +const ROOT = path.resolve(__dirname, '../..'); +const HTML_ROOT = path.join(ROOT, 'html'); +const OUTPUT_BASE = path.join(os.homedir(), 'Build', 'wowngeon', 'showcase-browser'); +const FIXTURE_ENTITLEMENTS = Object.freeze({ + premium: true, + level: 'free', + credits: 0, + totalCreditsPurchased: 0, + packs: Object.freeze({ + 'iso-dungeon': true, + 'generated-skins': false, + 'kenney-3d-characters': false, + 'roguelike-dungeon': false, + 'iso-medieval': false + }) +}); + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function parseArgs(argv) { + let outputRoot = OUTPUT_BASE; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--output-root') { + assert(argv[i + 1], '--output-root requires a directory'); + outputRoot = path.resolve(argv[++i]); + } else { + throw new Error(`Unknown argument: ${argv[i]}`); + } + } + return outputRoot; +} + +function isLoopback(hostname) { + return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]'; +} + +async function main() { + assert(process.env.NODE_ENV !== 'production', 'Refusing to run with NODE_ENV=production'); + assert(fs.existsSync(path.join(HTML_ROOT, 'tavern.html')), 'Tavern source page is missing'); + + const outputRoot = parseArgs(process.argv.slice(2)); + fs.mkdirSync(outputRoot, { recursive: true }); + const outputDir = fs.mkdtempSync(path.join(outputRoot, 'run-')); + + process.env.TAVERN_ENABLED = 'true'; + process.env.TAVERN_TICK_MS = '50'; + + const app = express(); + const httpServer = require('node:http').createServer(app); + const io = new Server(httpServer, { serveClient: true }); + const manager = new TavernManager({ + io, + tickMs: 50, + entitlementProvider: socket => socket.data.showcaseFixture === true + ? FIXTURE_ENTITLEMENTS + : { premium: false, totalCreditsPurchased: 0, packs: {} }, + globalChatProvider: { + publish(message) { + io.emit('chat_broadcast', { + scope: 'global', username: message.username, message: message.text + }); + } + } + }); + const browserFailures = []; + const blockedExternal = []; + const clientsByRole = new Map(); + const expectedMissingSkin = '/assets/generated/wownero_walk.webp'; + const observedHttpFailures = []; + + app.use((req, res, next) => { + res.setHeader('Content-Security-Policy', [ + "default-src 'self'", "script-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", "font-src 'self'", "connect-src 'self'", "object-src 'none'", + "base-uri 'self'", "frame-ancestors 'self'", "form-action 'self'" + ].join('; ')); + next(); + }); + app.get('/runtime-config.js', (_req, res) => { + res.type('application/javascript').send('window.WOWNGEON_RUNTIME = {rendererLocalEnabled:false, rendererCdnEnabled:false};'); + }); + app.get('/tavern', (_req, res) => res.sendFile(path.join(HTML_ROOT, 'tavern.html'))); + app.use(express.static(HTML_ROOT)); + + io.on('connection', socket => { + socket.on('register_client', data => { + const role = socket.handshake.headers['user-agent']?.match(/WowngeonShowcaseSmoke\/(mica|moth)/)?.[1]; + assert(data && data.clientId === socket.id, 'Page registration did not use its own Socket.IO id'); + assert(role, 'Unexpected browser user agent in local fixture'); + assert(!clientsByRole.has(role), `Duplicate browser role: ${role}`); + clientsByRole.set(role, socket.id); + socket.data.showcaseFixture = role === 'mica'; + }); + socket.on('identity:get', () => socket.emit('identity_update', { + appearance: { avatar: 'default' }, + entitlements: socket.data.showcaseFixture ? FIXTURE_ENTITLEMENTS : { + premium: false, level: 'free', credits: 0, totalCreditsPurchased: 0, packs: {} + } + })); + socket.on('identity:update', data => socket.emit('identity_update', { + appearance: data?.appearance || { avatar: 'default' }, + entitlements: socket.data.showcaseFixture ? FIXTURE_ENTITLEMENTS : { + premium: false, level: 'free', credits: 0, totalCreditsPurchased: 0, packs: {} + } + })); + socket.on('tavern_join', data => manager.join(socket, data)); + socket.on('tavern_move', data => manager.move(socket, data)); + socket.on('tavern_chat', data => manager.chat(socket, data)); + socket.on('disconnect', () => manager.handleDisconnect(socket.id, socket)); + }); + + let browser; + try { + manager.initialize(); + await new Promise((resolve, reject) => { + httpServer.once('error', reject); + httpServer.listen(0, '127.0.0.1', resolve); + }); + const address = httpServer.address(); + assert(address && isLoopback(address.address), 'Local server did not bind to loopback'); + const origin = `http://127.0.0.1:${address.port}`; + + browser = await chromium.launch({ headless: true }); + const views = {}; + for (const role of ['mica', 'moth']) { + const context = await browser.newContext({ userAgent: `WowngeonShowcaseSmoke/${role}` }); + context.on('requestfailed', request => { + const url = new URL(request.url()); + if (!isLoopback(url.hostname) || Number(url.port || 80) !== address.port) { + blockedExternal.push(url.origin); + } + }); + await context.route('**/*', route => { + const url = new URL(route.request().url()); + if (url.protocol === 'data:' || url.protocol === 'blob:') return route.continue(); + if (url.protocol === 'http:' && isLoopback(url.hostname) && Number(url.port || 80) === address.port) { + return route.continue(); + } + blockedExternal.push(url.origin); + return route.abort('blockedbyclient'); + }); + await context.routeWebSocket('**/*', ws => { + const url = new URL(ws.url()); + if (url.protocol === 'ws:' && isLoopback(url.hostname) && Number(url.port || 80) === address.port) { + ws.connectToServer(); + } else { + blockedExternal.push(url.origin); + } + }); + const page = await context.newPage(); + await page.route(`**${expectedMissingSkin}`, route => route.fulfill({ + status: 404, + contentType: 'text/plain', + body: 'Generated skin failure fixture' + })); + page.on('response', response => { + if (response.status() >= 400) { + observedHttpFailures.push({ path: new URL(response.url()).pathname, status: response.status() }); + } + }); + page.on('pageerror', error => browserFailures.push(error.message)); + page.on('console', message => { + if (message.type() !== 'error') return; + const expectedAssetFailure = message.text().includes('Failed to load resource') + && observedHttpFailures.some(f => f.status === 404 && f.path === expectedMissingSkin); + if (!expectedAssetFailure) browserFailures.push(message.text()); + }); + views[role] = { context, page }; + } + + await Promise.all(Object.values(views).map(({ page }) => page.goto(`${origin}/tavern`))); + for (const { page } of Object.values(views)) { + await page.locator('#joinBtn').waitFor({ state: 'visible' }); + await page.waitForFunction(() => document.getElementById('joinBtn') && !document.getElementById('joinBtn').disabled); + } + await views.mica.page.locator('#name').fill('Mica'); + await views.mica.page.locator('#joinBtn').click(); + await views.moth.page.locator('#name').fill('Moth'); + await views.moth.page.locator('#joinBtn').click(); + await Promise.all(Object.values(views).map(({ page }) => page.locator('#stage canvas.rk-canvas').waitFor({ state: 'visible' }))); + await pageWaitFor(() => manager.room.size === 2, 3000, 'Two browser clients joined TavernManager'); + assert(clientsByRole.size === 2, 'Both page-owned client ids were not registered'); + const ids = { mica: clientsByRole.get('mica'), moth: clientsByRole.get('moth') }; + assert(ids.mica && ids.moth && ids.mica !== ids.moth, 'Browser socket ids are not distinct'); + assert(manager.room.getOccupant(ids.mica)?.name === 'Mica', 'Mica page id does not map to its server occupant'); + assert(manager.room.getOccupant(ids.moth)?.name === 'Moth', 'Moth page id does not map to its server occupant'); + + const chatInput = views.mica.page.locator('#chatInput'); + await chatInput.fill('Room check'); + await views.mica.page.locator('#chatForm button[type="submit"]').click(); + await views.moth.page.locator('#chatLog').getByText('Room check').waitFor(); + + const keyboard = views.mica.page; + const micaStart = { ...manager.room.getOccupant(ids.mica) }; + for (const selector of ['#chatInput', 'select', '[contenteditable="true"]']) { + await keyboard.evaluate(selectorText => { + let node = document.querySelector(selectorText); + if (!node) { + node = selectorText === 'select' ? document.createElement('select') : document.createElement('div'); + if (selectorText === '[contenteditable="true"]') node.contentEditable = 'true'; + document.body.appendChild(node); + } + node.focus(); + }, selector); + await keyboard.keyboard.press('ArrowRight'); + await new Promise(resolve => setTimeout(resolve, 100)); + const after = manager.room.getOccupant(ids.mica); + assert(after.x === micaStart.x && after.y === micaStart.y, `Arrow key moved player while ${selector} had focus`); + } + await keyboard.locator('#stage').click({ position: { x: 15, y: 15 } }); + await keyboard.keyboard.press('ArrowRight'); + await pageWaitFor(() => { + const current = manager.room.getOccupant(ids.mica); + return current.x !== micaStart.x || current.y !== micaStart.y; + }, 1500, 'Game-surface arrow key moves the active player'); + + const modeResults = {}; + for (const mode of ['tiles', 'ascii', 'iso']) { + const page = views.mica.page; + await page.locator(`.modebtn[data-mode="${mode}"]`).click(); + await page.waitForFunction(expected => { + const button = document.querySelector(`.modebtn[data-mode="${expected}"]`); + const canvas = document.querySelector('#stage canvas.rk-canvas'); + if (!button || button.getAttribute('aria-pressed') !== 'true' || !canvas) return false; + const ctx = canvas.getContext('2d'); + if (!ctx || !canvas.width || !canvas.height) return false; + const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height).data; + const colors = new Set(); + const sampleCols = 32, sampleRows = 32; + for (let row = 0; row < sampleRows; row++) { + const y = Math.min(canvas.height - 1, Math.floor((row + 0.5) * canvas.height / sampleRows)); + for (let col = 0; col < sampleCols; col++) { + const x = Math.min(canvas.width - 1, Math.floor((col + 0.5) * canvas.width / sampleCols)); + const i = (y * canvas.width + x) * 4; + colors.add(`${pixels[i]},${pixels[i + 1]},${pixels[i + 2]}`); + } + } + return colors.size >= 3; + }, mode); + modeResults[mode] = await page.locator('#stage canvas.rk-canvas').evaluate(canvas => { + const ctx = canvas.getContext('2d'), pixels = ctx.getImageData(0, 0, canvas.width, canvas.height).data; + const colors = new Set(), cols = 32, rows = 32; + for (let row = 0; row < rows; row++) for (let col = 0; col < cols; col++) { + const x = Math.min(canvas.width - 1, Math.floor((col + 0.5) * canvas.width / cols)); + const y = Math.min(canvas.height - 1, Math.floor((row + 0.5) * canvas.height / rows)); + const i = (y * canvas.width + x) * 4; + colors.add(`${pixels[i]},${pixels[i + 1]},${pixels[i + 2]}`); + } + return { width: canvas.width, height: canvas.height, distinctSampleColors: colors.size }; + }); + if (mode === 'iso') { + await page.screenshot({ path: path.join(outputDir, 'tavern-desktop-iso.png'), fullPage: true }); + } + } + + await views.mica.page.evaluate(() => RK.loadSkin('wownero-rogue')); + await views.mica.page.waitForFunction(() => RK.skinSheetFailed('wownero-rogue')); + const skinFailure = await views.mica.page.evaluate(() => ({ + failed: RK.skinSheetFailed('wownero-rogue'), ready: !!RK.skinSheet('wownero-rogue'), failureInjected: true + })); + assert(skinFailure.failed && !skinFailure.ready, 'Missing generated skin did not reach failed fallback state'); + + await Promise.all(Object.values(views).map(({ page }) => page.setViewportSize({ width: 390, height: 844 }))); + const mobile = await views.moth.page.evaluate(() => ({ + viewport: document.documentElement.clientWidth, + document: document.documentElement.scrollWidth, + stage: document.getElementById('stage').clientWidth, + dpadVisible: getComputedStyle(document.getElementById('tavernDpad')).display !== 'none' + })); + assert(mobile.document <= mobile.viewport, 'Tavern overflows the narrow viewport horizontally'); + assert(mobile.stage > 0 && mobile.dpadVisible, 'Mobile stage or movement controls are hidden'); + const mothStart = { ...manager.room.getOccupant(ids.moth) }; + await views.moth.page.getByRole('button', { name: 'Move up' }).click(); + await pageWaitFor(() => { + const current = manager.room.getOccupant(ids.moth); + return current.x !== mothStart.x || current.y !== mothStart.y; + }, 1500, 'Mobile movement control moves the other player'); + + const unexpectedHttpFailures = observedHttpFailures.filter(f => !(f.status === 404 && f.path === expectedMissingSkin)); + assert(observedHttpFailures.some(f => f.status === 404 && f.path === expectedMissingSkin), 'Expected missing generated skin was not requested'); + assert(unexpectedHttpFailures.length === 0, `Unexpected HTTP failures: ${JSON.stringify(unexpectedHttpFailures)}`); + assert(blockedExternal.length === 0, `Browser attempted external network access: ${blockedExternal.join(', ')}`); + assert(browserFailures.length === 0, `Browser errors: ${browserFailures.join(' | ')}`); + + const screenshotPaths = {}; + for (const role of ['mica', 'moth']) { + const filename = `tavern-mobile-${role}.png`; + await views[role].page.screenshot({ path: path.join(outputDir, filename), fullPage: true }); + screenshotPaths[role] = filename; + } + const result = { + ok: true, + scope: 'gameplay-only Tavern browser smoke; no queue/race transitions', + origin: '127.0.0.1 loopback only', + fixture: 'synthetic Iso entitlement fixture; zero credits, no purchase, no generated skins or 3D grant', + clients: { count: clientsByRole.size, mappedByPageOwnedSocketId: true }, + modes: modeResults, + chat: 'broadcast to both clients', + keyboardFocusGuards: ['input', 'select', 'contenteditable'], + mobile, + missingGeneratedSkin: skinFailure, + screenshots: { desktopIso: 'tavern-desktop-iso.png', mobile: screenshotPaths }, + unexpectedHttpFailures, + externalRequestsBlocked: blockedExternal.length, + outputDir + }; + fs.writeFileSync(path.join(outputDir, 'result.json'), `${JSON.stringify(result, null, 2)}\n`, { flag: 'wx' }); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } finally { + if (browser) await browser.close(); + manager.shutdown(); + await new Promise(resolve => io.close(() => resolve())); + if (httpServer.listening) await new Promise(resolve => httpServer.close(() => resolve())); + } +} + +function pageWaitFor(predicate, timeoutMs, message) { + const started = Date.now(); + return new Promise((resolve, reject) => { + const tick = () => { + let value; + try { value = predicate(); } catch (error) { reject(error); return; } + if (value) return resolve(value); + if (Date.now() - started >= timeoutMs) return reject(new Error(`Timed out: ${message}`)); + setTimeout(tick, 25); + }; + tick(); + }); +} + +main().catch(error => { + process.stderr.write(`Showcase browser smoke failed: ${error.message}\n`); + process.exitCode = 1; +}); diff --git a/test/authRateLimiterEviction.test.js b/test/authRateLimiterEviction.test.js index e845b05..e1f94cb 100644 --- a/test/authRateLimiterEviction.test.js +++ b/test/authRateLimiterEviction.test.js @@ -8,7 +8,7 @@ * window), so a map full of long-expired entries stayed full. */ -const express = require('express'); +const express = require('../src/node_modules/express'); const http = require('http'); const createAuthRoutes = require('../src/routes/auth'); diff --git a/test/matchQueue.test.js b/test/matchQueue.test.js index 4f5a797..040181c 100644 --- a/test/matchQueue.test.js +++ b/test/matchQueue.test.js @@ -1,4 +1,6 @@ const MatchQueue = require('../src/network/matchQueue'); +const MatchManager = require('../src/network/matchManager'); +const MatchRoom = require('../src/multiplayer/MatchRoom'); const SocketHandlers = require('../src/network/socketHandlers'); describe('MatchQueue', () => { @@ -238,4 +240,179 @@ describe('SocketHandlers match queue acknowledgements', () => { payload: { success: true, economy: 'free' } }); }); + + test('reconnect restores owned queued entries without joining or charging again', async () => { + const emitted = []; + const socket = { id: 'new-socket', emit: (event, payload) => emitted.push({ event, payload }) }; + const reattach = jest.fn(async (userId, economy, socketId, token) => { + expect(userId).toBe(7); + expect(socketId).toBe(socket.id); + expect(token).toBe('current-session'); + return economy === 'free' || economy === 'credits_prestige' + ? { inQueue: true, position: economy === 'free' ? 2 : 1 } + : { inQueue: false }; + }); + const enqueue = jest.fn(); + const handleReconnect = jest.fn(() => false); + const context = { + matchManager: { enabled: true, handleReconnect }, + matchQueue: { reattach, enqueue }, + _resolveMatchSession: jest.fn(async () => ({ userId: 7, sessionToken: 'current-session' })), + debugManager: { CONSOLE_LOGGING: false } + }; + + await SocketHandlers.prototype._handleMatchReconnect.call(context, socket, { + userId: 999, economy: 'crypto_race', sessionToken: 'untrusted' + }); + + expect(handleReconnect).toHaveBeenCalledWith(socket, expect.objectContaining({ userId: 7 })); + expect(reattach).toHaveBeenCalledWith(7, 'free', socket.id, 'current-session'); + expect(reattach).toHaveBeenCalledWith(7, 'credits_prestige', socket.id, 'current-session'); + expect(reattach).toHaveBeenCalledWith(7, 'crypto_race', socket.id, 'current-session'); + expect(enqueue).not.toHaveBeenCalled(); + expect(emitted.filter(item => item.event === 'match_queue_joined')).toEqual([ + { event: 'match_queue_joined', payload: { + success: true, economy: 'free', position: 2, alreadyQueued: true, restored: true + } }, + { event: 'match_queue_joined', payload: { + success: true, economy: 'credits_prestige', position: 1, alreadyQueued: true, restored: true + } } + ]); + }); + + test('reconnect does not reclaim a queue row while its active match owns the player', async () => { + const socket = { id: 'new-socket', emit: jest.fn() }; + const reattach = jest.fn(); + const context = { + matchManager: { enabled: true, handleReconnect: jest.fn(() => true) }, + matchQueue: { reattach }, + _resolveMatchSession: jest.fn(async () => ({ userId: 7, sessionToken: 'current-session' })) + }; + + await SocketHandlers.prototype._handleMatchReconnect.call(context, socket); + + expect(reattach).not.toHaveBeenCalled(); + expect(socket.emit).not.toHaveBeenCalledWith('match_queue_joined', expect.anything()); + }); + + test('reconnect does not use a client claimed user when session resolution fails', async () => { + const socket = { id: 'new-socket', emit: jest.fn() }; + const handleReconnect = jest.fn(); + const reattach = jest.fn(); + const context = { + matchManager: { enabled: true, handleReconnect }, + matchQueue: { reattach }, + _resolveMatchSession: jest.fn(async () => null) + }; + + await SocketHandlers.prototype._handleMatchReconnect.call(context, socket, { userId: 7 }); + + expect(handleReconnect).not.toHaveBeenCalled(); + expect(reattach).not.toHaveBeenCalled(); + expect(socket.emit).not.toHaveBeenCalled(); + }); +}); + +describe('MatchQueue queued reconnect', () => { + test('updates the durable queued row and memory socket while preserving its place', async () => { + process.env.MATCH_ENABLED = 'true'; + const statements = []; + const queuedRow = { id: 31, created_at: new Date('2026-10-21T10:00:00Z') }; + const db = { query: jest.fn(async (sql, params) => { + statements.push(sql); + expect(params).toEqual(['new-socket', 'current-session', 7, 'free']); + return { rowCount: 1, rows: [queuedRow] }; + }) }; + const queue = new MatchQueue({ db }); + queue._queues.free.push({ + queueEntryId: queuedRow.id, userId: 7, socketId: 'old-socket', + sessionToken: 'old-session', createdAt: queuedRow.created_at.getTime() + }); + try { + const restored = await queue.reattach(7, 'free', 'new-socket', 'current-session'); + expect(restored).toEqual({ inQueue: true, position: 1 }); + expect(queue._queues.free[0]).toMatchObject({ + queueEntryId: queuedRow.id, userId: 7, socketId: 'new-socket', + sessionToken: 'current-session', createdAt: queuedRow.created_at.getTime() + }); + expect(statements).toHaveLength(1); + } finally { + delete process.env.MATCH_ENABLED; + } + }); + + test('a matched or absent durable row cannot be restored from stale memory', async () => { + process.env.MATCH_ENABLED = 'true'; + const db = { query: jest.fn(async () => ({ rowCount: 0, rows: [] })) }; + const queue = new MatchQueue({ db }); + queue._queues.free.push({ userId: 7, socketId: 'old-socket' }); + try { + expect(await queue.reattach(7, 'free', 'new-socket', 'current-session')) + .toEqual({ inQueue: false }); + expect(queue._queues.free[0].socketId).toBe('old-socket'); + } finally { + delete process.env.MATCH_ENABLED; + } + }); + + test('a restored entrant receives the countdown and start on the new socket', async () => { + jest.useFakeTimers(); + process.env.MATCH_ENABLED = 'true'; + const db = { query: jest.fn(async () => ({ + rowCount: 1, rows: [{ id: 31, created_at: new Date('2026-10-21T10:00:00Z') }] + })) }; + const queue = new MatchQueue({ db }); + queue._queues.free.push({ + queueEntryId: 31, userId: 7, socketId: 'old-socket', + sessionToken: 'old-session', createdAt: Date.parse('2026-10-21T10:00:00Z') + }); + const makeSocket = id => ({ + id, channels: new Set(), events: [], + join(channel) { this.channels.add(channel); }, + emit(event, payload) { this.events.push({ event, payload }); } + }); + const oldSocket = makeSocket('old-socket'); + const newSocket = makeSocket('new-socket'); + const otherSocket = makeSocket('other-socket'); + const sockets = new Map([[newSocket.id, newSocket], [otherSocket.id, otherSocket]]); + const io = { + sockets: { sockets }, + to(channel) { + return { emit(event, payload) { + for (const socket of sockets.values()) { + if (socket.channels.has(channel)) socket.emit(event, payload); + } + } }; + } + }; + let manager; + try { + await queue.reattach(7, 'free', newSocket.id, 'current-session'); + const entrant = queue._queues.free[0]; + const room = new MatchRoom({ + economy: 'free', + entrants: { + [entrant.socketId]: { userId: entrant.userId }, + [otherSocket.id]: { userId: 8 } + }, + seed: '0'.repeat(64) + }); + manager = new MatchManager({ io, db: null }); + manager.setEngine(room.id, { start: () => room.start(), stop: () => {} }); + manager.attach(room, [entrant, { userId: 8, socketId: otherSocket.id }], { + countdownMs: 20, minDurationMs: 20000, hardCeilingMs: 60000 + }); + + expect(newSocket.events.some(item => item.event === 'match_joined')).toBe(true); + expect(oldSocket.events).toHaveLength(0); + jest.advanceTimersByTime(20); + expect(room.status).toBe('active'); + expect(newSocket.events.some(item => item.event === 'match_start')).toBe(true); + expect(oldSocket.events).toHaveLength(0); + } finally { + manager?.shutdown(); + jest.useRealTimers(); + delete process.env.MATCH_ENABLED; + } + }); }); diff --git a/test/pvpCaptureHarness.test.js b/test/pvpCaptureHarness.test.js index a0bdcb3..828b848 100644 --- a/test/pvpCaptureHarness.test.js +++ b/test/pvpCaptureHarness.test.js @@ -7,6 +7,7 @@ const { DEFAULT_SEED, assertCaptureSafety, assertCaptureStateEnvironment, + assertFreeRoom, capturePageHtml, capturePresentation, captureResultSummary, @@ -123,6 +124,51 @@ describe('deterministic PvP capture harness', () => { } }); + test('the curated escape race reaches a decisive first exit with no money state', () => { + const config = options({ args: ['--ruleset', 'race', '--ticks', '120'] }); + const room = createCaptureRoom(config); + room.start(); + const events = []; + + for (let tick = 0; tick < config.ticks && room.status === 'active'; tick++) { + const state = room.toGameState(); + for (const botId of room.occupants.keys()) { + const move = planBotMove(botId, state); + if (Math.abs(move.dx) + Math.abs(move.dy) === 1) { + room.queueMove(botId, move.dx, move.dy); + } + } + events.push(...room.resolveTick().events); + } + room.finalize(); + + const exits = events.filter(event => event.type === 'player_exit'); + expect(room.status).toBe('finished'); + expect(room.endReason).toBe('escaped'); + expect(exits).toHaveLength(1); + expect(room.winnerId).toBe(exits[0].id); + expect(room.playerStates.get(room.winnerId).escaped).toBe(true); + expect(assertFreeRoom(room)).toBe(true); + }); + + test('race and co-op use distinct exit completion rules', () => { + for (const rulesetId of ['race', 'coop-escape']) { + const room = createCaptureRoom(options({ args: ['--ruleset', rulesetId] })); + room.start(); + const [firstId] = room.occupants.keys(); + const [x, y] = room.dungeon.exit; + room.occupants.get(firstId).moveTo(x, y); + const result = room.resolveTick(); + + expect(result.events.some(event => event.type === 'player_exit' && event.id === firstId)) + .toBe(true); + expect(result.finished).toBe(rulesetId === 'race'); + expect(room.status).toBe(rulesetId === 'race' ? 'finished' : 'active'); + expect(room.pvpCombat).toBe(false); + expect(assertFreeRoom(room)).toBe(true); + } + }); + test('last-alive bots pursue a rival instead of following the race exit', () => { const move = planBotMove('bot-1', { tick: 1, diff --git a/test/releaseArtifactBuilder.test.js b/test/releaseArtifactBuilder.test.js index 655acc6..c09a4f9 100644 --- a/test/releaseArtifactBuilder.test.js +++ b/test/releaseArtifactBuilder.test.js @@ -40,6 +40,7 @@ function fixture() { write(root, 'src/package-lock.json', '{"name":"fixture","lockfileVersion":3}\n'); write(root, 'src/scripts/preflight.js', "'use strict';\n"); write(root, 'src/scripts/migrate-disposable-clone.js', "'use strict';\n"); + write(root, 'src/scripts/showcase-browser-smoke.js', "throw new Error('source-only browser harness');\n"); write(root, 'src/scripts/pvp-capture.js', "throw new Error('source-only ad harness');\n"); write(root, 'src/scripts/stagenet-financial-canary.js', "throw new Error('source-only transfer canary');\n"); write(root, 'src/scripts/setup_db.js', "throw new Error('source-only database helper');\n"); @@ -136,6 +137,7 @@ describe('release artifact builder', () => { expect(members).not.toContain('candidate-1/scripts/deploy/migrate-monero-stagenet-wallet.sh'); expect(members.some(member => /^candidate-1\/src\/\.env(?:\.|$)/.test(member))).toBe(false); expect(members.some(member => /\/\.env(?:\..*)?\.example$/.test(member))).toBe(false); + expect(members).not.toContain('candidate-1/src/scripts/showcase-browser-smoke.js'); expect(members).not.toContain('candidate-1/src/scripts/pvp-capture.js'); expect(members).not.toContain('candidate-1/src/scripts/stagenet-financial-canary.js'); expect(members).not.toContain('candidate-1/src/scripts/setup_db.js'); diff --git a/test/renderUiFallback.test.js b/test/renderUiFallback.test.js new file mode 100644 index 0000000..bbee651 --- /dev/null +++ b/test/renderUiFallback.test.js @@ -0,0 +1,229 @@ +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +function makeElement(tag = 'div') { + const attributes = {}; + const element = { + tagName: tag.toUpperCase(), + style: {}, + dataset: {}, + children: [], + listeners: {}, + attributes, + disabled: false, + textContent: '', + className: '', + classList: { + values: new Set(), + add(value) { this.values.add(value); }, + remove(value) { this.values.delete(value); }, + contains(value) { return this.values.has(value); }, + toggle(value, force) { + const enabled = force == null ? !this.values.has(value) : !!force; + if (enabled) this.values.add(value); else this.values.delete(value); + return enabled; + } + }, + addEventListener(name, fn) { (this.listeners[name] ||= []).push(fn); }, + dispatch(name, event = {}) { + event.target ||= this; + (this.listeners[name] || []).forEach(fn => fn(event)); + }, + appendChild(child) { this.children.push(child); child.parentNode = this; }, + setAttribute(name, value) { attributes[name] = String(value); }, + querySelectorAll(selector) { + if (selector === '[data-economy]') return this.children.filter(child => child.dataset.economy); + if (selector === '[data-dir]') return this.children.filter(child => child.dataset.dir); + return []; + } + }; + Object.defineProperty(element, 'innerHTML', { + get() { return ''; }, + set() { element.children = []; } + }); + return element; +} + +function loadMatchClient() { + const ids = [ + 'economies', 'status', 'queueBtn', 'leaveBtn', 'queuePanel', 'racePanel', 'raceMode', + 'raceEconomy', 'raceTimer', 'racePlace', 'raceResult', 'stage', 'renderModes', + 'modeTitle', 'modeDescription', 'modeBadge', 'rulesetCatalog', 'economyHelp', + 'objectiveHint', 'matchDpad' + ]; + const elements = Object.fromEntries(ids.map(id => [id, makeElement(id.includes('Btn') ? 'button' : 'div')])); + const socketHandlers = {}; + const windowHandlers = {}; + const emitted = []; + const socket = { + id: 'local-player', connected: true, + on(name, fn) { socketHandlers[name] = fn; }, + emit(name, payload) { emitted.push({ name, payload }); } + }; + const modeList = [ + { id: 'tiles', label: 'Tiled', premium: false }, + { id: 'ascii', label: 'ASCII', premium: false } + ]; + const RK = { + RENDER_MODES: modeList, + loadMode: () => 'tiles', + saveMode: () => {}, + canUseMode: () => true, + modeAvailability: id => ({ usable: true, runtimeAvailable: true }), + createRendererAsync: (mode, host, options, done) => done({ name: mode, destroy() {}, render() {} }) + }; + const MatchCopy = { + normalize: raw => ({ id: raw.id || 'race', label: 'Race', description: 'Reach the exit', badge: 'RACE', objective: 'reach the exit' }), + fromPayload: (data, fallback) => fallback, + plural: () => 'delvers', + liveSummary: () => 'Racing', + finalResult: () => ({ text: 'Finished', mode: { id: 'race' } }) + }; + const window = { + RK, + MatchUiCopy: MatchCopy, + addEventListener(name, fn) { windowHandlers[name] = fn; }, + CommerceConsent: null + }; + const document = { getElementById: id => elements[id], createElement: tag => makeElement(tag) }; + const context = { + window, document, io: () => socket, localStorage: { getItem: () => null, setItem() {} }, + navigator: { userAgent: 'test' }, console, Date, Math, Promise, TextEncoder, + setTimeout: () => 1, clearTimeout() {}, setInterval: () => 1, clearInterval() {}, + Array, Object, Number, String, RegExp + }; + vm.createContext(context); + vm.runInContext(fs.readFileSync(path.join(__dirname, '../html/js/matchClient.js'), 'utf8'), context); + return { elements, socket, socketHandlers, windowHandlers, emitted, modeList }; +} + +describe('renderer controls and failed skin fallback', () => { + test('match render mode announces its selected state and updates it after a switch', () => { + const { elements, socketHandlers } = loadMatchClient(); + socketHandlers.game_mode_info({ modes: { match: { enabled: true, economies: { free: true }, rulesets: [{ id: 'race' }] } } }); + let [tiled, ascii] = elements.renderModes.children; + expect(tiled.attributes['aria-pressed']).toBe('true'); + expect(ascii.attributes['aria-pressed']).toBe('false'); + + ascii.dispatch('click'); + [tiled, ascii] = elements.renderModes.children; + expect(tiled.attributes['aria-pressed']).toBe('false'); + expect(ascii.attributes['aria-pressed']).toBe('true'); + }); + + test('a restored queue selects the server-returned economy and exposes the leave action', () => { + const { elements, socketHandlers } = loadMatchClient(); + socketHandlers.game_mode_info({ modes: { match: { + enabled: true, + economies: { free: true, credits_prestige: true }, + rulesets: [{ id: 'race' }] + } } }); + + socketHandlers.match_queue_joined({ success: true, economy: 'credits_prestige', position: 2, restored: true }); + + const [free, prestige] = elements.economies.children; + expect(free.attributes['aria-pressed']).toBe('false'); + expect(prestige.attributes['aria-pressed']).toBe('true'); + expect(elements.queueBtn.classList.contains('hidden')).toBe(true); + expect(elements.leaveBtn.classList.contains('hidden')).toBe(false); + expect(elements.status.textContent).toContain('Queue restored for Prestige'); + }); + + test.each([ + ['INPUT', false], ['TEXTAREA', false], ['SELECT', false], ['DIV', true] + ])('match movement ignores text-entry and select targets (%s)', (tagName, contentEditable) => { + const { socketHandlers, windowHandlers, emitted } = loadMatchClient(); + socketHandlers.match_start({ ruleset: { id: 'race' } }); + const target = { tagName, isContentEditable: contentEditable }; + let prevented = false; + windowHandlers.keydown({ key: 'ArrowUp', target, preventDefault() { prevented = true; } }); + expect(prevented).toBe(false); + expect(emitted.some(event => event.name === 'match_move')).toBe(false); + }); + + test('match movement ignores contenteditable targets but still moves from the game surface', () => { + const { socketHandlers, windowHandlers, emitted } = loadMatchClient(); + socketHandlers.match_start({ ruleset: { id: 'race' } }); + let prevented = false; + windowHandlers.keydown({ key: 'ArrowUp', target: { tagName: 'DIV', isContentEditable: true }, preventDefault() { prevented = true; } }); + expect(prevented).toBe(false); + + windowHandlers.keydown({ key: 'ArrowUp', target: { tagName: 'DIV' }, preventDefault() { prevented = true; } }); + expect(prevented).toBe(true); + expect(emitted.some(event => event.name === 'match_move' && event.payload.dx === 0 && event.payload.dy === -1)).toBe(true); + }); + + test('a failed premium skin completes pending loads and draws a free character fallback', () => { + const images = []; + const RK = { + CHARS: { 'char-villager': { frame: 1 } }, + normalizeAppearance: appearance => ({ avatar: typeof appearance === 'string' ? appearance : appearance.avatar || 'default' }), + appearance: appearance => ({ label: appearance.avatar }), + isChar: id => id === 'char-villager', + charAppearance: appearance => ({ avatar: appearance.avatar, tint: 'none', colors: {}, equipment: {} }), + charAtlas: () => ({}), + charFrame: () => ({ squash: 1, bob: 0 }), + drawCharTileCanvas: jest.fn(), + charOverlayParts: () => [] + }; + const context = { + window: null, + Image: class { constructor() { images.push(this); } set src(value) { this.url = value; } }, + console: { warn: jest.fn() } + }; + context.window = context; + context.RK = RK; + vm.createContext(context); + vm.runInContext(fs.readFileSync(path.join(__dirname, '../html/js/render/skins.js'), 'utf8'), context); + vm.runInContext(fs.readFileSync(path.join(__dirname, '../html/js/render/avatarVisuals.js'), 'utf8'), context); + + const visual = RK.avatarVisuals.resolve({ avatar: 'wownero-rogue' }, { projection: 'topdown' }); + const ready = jest.fn(); + const canvas = { beginPath() {}, ellipse() {}, stroke() {} }; + const entity = { id: 'me', x: 1, y: 1, facing: 'down', you: true }; + expect(RK.avatarVisuals.drawTopdownWorld(canvas, visual, entity, { screenX: 1, screenY: 1, cell: 24 }, { onReady: ready })).toBe(false); + expect(images).toHaveLength(1); + + images[0].onerror(); + expect(ready).toHaveBeenCalledTimes(1); + expect(RK.skinSheetFailed('wownero-rogue')).toBe(true); + expect(RK.avatarVisuals.drawTopdownWorld(canvas, visual, entity, { screenX: 1, screenY: 1, cell: 24 }, {})).toBe(true); + expect(RK.drawCharTileCanvas).toHaveBeenCalled(); + }); + + test('a normal character still waits for its atlas and resolves the render callback', () => { + let atlasReady; + let atlasLoaded = false; + const RK = { + CHARS: { 'char-villager': { frame: 1 } }, + normalizeAppearance: appearance => ({ avatar: typeof appearance === 'string' ? appearance : appearance.avatar || 'default' }), + appearance: appearance => ({ label: appearance.avatar }), + isChar: id => id === 'char-villager', + charAtlas: () => atlasLoaded ? {} : null, + loadCharAtlas: callback => { atlasReady = callback; }, + charAppearance: appearance => ({ avatar: appearance.avatar, tint: 'none', colors: {}, equipment: {} }), + charFrame: () => ({ squash: 1, bob: 0 }), + drawCharTileCanvas: jest.fn(), + charOverlayParts: () => [] + }; + const context = { window: null, Image: class {}, console }; + context.window = context; + context.RK = RK; + vm.createContext(context); + vm.runInContext(fs.readFileSync(path.join(__dirname, '../html/js/render/skins.js'), 'utf8'), context); + vm.runInContext(fs.readFileSync(path.join(__dirname, '../html/js/render/avatarVisuals.js'), 'utf8'), context); + + const visual = RK.avatarVisuals.resolve({ avatar: 'char-villager' }, { projection: 'topdown' }); + const ready = jest.fn(); + const canvas = { beginPath() {}, ellipse() {}, stroke() {} }; + const entity = { id: 'me', x: 1, y: 1, facing: 'down', you: true }; + expect(RK.avatarVisuals.drawTopdownWorld(canvas, visual, entity, { screenX: 1, screenY: 1, cell: 24 }, { onReady: ready })).toBe(false); + + atlasLoaded = true; + atlasReady(); + expect(ready).toHaveBeenCalledTimes(1); + expect(RK.avatarVisuals.drawTopdownWorld(canvas, visual, entity, { screenX: 1, screenY: 1, cell: 24 }, {})).toBe(true); + expect(RK.drawCharTileCanvas).toHaveBeenCalled(); + }); +}); diff --git a/test/smirkInvoiceContract.test.js b/test/smirkInvoiceContract.test.js new file mode 100644 index 0000000..ac3df5a --- /dev/null +++ b/test/smirkInvoiceContract.test.js @@ -0,0 +1,58 @@ +const PaymentHandlers = require('../src/network/paymentHandlers'); +const GameModeManager = require('../src/game/gameModeManager'); +const ConnectionHandler = require('../src/network/connectionHandler'); +const SocketHandlers = require('../src/network/socketHandlers'); +const { paymentRequest } = require('../html/js/network/smirkPaymentAdapter'); + +const row = { + id: 31, user_id: 7, subaddress: 'WW3UyJFX6GPKdM21APyVYjGhfX3ebyHvPPgtFC3wHxkYTCGg8k2UM9DLTCzcgoaLHSKnvJKRtCPgJ1sxvgYiFCoK19vLk83Jn', + expected_amount: '123456789012', description: '10 credit package', payment_type: 'credits_package', + expires_at: new Date(Date.now() + 3600000).toISOString() +}; + +test.each([false, true])('authenticated session events expose the invoice owner without deriving it from the browser: resumed=%s', resumed => { + const emit = jest.fn(); + ConnectionHandler.prototype._emitSessionEvents.call({ io: { to: () => ({ emit }) } }, + { id: 'fixture-socket' }, { resumed, token: 'fixture-session', user: { id: 7 } }); + expect(emit).toHaveBeenCalledWith(resumed ? 'session_resumed' : 'session_token', expect.objectContaining({ userId: 7 })); +}); + +test.each(['request', 'show', 'restore'])('the %s invoice path supplies an exact, spendable WOW contract', async path => { + const manager = Object.create(GameModeManager.prototype); + Object.assign(manager, { + cryptoType: 'WOW', network: 'mainnet', currencyDecimals: 11, + isPaymentIntakeEnabled: () => true, + formatAtomicHuman: () => '1.2346', + gameMode: 'PAID_CREDITS', creditsPackagePrice: row.expected_amount, + getPrimaryCreditPackage: () => ({ price: row.expected_amount, credits: 10 }), + createPaymentRequest: jest.fn(async () => manager._mapPaymentRowToRequest(row, 'credits_package', { credits: 10 })), + db: { query: jest.fn(async () => ({ rows: [row] })) } + }); + const emitted = []; + const io = { to: () => ({ emit: (event, data) => emitted.push([event, data]) }) }; + const handlers = new PaymentHandlers({ + io, gameModeManager: manager, debugManager: { CONSOLE_LOGGING: false }, + queueManager: { getUserBySocket: () => ({ serverId: 'fixture-socket' }) }, + broadcastManager: { sendStatusUpdate: jest.fn() }, + walletService: {} + }); + handlers._requirePaidAcknowledgement = () => ({ policyVersion: 'fixture' }); + handlers._allowInvoiceFlowForReserve = async () => true; + handlers._ensureRequiredPayoutAddress = async () => ({ ok: true }); + handlers._monitorAddress = jest.fn(); + const socket = { id: 'fixture-socket', emit: (event, data) => emitted.push([event, data]) }; + try { + if (path === 'request') await handlers.handlePaymentRequest(socket, { type: 'credits_package' }); + else if (path === 'show') await handlers.createAndShowPaymentRequest(socket, { paymentType: 'credits_package' }); + else await SocketHandlers.prototype._restorePaymentMonitoring.call({ + gameModeManager: manager, paymentHandlers: handlers, debugManager: { CONSOLE_LOGGING: false }, + connectionHandler: { getUserBySocket: () => ({ serverId: socket.id }) }, + broadcastManager: { sendStatusUpdate: jest.fn() } + }, socket, 7); + const payload = emitted.find(([event]) => event === 'payment_created')?.[1]; + expect(payload).toMatchObject({ ownerUserId: 7, asset: 'wow', network: 'mainnet', humanAmount: '1.23456789012', memo: row.description }); + expect(paymentRequest(payload)).toEqual({ asset: 'wow', amount: '1.23456789012', address: row.subaddress, memo: row.description }); + } finally { + handlers.dispose(); + } +}); diff --git a/test/smirkLoginClient.test.js b/test/smirkLoginClient.test.js new file mode 100644 index 0000000..7256b85 --- /dev/null +++ b/test/smirkLoginClient.test.js @@ -0,0 +1,116 @@ +const fs = require('fs'); +const vm = require('vm'); + +function loadAuth(wallet) { + const context = { + window: { smirk: wallet, socket: { id: 'fixture-socket', on() {}, emit: jest.fn() }, location: { origin: 'https://game.test' } }, + localStorage: { getItem: () => 'fixture-session' }, + document: {}, URL, console: { log() {}, warn() {}, error() {} }, setTimeout() {}, + $: () => ({ ready() {}, prop() { return this; }, css() { return this; }, text() { return this; }, remove() {} }), + SocketHandlers: { _smirkEnabled: true }, + fetch: jest.fn(async path => ({ + ok: true, + json: async () => path.endsWith('challenge') ? { challenge: 'game-fixture-challenge' } : { linked: true } + })) + }; + vm.createContext(context); + vm.runInContext(fs.readFileSync(require.resolve('../html/js/network/smirkAuth'), 'utf8') + '\nthis.auth = SmirkAuth;', context); + return context; +} +function walletFixture() { + return { + connect: jest.fn().mockResolvedValue({ wow: 'fixture-wow-key' }), + signNostrEvent: jest.fn().mockResolvedValue({ pubkey: 'fixture-nostr-key' }), + getNostrPublicKey: jest.fn().mockResolvedValue('fixture-nostr-key'), + getAddresses: jest.fn().mockResolvedValue({ wow: 'fixture-wow-address' }), + getPublicKeys: jest.fn().mockResolvedValue({ wow: 'fixture-wow-key' }), + signMessage: jest.fn().mockResolvedValue({ signatures: [{ asset: 'wow', publicKey: 'legacy-key', signature: 'fixture-proof' }] }) + }; +} + +describe('Smirk client identity and WOW grant boundaries', () => { + test('keeps Nostr identity while explicitly acquiring only WOW asset scope', async () => { + const wallet = walletFixture(); + const { auth } = loadAuth(wallet); + await auth.login(); + expect(wallet.connect).toHaveBeenCalledWith(['wow']); + expect(wallet.signNostrEvent).toHaveBeenCalledTimes(1); + expect(wallet.signMessage).not.toHaveBeenCalled(); + }); + + test('does not switch account credential method after Nostr transport failure', async () => { + const wallet = walletFixture(); + wallet.signNostrEvent.mockRejectedValue(Object.assign(new Error('transport interrupted'), { code: 'TIMEOUT' })); + const { auth } = loadAuth(wallet); + await expect(auth.login()).rejects.toThrow('transport interrupted'); + expect(wallet.signMessage).not.toHaveBeenCalled(); + }); + + test('WOW scope denial stops the flow without another signature popup', async () => { + const wallet = walletFixture(); + wallet.connect.mockRejectedValue(Object.assign(new Error('denied'), { code: 'USER_REJECTED' })); + const { auth } = loadAuth(wallet); + await expect(auth.login()).rejects.toMatchObject({ code: 'USER_REJECTED' }); + expect(wallet.signNostrEvent).not.toHaveBeenCalled(); + expect(wallet.signMessage).not.toHaveBeenCalled(); + }); + + test('a declined Nostr grant does not retry signing or switch credentials', async () => { + const wallet = walletFixture(); + wallet.signNostrEvent.mockRejectedValue(Object.assign(new Error('scope required'), { code: 'NOT_AUTHORIZED' })); + wallet.getNostrPublicKey.mockResolvedValue(null); + const { auth } = loadAuth(wallet); + await expect(auth.login()).rejects.toThrow(); + expect(wallet.signNostrEvent).toHaveBeenCalledTimes(1); + expect(wallet.signMessage).not.toHaveBeenCalled(); + }); + + test('legacy-only wallets still prove the same credential with an explicit WOW grant', async () => { + const wallet = walletFixture(); + delete wallet.signNostrEvent; + delete wallet.getNostrPublicKey; + const { auth } = loadAuth(wallet); + await auth.login(); + expect(wallet.connect).toHaveBeenCalledTimes(1); + expect(wallet.connect).toHaveBeenCalledWith(['wow']); + expect(wallet.signMessage).toHaveBeenCalledTimes(1); + }); + + test('backend account linkage does not imply a WOW grant and never prompts on status check', async () => { + const wallet = walletFixture(); + wallet.getPublicKeys.mockResolvedValue({ wow: null }); + const { auth } = loadAuth(wallet); + await auth.checkStatus(); + expect(auth._isLinked).toBe(true); + expect(auth._wowReady).toBe(false); + expect(wallet.connect).not.toHaveBeenCalled(); + await auth.requestWowAccess(); + expect(auth._wowReady).toBe(true); + expect(wallet.signNostrEvent).not.toHaveBeenCalled(); + }); + + test.each(['forbidden', 'offline'])('failed status lookup clears stale account and scope: %s', async failure => { + const context = loadAuth(walletFixture()); + context.auth._isLinked = true; + context.auth._wowReady = true; + context.fetch.mockImplementation(async () => { + if (failure === 'offline') throw new Error('offline fixture'); + return { ok: false }; + }); + await context.auth.checkStatus(); + expect(context.auth._isLinked).toBe(false); + expect(context.auth._wowReady).toBe(false); + }); + + test('a prior account status response cannot relink the current session', async () => { + const context = loadAuth(walletFixture()); + let finish; + context.fetch.mockImplementation(() => new Promise(resolve => { finish = resolve; })); + const pending = context.auth.checkStatus(); + context.SocketHandlers._paymentSessionEpoch = 2; + finish({ ok: true, json: async () => ({ linked: true }) }); + await pending; + expect(context.auth._isLinked).toBe(false); + expect(context.auth._wowReady).toBe(false); + }); +}); diff --git a/test/smirkPaymentAdapter.test.js b/test/smirkPaymentAdapter.test.js new file mode 100644 index 0000000..b0aebe5 --- /dev/null +++ b/test/smirkPaymentAdapter.test.js @@ -0,0 +1,138 @@ +const { createAdapter, paymentRequest } = require('../html/js/network/smirkPaymentAdapter'); + +const NOW = 1800000000000; +function invoice(extra = {}) { + return { + paymentId: 31, ownerUserId: 7, asset: 'wow', network: 'mainnet', + amount: '123456789012', humanAmount: '1.23456789012', + address: 'Wo' + '1'.repeat(95), expiresAt: new Date(NOW + 60000).toISOString(), + paymentType: 'credits_package', memo: 'Buy 10 credits', package: { credits: 10 }, ...extra + }; +} +function setup(extra = {}) { + const values = new Map(); + const storage = { + getItem: key => values.get(key) || null, + setItem: (key, value) => values.set(key, value), + removeItem: key => values.delete(key) + }; + let tail = Promise.resolve(); + const withLock = (_name, work) => { + const result = tail.then(work); + tail = result.catch(() => {}); + return result; + }; + const options = { storage, withLock, crypto: require('crypto').webcrypto, now: () => NOW, ...extra }; + return { adapter: createAdapter(options), options, values }; +} + +describe('Smirk WOW invoice contract', () => { + test('uses exact server decimal and memo without inventing wallet parameters', () => { + expect(paymentRequest(invoice(), NOW)).toEqual({ + asset: 'wow', amount: '1.23456789012', address: invoice().address, memo: 'Buy 10 credits' + }); + }); + + test('accepts a WOW RPC subaddress as well as a standard address', () => { + const address = 'WW3UyJFX6GPKdM21APyVYjGhfX3ebyHvPPgtFC3wHxkYTCGg8k2UM9DLTCzcgoaLHSKnvJKRtCPgJ1sxvgYiFCoK19vLk83Jn'; + expect(paymentRequest(invoice({ address }), NOW).address).toBe(address); + }); + + test('restored invoices keep the persisted memo without package display metadata', () => { + expect(paymentRequest(invoice({ package: undefined, restored: true }), NOW).memo).toBe('Buy 10 credits'); + }); + + test.each([ + { humanAmount: undefined, amountFormatted: undefined }, + { humanAmount: '0' }, { humanAmount: '-1' }, { humanAmount: '1e-3' }, + { humanAmount: '1.000000000001' }, { humanAmount: 1 }, + { humanAmount: '1.23' }, { amount: '1.2' }, + { asset: 'xmr' }, { asset: undefined }, { network: 'stagenet' }, { network: undefined }, + { paymentId: undefined }, { ownerUserId: undefined }, { address: 'not-a-wow-address' }, { memo: undefined }, + { expiresAt: undefined }, { expiresAt: new Date(NOW).toISOString() } + ])('refuses invalid or mismatched invoice before wallet invocation: %j', async extra => { + const { adapter } = setup(); + const wallet = { requestPayment: jest.fn() }; + await expect(adapter.pay(invoice(extra), wallet)).rejects.toThrow(); + expect(wallet.requestPayment).not.toHaveBeenCalled(); + }); +}); + +describe('Smirk submission evidence and repeat protection', () => { + test.each([ + [{ success: true, txid: 'a'.repeat(64) }, 'submitted'], + [{ success: true }, 'unknown'], + [{ success: false }, 'failed'], + [{ success: false, txid: 'a'.repeat(64) }, 'unknown'], + [undefined, 'unknown'], [true, 'unknown'], [{ txid: 'a'.repeat(64) }, 'unknown'], + [{ success: true, txid: 'malformed' }, 'unknown'] + ])('classifies wallet result %j as %s without confirming credits', async (result, state) => { + const { adapter } = setup(); + const wallet = { requestPayment: jest.fn().mockResolvedValue(result) }; + expect(await adapter.pay(invoice(), wallet)).toMatchObject({ state }); + expect(wallet.requestPayment).toHaveBeenCalledTimes(1); + expect(await adapter.pay(invoice(), wallet)).toMatchObject({ state, repeated: true }); + expect(wallet.requestPayment).toHaveBeenCalledTimes(1); + }); + + test.each([['USER_REJECTED', 'cancelled'], ['NOT_AUTHORIZED', 'unavailable'], ['TIMEOUT', 'unknown']])( + 'classifies %s and never automatically retries', async (code, state) => { + const { adapter } = setup(); + const wallet = { requestPayment: jest.fn().mockRejectedValue(Object.assign(new Error('fixture'), { code })) }; + expect(await adapter.pay(invoice(), wallet)).toMatchObject({ state }); + await adapter.pay(invoice(), wallet); + expect(wallet.requestPayment).toHaveBeenCalledTimes(1); + } + ); + + test('coordinates concurrent events, two tabs and a page reload for the same invoice', async () => { + const { adapter, options } = setup(); + const secondTab = createAdapter(options); + const wallet = { requestPayment: jest.fn().mockResolvedValue({ success: true }) }; + await Promise.all([adapter.pay(invoice(), wallet), secondTab.pay(invoice(), wallet)]); + const reloaded = createAdapter(options); + expect(await reloaded.pay(invoice({ reused: true }), wallet)).toMatchObject({ state: 'unknown', repeated: true }); + expect(wallet.requestPayment).toHaveBeenCalledTimes(1); + }); + + test('records intent before invoking a wallet and retains unknown after interruption', async () => { + const { adapter, options } = setup(); + const wallet = { requestPayment: jest.fn(async () => { + expect(createAdapter(options).status(invoice()).state).toBe('unknown'); + throw new Error('Transport closed after possible submission'); + }) }; + expect(await adapter.pay(invoice(), wallet)).toMatchObject({ state: 'unknown' }); + expect(createAdapter(options).status(invoice())).toMatchObject({ state: 'unknown' }); + }); + + test.each([ + { ownerUserId: 8 }, { memo: 'different purchase' }, + { humanAmount: '2', amount: '200000000000' }, + { expiresAt: new Date(NOW + 120000).toISOString() } + ])('refuses changed immutable invoice fields after an attempt: %j', async extra => { + const { adapter } = setup(); + const wallet = { requestPayment: jest.fn().mockResolvedValue({ success: true }) }; + await adapter.pay(invoice(), wallet); + await expect(adapter.pay(invoice(extra), wallet)).rejects.toThrow(/changed/i); + expect(wallet.requestPayment).toHaveBeenCalledTimes(1); + }); + + test('rechecks session admission inside the cross-tab lock before writing intent', async () => { + const { adapter } = setup(); + const wallet = { requestPayment: jest.fn() }; + await expect(adapter.pay(invoice(), wallet, () => { throw new Error('Account changed'); })).rejects.toThrow('Account changed'); + expect(wallet.requestPayment).not.toHaveBeenCalled(); + expect(adapter.status(invoice())).toBeNull(); + }); + + test.each([ + { withLock: null }, + { storage: null }, + { storage: { getItem() { throw new Error('blocked'); }, setItem() {} } } + ])('refuses unsafe repeat protection before requesting funds', async options => { + const { adapter } = setup(options); + const wallet = { requestPayment: jest.fn() }; + await expect(adapter.pay(invoice(), wallet)).rejects.toThrow(); + expect(wallet.requestPayment).not.toHaveBeenCalled(); + }); +}); diff --git a/test/smirkPaymentConsent.test.js b/test/smirkPaymentConsent.test.js new file mode 100644 index 0000000..5456a7f --- /dev/null +++ b/test/smirkPaymentConsent.test.js @@ -0,0 +1,56 @@ +const handlers = require('../html/js/network/socketHandlers'); + +test('new, duplicate and recovered invoice events display details without opening a wallet', () => { + const previous = handlers._showPaymentFlow; + handlers._showPaymentFlow = jest.fn(); + global.SmirkAuth = { _isLinked: true, isAvailable: () => true }; + global.window = { smirk: { requestPayment: jest.fn() } }; + const payment = jest.spyOn(handlers, '_trySmirkPayment').mockResolvedValue(); + try { + handlers.onPaymentCreated({ paymentId: 8 }); + handlers.onPaymentCreated({ paymentId: 8 }); + handlers.onPaymentCreated({ paymentId: 8, reused: true }); + expect(payment).not.toHaveBeenCalled(); + expect(handlers._showPaymentFlow).toHaveBeenCalledTimes(3); + } finally { + payment.mockRestore(); + handlers._showPaymentFlow = previous; + delete global.SmirkAuth; + delete global.window; + } +}); + +test('account changes invalidate invoice consent and cannot submit an old owner invoice', async () => { + const chain = { + append() { return this; }, text: jest.fn(function() { return this; }), + prop: jest.fn(function() { return this; }), remove: jest.fn(), + hide: jest.fn(), val: jest.fn(), removeData: jest.fn(function() { return this; }) + }; + global.$ = () => chain; + global.UI = { scrollChat() {} }; + global.localStorage = { setItem() {} }; + global.SmirkAuth = { _isLinked: true, _wowReady: true }; + global.window = { smirk: { requestPayment: jest.fn() } }; + const refresh = jest.spyOn(handlers, '_refreshSmirkLinked').mockImplementation(() => {}); + const address = jest.spyOn(handlers, '_updateAddressButtonStatus').mockImplementation(() => {}); + const adapter = jest.spyOn(handlers, '_getSmirkPaymentAdapter').mockReturnValue({ pay: jest.fn() }); + const hideQR = jest.spyOn(handlers, 'hidePaymentQR').mockImplementation(() => {}); + try { + Object.assign(handlers, { _smirkEnabled: true, _cryptoType: 'WOW', _network: 'mainnet', _sessionUserId: 7, _activePaymentId: 31 }); + handlers.onSessionResumed({ token: 'fixture-session', userId: 8 }); + expect(handlers._sessionUserId).toBe(8); + expect(handlers._activePaymentId).toBeNull(); + expect(chain.val).toHaveBeenCalledWith(''); + expect(chain.removeData).toHaveBeenCalledWith('payment-id'); + expect(hideQR).toHaveBeenCalled(); + expect(SmirkAuth._isLinked).toBe(false); + expect(SmirkAuth._wowReady).toBe(false); + await handlers._trySmirkPayment({ paymentId: 31, ownerUserId: 7 }); + expect(adapter).not.toHaveBeenCalled(); + expect(window.smirk.requestPayment).not.toHaveBeenCalled(); + } finally { + refresh.mockRestore(); address.mockRestore(); adapter.mockRestore(); hideQR.mockRestore(); + for (const key of ['$', 'UI', 'localStorage', 'SmirkAuth', 'window']) delete global[key]; + handlers._sessionUserId = null; + } +});