fix(showcase): harden Smirk checkout and multiplayer presentation

This commit is contained in:
2026-10-21 12:43:29 -04:00
parent 5712007d31
commit c10fbd7142
30 changed files with 1695 additions and 151 deletions
+1
View File
@@ -117,6 +117,7 @@
<script src="js/ui/leaderboard.js"></script>
<script src="js/ui/helpModal.js"></script>
<script src="js/ui/publicStats.js"></script>
<script src="js/network/smirkPaymentAdapter.js"></script>
<script src="js/network/socketHandlers.js"></script>
<script src="js/network/smirkAuth.js"></script>
<script src="js/input/inputHandler.js"></script>
+12 -3
View File
@@ -117,7 +117,7 @@
var available = info.economies || {};
var availableKeys = Object.keys(ECONOMIES).filter(function (key) { return !!available[key]; });
if (!available[selectedEconomy] && availableKeys.length) selectedEconomy = availableKeys[0];
if (!inQueue && !available[selectedEconomy] && availableKeys.length) selectedEconomy = availableKeys[0];
els.economies.innerHTML = '';
availableKeys.forEach(function (key) {
var def = ECONOMIES[key];
@@ -151,6 +151,7 @@
b.type = 'button'; b.textContent = meta.label
+ (!availability.runtimeAvailable ? ' ⛔' : (meta.premium ? ' ★' : ''));
b.className = (meta.id === mode ? 'active ' : '') + (usable ? '' : 'locked');
b.setAttribute('aria-pressed', meta.id === mode ? 'true' : 'false');
b.disabled = !usable;
if (!availability.runtimeAvailable) {
b.title = 'Unavailable on this server: optional renderer dependency disabled.';
@@ -272,10 +273,17 @@
socket.on('match_queue_joined', function (res) {
els.queueBtn.disabled = false;
if (res && res.success) {
if (res.economy && ECONOMIES[res.economy]) selectedEconomy = res.economy;
inQueue = true;
els.queueBtn.classList.add('hidden');
els.leaveBtn.classList.remove('hidden');
setStatus('Queued for ' + economyLabel(selectedEconomy) + ' · position ' + (res.position || '?') + ' · waiting for the next block.');
Array.prototype.forEach.call(els.economies.querySelectorAll('[data-economy]'), function (button) {
var selected = button.dataset.economy === selectedEconomy;
button.classList.toggle('active', selected);
button.setAttribute('aria-pressed', selected ? 'true' : 'false');
});
setStatus((res.restored ? 'Queue restored for ' : 'Queued for ') + economyLabel(selectedEconomy)
+ ' · position ' + (res.position || '?') + ' · waiting for the next block.');
} else {
setStatus('Queue failed: ' + ((res && (res.reason || res.message)) || 'unknown error'));
}
@@ -285,6 +293,7 @@
inQueue = false;
els.queueBtn.classList.remove('hidden');
els.leaveBtn.classList.add('hidden');
renderEconomies();
setStatus(res && res.success ? 'Left the queue. Any held entry was returned.' : 'Could not leave: ' + ((res && res.reason) || 'unknown error'));
});
@@ -404,7 +413,7 @@
else if (dir === 'right') sendMove(1, 0);
}
window.addEventListener('keydown', function (e) {
if (e.target && /INPUT|TEXTAREA|SELECT/.test(e.target.tagName)) return;
if (e.target && (/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName) || e.target.isContentEditable)) return;
if (keyState[e.key]) return;
keyState[e.key] = true;
if (['ArrowUp', 'w', 'W'].indexOf(e.key) !== -1) { e.preventDefault(); directionMove('up'); }
+93 -62
View File
@@ -5,11 +5,11 @@
* Uses the Smirk browser extension API (window.smirk) for wallet interactions.
*
* Flow:
* 1. Get challenge from server
* 2. Connect to Smirk extension to get public keys
* 1. Acquire explicit WOW permission from Smirk
* 2. Get challenge from server
* 3. Sign challenge with wallet
* 4. Verify signature with backend
* 5. Get wallet addresses via getAddresses() and auto-set payout address
* 5. Read wallet addresses; update a payout address only in payout-enabled modes
*/
const SmirkAuth = {
@@ -24,8 +24,34 @@ const SmirkAuth = {
* Current link status
*/
_isLinked: false,
_wowReady: false,
_walletAddress: null,
_sessionEpoch() {
return typeof SocketHandlers !== 'undefined' ? SocketHandlers._paymentSessionEpoch : null;
},
async requestWowAccess() {
const epoch = this._sessionEpoch();
this._wowReady = false;
const keys = await window.smirk.connect(['wow']);
if (epoch !== this._sessionEpoch()) throw new Error('Game session changed. Reconnect Smirk for the current account.');
if (!keys || !keys.wow) throw new Error('Smirk did not grant WOW access.');
this._wowReady = true;
return keys;
},
async checkWowAccess() {
const epoch = this._sessionEpoch();
this._wowReady = false;
try {
const keys = await window.smirk.getPublicKeys();
if (epoch !== this._sessionEpoch()) return false;
this._wowReady = !!keys?.wow;
} catch (_) { /* Missing permission or provider is not a grant. */ }
return this._wowReady;
},
/**
* Detect whether the connected Smirk wallet supports NIP-98 (nostr) signing.
*/
@@ -37,10 +63,8 @@ const SmirkAuth = {
/**
* Main login flow.
*
* Prefers NIP-98 (nostr kind:27235 HTTP auth event) when the wallet
* advertises the capability, and gracefully falls back to the legacy
* Ed25519 challenge-signing path otherwise (or if NIP-98 signing fails
* mid-flow).
* Keeps NIP-98 identity when supported. Errors never switch credential methods.
* WOW asset consent is separate from Nostr signing permission.
*/
async login() {
if (!this.isAvailable()) {
@@ -58,6 +82,10 @@ const SmirkAuth = {
throw new Error('Your game session is still being established. Please retry in a moment.');
}
// An explicit click grants only the asset this game supports. Nostr-only grants do
// not authorize WOW payments. A denial ends this attempt without another popup.
const keys = await this.requestWowAccess();
// Step 1: Get challenge from server
const challengeRes = await fetch('/api/auth/smirk/challenge', {
method: 'POST',
@@ -80,26 +108,11 @@ const SmirkAuth = {
let provenPublicKey = null;
if (this._supportsNip98()) {
try {
const result = await this._verifyNip98(socketId, challenge, sessionToken);
verifyData = result.verifyData;
provenPublicKey = result.pubkey;
} catch (nip98Err) {
// Fall back to the legacy connect() path UNLESS the user explicitly cancelled the
// popup. This gives connect() a chance to open the unlock prompt when a locked
// signNostrEvent threw LOCKED instead of prompting (partial/stale builds), and
// covers wallets that lack NIP-98 entirely. Only an explicit cancel is not retried.
const code = nip98Err && nip98Err.code;
const userCancelled = code === 'USER_REJECTED'
|| /reject|cancel|denied|declined/i.test((nip98Err && nip98Err.message) || '');
if (userCancelled) throw nip98Err;
console.warn('Smirk NIP-98 failed, trying legacy connect():', nip98Err && nip98Err.message);
const result = await this._verifyLegacy(socketId, challenge, sessionToken);
verifyData = result.verifyData;
provenPublicKey = result.pubkey;
}
const result = await this._verifyNip98(socketId, challenge, sessionToken);
verifyData = result.verifyData;
provenPublicKey = result.pubkey;
} else {
const result = await this._verifyLegacy(socketId, challenge, sessionToken);
const result = await this._verifyLegacy(socketId, challenge, sessionToken, keys);
verifyData = result.verifyData;
provenPublicKey = result.pubkey;
}
@@ -108,22 +121,26 @@ const SmirkAuth = {
// us into it and returned that account's session token. Persist it and reload so the whole
// session re-establishes as that account (credits, address, history).
if (verifyData && verifyData.adopted && verifyData.sessionToken) {
if (typeof SocketHandlers !== 'undefined') {
SocketHandlers._invalidatePaymentSession();
}
try { localStorage.setItem('wownerogue_token', verifyData.sessionToken); } catch (_) { /* ignore */ }
$('#messages').append($('<li class="status">').text('🔑 Signed in to your wallet-linked account. Reloading…'));
setTimeout(function () { window.location.reload(); }, 700);
return { success: true, linked: true, adopted: true, address: verifyData.address || null };
}
// Step 3: Get wallet addresses and auto-set payout address
// Note: the proven public key is a pubkey (hex), NOT an address.
// We need to call getAddresses() to get the actual WOW address.
// Receiving addresses are independent of the proven login identity.
let walletAddress = null;
try {
const addresses = await window.smirk.getAddresses();
if (addresses && addresses.wow && window.socket) {
window.socket.emit('address:update', { address: addresses.wow });
// Prestige-only play never needs a payout address.
if (typeof SocketHandlers !== 'undefined' &&
(SocketHandlers._directPayoutsEnabled || SocketHandlers._creditsPayoutsEnabled)) {
window.socket.emit('address:update', { address: addresses.wow });
}
walletAddress = addresses.wow;
console.log('Smirk WOW address set:', addresses.wow.substring(0, 20) + '...');
}
} catch (addrErr) {
console.warn('Could not get Smirk addresses:', addrErr.message);
@@ -169,10 +186,10 @@ const SmirkAuth = {
try {
signed = await window.smirk.signNostrEvent(evt);
} catch (scopeErr) {
const needsScope = (scopeErr && scopeErr.code === 'NOT_AUTHORIZED')
|| /nostr scope|getNostrPublicKey/i.test((scopeErr && scopeErr.message) || '');
const needsScope = scopeErr && scopeErr.code === 'NOT_AUTHORIZED';
if (!needsScope) throw scopeErr;
await window.smirk.getNostrPublicKey(); // grant the Nostr scope (one-time per origin)
const granted = await window.smirk.getNostrPublicKey();
if (!granted) throw new Error('Smirk Nostr permission was declined.');
signed = await window.smirk.signNostrEvent(evt);
}
if (!signed || typeof signed !== 'object') {
@@ -203,17 +220,7 @@ const SmirkAuth = {
*
* @returns {Promise<{verifyData: object, pubkey: string|null}>}
*/
async _verifyLegacy(socketId, challenge, sessionToken) {
// Connect to Smirk extension
let keys;
try {
keys = await window.smirk.connect();
} catch (smirkError) {
const e = new Error('Failed to connect to Smirk wallet: ' + (smirkError.message || 'Unknown error'));
e.code = smirkError && smirkError.code; // preserve SmirkRpcError code ('LOCKED', 'USER_REJECTED', …)
throw e;
}
async _verifyLegacy(socketId, challenge, sessionToken, keys) {
if (!keys || !keys.wow) {
throw new Error('Smirk wallet did not return WOW public key');
}
@@ -266,23 +273,44 @@ const SmirkAuth = {
return { linked: false, hasPayoutAddress: false };
}
const epoch = this._sessionEpoch();
const socketId = window.socket.id;
const requestId = this._statusRequestId = (this._statusRequestId || 0) + 1;
let token = '';
try { token = localStorage.getItem('wownerogue_token') || ''; } catch (_) {}
const current = () => {
let activeToken = '';
try { activeToken = localStorage.getItem('wownerogue_token') || ''; } catch (_) {}
return epoch === this._sessionEpoch() && socketId === window.socket?.id &&
token === activeToken && requestId === this._statusRequestId;
};
try {
// /status is session-gated (BOLA fix): send the session token so a linked user
// is recognised; without it the endpoint returns 401 and we fall through to unlinked.
var token = '';
try { token = localStorage.getItem('wownerogue_token') || ''; } catch (_) { token = ''; }
const res = await fetch(`/api/auth/smirk/status?socketId=${encodeURIComponent(window.socket.id)}`, {
headers: token ? { 'X-Session-Token': token } : {}
});
if (res.ok) {
const data = await res.json();
this._isLinked = data.linked;
if (!current()) return { linked: false, hasPayoutAddress: false };
await this.checkWowAccess();
if (!current()) return { linked: false, hasPayoutAddress: false };
this._isLinked = data.linked === true;
this._updateButton($('#smirkLoginBtn'), data.linked ? (this._wowReady ? 'connected' : 'linked') : 'ready');
if (typeof SocketHandlers !== 'undefined' && SocketHandlers._refreshSmirkPaymentButton) {
SocketHandlers._refreshSmirkPaymentButton();
}
return data;
}
} catch (e) {
console.warn('Failed to check Smirk status:', e.message);
}
if (current()) {
this._isLinked = false;
this._wowReady = false;
this._updateButton($('#smirkLoginBtn'), 'ready');
}
return { linked: false, hasPayoutAddress: false };
},
@@ -318,9 +346,14 @@ const SmirkAuth = {
.text('Connecting...');
break;
case 'connected':
btn.prop('disabled', true)
.css({ 'background': '#0a5c0a', 'border-color': '#0a8c0a', 'cursor': 'default' })
.text('Smirk Connected');
btn.prop('disabled', false)
.css({ 'background': '#0a5c0a', 'border-color': '#0a8c0a', 'cursor': 'pointer' })
.text('Smirk connected · Recheck WOW access');
break;
case 'linked':
btn.prop('disabled', false)
.css({ 'background': '#1a1a4d', 'cursor': 'pointer' })
.text('Enable WOW payments');
break;
case 'error':
btn.prop('disabled', false)
@@ -341,15 +374,18 @@ const SmirkAuth = {
this._updateButton(btn, 'connecting');
try {
const result = await this.login();
const result = this._isLinked
? (await this.requestWowAccess(), { success: true, linked: true, address: this._walletAddress })
: await this.login();
this._updateButton(btn, 'connected');
if (typeof SocketHandlers !== 'undefined' && SocketHandlers._refreshSmirkPaymentButton) {
SocketHandlers._refreshSmirkPaymentButton();
}
// Show success message in chat
const addressSet = result.address != null;
const successMsg = addressSet
? 'Smirk wallet connected! Payout address set.'
: 'Smirk wallet authenticated! Please set your payout address manually.';
const successMsg = 'Smirk account connected. WOW payment permission granted.';
// .text() sets textContent, so the message is safe from injection.
$('#messages').append($('<li class="status">').text(successMsg));
@@ -362,7 +398,6 @@ const SmirkAuth = {
return result;
} catch (err) {
console.error('Smirk login failed:', err);
this._updateButton(btn, 'error'); // button label becomes "Retry Smirk Connect"
// A locked wallet is an action, not a red error. Current Smirk builds auto-open the
@@ -415,11 +450,7 @@ const SmirkAuth = {
container.append(btn);
// Check if already linked on page load
this.checkStatus().then(status => {
if (status.linked) {
this._updateButton(btn, 'connected');
}
});
this.checkStatus();
console.log('Smirk extension detected, connect button shown');
} else {
@@ -483,7 +514,7 @@ if (typeof window.socket !== 'undefined') {
SmirkAuth.checkStatus().then(status => {
const btn = $('#smirkLoginBtn');
if (btn.length > 0 && status.linked) {
SmirkAuth._updateButton(btn, 'connected');
SmirkAuth._updateButton(btn, SmirkAuth._wowReady ? 'connected' : 'linked');
}
});
}
+120
View File
@@ -0,0 +1,120 @@
/** Exact WOW invoices and conservative, once-only wallet submission. Backend receipts grant credits. */
(function(root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else root.SmirkPayments = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function() {
const ATTEMPT_STORAGE = 'wowngeon_smirk_attempts_v1';
const MAX_ATTEMPTS = 128;
const STATES = new Set(['unknown', 'submitted', 'failed', 'cancelled', 'unavailable']);
function paymentRequest(invoice, now = Date.now()) {
if (!invoice || invoice.asset !== 'wow' || invoice.network !== 'mainnet') {
throw new Error('Smirk requires an explicitly identified WOW mainnet invoice.');
}
if (!/^[1-9]\d*$/.test(String(invoice.paymentId || '')) ||
!/^[1-9]\d*$/.test(String(invoice.ownerUserId || ''))) {
throw new Error('Payment identity or account ownership is missing.');
}
const expiry = new Date(invoice.expiresAt).getTime();
if (!invoice.expiresAt || !Number.isFinite(expiry) || expiry <= now) {
throw new Error('This payment request is expired or has no valid expiry.');
}
// Wallet RPC owns checksum/nettype validation. Standard WOW addresses and RPC
// subaddresses are both 97-character Cryptonote base58, including the WW family.
if (typeof invoice.address !== 'string' || !/^W[1-9A-HJ-NP-Za-km-z]{96}$/.test(invoice.address)) {
throw new Error('This invoice has no valid WOW recipient.');
}
// Never interpret an atomic-unit amount or rounded display label as whole WOW.
const amount = invoice.humanAmount;
if (typeof amount !== 'string' || !/^(0|[1-9]\d*)(\.\d{1,11})?$/.test(amount)) {
throw new Error('This invoice has no exact WOW decimal amount.');
}
const [whole, fraction = ''] = amount.split('.');
const atomic = BigInt(whole) * 100000000000n + BigInt(fraction.padEnd(11, '0'));
if (atomic <= 0n || !/^\d+$/.test(String(invoice.amount)) ||
(typeof invoice.amount === 'number' && !Number.isSafeInteger(invoice.amount)) ||
atomic !== BigInt(invoice.amount)) {
throw new Error('The invoice decimal amount does not match its accounting amount.');
}
const memo = invoice.memo;
if (typeof memo !== 'string' || !memo.trim() || memo.length > 256) {
throw new Error('The invoice product description is missing or invalid.');
}
return { asset: 'wow', amount, address: invoice.address, memo };
}
function createAdapter(options = {}) {
const now = options.now || (() => Date.now());
const storage = options.storage;
const withLock = options.withLock;
const crypto = options.crypto || globalThis.crypto;
function read() {
if (!storage) throw new Error('Browser payment history is unavailable. Use manual payment before any wallet submission.');
const raw = storage.getItem(ATTEMPT_STORAGE);
const entries = raw ? JSON.parse(raw) : [];
if (!Array.isArray(entries) || entries.some(entry => !entry ||
!STATES.has(entry.state) || !Number.isFinite(entry.expiresAt) ||
typeof entry.fingerprint !== 'string' || !/^[1-9]\d*$/.test(entry.paymentId))) {
throw new Error('Payment history cannot be read safely. Check invoice status before paying.');
}
return entries.filter(entry => entry.expiresAt > now());
}
function save(entries) {
const serialized = JSON.stringify(entries);
storage.setItem(ATTEMPT_STORAGE, serialized);
if (storage.getItem(ATTEMPT_STORAGE) !== serialized) {
throw new Error('Payment history could not be retained. No wallet payment was requested.');
}
}
function status(invoice) {
return read().find(entry => entry.paymentId === String(invoice.paymentId)) || null;
}
async function pay(invoice, wallet, admit = () => {}) {
const request = paymentRequest(invoice, now());
if (typeof wallet?.requestPayment !== 'function') throw new Error('This wallet does not support payment requests.');
if (typeof withLock !== 'function' || !crypto?.subtle) {
throw new Error('This browser cannot safely coordinate wallet payments across tabs. Use manual payment before any wallet submission.');
}
// Retain a digest, never an address, session token, signature or wallet key.
const contract = JSON.stringify([String(invoice.ownerUserId), invoice.asset, invoice.network,
request.amount, request.address, request.memo, invoice.expiresAt]);
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(contract));
const fingerprint = Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('');
return withLock(ATTEMPT_STORAGE, async () => {
// Recheck expiry after waiting for another tab or wallet approval.
paymentRequest(invoice, now());
admit();
const entries = read();
const previous = entries.find(entry => entry.paymentId === String(invoice.paymentId));
if (previous) {
if (previous.fingerprint !== fingerprint) throw new Error('This invoice changed. Check its status before paying.');
return { state: previous.state, repeated: true };
}
if (entries.length >= MAX_ATTEMPTS) throw new Error('Too many active payment attempts. Check their status before paying again.');
const entry = { paymentId: String(invoice.paymentId), expiresAt: new Date(invoice.expiresAt).getTime(),
fingerprint, state: 'unknown' };
entries.push(entry);
// Intent is durable before the call. A closed tab cannot turn ambiguity into retry.
save(entries);
try {
const result = await wallet.requestPayment(request);
if (result && typeof result === 'object' && result.success === true &&
typeof result.txid === 'string' && /^[0-9a-f]{64}$/i.test(result.txid)) {
entry.state = 'submitted';
} else if (result && typeof result === 'object' && result.success === false && !result.txid) {
entry.state = 'failed';
}
} catch (error) {
if (error?.code === 'USER_REJECTED') entry.state = 'cancelled';
else if (['NOT_AUTHORIZED', 'UNSUPPORTED_ASSET', 'LOCKED'].includes(error?.code)) entry.state = 'unavailable';
// Transport errors can occur after submission. Never offer an automatic retry.
}
try { save(entries); } catch (_) { return { state: 'unknown' }; }
return { state: entry.state };
});
}
return { pay, status };
}
return { createAdapter, paymentRequest };
});
+120 -65
View File
@@ -182,6 +182,7 @@ const SocketHandlers = {
// Connection handlers
socket.on('connect', this.onConnect);
socket.on('disconnect', function() {
SocketHandlers._invalidatePaymentSession();
if (typeof CommerceConsent !== 'undefined' && CommerceConsent.clear) {
CommerceConsent.clear();
}
@@ -311,6 +312,8 @@ const SocketHandlers = {
onSessionToken: function(data) {
if (data && data.token) {
SocketHandlers._invalidatePaymentSession();
SocketHandlers._sessionUserId = data.userId || null;
try { localStorage.setItem('wownerogue_token', data.token); } catch(e) {}
$('#messages').append($('<li class="status">').text('New session established. Token stored.'));
UI.scrollChat();
@@ -332,6 +335,8 @@ const SocketHandlers = {
onSessionResumed: function(data) {
if (data && data.token) {
SocketHandlers._invalidatePaymentSession();
SocketHandlers._sessionUserId = data.userId || null;
try { localStorage.setItem('wownerogue_token', data.token); } catch(e) {}
$('#messages').append($('<li class="status">').text('Session resumed.'));
UI.scrollChat();
@@ -899,6 +904,7 @@ const SocketHandlers = {
SocketHandlers._paymentsEnabled = !!data.paymentsEnabled;
SocketHandlers._smirkEnabled = SocketHandlers._isSmirkExplicitlyEnabled(data);
SocketHandlers._cryptoType = data.cryptoType || 'WOW';
SocketHandlers._network = data.network || null;
SocketHandlers._currencyLabel = data.currencyLabel || data.cryptoType || 'WOW'; // e.g. sXMR on stagenet
SocketHandlers._explorerTxUrl = data.explorerTxUrl || null;
if (typeof Leaderboard !== 'undefined' && Leaderboard.updateConfig) {
@@ -978,83 +984,116 @@ const SocketHandlers = {
},
onPaymentCreated: function(data) {
console.log('Payment created:', data);
if (typeof AudioAlerts !== 'undefined') { AudioAlerts.playRequestCoin(); }
// Native Smirk payment is only possible while the user is linked through the extension.
if (typeof SmirkAuth !== 'undefined' && SmirkAuth._isLinked && SmirkAuth.isAvailable() &&
typeof window.smirk !== 'undefined' && window.smirk.requestPayment) {
SocketHandlers._trySmirkPayment(data);
return;
}
// Invoice delivery, replay and reconnect are never wallet consent.
SocketHandlers._showPaymentFlow(data);
},
/**
* Attempt payment via Smirk wallet extension.
* Falls back to normal address/QR flow on rejection or error.
*/
_trySmirkPayment: async function(data) {
$('#messages').append($('<li class="status">').text('Opening Smirk wallet for payment...'));
UI.scrollChat();
try {
// Smirk expects a human-readable amount (e.g. "1"), not atomic units.
var payAmount = data.humanAmount || data.amountFormatted || String(data.amount);
var description = 'Single game entry';
if (data.paymentType === 'credits_package') {
description = 'Buy ' + (data.package ? data.package.credits : '') + ' credits';
} else if (data.paymentType === 'cosmetic_pack') {
description = 'Unlock ' + (data.package ? (data.package.label || data.package.id || 'premium pack') : 'premium pack');
}
await window.smirk.requestPayment({
address: data.address,
amount: payAmount,
asset: (SocketHandlers._cryptoType || 'WOW').toLowerCase(),
description: description
_smirkPaymentAdapter: null,
_getSmirkPaymentAdapter: function() {
if (!SocketHandlers._smirkPaymentAdapter) {
let storage = null;
try { storage = window.localStorage; } catch (_) {}
SocketHandlers._smirkPaymentAdapter = SmirkPayments.createAdapter({
storage,
withLock: window.navigator?.locks
? (name, work) => window.navigator.locks.request(name, work) : null
});
}
return SocketHandlers._smirkPaymentAdapter;
},
// The user confirmed in Smirk and the transaction is submitted; server-side monitoring
// handles confirmation from here.
$('#messages').append($('<li class="status" style="color:#4ade80;">').text(
'Payment sent via Smirk! Waiting for confirmation...'
));
UI.scrollChat();
_smirkPaymentCopy: function(state) {
return {
submitted: 'Smirk reported transaction submission. Waiting for backend confirmation. Do not pay again.',
unknown: 'Payment submission is uncertain. Check payment status and wallet history before any further payment. Do not pay again.',
failed: 'Smirk reported a failed payment. Check wallet history and invoice status before choosing any further payment.',
cancelled: 'Smirk payment cancelled. No automatic retry will be made.',
unavailable: 'Smirk payment permission is unavailable. Reconnect the wallet deliberately before creating another payment request.'
}[state];
},
if (typeof PaymentUI !== 'undefined') {
PaymentUI.showPaymentRequest(data);
$('#payment-status').html('<span style="color:#4ade80;">Payment sent via Smirk, awaiting confirmation...</span>');
_invalidatePaymentSession: function() {
SocketHandlers._paymentSessionEpoch = (SocketHandlers._paymentSessionEpoch || 0) + 1;
SocketHandlers._activePaymentId = null;
SocketHandlers._activePaymentData = null;
SocketHandlers._sessionUserId = null;
if (typeof SmirkAuth !== 'undefined') {
SmirkAuth._isLinked = false;
SmirkAuth._wowReady = false;
}
$('#smirkPayButton, .payment-info').remove();
$('#payment-ui, #payment-details').hide();
$('#payment-address').val('');
$('#payment-amount').text('');
$('#check-payment').removeData('payment-id').prop('disabled', true);
$('#copy-address').prop('disabled', true);
SocketHandlers.hidePaymentQR();
},
_trySmirkPayment: async function(data, button, consentEpoch = SocketHandlers._paymentSessionEpoch) {
if (button) button.prop('disabled', true);
try {
const admit = function() {
if (!SocketHandlers._smirkEnabled || SocketHandlers._cryptoType !== 'WOW' ||
SocketHandlers._network !== 'mainnet' || !SmirkAuth._isLinked || !SmirkAuth._wowReady ||
consentEpoch !== SocketHandlers._paymentSessionEpoch ||
!SocketHandlers._sessionUserId || String(SocketHandlers._sessionUserId) !== String(data.ownerUserId) ||
String(SocketHandlers._activePaymentId) !== String(data.paymentId)) {
throw new Error('This WOW invoice is no longer active for the connected account. Check payment status first.');
}
};
admit();
if (!await SmirkAuth.checkWowAccess()) {
SmirkAuth._updateButton($('#smirkLoginBtn'), 'linked');
throw new Error('WOW permission is missing. Click Enable WOW payments to reconnect deliberately.');
}
SocketHandlers._setBannerStatus('Pay', '#0af');
if (typeof Game !== 'undefined' && Game._paymentRequested) Game._paymentRequested();
if (typeof Game !== 'undefined' && Game.drawWaitingScreen) Game.drawWaitingScreen();
const result = await SocketHandlers._getSmirkPaymentAdapter().pay(data, window.smirk, admit);
if (consentEpoch !== SocketHandlers._paymentSessionEpoch ||
String(SocketHandlers._activePaymentId) !== String(data.paymentId)) return;
const message = SocketHandlers._smirkPaymentCopy(result.state);
$('#payment-status').text(message);
$('#messages').append($('<li class="status">').text(message));
SocketHandlers._protectSubmittedPayment(result.state);
} catch (err) {
console.log('Smirk payment declined/failed, falling back to manual:', err);
var errMsg = String(err.message || err || '').toLowerCase();
if (consentEpoch !== SocketHandlers._paymentSessionEpoch ||
String(SocketHandlers._activePaymentId) !== String(data.paymentId)) return;
$('#payment-status').text(err.message);
$('#messages').append($('<li class="status">').text(err.message));
}
UI.scrollChat();
},
// Distinguish a user-initiated denial from an extension error.
var isUserDenied = errMsg.indexOf('denied') !== -1 ||
errMsg.indexOf('rejected') !== -1 ||
errMsg.indexOf('cancelled') !== -1 ||
errMsg.indexOf('user') !== -1;
_protectSubmittedPayment: function(state) {
if (state !== 'unknown' && state !== 'submitted') return;
$('#copy-address, .copy-pay-address').prop('disabled', true);
SocketHandlers.hidePaymentQR();
},
if (isUserDenied) {
$('#messages').append($('<li class="status">').text(
'Smirk payment cancelled. Use the address below.'
));
} else {
// Extension error: invalidated context, service worker issue, and similar.
$('#messages').append($('<li class="status" style="color:#f59e0b;">').text(
'Smirk payment unavailable, using manual payment. Refresh the page to restore Smirk.'
));
SmirkAuth._isLinked = false;
_refreshSmirkPaymentButton: function() {
if (SocketHandlers._activePaymentData) SocketHandlers._showSmirkPaymentButton(SocketHandlers._activePaymentData);
},
_showSmirkPaymentButton: function(data) {
$('#smirkPayButton').remove();
if (typeof SmirkPayments === 'undefined' || typeof SmirkAuth === 'undefined' ||
!SmirkAuth._isLinked || !SmirkAuth.isAvailable() || !SocketHandlers._smirkEnabled ||
SocketHandlers._cryptoType !== 'WOW' || SocketHandlers._network !== 'mainnet' ||
!SocketHandlers._sessionUserId || String(SocketHandlers._sessionUserId) !== String(data.ownerUserId)) return;
try {
SmirkPayments.paymentRequest(data);
const adapter = SocketHandlers._getSmirkPaymentAdapter();
const previous = adapter.status(data);
if (previous) {
$('#payment-status').text(SocketHandlers._smirkPaymentCopy(previous.state));
return;
}
UI.scrollChat();
SocketHandlers._showPaymentFlow(data);
const button = $('<button id="smirkPayButton" type="button" class="menu-button">').text('Pay with Smirk · WOW mainnet');
const consentEpoch = SocketHandlers._paymentSessionEpoch;
button.on('click', () => SocketHandlers._trySmirkPayment(data, button, consentEpoch));
$('#payment-details').append(button);
} catch (err) {
$('#payment-status').text('Smirk unavailable: ' + err.message);
}
},
@@ -1062,9 +1101,14 @@ const SocketHandlers = {
* Show normal payment flow: modal + chat message + QR code
*/
_showPaymentFlow: function(data) {
SocketHandlers._activePaymentId = data.paymentId;
SocketHandlers._activePaymentData = data;
$('#check-payment').prop('disabled', false);
$('#copy-address, .copy-pay-address').prop('disabled', false);
if (typeof PaymentUI !== 'undefined') {
PaymentUI.showPaymentRequest(data);
}
SocketHandlers._showSmirkPaymentButton(data);
const parts = [];
const reusedCopy = data.reused
@@ -1152,6 +1196,12 @@ const SocketHandlers = {
qrHolder.textContent = 'QR unavailable';
}
}
if (typeof SmirkPayments !== 'undefined') {
try {
const previous = SocketHandlers._getSmirkPaymentAdapter().status(data);
if (previous) SocketHandlers._protectSubmittedPayment(previous.state);
} catch (_) { /* An unreadable history already refuses the wallet button. */ }
}
},
// Removes the payment QR container from the sidebar.
@@ -1271,6 +1321,11 @@ const SocketHandlers = {
onPaymentConfirmed: function(data) {
console.log('Payment confirmed in block:', data);
if (!data || !data.paymentId) return; // A payload without a paymentId cannot be deduped.
if (String(SocketHandlers._activePaymentId) === String(data.paymentId)) {
SocketHandlers._activePaymentId = null;
SocketHandlers._activePaymentData = null;
$('#smirkPayButton').remove();
}
SocketHandlers._expireOldClientPaymentMarkers();
if (SocketHandlers._lastDisplayedConfirmation === data.paymentId) return;
SocketHandlers._lastDisplayedConfirmation = data.paymentId;
+13 -3
View File
@@ -149,6 +149,7 @@
return false;
}
if (visual.kind === 'skin') {
if (RK.skinSheetFailed && RK.skinSheetFailed(visual.appearance.avatar)) return false;
if (RK.skinSheet && RK.skinSheet(visual.appearance.avatar)) return true;
if (!RK.loadSkin) return false;
rememberLoad('skin:' + visual.appearance.avatar, function (done) {
@@ -294,9 +295,18 @@
function drawTopdownWorld(ctx, visual, entity, viewport, opts) {
opts = opts || {};
visual = visual && visual.appearance ? visual : resolve(visual, opts);
if (!ctx || !entity || !viewport || !visual.allowed || !ensureCanvasReady(visual, opts.onReady)) return false;
if (visual.kind === 'char') return drawTopdownChar(ctx, visual, entity, viewport, opts);
if (visual.kind === 'skin') return drawTopdownSkin(ctx, visual, entity, viewport, opts);
if (!ctx || !entity || !viewport || !visual.allowed) return false;
if (visual.kind === 'char') {
if (!ensureCanvasReady(visual, opts.onReady)) return false;
return drawTopdownChar(ctx, visual, entity, viewport, opts);
}
if (visual.kind === 'skin') {
if (ensureCanvasReady(visual, opts.onReady)) return drawTopdownSkin(ctx, visual, entity, viewport, opts);
if (RK.skinSheetFailed && RK.skinSheetFailed(visual.appearance.avatar)) {
var fallback = resolve({ avatar: 'char-villager' }, { projection: 'topdown', context: visual.context, entity: entity });
if (ensureCanvasReady(fallback, opts.onReady)) return drawTopdownChar(ctx, fallback, entity, viewport, opts);
}
}
return false;
}
+13 -4
View File
@@ -41,18 +41,27 @@
var s = RK.SKINS[id];
if (!s) return null;
if (sheets[id]) return sheets[id];
var rec = sheets[id] = { ready: false, cbs: [] };
var rec = sheets[id] = { ready: false, failed: false, cbs: [] };
var img = new Image();
img.onload = function () {
rec.ready = true; rec.img = img;
rec.cbs.forEach(function (cb) { try { cb(rec); } catch (_) {} });
rec.cbs = [];
settle(rec);
};
img.onerror = function () {
rec.failed = true;
if (root.console) console.warn('skin sheet failed:', s.sheet);
settle(rec);
};
img.onerror = function () { if (root.console) console.warn('skin sheet failed:', s.sheet); };
img.src = s.sheet;
return rec;
};
function settle(rec) {
var callbacks = rec.cbs.slice();
rec.cbs.length = 0;
callbacks.forEach(function (cb) { try { cb(rec); } catch (_) {} });
}
RK.skinSheet = function (id) { return (sheets[id] && sheets[id].ready) ? sheets[id] : null; };
RK.skinSheetFailed = function (id) { return !!(sheets[id] && sheets[id].failed); };
// --- Movement-aware frame selection ------------------------------------------------------
var last = {}; // entity id -> { x, y, t }
+1 -1
View File
@@ -147,7 +147,7 @@
<span id="raceTimer"></span>
<span id="racePlace"></span>
</div>
<div id="renderModes" aria-label="Render style"></div>
<div id="renderModes" role="group" aria-label="Render style"></div>
</div>
<div id="stage" aria-label="Live multiplayer dungeon"></div>
<div id="raceResult" class="hidden" role="status"></div>
+7 -4
View File
@@ -125,8 +125,8 @@
<p id="joinStatus" role="status" aria-live="polite">Connecting to the tavern…</p>
</form>
<div id="modebar" class="hidden"></div>
<div id="packbar" class="hidden"></div>
<div id="modebar" class="hidden" role="group" aria-label="Render style"></div>
<div id="packbar" class="hidden" role="group" aria-label="Visual pack"></div>
<div id="stage" class="hidden" aria-label="Shared tavern room"></div>
<div id="tavernDpad" class="touch-pad hidden" role="group" aria-label="Tavern movement controls">
<button type="button" data-dir="up" aria-label="Move up">▲</button>
@@ -420,6 +420,7 @@
var b = document.createElement('button');
b.className = 'packbtn' + (p.id === activeId ? ' active' : '');
b.textContent = p.label;
b.setAttribute('aria-pressed', p.id === activeId ? 'true' : 'false');
b.addEventListener('click', function () {
if (RK.setActivePack(p.id)) { buildPackbar(); setMode(currentMode); }
});
@@ -428,7 +429,9 @@
}
function updateModeButtons() {
Array.prototype.forEach.call(modebar.children, function (b) {
b.classList.toggle('active', b.dataset.mode === currentMode);
var active = b.dataset.mode === currentMode;
b.classList.toggle('active', active);
b.setAttribute('aria-pressed', active ? 'true' : 'false');
});
}
@@ -635,7 +638,7 @@
if (!spectatorKind && state.you) socket.emit('tavern_move', { dir: dir });
}
window.addEventListener('keydown', function (e) {
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
if (e.target && (/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName) || e.target.isContentEditable)) return;
var dir = KEYS[e.key];
if (!dir || (!state.you && spectatorKind !== 'player-match')) return;
e.preventDefault();