openvibers/openvibe.play Commits

Time boards: a board of kind time ranks each player's fastest run (the lowest whole milliseconds, at most 24 hours, ascending, ties to whoever got there first); boards answer their unit (ms or points) (#73)

OP OpenVibers committed 16 days ago · 23709533d3 · 8 files, +197 −29

Changes

ddiff --git a/README.md b/README.md
iindex 879f438..a9a4b3a 100644
---- a/README.md
++++ b/README.md
@@@ -16,8 +16,10 @@ its boards. The rules of each game stay in the game; Play keeps the record and a
project) registers the real name; it never takes a `sandbox-` id.
- **Achievements**, including hidden ones whose name shows only once unlocked. A definition left out of an update is
retired, never deleted: what players earned stays.
--- **Leaderboards** of two kinds: `best` ranks each player's best score, `sum` the total of theirs. Each reads this week
-- (from Monday 00:00 UTC) or all time, 25 a page. A tie goes to whoever got there first.
++- **Leaderboards** of three kinds. `best` ranks each player's best score. `sum` ranks the total of theirs. `time`
++ ranks each player's fastest run, in whole milliseconds (at most 24 hours), fastest first. Each reads this week (from
++ Monday 00:00 UTC) or all time, 25 a page, and a tie goes to whoever got there first. A board's `unit` says what its
++ scores mean: `"ms"` for `time`, `"points"` for the others.
- **Players** are people (`usr_…`) or guests a game names (`guest:<opaque>`). When a guest signs in, the game adopts
them and their unlocks and scores become the person's. When two OpenVibe accounts are merged (`network.subject.merged`),
the folded account's unlocks and scores become the survivor's in every title.
@@@ -82,7 +84,9 @@ exist. Reads of titles and boards are public.
Tested in `test/` (`npm test`, every file in its own process on PGlite; `npm run test:pg` on PostgreSQL):
`play-api.test.js` (titles, owner-only writes, idempotent scores, best and sum boards with the Monday week, guest
--adoption, secret achievements, retired definitions, a person's own achievements, the pages), `account-data.test.js`
++adoption, secret achievements, retired definitions, a person's own achievements, the pages), `time-boards.test.js`
++(time boards, their ties, validation and units), `time-migration.test.js` (the kind CHECK widening over existing
++boards), `account-data.test.js`
(export, erasure and account merges), the auth, limits, discovery, layout, open-redirect, nginx and security tests, and
the home page's size budget.
@@@ -105,11 +109,11 @@ Migrations run at boot.
| Route | Who | What |
|---|---|---|
| `GET /titles`, `GET /titles/:title` | anyone (CORS) | the games, their achievements (with how many players have each) and boards |
--| `GET /titles/:title/boards/:board?period=week\|all&page=` | anyone (CORS) | a board: `rank`, `name`, `score`, `signed_in` |
++| `GET /titles/:title/boards/:board?period=week\|all&page=` | anyone (CORS) | a board: `rank`, `name`, `score`, `signed_in`, `unit` (`ms` for a time board, `points` otherwise) |
| `GET /people/:subject/achievements` | anyone (CORS) | a person's unlocks in every game, `{ subject, total, titles }`: their profile on OpenVibe.Network and any site's showcase (only people, never a guest) |
| `GET /me/achievements` | a person | their unlocks in every game |
| `PUT /titles/:title` | `play.title.manage` | register or update a title: name, url, summary, achievements, boards |
--| `POST /titles/:title/scores` | `play.result.write` | `{ board, ref, entries: [{ player, name, score }] }`, up to 100 |
++| `POST /titles/:title/scores` | `play.result.write` | `{ board, ref, entries: [{ player, name, score }] }`, up to 100; a `time` board's score is whole milliseconds from 0 to 86400000 |
| `POST /titles/:title/unlocks` | `play.result.write` | `{ player, achievements: [id] }` → the new ones |
| `POST /titles/:title/players/:guest/adopt` | `play.result.write` | `{ person: "usr_…" }`: a guest signed in |
| `GET /titles/:title/players/:player/achievements` | `play.player.read` | a player's unlocks in your title |
ddiff --git a/STATUS.json b/STATUS.json
iindex 140b80e..7de4ee1 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -15,14 +15,14 @@
"openvibe-sdk v0.67.0",
"openvibe-shared v4.30.0"
],
-- "summary": "OpenVibe.Play — achievements and leaderboards for every OpenVibe game (Games ADR-0007 decision 10). A title is registered by its own principal (a game's service or app token), which alone writes to it: achievements (with hidden ones), boards that rank each player's best score or the sum of theirs, this week and all time, idempotent score and unlock posts, and guests adopted by the person who signs in. Boards and titles are public reads (CORS) and pages; people's rows are in the account export and erased with the account.",
++ "summary": "OpenVibe.Play — achievements and leaderboards for every OpenVibe game (Games ADR-0007 decision 10). A title is registered by its own principal (a game's service or app token), which alone writes to it: achievements (with hidden ones), boards that rank each player's best score, the sum of theirs or their fastest time in milliseconds, this week and all time, idempotent score and unlock posts, and guests adopted by the person who signs in. Boards and titles are public reads (CORS) and pages; people's rows are in the account export and erased with the account.",
"works": [
"Titles registered and updated by their own principal (play.title.manage); a definition left out is retired, never deleted; a board never changes kind",
"Score posts to a board, idempotent by ref (up to 100 entries), and unlocks that answer the new ones (play.result.write)",
"Guest adoption: a guest's unlocks and scores become the person's when they sign in",
"Account merges (network.subject.merged): the folded account's unlocks, scores and saves become the survivor's in every title",
"Cloud saves: up to 8 versioned slots per player per title (a stale write is refused), listed and deleted by the person at /me/saves",
-- "Boards: each player's best or total, this week (from Monday UTC) or all time, 25 a page, names and scores only",
++ "Boards: each player's best, total or fastest time (milliseconds, ascending), this week (from Monday UTC) or all time, 25 a page, names and scores only",
"Pages: home (every game, this week's leaders), a title, a board, your achievements, the API docs, /updates",
"Account export and deletion (ADR-033) at the loopback POST /internal/events, subscriptions created at boot"
],
@@@ -39,7 +39,7 @@
"features": [
"titles",
"achievements",
-- "leaderboards (best, sum; week, all time)",
++ "leaderboards (best, sum, time; week, all time)",
"idempotent results",
"guest adoption",
"account merges",
ddiff --git a/migrations/0005_time_boards.sql b/migrations/0005_time_boards.sql
nnew file mode 100644
iindex 0000000..9267580
---- /dev/null
++++ b/migrations/0005_time_boards.sql
@@@ -0,0 +1,6 @@
++-- phase: expand
++-- A time board ranks a player's fastest run: the lowest score in whole milliseconds (at most 24 hours), ascending.
++-- Only the kind CHECK widens; scores, refs and the rest of the schema stay as they are. PostgreSQL named 0001's
++-- inline CHECK on play_boards.kind play_boards_kind_check.
++ALTER TABLE play_boards DROP CONSTRAINT IF EXISTS play_boards_kind_check;
++ALTER TABLE play_boards ADD CONSTRAINT play_boards_kind_check CHECK (kind IN ('best', 'sum', 'time'));
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex b1873c4..5f718ba 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -105,7 +105,7 @@ function createDiscoveryRoutes(ctx) {
pages: [
{ title: `${t.name}: achievements and leaderboards`, url: `/t/${t.id}`, text: t.summary || `${plural(t.achievements.length, 'achievement', 'achievements')}` },
...t.achievements.filter((a) => !a.hidden).map((a) => ({ title: a.name, url: `/t/${t.id}`, text: a.description || 'An achievement' })),
-- ...t.boards.map((b) => ({ title: b.name, url: boardPath(t.id, b.id, 'all'), text: b.kind === 'sum' ? 'Each player\'s total' : 'Each player\'s best score' })),
++ ...t.boards.map((b) => ({ title: b.name, url: boardPath(t.id, b.id, 'all'), text: b.kind === 'sum' ? 'Each player\'s total' : b.kind === 'time' ? 'Each player\'s fastest time in milliseconds' : 'Each player\'s best score' })),
],
}));
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex c7674f1..38ae1c6 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -30,7 +30,7 @@ const pct = (part, whole) => (whole > 0 ? `${Math.round((part / whole) * 100)}%`
/** The home's questions: shown on the page and given to search engines as FAQPage data (the same text). */
const HOME_FAQ = [
{ q: 'Do players need an account?', a: 'No. A guest plays at once and appears on the boards as a guest. When they sign in with OpenVibe, the game moves their guest achievements and scores to their account.' },
-- { q: 'What does a leaderboard show?', a: "Rank, name and score, never a player id. Each board keeps a player's best score or their total, this week (from Monday, UTC) and all time, and anyone can read it as public JSON." },
++ { q: 'What does a leaderboard show?', a: "Rank, name and score, never a player id. Each board keeps a player's best score, their total, or their fastest time, this week (from Monday, UTC) and all time, and anyone can read it as public JSON." },
{ q: 'How do cloud saves avoid overwriting each other?', a: `A player has up to ${MAX_SLOTS} save slots per game, ${MAX_SAVE_BYTES / 1024} KB each. Every write names the version it read; if another device saved in between, the write is refused with play.save_conflict, so the game can merge instead of losing progress.` },
{ q: 'What happens when a game changes its achievements?', a: 'An achievement or board the game stops listing is retired, never deleted: what players earned stays on their profile.' },
{ q: 'How does a game use Play?', a: "Its server calls Play's API (or the openvibe-sdk/play client) with an OpenVibe token granted Play's capabilities. The app that registers a title is its only writer; posting the same round twice changes nothing." },
@@@ -44,8 +44,8 @@ function homeFaqJsonLd() {
function boardTable(b, { compact = false } = {}) {
if (!b.entries.length) return html`<p class="pl-empty">${b.period === 'week' ? 'Nobody has scored this week yet.' : 'Nobody has scored yet.'}</p>`;
return html`<table class="pl-board${compact ? ' pl-board-compact' : ''}"><caption class="pl-sr">${b.name}, ${b.period === 'week' ? 'this week' : 'all time'}</caption>
--<thead><tr><th scope="col">#</th><th scope="col">Player</th><th scope="col" class="pl-n">${b.kind === 'sum' ? 'Total' : 'Best'}</th></tr></thead>
--<tbody>${b.entries.map((e) => html`<tr><td class="pl-rank">${String(e.rank)}</td><td>${e.name}${e.signed_in ? '' : raw(' <span class="pl-guest" title="Playing as a guest">guest</span>')}</td><td class="pl-n">${num(e.score)}</td></tr>`)}</tbody></table>`;
++<thead><tr><th scope="col">#</th><th scope="col">Player</th><th scope="col" class="pl-n">${b.kind === 'sum' ? 'Total' : b.kind === 'time' ? 'Time' : 'Best'}</th></tr></thead>
++<tbody>${b.entries.map((e) => html`<tr><td class="pl-rank">${String(e.rank)}</td><td>${e.name}${e.signed_in ? '' : raw(' <span class="pl-guest" title="Playing as a guest">guest</span>')}</td><td class="pl-n">${num(e.score)}${b.kind === 'time' ? ' ms' : ''}</td></tr>`)}</tbody></table>`;
}
function achievementList(list, { players = 0 } = {}) {
@@@ -111,7 +111,7 @@ ${raw(showcase.features({
lede: 'Four services, one OpenVibe account for every player, and nothing to host yourself.',
items: [
{ title: 'Achievements', text: 'Define them once; unlocks are idempotent, secret ones show only as "A secret achievement", and retired ones stay on every profile.' },
-- { title: 'Leaderboards', text: `Best score or total, this week and all time, ${PER_PAGE} to a page, readable by any site as public JSON.` },
++ { title: 'Leaderboards', text: `Best score, total or fastest time, this week and all time, ${PER_PAGE} to a page, readable by any site as public JSON.` },
{ title: 'Cloud saves', text: `${MAX_SLOTS} slots per player per game, ${MAX_SAVE_BYTES / 1024} KB each, with a version check so two devices never overwrite each other.`, href: '/docs' },
{ title: 'Guests and accounts', text: 'Guests play at once; when they sign in, their unlocks and scores move to their account, and merged OpenVibe accounts keep both histories.' },
],
@@@ -121,7 +121,7 @@ ${raw(showcase.steps({
items: [
{ title: 'Register the title', text: 'A game\'s server registers its title with its own OpenVibe token: its achievements and its boards. Yours starts in the sandbox.', href: '/docs#sandbox' },
{ title: 'Post what happened', text: 'Scores for each round and the achievements a player unlocked, each idempotent, so a retry never counts twice.' },
-- { title: 'Read the boards anywhere', text: 'Boards are public JSON for any page: a best score or a total, this week or all time.' },
++ { title: 'Read the boards anywhere', text: 'Boards are public JSON for any page: a best score, a total or a fastest time, this week or all time.' },
{ title: 'Guests count', text: 'A guest plays at once; when they sign in, the game moves their progress to their account.', href: '/docs' },
],
}))}
@@@ -204,10 +204,10 @@ ${boards.length ? html`<section aria-labelledby="pl-boards-h"><h2 id="pl-boards-
page(req, res, {
canonicalPath: link({ page: pageNo }),
index: !isSandbox(b.title), cache: PUBLIC_CACHE, title: `${b.name}, ${period === 'week' ? 'this week' : 'all time'} · ${b.title_name}`,
-- description: `The ${b.name} leaderboard of ${b.title_name}, ${period === 'week' ? 'this week' : 'all time'}: ${b.kind === 'sum' ? 'each player\'s total' : 'each player\'s best score'}.`,
++ description: `The ${b.name} leaderboard of ${b.title_name}, ${period === 'week' ? 'this week' : 'all time'}: ${b.kind === 'sum' ? 'each player\'s total' : b.kind === 'time' ? 'each player\'s fastest time' : 'each player\'s best score'}.`,
body: html`<p class="pl-crumbs"><a href="/">Games</a> / <a href="/t/${b.title}">${b.title_name}</a> /</p>
<h1>${b.name}</h1>
--<p class="pl-muted">${b.kind === 'sum' ? 'Each player\'s total' : 'Each player\'s best score'}${period === 'week' ? `, since Monday ${String(b.since || '').slice(0, 10)} (UTC)` : ', all time'}. ${plural(b.total, 'player', 'players')}.</p>
++<p class="pl-muted">${b.kind === 'sum' ? 'Each player\'s total' : b.kind === 'time' ? 'Each player\'s fastest time' : 'Each player\'s best score'}${period === 'week' ? `, since Monday ${String(b.since || '').slice(0, 10)} (UTC)` : ', all time'}. ${plural(b.total, 'player', 'players')}.</p>
<nav class="pl-tabs" aria-label="Period">${period === 'week' ? html`<span aria-current="page">This week</span>` : html`<a href="${link({ period: 'week' })}">This week</a>`}${period === 'all' ? html`<span aria-current="page">All time</span>` : html`<a href="${link({ period: 'all' })}">All time</a>`}</nav>
${boardTable(b)}
${b.pages > 1 ? html`<nav class="pl-pages" aria-label="Pages">${pageNo > 1 ? html`<a href="${link({ page: pageNo - 1 })}" rel="prev">Previous</a>` : ''}<span>Page ${String(pageNo)} of ${String(b.pages)}</span>${pageNo < b.pages ? html`<a href="${link({ page: pageNo + 1 })}" rel="next">Next</a>` : ''}</nav>` : ''}`,
@@@ -241,15 +241,15 @@ ${saves.length ? html`<section><h2>Your saves</h2><p class="pl-muted">What games
<ul>
<li><code>GET /titles</code>: every game, with its achievements (how many players have each) and its 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>, and whether the player was signed in. Never a player id.</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>, and whether the player was signed in; <code>unit</code> is <code>ms</code> on a time board and <code>points</code> otherwise. Never a player id.</li>
<li><code>GET /people/:subject/achievements</code>: anyone, from any site: a person's unlocks in every game (what their profile shows).</li>
<li><code>GET /me/achievements</code>: with a person's Network token, their own unlocks in every game.</li>
</ul>
<h2>Writing (the game's own token)</h2>
<p>A game's server uses a Network service or app token for audience <code>openvibe.play</code>. Only the principal that registered a title writes to it; to anyone else the title does not exist.</p>
<ul>
--<li><code>PUT /titles/:title</code> (<code>play.title.manage</code>): <code>{ name, url, summary, achievements: [{ id, name, description, hidden }], boards: [{ id, name, kind: "best" | "sum" }] }</code>. A definition left out is retired, never deleted: what players earned stays. A board's kind never changes.</li>
--<li><code>POST /titles/:title/scores</code> (<code>play.result.write</code>): <code>{ board, ref, entries: [{ player, name, score }] }</code>, up to 100 entries. <code>ref</code> names the round or run; posting it again changes nothing.</li>
++<li><code>PUT /titles/:title</code> (<code>play.title.manage</code>): <code>{ name, url, summary, achievements: [{ id, name, description, hidden }], boards: [{ id, name, kind: "best" | "sum" | "time" }] }</code>. A definition left out is retired, never deleted: what players earned stays. A board's kind never changes.</li>
++<li><code>POST /titles/:title/scores</code> (<code>play.result.write</code>): <code>{ board, ref, entries: [{ player, name, score }] }</code>, up to 100 entries. <code>ref</code> names the round or run; posting it again changes nothing. A <code>time</code> board's score is whole milliseconds from 0 to 86400000.</li>
<li><code>POST /titles/:title/unlocks</code> (<code>play.result.write</code>): <code>{ player, achievements: [id] }</code>; answers the ones that were new.</li>
<li><code>POST /titles/:title/players/:guest/adopt</code> (<code>play.result.write</code>): <code>{ person: "usr_…" }</code> when a guest signs in: their unlocks and scores become the person's.</li>
<li><code>GET /titles/:title/players/:player/achievements</code> (<code>play.player.read</code>): a player's unlocks in your game.</li>
ddiff --git a/server/play/store.js b/server/play/store.js
iindex 5a5cb1c..75151aa 100644
---- a/server/play/store.js
++++ b/server/play/store.js
@@@ -34,10 +34,12 @@ const DEF_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
const PER_PAGE = 25;
const MAX_ENTRIES = 100;
const MAX_DEFS = 200;
++const MAX_TIME_MS = 86_400_000;
const EARLIEST = Date.UTC(2020, 0, 1);
const SANDBOX_PREFIX = 'sandbox-';
const MAX_SANDBOX_TITLES = 3;
const isSandbox = (titleId) => String(titleId || '').startsWith(SANDBOX_PREFIX);
++const unitOf = (kind) => (kind === 'time' ? 'ms' : 'points');
class PlayError extends Error {
constructor(status, code, detail) { super(detail || code); this.status = status; this.code = code; this.detail = detail; }
@@@ -100,7 +102,7 @@ function createPlay({ s }) {
if (url !== null && !/^https:\/\/[^\s]{3,300}$/.test(url)) fail(400, 'play.bad_title', 'url is an https address');
const achievements = defsOf(doc.achievements, 'achievements', (d) => ({ description: str(d.description, 300), hidden: d.hidden === true }));
const boards = defsOf(doc.boards, 'boards', (d, i) => {
-- if (!['best', 'sum'].includes(d.kind)) fail(400, 'play.bad_definitions', `boards[${i}].kind is best or sum`);
++ if (!['best', 'sum', 'time'].includes(d.kind)) fail(400, 'play.bad_definitions', `boards[${i}].kind is best, sum or time`);
return { kind: d.kind };
});
const now = s.now();
@@@ -153,7 +155,11 @@ function createPlay({ s }) {
const at = when(post.at);
const rows = entries.map((e, i) => {
const score = e && e.score;
-- if (!Number.isSafeInteger(score) || score < 0) fail(400, 'play.bad_scores', `entries[${i}].score is a whole number, 0 or more`);
++ if (!Number.isSafeInteger(score) || score < 0 || (board.kind === 'time' && score > MAX_TIME_MS)) {
++ fail(400, 'play.bad_scores', board.kind === 'time'
++ ? `entries[${i}].score is a whole number of milliseconds, 0 to ${MAX_TIME_MS}`
++ : `entries[${i}].score is a whole number, 0 or more`);
++ }
const name = str(e.name, 40);
if (!name) fail(400, 'play.bad_scores', `entries[${i}].name is the name to show`);
return [playerIn(titleId, e.player), name, score];
@@@ -240,21 +246,28 @@ function createPlay({ s }) {
if (!['all', 'week'].includes(period)) fail(400, 'play.bad_query', 'period is all or week');
if (!Number.isSafeInteger(page) || page < 1 || page > 10_000) fail(400, 'play.bad_query', 'page is 1 or more');
const since = period === 'week' ? weekStart(s.now()) : 0;
-- // A player's best score (best) or the sum of theirs (sum) in the period. A tie goes to whoever got there
-- // first: the earliest post of a best score, the earlier last post of a sum. The name shown is the newest one.
++ // A player's best score (best), the sum of theirs (sum), or their fastest time (time) in the period. A tie
++ // goes to whoever got there first: the earliest post of a best score or fastest time, the earlier last post of
++ // a sum (and, for a time at the same `at`, the earlier submission). The name shown is the newest one.
const ranked = b.kind === 'sum'
? 'SELECT player, SUM(score)::bigint AS score, MAX(at) AS reached FROM s GROUP BY player'
-- : 'SELECT player, MAX(score)::bigint AS score, NULL::bigint AS reached FROM s GROUP BY player';
++ : b.kind === 'time'
++ ? 'SELECT player, MIN(score)::bigint AS score, NULL::bigint AS reached FROM s GROUP BY player'
++ : 'SELECT player, MAX(score)::bigint AS score, NULL::bigint AS reached FROM s GROUP BY player';
++ const order = b.kind === 'time' ? 'ASC' : 'DESC';
++ const timeTie = b.kind === 'time'
++ ? ', (SELECT MIN(seq) FROM s WHERE s.player = r0.player AND s.score = r0.score) AS reached_seq'
++ : '';
const rows = await db.many(`WITH s AS (SELECT player, player_name, score, at, seq FROM play_scores WHERE title_id = $1 AND board_id = $2 AND at >= $3),
r0 AS (${ranked}),
r AS (SELECT r0.player, r0.score,
-- COALESCE(r0.reached, (SELECT MIN(at) FROM s WHERE s.player = r0.player AND s.score = r0.score)) AS reached FROM r0)
++ COALESCE(r0.reached, (SELECT MIN(at) FROM s WHERE s.player = r0.player AND s.score = r0.score)) AS reached${timeTie} FROM r0)
SELECT r.player, r.score, r.reached, (SELECT player_name FROM s WHERE s.player = r.player ORDER BY at DESC, seq DESC LIMIT 1) AS name,
COUNT(*) OVER () AS total
-- FROM r ORDER BY r.score DESC, r.reached ASC, r.player ASC LIMIT $4 OFFSET $5`, [b.title_id, b.id, since, PER_PAGE, (page - 1) * PER_PAGE]);
++ FROM r ORDER BY r.score ${order}, r.reached ASC${b.kind === 'time' ? ', r.reached_seq ASC' : ''}, r.player ASC LIMIT $4 OFFSET $5`, [b.title_id, b.id, since, PER_PAGE, (page - 1) * PER_PAGE]);
const total = rows.length ? Number(rows[0].total) : 0;
return {
-- title: b.title_id, title_name: b.title_name, board: b.id, name: b.name, kind: b.kind, period, page,
++ title: b.title_id, title_name: b.title_name, board: b.id, name: b.name, kind: b.kind, unit: unitOf(b.kind), period, page,
pages: Math.max(1, Math.ceil(total / PER_PAGE)), total, ...(period === 'week' ? { since: new Date(since).toISOString() } : {}),
entries: rows.map((r, i) => ({ rank: (page - 1) * PER_PAGE + i + 1, name: r.name, score: Number(r.score), signed_in: r.player.startsWith('usr_') })),
};
@@@ -267,7 +280,8 @@ function createPlay({ s }) {
const achievements = await db.many(`SELECT a.id, a.name, a.description, a.hidden, COUNT(u.player)::int AS unlocked_by
FROM play_achievements a LEFT JOIN play_unlocks u ON u.title_id = a.title_id AND u.achievement_id = a.id
WHERE a.title_id = $1 AND NOT a.retired GROUP BY a.title_id, a.id ORDER BY a.position, a.id`, [t.id]);
-- const boards = await db.many('SELECT id, name, kind FROM play_boards WHERE title_id = $1 AND NOT retired ORDER BY position, id', [t.id]);
++ const boards = (await db.many('SELECT id, name, kind FROM play_boards WHERE title_id = $1 AND NOT retired ORDER BY position, id', [t.id]))
++ .map((b) => ({ ...b, unit: unitOf(b.kind) }));
return {
id: t.id, name: t.name, url: t.url, summary: t.summary, players, ...(isSandbox(t.id) ? { sandbox: true } : {}),
achievements: achievements.map((a) => (a.hidden
@@@ -355,4 +369,4 @@ function createPlay({ s }) {
putSave, getSave, listSaves, deleteSave, mySaves, deleteMySave };
}
--module.exports = { createPlay, PlayError, PLAYER_RE, TITLE_RE, SLOT_RE, MAX_SLOTS, MAX_SAVE_BYTES, MAX_SANDBOX_TITLES, SANDBOX_PREFIX, isSandbox, weekStart, PER_PAGE };
++module.exports = { createPlay, PlayError, PLAYER_RE, TITLE_RE, SLOT_RE, MAX_SLOTS, MAX_SAVE_BYTES, MAX_TIME_MS, MAX_SANDBOX_TITLES, SANDBOX_PREFIX, isSandbox, weekStart, PER_PAGE };
ddiff --git a/test/time-boards.test.js b/test/time-boards.test.js
nnew file mode 100644
iindex 0000000..109a70b
---- /dev/null
++++ b/test/time-boards.test.js
@@@ -0,0 +1,104 @@
++'use strict';
++// OpenVibe.Play's time boards: a score is whole milliseconds, a player's entry is their fastest run, and the board
++// ranks it ascending. best and sum keep their MAX/DESC and SUM/DESC behaviour, and every board says its unit.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const P1 = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0';
++const P2 = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ1';
++const P3 = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ2';
++const DAY = 86_400_000;
++
++(async () => {
++ let clock = Date.UTC(2026, 9, 14, 12); // a Wednesday
++ const t = await boot({ now: () => clock });
++ try {
++ const games = { bearer: t.network.serviceToken('surf', ['play.title.manage', 'play.result.write', 'play.player.read']) };
++ const define = (body) => t.get('/api/v1/titles/surf-ride', { ...games, method: 'PUT', json: body });
++ const post = (body) => t.get('/api/v1/titles/surf-ride/scores', { ...games, json: body });
++ const board = async (id, period = 'all') => (await t.get(`/api/v1/titles/surf-ride/boards/${id}?period=${period}`)).json();
++ const DOC = {
++ name: 'Surf Ride', url: 'https://openvibe.games/play?game=surf-ride', summary: 'Browser surf runs.',
++ boards: [
++ { id: 'best', name: 'Best score', kind: 'best' },
++ { id: 'haul', name: 'Total haul', kind: 'sum' },
++ { id: 'time', name: 'Fastest run', kind: 'time' },
++ ],
++ };
++
++ await check('a game defines a time board; its public answers say milliseconds, the others points', async () => {
++ const created = await define(DOC);
++ assert.strictEqual(created.status, 201, created.text);
++ assert.deepStrictEqual(created.json().title.boards.map((b) => [b.id, b.kind, b.unit]),
++ [['best', 'best', 'points'], ['haul', 'sum', 'points'], ['time', 'time', 'ms']]);
++ assert.deepStrictEqual([(await board('time')).kind, (await board('time')).unit], ['time', 'ms']);
++ assert.strictEqual((await board('best')).unit, 'points');
++ assert.strictEqual((await board('haul')).unit, 'points');
++ const bad = await define({ ...DOC, boards: [...DOC.boards, { id: 'bad', name: 'Bad', kind: 'lowest' }] });
++ assert.deepStrictEqual([bad.status, bad.json().code], [400, 'play.bad_definitions']);
++ });
++
++ await check('a time board keeps each player\'s lowest score and ranks fastest first', async () => {
++ clock += 60_000;
++ await post({ board: 'time', ref: 'run-1', entries: [{ player: P1, name: 'Ada', score: 61_234 }] });
++ clock += 60_000;
++ await post({ board: 'time', ref: 'run-2', entries: [{ player: P1, name: 'Ada L.', score: 59_870 }] });
++ clock += 60_000;
++ await post({ board: 'time', ref: 'run-3', entries: [{ player: P1, name: 'Ada L.', score: 60_001 }, { player: P2, name: 'Bo', score: 59_000 }] });
++ assert.deepStrictEqual((await board('time')).entries, [
++ { rank: 1, name: 'Bo', score: 59_000, signed_in: true },
++ { rank: 2, name: 'Ada L.', score: 59_870, signed_in: true },
++ ], 'P1 keeps 59 870, not the later 60 001; the board is ascending');
++ });
++
++ await check('a time tie goes to the earlier submission', async () => {
++ clock += 60_000;
++ // One post has one `at`: the order here is the submission order. P1 and P2 have smaller player ids than P3,
++ // so a player-id tie-break would rank them first; the earliest row must win instead.
++ await post({ board: 'time', ref: 'tie', entries: [
++ { player: P3, name: 'Cy', score: 55_000 },
++ { player: P2, name: 'Bo', score: 55_000 },
++ { player: P1, name: 'Ada L.', score: 55_000 },
++ ] });
++ assert.deepStrictEqual((await board('time')).entries.map((e) => [e.name, e.score]),
++ [['Cy', 55_000], ['Bo', 55_000], ['Ada L.', 55_000]]);
++ });
++
++ await check('a repeated round changes nothing, even with a faster time', async () => {
++ const repeated = await post({ board: 'time', ref: 'run-2', entries: [{ player: P1, name: 'Ada', score: 1 }] });
++ assert.deepStrictEqual(repeated.json(), { recorded: 0, repeated: 1 });
++ assert.strictEqual((await board('time')).entries.find((e) => e.name === 'Ada L.').score, 55_000);
++ });
++
++ await check('a time score is whole milliseconds from 0 to 24 hours', async () => {
++ for (const score of [-1, 1.5, DAY + 1, Number.MAX_SAFE_INTEGER + 1]) {
++ const refused = await post({ board: 'time', ref: `bad-${String(score)}`, entries: [{ player: P1, name: 'Ada', score }] });
++ assert.strictEqual(refused.status, 400, `score ${score}`);
++ assert.strictEqual(refused.json().code, 'play.bad_scores');
++ assert.match(refused.headers.get('content-type'), /application\/problem\+json/);
++ }
++ assert.deepStrictEqual((await post({ board: 'time', ref: 'max', entries: [{ player: P2, name: 'Bo', score: DAY }] })).json(), { recorded: 1, repeated: 0 });
++ assert.deepStrictEqual((await post({ board: 'time', ref: 'zero', entries: [{ player: P2, name: 'Bo', score: 0 }] })).json(), { recorded: 1, repeated: 0 });
++ assert.strictEqual((await board('time')).entries[0].score, 0, 'the maximum is inclusive and zero is a time');
++ });
++
++ await check('a time board still reads this week or all time', async () => {
++ await post({ board: 'time', ref: 'old-run', at: clock - 8 * DAY, entries: [{ player: P1, name: 'Ada', score: 1_000 }] });
++ assert.strictEqual((await board('time')).entries[0].score, 0, 'all time keeps the lowest, including an old run');
++ assert.deepStrictEqual((await board('time', 'week')).entries.map((e) => [e.name, e.score]),
++ [['Bo', 0], ['Cy', 55_000], ['Ada L.', 55_000]], 'the old run is not this week');
++ });
++
++ await check('best and sum boards are unchanged', async () => {
++ clock += 60_000;
++ await post({ board: 'best', ref: 'b-1', entries: [{ player: P1, name: 'Ada', score: 300 }, { player: P2, name: 'Bo', score: 450 }] });
++ clock += 60_000;
++ await post({ board: 'best', ref: 'b-2', entries: [{ player: P1, name: 'Ada L.', score: 500 }, { player: P2, name: 'Bo', score: 100 }] });
++ await post({ board: 'haul', ref: 'h-1', entries: [{ player: P1, name: 'Ada L.', score: 20 }, { player: P2, name: 'Bo', score: 25 }] });
++ await post({ board: 'haul', ref: 'h-2', entries: [{ player: P1, name: 'Ada L.', score: 10 }] });
++ assert.deepStrictEqual((await board('best')).entries.map((e) => [e.name, e.score]), [['Ada L.', 500], ['Bo', 450]]);
++ assert.deepStrictEqual((await board('haul')).entries.map((e) => [e.name, e.score]), [['Ada L.', 30], ['Bo', 25]]);
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/time-migration.test.js b/test/time-migration.test.js
nnew file mode 100644
iindex 0000000..5a46f17
---- /dev/null
++++ b/test/time-migration.test.js
@@@ -0,0 +1,40 @@
++'use strict';
++// migrations/0005_time_boards.sql widens the kind CHECK on a database that already has best and sum boards; the
++// existing boards and their rows stay, and 'time' becomes a valid kind.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { createDb } = require('openvibe-sdk/db');
++const { MIGRATIONS } = require('../server/db');
++const { check, done } = require('./helpers/boot');
++
++(async () => {
++ const priorDir = fs.mkdtempSync(path.join(os.tmpdir(), 'play-time-migration-'));
++ let db;
++ try {
++ for (const f of fs.readdirSync(MIGRATIONS).filter((n) => n.endsWith('.sql') && Number(n.slice(0, 4)) < 5)) {
++ fs.copyFileSync(path.join(MIGRATIONS, f), path.join(priorDir, f));
++ }
++ db = createDb({ pglite: true, service: 'play-time-migration' });
++ await db.migrate({ dir: priorDir });
++ await db.exec("INSERT INTO play_titles (id, owner, name, created_at, updated_at) VALUES ('surf-ride', 'service:games', 'Surf Ride', 1, 1)");
++ await db.exec("INSERT INTO play_boards (title_id, id, name, kind) VALUES ('surf-ride', 'best', 'Best score', 'best'), ('surf-ride', 'sum', 'Total haul', 'sum')");
++
++ await check('the time-board migration applies over existing best and sum boards', async () => {
++ const migrated = await db.migrate({ dir: MIGRATIONS });
++ assert.deepStrictEqual(migrated.applied.map((m) => m.id), ['0005']);
++ assert.deepStrictEqual((await db.many('SELECT id, kind FROM play_boards ORDER BY id')).map((b) => [b.id, b.kind]),
++ [['best', 'best'], ['sum', 'sum']]);
++ await db.exec("INSERT INTO play_boards (title_id, id, name, kind) VALUES ('surf-ride', 'time', 'Fastest run', 'time')");
++ assert.deepStrictEqual((await db.many('SELECT id, kind FROM play_boards ORDER BY id')).map((b) => [b.id, b.kind]),
++ [['best', 'best'], ['sum', 'sum'], ['time', 'time']]);
++ await assert.rejects(() => db.exec("INSERT INTO play_boards (title_id, id, name, kind) VALUES ('surf-ride', 'bad', 'Bad', 'fastest')"),
++ /play_boards_kind_check/);
++ });
++ } finally {
++ if (db) await db.close().catch(() => {});
++ fs.rmSync(priorDir, { recursive: true, force: true });
++ }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });