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
+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; }