diff --git a/html/index.html b/html/index.html index 042442d..bb70398 100644 --- a/html/index.html +++ b/html/index.html @@ -80,6 +80,7 @@ + @@ -98,6 +99,8 @@
Block Height: Loading...
Status: Connecting...
+ + '); + $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; @@ -1097,6 +1169,37 @@ const SocketHandlers = { 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) { diff --git a/html/js/ui/publicStats.js b/html/js/ui/publicStats.js new file mode 100644 index 0000000..1fe0ead --- /dev/null +++ b/html/js/ui/publicStats.js @@ -0,0 +1,45 @@ +/** + * Public stats strip โ€” light "social proof" shown in the status area: games and escapes in + * the last 24h, and total paid out (only where payouts are enabled). Polls /api/stats, which + * is cached server-side (10s), so a 30s poll is cheap. Online-player count is intentionally + * NOT shown here โ€” it already updates live via the 'user_count' socket event. + */ +(function () { + var EL_ID = 'publicStats'; + + function fmtAmount(n) { + if (!isFinite(n)) return '0'; + // Trim to a tidy magnitude: whole numbers plain, otherwise up to 4 dp without trailing zeros. + if (n >= 1000) return Math.round(n).toLocaleString(); + var s = (Math.round(n * 10000) / 10000).toString(); + return s; + } + + function render(data) { + var el = document.getElementById(EL_ID); + if (!el || !data) return; + var parts = []; + parts.push('๐ŸŽฎ ' + (data.gamesToday || 0) + ' games (24h)'); + parts.push('๐Ÿ† ' + (data.escapesToday || 0) + ' escapes'); + if (data.payoutsEnabled && data.totalPaidOut > 0) { + parts.push('๐Ÿ’ฐ ' + fmtAmount(data.totalPaidOut) + ' ' + (data.currencyLabel || '') + ' paid out'); + } + el.innerHTML = parts.join('  ยท  '); + el.style.display = 'block'; + } + + function poll() { + fetch('/api/stats') + .then(function (r) { return r.json(); }) + .then(render) + .catch(function () { /* ignore; transient */ }); + } + + function start() { + poll(); + setInterval(poll, 30000); + } + + if (document.readyState !== 'loading') start(); + else document.addEventListener('DOMContentLoaded', start); +})(); diff --git a/html/og-card-wow.png b/html/og-card-wow.png new file mode 100644 index 0000000..8ca8ccc Binary files /dev/null and b/html/og-card-wow.png differ diff --git a/html/og-card-xmr.png b/html/og-card-xmr.png new file mode 100644 index 0000000..424426c Binary files /dev/null and b/html/og-card-xmr.png differ diff --git a/html/styles/stylesheet.css b/html/styles/stylesheet.css index 956b90d..deba7a5 100644 --- a/html/styles/stylesheet.css +++ b/html/styles/stylesheet.css @@ -314,6 +314,37 @@ li.debug { } +/* ========================================================================= + Win feed toasts (global "someone escaped" announcements) + ========================================================================= */ +#win-feed { + position: fixed; + top: 56px; + right: 12px; + z-index: 1800; + display: flex; + flex-direction: column; + gap: 6px; + pointer-events: none; + max-width: min(320px, 80vw); +} +.win-toast { + background: rgba(0, 30, 0, 0.92); + border: 1px solid #0f0; + border-left: 4px solid #4ade80; + border-radius: 6px; + color: #cffccf; + font-family: 'PixelOperator', monospace; + font-size: 13px; + padding: 8px 12px; + box-shadow: 0 0 14px rgba(0, 255, 0, 0.18); + opacity: 0; + transform: translateX(40px); + transition: opacity 0.35s ease, transform 0.35s ease; +} +.win-toast.show { opacity: 1; transform: translateX(0); } +.win-toast strong { color: #4ade80; } + /* ========================================================================= Mobile / touch support ========================================================================= */ diff --git a/src/game/gameManager.js b/src/game/gameManager.js index e458518..b0ecf3e 100644 --- a/src/game/gameManager.js +++ b/src/game/gameManager.js @@ -152,6 +152,19 @@ class GameManager { score: finalScore, treasure: game.player.hasTreasure || false }); + + // Global "win feed": announce actual escapes to everyone so the room feels + // alive (the leaderboard_update above also fires for lost-with-treasure runs; + // the feed should only celebrate real escapes). Payout amount is intentionally + // omitted โ€” it's processed asynchronously and not reliably known yet here. + if (status === 'won') { + this.io.emit('win_feed', { + name: displayName || 'Someone', + treasure: game.player.hasTreasure || false, + score: finalScore, + paid: !!(payoutInfo && payoutInfo.amount) + }); + } } catch (lbErr) { // Non-critical, don't block game over if (this.debugManager.CONSOLE_LOGGING) { diff --git a/src/index.js b/src/index.js index 148960e..2d85337 100644 --- a/src/index.js +++ b/src/index.js @@ -550,6 +550,52 @@ app.get('/api/leaderboard', asyncHandler(async (req, res) => { res.json({ leaderboard: result.rows, period, board }); })); +// Public "social proof" stats strip: players online, games + escapes in the last 24h, and +// total paid out. Cached in-memory for 10s so a public, unauthenticated endpoint can't hammer +// the DB (one query set per 10s regardless of traffic). +let _statsCache = { at: 0, data: null }; +app.get('/api/stats', asyncHandler(async (req, res) => { + const now = Date.now(); + const online = io.sockets.sockets.size || io.engine.clientsCount || 0; + + if (_statsCache.data && (now - _statsCache.at) < 10000) { + return res.json({ ..._statsCache.data, online }); + } + + const config = paymentConfigManager.getConfig(); + const decimals = Number(config.currency?.decimals ?? 12); + const divisor = Number.isFinite(decimals) ? Math.pow(10, decimals) : 1; + const currencyLabel = gameModeManager.currencyLabel || config.currency?.symbol || 'WOW'; + + let gamesToday = 0, escapesToday = 0, totalPaidOut = 0; + try { + const [games, payouts] = await Promise.all([ + db.query(`SELECT COUNT(*) AS games, + COUNT(*) FILTER (WHERE status = 'won') AS escapes + FROM games WHERE created_at > NOW() - INTERVAL '24 hours'`), + // Only count payouts that actually left the wallet. + db.query(`SELECT COALESCE(SUM(amount), 0) AS total FROM payouts WHERE status = 'completed'`) + ]); + gamesToday = parseInt(games.rows[0]?.games || 0, 10); + escapesToday = parseInt(games.rows[0]?.escapes || 0, 10); + // amount is atomic units (BIGINT); divide for display. Number() is safe at these magnitudes. + totalPaidOut = Number(payouts.rows[0]?.total || 0) / divisor; + } catch (e) { + // Best-effort; serve whatever we have rather than 500 on a public widget. + } + + const data = { + gamesToday, + escapesToday, + totalPaidOut, + currencyLabel, + // Paid out is only meaningful where payouts are enabled (stagenet / payout instances). + payoutsEnabled: !!(gameModeManager.directPayoutEnabled || gameModeManager.creditsPayoutEnabled) + }; + _statsCache = { at: now, data }; + res.json({ ...data, online }); +})); + app.get('/api/game-modes', (req, res) => { const config = paymentConfigManager.getConfig(); const decimals = Number(config.currency?.decimals ?? 12); @@ -641,7 +687,15 @@ app.get('/verify/:gameId', asyncHandler(async (req, res) => { } // Return the server-rendered HTML verification page (see src/views/verifyPage.js). - res.send(renderVerifyPage(gameId, gameRecord, gameModeManager.gameName)); + // Pass an absolute base URL (honours the trusted reverse proxy) + a brand-specific social + // card image so shared /verify links unfurl with a preview on Twitter/Discord/etc. + const baseUrl = `${req.protocol}://${req.get('host')}`; + const ogImage = (process.env.CRYPTO_TYPE === 'XMR') ? 'og-card-xmr.png' : 'og-card-wow.png'; + res.send(renderVerifyPage(gameId, gameRecord, { + gameName: gameModeManager.gameName, + baseUrl, + ogImage + })); })); // API endpoint for programmatic verification diff --git a/src/views/verifyPage.js b/src/views/verifyPage.js index 0f646aa..d9ebe39 100644 --- a/src/views/verifyPage.js +++ b/src/views/verifyPage.js @@ -14,8 +14,35 @@ function esc(value) { .replace(/'/g, '''); } -function renderVerifyPage(gameId, gameRecord, gameName) { - const name = gameName || 'Wownerogue'; +function renderVerifyPage(gameId, gameRecord, opts = {}) { + // Back-compat: opts may be a plain gameName string (older callers). + if (typeof opts === 'string') opts = { gameName: opts }; + const name = opts.gameName || 'Wownerogue'; + const baseUrl = (opts.baseUrl || '').replace(/\/$/, ''); + const ogImage = opts.ogImage ? `${baseUrl}/${opts.ogImage.replace(/^\//, '')}` : ''; + const pageUrl = `${baseUrl}/verify/${esc(gameId)}`; + + // Social card description reflects the actual result when we have a record. + let ogDescription = `Provably-fair roguelike โ€” escape the dungeon before the next block. Verify any game was fair.`; + if (gameRecord) { + const escaped = gameRecord.status === 'won'; + const bag = gameRecord.treasure_found ? ' with the treasure bag' : ''; + ogDescription = escaped + ? `Escaped the dungeon${bag} in ${gameRecord.moves_made || '?'} moves. Cryptographically verified provably fair.` + : `A provably-fair run that didn't make it out. Verify the seed generated this exact dungeon.`; + } + const ogTitle = `${name} โ€” Provably-Fair Roguelike`; + const metaTags = ` + + + + ${pageUrl ? `` : ''} + ${ogImage ? `` : ''} + + + + ${ogImage ? `` : ''}`; + const recordBlock = gameRecord ? `

Game Record

@@ -32,7 +59,9 @@ function renderVerifyPage(gameId, gameRecord, gameName) { - ${esc(name)} - Game Verification + + + ${esc(name)} - Game Verification${metaTags}