').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 = $('
');
if (data.socketId) {
// 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(data.socketId).substring(0, 6)) + ': ' + 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 = $('
').html(
'🔐 Provably Fair: Game hash commitment: ' + shortHash + ' ' +
''
);
$('#messages').append($proofMsg);
// 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 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();
},
onGameOver: function(data) {
$('#messages').append($('
').html(
'🔓 Game Verified: ' +
'Seed:' + shortSeed + ' ' +
' ' +
'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._earlyEntryConfig = data.earlyEntry || { enabled: false };
SocketHandlers._creditsPayoutsEnabled = !!data.creditsPayoutsEnabled;
SocketHandlers._directPayoutsEnabled = !!data.directPayoutsEnabled;
SocketHandlers._paymentsEnabled = !!data.paymentsEnabled;
SocketHandlers._smirkEnabled = data.smirkEnabled !== false; // Default to true if not specified
SocketHandlers._cryptoType = data.cryptoType || 'WOW';
SocketHandlers._currencyLabel = data.currencyLabel || data.cryptoType || 'WOW'; // sXMR on stagenet
SocketHandlers._explorerTxUrl = data.explorerTxUrl || null;
// 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();
}
},
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...');
socket.emit('early_entry');
},
/**
* 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($('