/** * 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('Payout address restored.')); UI.scrollChat(); if (typeof AddressModal !== 'undefined') { AddressModal.setCurrentAddress(data.payoutAddress); } SocketHandlers._updateAddressButtonStatus(true); } else { SocketHandlers._updateAddressButtonStatus(false); } }, onCreditsUpdate: function(data) { if (!data) return; if (typeof data.balance === 'number') { SocketHandlers._updateCreditsDisplay(data.balance); } if (typeof SinglePlayerAvatar !== 'undefined') { SinglePlayerAvatar.applyEntitlements({ credits: data.balance, balance: data.balance, totalCreditsPurchased: data.totalCreditsPurchased, total_credits_purchased: data.total_credits_purchased }); } }, onIdentityUpdate: function(data) { if (typeof SinglePlayerAvatar !== 'undefined') { SinglePlayerAvatar.applyIdentity(data); } }, onIdentityError: function(data) { if (data && data.message) { $('#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 = $('
  • '); if (msg.id) { msgElement.attr('data-msg-id', msg.id); } const username = msg.playerId || msg.username || msg.publicId || (msg.socketId ? msg.socketId.substring(0, 6) : 'anon'); const isSystem = msg.type === 'system' || msg.socketId === 'system'; if (isSystem) { msgElement.addClass('status'); } // Add timestamp if available let timeStr = ''; if (msg.timestamp) { timeStr = '[' + SocketHandlers._formatTimeAgo(new Date(msg.timestamp)) + '] '; } msgElement.html(timeStr + '' + escapeHtml(username) + ': ' + escapeHtml(msg.message)); $('#messages').append(msgElement); }); if (data.messages.length > 0) { $('#messages').append($('
  • ').text(data.message)); UI.scrollChat(); }, _formatTimeAgo: function(date) { const seconds = Math.floor((Date.now() - date.getTime()) / 1000); if (seconds < 60) return 'just now'; if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago'; if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ago'; return Math.floor(seconds / 86400) + 'd ago'; }, onWaitingStatus: function(data) { if (data.status === 'waiting') { $('#messages').append($('
  • ').text(data.message)); // Check if we should show waiting screen const addressRequired = SocketHandlers.payoutAddressRequired(); const hasAddress = SocketHandlers._hasPayoutAddress; const canAfford = SocketHandlers.canAffordGame(); const isFree = SocketHandlers._gameMode === 'FREE'; const shouldShowWaiting = isFree || (canAfford && (!addressRequired || hasAddress)); if (shouldShowWaiting && typeof Game !== 'undefined' && Game.drawWaitingScreen) { Game.drawWaitingScreen(); } } UI.scrollChat(); }, onQueueJoined: function(data) { console.log('Queue joined:', data); // Mark as queued and update early entry button SocketHandlers._isQueued = true; SocketHandlers._updateEarlyEntryButton(); const currentBlockFallback = (typeof data.currentBlock === 'number' && data.currentBlock > 0) ? data.currentBlock : (typeof UI !== 'undefined' && typeof UI._currentBlockHeight === 'number' && UI._currentBlockHeight > 0 ? UI._currentBlockHeight : '?'); const nextBlockFallback = (typeof data.nextBlock === 'number' && data.nextBlock > 0) ? data.nextBlock : (currentBlockFallback === '?' ? '?' : currentBlockFallback + 1); $('#messages').append($('
  • ').html( '⏳ Queue Joined! Position: ' + data.position + '
    ' + '📦 Current block: ' + currentBlockFallback + ', Next: ' + nextBlockFallback )); UI.scrollChat(); if (typeof Game !== 'undefined' && Game.drawWaitingScreen) { Game.drawWaitingScreen(); } SocketHandlers._setBannerStatus('Queued', '#0af'); }, onGameStart: function(data) { // Clear queued state SocketHandlers._isQueued = false; SocketHandlers._updateEarlyEntryButton(); // Hide any lingering QR code SocketHandlers.hidePaymentQR(); // Clear the transient entry line ("dropping in…/queued…"); the game is starting now. $('#messages').find('.entry-progress').remove(); $('#messages').append($('
  • ').text("Starting game...")); // Display provably fair commitment if present if (data && data.proof && data.proof.commitment) { var attempted = SocketHandlers._pendingFairnessAttempt; var proofMatchesAttempt = !attempted || ( data.proof.offerId === attempted.offerId && data.proof.commitment === attempted.commitment && data.proof.clientSeed === attempted.clientSeed ); const shortHash = data.proof.commitment.substring(0, 16) + '...'; const shortClientSeed = data.proof.clientSeed ? data.proof.clientSeed.substring(0, 16) + '...' : '(legacy empty seed)'; const $proofMsg = $('
  • ').html( '🔐 Provably Fair: Game hash commitment: ' + shortHash + ' ' + '
    ' + 'Accepted client seed: ' + shortClientSeed + '' ); $('#messages').append($proofMsg); if (!proofMatchesAttempt) { $('#messages').append($('
  • ').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($('
  • ') .text('Result pending durable settlement · retrying safely…')); SocketHandlers._setBannerStatus('Saving result…', '#ffcc00'); UI.scrollChat(); }, onGameOver: function(data) { $('#messages').find('.solo-settlement-pending').remove(); $('#messages').append($('
  • ').text("Game Over: " + data.message)); // Display provably fair verification info if present if (data && data.proof && data.proof.seed) { const serverSeed = data.proof.serverSeed || ''; const effectiveSeed = data.proof.effectiveSeed || data.proof.seed; const clientSeed = data.proof.clientSeed || ''; const shortServerSeed = serverSeed ? serverSeed.substring(0, 16) + '...' : '(unavailable)'; const shortEffectiveSeed = effectiveSeed.substring(0, 16) + '...'; const shortClientSeed = clientSeed ? clientSeed.substring(0, 16) + '...' : '(legacy empty seed)'; const shortCommitment = data.proof.commitment.substring(0, 16) + '...'; const verifyUrl = data.proof.verificationUrl || ('/verify/' + data.proof.gameId); const $proofReveal = $('
  • ').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("Queue entry cancelled.")); if (typeof Game !== 'undefined' && Game._drawWelcomeScreen) { Game._drawWelcomeScreen(); } UI.scrollChat(); SocketHandlers._setBannerStatus('Ready', '#0f0'); }, onAddressDetected: function(data) { console.log('Address detected:', data); $('#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 + 'Payment QR'; } } 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(message)); if (typeof UI !== 'undefined' && UI.scrollChat) UI.scrollChat(); if (typeof CommerceConsent !== 'undefined' && CommerceConsent.require) { var refreshed = CommerceConsent.reject ? CommerceConsent.reject(data) : Promise.resolve(); refreshed.catch(function() { return null; }).then(function() { CommerceConsent.require(function() { $('#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($('
  • ').text('⚠️ ' + message)); UI.scrollChat(); SocketHandlers._setBannerStatus('Unavailable', '#ff6600'); }, _showBalanceCriticalModal: function(message) { // Remove any existing modal $('#balanceCriticalModal').remove(); const $modal = $(`
    ⚠️

    Service Temporarily Unavailable

    ${message}

    `); $('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 = $(`
    👁️ Live Games
    `); $('body').append($panel); // Close button handler $panel.on('click', '#gamesListClose', function() { $panel.hide(); }); // Spectate button handler (delegated) $panel.on('click', '.spectate-btn', function() { const gameId = $(this).data('gameid'); if (gameId && window.socket) { socket.emit('spectate_game', { gameId: gameId }); } }); } // Render pending games section const $pendingContent = $('#pendingGamesContent'); $pendingContent.empty(); if (data.pendingGames && data.pendingGames.length > 0) { $pendingContent.append(`
    ⏳ Pending (${data.pendingGames.length})
    `); data.pendingGames.forEach(function(pending) { const waitTime = Math.floor((Date.now() - pending.queuedAt) / 1000); const status = pending.waitingForConfirmation ? '⏳ Confirming' : '✅ Ready'; $pendingContent.append(`
    Player ${pending.playerId} ${waitTime}s ago
    ${status} - waiting for next block
    `); }); } // Render active games section const $content = $('#gamesListContent'); $content.empty(); if (!data.games || data.games.length === 0) { if (!data.pendingGames || data.pendingGames.length === 0) { $content.html('
    No active games
    '); } return; } $content.append(`
    🎮 Live (${data.games.length})
    `); // Render each game data.games.forEach(function(game) { const duration = game.durationSeconds || 0; const mins = Math.floor(duration / 60); const secs = duration % 60; const timeStr = mins > 0 ? mins + 'm ' + secs + 's' : secs + 's'; const $gameItem = $(`
    Player ${game.playerId} ${timeStr}
    ${game.moveCount} moves | ${game.hasTreasure ? '💎' : '⬜'} | 👁️ ${game.spectatorCount}
    `); $content.append($gameItem); }); // Pagination info const pag = data.pagination; if (pag && pag.totalGames > pag.pageSize) { $('#gamesListPagination').html( 'Page ' + pag.page + '/' + pag.totalPages + ' (' + pag.totalGames + ' games)' ); } else { $('#gamesListPagination').html(pag ? pag.totalGames + ' game(s)' : ''); } $panel.show(); }, _showGamesPanel: function() { $('#gamesListPanel').show(); }, _hideGamesPanel: function() { $('#gamesListPanel').hide(); }, _showSpectatorControls: function(playerId) { let $controls = $('#spectatorControls'); if (!$controls.length) { $controls = $(`
    👁️ Spectating: ---
    `); $('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({ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', background: 'rgba(0,0,0,0.74)', zIndex: 3000, display: 'flex', alignItems: 'center', justifyContent: 'center' }); var $modal = $('
    ').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) + '
    ' + '
    2 · Choose your stakes
    ' + (freeAvailable ? stake('ecFree', '🆓', 'Free Play', 'No cost · Pleb leaderboard', '#123a17') : '') + stake('ecRank', '💰', 'Ranked', (hasCredits ? '1 credit' : ('Pay ' + cost + ' ' + currency)) + ' · Hall of Champions', '#2a1a4a') + '
    + Buy credits (bulk discount)
    ' + '
    ' ); $('#ecWait').on('click', function () { timing = 'wait'; render(); }); $('#ecNow').on('click', function () { timing = 'now'; render(); }); $('#ecX').on('click', function () { $('#entryChoiceOverlay').remove(); }); $('#ecFree').on('click', function () { if (timing === 'now') begin('⚡ Free game — dropping in...', function () { return SocketHandlers.emitFairGameStart('auto_start', { free: true }); }); else begin('🛡️ Free game — queued for the next block...', function () { return SocketHandlers.emitFairGameStart('join_queue', { free: true }); }); }); $('#ecRank').on('click', function () { if (!hasCredits) { pay(); return; } if (timing === 'now') begin('⚡ Ranked — dropping in now...', function () { return SocketHandlers.emitFairGameStart('auto_start'); }); else begin('🛡️ Ranked — queued for the next block...', function () { return SocketHandlers.emitFairGameStart('join_queue'); }); }); $('#ecBuy').on('click', pay); } $overlay.append($modal).on('click', function (e) { if (e.target === this) $('#entryChoiceOverlay').remove(); }); $('body').append($overlay); render(); } }; if (typeof module !== 'undefined' && module.exports) { module.exports = SocketHandlers; }