mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-23 17:18:32 +02:00
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:
@@ -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
@@ -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
@@ -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 = `
|
||||
<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; }
|
||||
|
||||
Reference in New Issue
Block a user