openvibers/openvibe.games Commits

Play's player pages are Games pages now: achievements, leaderboards and the API guide (#320)

OP OpenVibers committed 5 days ago · 502217f98a · 33 files, +1821 −587

Changes

ddiff --git a/README.md b/README.md
iindex 0b436d2..e6c591c 100644
---- a/README.md
++++ b/README.md
@@@ -13,6 +13,10 @@ Players build their own **creator worlds** in the map editor and publish them at
(sandboxed: characters are not saved there); see [docs/games.md](docs/games.md#creator-worlds).
It also hosts the casual line (plan T22): small phone-first browser games built from one kit, starting with
**Vibe Heroes**, an idle hero collector at `/play/vibe-heroes`. See [docs/casual.md](docs/casual.md).
++The player pages of OpenVibe.Play (the game services behind Games) live here too: `/achievements`, `/leaderboards`
++and the API guide for game makers at `/developers`. They read Play's public API server-side; the service and its API
++stay at play.openvibe.network, whose old pages 301 here.
++
A persistent multiplayer physics sandbox for the browser: Source-style movement,
physgun-driven building from crafted physical objects, gathering, crafting, and
extraction risk — running against an authoritative dedicated server.
ddiff --git a/apps/client/achievements.html b/apps/client/achievements.html
nnew file mode 100644
iindex 0000000..80ad1eb
---- /dev/null
++++ b/apps/client/achievements.html
@@@ -0,0 +1,62 @@
++<!doctype html>
++<html lang="en">
++ <head>
++ <meta charset="UTF-8" />
++ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
++ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
++ <title>Achievements · OpenVibe.Games</title>
++ <meta name="description" content="Every achievement in every OpenVibe game. Sign in to see yours, or browse what Scraplandia, Salvage Rush and Vibe Heroes have to earn." />
++ <meta name="robots" content="index, follow" />
++ <link rel="canonical" href="https://openvibe.games/achievements" />
++ <meta property="og:type" content="website" />
++ <meta property="og:site_name" content="OpenVibe.Games" />
++ <meta property="og:title" content="Achievements · OpenVibe.Games" />
++ <meta
++ property="og:description"
++ content="Every achievement in every OpenVibe game: your unlocks when you are signed in, and what every game has to earn when you are not."
++ />
++ <meta property="og:url" content="https://openvibe.games/achievements" />
++ <meta property="og:image" content="https://openvibe.games/shared/social/games.jpg?v=a77ab4204f47" />
++ <meta property="og:image:width" content="1200" />
++ <meta property="og:image:height" content="630" />
++ <meta name="twitter:card" content="summary_large_image" />
++ <meta name="twitter:image" content="https://openvibe.games/shared/social/games.jpg?v=a77ab4204f47" />
++ <link rel="stylesheet" href="/src/pages/play-pages.css" />
++ <!-- The OpenVibe Frame: the user's theme before first paint, then navbar + footer. -->
++ <script src="/shared/theme-loader.js" defer></script>
++ <script src="/shared/navbar.js" defer></script>
++ <script src="/shared/footer.js" defer></script>
++ <script>
++ window.__ovgFrame = { page: 'portal' }
++ </script>
++ <script src="/frame.js" defer></script>
++ <script type="module" src="/src/pages/achievements.ts"></script>
++ </head>
++ <body>
++ <div id="navbar-mount"></div>
++ <main class="shell">
++ <nav class="crumbs" aria-label="Breadcrumb">
++ <a href="/">OpenVibe.Games</a><span aria-hidden="true">/</span><span>Achievements</span>
++ </nav>
++ <p class="eyebrow">ACHIEVEMENTS</p>
++ <h1 id="page-title">Achievements</h1>
++ <p class="lede" id="page-lede">
++ Every achievement in every OpenVibe game, kept on one shared record. Sign in with your
++ OpenVibe account and this page shows the ones you have unlocked.
++ </p>
++ <div class="actions" id="signin-actions" hidden>
++ <a class="btn primary" href="/auth/login?next=%2Fachievements">Sign in to see yours</a>
++ <a class="btn" href="/leaderboards">Leaderboards</a>
++ </div>
++ <ul class="summary" id="summary" hidden></ul>
++ <p class="status" id="status" role="status">Loading achievements…</p>
++ <div class="games" id="games"></div>
++ <section class="keep" id="keep" hidden aria-labelledby="keep-title">
++ <h2 id="keep-title" style="margin-top: 0">Keep playing</h2>
++ <p class="muted">Games you have not started yet, or where there is still more to earn.</p>
++ <ul id="keep-list"></ul>
++ </section>
++ </main>
++ <footer id="ov-footer" class="ovf"></footer>
++ </body>
++</html>
ddiff --git a/apps/client/developers.html b/apps/client/developers.html
nnew file mode 100644
iindex 0000000..69f3fa4
---- /dev/null
++++ b/apps/client/developers.html
@@@ -0,0 +1,197 @@
++<!doctype html>
++<html lang="en">
++ <head>
++ <meta charset="UTF-8" />
++ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
++ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
++ <title>Game developers: the Play API · OpenVibe.Games</title>
++ <meta name="description" content="Add achievements, leaderboards and cloud saves to your OpenVibe game: register a title with the Play API, post what players did, and read the boards anywhere." />
++ <meta name="robots" content="index, follow" />
++ <link rel="canonical" href="https://openvibe.games/developers" />
++ <meta property="og:type" content="website" />
++ <meta property="og:site_name" content="OpenVibe.Games" />
++ <meta property="og:title" content="The Play API · OpenVibe.Games" />
++ <meta
++ property="og:description"
++ content="Achievements, leaderboards and cloud saves for your game, on the OpenVibe network: one API, one player account, nothing to host."
++ />
++ <meta property="og:url" content="https://openvibe.games/developers" />
++ <meta property="og:image" content="https://openvibe.games/shared/social/games.jpg?v=a77ab4204f47" />
++ <meta property="og:image:width" content="1200" />
++ <meta property="og:image:height" content="630" />
++ <meta name="twitter:card" content="summary_large_image" />
++ <meta name="twitter:image" content="https://openvibe.games/shared/social/games.jpg?v=a77ab4204f47" />
++ <link rel="stylesheet" href="/src/pages/play-pages.css" />
++ <!-- The OpenVibe Frame: the user's theme before first paint, then navbar + footer. -->
++ <script src="/shared/theme-loader.js" defer></script>
++ <script src="/shared/navbar.js" defer></script>
++ <script src="/shared/footer.js" defer></script>
++ <script>
++ window.__ovgFrame = { page: 'portal' }
++ </script>
++ <script src="/frame.js" defer></script>
++ </head>
++ <body>
++ <div id="navbar-mount"></div>
++ <main class="shell">
++ <nav class="crumbs" aria-label="Breadcrumb">
++ <a href="/">OpenVibe.Games</a><span aria-hidden="true">/</span><span>Developers</span>
++ </nav>
++ <p class="eyebrow">GAME DEVELOPERS</p>
++ <h1>The Play API</h1>
++ <p class="lede">
++ Achievements, leaderboards and cloud saves for your game, on the OpenVibe network. One API,
++ one player account, nothing to host yourself. This is the same API the boards and
++ achievements on this site are drawn from.
++ </p>
++ <div class="actions">
++ <a class="btn primary" href="https://openvibe.services/projects">Make a project</a>
++ <a class="btn" href="https://play.openvibe.network/api/v1/titles"
++ >Browse the public titles JSON</a
++ >
++ </div>
++
++ <div class="doc">
++ <section class="card" aria-labelledby="base-h">
++ <h2 id="base-h">Base address and errors</h2>
++ <p>
++ Every call is under <code>https://play.openvibe.network/api/v1</code>. The API stays on
++ that host; this page is the documentation, moved here with the rest of the player pages.
++ </p>
++ <p class="muted">
++ Errors are RFC 9457 <code>problem+json</code> with a stable <code>code</code>.
++ </p>
++ </section>
++
++ <section class="card" aria-labelledby="reading-h">
++ <h2 id="reading-h">Reading: anyone, any site</h2>
++ <ul>
++ <li><code>GET /titles</code>: every game, with its achievements and boards.</li>
++ <li><code>GET /titles/:title</code>: one game.</li>
++ <li>
++ <code>GET /titles/:title/boards/:board?period=week|all&amp;page=1</code>: 25 entries a
++ page, <code>rank</code>, <code>name</code>, <code>score</code>, never a player id.
++ </li>
++ <li>
++ <code>GET /people/:subject/achievements</code>: a person's unlocks in every game, what
++ their OpenVibe profile shows.
++ </li>
++ </ul>
++ </section>
++
++ <section class="card" aria-labelledby="writing-h">
++ <h2 id="writing-h">Writing: your game's own token</h2>
++ <p>
++ Your server uses an OpenVibe Network service or app token for audience
++ <code>openvibe.play</code>. Only the principal that registered a title writes to it.
++ </p>
++ <ul>
++ <li>
++ <code>PUT /titles/:title</code> (<code>play.title.manage</code>): the name, url,
++ summary, achievements and boards. A definition left out is retired, never deleted.
++ </li>
++ <li>
++ <code>POST /titles/:title/scores</code> (<code>play.result.write</code>):
++ <code>{ board, ref, entries: [{ player, name, score }] }</code>. Posting the same
++ <code>ref</code> again changes nothing.
++ </li>
++ <li>
++ <code>POST /titles/:title/unlocks</code> (<code>play.result.write</code>):
++ <code>{ player, achievements: [id] }</code>, and it answers the new ones.
++ </li>
++ <li>
++ <code>POST /titles/:title/players/:guest/adopt</code>: a guest signed in, so their
++ unlocks and scores become the person's.
++ </li>
++ <li>
++ <code>GET /titles/:title/players/:player/achievements</code>
++ (<code>play.player.read</code>): what one player unlocked in your game.
++ </li>
++ </ul>
++ </section>
++
++ <section class="card" aria-labelledby="saves-h">
++ <h2 id="saves-h">Cloud saves</h2>
++ <p>
++ Up to 8 slots per player in your game, each any JSON value of at most 64 KB. Every write
++ names the version it read; if another device saved in between, the write is refused with
++ <code>409 play.save_conflict</code> so the game can merge instead of losing progress.
++ </p>
++ <ul>
++ <li>
++ <code>PUT /titles/:title/players/:player/saves/:slot</code>
++ (<code>play.result.write</code>): <code>{ data, version }</code>.
++ </li>
++ <li>
++ <code>GET /titles/:title/players/:player/saves/:slot</code>
++ (<code>play.player.read</code>): the slot and its version.
++ </li>
++ <li><code>GET /me/saves</code>: a signed-in person's slots in every game.</li>
++ </ul>
++ </section>
++
++ <section class="card" aria-labelledby="sandbox-h">
++ <h2 id="sandbox-h">Start in the sandbox</h2>
++ <ol>
++ <li>
++ Sign in on <a href="https://openvibe.services/projects">OpenVibe Services</a> and make
++ a project. New projects are sandbox-only.
++ </li>
++ <li>
++ Add a sandbox app and ask for <code>play.title.manage</code>,
++ <code>play.result.write</code> and <code>play.player.read</code>. A sandbox app gets
++ them without waiting for anyone.
++ </li>
++ <li>
++ Get a token for audience <code>openvibe.play</code> and
++ <code>PUT /titles/sandbox-your-game</code>. Sandbox titles are never listed, counted
++ or indexed; their players are guests only.
++ </li>
++ </ol>
++ <p class="muted">
++ When the game is ready, staff turn production on for your project and its production app
++ registers the real name.
++ </p>
++ </section>
++
++ <section class="card" aria-labelledby="sdk-h">
++ <h2 id="sdk-h">From your game server</h2>
++ <p>
++ The <code>openvibe-sdk/play</code> client wraps the same calls; plain HTTPS works too.
++ </p>
++ <pre><code>const { createClient } = require('openvibe-sdk/core');
++const { createPlayClient } = require('openvibe-sdk/play');
++
++const play = createPlayClient(createClient({ tokenProvider: tokens }));
++await play.titles.define('gem-run', {
++ name: 'Gem Run',
++ achievements: [{ id: 'first-gem', name: 'First Gem' }],
++ boards: [{ id: 'high-score', name: 'High score', kind: 'best' }],
++});
++await play.scores.post('gem-run', {
++ board: 'high-score', ref: 'round-42',
++ entries: [{ player: 'usr_…', name: 'kim', score: 1200 }],
++});
++await play.achievements.unlock('gem-run', {
++ player: 'usr_…', achievements: ['first-gem'],
++});
++await play.saves.put('gem-run', 'usr_…', 'main', { data: { level: 3 }, version: 0 });</code></pre>
++ </section>
++
++ <section class="card" aria-labelledby="where-h">
++ <h2 id="where-h">Where players see it</h2>
++ <p>
++ Every unlock and board score is on <a href="/achievements">Achievements</a> and
++ <a href="/leaderboards">Leaderboards</a> here, and on the player's OpenVibe profile. A
++ guest plays at once; when they sign in, their progress moves to their account.
++ </p>
++ <p class="muted">
++ Read more about the service in the
++ <a href="https://github.com/OpenVibers/OpenVibe.Play#readme">OpenVibe.Play README</a>.
++ </p>
++ </section>
++ </div>
++ </main>
++ <footer id="ov-footer" class="ovf"></footer>
++ </body>
++</html>
ddiff --git a/apps/client/index.html b/apps/client/index.html
iindex 9b89b9e..8d46eee 100644
---- a/apps/client/index.html
++++ b/apps/client/index.html
@@@ -1030,7 +1030,7 @@
<a class="play-cta" href="https://play.openvibe.games" data-dev-href="/play"
>▶ Play now, no download</a
>
-- <a class="card-link" href="/leaderboard.html?game=scraplandia">Haul leaderboard →</a>
++ <a class="card-link" href="/leaderboards?game=scraplandia">Haul leaderboard →</a>
</div>
</div>
</div>
@@@ -1131,7 +1131,7 @@
</div>
<div class="cta-row">
<a class="play-cta" href="/play?game=salvage-rush">▶ Play a round</a>
-- <a class="card-link" href="/leaderboard.html?game=salvage-rush">Leaderboard →</a>
++ <a class="card-link" href="/leaderboards?game=salvage-rush">Leaderboard →</a>
</div>
</div>
</div>
ddiff --git a/apps/client/leaderboard.html b/apps/client/leaderboard.html
ddeleted file mode 100644
iindex 4c6980d..0000000
---- a/apps/client/leaderboard.html
++++ /dev/null
@@@ -1,337 +0,0 @@
--<!doctype html>
--<html lang="en">
-- <head>
-- <meta charset="UTF-8" />
-- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-- <title>Leaderboard · OpenVibe.Games</title>
-- <meta name="description" content="OpenVibe.Games leaderboards: Salvage Rush best rounds and Scraplandia haul points, all time and this week. Scores come only from what the server played out." />
-- <link rel="canonical" href="https://openvibe.games/leaderboard.html" />
-- <meta property="og:type" content="website" />
-- <meta property="og:site_name" content="OpenVibe.Games" />
-- <meta property="og:title" content="Leaderboard · OpenVibe.Games" />
-- <meta property="og:description" content="OpenVibe.Games leaderboards: Salvage Rush best rounds and Scraplandia haul points, all time and this week. Scores come only from what the server played out." />
-- <meta property="og:url" content="https://openvibe.games/leaderboard.html" />
-- <meta property="og:image" content="https://openvibe.games/shared/social/games.jpg?v=a77ab4204f47" />
-- <meta property="og:image:width" content="1200" />
-- <meta property="og:image:height" content="630" />
-- <meta name="twitter:card" content="summary_large_image" />
-- <meta name="twitter:image" content="https://openvibe.games/shared/social/games.jpg?v=a77ab4204f47" />
-- <!-- The reader's theme, before the first paint: the page's own light rules follow it. -->
-- <script src="/shared/theme-loader.js"></script>
-- <style>
-- /* The shared tokens (theme-loader.js): the page wears the reader's theme, dark or light. The
-- values behind them are the page's own dark ones, so a reader without a theme sees the same. */
-- :root {
-- color-scheme: var(--color-scheme, dark);
-- --page-accent: var(--accent, #3b82f6);
-- --page-accent-light: var(--accent-light, #60a5fa);
-- --secondary: #22d3ee;
-- --bg: var(--bg-primary, #0a0f1c);
-- --bg-raised: var(--bg-secondary, #101828);
-- --bg-card: var(--bg-tertiary, rgba(19, 28, 46, 0.88));
-- --page-border: var(--border, #1f2d47);
-- --text: var(--text-primary, #e6edf7);
-- --text-dim: var(--text-secondary, #96a7c2);
-- --text-faint: var(--text-muted, #7386a3);
-- }
-- /* Light themes (theme-loader sets data-theme-mode): the cyan is text on tags and achievements, and #22d3ee on a light
-- page read at 1.7:1; this deeper cyan reads at 5:1 (axe, 2026-10-22). */
-- :root[data-theme-mode="light"] { --secondary: #0e7490; }
-- * {
-- box-sizing: border-box;
-- }
-- body {
-- margin: 0;
-- min-height: 100vh;
-- color: var(--text);
-- background:
-- radial-gradient(900px 420px at 20% -10%, rgba(59, 130, 246, 0.18), transparent 70%),
-- radial-gradient(700px 360px at 90% 0%, rgba(34, 211, 238, 0.1), transparent 70%),
-- var(--bg);
-- font:
-- 16px/1.5 system-ui,
-- -apple-system,
-- sans-serif;
-- }
-- a {
-- color: var(--page-accent-light);
-- }
-- main {
-- max-width: 760px;
-- margin: 0 auto;
-- padding: 20px 16px 64px;
-- }
-- /* One trail on one line, Games › this page: the navbar carries the site's links now (2026-11-01). */
-- .crumbs {
-- display: flex;
-- flex-wrap: wrap;
-- align-items: center;
-- gap: 6px;
-- font-size: 13px;
-- color: var(--text-faint);
-- }
-- .crumbs > * + *::before {
-- content: '›';
-- margin-right: 6px;
-- color: var(--text-faint);
-- }
-- /* The viewer's own row: the same accent the page already uses, no colour of its own. */
-- .you-line {
-- margin: 10px 0 0;
-- padding: 8px 12px;
-- border: 1px solid var(--page-border);
-- border-radius: 10px;
-- background: var(--bg-card);
-- color: var(--text);
-- font-size: 14px;
-- font-weight: 600;
-- }
-- tr.is-you {
-- background: var(--bg-raised);
-- }
-- .you {
-- margin-left: 8px;
-- padding: 1px 7px;
-- border: 1px solid var(--page-accent);
-- border-radius: 999px;
-- color: var(--page-accent-light);
-- font-size: 11px;
-- font-weight: 800;
-- letter-spacing: 0.08em;
-- text-transform: uppercase;
-- }
-- .eyebrow {
-- margin: 28px 0 4px;
-- font-size: 12px;
-- font-weight: 800;
-- letter-spacing: 0.14em;
-- color: var(--secondary);
-- }
-- h1 {
-- margin: 0 0 6px;
-- font-size: clamp(28px, 5vw, 40px);
-- font-weight: 900;
-- letter-spacing: 0.01em;
-- }
-- .lede {
-- margin: 0 0 18px;
-- color: var(--text-dim);
-- }
-- .board {
-- border: 1px solid var(--page-border);
-- border-radius: 18px;
-- background: var(--bg-card);
-- overflow: hidden;
-- }
-- .tabs {
-- display: flex;
-- gap: 6px;
-- padding: 12px;
-- border-bottom: 1px solid var(--page-border);
-- }
-- .tabs button {
-- flex: 0 0 auto;
-- padding: 8px 16px;
-- border: 1px solid var(--page-border);
-- border-radius: 999px;
-- background: transparent;
-- color: var(--text-dim);
-- font: inherit;
-- font-weight: 650;
-- cursor: pointer;
-- }
-- .tabs button[aria-pressed='true'] {
-- border-color: var(--page-accent);
-- background: rgba(59, 130, 246, 0.18);
-- color: var(--text);
-- }
-- .tabs button:focus-visible,
-- .pager button:focus-visible {
-- outline: 2px solid var(--page-accent-light);
-- outline-offset: 2px;
-- }
-- table {
-- width: 100%;
-- border-collapse: collapse;
-- font-variant-numeric: tabular-nums;
-- }
-- th,
-- td {
-- padding: 12px 16px;
-- text-align: left;
-- border-bottom: 1px solid var(--page-border);
-- }
-- th {
-- font-size: 12px;
-- letter-spacing: 0.08em;
-- text-transform: uppercase;
-- color: var(--text-faint);
-- font-weight: 700;
-- }
-- th:last-child,
-- td:last-child {
-- text-align: right;
-- }
-- td:last-child {
-- font-weight: 800;
-- }
-- tbody tr:last-child td {
-- border-bottom: 0;
-- }
-- .rank {
-- display: inline-grid;
-- place-items: center;
-- min-width: 30px;
-- height: 30px;
-- padding: 0 6px;
-- border-radius: 999px;
-- border: 1px solid var(--page-border);
-- font-weight: 800;
-- font-size: 14px;
-- }
-- tr.top-1 .rank {
-- background: linear-gradient(135deg, #fde68a, #f59e0b);
-- color: #1a1405;
-- border: 0;
-- }
-- tr.top-2 .rank {
-- background: linear-gradient(135deg, #e5e7eb, #9ca3af);
-- color: #111827;
-- border: 0;
-- }
-- tr.top-3 .rank {
-- background: linear-gradient(135deg, #fdba74, #c2410c);
-- color: #1c0d04;
-- border: 0;
-- }
-- .name {
-- overflow-wrap: anywhere;
-- }
-- #status {
-- margin: 0;
-- padding: 28px 16px;
-- text-align: center;
-- color: var(--text-dim);
-- }
-- #status:empty {
-- display: none;
-- }
-- .pager {
-- display: flex;
-- align-items: center;
-- justify-content: space-between;
-- gap: 12px;
-- padding: 12px 16px;
-- border-top: 1px solid var(--page-border);
-- color: var(--text-faint);
-- font-size: 14px;
-- }
-- .pager button {
-- padding: 7px 14px;
-- border: 1px solid var(--page-border);
-- border-radius: 10px;
-- background: var(--bg-raised);
-- color: var(--text);
-- font: inherit;
-- cursor: pointer;
-- }
-- .pager button:disabled {
-- opacity: 0.45;
-- cursor: default;
-- }
-- .play {
-- display: inline-block;
-- margin-top: 22px;
-- padding: 13px 28px;
-- border-radius: 12px;
-- background: linear-gradient(120deg, var(--page-accent), #2563eb);
-- color: #fff;
-- font-weight: 800;
-- letter-spacing: 0.03em;
-- text-decoration: none;
-- }
-- .cta-row {
-- display: flex;
-- flex-wrap: wrap;
-- align-items: center;
-- gap: 10px;
-- margin-top: 22px;
-- }
-- .cta-row .play {
-- margin-top: 0;
-- }
-- .note {
-- margin-top: 14px;
-- font-size: 14px;
-- color: var(--text-faint);
-- }
-- </style>
-- <script type="module" src="/src/pages/leaderboard.ts"></script>
-- <!-- The OpenVibe Frame (navbar + footer), as on every other page of the site: this page had only its own row of
-- links (phone sweep, 2026-11-01). -->
-- <link rel="stylesheet" href="/shared/kit.css" />
-- <script src="/shared/navbar.js" defer></script>
-- <script src="/shared/footer.js" defer></script>
-- <script>
-- window.__ovgFrame = { page: 'portal', title: 'Leaderboard' }
-- </script>
-- <script src="/frame.js" defer></script>
-- <style>
-- #navbar-mount {
-- min-height: 56px;
-- }
-- </style>
-- </head>
-- <body>
-- <div id="navbar-mount"></div>
-- <main>
-- <nav class="crumbs" aria-label="Breadcrumb">
-- <a href="/">OpenVibe.Games</a><span aria-current="page">Leaderboard</span>
-- </nav>
-- <p class="eyebrow">LEADERBOARD</p>
-- <h1 id="title">Salvage Rush</h1>
-- <p class="lede" id="lede">
-- Each player's best round score. Every point comes from a pickup the server counted.
-- </p>
-- <p class="you-line" id="you" hidden></p>
-- <section class="board" aria-labelledby="board-title">
-- <h2 id="board-title" hidden>Best scores</h2>
-- <div class="tabs" role="group" aria-label="Game">
-- <button type="button" data-game="salvage-rush" aria-pressed="true">Salvage Rush</button>
-- <button type="button" data-game="scraplandia" aria-pressed="false">
-- Scraplandia haul
-- </button>
-- </div>
-- <div class="tabs" role="group" aria-label="Period">
-- <button type="button" data-period="all" aria-pressed="true">All time</button>
-- <button type="button" data-period="week" aria-pressed="false">This week</button>
-- </div>
-- <table>
-- <thead>
-- <tr>
-- <th scope="col">Rank</th>
-- <th scope="col">Player</th>
-- <th scope="col" id="score-header">Best score</th>
-- </tr>
-- </thead>
-- <tbody id="entries"></tbody>
-- </table>
-- <p id="status" role="status"></p>
-- <div class="pager">
-- <button id="previous" type="button">Previous</button><span id="page"></span
-- ><button id="next" type="button">Next</button>
-- </div>
-- </section>
-- <div class="cta-row">
-- <button class="ovk-btn ovk-btn--quiet" type="button" id="share" data-ov-share-sheet data-title="Salvage Rush leaderboard" data-url="https://openvibe.games/leaderboard.html?game=salvage-rush"><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 20V9M7 14l5-5 5 5M5 4h14"/></svg><span>Share</span></button>
-- <a class="play" id="play-link" href="/play?game=salvage-rush">▶ Play a round</a>
-- </div>
-- <p class="note">
-- "This week" starts on Monday, 00:00 UTC. These boards are kept by
-- <a href="https://play.openvibe.network/">OpenVibe.Play</a>, with the achievements you have
-- unlocked with your OpenVibe account; a finished round or haul shows here within a minute.
-- </p>
-- </main>
-- <footer id="ov-footer" class="ovf"></footer>
-- </body>
--</html>
ddiff --git a/apps/client/leaderboards.html b/apps/client/leaderboards.html
nnew file mode 100644
iindex 0000000..8dbda11
---- /dev/null
++++ b/apps/client/leaderboards.html
@@@ -0,0 +1,84 @@
++<!doctype html>
++<html lang="en">
++ <head>
++ <meta charset="UTF-8" />
++ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
++ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
++ <title>Leaderboards · OpenVibe.Games</title>
++ <meta name="description" content="OpenVibe.Games leaderboards: Scraplandia haul points, Salvage Rush best rounds and Vibe Heroes' furthest stages, this week and all time." />
++ <meta name="robots" content="index, follow" />
++ <link rel="canonical" href="https://openvibe.games/leaderboards" />
++ <meta property="og:type" content="website" />
++ <meta property="og:site_name" content="OpenVibe.Games" />
++ <meta property="og:title" content="Leaderboards · OpenVibe.Games" />
++ <meta
++ property="og:description"
++ content="Every OpenVibe game's leaderboard, this week and all time. Scores come only from what the game server played out."
++ />
++ <meta property="og:url" content="https://openvibe.games/leaderboards" />
++ <meta property="og:image" content="https://openvibe.games/shared/social/games.jpg?v=a77ab4204f47" />
++ <meta property="og:image:width" content="1200" />
++ <meta property="og:image:height" content="630" />
++ <meta name="twitter:card" content="summary_large_image" />
++ <meta name="twitter:image" content="https://openvibe.games/shared/social/games.jpg?v=a77ab4204f47" />
++ <link rel="stylesheet" href="/src/pages/play-pages.css" />
++ <!-- The OpenVibe Frame: the user's theme before first paint, then navbar + footer. -->
++ <script src="/shared/theme-loader.js" defer></script>
++ <script src="/shared/navbar.js" defer></script>
++ <script src="/shared/footer.js" defer></script>
++ <script>
++ window.__ovgFrame = { page: 'portal' }
++ </script>
++ <script src="/frame.js" defer></script>
++ <script type="module" src="/src/pages/leaderboards.ts"></script>
++ </head>
++ <body>
++ <div id="navbar-mount"></div>
++ <main class="shell">
++ <nav class="crumbs" aria-label="Breadcrumb">
++ <a href="/">OpenVibe.Games</a><span aria-hidden="true">/</span><span>Leaderboards</span>
++ </nav>
++ <p class="eyebrow">LEADERBOARDS</p>
++ <h1 id="title">Leaderboards</h1>
++ <p class="lede" id="lede">
++ Every game's board, this week and all time. "This week" starts on Monday, 00:00 UTC.
++ </p>
++ <div class="actions">
++ <a class="btn" href="/achievements">Achievements</a>
++ <a class="btn primary" id="play-link" href="/play?game=salvage-rush">Play a game</a>
++ </div>
++ <section class="board" aria-labelledby="board-title">
++ <h2 id="board-title" hidden>Board</h2>
++ <div class="tabs" id="game-tabs" role="group" aria-label="Game"></div>
++ <div class="tabs" id="board-tabs" role="group" aria-label="Board"></div>
++ <div class="tabs" id="period-tabs" role="group" aria-label="Period">
++ <button type="button" data-period="all" aria-pressed="true">All time</button>
++ <button type="button" data-period="week" aria-pressed="false">This week</button>
++ </div>
++ <div class="table-wrap">
++ <table>
++ <thead>
++ <tr>
++ <th scope="col">Rank</th>
++ <th scope="col">Player</th>
++ <th scope="col" id="score-header">Score</th>
++ </tr>
++ </thead>
++ <tbody id="entries"></tbody>
++ </table>
++ </div>
++ <p id="status" class="status" role="status"></p>
++ <div class="pager">
++ <button id="previous" type="button">Previous</button><span id="page"></span
++ ><button id="next" type="button">Next</button>
++ </div>
++ </section>
++ <p class="note">
++ The boards are kept by OpenVibe.Play, the game services behind every OpenVibe game, and
++ shown here as part of OpenVibe.Games. A finished round or haul reaches a board within a
++ minute. <a href="/developers">Game developers: the Play API →</a>
++ </p>
++ </main>
++ <footer id="ov-footer" class="ovf"></footer>
++ </body>
++</html>
ddiff --git a/apps/client/play.html b/apps/client/play.html
iindex d7c1d1c..65b60de 100644
---- a/apps/client/play.html
++++ b/apps/client/play.html
@@@ -97,7 +97,7 @@
</p>
<p>
<a href="https://openvibe.games/" style="color: #8ab4ff">All games</a> ·
-- <a href="https://openvibe.games/leaderboard.html" style="color: #8ab4ff">Leaderboards</a>
++ <a href="https://openvibe.games/leaderboards" style="color: #8ab4ff">Leaderboards</a>
</p>
</main>
</noscript>
ddiff --git a/apps/client/public/frame.js b/apps/client/public/frame.js
iindex 30e658b..02bbe5e 100644
---- a/apps/client/public/frame.js
++++ b/apps/client/public/frame.js
@@@ -29,7 +29,10 @@
: onPlayHost
? '/?game=salvage-rush'
: 'https://play.openvibe.games/?game=salvage-rush'
-- var BOARD = local || !onPlayHost ? '/leaderboard.html' : 'https://openvibe.games/leaderboard.html'
++ var BOARD = local || !onPlayHost ? '/leaderboards' : 'https://openvibe.games/leaderboards'
++ var ACHIEVEMENTS =
++ local || !onPlayHost ? '/achievements' : 'https://openvibe.games/achievements'
++ var DEVELOPERS = local || !onPlayHost ? '/developers' : 'https://openvibe.games/developers'
var HEROES =
local || !onPlayHost ? '/play/vibe-heroes' : 'https://openvibe.games/play/vibe-heroes'
@@@ -53,11 +56,12 @@
}
var here = location.pathname + location.search
-- // The two places people go every visit carry an icon; each game folds into the navbar's More (Shared 4.22): the
-- // portal lists them all anyway, and five same-looking links read as clutter on a phone.
++ // The three places people go every visit carry an icon; each game folds into the navbar's More (Shared 4.22): the
++ // portal lists them all anyway, and six same-looking links read as clutter on a phone.
var links = [
{ label: 'Games', href: PORTAL, icon: 'fa-gamepad' },
-- { label: 'Leaderboard', href: BOARD, icon: 'fa-trophy' },
++ { label: 'Achievements', href: ACHIEVEMENTS, icon: 'fa-trophy' },
++ { label: 'Leaderboards', href: BOARD, icon: 'fa-ranking-star' },
{ label: 'Scraplandia', href: PLAY, more: true },
{ label: 'Salvage Rush', href: RUSH, more: true },
{ label: 'Vibe Heroes', href: HEROES, more: true },
@@@ -71,7 +75,9 @@
{ name: 'Play Scraplandia', url: PLAY },
{ name: 'Play Salvage Rush', url: RUSH },
{ name: 'Play Vibe Heroes', url: HEROES },
-- { name: 'Leaderboard', url: BOARD },
++ { name: 'Achievements', url: ACHIEVEMENTS },
++ { name: 'Leaderboards', url: BOARD },
++ { name: 'Developers', url: DEVELOPERS },
{ name: 'Map editor', url: EDITOR },
],
},
ddiff --git a/apps/client/src/pages/achievements.ts b/apps/client/src/pages/achievements.ts
nnew file mode 100644
iindex 0000000..abdaf89
---- /dev/null
++++ b/apps/client/src/pages/achievements.ts
@@@ -0,0 +1,218 @@
++/**
++ * Achievements on OpenVibe.Games: the person's unlocks when signed in, every game's
++ * achievements when not. The data comes from Play through this site's own read routes
++ * (/api/v1/play/…), which fetch Play server-side; the page never reads Play's tables.
++ */
++export {}
++
++interface PlayAchievement {
++ id: string
++ name: string
++ description?: string
++ unlocked_by?: number
++ hidden?: boolean
++}
++
++interface PlayTitle {
++ id: string
++ name: string
++ url?: string
++ summary?: string
++ players?: number
++ achievements: PlayAchievement[]
++}
++
++interface MineTitle {
++ title: string
++ name: string
++ achievements: { id: string; name: string; description?: string; unlocked_at?: string }[]
++}
++
++interface Mine {
++ signedIn?: boolean
++ titles: MineTitle[]
++}
++
++const pageTitle = document.getElementById('page-title') as HTMLElement
++const pageLede = document.getElementById('page-lede') as HTMLElement
++const status = document.getElementById('status') as HTMLElement
++const games = document.getElementById('games') as HTMLElement
++const summary = document.getElementById('summary') as HTMLElement
++const signin = document.getElementById('signin-actions') as HTMLElement
++const keep = document.getElementById('keep') as HTMLElement
++const keepList = document.getElementById('keep-list') as HTMLElement
++
++const num = (n: number): string => Number(n).toLocaleString('en-US')
++const plural = (n: number, one: string, many: string): string => `${num(n)} ${n === 1 ? one : many}`
++
++async function read<T>(url: string): Promise<T | null> {
++ const response = await fetch(url, { headers: { accept: 'application/json' } })
++ if (response.status === 401) return null
++ if (!response.ok) throw new Error(`${url}: ${response.status}`)
++ return (await response.json()) as T
++}
++
++function el(tag: string, className?: string, text?: string): HTMLElement {
++ const node = document.createElement(tag)
++ if (className) node.className = className
++ if (text !== undefined) node.textContent = text
++ return node
++}
++
++function summaryCard(value: string, label: string): HTMLLIElement {
++ const li = el('li') as HTMLLIElement
++ li.append(el('b', undefined, value), el('span', undefined, label))
++ return li
++}
++
++/** A game's card: its name, how many players, and one tile per achievement. */
++function gameCard(
++ title: PlayTitle,
++ achievements: { id: string; name: string; description?: string; unlocked_at?: string }[],
++ mine: boolean,
++): HTMLElement {
++ const card = el('section', 'game')
++ card.id = `game-${title.id}`
++ const head = el('div', 'game-head')
++ const heading = el('h2', undefined, title.name)
++ heading.style.margin = '0'
++ head.append(heading)
++ head.append(
++ el(
++ 'p',
++ undefined,
++ mine
++ ? `${plural(achievements.length, 'achievement', 'achievements')} unlocked`
++ : `${plural(title.players ?? 0, 'player has', 'players have')} unlocked at least one · ${plural(
++ title.achievements.length,
++ 'achievement',
++ 'achievements',
++ )}`,
++ ),
++ )
++ card.append(head)
++ const list = el('ul', 'ach-list') as HTMLUListElement
++ for (const achievement of achievements) {
++ const li = el('li', mine ? 'ach earned' : 'ach') as HTMLLIElement
++ li.append(el('b', undefined, achievement.name))
++ if (achievement.description) li.append(el('small', undefined, achievement.description))
++ if (mine && achievement.unlocked_at) {
++ li.append(el('span', 'meta', `Unlocked ${achievement.unlocked_at.slice(0, 10)}`))
++ } else if (!mine) {
++ const count = Number((achievement as PlayAchievement).unlocked_by ?? 0)
++ li.append(
++ el(
++ 'span',
++ 'meta',
++ count > 0
++ ? `Earned by ${plural(count, 'player', 'players')}`
++ : 'Nobody has earned it yet',
++ ),
++ )
++ }
++ list.append(li)
++ }
++ card.append(list)
++ return card
++}
++
++function showSignedIn(mine: Mine, titles: PlayTitle[]): void {
++ const byId = new Map(titles.map((t) => [t.id, t]))
++ const unlocked = mine.titles.reduce((n, t) => n + t.achievements.length, 0)
++ pageTitle.textContent = 'Your achievements'
++ pageLede.textContent =
++ 'Every achievement you have unlocked in every OpenVibe game, and when you earned it. Sign in on another device and this page follows you.'
++ signin.hidden = true
++ status.textContent = ''
++ summary.hidden = false
++ summary.replaceChildren(
++ summaryCard(num(unlocked), unlocked === 1 ? 'achievement unlocked' : 'achievements unlocked'),
++ summaryCard(num(mine.titles.length), mine.titles.length === 1 ? 'game played' : 'games played'),
++ )
++ games.replaceChildren()
++ if (!mine.titles.length) {
++ const empty = el('p', 'empty')
++ empty.append(
++ document.createTextNode('Nothing yet. '),
++ Object.assign(document.createElement('a'), { href: '/play', textContent: 'Play a game' }),
++ document.createTextNode(' signed in, and what you unlock shows here.'),
++ )
++ games.append(empty)
++ }
++ for (const t of mine.titles) {
++ const title = byId.get(t.title) ?? {
++ id: t.title,
++ name: t.name,
++ achievements: [],
++ }
++ games.append(gameCard(title, t.achievements, true))
++ }
++
++ const keepTitles = titles
++ .map((t) => ({
++ t,
++ got: new Set(mine.titles.find((m) => m.title === t.id)?.achievements.map((a) => a.id) ?? []),
++ }))
++ .filter(({ t, got }) => t.achievements.some((a) => !got.has(a.id)))
++ if (keepTitles.length) {
++ keep.hidden = false
++ keepList.replaceChildren(
++ ...keepTitles.map(({ t, got }) => {
++ const li = document.createElement('li')
++ const link = document.createElement('a')
++ link.href = t.url || '/play'
++ link.textContent = t.name
++ li.append(
++ link,
++ document.createTextNode(
++ `: ${t.achievements.filter((a) => !got.has(a.id)).length} still to earn`,
++ ),
++ )
++ return li
++ }),
++ )
++ } else {
++ keep.hidden = true
++ }
++}
++
++function showSignedOut(titles: PlayTitle[]): void {
++ const totalAchievements = titles.reduce((n, t) => n + t.achievements.length, 0)
++ const totalUnlocks = titles.reduce(
++ (n, t) => n + t.achievements.reduce((m, a) => m + (Number(a.unlocked_by) || 0), 0),
++ 0,
++ )
++ pageTitle.textContent = 'Achievements in every game'
++ pageLede.textContent =
++ 'Every achievement in every OpenVibe game, and how many players have earned each. Sign in with your OpenVibe account to see the ones you have unlocked.'
++ signin.hidden = false
++ status.textContent = ''
++ summary.hidden = false
++ summary.replaceChildren(
++ summaryCard(num(titles.length), titles.length === 1 ? 'game' : 'games'),
++ summaryCard(num(totalAchievements), totalAchievements === 1 ? 'achievement' : 'achievements'),
++ summaryCard(num(totalUnlocks), 'unlocks earned'),
++ )
++ games.replaceChildren()
++ if (!titles.length) {
++ games.append(el('p', 'empty', 'No game has joined OpenVibe.Play yet.'))
++ keep.hidden = true
++ return
++ }
++ for (const t of titles) games.append(gameCard(t, t.achievements, false))
++ keep.hidden = true
++}
++
++async function load(): Promise<void> {
++ try {
++ const mine = await read<Mine>('/api/v1/play/achievements')
++ const titles = (await read<{ titles: PlayTitle[] }>('/api/v1/play/titles'))?.titles ?? []
++ if (mine && mine.signedIn !== false && Array.isArray(mine.titles)) showSignedIn(mine, titles)
++ else showSignedOut(titles)
++ } catch {
++ status.className = 'status error'
++ status.textContent = 'Achievements are unavailable right now. Try again in a minute.'
++ }
++}
++
++void load()
ddiff --git a/apps/client/src/pages/leaderboard.ts b/apps/client/src/pages/leaderboard.ts
ddeleted file mode 100644
iindex 828e097..0000000
---- a/apps/client/src/pages/leaderboard.ts
++++ /dev/null
@@@ -1,217 +0,0 @@
--import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'
--
--export {}
--
--interface Entry {
-- rank: number
-- name: string
-- score: number
--}
--
--/** The viewer's own best for the game on the page, and where it sits on the board we keep. */
--interface Mine {
-- best: number | null
-- rank: number | null
-- name: string | null
--}
--
--type Game = 'salvage-rush' | 'scraplandia'
--
--const gameTabs = Array.from(document.querySelectorAll<HTMLButtonElement>('.tabs button[data-game]'))
--const periodTabs = Array.from(
-- document.querySelectorAll<HTMLButtonElement>('.tabs button[data-period]'),
--)
--const status = document.getElementById('status') as HTMLElement
--const entries = document.getElementById('entries') as HTMLElement
--const pageLabel = document.getElementById('page') as HTMLElement
--const previous = document.getElementById('previous') as HTMLButtonElement
--const next = document.getElementById('next') as HTMLButtonElement
--const title = document.getElementById('title') as HTMLElement
--const lede = document.getElementById('lede') as HTMLElement
--const scoreHeader = document.getElementById('score-header') as HTMLElement
--const playLink = document.getElementById('play-link') as HTMLAnchorElement
--const youLine = document.getElementById('you') as HTMLElement
--const shareButton = document.getElementById('share') as HTMLButtonElement
--
--const COPY: Record<
-- Game,
-- {
-- title: string
-- lede: string
-- score: string
-- play: string
-- playLabel: string
-- empty: (period: 'all' | 'week') => string
-- }
--> = {
-- 'salvage-rush': {
-- title: 'Salvage Rush',
-- lede: `Each player's best round score. Rounds last ${SALVAGE_RUSH_PACK.game?.roundSeconds ?? 90} seconds; every point comes from a pickup the server counted.`,
-- score: 'Best score',
-- play: '/play?game=salvage-rush',
-- playLabel: '▶ Play a round',
-- empty: (period) =>
-- period === 'week'
-- ? 'No rounds scored this week yet. Play one and take first place.'
-- : 'No rounds scored yet. Play one and take first place.',
-- },
-- scraplandia: {
-- title: 'Scraplandia haul',
-- lede: 'Total haul points from server-confirmed extractions. Hold a recovery-bird circle to secure valuables and score.',
-- score: 'Haul points',
-- play: '/play',
-- playLabel: '▶ Play Scraplandia',
-- empty: (period) =>
-- period === 'week'
-- ? 'No hauls recorded this week yet. Extract some salvage to take first place.'
-- : 'No hauls recorded yet. Extract some salvage to take first place.',
-- },
--}
--
--let game: Game =
-- new URLSearchParams(location.search).get('game') === 'scraplandia'
-- ? 'scraplandia'
-- : 'salvage-rush'
--let period: 'all' | 'week' =
-- new URLSearchParams(location.search).get('period') === 'week' ? 'week' : 'all'
--let page = 1
--
--function cell(text: string, className?: string): HTMLTableCellElement {
-- const td = document.createElement('td')
-- if (className) {
-- const span = document.createElement('span')
-- span.className = className
-- span.textContent = text
-- td.append(span)
-- } else td.textContent = text
-- return td
--}
--
--/** The one Share sheet sends the board on screen: its game and period, in its public address. */
--function applyShare(): void {
-- const copy = COPY[game]
-- const query = new URLSearchParams({ game })
-- if (period === 'week') query.set('period', 'week')
-- shareButton.dataset.title = `${copy.title} leaderboard`
-- shareButton.dataset.text = copy.lede
-- shareButton.dataset.url = `https://openvibe.games/leaderboard.html?${query.toString()}`
--}
--
--function applyGame(): void {
-- for (const tab of gameTabs) tab.setAttribute('aria-pressed', String(tab.dataset.game === game))
-- const copy = COPY[game]
-- applyShare()
-- title.textContent = copy.title
-- lede.textContent = copy.lede
-- scoreHeader.textContent = copy.score
-- playLink.href = copy.play
-- playLink.textContent = copy.playLabel
-- document.title = `${copy.title} leaderboard · OpenVibe.Games`
--}
--
--/**
-- * The viewer's own best for this game, read from the rows we keep. The board itself comes from
-- * OpenVibe.Play, so the row that is the viewer's is the one with their name and their best score.
-- */
--async function loadMine(): Promise<Mine | null> {
-- try {
-- const response = await fetch(`/api/v1/games/${game}/me`)
-- if (!response.ok) return null
-- const data = (await response.json()) as Mine
-- return data
-- } catch {
-- return null
-- }
--}
--
--function markMine(mine: Mine | null): void {
-- if (!mine || mine.best === null || mine.rank === null) {
-- youLine.hidden = true
-- youLine.textContent = ''
-- return
-- }
-- youLine.hidden = false
-- youLine.textContent = `Your best: ${mine.best.toLocaleString('en-US')} — rank ${mine.rank.toLocaleString('en-US')} on this board.`
--}
--
--async function load(): Promise<void> {
-- applyShare()
-- for (const tab of periodTabs)
-- tab.setAttribute('aria-pressed', String(tab.dataset.period === period))
-- const copy = COPY[game]
-- status.textContent = 'Loading…'
-- entries.replaceChildren()
-- previous.disabled = page === 1
-- next.disabled = true
-- pageLabel.textContent = `Page ${page}`
-- try {
-- const response = await fetch(`/api/v1/games/${game}/leaderboard?period=${period}&page=${page}`)
-- if (!response.ok) throw new Error('unavailable')
-- const data = (await response.json()) as { entries: Entry[]; pages: number }
-- const mine = await loadMine()
-- markMine(mine)
-- for (const entry of data.entries) {
-- const row = document.createElement('tr')
-- if (entry.rank <= 3) row.className = `top-${entry.rank}`
-- const isMine =
-- mine !== null &&
-- mine.best !== null &&
-- entry.score === mine.best &&
-- (mine.name === null || entry.name === mine.name)
-- if (isMine) row.classList.add('is-you')
-- const nameCell = cell(entry.name, 'name')
-- if (isMine) {
-- const tag = document.createElement('span')
-- tag.className = 'you'
-- tag.textContent = 'you'
-- nameCell.append(tag)
-- }
-- row.append(
-- cell(String(entry.rank), 'rank'),
-- nameCell,
-- cell(entry.score.toLocaleString('en-US')),
-- )
-- entries.append(row)
-- }
-- status.textContent = data.entries.length ? '' : copy.empty(period)
-- next.disabled = page >= data.pages
-- } catch {
-- status.textContent = 'The leaderboard is unavailable right now. Try again in a minute.'
-- }
--}
--
--for (const tab of gameTabs) {
-- tab.onclick = () => {
-- const chosen = tab.dataset.game === 'scraplandia' ? 'scraplandia' : 'salvage-rush'
-- if (chosen === game) return
-- game = chosen
-- page = 1
-- applyGame()
-- void load()
-- }
--}
--
--for (const tab of periodTabs) {
-- tab.onclick = () => {
-- const chosen = tab.dataset.period === 'week' ? 'week' : 'all'
-- if (chosen === period) return
-- period = chosen
-- page = 1
-- void load()
-- }
--}
--
--previous.onclick = () => {
-- if (page > 1) {
-- page--
-- void load()
-- }
--}
--
--next.onclick = () => {
-- page++
-- void load()
--}
--
--applyGame()
--void load()
ddiff --git a/apps/client/src/pages/leaderboards.ts b/apps/client/src/pages/leaderboards.ts
nnew file mode 100644
iindex 0000000..aa39c37
---- /dev/null
++++ b/apps/client/src/pages/leaderboards.ts
@@@ -0,0 +1,256 @@
++/**
++ * Leaderboards on OpenVibe.Games: every game's board, this week and all time.
++ *
++ * The data is Play's, read through this site's own route (/api/v1/play/titles/…); the page
++ * never reads Play's tables. The query string carries the game, board and period so a link
++ * opens the same board, and the old /leaderboard.html?game=… links keep working.
++ */
++export {}
++
++interface PlayBoardDef {
++ id: string
++ name: string
++ kind: 'best' | 'sum'
++}
++
++interface PlayTitle {
++ id: string
++ name: string
++ url?: string
++ summary?: string
++ boards: PlayBoardDef[]
++}
++
++interface BoardPage {
++ title: string
++ title_name: string
++ board: string
++ name: string
++ kind: 'best' | 'sum'
++ period: 'all' | 'week'
++ page: number
++ pages: number
++ total: number
++ entries: { rank: number; name: string; score: number; signed_in: boolean }[]
++}
++
++const gameTabs = document.getElementById('game-tabs') as HTMLElement
++const boardTabs = document.getElementById('board-tabs') as HTMLElement
++const periodTabs = document.getElementById('period-tabs') as HTMLElement
++const status = document.getElementById('status') as HTMLElement
++const entries = document.getElementById('entries') as HTMLElement
++const pageLabel = document.getElementById('page') as HTMLElement
++const previous = document.getElementById('previous') as HTMLButtonElement
++const next = document.getElementById('next') as HTMLButtonElement
++const title = document.getElementById('title') as HTMLElement
++const lede = document.getElementById('lede') as HTMLElement
++const scoreHeader = document.getElementById('score-header') as HTMLElement
++const playLink = document.getElementById('play-link') as HTMLAnchorElement
++
++const query = new URLSearchParams(location.search)
++const wantedGame = query.get('game') ?? ''
++const wantedBoard = query.get('board') ?? ''
++const wantedPeriod: 'all' | 'week' = query.get('period') === 'week' ? 'week' : 'all'
++
++let titles: PlayTitle[] = []
++let game = wantedGame
++let board = wantedBoard
++let period: 'all' | 'week' = wantedPeriod
++let page = 1
++let loadId = 0
++
++const num = (n: number): string => Number(n).toLocaleString('en-US')
++
++function cell(text: string, className?: string): HTMLTableCellElement {
++ const td = document.createElement('td')
++ if (className) {
++ const span = document.createElement('span')
++ span.className = className
++ span.textContent = text
++ td.append(span)
++ } else td.textContent = text
++ return td
++}
++
++function tabs(
++ host: HTMLElement,
++ items: { id: string; label: string }[],
++ current: string,
++ onPick: (id: string) => void,
++): void {
++ host.replaceChildren(
++ ...items.map((item) => {
++ const button = document.createElement('button')
++ button.type = 'button'
++ button.textContent = item.label
++ button.setAttribute('aria-pressed', String(item.id === current))
++ button.addEventListener('click', () => {
++ if (item.id !== current) onPick(item.id)
++ })
++ return button
++ }),
++ )
++}
++
++function currentTitle(): PlayTitle {
++ return titles.find((t) => t.id === game) ?? titles.find((t) => t.boards.length)!
++}
++
++function setUrl(): void {
++ const next = new URLSearchParams()
++ next.set('game', game)
++ if (board) next.set('board', board)
++ if (period === 'week') next.set('period', 'week')
++ history.replaceState(null, '', `${location.pathname}?${next.toString()}`)
++}
++
++function selectGame(id: string): void {
++ game = id
++ const t = currentTitle()
++ board = t.boards[0]?.id ?? ''
++ page = 1
++ apply()
++ setUrl()
++ void load()
++}
++
++function selectBoard(id: string): void {
++ board = id
++ page = 1
++ apply()
++ setUrl()
++ void load()
++}
++
++function selectPeriod(nextPeriod: 'all' | 'week'): void {
++ period = nextPeriod
++ page = 1
++ apply()
++ setUrl()
++ void load()
++}
++
++function apply(): void {
++ const t = currentTitle()
++ if (!t) return
++ game = t.id
++ if (!t.boards.some((b) => b.id === board)) board = t.boards[0]?.id ?? ''
++ const def = t.boards.find((b) => b.id === board)
++ title.textContent = t.name
++ lede.textContent = t.summary
++ ? `${t.summary} ${period === 'week' ? 'This week, from Monday 00:00 UTC.' : 'All time.'}`
++ : period === 'week'
++ ? "This week's board, from Monday 00:00 UTC."
++ : 'The all-time board.'
++ scoreHeader.textContent = def?.kind === 'sum' ? 'Total' : 'Best'
++ playLink.href = t.url || '/play'
++ playLink.textContent = `▶ Play ${t.name}`
++ document.title = `${t.name} leaderboard · OpenVibe.Games`
++ tabs(
++ gameTabs,
++ titles.filter((x) => x.boards.length).map((x) => ({ id: x.id, label: x.name })),
++ game,
++ selectGame,
++ )
++ tabs(
++ boardTabs,
++ t.boards.map((b) => ({ id: b.id, label: b.name })),
++ board,
++ selectBoard,
++ )
++ boardTabs.hidden = t.boards.length < 2
++ for (const button of periodTabs.querySelectorAll<HTMLButtonElement>('button[data-period]')) {
++ button.setAttribute('aria-pressed', String(button.dataset.period === period))
++ }
++}
++
++async function load(): Promise<void> {
++ const id = ++loadId
++ const t = currentTitle()
++ if (!t) return
++ status.className = 'status'
++ status.textContent = 'Loading…'
++ entries.replaceChildren()
++ previous.disabled = page === 1
++ next.disabled = true
++ pageLabel.textContent = `Page ${page}`
++ try {
++ const queryString = new URLSearchParams({ period, page: String(page) })
++ const response = await fetch(
++ `/api/v1/play/titles/${encodeURIComponent(t.id)}/boards/${encodeURIComponent(board)}?${queryString}`,
++ { headers: { accept: 'application/json' } },
++ )
++ if (!response.ok) throw new Error(`unavailable: ${response.status}`)
++ const data = (await response.json()) as BoardPage
++ if (id !== loadId) return
++ for (const entry of data.entries) {
++ const row = document.createElement('tr')
++ if (entry.rank <= 3) row.className = `top-${entry.rank}`
++ const player = cell(entry.name, 'name')
++ if (!entry.signed_in) {
++ const guest = document.createElement('span')
++ guest.className = 'guest'
++ guest.textContent = 'guest'
++ player.append(guest)
++ }
++ row.append(cell(String(entry.rank), 'rank'), player, cell(num(entry.score)))
++ entries.append(row)
++ }
++ status.textContent = data.entries.length
++ ? ''
++ : period === 'week'
++ ? 'Nobody has scored this week yet. Play a round and take first place.'
++ : 'Nobody has scored yet. Play a round and take first place.'
++ pageLabel.textContent = `Page ${page} of ${data.pages}`
++ previous.disabled = page <= 1
++ next.disabled = page >= data.pages
++ } catch {
++ if (id !== loadId) return
++ status.className = 'status error'
++ status.textContent = 'The leaderboard is unavailable right now. Try again in a minute.'
++ pageLabel.textContent = `Page ${page}`
++ }
++}
++
++for (const button of periodTabs.querySelectorAll<HTMLButtonElement>('button[data-period]')) {
++ button.addEventListener('click', () => {
++ const chosen = button.dataset.period === 'week' ? 'week' : 'all'
++ if (chosen !== period) selectPeriod(chosen)
++ })
++}
++
++previous.addEventListener('click', () => {
++ if (page > 1) {
++ page--
++ void load()
++ }
++})
++
++next.addEventListener('click', () => {
++ page++
++ void load()
++})
++
++async function start(): Promise<void> {
++ try {
++ const response = await fetch('/api/v1/play/titles', { headers: { accept: 'application/json' } })
++ if (!response.ok) throw new Error(`unavailable: ${response.status}`)
++ titles = ((await response.json()) as { titles: PlayTitle[] }).titles.filter(
++ (t) => t.boards.length,
++ )
++ const first = titles[0]
++ if (!first) {
++ status.textContent = 'No game has a board yet.'
++ return
++ }
++ if (!titles.some((t) => t.id === game)) game = first.id
++ apply()
++ setUrl()
++ await load()
++ } catch {
++ status.className = 'status error'
++ status.textContent = 'The leaderboards are unavailable right now. Try again in a minute.'
++ }
++}
++
++void start()
ddiff --git a/apps/client/src/pages/pageIcon.test.ts b/apps/client/src/pages/pageIcon.test.ts
iindex 744c9e0..cc827f3 100644
---- a/apps/client/src/pages/pageIcon.test.ts
++++ b/apps/client/src/pages/pageIcon.test.ts
@@@ -12,7 +12,7 @@ import { describe, expect, it } from 'vitest'
const client = (path: string) => fileURLToPath(new URL(`../../${path}`, import.meta.url))
describe('page icon', () => {
-- it.each(['index.html', 'play.html', 'editor.html'])('%s links /favicon.svg', (page) => {
++ it.each(['index.html', 'play.html', 'editor.html', 'achievements.html', 'leaderboards.html', 'developers.html'])('%s links /favicon.svg', (page) => {
const html = readFileSync(client(page), 'utf8')
expect(html).toContain('<link rel="icon" type="image/svg+xml" href="/favicon.svg" />')
})
ddiff --git a/apps/client/src/pages/pageMeta.test.ts b/apps/client/src/pages/pageMeta.test.ts
iindex 7d0297e..0246262 100644
---- a/apps/client/src/pages/pageMeta.test.ts
++++ b/apps/client/src/pages/pageMeta.test.ts
@@@ -19,7 +19,7 @@ const social = req('openvibe-shared/social') as { url(service: string): string |
const CARD = `https://openvibe.games${social.url('games')}`
describe('page metadata', () => {
-- it.each(['play.html', 'worlds.html', 'leaderboard.html'])(
++ it.each(['play.html', 'worlds.html', 'achievements.html', 'leaderboards.html', 'developers.html'])(
'%s has a one-line description of 120 to 160 characters',
(page) => {
const d = description(page)
@@@ -30,12 +30,12 @@ describe('page metadata', () => {
},
)
-- it('leaderboard.html names its canonical URL', () => {
-- const page = html('leaderboard.html')
-- expect(page).toContain('<link rel="canonical" href="https://openvibe.games/leaderboard.html" />')
++ it('leaderboards.html names its canonical URL', () => {
++ const page = html('leaderboards.html')
++ expect(page).toContain('<link rel="canonical" href="https://openvibe.games/leaderboards" />')
})
-- it.each(['index.html', 'play.html', 'worlds.html', 'editor.html', 'leaderboard.html'])(
++ it.each(['index.html', 'play.html', 'worlds.html', 'editor.html', 'achievements.html', 'leaderboards.html', 'developers.html'])(
'%s shares the content-hashed site card',
(page) => {
const doc = html(page)
ddiff --git a/apps/client/src/pages/play-pages.css b/apps/client/src/pages/play-pages.css
nnew file mode 100644
iindex 0000000..84d7af0
---- /dev/null
++++ b/apps/client/src/pages/play-pages.css
@@@ -0,0 +1,484 @@
++/*
++ * The Play pages on OpenVibe.Games: Achievements, Leaderboards and Developers.
++ *
++ * They are drawn in the Games look — the same palette, type and card language as the
++ * portal and the in-game leaderboard — and share one stylesheet so the three pages
++ * cannot drift apart. The shared OpenVibe navbar and footer mount around `.shell`.
++ */
++:root {
++ color-scheme: dark;
++ --accent: #3b82f6;
++ --accent-light: #60a5fa;
++ --accent-dark: #1d4ed8;
++ --secondary: #22d3ee;
++ --bg: var(--bg-primary, #0a0f1c);
++ --bg-raised: var(--bg-secondary, #101828);
++ --bg-card: var(--bg-card-page, rgba(19, 28, 46, 0.88));
++ --border: #1f2d47;
++ --text: var(--text-primary, #e6edf7);
++ --text-dim: var(--text-secondary, #96a7c2);
++ --text-faint: var(--text-muted, #7386a3);
++ --ok: #34d399;
++}
++/* Light themes (theme-loader sets data-theme-mode): #22d3ee reads at 1.7:1 on a light page. */
++:root[data-theme-mode='light'] {
++ --secondary: #0e7490;
++ --ok: #047857;
++ --border: #cbd5e1;
++ --bg-card: rgba(255, 255, 255, 0.9);
++}
++* {
++ box-sizing: border-box;
++}
++[hidden] {
++ display: none !important;
++}
++html,
++body {
++ min-height: 100%;
++}
++body {
++ margin: 0;
++ color: var(--text);
++ background:
++ radial-gradient(900px 420px at 18% -10%, rgba(59, 130, 246, 0.18), transparent 70%),
++ radial-gradient(700px 360px at 92% 0%, rgba(34, 211, 238, 0.1), transparent 70%), var(--bg);
++ font:
++ 16px/1.55 system-ui,
++ -apple-system,
++ 'Segoe UI',
++ sans-serif;
++ overflow-x: hidden;
++}
++a {
++ color: var(--accent-light);
++}
++a:focus-visible,
++button:focus-visible {
++ outline: 2px solid var(--accent-light);
++ outline-offset: 2px;
++}
++#navbar-mount {
++ min-height: 56px;
++}
++.shell {
++ width: 100%;
++ max-width: 1040px;
++ margin: 0 auto;
++ padding: 20px 16px 72px;
++ min-width: 0;
++}
++.crumbs {
++ display: flex;
++ flex-wrap: wrap;
++ gap: 6px 14px;
++ font-size: 14px;
++ color: var(--text-faint);
++}
++.crumbs a {
++ color: var(--text-dim);
++}
++.eyebrow {
++ margin: 28px 0 4px;
++ font-size: 12px;
++ font-weight: 800;
++ letter-spacing: 0.14em;
++ color: var(--secondary);
++}
++h1 {
++ margin: 0 0 8px;
++ font-size: clamp(30px, 5vw, 44px);
++ line-height: 1.05;
++ font-weight: 900;
++ letter-spacing: 0.01em;
++}
++h2 {
++ margin: 34px 0 10px;
++ font-size: clamp(20px, 3vw, 26px);
++ line-height: 1.2;
++}
++h3 {
++ margin: 0 0 6px;
++ font-size: 17px;
++ line-height: 1.3;
++}
++p {
++ margin: 0 0 14px;
++}
++.lede {
++ max-width: 70ch;
++ color: var(--text-dim);
++ margin-bottom: 18px;
++}
++.muted {
++ color: var(--text-faint);
++ font-size: 14px;
++}
++.actions {
++ display: flex;
++ flex-wrap: wrap;
++ gap: 10px;
++ margin: 18px 0 6px;
++}
++.btn {
++ display: inline-flex;
++ align-items: center;
++ justify-content: center;
++ min-height: 44px;
++ padding: 10px 20px;
++ border: 1px solid var(--border);
++ border-radius: 12px;
++ background: var(--bg-raised);
++ color: var(--text);
++ font-weight: 750;
++ text-decoration: none;
++}
++.btn.primary {
++ border: 0;
++ background: linear-gradient(120deg, var(--accent), var(--accent-dark));
++ color: #fff;
++}
++.summary {
++ display: grid;
++ grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));
++ gap: 10px;
++ margin: 20px 0 4px;
++ padding: 0;
++ list-style: none;
++}
++.summary li {
++ padding: 14px 16px;
++ border: 1px solid var(--border);
++ border-radius: 14px;
++ background: var(--bg-card);
++}
++.summary b {
++ display: block;
++ font-size: 24px;
++ line-height: 1.15;
++}
++.summary span {
++ color: var(--text-dim);
++ font-size: 13px;
++}
++.status {
++ margin: 20px 0;
++ color: var(--text-dim);
++ text-align: center;
++}
++.status:empty {
++ display: none;
++}
++.status.error {
++ color: var(--text);
++ text-align: left;
++ padding: 16px 18px;
++ border: 1px solid var(--border);
++ border-radius: 14px;
++ background: var(--bg-card);
++}
++.games {
++ display: flex;
++ flex-direction: column;
++ gap: 18px;
++ margin-top: 18px;
++}
++.game {
++ border: 1px solid var(--border);
++ border-radius: 18px;
++ background: var(--bg-card);
++ overflow: hidden;
++}
++.game-head {
++ display: flex;
++ flex-wrap: wrap;
++ align-items: baseline;
++ justify-content: space-between;
++ gap: 8px 16px;
++ padding: 18px 20px 0;
++}
++.game-head p {
++ margin: 0 0 10px;
++ color: var(--text-faint);
++ font-size: 14px;
++}
++.ach-list {
++ list-style: none;
++ margin: 0;
++ padding: 0 20px 18px;
++ display: grid;
++ grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
++ gap: 10px;
++}
++.ach {
++ min-width: 0;
++ padding: 12px 14px;
++ border: 1px solid var(--border);
++ border-radius: 12px;
++ background: color-mix(in srgb, var(--bg-raised) 72%, transparent);
++}
++.ach b {
++ display: block;
++ overflow-wrap: anywhere;
++}
++.ach small {
++ display: block;
++ margin-top: 2px;
++ color: var(--text-dim);
++ font-size: 13px;
++ overflow-wrap: anywhere;
++}
++.ach .meta {
++ display: inline-block;
++ margin-top: 8px;
++ font-size: 12px;
++ color: var(--text-faint);
++}
++.ach.earned {
++ border-color: color-mix(in srgb, var(--ok) 55%, var(--border));
++}
++.ach.earned .meta {
++ color: var(--ok);
++}
++.empty {
++ padding: 18px 20px 22px;
++ color: var(--text-dim);
++}
++.keep {
++ margin-top: 22px;
++ padding: 18px 20px;
++ border: 1px dashed var(--border);
++ border-radius: 16px;
++}
++.keep ul {
++ margin: 8px 0 0;
++ padding-left: 20px;
++}
++.keep li {
++ margin: 4px 0;
++}
++/* ── leaderboards ─────────────────────────────────────────── */
++.board {
++ margin-top: 18px;
++ border: 1px solid var(--border);
++ border-radius: 18px;
++ background: var(--bg-card);
++ overflow: hidden;
++}
++.tabs {
++ display: flex;
++ gap: 6px;
++ padding: 12px;
++ overflow-x: auto;
++ overscroll-behavior-x: contain;
++ border-bottom: 1px solid var(--border);
++ scrollbar-width: thin;
++}
++.tabs:empty {
++ display: none;
++}
++.tabs button {
++ flex: 0 0 auto;
++ min-height: 44px;
++ padding: 8px 16px;
++ border: 1px solid var(--border);
++ border-radius: 999px;
++ background: transparent;
++ color: var(--text-dim);
++ font: inherit;
++ font-weight: 650;
++ cursor: pointer;
++}
++.tabs button[aria-pressed='true'] {
++ border-color: var(--accent);
++ background: rgba(59, 130, 246, 0.18);
++ color: var(--text);
++}
++.table-wrap {
++ width: 100%;
++ overflow-x: auto;
++}
++table {
++ width: 100%;
++ min-width: 360px;
++ border-collapse: collapse;
++ font-variant-numeric: tabular-nums;
++}
++th,
++td {
++ padding: 12px 16px;
++ text-align: left;
++ border-bottom: 1px solid var(--border);
++}
++th {
++ font-size: 12px;
++ letter-spacing: 0.08em;
++ text-transform: uppercase;
++ color: var(--text-faint);
++ font-weight: 700;
++}
++th:last-child,
++td:last-child {
++ text-align: right;
++}
++td:last-child {
++ font-weight: 800;
++}
++tbody tr:last-child td {
++ border-bottom: 0;
++}
++.rank {
++ display: inline-grid;
++ place-items: center;
++ min-width: 30px;
++ height: 30px;
++ padding: 0 6px;
++ border-radius: 999px;
++ border: 1px solid var(--border);
++ font-weight: 800;
++ font-size: 14px;
++}
++tr.top-1 .rank {
++ background: linear-gradient(135deg, #fde68a, #f59e0b);
++ color: #1a1405;
++ border: 0;
++}
++tr.top-2 .rank {
++ background: linear-gradient(135deg, #e5e7eb, #9ca3af);
++ color: #111827;
++ border: 0;
++}
++tr.top-3 .rank {
++ background: linear-gradient(135deg, #fdba74, #c2410c);
++ color: #1c0d04;
++ border: 0;
++}
++.name {
++ overflow-wrap: anywhere;
++}
++.guest {
++ margin-left: 6px;
++ font-size: 11px;
++ font-weight: 700;
++ letter-spacing: 0.03em;
++ color: var(--text-faint);
++}
++.pager {
++ display: flex;
++ align-items: center;
++ justify-content: space-between;
++ gap: 12px;
++ padding: 12px 16px;
++ border-top: 1px solid var(--border);
++ color: var(--text-faint);
++ font-size: 14px;
++}
++.pager button {
++ min-height: 44px;
++ padding: 7px 14px;
++ border: 1px solid var(--border);
++ border-radius: 10px;
++ background: var(--bg-raised);
++ color: var(--text);
++ font: inherit;
++ cursor: pointer;
++}
++.pager button:disabled {
++ opacity: 0.45;
++ cursor: default;
++}
++/* ── developers ───────────────────────────────────────────── */
++.doc {
++ display: grid;
++ grid-template-columns: minmax(0, 1fr);
++ gap: 16px;
++ margin-top: 20px;
++}
++.card {
++ min-width: 0;
++ padding: 20px;
++ border: 1px solid var(--border);
++ border-radius: 18px;
++ background: var(--bg-card);
++}
++.card h2 {
++ margin-top: 0;
++}
++.card h3 {
++ margin-top: 18px;
++}
++.card ul {
++ margin: 0 0 12px;
++ padding-left: 20px;
++}
++.card li {
++ margin: 5px 0;
++ overflow-wrap: anywhere;
++}
++code {
++ padding: 1px 5px;
++ border-radius: 5px;
++ background: color-mix(in srgb, var(--bg-raised) 80%, transparent);
++ font:
++ 0.92em/1.5 ui-monospace,
++ SFMono-Regular,
++ Menlo,
++ Consolas,
++ monospace;
++ overflow-wrap: anywhere;
++}
++pre {
++ margin: 0;
++ padding: 14px 16px;
++ border: 1px solid var(--border);
++ border-radius: 12px;
++ background: #070c16;
++ color: #e6edf7;
++ overflow-x: auto;
++ font:
++ 13px/1.6 ui-monospace,
++ SFMono-Regular,
++ Menlo,
++ Consolas,
++ monospace;
++}
++pre code {
++ padding: 0;
++ background: none;
++ overflow-wrap: normal;
++}
++.split {
++ display: grid;
++ grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
++ gap: 16px;
++}
++.note {
++ margin-top: 24px;
++ padding: 18px 20px;
++ border: 1px dashed var(--border);
++ border-radius: 16px;
++ color: var(--text-dim);
++}
++@media (max-width: 600px) {
++ .shell {
++ padding: 14px 14px 56px;
++ }
++ .eyebrow {
++ margin-top: 22px;
++ }
++ .game-head,
++ .ach-list {
++ padding-left: 14px;
++ padding-right: 14px;
++ }
++ th,
++ td {
++ padding: 10px 12px;
++ }
++ .tabs {
++ padding: 10px;
++ }
++ .card {
++ padding: 16px;
++ }
++}
ddiff --git a/apps/client/src/pages/playNoscript.test.ts b/apps/client/src/pages/playNoscript.test.ts
iindex 592f00b..d550d61 100644
---- a/apps/client/src/pages/playNoscript.test.ts
++++ b/apps/client/src/pages/playNoscript.test.ts
@@@ -14,7 +14,7 @@ describe('play.html without JavaScript', () => {
it('describes both games and what they need', () => {
expect(noscript).toContain('<h1>Scraplandia and Salvage Rush</h1>')
expect(noscript).toMatch(/need JavaScript and\s+WebGL/)
-- expect(noscript).toContain('href="https://openvibe.games/leaderboard.html"')
++ expect(noscript).toContain('href="https://openvibe.games/leaderboards"')
})
it('sits before the stage, inside the body', () => {
ddiff --git a/apps/client/src/pages/playPages.test.ts b/apps/client/src/pages/playPages.test.ts
nnew file mode 100644
iindex 0000000..8c89898
---- /dev/null
++++ b/apps/client/src/pages/playPages.test.ts
@@@ -0,0 +1,37 @@
++/**
++ * The Play pages that moved into Games (Achievements, Leaderboards, Developers) are real
++ * OpenVibe.Games pages: the shared Frame mounts on them, they carry the Games stylesheet, and
++ * they load their own script from the build. The old /leaderboard.html is gone; /leaderboards
++ * is the address, and the server redirects the old one.
++ */
++import { readFileSync } from 'node:fs'
++import { fileURLToPath } from 'node:url'
++import { describe, expect, it } from 'vitest'
++
++const html = (page: string) =>
++ readFileSync(fileURLToPath(new URL(`../../${page}`, import.meta.url)), 'utf8')
++
++const PAGES = [
++ ['achievements.html', '/src/pages/achievements.ts'],
++ ['leaderboards.html', '/src/pages/leaderboards.ts'],
++ ['developers.html', null],
++] as const
++
++describe('the Play pages on OpenVibe.Games', () => {
++ it.each(PAGES)('%s mounts the OpenVibe Frame and the Games stylesheet', (page, script) => {
++ const doc = html(page)
++ for (const tag of [
++ '<meta name="viewport" content="width=device-width, initial-scale=1.0" />',
++ '<link rel="stylesheet" href="/src/pages/play-pages.css" />',
++ '<script src="/shared/theme-loader.js" defer></script>',
++ '<script src="/shared/navbar.js" defer></script>',
++ '<script src="/shared/footer.js" defer></script>',
++ '<script src="/frame.js" defer></script>',
++ '<div id="navbar-mount"></div>',
++ '<footer id="ov-footer" class="ovf"></footer>',
++ ])
++ expect(doc).toContain(tag)
++ if (script) expect(doc).toContain(`<script type="module" src="${script}"></script>`)
++ expect(doc).not.toContain('leaderboard.html')
++ })
++})
ddiff --git a/apps/client/src/ui/rushResult.test.ts b/apps/client/src/ui/rushResult.test.ts
iindex 256084b..2db8606 100644
---- a/apps/client/src/ui/rushResult.test.ts
++++ b/apps/client/src/ui/rushResult.test.ts
@@@ -49,7 +49,7 @@ describe('the result card of a round', () => {
'I scored 12 in Salvage Rush (rank 2 of 7). My best round: 12.',
)
expect(card.querySelector('.rush-result-board')?.getAttribute('href')).toBe(
-- '/leaderboard.html?game=salvage-rush',
++ '/leaderboards?game=salvage-rush',
)
})
})
ddiff --git a/apps/client/src/ui/rushResult.ts b/apps/client/src/ui/rushResult.ts
iindex de6b285..656ae60 100644
---- a/apps/client/src/ui/rushResult.ts
++++ b/apps/client/src/ui/rushResult.ts
@@@ -77,7 +77,7 @@ export function buildResultCard(result: RoundResult, origin: string): HTMLElemen
share.innerHTML = '<svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 20V9M7 14l5-5 5 5M5 4h14"/></svg><span>Share</span>'
const board = document.createElement('a')
board.className = 'rush-result-board'
-- board.href = '/leaderboard.html?game=salvage-rush'
++ board.href = '/leaderboards?game=salvage-rush'
board.textContent = 'Full board'
const close = document.createElement('button')
close.type = 'button'
ddiff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts
iindex 2ec36fb..a7640f4 100644
---- a/apps/client/vite.config.ts
++++ b/apps/client/vite.config.ts
@@@ -25,6 +25,9 @@ export default defineConfig({
if (req.url === '/play') req.url = '/play.html'
else if (req.url === '/editor') req.url = '/editor.html'
else if (req.url === '/worlds') req.url = '/worlds.html'
++ else if (req.url === '/achievements') req.url = '/achievements.html'
++ else if (req.url === '/leaderboards') req.url = '/leaderboards.html'
++ else if (req.url === '/developers') req.url = '/developers.html'
else if (/^\/play\/[a-z0-9-]+(\?|$)/.test(req.url ?? '')) req.url = '/casual.html'
next()
})
@@@ -53,7 +56,9 @@ export default defineConfig({
home: 'index.html',
main: 'play.html',
editor: 'editor.html',
-- leaderboard: 'leaderboard.html',
++ achievements: 'achievements.html',
++ leaderboards: 'leaderboards.html',
++ developers: 'developers.html',
worlds: 'worlds.html',
casual: 'casual.html',
},
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 25fbfb4..223ecd0 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -88,6 +88,7 @@ import { GameProgressWriter, playerBestScore, type Milestone } from './game/prog
import { handleResultPage } from './net/resultPage.js'
import type { RoundResult } from './game/salvageRush.js'
import { handleGameRead } from './net/gameRoutes.js'
++import { createPlayProxy } from './net/playRoutes.js'
import { createPlayBoards } from './platform/playBoards.js'
import { PgSurfRecords, createSurfPlay, surfBoardId } from './platform/surfRuns.js'
import { handleSurfRead } from './net/surfRoutes.js'
@@@ -996,6 +997,14 @@ async function main(): Promise<void> {
onLimited: (name, actor) => log.warn('rate limited', { limit: name, actor }),
valkey,
})
++ // The Achievements, Leaderboards and Developers pages read Play through these routes (server-side,
++ // public reads); the pages themselves are part of this site, not play.openvibe.network.
++ const playPages = createPlayProxy({
++ url: config.platform.playUrl,
++ limits: actorLimits,
++ address: (request) => resolveClientAddress(request, config.trustProxy),
++ log: log.child({ system: 'play-pages' }),
++ })
const ticketStore = createWsTicketStore({ valkey })
// One trusted-proxy-aware address resolution for the upgrade limit, the
// ticket limit and logs (config.TRUST_PROXY; the socket address otherwise).
@@@ -1311,6 +1320,7 @@ async function main(): Promise<void> {
})
)
return true
++ if (playPages(req, res)) return true
if (
handleGameRead(req, res, {
db,
ddiff --git a/apps/server/src/net/agent.ts b/apps/server/src/net/agent.ts
iindex b5bb7e1..929049f 100644
---- a/apps/server/src/net/agent.ts
++++ b/apps/server/src/net/agent.ts
@@@ -48,10 +48,11 @@ const STATIC_PAGES: Record<string, { file: string; canonical: string }> = {
'/': { file: 'index.html', canonical: `${SITE}/` },
'/play': { file: 'play.html', canonical: `${PLAY_SITE}/` },
'/worlds': { file: 'worlds.html', canonical: `${SITE}/worlds` },
++ '/developers': { file: 'developers.html', canonical: `${SITE}/developers` },
}
/** The client build's own files that are pages with a twin (the portal is served through portalPage). */
--export const AGENT_PAGE_FILES = new Set(['index.html', 'play.html', 'worlds.html', 'casual.html'])
++export const AGENT_PAGE_FILES = new Set(['index.html', 'play.html', 'worlds.html', 'casual.html', 'developers.html'])
/** What the twin renderer needs from the server: the built client, the portal's live parts and the host. */
export interface AgentContext {
ddiff --git a/apps/server/src/net/cardFacts.test.ts b/apps/server/src/net/cardFacts.test.ts
iindex f77c6ff..b7ab13e 100644
---- a/apps/server/src/net/cardFacts.test.ts
++++ b/apps/server/src/net/cardFacts.test.ts
@@@ -31,7 +31,7 @@ describe('the game cards of the home page', () => {
it('writes no round length into the pages by hand', () => {
// The length of a round is the pack's number; a sentence that carries one by hand would
// drift from the game the day the pack changes.
-- for (const file of ['index.html', 'play.html', 'leaderboard.html']) {
++ for (const file of ['index.html', 'play.html', 'leaderboards.html']) {
const html = readFileSync(join(client, file), 'utf8')
expect(html).not.toMatch(/\d+-second round/)
}
ddiff --git a/apps/server/src/net/discovery.test.ts b/apps/server/src/net/discovery.test.ts
iindex b16d775..211259f 100644
---- a/apps/server/src/net/discovery.test.ts
++++ b/apps/server/src/net/discovery.test.ts
@@@ -148,6 +148,7 @@ describe('crawl artifacts (plan T11)', () => {
expect(r.body).toContain('<loc>https://openvibe.games/</loc>')
expect(r.body).toContain('<loc>https://play.openvibe.games/</loc>')
expect(r.body).toContain('<loc>https://openvibe.games/play/vibe-heroes</loc>')
++ for (const page of ['/achievements', '/leaderboards', '/developers']) expect(r.body).toContain(`<loc>https://openvibe.games${page}</loc>`)
// The lastmod is the committed content-revision date, not the clock.
const status = JSON.parse(readFileSync(join(ROOT, 'STATUS.json'), 'utf8')) as {
updated: string
@@@ -168,6 +169,10 @@ describe('crawl artifacts (plan T11)', () => {
expect(r.body).toContain('- [Vibe Heroes](https://openvibe.games/play/vibe-heroes.md)')
// /steam and /updates have no twin: their URL stays, marked (HTML).
expect(r.body).toContain('- [On Steam right now](https://openvibe.games/steam) (HTML)')
++ // The two Play pages are drawn by their own script, so they are named as HTML; the developers page has a twin.
++ expect(r.body).toContain('- [Achievements](https://openvibe.games/achievements) (HTML)')
++ expect(r.body).toContain('- [Leaderboards](https://openvibe.games/leaderboards) (HTML)')
++ expect(r.body).toContain('- [Game developers: the Play API](https://openvibe.games/developers.md)')
expect(r.body).toContain('- [What shipped](https://openvibe.games/updates) (HTML)')
expect(r.body).toContain('\n## Optional\n')
expect(r.body).toContain('https://openvibe.games/sitemap.xml')
ddiff --git a/apps/server/src/net/discovery.ts b/apps/server/src/net/discovery.ts
iindex 8304372..0abd864 100644
---- a/apps/server/src/net/discovery.ts
++++ b/apps/server/src/net/discovery.ts
@@@ -135,7 +135,9 @@ const SITE_DETAILS =
const DISALLOW = ['/auth/', '/api/', '/metrics', '/editor']
/** Public pages with no Markdown twin (served by another server, or not rendered as Markdown): linked as HTML. */
--const TWINLESS = new Set([`${SITE}/steam`, `${SITE}/updates`])
++// /achievements and /leaderboards are drawn by their own script, so a Markdown twin would be an empty skeleton:
++// llms.txt names the HTML page instead. /developers is static and has a real twin.
++const TWINLESS = new Set([`${SITE}/steam`, `${SITE}/updates`, `${SITE}/achievements`, `${SITE}/leaderboards`])
/** "2026-09-29" or "2026-09-29T12:00:00Z" as YYYY-MM-DD; null when the value is unusable. */
function dayOf(ts: unknown): string | null {
@@@ -211,6 +213,27 @@ function publicPageDocs(): PublicPageDoc[] {
text: pack.tagline,
priority: 0.8,
})),
++ {
++ title: 'Achievements',
++ url: `${SITE}/achievements`,
++ note: 'every achievement in every OpenVibe game, and yours when you sign in',
++ text: 'Achievements on OpenVibe.Games: every achievement in every OpenVibe game, with how many players have earned each. Sign in with an OpenVibe account and the page shows the achievements you have unlocked, in every game, and when. Progress is kept by OpenVibe.Play, the game services behind every OpenVibe game, and shown here as part of Games.',
++ priority: 0.8,
++ },
++ {
++ title: 'Leaderboards',
++ url: `${SITE}/leaderboards`,
++ note: "every game's board, this week and all time",
++ text: 'Leaderboards on OpenVibe.Games: every OpenVibe game\'s board, this week (from Monday 00:00 UTC) and all time, with a name and a score and never a player id. The boards are kept by OpenVibe.Play and drawn here in the Games look; a finished round or haul reaches its board within a minute.',
++ priority: 0.8,
++ },
++ {
++ title: 'Game developers: the Play API',
++ url: `${SITE}/developers`,
++ note: 'achievements, leaderboards and cloud saves for your game',
++ text: 'The Play API for game makers: register a title with your own OpenVibe token, post scores and unlocks, read the boards, and keep cloud saves with a version check. Games\' servers use the openvibe-sdk/play client or plain HTTPS; a developer starts in the sandbox with a project on OpenVibe.Services and needs nobody\'s permission to try it.',
++ priority: 0.6,
++ },
{
title: 'Creator worlds',
url: `${SITE}/worlds`,
ddiff --git a/apps/server/src/net/httpServer.test.ts b/apps/server/src/net/httpServer.test.ts
iindex b2afd4a..191f9f6 100644
---- a/apps/server/src/net/httpServer.test.ts
++++ b/apps/server/src/net/httpServer.test.ts
@@@ -82,6 +82,9 @@ beforeAll(async () => {
writeFileSync(join(dist, 'index.html'), '<!doctype html><html><head><title>portal page</title></head><body><h1>portal page</h1></body></html>')
writeFileSync(join(dist, 'play.html'), '<!doctype html><html><head><title>game page</title></head><body><h1>game page</h1></body></html>')
writeFileSync(join(dist, 'editor.html'), '<!doctype html><title>editor page</title>')
++ writeFileSync(join(dist, 'achievements.html'), '<!doctype html><title>achievements page</title>')
++ writeFileSync(join(dist, 'leaderboards.html'), '<!doctype html><title>leaderboards page</title>')
++ writeFileSync(join(dist, 'developers.html'), '<!doctype html><title>developers page</title>')
writeFileSync(join(dist, 'frame.js'), 'void 0')
writeFileSync(join(dist, ASSET.slice(1)), 'export {}')
writeFileSync(join(dist, 'assets', 'tex', 'wood_planks.jpg'), 'jpeg')
@@@ -137,6 +140,9 @@ describe('real routes on the apex', () => {
['/play', 'game page'],
['/play?sso=none', 'game page'],
['/editor', 'editor page'],
++ ['/achievements', 'achievements page'],
++ ['/leaderboards', 'leaderboards page'],
++ ['/developers', 'developers page'],
['/index.html', 'portal page'],
])('%s answers 200 with its page', async (path, marker) => {
const r = await send(path)
@@@ -146,6 +152,12 @@ describe('real routes on the apex', () => {
expect(r.body).toContain(marker)
})
++ it('sends the old board page to Leaderboards with its game and period', async () => {
++ const r = await send('/leaderboard.html?game=salvage-rush&period=week')
++ expect(r.status).toBe(301)
++ expect(r.headers.location).toBe('/leaderboards?game=salvage-rush&period=week')
++ })
++
it('serves hashed build assets as immutable, and public files', async () => {
const asset = await send(ASSET)
expect(asset.status).toBe(200)
ddiff --git a/apps/server/src/net/httpServer.ts b/apps/server/src/net/httpServer.ts
iindex 10618d3..218076b 100644
---- a/apps/server/src/net/httpServer.ts
++++ b/apps/server/src/net/httpServer.ts
@@@ -313,7 +313,9 @@ export function createHttpServer(
{ href: '/play', label: 'Play Scraplandia' },
{ href: '/play?game=salvage-rush', label: 'Play Salvage Rush' },
{ href: '/play/vibe-heroes', label: 'Play Vibe Heroes' },
-- { href: '/leaderboard.html', label: 'Leaderboard' },
++ { href: '/leaderboards', label: 'Leaderboards' },
++ { href: '/achievements', label: 'Achievements' },
++ { href: '/developers', label: 'Game developers' },
{ href: '/worlds', label: 'Creator worlds' },
{ href: '/steam', label: 'On Steam right now' },
{ href: '/editor', label: 'Map editor' },
@@@ -993,11 +995,18 @@ export function createHttpServer(
res.end()
return
}
-- if (url === '/play/' || url === '/editor/') {
++ if (url === '/play/' || url === '/editor/' || url === '/achievements/' || url === '/leaderboards/' || url === '/developers/') {
res.writeHead(301, { location: url.slice(0, -1) })
res.end()
return
}
++ // The old board page became Leaderboards: a link from a round, a share or a bookmark keeps its game and period.
++ if (url === '/leaderboard.html') {
++ const tail = (req.url ?? '').slice(url.length)
++ res.writeHead(301, { location: `/leaderboards${tail}` })
++ res.end()
++ return
++ }
const pageAlias =
url === '/'
? playHost
@@@ -1009,9 +1018,15 @@ export function createHttpServer(
? 'editor.html'
: url === '/worlds'
? 'worlds.html'
-- : url === '/surf/embed' || surfRoute?.page
-- ? 'play.html'
-- : null
++ : url === '/achievements'
++ ? 'achievements.html'
++ : url === '/leaderboards'
++ ? 'leaderboards.html'
++ : url === '/developers'
++ ? 'developers.html'
++ : url === '/surf/embed' || surfRoute?.page
++ ? 'play.html'
++ : null
// Static files with path traversal guard.
const safePath = normalize(pageAlias ?? url).replace(/^(\.\.[/\\])+/, '')
ddiff --git a/apps/server/src/net/playRoutes.test.ts b/apps/server/src/net/playRoutes.test.ts
nnew file mode 100644
iindex 0000000..385d9de
---- /dev/null
++++ b/apps/server/src/net/playRoutes.test.ts
@@@ -0,0 +1,165 @@
++import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
++import type { AddressInfo } from 'node:net'
++import { afterAll, beforeAll, describe, expect, it } from 'vitest'
++import { createPlayProxy } from './playRoutes.js'
++
++/** A stand-in for Play: records every call and answers the public reads the pages use. */
++function playServer(): {
++ server: Server
++ calls: { url: string; headers: IncomingMessage['headers'] }[]
++} {
++ const calls: { url: string; headers: IncomingMessage['headers'] }[] = []
++ const server = createServer((req, res) => {
++ calls.push({ url: req.url ?? '', headers: req.headers })
++ const json = (status: number, body: unknown): void => {
++ res.writeHead(status, { 'content-type': 'application/json' })
++ res.end(JSON.stringify(body))
++ }
++ if (req.url === '/api/v1/titles') {
++ return json(200, {
++ titles: [
++ {
++ id: 'salvage-rush',
++ name: 'Salvage Rush',
++ boards: [{ id: 'best', name: 'Best round', kind: 'best' }],
++ achievements: [],
++ },
++ ],
++ })
++ }
++ if (req.url === '/api/v1/me/achievements') {
++ if (!/^Bearer .+/.test(String(req.headers.authorization ?? '')))
++ return json(401, { code: 'token.required' })
++ return json(200, {
++ titles: [
++ {
++ title: 'salvage-rush',
++ name: 'Salvage Rush',
++ achievements: [{ id: 'first', name: 'First' }],
++ },
++ ],
++ })
++ }
++ if (req.url?.startsWith('/api/v1/titles/salvage-rush/boards/best?')) {
++ return json(200, {
++ title: 'salvage-rush',
++ title_name: 'Salvage Rush',
++ board: 'best',
++ name: 'Best round',
++ kind: 'best',
++ period: 'week',
++ page: 1,
++ pages: 2,
++ total: 30,
++ entries: [{ rank: 1, name: 'Kim', score: 9, signed_in: true }],
++ })
++ }
++ return json(404, { code: 'route.not_found' })
++ })
++ return { server, calls }
++}
++
++let play: ReturnType<typeof playServer>
++let proxy: Server
++let playPort = 0
++let proxyPort = 0
++
++function listen(server: Server): Promise<number> {
++ return new Promise((resolve) =>
++ server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port)),
++ )
++}
++
++function request(
++ path: string,
++ headers: Record<string, string> = {},
++): Promise<{
++ status: number
++ headers: Record<string, string | string[] | undefined>
++ text: string
++}> {
++ return new Promise((resolve, reject) => {
++ fetch(`http://127.0.0.1:${proxyPort}${path}`, { headers, redirect: 'manual' })
++ .then(async (r) =>
++ resolve({ status: r.status, headers: Object.fromEntries(r.headers), text: await r.text() }),
++ )
++ .catch(reject)
++ })
++}
++
++beforeAll(async () => {
++ play = playServer()
++ playPort = await listen(play.server)
++ let handle: ((req: IncomingMessage, res: ServerResponse) => boolean) | null = null
++ proxy = createServer((req, res) => {
++ if (handle?.(req, res)) return
++ res.writeHead(404)
++ res.end()
++ })
++ handle = createPlayProxy({
++ url: `http://127.0.0.1:${playPort}`,
++ address: () => '203.0.113.7',
++ now: (() => {
++ let t = 1_000
++ return () => (t += 1_000)
++ })(),
++ })
++ proxyPort = await listen(proxy)
++})
++
++afterAll(() => {
++ play.server.close()
++ proxy.close()
++})
++
++describe('the Games read bridge to Play', () => {
++ it('passes the public titles through and keeps them for a moment', async () => {
++ const a = await request('/api/v1/play/titles')
++ expect(a.status).toBe(200)
++ expect(JSON.parse(a.text).titles[0].id).toBe('salvage-rush')
++ expect(a.headers['cache-control']).toContain('max-age=15')
++ await request('/api/v1/play/titles')
++ expect(play.calls.filter((c) => c.url === '/api/v1/titles')).toHaveLength(1)
++ expect(play.calls[0]?.headers['x-forwarded-for']).toBe('203.0.113.7')
++ })
++
++ it('forwards a board page and its period, and refuses a bad query', async () => {
++ const r = await request('/api/v1/play/titles/salvage-rush/boards/best?period=week&page=1')
++ expect(r.status).toBe(200)
++ expect(JSON.parse(r.text).entries[0].name).toBe('Kim')
++ expect(
++ play.calls.some(
++ (c) => c.url === '/api/v1/titles/salvage-rush/boards/best?period=week&page=1',
++ ),
++ ).toBe(true)
++ expect(
++ (await request('/api/v1/play/titles/salvage-rush/boards/best?period=month')).status,
++ ).toBe(400)
++ expect((await request('/api/v1/play/titles/..%2Fetc/boards/best')).status).toBe(404)
++ })
++
++ it("answers the person's own achievements from the ovg_sso session, and signedIn:false without one", async () => {
++ const anon = await request('/api/v1/play/achievements')
++ expect(anon.status).toBe(200)
++ expect(JSON.parse(anon.text)).toEqual({ signedIn: false })
++ const r = await request('/api/v1/play/achievements', { cookie: 'ovg_sso=person-token' })
++ expect(r.status).toBe(200)
++ expect(JSON.parse(r.text).titles[0].achievements[0].name).toBe('First')
++ expect(r.headers['cache-control']).toBe('private, no-store')
++ expect(play.calls.find((c) => c.url === '/api/v1/me/achievements')?.headers.authorization).toBe(
++ 'Bearer person-token',
++ )
++ })
++
++ it('says 503, never a page, when Play is not configured or down', async () => {
++ const off = createServer((req, res) => {
++ const handled = createPlayProxy({ url: null, address: () => '127.0.0.1' })(req, res)
++ if (!handled) res.writeHead(404).end()
++ })
++ const port = await listen(off)
++ const r = await fetch(`http://127.0.0.1:${port}/api/v1/play/titles`)
++ expect(r.status).toBe(503)
++ expect(await r.json()).toEqual({ error: 'play_unavailable' })
++ off.close()
++ })
++})
ddiff --git a/apps/server/src/net/playRoutes.ts b/apps/server/src/net/playRoutes.ts
nnew file mode 100644
iindex 0000000..52f7c35
---- /dev/null
++++ b/apps/server/src/net/playRoutes.ts
@@@ -0,0 +1,198 @@
++/**
++ * The Play reads the new OpenVibe.Games pages use (Achievements, Leaderboards, Developers).
++ *
++ * The pages live here now; the service and its API stay at play.openvibe.network. This module is
++ * Games' server-side bridge to Play's public reads, so the browser never talks to Play directly
++ * and the site keeps one origin for its own JSON:
++ *
++ * GET /api/v1/play/titles every game, its achievements and boards
++ * GET /api/v1/play/titles/:title/boards/:board?period=&page= one board, this week or all time
++ * GET /api/v1/play/achievements the signed-in person's unlocks
++ *
++ * Titles and boards are public and cached briefly. The person's unlocks forward the ovg_sso
++ * session (or a bearer token) to Play's /api/v1/me/achievements; no player id is ever put in a
++ * page URL, and the token is never logged.
++ */
++import type { IncomingMessage, ServerResponse } from 'node:http'
++import type { GamesActorLimits } from './actorLimits.js'
++import { SESSION_COOKIE, parseCookies } from './sso.js'
++
++const TITLE_RE = /^[a-z0-9][a-z0-9-]{1,39}$/
++const BOARD_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
++const TIMEOUT_MS = 5_000
++const TITLES_CACHE_MS = 20_000
++const BOARD_CACHE_MS = 15_000
++const CACHE_MAX = 200
++
++export interface PlayProxyOptions {
++ /** Play's base URL (GAMES_PLAY_URL, host-internal in production); null = the pages show no data. */
++ url: string | null
++ /** Per-address limits; absent in tests that do not need them. */
++ limits?: GamesActorLimits
++ /** The viewer's address, forwarded so Play's own per-address limits count the viewer. */
++ address: (req: IncomingMessage) => string
++ fetchImpl?: typeof fetch
++ now?: () => number
++ log?: { warn(message: string, meta?: Record<string, unknown>): void }
++}
++
++interface Cached {
++ at: number
++ body: string
++}
++
++function sendJson(
++ res: ServerResponse,
++ status: number,
++ body: string,
++ cache: string,
++ req: IncomingMessage,
++): void {
++ res.writeHead(status, {
++ 'content-type': 'application/json; charset=utf-8',
++ 'cache-control': cache,
++ })
++ res.end(req.method === 'HEAD' ? undefined : body)
++}
++
++/** The person's token: the browser's session cookie, or an Authorization header from a client. */
++function personToken(req: IncomingMessage): string | null {
++ const header = req.headers.authorization
++ const bearer = header ? /^Bearer\s+(.+)$/i.exec(header)?.[1] : null
++ if (bearer) return bearer
++ const cookie = parseCookies(req.headers.cookie ?? '')[SESSION_COOKIE]
++ return cookie || null
++}
++
++export function createPlayProxy(
++ opts: PlayProxyOptions,
++): (req: IncomingMessage, res: ServerResponse) => boolean {
++ const doFetch = opts.fetchImpl ?? fetch
++ const now = opts.now ?? Date.now
++ const titlesCache: { value: Cached | null } = { value: null }
++ const boardCache = new Map<string, Cached>()
++ const base = opts.url ? opts.url.replace(/\/+$/, '') : null
++
++ async function playGet(path: string, token?: string, address?: string): Promise<Response> {
++ if (!base) throw new Error('GAMES_PLAY_URL is not set')
++ return await doFetch(`${base}${path}`, {
++ headers: {
++ accept: 'application/json',
++ ...(token ? { authorization: `Bearer ${token}` } : {}),
++ ...(address ? { 'x-forwarded-for': address } : {}),
++ },
++ signal: AbortSignal.timeout(TIMEOUT_MS),
++ })
++ }
++
++ function cacheGet(res: ServerResponse, req: IncomingMessage, cached: Cached | null): boolean {
++ if (!cached) return false
++ sendJson(res, 200, cached.body, 'public, max-age=15, stale-while-revalidate=60', req)
++ return true
++ }
++
++ return function handle(req: IncomingMessage, res: ServerResponse): boolean {
++ if (req.method !== 'GET' && req.method !== 'HEAD') return false
++ const url = new URL(req.url ?? '/', 'http://games.local')
++ const titles = url.pathname === '/api/v1/play/titles'
++ const achievements = url.pathname === '/api/v1/play/achievements'
++ const board = /^\/api\/v1\/play\/titles\/([^/]+)\/boards\/([^/]+)$/.exec(url.pathname)
++ if (!titles && !achievements && !board) return false
++
++ const fail = (status: number, error: string, detail?: string): void => {
++ sendJson(
++ res,
++ status,
++ JSON.stringify({ error, ...(detail ? { detail } : {}) }),
++ 'no-store',
++ req,
++ )
++ }
++ const reader = opts.limits?.publicRead
++ if (reader) {
++ void reader(req, res, `ip:${opts.address(req)}`)
++ .then((ok) => {
++ if (ok) run()
++ })
++ .catch(() => fail(503, 'read_unavailable'))
++ } else run()
++
++ function run(): void {
++ void (async () => {
++ if (titles) {
++ if (
++ cacheGet(
++ res,
++ req,
++ titlesCache.value && now() - titlesCache.value.at < TITLES_CACHE_MS
++ ? titlesCache.value
++ : null,
++ )
++ )
++ return
++ const r = await playGet('/api/v1/titles', undefined, opts.address(req))
++ if (!r.ok) return fail(503, 'play_unavailable')
++ const body = await r.text()
++ titlesCache.value = { at: now(), body }
++ return sendJson(res, 200, body, 'public, max-age=15, stale-while-revalidate=60', req)
++ }
++ if (achievements) {
++ const token = personToken(req)
++ // No session (or a session Play no longer accepts) is a signed-out page, not an error: the page draws
++ // every game's achievements instead, and the browser console stays clean on a first visit.
++ if (!token)
++ return sendJson(res, 200, JSON.stringify({ signedIn: false }), 'private, no-store', req)
++ const r = await playGet('/api/v1/me/achievements', token, opts.address(req))
++ const body = await r.text()
++ if (r.status === 401)
++ return sendJson(res, 200, JSON.stringify({ signedIn: false }), 'private, no-store', req)
++ if (!r.ok) return fail(503, 'play_unavailable')
++ return sendJson(res, 200, body, 'private, no-store', req)
++ }
++ if (board) {
++ const title = decodeURIComponent(board[1] ?? '')
++ const boardId = decodeURIComponent(board[2] ?? '')
++ if (!TITLE_RE.test(title) || !BOARD_RE.test(boardId)) return fail(404, 'unknown_board')
++ const period = url.searchParams.get('period') ?? 'all'
++ const page = Number(url.searchParams.get('page') ?? '1')
++ if (
++ (period !== 'all' && period !== 'week') ||
++ !Number.isSafeInteger(page) ||
++ page < 1 ||
++ page > 10_000
++ ) {
++ return fail(400, 'invalid_query')
++ }
++ const key = `${title}\u0000${boardId}\u0000${period}\u0000${page}`
++ const hit = boardCache.get(key)
++ if (cacheGet(res, req, hit && now() - hit.at < BOARD_CACHE_MS ? hit : null)) return
++ const query = new URLSearchParams({ period, page: String(page) })
++ const r = await playGet(
++ `/api/v1/titles/${encodeURIComponent(title)}/boards/${encodeURIComponent(boardId)}?${query}`,
++ undefined,
++ opts.address(req),
++ )
++ const body = await r.text()
++ if (!r.ok)
++ return fail(
++ r.status === 404 ? 404 : 503,
++ r.status === 404 ? 'unknown_board' : 'play_unavailable',
++ )
++ boardCache.delete(key)
++ boardCache.set(key, { at: now(), body })
++ if (boardCache.size > CACHE_MAX)
++ boardCache.delete(boardCache.keys().next().value as string)
++ return sendJson(res, 200, body, 'public, max-age=15, stale-while-revalidate=60', req)
++ }
++ })().catch((err: unknown) => {
++ opts.log?.warn('play read unavailable', {
++ error: err instanceof Error ? err.message : String(err),
++ })
++ if (!res.headersSent) fail(503, 'play_unavailable')
++ })
++ }
++ return true
++ }
++}
++
++export { personToken }
ddiff --git a/apps/server/src/net/portalHome.ts b/apps/server/src/net/portalHome.ts
iindex 480c46c..39868e6 100644
---- a/apps/server/src/net/portalHome.ts
++++ b/apps/server/src/net/portalHome.ts
@@@ -30,7 +30,7 @@ const sharedServe = sharedRequire('openvibe-shared/serve') as SharedServe
const CACHE_MS = 60_000
const FIRST_WAIT_MS = 2_500
--const PLAY_SITE = 'https://play.openvibe.network'
++const ACHIEVEMENTS_SITE = 'https://openvibe.games'
export interface BoardRow {
rank: number
@@@ -245,7 +245,7 @@ function boardsSection(d: HomeData): string {
board: haulWeek ? 'haul points this week' : 'haul points, all time',
rows: haulWeek ? d.boards.haulWeek : d.boards.haulAll,
unit: (s) => `${n(s)} pts`,
-- href: '/leaderboard.html?game=scraplandia',
++ href: '/leaderboards?game=scraplandia',
more: 'Full haul board →',
}),
boardCard({
@@@ -253,7 +253,7 @@ function boardsSection(d: HomeData): string {
board: rushWeek ? 'best round this week' : 'best round, all time',
rows: rushWeek ? d.boards.rushWeek : d.boards.rushAll,
unit: (s) => plural(s, 'scrap', 'scrap'),
-- href: '/leaderboard.html?game=salvage-rush',
++ href: '/leaderboards?game=salvage-rush',
more: 'Full leaderboard →',
}),
boardCard({
@@@ -301,7 +301,7 @@ function achievementsSection(d: HomeData): string {
<p class="home-more">${d.titles
.map(
(t) =>
-- `<a class="card-link" href="${PLAY_SITE}/t/${encodeURIComponent(t.id)}">${esc(t.name)} on OpenVibe.Play →</a>`,
++ `<a class="card-link" href="${ACHIEVEMENTS_SITE}/achievements#game-${encodeURIComponent(t.id)}">${esc(t.name)} achievements →</a>`,
)
.join(' ')}</p>
</section>`,
ddiff --git a/apps/server/src/net/resultPage.ts b/apps/server/src/net/resultPage.ts
iindex 150108b..6806289 100644
---- a/apps/server/src/net/resultPage.ts
++++ b/apps/server/src/net/resultPage.ts
@@@ -123,7 +123,7 @@ export function resultPageHtml(input: ResultPageInput): string {
kit.section({
body:
`<p class="ovk-note">${kit.esc(GAME_PROMISE)}</p>` +
-- kit.button({ href: '/leaderboard.html?game=salvage-rush', label: 'Full leaderboard', kind: 'ghost', icon: 'trophy' }),
++ kit.button({ href: '/leaderboards?game=salvage-rush', label: 'Full leaderboard', kind: 'ghost', icon: 'trophy' }),
}),
].join('\n')
const html = kit.page({
@@@ -138,7 +138,7 @@ export function resultPageHtml(input: ResultPageInput): string {
service: 'games',
apiBase: 'https://openvibe.network',
links: [
-- { label: 'Leaderboard', href: '/leaderboard.html' },
++ { label: 'Leaderboard', href: '/leaderboards' },
{ label: 'Play Scraplandia', href: '/play' },
],
history: { type: 'page', title: 'A Salvage Rush round' },
ddiff --git a/apps/server/src/net/updatesPage.ts b/apps/server/src/net/updatesPage.ts
iindex 77c29d6..2edbb88 100644
---- a/apps/server/src/net/updatesPage.ts
++++ b/apps/server/src/net/updatesPage.ts
@@@ -31,7 +31,7 @@ export function updatesHandler(req: NodeRequire = createRequire(import.meta.url)
if (html) return html
const links = [
{ label: 'Play', href: '/play' },
-- { label: 'Leaderboard', href: '/leaderboard.html' },
++ { label: 'Leaderboards', href: '/leaderboards' },
]
html = shell.page({
name: SITE_NAME,
ddiff --git a/test/apps__client__src__pages__playPages.test.js b/test/apps__client__src__pages__playPages.test.js
nnew file mode 100644
iindex 0000000..82aa6d9
---- /dev/null
++++ b/test/apps__client__src__pages__playPages.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/client/src/pages/playPages.test.ts'
++// covers: 'apps/client/src/pages'
++// Generated by node test/run.js --index; do not edit.
ddiff --git a/test/apps__server__src__net__playRoutes.test.js b/test/apps__server__src__net__playRoutes.test.js
nnew file mode 100644
iindex 0000000..08a7e41
---- /dev/null
++++ b/test/apps__server__src__net__playRoutes.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/server/src/net/playRoutes.test.ts'
++// covers: 'apps/server/src/net'
++// Generated by node test/run.js --index; do not edit.