launch features: win feed, public stats, share-a-win, live CTA

Four pre-launch additions to make the game feel alive and shareable:

- Live win feed: server broadcasts 'win_feed' on every escape; client shows a
  transient toast ('X escaped with the bag') so the room feels active.
- Public stats: GET /api/stats (10s-cached) returns games + escapes in the last
  24h and total paid out; a status-area strip polls it for social proof.
- Share-a-win: game-over screen gets 'Share to X' + 'Copy link' buttons pointing
  at the /verify page, which now renders OpenGraph/Twitter-card meta (brand OG
  image per instance) so shared links unfurl with a preview.
- Land-on-action: a dismissible 'N games live now — watch' CTA appears on connect
  when games are in progress, opening the existing spectator panel. Non-intrusive:
  hidden while playing/spectating and never hijacks the canvas.
This commit is contained in:
jwinterm
2026-06-01 09:17:48 -04:00
parent 00b013978f
commit 63e3006aca
10 changed files with 324 additions and 4 deletions
+3
View File
@@ -80,6 +80,7 @@
<script src="js/ui/transactionHistory.js"></script>
<script src="js/ui/leaderboard.js"></script>
<script src="js/ui/helpModal.js"></script>
<script src="js/ui/publicStats.js"></script>
<script src="js/network/socketHandlers.js"></script>
<script src="js/network/smirkAuth.js"></script>
<script src="js/input/inputHandler.js"></script>
@@ -98,6 +99,8 @@
<div class="status">
<div id="blockHeight">Block Height: <span id="heightValue">Loading...</span></div>
<div id="connectionStatus">Status: <span id="statusValue">Connecting...</span></div>
<div id="publicStats" style="display:none; margin-top:4px; font-size:11px; color:#9cf; font-family:monospace; line-height:1.4;"></div>
<div id="liveCta" style="display:none; margin-top:6px; cursor:pointer; background:rgba(80,0,0,0.55); border:1px solid #f33; border-radius:4px; padding:5px 10px; font-size:12px; font-family:monospace; color:#ff8a8a; font-weight:bold;" title="Watch a live game"></div>
<div id="creditsDisplay" style="display:none; margin-top:4px; font-size:13px; font-family:monospace; color:#0f0; background:#001a00; padding:4px 8px; border:1px solid #0a0; border-radius:4px; font-weight:bold;"></div>
<button id="startButton" style="background: #0a5c0a; color: #ff0; border: 2px solid #0f0; padding: 8px 16px; font-weight: bold; cursor: pointer; margin-top: 5px; display: none;">
🎮 <span class="lbl-full">START GAME</span><span class="lbl-short">START</span>
+103
View File
@@ -158,6 +158,35 @@ const SocketHandlers = {
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 ? ' <span style="color:#fbbf24;">(payout!)</span>' : '';
try {
var $c = $('#win-feed');
if (!$c.length) {
$c = $('<div id="win-feed"></div>').appendTo('body');
}
var $t = $('<div class="win-toast"></div>').html(
'🏆 <strong>' + escapeHtml(name) + '</strong> 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 */ }
},
onConnect: function() {
@@ -523,6 +552,12 @@ const SocketHandlers = {
));
}
// 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');
@@ -531,6 +566,43 @@ const SocketHandlers = {
}
},
// 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 = $('<li class="share-win" style="margin-top:6px; padding:8px; background:rgba(0,40,0,0.4); border-radius:4px; display:flex; gap:8px; align-items:center; flex-wrap:wrap;">');
$row.append($('<strong style="color:#4ade80;">').text('Brag about it:'));
var $x = $('<button type="button" class="share-x-btn" style="cursor:pointer; background:#000; color:#fff; border:1px solid #555; border-radius:4px; padding:4px 10px; font-size:12px;">𝕏 Share to X</button>');
$x.on('click', function () { window.open(intent, '_blank', 'noopener'); });
var $copy = $('<button type="button" class="share-copy-btn" style="cursor:pointer; background:#053655; color:#0af; border:1px solid #0af; border-radius:4px; padding:4px 10px; font-size:12px;">🔗 Copy link</button>');
$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 — <span style="text-decoration:underline;">watch &raquo;</span>';
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) {
+45
View File
@@ -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(' &nbsp;·&nbsp; ');
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);
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+31
View File
@@ -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
========================================================================= */
+13
View File
@@ -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) {
+55 -1
View File
@@ -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
+32 -3
View File
@@ -14,8 +14,35 @@ function esc(value) {
.replace(/'/g, '&#39;');
}
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 = `
<meta property="og:type" content="website">
<meta property="og:title" content="${esc(ogTitle)}">
<meta property="og:description" content="${esc(ogDescription)}">
${pageUrl ? `<meta property="og:url" content="${esc(pageUrl)}">` : ''}
${ogImage ? `<meta property="og:image" content="${esc(ogImage)}">` : ''}
<meta name="twitter:card" content="${ogImage ? 'summary_large_image' : 'summary'}">
<meta name="twitter:title" content="${esc(ogTitle)}">
<meta name="twitter:description" content="${esc(ogDescription)}">
${ogImage ? `<meta name="twitter:image" content="${esc(ogImage)}">` : ''}`;
const recordBlock = gameRecord ? `
<div class="box">
<h3>Game Record</h3>
@@ -32,7 +59,9 @@ function renderVerifyPage(gameId, gameRecord, gameName) {
<!DOCTYPE html>
<html>
<head>
<title>${esc(name)} - Game Verification</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(name)} - Game Verification</title>${metaTags}
<style>
body { background: #0a0a0a; color: #0f0; font-family: monospace; padding: 20px; max-width: 800px; margin: 0 auto; }
h1 { color: #0ff; }
+42
View File
@@ -0,0 +1,42 @@
/**
* Verify page OpenGraph/Twitter card meta (share-a-win unfurl).
* The /verify/:id page must render social-card meta so shared links preview nicely, while
* staying backward compatible with the old `renderVerifyPage(id, record, gameName)` signature.
*/
const { renderVerifyPage } = require('../src/views/verifyPage');
describe('renderVerifyPage social card meta', () => {
test('emits OG + Twitter tags with an absolute image when opts provided', () => {
const html = renderVerifyPage('seedABC', null, {
gameName: 'Monero Roguelike',
baseUrl: 'https://monerogue.app',
ogImage: 'og-card-xmr.png'
});
expect(html).toContain('property="og:title"');
expect(html).toContain('Monero Roguelike — Provably-Fair Roguelike');
expect(html).toContain('<meta property="og:image" content="https://monerogue.app/og-card-xmr.png">');
expect(html).toContain('content="summary_large_image"');
expect(html).toContain('<meta property="og:url" content="https://monerogue.app/verify/seedABC">');
});
test('uses the result in the description when a game record is present', () => {
const html = renderVerifyPage('seedXYZ', {
status: 'won', treasure_found: true, moves_made: 42, duration_seconds: 30, created_at: 'now'
}, { gameName: 'X', baseUrl: 'https://h', ogImage: 'og-card-wow.png' });
expect(html).toMatch(/Escaped the dungeon with the treasure bag in 42 moves/);
});
test('falls back to summary card (no image) and stays valid without opts', () => {
const html = renderVerifyPage('seed1', null, 'Wownerogue'); // legacy string arg
expect(html).toContain('Wownerogue - Game Verification');
expect(html).toContain('content="summary"');
expect(html).not.toContain('og:image');
});
test('escapes interpolated values in meta', () => {
const html = renderVerifyPage('s', null, { gameName: '"><script>x', baseUrl: 'https://h', ogImage: 'a.png' });
expect(html).not.toContain('<script>x');
expect(html).toContain('&quot;&gt;&lt;script&gt;x');
});
});