mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-23 17:18:32 +02:00
fix(showcase): harden Smirk checkout and multiplayer presentation
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user