fix(showcase): qualify Tavern graphics and harden Smirk checkout (#1)

Validated by the full Linux source suite, local Chromium gameplay smoke, and three deterministic Escape Race captures. Production activation and installed-wallet acceptance remain separate.
This commit is contained in:
John Winter Murphy
2026-10-21 12:47:19 -04:00
committed by GitHub
32 changed files with 1752 additions and 205 deletions
+3
View File
@@ -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.
+7 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+82
View File
@@ -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.
+9
View File
@@ -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 <directory>` 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.
+1
View File
@@ -117,6 +117,7 @@
<script src="js/ui/leaderboard.js"></script>
<script src="js/ui/helpModal.js"></script>
<script src="js/ui/publicStats.js"></script>
<script src="js/network/smirkPaymentAdapter.js"></script>
<script src="js/network/socketHandlers.js"></script>
<script src="js/network/smirkAuth.js"></script>
<script src="js/input/inputHandler.js"></script>
+12 -3
View File
@@ -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'); }
+93 -62
View File
@@ -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($('<li class="status">').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($('<li class="status">').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');
}
});
}
+120
View File
@@ -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 };
});
+120 -65
View File
@@ -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($('<li class="status">').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($('<li class="status">').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($('<li class="status">').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($('<li class="status" style="color:#4ade80;">').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('<span style="color:#4ade80;">Payment sent via Smirk, awaiting confirmation...</span>');
_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($('<li class="status">').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($('<li class="status">').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($('<li class="status">').text(
'Smirk payment cancelled. Use the address below.'
));
} else {
// Extension error: invalidated context, service worker issue, and similar.
$('#messages').append($('<li class="status" style="color:#f59e0b;">').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 = $('<button id="smirkPayButton" type="button" class="menu-button">').text('Pay with Smirk · WOW mainnet');
const consentEpoch = SocketHandlers._paymentSessionEpoch;
button.on('click', () => SocketHandlers._trySmirkPayment(data, button, consentEpoch));
$('#payment-details').append(button);
} catch (err) {
$('#payment-status').text('Smirk unavailable: ' + err.message);
}
},
@@ -1062,9 +1101,14 @@ const SocketHandlers = {
* Show normal payment flow: modal + chat message + QR code
*/
_showPaymentFlow: function(data) {
SocketHandlers._activePaymentId = data.paymentId;
SocketHandlers._activePaymentData = data;
$('#check-payment').prop('disabled', false);
$('#copy-address, .copy-pay-address').prop('disabled', false);
if (typeof PaymentUI !== 'undefined') {
PaymentUI.showPaymentRequest(data);
}
SocketHandlers._showSmirkPaymentButton(data);
const parts = [];
const reusedCopy = data.reused
@@ -1152,6 +1196,12 @@ const SocketHandlers = {
qrHolder.textContent = 'QR unavailable';
}
}
if (typeof SmirkPayments !== 'undefined') {
try {
const previous = SocketHandlers._getSmirkPaymentAdapter().status(data);
if (previous) SocketHandlers._protectSubmittedPayment(previous.state);
} catch (_) { /* An unreadable history already refuses the wallet button. */ }
}
},
// Removes the payment QR container from the sidebar.
@@ -1271,6 +1321,11 @@ const SocketHandlers = {
onPaymentConfirmed: function(data) {
console.log('Payment confirmed in block:', data);
if (!data || !data.paymentId) return; // A payload without a paymentId cannot be deduped.
if (String(SocketHandlers._activePaymentId) === String(data.paymentId)) {
SocketHandlers._activePaymentId = null;
SocketHandlers._activePaymentData = null;
$('#smirkPayButton').remove();
}
SocketHandlers._expireOldClientPaymentMarkers();
if (SocketHandlers._lastDisplayedConfirmation === data.paymentId) return;
SocketHandlers._lastDisplayedConfirmation = data.paymentId;
+13 -3
View File
@@ -149,6 +149,7 @@
return false;
}
if (visual.kind === 'skin') {
if (RK.skinSheetFailed && RK.skinSheetFailed(visual.appearance.avatar)) return false;
if (RK.skinSheet && RK.skinSheet(visual.appearance.avatar)) return true;
if (!RK.loadSkin) return false;
rememberLoad('skin:' + visual.appearance.avatar, function (done) {
@@ -294,9 +295,18 @@
function drawTopdownWorld(ctx, visual, entity, viewport, opts) {
opts = opts || {};
visual = visual && visual.appearance ? visual : resolve(visual, opts);
if (!ctx || !entity || !viewport || !visual.allowed || !ensureCanvasReady(visual, opts.onReady)) return false;
if (visual.kind === 'char') return drawTopdownChar(ctx, visual, entity, viewport, opts);
if (visual.kind === 'skin') return drawTopdownSkin(ctx, visual, entity, viewport, opts);
if (!ctx || !entity || !viewport || !visual.allowed) return false;
if (visual.kind === 'char') {
if (!ensureCanvasReady(visual, opts.onReady)) return false;
return drawTopdownChar(ctx, visual, entity, viewport, opts);
}
if (visual.kind === 'skin') {
if (ensureCanvasReady(visual, opts.onReady)) return drawTopdownSkin(ctx, visual, entity, viewport, opts);
if (RK.skinSheetFailed && RK.skinSheetFailed(visual.appearance.avatar)) {
var fallback = resolve({ avatar: 'char-villager' }, { projection: 'topdown', context: visual.context, entity: entity });
if (ensureCanvasReady(fallback, opts.onReady)) return drawTopdownChar(ctx, fallback, entity, viewport, opts);
}
}
return false;
}
+13 -4
View File
@@ -41,18 +41,27 @@
var s = RK.SKINS[id];
if (!s) return null;
if (sheets[id]) return sheets[id];
var rec = sheets[id] = { ready: false, cbs: [] };
var rec = sheets[id] = { ready: false, failed: false, cbs: [] };
var img = new Image();
img.onload = function () {
rec.ready = true; rec.img = img;
rec.cbs.forEach(function (cb) { try { cb(rec); } catch (_) {} });
rec.cbs = [];
settle(rec);
};
img.onerror = function () {
rec.failed = true;
if (root.console) console.warn('skin sheet failed:', s.sheet);
settle(rec);
};
img.onerror = function () { if (root.console) console.warn('skin sheet failed:', s.sheet); };
img.src = s.sheet;
return rec;
};
function settle(rec) {
var callbacks = rec.cbs.slice();
rec.cbs.length = 0;
callbacks.forEach(function (cb) { try { cb(rec); } catch (_) {} });
}
RK.skinSheet = function (id) { return (sheets[id] && sheets[id].ready) ? sheets[id] : null; };
RK.skinSheetFailed = function (id) { return !!(sheets[id] && sheets[id].failed); };
// --- Movement-aware frame selection ------------------------------------------------------
var last = {}; // entity id -> { x, y, t }
+1 -1
View File
@@ -147,7 +147,7 @@
<span id="raceTimer"></span>
<span id="racePlace"></span>
</div>
<div id="renderModes" aria-label="Render style"></div>
<div id="renderModes" role="group" aria-label="Render style"></div>
</div>
<div id="stage" aria-label="Live multiplayer dungeon"></div>
<div id="raceResult" class="hidden" role="status"></div>
+7 -4
View File
@@ -125,8 +125,8 @@
<p id="joinStatus" role="status" aria-live="polite">Connecting to the tavern…</p>
</form>
<div id="modebar" class="hidden"></div>
<div id="packbar" class="hidden"></div>
<div id="modebar" class="hidden" role="group" aria-label="Render style"></div>
<div id="packbar" class="hidden" role="group" aria-label="Visual pack"></div>
<div id="stage" class="hidden" aria-label="Shared tavern room"></div>
<div id="tavernDpad" class="touch-pad hidden" role="group" aria-label="Tavern movement controls">
<button type="button" data-dir="up" aria-label="Move up">▲</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();
+1
View File
@@ -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'
+3 -1
View File
@@ -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)),
+2 -1
View File
@@ -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.
+11 -2
View File
@@ -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,
+24 -2
View File
@@ -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,
+55 -54
View File
@@ -1352,13 +1352,13 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
"integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
"integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"form-data": "^4.0.6",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
@@ -1479,14 +1479,6 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
"engines": {
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.42",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz",
@@ -1501,9 +1493,9 @@
}
},
"node_modules/body-parser": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
"version": "1.20.8",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
"integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
@@ -1514,7 +1506,7 @@
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"qs": "~6.16.0",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
@@ -2172,16 +2164,15 @@
}
},
"node_modules/engine.io": {
"version": "6.6.9",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz",
"integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==",
"version": "6.6.11",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.11.tgz",
"integrity": "sha512-z34mGpLoY/82ZbkX1LqGMIaf3wjVMPL6gVC681QDqnmzJ26synI8qAE32P+a7UazmpJZ8UGrFIeAavgRfXzYrg==",
"license": "MIT",
"dependencies": {
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"@types/ws": "^8.5.12",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.4.1",
@@ -2429,9 +2420,9 @@
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"version": "4.22.3",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
"integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
@@ -2453,9 +2444,9 @@
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"path-to-regexp": "~0.1.13",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"qs": "~6.16.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
@@ -2537,9 +2528,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
"funding": [
{
"type": "individual",
@@ -4498,15 +4489,20 @@
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/proxy-from-env": {
@@ -4663,9 +4659,9 @@
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"version": "6.16.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz",
"integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
@@ -5065,14 +5061,14 @@
}
},
"node_modules/socket.io": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
"integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.4.tgz",
"integrity": "sha512-9ZV9zg8vN8hO3Y25DhR7nUkJ1VDnBhq5O/JrXhFT9s6+F0gqytSASTHVfCFc2mJXaTfARroErplXlzFWwETZqQ==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.3.2",
"debug": "~4.4.1",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
@@ -5115,12 +5111,13 @@
"license": "MIT"
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.4.tgz",
"integrity": "sha512-7wjKAvVXYOJsbmUgkWbnhPD4bFpkqrUR0++XOjpGFmqDqsqV1CI93VTraeExLxpjxEqrWgFoSRZu1ihjLWfkAA==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
@@ -5129,9 +5126,10 @@
}
},
"node_modules/socket.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
@@ -5147,12 +5145,13 @@
"node_modules/socket.io-client/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"version": "4.2.7",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
@@ -5186,9 +5185,10 @@
"license": "MIT"
},
"node_modules/socket.io/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
@@ -5204,7 +5204,8 @@
"node_modules/socket.io/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/source-map": {
"version": "0.6.1",
+1
View File
@@ -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",
+348
View File
@@ -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;
});
+1 -1
View File
@@ -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');
+177
View File
@@ -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;
}
});
});
+46
View File
@@ -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,
+2
View File
@@ -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');
+229
View File
@@ -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();
});
});
+2
View File
@@ -56,6 +56,8 @@ describe('server-side fog of war', () => {
process.env.DUNGEON_LEVELS = '1';
const game = new Game('sock', user(), {});
const exit = game.dungeon.exit;
// A generated spawn can already see the exit. Start this discovery test out of view.
setVisible(game, exit, false);
expect(game.getState().exit).toBeNull();
+58
View File
@@ -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();
}
});
+116
View File
@@ -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);
});
});
+138
View File
@@ -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();
});
});
+56
View File
@@ -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;
}
});