/**
* 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; was previously declared
// twice in this object literal (the later `null` silently won)
_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;
}
// Generate the player's contribution 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) {
// Address is set - show green indicator
$btn.css({
'background': '#053655',
'color': '#0ff',
'border-color': '#0ff'
});
$btn.html('✅ Payout Address Set');
} else {
// No address - show warning indicator (yellow/orange)
$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 {
// Fallback
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() {
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. Multi-level DESCEND is the big one — without this, taking the
// stairs down silently dumped the player at a new entrance and read as a bug.
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);
},
// Show a transient "someone just escaped" toast so the room feels alive. Toasts stack in a
// fixed container (top-right), 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);
// Fade in, hold, fade out, remove.
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');
// Update small status banner immediately
SocketHandlers._setBannerStatus('Connected', '#0f0');
},
onSessionToken: function(data) {
if (data && data.token) {
try { localStorage.setItem('wownerogue_token', data.token); } catch(e) {}
$('#messages').append($('
').text('New session established. Token stored.'));
UI.scrollChat();
// New session = no address set yet
SocketHandlers._updateAddressButtonStatus(false);
SocketHandlers._refreshSmirkLinked();
}
},
// The server ROTATES the session token on every resume, so the initial SmirkAuth.checkStatus()
// races the rotation and 403's with the stale token — leaving _isLinked=false and disabling
// one-click Smirk payment after a reload. Re-check with the FRESH token once it's 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) {
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) {
// Store current balance
this._creditsBalance = balance;
// Update stored creditsPerGame if provided
if (creditsPerGame && creditsPerGame > 0) {
SocketHandlers._creditsPerGame = creditsPerGame;
}
var el = document.getElementById('creditsDisplay');
if (!el) return;
// Calculate games remaining
var perGame = SocketHandlers._creditsPerGame || 1;
var gamesRemaining = Math.floor(balance / perGame);
// Show/hide based on balance
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();
},
// A big, one-shot on-screen banner over the game view for headline in-run events (descend /
// treasure). Pops in, holds, fades — pointer-events:none so it never eats input, and auto-removes.
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);
}
// Drop any banner still on screen so they never stack.
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 =
'
' + title + '
' +
(sub ? '
' + sub + '
' : '');
host.appendChild(b);
setTimeout(function () { if (b.parentNode) b.parentNode.removeChild(b); }, 2650);
// A little extra pop: a soft colored flash if the FX overlay is up.
try { if (window.FX && window.FX.flash) window.FX.flash(color, 0.16, 320); } catch (e) {}
},
onStatusUpdate: function(data) {
const statusClass = data.type === 'error' ? 'error' : 'status';
const statusColor = data.type === 'error' ? '#f00' :
data.type === 'warning' ? '#ff0' :
data.type === 'success' ? '#0f0' :
data.type === 'payment' ? '#0af' : '#fff';
$('#messages').append($('
').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 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');
}
},
onChatBroadcast: function(data) {
const msgElement = $('
');
// Prefer non-sensitive attribution fields; the server no longer sends the raw full
// socket.id (S1). Fall back to a short slice only for legacy payloads.
const sender = data.username || data.playerId || data.publicId ||
(data.socketId ? String(data.socketId).substring(0, 6) : null);
if (sender) {
// Escape both fields — chat content is attacker-controlled. Defense in depth:
// the server also escapes, but the client must never trust that.
msgElement.html('' + escapeHtml(String(sender)) + ': ' + escapeHtml(data.message));
} else {
msgElement.text(data.message);
}
$('#messages').append(msgElement);
UI.scrollChat();
},
onChatHistory: function(data) {
if (!data || !data.messages || !Array.isArray(data.messages)) return;
// Add a separator for history messages
if (data.messages.length > 0) {
$('#messages').append($('
').text('--- Recent Chat History ---'));
}
// Add each historical message with data-msg-id for deletion support
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;
// Store for later verification display
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 {
// Game is live and rendering — drop the "Starting game…" transient; the server's
// "Game started! Escape before the next block!" 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');
// Sound handled by AudioAlerts._patchSocketHandlers() - don't duplicate
},
onGameUpdate: function(data) {
// Server pushed a game state update. The Game object exposes updateGameState().
// Older code referenced Game.updateGame, which doesn't exist in the current refactor,
// so updates were silently ignored (player appeared frozen).
if (typeof Game !== 'undefined') {
if (Game.updateGameState) {
Game.updateGameState(data);
} else if (Game.updateGame) { // Fallback if an alias gets added later
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 + any payout
// obligation commit together. Keep the player informed without publishing a result that
// PostgreSQL cannot yet prove or offering another entry while retry is in progress.
$('#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);
// Clear stored proof
SocketHandlers._currentGameProof = null;
}
if (typeof Game !== 'undefined') {
if (Game.endGame) {
Game.endGame(data);
} else if (Game.drawLoseScreen && data && data.reason) {
// Minimal fallback if endGame not present
if (data.reason === 'monster') Game.drawLoseScreen('monster');
}
}
const won = data && (data.status === 'won' || data.reason === 'escaped');
// Show payout notification for wins
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
));
}
// Offer a one-tap "Share to X" on wins (brag mechanic). The verify link unfurls with a
// social card (OG meta on the /verify page), so a shared tweet shows a preview.
if (won) {
SocketHandlers._appendShareWin(data);
}
UI.scrollChat();
if (won) {
SocketHandlers._setBannerStatus('Won', '#0f0');
} else {
SocketHandlers._setBannerStatus('Lost', '#f00');
}
},
// Build an 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; // no proof -> nothing to verify/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) {
// Clear queued state
SocketHandlers._isQueued = false;
SocketHandlers._updateEarlyEntryButton();
$('#messages').append($('
').text(data.message));
UI.scrollChat();
},
onAddressConfirmed: function(data) {
console.log('Address confirmed:', data);
// Cleanup previous address-related messages to reduce clutter
$('#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);
}
// Update button to show address is set (unless cancelled)
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);
// Store creditsPerGame for calculating games remaining
if (data.creditsPerGame) {
SocketHandlers._creditsPerGame = data.creditsPerGame;
}
// Store game mode and early entry config
SocketHandlers._gameMode = data.mode;
SocketHandlers._freePlayEnabled = !!data.freePlayEnabled;
SocketHandlers._earlyEntryConfig = data.earlyEntry || { enabled: false };
SocketHandlers._creditsPayoutsEnabled = !!data.creditsPayoutsEnabled;
SocketHandlers._directPayoutsEnabled = !!data.directPayoutsEnabled;
SocketHandlers._paymentsEnabled = !!data.paymentsEnabled;
SocketHandlers._smirkEnabled = SocketHandlers._isSmirkExplicitlyEnabled(data);
SocketHandlers._cryptoType = data.cryptoType || 'WOW';
SocketHandlers._currencyLabel = data.currencyLabel || data.cryptoType || 'WOW'; // sXMR on stagenet
SocketHandlers._explorerTxUrl = data.explorerTxUrl || null;
// Do not ask prestige-only users for a payout address or imply that mainnet paid
// entries award crypto. Match crypto is exposed 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). Backward compatible: if the server
// doesn't send `modes`, assume solo-only (the historical behavior).
var modes = data.modes || { solo: true, tavern: false, multiplayer: false };
SocketHandlers._modes = modes;
if (modes.tavern) { $('#tavernButton').show(); } else { $('#tavernButton').hide(); }
// Tavern-only / no single-player instance: don't surface the solo start button.
if (modes.solo === false) { $('#startButton').hide(); }
// Initialize Smirk auth if enabled and not already initialized
if (SocketHandlers._smirkEnabled && typeof SmirkAuth !== 'undefined' && !SmirkAuth._initialized) {
SmirkAuth.init();
}
if (typeof UI !== 'undefined' && UI.updateGameTitle) {
UI.updateGameTitle(data.cryptoType);
}
// Update ScreenManager with crypto type for welcome screen title
if (typeof ScreenManager !== 'undefined' && ScreenManager.setCryptoType) {
ScreenManager.setCryptoType(data.cryptoType);
}
// Display testnet/stagenet warning if applicable
if (data.testnetWarning) {
SocketHandlers._showNetworkWarning(data.testnetWarning, data.network);
} else {
SocketHandlers._hideNetworkWarning();
}
if (typeof PaymentUI !== 'undefined') {
PaymentUI.updateConfig(data);
// Show shop button if payments enabled
if (data.paymentsEnabled) {
$('#shopButton').show();
} else {
$('#shopButton').hide();
}
}
// Update HelpModal with config
if (typeof HelpModal !== 'undefined') {
HelpModal.updateConfig(data);
}
// Update early entry button visibility
SocketHandlers._updateEarlyEntryButton();
},
_showNetworkWarning: function(message, network) {
let $warning = $('#networkWarning');
if (!$warning.length) {
$warning = $('');
// Insert at top of container
$('.container').prepend($warning);
}
const networkUpper = (network || 'stagenet').toUpperCase();
$warning.html(`
⚠️ ${networkUpper} MODE ⚠️ ${message}
`).show();
},
_hideNetworkWarning: function() {
$('#networkWarning').hide();
},
onPaymentCreated: function(data) {
console.log('Payment created:', data);
if (typeof AudioAlerts !== 'undefined') { AudioAlerts.playRequestCoin(); }
// Try Smirk native payment if user is connected via Smirk
if (typeof SmirkAuth !== 'undefined' && SmirkAuth._isLinked && SmirkAuth.isAvailable() &&
typeof window.smirk !== 'undefined' && window.smirk.requestPayment) {
SocketHandlers._trySmirkPayment(data);
return;
}
// Normal flow: show payment modal + chat message + QR
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($('
').text('Opening Smirk wallet for payment...'));
UI.scrollChat();
try {
// Use human-readable amount (e.g. "1"), NOT atomic units (e.g. 100000000000)
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
});
// User confirmed in Smirk — TX submitted, server monitoring handles the rest
$('#messages').append($('
').text(
'Payment sent via Smirk! Waiting for confirmation...'
));
UI.scrollChat();
// Show payment UI in "waiting for confirmation" state
if (typeof PaymentUI !== 'undefined') {
PaymentUI.showPaymentRequest(data);
$('#payment-status').html('Payment sent via Smirk — awaiting confirmation...');
}
SocketHandlers._setBannerStatus('Pay', '#0af');
if (typeof Game !== 'undefined' && Game._paymentRequested) Game._paymentRequested();
if (typeof Game !== 'undefined' && Game.drawWaitingScreen) Game.drawWaitingScreen();
} catch (err) {
console.log('Smirk payment declined/failed, falling back to manual:', err);
var errMsg = String(err.message || err || '').toLowerCase();
// Check if this is a user-initiated denial vs extension error
var isUserDenied = errMsg.indexOf('denied') !== -1 ||
errMsg.indexOf('rejected') !== -1 ||
errMsg.indexOf('cancelled') !== -1 ||
errMsg.indexOf('user') !== -1;
if (isUserDenied) {
$('#messages').append($('
').text(
'Smirk payment cancelled. Use the address below.'
));
} else {
// Extension error (context invalidated, service worker issue, etc.)
$('#messages').append($('
').text(
'Smirk payment unavailable — using manual payment. Try refreshing the page to fix Smirk.'
));
SmirkAuth._isLinked = false;
}
UI.scrollChat();
// Fall back to normal address/QR flow
SocketHandlers._showPaymentFlow(data);
}
},
/**
* Show normal payment flow: modal + chat message + QR code
*/
_showPaymentFlow: function(data) {
if (typeof PaymentUI !== 'undefined') {
PaymentUI.showPaymentRequest(data);
}
const parts = [];
const reusedCopy = data.reused
? '🔁 Pending payment request still active. Use the same amount and address below.'
: '💳 Payment Required';
parts.push(reusedCopy);
const displayAmount = data.humanAmount || data.amountFormatted || data.amount;
parts.push('Amount: ' + displayAmount + ' ' + (data.currencyLabel || SocketHandlers._currencyLabel || data.cryptoType || ''));
const shortAddr = data.address.substring(0, 10) + '…' + data.address.slice(-6);
parts.push('Address: ' + shortAddr + '' +
' ');
const $li = $('
').html(parts.join(' '));
$('#messages').append($li);
// Attach copy handler
const fullAddress = data.address;
$li.on('click', '.copy-pay-address', function(e) {
e.preventDefault();
const addr = $(this).data('address');
const doCopy = async () => {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(addr);
} else {
var ta = document.createElement('textarea');
ta.value = addr; document.body.appendChild(ta); ta.select();
document.execCommand('copy'); document.body.removeChild(ta);
}
$(this).text('Copied!');
setTimeout(() => { $(this).text('Copy'); }, 2000);
} catch (err) {
console.error('Copy failed', err);
$(this).text('Error');
setTimeout(() => { $(this).text('Copy'); }, 2500);
}
};
doCopy();
});
// Toggle short/full address on span click
var showingFull = false;
$li.on('click', '.pay-address-full', function() {
showingFull = !showingFull;
$(this).text(showingFull ? fullAddress : shortAddr);
});
UI.scrollChat();
setTimeout(function() {
$('#chatInput').focus();
}, 100);
SocketHandlers._setBannerStatus('Pay', '#0af');
if (typeof Game !== 'undefined' && Game._paymentRequested) Game._paymentRequested();
if (typeof Game !== 'undefined' && Game.drawWaitingScreen) {
Game.drawWaitingScreen();
}
// Sidebar QR (persistent) - create/update separate from chat scroll
if (data.qr) {
let qrHolder = document.getElementById('paymentQRContainer');
if (!qrHolder) {
const statusDiv = document.querySelector('.status');
if (statusDiv) {
qrHolder = document.createElement('div');
qrHolder.id = 'paymentQRContainer';
qrHolder.style.cssText = 'position:relative;margin-top:6px;padding:8px;border:1px solid #0f0;background:#000;display:block;width:calc(100% - 18px);text-align:center;';
statusDiv.appendChild(qrHolder);
}
}
if (qrHolder) {
// Create close button
const closeBtn = '
✕
';
qrHolder.innerHTML = closeBtn + '';
}
} else {
console.warn('Payment created but no QR data supplied by server.');
let qrHolder = document.getElementById('paymentQRContainer');
if (!qrHolder) {
const statusDiv = document.querySelector('.status');
if (statusDiv) {
qrHolder = document.createElement('div');
qrHolder.id = 'paymentQRContainer';
qrHolder.style.cssText = 'margin-top:6px;padding:4px;border:1px solid #f80;background:#000;display:inline-block;color:#f80;font-size:11px;max-width:150px;';
statusDiv.appendChild(qrHolder);
}
}
if (qrHolder) {
qrHolder.textContent = 'QR unavailable';
}
}
},
// Helper to hide/remove the payment QR code container
hidePaymentQR: function() {
const qrHolder = document.getElementById('paymentQRContainer');
if (qrHolder) {
qrHolder.remove();
}
},
onShowPaymentOptions: function(data) {
console.log('Show payment options:', data);
// Show the payment options modal so user can choose how to play
if (typeof PaymentUI !== 'undefined') {
PaymentUI.show();
}
},
onCommerceAcknowledgementRequired: function(data) {
var message = data && (data.message || data.error)
|| 'Review the paid-play disclosures before continuing.';
$('#messages').append($('
').text('Disclosures acknowledged. Retry the paid action when ready.'));
if (typeof UI !== 'undefined' && UI.scrollChat) UI.scrollChat();
});
});
}
},
onBalanceCritical: function(data) {
console.warn('Balance critical - games halted:', data);
const message = data?.message || 'Sorry, the house balance is too low to initiate new games. Please try again later.';
// Show a modal to the user
SocketHandlers._showBalanceCriticalModal(message);
// Also show in chat
$('#messages').append($('
`);
$('body').append($modal);
// Close handlers
$modal.on('click', '#balanceCriticalOK', function() {
$modal.fadeOut(200, function() { $modal.remove(); });
});
// Also close on clicking outside
$modal.on('click', function(e) {
if (e.target === $modal[0]) {
$modal.fadeOut(200, function() { $modal.remove(); });
}
});
// Close on ESC key
$(document).one('keydown.balanceCritical', function(e) {
if (e.key === 'Escape') {
$modal.fadeOut(200, function() { $modal.remove(); });
}
});
},
_lastDisplayedConfirmation: null,
_confirmationTimestamps: {}, // paymentId -> ts
onPaymentConfirmed: function(data) {
console.log('Payment confirmed in block:', data);
if (!data || !data.paymentId) return; // ignore malformed legacy event
// Expire old confirmations (6h default) to prevent unbounded memory
SocketHandlers._expireOldClientPaymentMarkers();
if (SocketHandlers._lastDisplayedConfirmation === data.paymentId) return;
SocketHandlers._lastDisplayedConfirmation = data.paymentId;
SocketHandlers._confirmationTimestamps[data.paymentId] = Date.now();
// Hide the QR code now that payment is confirmed
SocketHandlers.hidePaymentQR();
// Credits purchase: return to home screen (no game queue)
if (data.creditsAdded || data.newBalance !== undefined) {
if (typeof Game !== 'undefined') {
Game._pendingPaymentConfirmed();
}
// Return to welcome/home screen so user can start a game
if (typeof ScreenManager !== 'undefined' && ScreenManager.drawWelcomeScreen) {
ScreenManager.drawWelcomeScreen();
}
$('#messages').append($('
').html(
'✅ Credits purchased! Type \'enter\' or click START GAME to play.'
));
UI.scrollChat();
SocketHandlers._setBannerStatus('Credits Added', '#0f0');
} else {
// Single game payment: show waiting screen (player is in queue)
if (typeof Game !== 'undefined' && Game.drawWaitingScreen) {
Game.drawWaitingScreen();
}
if (typeof Game !== 'undefined') {
Game._pendingPaymentConfirmed();
}
$('#messages').append($('
').html(
'✅ Payment confirmed in block. You are in the game queue.'
));
UI.scrollChat();
SocketHandlers._setBannerStatus('Confirmed', '#0f0');
}
// Sound handled by AudioAlerts._patchSocketHandlers() - don't duplicate
},
_mempoolShownForPayment: new Set(),
_mempoolTimestamps: {}, // paymentId -> ts
onPaymentDetected: function(data) {
console.log('Payment detected (mempool):', data);
if (!data || !data.paymentId) return; // require paymentId for dedupe
SocketHandlers._expireOldClientPaymentMarkers();
if (SocketHandlers._mempoolShownForPayment.has(data.paymentId)) return;
SocketHandlers._mempoolShownForPayment.add(data.paymentId);
SocketHandlers._mempoolTimestamps[data.paymentId] = Date.now();
// Hide the QR code now that payment was detected
SocketHandlers.hidePaymentQR();
if (typeof Game !== 'undefined') {
Game._pendingPaymentDetected(data);
if (Game.drawWaitingScreen) Game.drawWaitingScreen();
}
$('#messages').append($('
').html(
'🌀 Payment detected (mempool) – awaiting block confirmation...'
));
UI.scrollChat();
SocketHandlers._setBannerStatus('Mempool', '#0af');
// Sound handled by AudioAlerts._patchSocketHandlers() - don't duplicate
},
// Internal: periodic cleanup of client-side payment marker sets (invoked opportunistically)
_expireOldClientPaymentMarkers: function() {
const TTL = 6 * 60 * 60 * 1000; // 6 hours
const now = Date.now();
// Mempool markers
for (const pid of Array.from(SocketHandlers._mempoolShownForPayment)) {
const ts = SocketHandlers._mempoolTimestamps[pid];
if (!ts || now - ts > TTL) {
SocketHandlers._mempoolShownForPayment.delete(pid);
delete SocketHandlers._mempoolTimestamps[pid];
}
}
// Confirmation marker (single id tracking + map of timestamps)
if (SocketHandlers._lastDisplayedConfirmation) {
const lastId = SocketHandlers._lastDisplayedConfirmation;
const ts = SocketHandlers._confirmationTimestamps[lastId];
if (ts && now - ts > TTL) {
delete SocketHandlers._confirmationTimestamps[lastId];
// Allow new confirmation message for same id after TTL (rare, but cleans memory)
SocketHandlers._lastDisplayedConfirmation = null;
}
}
},
onBlockHeight: function(data) {
if (typeof UI !== 'undefined' && UI.updateBlockHeight) {
UI.updateBlockHeight(data.blockHeight);
}
// Sync to ScreenManager for welcome screen display
if (typeof ScreenManager !== 'undefined' && data.blockHeight) {
ScreenManager._currentBlockHeight = data.blockHeight;
// Redraw welcome screen if not in a game, not spectating, and not showing waiting screen
if (typeof GameState !== 'undefined' && !GameState.isGameActive() &&
typeof Game !== 'undefined' && !Game._isSpectating &&
!ScreenManager.isShowingWaitingScreen()) {
ScreenManager.drawWelcomeScreen();
}
}
// Fallback: if status banner still says Connecting..., upgrade it
const current = $('#statusValue').text();
if (current === 'Connecting...') {
SocketHandlers._setBannerStatus('Ready', '#0f0');
}
},
onUserCount: function(data) {
console.log('👥 User count received:', data);
if (!data || typeof data.count !== 'number') return;
SocketHandlers._updateUserCountDisplay(data.count);
},
_updateUserCountDisplay: function(count) {
let el = document.getElementById('userCountDisplay');
if (!el) {
// Create display element next to connection status
const statusDiv = document.querySelector('.status') || document.getElementById('connectionStatus')?.parentElement;
if (statusDiv) {
el = document.createElement('div');
el.id = 'userCountDisplay';
el.style.cssText = 'font-size:12px;color:#0af;margin-top:2px;';
statusDiv.appendChild(el);
}
}
if (el) {
const plural = count === 1 ? '' : 's';
el.innerHTML = `👥 ${count} player${plural} online`;
}
},
// ====== SPECTATOR HANDLERS ======
_spectatorMode: false,
_spectatingGameId: null,
_activeGames: [],
onActiveGames: function(data) {
if (!data || !data.games) return;
SocketHandlers._activeGames = data.games;
SocketHandlers._updateGamesListPanel(data);
SocketHandlers._updateLiveCta(data);
},
// "Land on action": when live games are in progress, surface a prominent, dismissible CTA
// in the status area so a new visitor immediately sees the room is alive and can jump into
// spectating with one click. Deliberately NON-intrusive — it never hijacks the canvas or
// interrupts someone who came to play (hidden while in a game / spectating, and dismissible).
_liveCtaDismissed: false,
_updateLiveCta: function(data) {
var el = document.getElementById('liveCta');
if (!el) return;
var liveCount = (data.games || []).length;
var inGame = (typeof Game !== 'undefined' && (Game._gameActive || Game._isSpectating)) || SocketHandlers._spectatorMode || SocketHandlers._isQueued;
var panelOpen = $('#gamesListPanel').is(':visible');
if (liveCount > 0 && !inGame && !panelOpen && !SocketHandlers._liveCtaDismissed) {
el.innerHTML = '🔴 ' + liveCount + ' game' + (liveCount === 1 ? '' : 's') +
' live right now — watch »';
el.style.display = 'block';
if (!el._bound) {
el._bound = true;
el.addEventListener('click', function () {
SocketHandlers._liveCtaDismissed = true; // don't nag after they engage
el.style.display = 'none';
if (window.socket) socket.emit('get_active_games', { page: 1, pageSize: 20 });
SocketHandlers._showGamesPanel();
});
}
} else {
el.style.display = 'none';
}
},
onSpectateStart: function(data) {
if (!data) return;
SocketHandlers._spectatorMode = true;
SocketHandlers._spectatingGameId = data.gameId;
// Hide games panel
SocketHandlers._hideGamesPanel();
// Show spectator controls
SocketHandlers._showSpectatorControls(data.playerId);
// Start the game display in spectator mode
if (typeof Game !== 'undefined' && data.initialState) {
const state = data.initialState;
try {
// Use a modified startGame that marks as spectator
Game._isSpectating = true;
Game.startGame(
state.player,
state.map,
state.monster,
state.items || {},
state.visibleTiles,
state.lighting,
state.torches
);
$('#messages').append($('
').text(
'👁️ Now spectating player ' + data.playerId + '. Press ESC or click "Leave" to exit.'
));
UI.scrollChat();
} catch (err) {
console.error('Failed to start spectator view:', err);
}
}
SocketHandlers._setBannerStatus('Spectating', '#0af');
},
onSpectatorUpdate: function(data) {
if (!data || !SocketHandlers._spectatorMode) return;
// Update the game display with new state
if (typeof Game !== 'undefined' && typeof Game.updateGameState === 'function') {
Game.updateGameState(data.gameState);
}
},
onSpectateEnded: function(data) {
SocketHandlers._spectatorMode = false;
SocketHandlers._spectatingGameId = null;
if (typeof Game !== 'undefined') {
Game._isSpectating = false;
}
// Hide spectator controls
SocketHandlers._hideSpectatorControls();
// Show end message
const reason = data?.reason || 'unknown';
const gameOverData = data?.gameOverData;
let message = '👁️ Spectating ended';
if (reason === 'game_over' && gameOverData) {
message = '👁️ Game ended: ' + (gameOverData.status === 'won' ? '🏆 Player escaped!' : '💀 Player caught!');
} else if (reason === 'user_left') {
message = '👁️ Left spectator mode';
}
$('#messages').append($('
').text(message));
UI.scrollChat();
// Return to welcome screen
if (typeof ScreenManager !== 'undefined' && ScreenManager.drawWelcomeScreen) {
ScreenManager.drawWelcomeScreen();
}
// Show games panel again
SocketHandlers._showGamesPanel();
SocketHandlers._setBannerStatus('Ready', '#0f0');
},
_updateGamesListPanel: function(data) {
let $panel = $('#gamesListPanel');
// Create panel if it doesn't exist
if (!$panel.length) {
$panel = $(`
`);
$('body').append($controls);
$controls.on('click', '#leaveSpectate', function() {
if (window.socket) {
socket.emit('leave_spectate');
}
});
}
$('#spectatingPlayer').text(playerId);
$controls.show();
},
_hideSpectatorControls: function() {
$('#spectatorControls').hide();
},
// =====================
// Early Entry Functions
// =====================
_earlyEntryConfig: { enabled: false },
_isQueued: false,
/**
* Check if early entry is currently allowed based on mode and config
*/
isEarlyEntryAllowed: function() {
const config = this._earlyEntryConfig;
if (!config || !config.enabled) return false;
const mode = this._gameMode;
if (mode === 'FREE' && config.allowInFreeMode) return true;
if (mode === 'PAID_CREDITS' && config.allowInCreditsMode) return true;
return false;
},
/**
* Update early entry button visibility based on current state
*/
_updateEarlyEntryButton: function() {
let $btn = $('#earlyEntryButton');
// Create button if it doesn't exist
if (!$btn.length) {
$btn = $(`
`);
// Add CSS animation
if (!$('#earlyEntryStyles').length) {
$('head').append(`
`);
}
// Insert near animation toggle button
const $animBtn = $('#animationToggleButton');
if ($animBtn.length) {
$animBtn.after($btn);
} else {
// Fallback - add to header area
$('#header').append($btn);
}
// Click handler
$btn.on('click', function() {
SocketHandlers.requestEarlyEntry();
});
}
// Show/hide based on whether we're queued and early entry is allowed
if (this._isQueued && this.isEarlyEntryAllowed()) {
// Update button text to show credit cost when in paid mode
var mode = this._gameMode;
if (mode === 'PAID_CREDITS' || mode === 'MIXED') {
var cost = this._creditsPerGame || 1;
$btn.text('⚡ ENTER NOW (' + cost + ' credit, RISKY!) ⚡');
} else {
$btn.text('⚡ ENTER NOW (RISKY!) ⚡');
}
$btn.show();
} else {
$btn.hide();
}
},
/**
* Request early entry from the server
*/
requestEarlyEntry: function() {
if (!window.socket) return;
// Confirm before spending credits
var mode = this._gameMode;
var msg = 'Enter the dungeon NOW?\n\nYou will die when the next block is found!';
if (mode === 'PAID_CREDITS' || mode === 'MIXED') {
var cost = this._creditsPerGame || 1;
msg = 'Use ' + cost + ' credit to enter the dungeon NOW?\n\nYou will die when the next block is found!';
}
if (!confirm(msg)) return;
// Disable button to prevent double-clicks
var $btn = $('#earlyEntryButton');
$btn.prop('disabled', true).text('⏳ Entering...');
if (!SocketHandlers.emitFairGameStart('early_entry')) {
$btn.prop('disabled', false).text('⚡ ENTER NOW (RISKY!) ⚡');
}
},
/**
* Handle early entry success
*/
onEarlyEntrySuccess: function(data) {
console.log('Early entry success:', data);
SocketHandlers._isQueued = false;
SocketHandlers._updateEarlyEntryButton();
// The game_start event will follow - just show feedback
$('#messages').append($('
').text('⚡ Early entry! Race to escape before the next block!'));
UI.scrollChat();
},
/**
* Handle early entry error
*/
onEarlyEntryError: function(data) {
console.error('Early entry error:', data);
// Re-enable button
const $btn = $('#earlyEntryButton');
$btn.prop('disabled', false).text('⚡ ENTER NOW (RISKY!) ⚡');
const message = data?.message || 'Early entry not available';
$('#messages').append($('
').text('Early entry failed: ' + message));
UI.scrollChat();
},
// =====================
// Entry Choice Modal
// =====================
// ONE consolidated entry modal: timing (when) + stakes (free vs ranked) in a single dialog, so
// the player never hits the old timing-modal-then-payment-modal double. Each card emits the
// FINAL intent — Free sends {free:true} (server skips its options modal), Ranked uses a credit
// or opens the real payment UI. Nothing starts until a card is clicked.
showEntryChoiceModal: function(opts) {
opts = opts || {};
$('#entryChoiceOverlay').remove();
var freeAvailable = !!opts.freeAvailable;
var hasCredits = !!opts.hasCredits;
var cost = this._creditsPerGame || 1;
var currency = this._currencyLabel || 'WOW';
var timing = 'wait'; // safe default
var $overlay = $('
').css({
background: 'linear-gradient(180deg,#101609,#0b0f07)', border: '1px solid #33421f',
borderRadius: '12px', width: '92%', maxWidth: '480px', color: '#e6ead0',
fontFamily: 'monospace', boxShadow: '0 22px 60px rgba(0,0,0,.6)', overflow: 'hidden'
});
function seg(id, on, t, d, danger) {
var col = on ? (danger ? '#ff6a1a' : '#4fd463') : '#8b9a78';
var bg = on ? (danger ? 'linear-gradient(180deg,#4a1c05,#2a1103)' : 'linear-gradient(180deg,#123a17,#0b2410)') : '#0a0f07';
var bc = on ? (danger ? '#ff6a1a' : '#4fd463') : '#33421f';
return '';
}
function stake(id, k, t, s, accent) {
return '
' +
'' + k + '' +
'
' + t + '
' +
'
' + s + '
' +
'ENTER ▸
';
}
function begin(msg, emit) {
if (emit() === false) return;
$('#entryChoiceOverlay').remove();
// Transient "dropping in…/queued…" line — cleared in onGameStart once the game is live so
// it doesn't linger next to a running game reading as "still starting". The queued variant
// stays visible during the (real) block wait; only a started game removes it.
$('#messages').append($('
').text(msg));
if (typeof UI !== 'undefined' && UI.scrollChat) UI.scrollChat();
if (typeof ScreenManager !== 'undefined' && ScreenManager.drawWaitingScreen) ScreenManager.drawWaitingScreen();
}
function pay() {
$('#entryChoiceOverlay').remove();
if (typeof PaymentUI !== 'undefined' && PaymentUI.show) PaymentUI.show();
}
function render() {
$modal.html(
'
' +
'⚔' +
'
Enter the Dungeon
' +
'esc' +
'
' +
'
' +
'
1 · When do you drop in?
' +
'
' +
seg('ecWait', timing === 'wait', '🛡 Next block', 'Start fresh · blocks land at random', false) +
seg('ecNow', timing === 'now', '⚡ Right now', 'Jump in · race the time left', true) +
'