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

    Service Temporarily Unavailable

    ${message}

    `); $('body').append($modal); $modal.on('click', '#balanceCriticalOK', function() { $modal.fadeOut(200, function() { $modal.remove(); }); }); // Clicking the backdrop closes the modal. $modal.on('click', function(e) { if (e.target === $modal[0]) { $modal.fadeOut(200, function() { $modal.remove(); }); } }); $(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; // A payload without a paymentId cannot be deduped. SocketHandlers._expireOldClientPaymentMarkers(); if (SocketHandlers._lastDisplayedConfirmation === data.paymentId) return; SocketHandlers._lastDisplayedConfirmation = data.paymentId; SocketHandlers._confirmationTimestamps[data.paymentId] = Date.now(); SocketHandlers.hidePaymentQR(); // A credits purchase does not enqueue a game, so the player returns to the welcome screen. if (data.creditsAdded || data.newBalance !== undefined) { if (typeof Game !== 'undefined') { Game._pendingPaymentConfirmed(); } 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 { // A single-game payment puts the player in the queue, so show the waiting screen. 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'); } // AudioAlerts._patchSocketHandlers() plays the sound for this event. }, _mempoolShownForPayment: new Set(), _mempoolTimestamps: {}, // paymentId -> ts onPaymentDetected: function(data) { console.log('Payment detected (mempool):', data); if (!data || !data.paymentId) return; // A payload without a paymentId cannot be deduped. SocketHandlers._expireOldClientPaymentMarkers(); if (SocketHandlers._mempoolShownForPayment.has(data.paymentId)) return; SocketHandlers._mempoolShownForPayment.add(data.paymentId); SocketHandlers._mempoolTimestamps[data.paymentId] = Date.now(); 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'); // AudioAlerts._patchSocketHandlers() plays the sound for this event. }, // Bounds the client-side payment marker sets. Called opportunistically from the payment // handlers rather than on a timer. _expireOldClientPaymentMarkers: function() { const TTL = 6 * 60 * 60 * 1000; // 6 hours const now = Date.now(); 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]; } } if (SocketHandlers._lastDisplayedConfirmation) { const lastId = SocketHandlers._lastDisplayedConfirmation; const ts = SocketHandlers._confirmationTimestamps[lastId]; if (ts && now - ts > TTL) { delete SocketHandlers._confirmationTimestamps[lastId]; // Clearing the id frees the entry; the same id may display again after the TTL. SocketHandlers._lastDisplayedConfirmation = null; } } }, onBlockHeight: function(data) { if (typeof UI !== 'undefined' && UI.updateBlockHeight) { UI.updateBlockHeight(data.blockHeight); } // ScreenManager shows the height on the welcome screen. if (typeof ScreenManager !== 'undefined' && data.blockHeight) { ScreenManager._currentBlockHeight = data.blockHeight; // Redrawing is only safe when no game, spectator view, or waiting screen owns the canvas. if (typeof GameState !== 'undefined' && !GameState.isGameActive() && typeof Game !== 'undefined' && !Game._isSpectating && !ScreenManager.isShowingWaitingScreen()) { ScreenManager.drawWelcomeScreen(); } } // A banner still reading Connecting... has missed its status event; this block height // proves the connection is live. 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) { 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); }, // Dismissible call to action in the status area, shown only when live games exist. It stays out // of the canvas and hides while the visitor is in a game, queued, or spectating, so it never // interrupts play. _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; // Engaging once suppresses it for the session. 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; SocketHandlers._hideGamesPanel(); SocketHandlers._showSpectatorControls(data.playerId); if (typeof Game !== 'undefined' && data.initialState) { const state = data.initialState; try { // _isSpectating is set before startGame so the run renders read-only. 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; 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; } SocketHandlers._hideSpectatorControls(); 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(); if (typeof ScreenManager !== 'undefined' && ScreenManager.drawWelcomeScreen) { ScreenManager.drawWelcomeScreen(); } SocketHandlers._showGamesPanel(); SocketHandlers._setBannerStatus('Ready', '#0f0'); }, _updateGamesListPanel: function(data) { let $panel = $('#gamesListPanel'); if (!$panel.length) { $panel = $(`
    👁️ Live Games
    `); $('body').append($panel); $panel.on('click', '#gamesListClose', function() { $panel.hide(); }); // Delegated: game rows are re-rendered on every active_games update. $panel.on('click', '.spectate-btn', function() { const gameId = $(this).data('gameid'); if (gameId && window.socket) { socket.emit('spectate_game', { gameId: gameId }); } }); } 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
    `); }); } 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})
    `); 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); }); 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, /** * Whether early entry is allowed for the current mode and server 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; }, /** * Sets early entry button visibility and label from the current state. */ _updateEarlyEntryButton: function() { let $btn = $('#earlyEntryButton'); if (!$btn.length) { $btn = $(` `); if (!$('#earlyEntryStyles').length) { $('head').append(` `); } const $animBtn = $('#animationToggleButton'); if ($animBtn.length) { $animBtn.after($btn); } else { $('#header').append($btn); } $btn.on('click', function() { SocketHandlers.requestEarlyEntry(); }); } if (this._isQueued && this.isEarlyEntryAllowed()) { // Paid modes name the credit cost in the label. 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(); } }, /** * Requests early entry from the server after confirming the credit spend. */ requestEarlyEntry: function() { if (!window.socket) return; 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; // Disabling the button prevents a second entry request while the first is in flight. var $btn = $('#earlyEntryButton'); $btn.prop('disabled', true).text('⏳ Entering...'); if (!SocketHandlers.emitFairGameStart('early_entry')) { $btn.prop('disabled', false).text('⚡ ENTER NOW (RISKY!) ⚡'); } }, onEarlyEntrySuccess: function(data) { console.log('Early entry success:', data); SocketHandlers._isQueued = false; SocketHandlers._updateEarlyEntryButton(); // game_start follows and draws the run; this line is acknowledgement only. $('#messages').append($('
  • ').text('⚡ Early entry! Race to escape before the next block!')); UI.scrollChat(); }, onEarlyEntryError: function(data) { console.error('Early entry error:', data); 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 // ===================== // A single dialog covering both timing and entry type, so the player answers one modal rather // than a timing modal followed by a payment modal. Each card emits the final intent: Free sends // {free:true} so the server skips its own options modal, Ranked spends a credit or opens the // 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 creditEntry = cost + (cost === 1 ? ' credit' : ' credits'); var buyRankedEntry = SocketHandlers._directModeEnabled ? 'Buy a single entry or credits with ' + currency : 'Buy credits with ' + currency; var timing = 'wait'; // Waiting for the next block is the non-risky option. 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 entryCard(id, k, t, s, accent) { return '
    ' + '' + k + '' + '
    ' + t + '
    ' + '
    ' + s + '
    ' + 'ENTER ▸
    '; } function begin(msg, emit) { if (emit() === false) return; $('#entryChoiceOverlay').remove(); // Transient entry line, cleared in onGameStart once the game is live so it cannot read // as "still starting" beside a running game. The queued variant stays visible for the // whole 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 entry
    ' + (freeAvailable ? entryCard('ecFree', '🆓', 'Free Play', 'No cost · Pleb leaderboard', '#123a17') : '') + entryCard('ecRank', '💰', 'Ranked', (hasCredits ? creditEntry : buyRankedEntry) + ' · 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; }