mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 06:41:27 +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:
@@ -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>
|
||||
|
||||
@@ -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 »</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) {
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
@@ -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
|
||||
========================================================================= */
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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('"><script>x');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user