mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-23 17:18:32 +02:00
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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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'); }
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+55
-54
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user