/**
* Socket event handlers for the Wownerogue game
*/
const SocketHandlers = {
_initialized: false,
_didConnect: false,
_setBannerStatus: function(text, color) {
const el = $('#statusValue');
if (!el.length) return;
el.text(text);
if (color) el.css('color', color);
},
_hasPayoutAddress: false,
_creditsBalance: 0,
_gameMode: null, // Set from the server's game-modes event. Declare it exactly once: a duplicate
// key in this object literal would silently shadow the other.
_directModeEnabled: false,
_directPayoutsEnabled: false,
_paymentsEnabled: false,
_isSmirkExplicitlyEnabled: function(data) {
return !!data && data.smirkEnabled === true;
},
_fairnessOffer: null,
_pendingFairnessAttempt: null,
_randomClientSeed: function() {
try {
if (!window.crypto || typeof window.crypto.getRandomValues !== 'function') return null;
var bytes = new Uint8Array(32);
window.crypto.getRandomValues(bytes);
return Array.prototype.map.call(bytes, function(b) { return b.toString(16).padStart(2, '0'); }).join('');
} catch (e) { return null; }
},
/** Build a start payload only after receiving the server's commitment. */
fairnessAttempt: function(extra) {
var offer = this._fairnessOffer;
if (!offer || !offer.offerId || !/^[0-9a-f]{64}$/i.test(String(offer.commitment || ''))) {
socket.emit('fairness_offer_request');
$('#messages').append($('
').text('Preparing a fairness commitment; try again in a moment.'));
if (typeof UI !== 'undefined' && UI.scrollChat) UI.scrollChat();
return null;
}
// The player's contribution is generated only AFTER selecting the published commitment.
var clientSeed = this._randomClientSeed();
if (!clientSeed) {
$('#messages').append($('
').text('Secure randomness is unavailable in this browser; the game was not started.'));
return null;
}
var payload = Object.assign({}, extra || {}, {
fairnessOfferId: offer.offerId,
clientSeed: clientSeed
});
this._pendingFairnessAttempt = {
offerId: offer.offerId,
commitment: offer.commitment,
clientSeed: clientSeed
};
return payload;
},
emitFairGameStart: function(eventName, extra, acknowledgementReady) {
var paidIntent = !(extra && extra.free === true);
var existingAcknowledgement = paidIntent
&& typeof CommerceConsent !== 'undefined'
&& CommerceConsent.acknowledgement
? CommerceConsent.acknowledgement()
: null;
if (paidIntent && !acknowledgementReady && !existingAcknowledgement
&& typeof CommerceConsent !== 'undefined' && CommerceConsent.require) {
CommerceConsent.require(function(acknowledgement) {
var acknowledgedExtra = Object.assign({}, extra || {});
if (acknowledgement) acknowledgedExtra.legalAcknowledgement = acknowledgement;
if (SocketHandlers.emitFairGameStart(eventName, acknowledgedExtra, true)) {
$('#entryChoiceOverlay').remove();
if (typeof ScreenManager !== 'undefined' && ScreenManager.drawWaitingScreen) ScreenManager.drawWaitingScreen();
}
});
return false;
}
var acknowledgedPayload = Object.assign({}, extra || {});
if (existingAcknowledgement) acknowledgedPayload.legalAcknowledgement = existingAcknowledgement;
var payload = this.fairnessAttempt(acknowledgedPayload);
if (!payload) return false;
socket.emit(eventName, payload);
return true;
},
payoutAddressRequired: function() {
if (this._gameMode === 'PAID_SINGLE' && this._directPayoutsEnabled) return true;
if (this._gameMode === 'PAID_CREDITS' && this._creditsPayoutsEnabled) return true;
return false;
},
canAffordGame: function() {
if (this._gameMode === 'FREE') return true;
if (this._gameMode === 'PAID_SINGLE') return true; // Payment will be requested
if (this._gameMode === 'PAID_CREDITS') {
return this._creditsBalance >= (this._creditsPerGame || 1);
}
return false;
},
_updateAddressButtonStatus: function(hasAddress) {
const $btn = $('#manageAddressButton');
this._hasPayoutAddress = !!hasAddress;
if (!$btn.length) return;
if (hasAddress) {
$btn.css({
'background': '#053655',
'color': '#0ff',
'border-color': '#0ff'
});
$btn.html('✅ Payout Address Set');
} else {
$btn.css({
'background': '#553300',
'color': '#ffa500',
'border-color': '#ffa500'
});
$btn.html('⚠️ Set Payout Address');
}
},
init: function() {
if (this._initialized) {
return;
}
if (!window.socket) {
console.error("Socket not available!");
return;
}
this.registerEventHandlers();
this._initialized = true;
// Delegated handlers for proof copy buttons
$(document).on('click', '.copy-hash-btn', function() {
const hash = $(this).data('hash');
if (hash) {
SocketHandlers._copyToClipboard(hash, $(this));
}
});
$(document).on('click', '.copy-seed-btn', function() {
const seed = $(this).data('seed');
if (seed) {
SocketHandlers._copyToClipboard(seed, $(this));
}
});
// If the low-level socket connected before handlers were registered, emulate onConnect.
if (window.socket && window.socket.connected) {
this.onConnect();
}
},
_copyToClipboard: function(text, $btn) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function() {
const origText = $btn.text();
$btn.text('Copied!');
setTimeout(function() { $btn.text(origText); }, 1500);
});
} else {
// execCommand fallback for browsers without the async clipboard API.
const $temp = $('');
$('body').append($temp);
$temp.val(text).select();
document.execCommand('copy');
$temp.remove();
const origText = $btn.text();
$btn.text('Copied!');
setTimeout(function() { $btn.text(origText); }, 1500);
}
},
registerEventHandlers: function() {
// Connection handlers
socket.on('connect', this.onConnect);
socket.on('disconnect', function() {
SocketHandlers._invalidatePaymentSession();
if (typeof CommerceConsent !== 'undefined' && CommerceConsent.clear) {
CommerceConsent.clear();
}
});
socket.on('welcome', this.onWelcome);
socket.on('session_token', this.onSessionToken);
socket.on('session_resumed', this.onSessionResumed);
socket.on('message', this.onMessage);
socket.on('status_update', this.onStatusUpdate);
socket.on('chat_broadcast', this.onChatBroadcast);
socket.on('chat_history', this.onChatHistory);
socket.on('chat_deleted', this.onChatDeleted);
socket.on('chat_error', this.onChatError);
// Game state handlers
socket.on('waiting_status', this.onWaitingStatus);
socket.on('queue_joined', this.onQueueJoined);
socket.on('game_start', this.onGameStart);
socket.on('game_update', this.onGameUpdate);
socket.on('game_over', this.onGameOver);
socket.on('game_settlement_pending', this.onGameSettlementPending);
socket.on('fairness_offer', this.onFairnessOffer);
socket.on('fairness_error', this.onFairnessError);
// Discrete in-run events. The descend event is what tells the player a new entrance means a
// deeper level rather than a reset.
socket.on('game_event', function (data) {
if (!data || !data.event) return;
var $m = $('#messages');
if (data.event === 'descend') {
var lvl = (data.depth && data.maxDepth) ? ('Level ' + data.depth + ' of ' + data.maxDepth + '. ') : '';
$m.append($('
')
.text('⬇️ Stairs down! ' + lvl + 'Keep going: escape before the block!'));
var lvlTitle = (data.depth && data.maxDepth) ? ('⬇ LEVEL ' + data.depth + ' / ' + data.maxDepth) : '⬇ DESCEND';
SocketHandlers.showEventBanner(lvlTitle, 'Deeper in: escape before the block!', '#ffcc00');
} else if (data.event === 'treasure_found') {
$m.append($('
').text('💰 Treasure secured!'));
SocketHandlers.showEventBanner('💰 TREASURE SECURED', 'Now get to the stairs and escape!', '#fbbf24');
} else return;
if (typeof UI !== 'undefined' && UI.scrollChat) UI.scrollChat();
});
socket.on('queue_cancelled', this.onQueueCancelled);
// Payment/Address handlers
socket.on('address_detected', this.onAddressDetected);
socket.on('address_confirmed', this.onAddressConfirmed);
socket.on('address_update_error', this.onAddressUpdateError);
socket.on('address_prompt', this.onAddressPrompt);
socket.on('game_mode_info', this.onGameModeInfo);
socket.on('payment_created', this.onPaymentCreated);
socket.on('commerce_ack_required', this.onCommerceAcknowledgementRequired);
socket.on('payment_confirmed', this.onPaymentConfirmed);
socket.on('payment_detected', this.onPaymentDetected);
socket.on('show_payment_options', this.onShowPaymentOptions);
socket.on('balance_critical', this.onBalanceCritical);
socket.on('credits_update', this.onCreditsUpdate);
socket.on('identity_update', this.onIdentityUpdate);
socket.on('identity_error', this.onIdentityError);
socket.on('user_count', this.onUserCount);
// Block height handler
socket.on('blockheight', this.onBlockHeight);
// Early entry handlers
socket.on('early_entry_success', this.onEarlyEntrySuccess);
socket.on('early_entry_error', this.onEarlyEntryError);
// Spectator handlers
socket.on('active_games', this.onActiveGames);
socket.on('spectate_start', this.onSpectateStart);
socket.on('spectator_update', this.onSpectatorUpdate);
socket.on('spectate_ended', this.onSpectateEnded);
// Global win feed (someone escaped) -> floating toast
socket.on('win_feed', this.onWinFeed);
},
// Transient "someone just escaped" toast. Toasts stack in a fixed top-right container,
// auto-dismiss, and never block the UI.
onWinFeed: function(data) {
if (!data) return;
var name = String(data.name || 'Someone').slice(0, 24);
var bag = data.treasure ? ' 💰 with the bag' : '';
var paid = data.paid ? ' (payout!)' : '';
try {
var $c = $('#win-feed');
if (!$c.length) {
$c = $('').appendTo('body');
}
var $t = $('').html(
'🏆 ' + escapeHtml(name) + ' escaped' + bag + paid
);
$c.append($t);
requestAnimationFrame(function () { $t.addClass('show'); });
setTimeout(function () { $t.removeClass('show'); }, 5000);
setTimeout(function () { $t.remove(); }, 5600);
// Cap the number of simultaneous toasts.
var $all = $c.children('.win-toast');
if ($all.length > 4) { $all.first().remove(); }
} catch (e) { /* non-critical */ }
},
onFairnessOffer: function(data) {
if (!data || !data.offerId || !/^[0-9a-f]{64}$/i.test(String(data.commitment || ''))) return;
SocketHandlers._fairnessOffer = data;
},
onFairnessError: function(data) {
SocketHandlers._pendingFairnessAttempt = null;
var message = (data && data.message) || 'Fairness offer could not be consumed. Please retry.';
$('#messages').append($('
').text(message));
if (typeof UI !== 'undefined' && UI.scrollChat) UI.scrollChat();
socket.emit('fairness_offer_request');
},
onConnect: function() {
if (SocketHandlers._didConnect) return; // prevent duplicate registration emission
SocketHandlers._didConnect = true;
// The token is supplied in Socket.IO's handshake auth object by index.html. Do not copy
// it into a query string: reverse proxies commonly log URLs, which would leak sessions.
socket.emit('register_client', {
clientId: socket.id,
userAgent: navigator.userAgent
});
socket.emit('identity:get');
SocketHandlers._setBannerStatus('Connected', '#0f0');
},
onSessionToken: function(data) {
if (data && data.token) {
SocketHandlers._invalidatePaymentSession();
SocketHandlers._sessionUserId = data.userId || null;
try { localStorage.setItem('wownerogue_token', data.token); } catch(e) {}
$('#messages').append($('
').text('New session established. Token stored.'));
UI.scrollChat();
// A new session carries no payout address.
SocketHandlers._updateAddressButtonStatus(false);
SocketHandlers._refreshSmirkLinked();
}
},
// The server rotates the session token on every resume, so an initial SmirkAuth.checkStatus()
// can race the rotation and 403 with the stale token, leaving _isLinked=false and disabling
// one-click Smirk payment. Re-check once the fresh token is stored.
_refreshSmirkLinked: function() {
if (typeof SmirkAuth !== 'undefined' && SmirkAuth.checkStatus &&
SmirkAuth.isAvailable && SmirkAuth.isAvailable()) {
try { SmirkAuth.checkStatus(); } catch (e) { /* non-critical */ }
}
},
onSessionResumed: function(data) {
if (data && data.token) {
SocketHandlers._invalidatePaymentSession();
SocketHandlers._sessionUserId = data.userId || null;
try { localStorage.setItem('wownerogue_token', data.token); } catch(e) {}
$('#messages').append($('
').text('Session resumed.'));
UI.scrollChat();
SocketHandlers._refreshSmirkLinked();
}
if (data && typeof data.credits === 'number') {
SocketHandlers._updateCreditsDisplay(data.credits);
}
if (typeof SinglePlayerAvatar !== 'undefined') {
SinglePlayerAvatar.applyEntitlements({
credits: data && data.credits,
totalCreditsPurchased: data && data.totalCreditsPurchased
});
}
if (data && data.payoutAddress) {
$('#messages').append($('
').text(data.message));
UI.scrollChat();
}
},
// Default credits per game (overridden by game_mode_info)
_creditsPerGame: 1,
_updateCreditsDisplay: function(balance, creditsPerGame) {
this._creditsBalance = balance;
if (creditsPerGame && creditsPerGame > 0) {
SocketHandlers._creditsPerGame = creditsPerGame;
}
var el = document.getElementById('creditsDisplay');
if (!el) return;
var perGame = SocketHandlers._creditsPerGame || 1;
var gamesRemaining = Math.floor(balance / perGame);
if (balance > 0) {
el.style.display = 'block';
if (gamesRemaining > 0) {
el.textContent = 'Credits: ' + balance + ' (' + gamesRemaining + ' game' + (gamesRemaining !== 1 ? 's' : '') + ')';
} else {
el.textContent = 'Credits: ' + balance;
}
} else {
el.style.display = 'none';
}
},
onWelcome: function(msg) {
$('#messages').append($('
').text("Connected to server!"));
UI.scrollChat();
},
onMessage: function(msg) {
$('#messages').append($('
').text(msg));
UI.scrollChat();
},
// One-shot banner over the game view for headline in-run events (descend / treasure). It is
// pointer-events:none so it never eats input, and it removes itself when the animation ends.
showEventBanner: function(title, sub, color) {
color = color || '#ffcc00';
var host = document.getElementById('game-display') || document.body;
if (!document.getElementById('evt-banner-style')) {
var st = document.createElement('style');
st.id = 'evt-banner-style';
st.textContent =
'@keyframes evtBannerIn{0%{opacity:0;transform:translate(-50%,-50%) scale(.6)}' +
'12%{opacity:1;transform:translate(-50%,-50%) scale(1.12)}' +
'22%{transform:translate(-50%,-50%) scale(1)}' +
'76%{opacity:1;transform:translate(-50%,-50%) scale(1)}' +
'100%{opacity:0;transform:translate(-50%,-50%) scale(1.05)}}';
document.head.appendChild(st);
}
// Only one banner is on screen at a time.
var prev = document.getElementById('evt-banner');
if (prev && prev.parentNode) prev.parentNode.removeChild(prev);
var b = document.createElement('div');
b.id = 'evt-banner';
var positioned = host.id === 'game-display'; // #game-display is position:relative
b.style.cssText = (positioned ? 'position:absolute;' : 'position:fixed;') +
'left:50%;top:34%;z-index:30;pointer-events:none;text-align:center;' +
'font-family:ui-monospace,monospace;animation:evtBannerIn 2600ms ease-out forwards;';
b.innerHTML =
'
').text(data.message));
UI.scrollChat();
// Mirror concise status in header banner for key lifecycle types
if (data.type === 'connection') {
SocketHandlers._setBannerStatus('Ready', '#0f0');
} else if (data.type === 'queue') {
SocketHandlers._setBannerStatus('Queued', '#0af');
} else if (data.type === 'payment') {
// Generic payment message; more granular handlers override this later.
SocketHandlers._setBannerStatus('Payment', '#0af');
if (typeof AudioAlerts !== 'undefined') { AudioAlerts.playRequestCoin(); }
} else if (data.type === 'error') {
SocketHandlers._setBannerStatus('Error', '#f00');
} else if (data.type === 'success') {
SocketHandlers._setBannerStatus('Success', '#0f0');
}
},
// Chat text arrives already HTML-escaped by the server; that is the delivery contract, since the
// tavern renders it as HTML directly. Decoding the exact five entities the server writes and
// rendering through a TEXT node avoids double encoding (apostrophes as ', ampersands as
// &) while keeping the message unparseable as markup, so the client never has to trust that
// the server escaped it.
_chatText: function(value) {
return String(value == null ? '' : value)
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/&/g, '&'); // Last, mirroring the server escaping & first.
},
// sender: followed by the message as a text node.
_chatLine: function(msgElement, sender, message) {
const strong = document.createElement('strong');
strong.textContent = SocketHandlers._chatText(sender) + ':';
msgElement.append(strong);
msgElement.append(document.createTextNode(' ' + SocketHandlers._chatText(message)));
},
onChatBroadcast: function(data) {
const msgElement = $('
');
// Prefer non-sensitive attribution fields; the server does not send the raw full socket.id.
// The short slice covers older payloads only.
const sender = data.username || data.playerId || data.publicId ||
(data.socketId ? String(data.socketId).substring(0, 6) : null);
if (sender) {
SocketHandlers._chatLine(msgElement, String(sender), data.message);
} else {
msgElement.text(SocketHandlers._chatText(data.message));
}
$('#messages').append(msgElement);
UI.scrollChat();
},
onChatHistory: function(data) {
if (!data || !data.messages || !Array.isArray(data.messages)) return;
if (data.messages.length > 0) {
$('#messages').append($('
').text('--- Recent Chat History ---'));
}
// data-msg-id lets chat_deleted find and remove a specific line later.
data.messages.forEach(function(msg) {
const msgElement = $('
').text('⚠️ Fairness proof mismatch: the server did not echo the offer/client seed used for this attempt.'));
}
SocketHandlers._pendingFairnessAttempt = null;
// Held for the reveal rendered at game over.
SocketHandlers._currentGameProof = data.proof;
}
if (typeof Game !== 'undefined' && Game.stopWaitingScreen) {
Game.stopWaitingScreen();
}
if (!data) {
$('#messages').append($('
').text("Error: No game data received from server."));
if (typeof Game !== 'undefined' && Game._drawWelcomeScreen) Game._drawWelcomeScreen();
return;
}
try {
var success = Game.startGame(data.player, data.map, data.monster, data.items, data.visibleTiles, data.lighting, data.torches);
if (!success) {
$('#messages').append($('
').text("Game start failed. Check console for details."));
if (typeof Game !== 'undefined' && Game._drawWelcomeScreen) Game._drawWelcomeScreen();
} else {
// The game is live and rendering, so the "Starting game..." transient is dropped.
// The server's "Game started!" success line is the standing status.
$('#messages').find('.game-start').remove();
setTimeout(function() {
$('#game-display').focus();
}, 100);
}
} catch (error) {
console.error("Error starting game:", error);
$('#messages').append($('
').text("Game start error: " + error.message));
if (typeof Game !== 'undefined' && Game._drawWelcomeScreen) Game._drawWelcomeScreen();
}
UI.scrollChat();
SocketHandlers._setBannerStatus('In Game', '#0f0');
// AudioAlerts._patchSocketHandlers() plays the sound for this event.
},
onGameUpdate: function(data) {
// Game exposes updateGameState(); updateGame is only an alias some builds may add.
if (typeof Game !== 'undefined') {
if (Game.updateGameState) {
Game.updateGameState(data);
} else if (Game.updateGame) {
Game.updateGame(data);
} else {
console.warn('Game update received but no updateGameState()/updateGame() method found on Game.');
}
}
UI.scrollChat();
},
onGameSettlementPending: function() {
// The server withholds game_over until the authoritative completion and any payout
// obligation commit together. This keeps the player informed without publishing a result
// PostgreSQL cannot yet prove, and without offering another entry mid-retry.
$('#messages').find('.solo-settlement-pending').remove();
$('#messages').append($('
').html(
'🔓 Proof revealed: ' +
'Server seed:' + shortServerSeed + ' ' +
' ' +
'Client seed:' + shortClientSeed + ' ' +
'Effective seed:' + shortEffectiveSeed + ' ' +
'Hash:' + shortCommitment + ' ' +
'🔗 Verify this game'
);
$('#messages').append($proofReveal);
SocketHandlers._currentGameProof = null;
}
if (typeof Game !== 'undefined') {
if (Game.endGame) {
Game.endGame(data);
} else if (Game.drawLoseScreen && data && data.reason) {
// Minimal fallback when endGame is absent.
if (data.reason === 'monster') Game.drawLoseScreen('monster');
}
}
const won = data && (data.status === 'won' || data.reason === 'escaped');
if (won && data.payout && data.payout.amount) {
var decimals = (SocketHandlers._cryptoType === 'WOW') ? 11 : 12;
var amountFormatted = (data.payout.amount / Math.pow(10, decimals)).toFixed(4);
var currency = SocketHandlers._currencyLabel || SocketHandlers._cryptoType || 'WOW';
var multiplierText = data.payout.multiplier ? ' (' + data.payout.multiplier + 'x)' : '';
var treasureText = data.payout.treasure ? ' + Treasure bonus!' : '';
$('#messages').append($('
').html(
'💰 Payout queued: ' + amountFormatted + ' ' + currency + multiplierText + treasureText
));
}
// The verify link carries OG meta on the /verify page, so a shared post unfurls a preview.
if (won) {
SocketHandlers._appendShareWin(data);
}
UI.scrollChat();
if (won) {
SocketHandlers._setBannerStatus('Won', '#0f0');
} else {
SocketHandlers._setBannerStatus('Lost', '#f00');
}
},
// Absolute verify URL for this game, used by the share button.
_verifyUrlFor: function(data) {
var path = (data && data.proof && (data.proof.verificationUrl ||
(data.proof.gameId ? '/verify/' + data.proof.gameId : null)));
if (!path) return null;
try { return new URL(path, window.location.origin).href; }
catch (e) { return window.location.origin + path; }
},
_appendShareWin: function(data) {
var url = SocketHandlers._verifyUrlFor(data);
if (!url) return; // Without a proof there is nothing to verify or share.
var bag = data && data.treasure ? ' with the bag 💰' : '';
var score = (data && typeof data.score === 'number') ? (' (score ' + data.score + ')') : '';
var text = '🏆 I escaped the dungeon' + bag + score + ' in this provably-fair crypto roguelike!';
var intent = 'https://twitter.com/intent/tweet?text=' +
encodeURIComponent(text) + '&url=' + encodeURIComponent(url);
var $row = $('
');
$row.append($('').text('Brag about it:'));
var $x = $('');
$x.on('click', function () { window.open(intent, '_blank', 'noopener'); });
var $copy = $('');
$copy.on('click', function () {
var done = function () { $copy.text('✅ Copied!'); setTimeout(function () { $copy.text('🔗 Copy link'); }, 1500); };
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(done).catch(function () { window.prompt('Copy this link:', url); });
} else { window.prompt('Copy this link:', url); }
});
$row.append($x).append($copy);
$('#messages').append($row);
},
onQueueCancelled: function(data) {
SocketHandlers._isQueued = false;
SocketHandlers._updateEarlyEntryButton();
$('#messages').append($('
').text(data.message));
UI.scrollChat();
},
onAddressConfirmed: function(data) {
console.log('Address confirmed:', data);
// Drop the earlier address prompts now that the address is confirmed.
$('#messages li').each(function() {
const text = $(this).text();
if (text.includes('Paste your payout address') ||
text.includes('Detected payout address') ||
text.includes('Type \'confirm\' to save') ||
text.includes('Address detected. Type confirm')) {
$(this).remove();
}
});
$('#messages').append($('
').text(data.message));
UI.scrollChat();
if (typeof AddressModal !== 'undefined') {
AddressModal.onConfirmed(data);
}
if (!data.cancelled && data.address) {
SocketHandlers._updateAddressButtonStatus(true);
}
},
onAddressPrompt: function(data) {
if (typeof AddressModal !== 'undefined') {
AddressModal.show({
existingAddress: data?.existingAddress || null,
message: data?.message || null
});
}
},
onAddressUpdateError: function(data) {
if (typeof AddressModal !== 'undefined') {
AddressModal.handleError(data?.message || 'Failed to update address.');
}
},
onGameModeInfo: function(data) {
console.log('Game mode info received:', data);
if (data.creditsPerGame) {
SocketHandlers._creditsPerGame = data.creditsPerGame;
}
SocketHandlers._gameMode = data.mode;
SocketHandlers._freePlayEnabled = !!data.freePlayEnabled;
SocketHandlers._earlyEntryConfig = data.earlyEntry || { enabled: false };
SocketHandlers._creditsPayoutsEnabled = !!data.creditsPayoutsEnabled;
SocketHandlers._directModeEnabled = !!data.directModeEnabled;
SocketHandlers._directPayoutsEnabled = !!data.directPayoutsEnabled;
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) {
Leaderboard.updateConfig(data);
}
// Prestige-only users are never asked for a payout address, so the UI never implies paid
// entries award crypto. Match crypto counts only when its economy is admitted.
var matchCryptoPayouts = !!(data.modes && data.modes.match && data.modes.match.economies
&& data.modes.match.economies.crypto_race);
var anyPayouts = SocketHandlers._creditsPayoutsEnabled
|| SocketHandlers._directPayoutsEnabled || matchCryptoPayouts;
$('#manageAddressButton').toggle(anyPayouts);
if (data.entitlements && typeof SinglePlayerAvatar !== 'undefined') {
SinglePlayerAvatar.applyEntitlements(data.entitlements);
}
// Mode availability (Solo / Tavern / Multiplayer). A server that omits `modes` is treated
// as solo-only.
var modes = data.modes || { solo: true, tavern: false, multiplayer: false };
SocketHandlers._modes = modes;
if (modes.tavern) { $('#tavernButton').show(); } else { $('#tavernButton').hide(); }
// A tavern-only instance has no single-player entry point.
if (modes.solo === false) { $('#startButton').hide(); }
if (SocketHandlers._smirkEnabled && typeof SmirkAuth !== 'undefined' && !SmirkAuth._initialized) {
SmirkAuth.init();
}
if (typeof UI !== 'undefined' && UI.updateGameTitle) {
UI.updateGameTitle(data.cryptoType);
}
// ScreenManager uses the crypto type in the welcome screen title.
if (typeof ScreenManager !== 'undefined' && ScreenManager.setCryptoType) {
ScreenManager.setCryptoType(data.cryptoType);
}
if (data.testnetWarning) {
SocketHandlers._showNetworkWarning(data.testnetWarning, data.network);
} else {
SocketHandlers._hideNetworkWarning();
}
if (typeof PaymentUI !== 'undefined') {
PaymentUI.updateConfig(data);
if (data.paymentsEnabled) {
$('#shopButton').show();
} else {
$('#shopButton').hide();
}
}
if (typeof HelpModal !== 'undefined') {
HelpModal.updateConfig(data);
}
SocketHandlers._updateEarlyEntryButton();
},
_showNetworkWarning: function(message, network) {
let $warning = $('#networkWarning');
if (!$warning.length) {
$warning = $('');
$('.container').prepend($warning);
}
const networkUpper = (network || 'stagenet').toUpperCase();
$warning.html(`
⚠️ ${networkUpper} MODE ⚠️ ${message}
`).show();
},
_hideNetworkWarning: function() {
$('#networkWarning').hide();
},
onPaymentCreated: function(data) {
if (typeof AudioAlerts !== 'undefined') { AudioAlerts.playRequestCoin(); }
// Invoice delivery, replay and reconnect are never wallet consent.
SocketHandlers._showPaymentFlow(data);
},
_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;
},
_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];
},
_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.');
}
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($('