openvibers/openvibe.play Commits

The player pages moved to OpenVibe.Games: every old page 301s, the API stays (#175)

OP OpenVibers committed 5 days ago · 7a731d3f97 · 18 files, +264 −710

Changes

ddiff --git a/README.md b/README.md
iindex 521dc2a..97dd87c 100644
---- a/README.md
++++ b/README.md
@@@ -48,7 +48,7 @@ game, to the player's account page and to any page that wants a board.
- Titles, their achievement definitions and their boards (`play_titles`, `play_achievements`, `play_boards`).
- Unlocks, scores and cloud saves per player (`play_unlocks`, `play_scores`, `play_saves`), guest adoption, and the merges applied
(`play_subject_merges`).
--- The public pages at play.openvibe.network and the `/api/v1` read and write API.
++- The `/api/v1` read and write API. The player pages now live on OpenVibe.Games (achievements, leaderboards, developers); every old page at play.openvibe.network answers 301 to its counterpart there, and the API stays here.
- Request telemetry (`telemetry_samples`, `0003_telemetry.sql`): each HTTP request aggregated by route template, method, status class,
never by raw URL; `/shared`, static assets, health, readiness and `/metrics` are not counted. `server/app.js` builds the collector,
`server/index.js` flushes per key one `http.request` sample plus the active-requests, p95 latency and event-loop-lag gauges each
@@@ -84,11 +84,11 @@ 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), `time-boards.test.js`
++adoption, secret achievements, retired definitions, a person's own achievements, the API), `moved-pages.test.js`
++(the page redirects and the crawl files), `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.
++(export, erasure and account merges), the auth, limits, redirects, open-redirect, nginx and security tests.
## Security
@@@ -140,7 +140,8 @@ server/play/store.js titles, achievements, boards, scores, unlocks, adop
server/play/account-data.js account export and deletion (ADR-033)
server/play/subject-merge.js account merges (ADR-029)
server/http/api.js /api/v1
--server/http/pages.js the pages; discovery.js robots.txt, sitemap.xml, llms.txt
++server/http/moved.js the old page addresses, 301 to OpenVibe.Games
++server/http/discovery.js robots.txt and llms.txt (sitemap.xml and llms-full.txt redirect to Games)
migrations/ the schema (0001 the store, 0002 the merges applied)
```
ddiff --git a/deploy/nginx/play.openvibe.network.conf b/deploy/nginx/play.openvibe.network.conf
iindex 4113a95..15ae4a1 100644
---- a/deploy/nginx/play.openvibe.network.conf
++++ b/deploy/nginx/play.openvibe.network.conf
@@@ -2,14 +2,14 @@
## play.openvibe.network — Nginx configuration (REFERENCE)
##
## Deployed to /etc/nginx/sites-available/play.openvibe.network.conf (ov-vhost-install) — keep them in sync and reload
--## nginx after changing it. The Node app (port 5040, systemd unit openvibe-play) renders every page and serves
--## the API; nginx terminates TLS with the openvibe.network wildcard certificate (*.openvibe.network: a repo vhost uses
++## nginx after changing it. The Node app (port 5040, systemd unit openvibe-play) serves the API and 301s every old
++## player page to its OpenVibe.Games counterpart; nginx terminates TLS with the openvibe.network wildcard certificate (*.openvibe.network: a repo vhost uses
## the certificate that really covers its name, or Cloudflare answers 526), rate-limits sign-in and the API, keeps
## /internal/ (OpenVibe.Events deliveries) off the internet, and compresses text. There is no www. name:
## the wildcard does not cover a second level.
##
--## No proxy_cache: the app decides cacheability per response (public for the pages rendered for anonymous
--## visitors; private, no-store for everything personal). Never log request bodies here.
++## No proxy_cache: the app decides cacheability per response (the API's public reads are briefly cacheable;
++## everything personal is private, no-store). Never log request bodies here.
## ═══════════════════════════════════════════════════════════════
limit_req_zone $binary_remote_addr zone=play_auth:10m rate=10r/m;
ddiff --git a/server/agent-descriptor.js b/server/agent-descriptor.js
iindex 413f563..417e894 100644
---- a/server/agent-descriptor.js
++++ b/server/agent-descriptor.js
@@@ -1,15 +1,16 @@
'use strict';
// What openvibe.play tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
--// Every field is something this site really serves: the public read API at /api/v1, the developer guide at
--// /docs, and the three ways in (none for reads, Network OIDC for people, a game's Network service token to write).
++// Every field is something this host really serves: the public read API at /api/v1 and the three ways in (none for
++// reads, Network OIDC for people, a game's Network service token to write). The pages and the API guide moved to
++// openvibe.games; this descriptor points there rather than at a page that 301s.
module.exports = {
id: 'play',
name: 'OpenVibe.Play',
purpose: 'Achievements, weekly and all-time leaderboards and cloud saves for every OpenVibe game, with guests welcome and an SDK for developers.',
-- home: 'https://play.openvibe.network',
++ home: 'https://openvibe.games/developers',
site: 'play.openvibe.network',
api: '/api/v1',
-- docs: '/docs',
++ docs: 'https://openvibe.games/developers',
openapi: '/openapi.json',
policy: {
auth: ['none', 'Network OIDC', 'Network service token (Bearer, per game)'],
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
ddeleted file mode 100644
iindex fd11805..0000000
---- a/server/agent-pages.js
++++ /dev/null
@@@ -1,30 +0,0 @@
--'use strict';
--// The Markdown twins of openvibe.play's public pages (openvibe-shared/agent-layer), rendered by the same page
--// builders the routes use (createPageRoutes().publicViews + render/layout.js's renderPage), so a twin cannot
--// drift from its page: the home, every production game and its boards, the developer guide and the update log.
--// A sandbox title's pages are not listed, not indexed and not advertised, so they have no twin.
--const { renderPage } = require('./render/layout');
--
--function createAgentPages(ctx, publicViews) {
-- // A view that is not marked public (a sandbox title's) has no twin.
-- const twin = (page, path) => (page && page.agent === true ? { html: renderPage({ ...page, viewer: { kind: 'anonymous' }, config: ctx.config, path }) } : null);
--
-- async function renderPublicPage(path, { query } = {}) {
-- if (path === '/') return twin(await publicViews.home({ signed: false }), path);
-- if (path === '/docs') return twin(publicViews.docs(), path);
-- if (path === '/updates') return twin(publicViews.updates(), path);
-- let m = /^\/t\/([^/]+)$/.exec(path);
-- if (m) return twin(await publicViews.title(decodeURIComponent(m[1])), path);
-- m = /^\/t\/([^/]+)\/b\/([^/]+)$/.exec(path);
-- if (m) {
-- const period = query && query.get('period') === 'week' ? 'week' : 'all';
-- const pageNo = Math.max(1, Math.min(10_000, parseInt(query && query.get('page'), 10) || 1));
-- return twin(await publicViews.board(decodeURIComponent(m[1]), decodeURIComponent(m[2]), { period, page: pageNo }), path);
-- }
-- return null;
-- }
--
-- return { renderPublicPage };
--}
--
--module.exports = { createAgentPages };
ddiff --git a/server/app.js b/server/app.js
iindex 193f86e..f8284f4 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -4,13 +4,14 @@
* OpenVibe.Play — Express app factory; server/index.js listens, tests build their own instance with a temp database
* and a mock Network.
*
-- * /, /t/:title, /me, /docs, /updates the pages (http/pages.js)
* /api/v1/* the API (http/api.js)
* /auth/* Network SSO with PKCE (auth/sso.js)
* /api/health, /api/ready, /release.json, /metrics (loopback only)
++ * /, /t/:title, /me, /docs, /updates 301 to the OpenVibe.Games pages (http/moved.js)
*
-- * The product is play/store.js (titles, achievements, leaderboards) behind http/api.js and http/pages.js; the plumbing
-- * here (helmet, SSO, legal pages, static assets, the API mount, the 404 and the error handler) is the network's.
++ * The product is play/store.js (titles, achievements, leaderboards) behind http/api.js; the pages that used to be
++ * here are part of OpenVibe.Games now. The plumbing (helmet, SSO, legal pages, static assets, the API mount, the
++ * discovery files, the 404 and the error handler) is the network's.
*/
const path = require('path');
const express = require('express');
@@@ -26,15 +27,15 @@ const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createApi } = require('./http/api');
--const { createPageRoutes } = require('./http/pages');
++const { createDiscoveryRoutes } = require('./http/discovery');
++const { createMovedRoutes } = require('./http/moved');
const { createServiceReadiness } = require('./observability');
const { createCallerLimits } = require('./http/caller-limits');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createPlay } = require('./play/store');
const subjectMerge = require('./play/subject-merge');
const accountDataLib = require('./play/account-data');
--const { assetVersion, send } = require('./render/layout');
--const { html } = require('./render/html');
++const { assetVersion } = require('./render/layout');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
@@@ -182,24 +183,23 @@ async function createApp(opts = {}) {
app.use('/api/v1', createApi(ctx));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
-- // ── Pages ───────────────────────────────────────────────
-- app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.PLAY_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
-- const pageRoutes = createPageRoutes(ctx);
-- // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the public
-- // pages. The pages keep their own routes — a signed-in person's progress is theirs — so only the manifest and
-- // twin requests reach this mount; the pages advertise the twin themselves.
++ // ── Crawl files and the moved pages ─────────────────────
++ // The player pages are on openvibe.games now. The crawl files answer first (robots.txt, llms.txt) and the old
++ // page addresses 301 to their counterparts; every /api/v1 route above stays exactly where it is. The agent
++ // manifest still says what this host offers (the API and its guide); the pages' Markdown twins moved with them.
const agentLayer = express();
require('openvibe-shared/agent-layer').mount(agentLayer, {
descriptor: require('./agent-descriptor'),
-- render: require('./agent-pages').createAgentPages(ctx, pageRoutes.publicViews).renderPublicPage,
++ render: async () => null,
});
app.use((req, res, next) => {
-- const twin = req.path.endsWith('.md') || req.query.format === 'markdown' || String(req.headers.accept || '').includes('text/markdown');
-- if (req.path !== '/.well-known/agent.json' && !twin) return next();
-- return agentLayer(req, res, next);
++ if (req.path === '/.well-known/agent.json') return agentLayer(req, res, next);
++ return next();
});
-- app.use(pageRoutes);
-- app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/">the home page</a> or <a href="/updates">the update log</a>.</p>` }));
++ app.use(createDiscoveryRoutes());
++ app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.PLAY_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
++ app.use(createMovedRoutes());
++ app.use((req, res) => res.status(404).type('text/plain').send('Not found. The player pages are on openvibe.games; the API is at /api/v1.'));
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, _next) => {
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex dfd4421..1881a5c 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -1,135 +1,73 @@
'use strict';
/**
-- * Crawl artifacts for play.openvibe.network, built with openvibe-shared/seo: robots.txt, sitemap.xml, llms.txt and
-- * llms-full.txt, and the home page's JSON-LD. The public pages are for search engines and AI crawlers; sign-in and
-- * the API are not (the boards' public JSON is linked from llms.txt for agents that read it).
++ * Crawl artifacts for play.openvibe.network. The service's pages moved into OpenVibe.Games (2026-11), so Play is
++ * an API host now: robots.txt points crawlers at Games' sitemap, sitemap.xml redirects there, and llms.txt names
++ * the Games pages a person (or a model) wants while keeping the API's own machine-readable reads.
*
-- * The titles are read through ctx.play: the sitemap lists each game's page and each of its boards (this week and all
-- * time); llms.txt maps them with the read API; llms-full.txt spells out every achievement.
++ * /robots.txt public; sitemap is Games', /auth and /api stay out
++ * /sitemap.xml 301 → https://openvibe.games/sitemap.xml
++ * /llms.txt public; the moved pages on Games and the API that stays here
++ * /llms-full.txt 301 → https://openvibe.games/llms-full.txt
++ *
++ * Nothing here reads the database: the API's own reads are the source of truth and are linked, not copied.
*/
--const fs = require('fs');
--const path = require('path');
const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const SITE_NAME = 'OpenVibe.Play';
--const DESCRIPTION = 'Game services for OpenVibe games: achievements, weekly and all-time leaderboards and cloud saves, with guests welcome and an SDK for developers.';
--const DISALLOW = ['/auth/', '/api/', '/me'];
--
--const PAGE_TEXT = {
-- '/': ['OpenVibe.Play home', 'Every OpenVibe game with its achievements and this week\'s leaders.'],
-- '/docs': ['The Play API', 'How a game registers its achievements and leaderboards, posts scores and unlocks, and reads its boards.'],
-- '/updates': ['What shipped on OpenVibe.Play', 'This site\'s update log, from the network changelog feed.'],
--};
--
--function dayOf(ts) {
-- const m = String(ts == null ? '' : ts).match(/^(\d{4}-\d{2}-\d{2})/);
-- return m ? m[1] : null;
--}
--function siteUpdated() {
-- try { return dayOf(JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'STATUS.json'), 'utf8')).updated); } catch { return null; }
--}
++const DESCRIPTION = 'The game services API behind OpenVibe games: achievements, leaderboards and cloud saves.';
++const GAMES_SITE = 'https://openvibe.games';
++const SITE = 'https://play.openvibe.network';
++const DISALLOW = ['/auth/', '/api/'];
--function homeJsonLd(config) {
-- const site = String(config.baseUrl).replace(/\/+$/, '');
-- return [
-- seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),
-- seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
-- ];
--}
--
--const publicPages = () => [
-- { path: '/', changefreq: 'hourly', priority: 1.0 },
-- { path: '/docs', changefreq: 'monthly', priority: 0.6 },
-- { path: '/updates', changefreq: 'daily', priority: 0.4 },
--];
--const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
--
--function createDiscoveryRoutes(ctx) {
-- const { config, play } = ctx;
++function createDiscoveryRoutes() {
const r = asyncRouter();
-- const site = String(config.baseUrl).replace(/\/+$/, '');
-- const abs = (p) => `${site}${p}`;
const TEXT = cache.htmlHeaders({ maxAge: 3600 });
-- // All time is the board's plain address, this week ?period=week (each page's own canonical).
-- const boardPath = (t, b, period) => `/t/${t}/b/${b}${period === 'week' ? '?period=week' : ''}`;
r.get('/robots.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(
-- '# play.openvibe.network: the public pages are for search and AI crawlers; sign-in and the API are not.\n'
-- + '# Agent manifest: https://play.openvibe.network/.well-known/agent.json; every public page has a .md twin.\n'
-- + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
++ '# play.openvibe.network: the player pages live on openvibe.games; this host serves the games API.\n'
++ + seo.robotsTxt({ sitemaps: [`${GAMES_SITE}/sitemap.xml`], disallow: DISALLOW }));
});
-- r.get('/llms.txt', async (_req, res) => {
-- const titles = await play.titles();
++ // The pages are Games' now. A crawler that still asks Play's sitemap is sent to the one that lists them.
++ r.get('/sitemap.xml', (_req, res) => res.redirect(301, `${GAMES_SITE}/sitemap.xml`));
++ r.get('/llms-full.txt', (_req, res) => res.redirect(301, `${GAMES_SITE}/llms-full.txt`));
++
++ r.get('/llms.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
name: SITE_NAME,
-- summary: DESCRIPTION,
-- details: 'Each OpenVibe game registers its achievements and leaderboards here with its own token and posts what its '
-- + 'players did; the boards are public. A player is a person with an OpenVibe account or a guest of the game. '
-- + 'Every page is server-rendered and readable without JavaScript.',
-- markdown: true,
-- base: site,
++ summary: 'The game services API behind OpenVibe games: achievements, leaderboards and cloud saves. The player pages are part of OpenVibe.Games.',
++ details: 'Play keeps each game\'s title, achievements, boards and cloud saves and answers them as JSON. '
++ + 'The pages that show them are on openvibe.games: achievements, leaderboards and the API guide for game makers. '
++ + 'The API itself stays at play.openvibe.network/api/v1 because every game calls it.',
sections: [
-- { title: 'Start here', links: [
-- { title: 'OpenVibe.Play', url: abs('/'), note: 'every game and this week\'s leaders' },
-- { title: 'The Play API', url: abs('/docs') },
++ { title: 'Page now on OpenVibe.Games', links: [
++ { title: 'Achievements', url: `${GAMES_SITE}/achievements`, note: 'yours when signed in, every game\'s when signed out' },
++ { title: 'Leaderboards', url: `${GAMES_SITE}/leaderboards`, note: 'each game, this week and all time' },
++ { title: 'The Play API for game makers', url: `${GAMES_SITE}/developers`, note: 'register a title, post scores and unlocks, keep cloud saves' },
] },
-- ...titles.map((t) => ({ title: t.name, links: [
-- { title: `${t.name}: achievements and leaderboards`, url: abs(`/t/${t.id}`), note: `${plural(t.achievements.length, 'achievement', 'achievements')}` },
-- ...t.boards.map((b) => ({ title: `${b.name}, this week`, url: abs(boardPath(t.id, b.id, 'week')) })),
-- ...t.boards.map((b) => ({ title: `${b.name} (JSON)`, url: abs(`/api/v1/titles/${t.id}/boards/${b.id}?period=all`) })),
-- ] })),
-- // A JSON API is what an agent came for, not something to skip (AGT-5 review).
-- { title: 'API', links: [
-- { title: 'GET /api/v1/titles', url: abs('/api/v1/titles'), note: 'every game, its achievements and boards' },
++ { title: 'API (still on this host)', links: [
++ { title: 'GET /api/v1/titles', url: `${SITE}/api/v1/titles`, note: 'every game, its achievements and boards (JSON, public)' },
++ { title: 'GET /api/v1/titles/:title/boards/:board?period=week|all', url: `${SITE}/api/v1/titles`, note: 'a board: rank, name, score, never a player id' },
++ { title: 'GET /api/v1/people/:subject/achievements', url: `${SITE}/api/v1/people`, note: 'a person\'s unlocks in every game' },
++ ] },
++ { title: 'Machine-readable', links: [
++ { title: 'Sitemap (OpenVibe.Games)', url: `${GAMES_SITE}/sitemap.xml` },
++ { title: 'Full text for language models (OpenVibe.Games)', url: `${GAMES_SITE}/llms-full.txt` },
++ { title: 'Release metadata (JSON)', url: `${SITE}/release.json` },
++ ] },
++ { title: 'Elsewhere', links: [
++ { title: 'OpenVibe.Games', url: `${GAMES_SITE}/`, note: 'play the games' },
++ { title: 'OpenVibe.Play README', url: 'https://github.com/OpenVibers/OpenVibe.Play#readme', note: 'the service and its API' },
] },
],
-- optional: [
-- { title: 'Sitemap', url: abs('/sitemap.xml') },
-- { title: 'Full text for language models', url: abs('/llms-full.txt') },
-- { title: 'Release metadata (JSON)', url: abs('/release.json') },
-- { title: 'OpenVibe.Games', url: 'https://openvibe.games', note: 'play the games' },
-- { title: 'OpenVibe.Network', url: 'https://openvibe.network', note: 'accounts, apps and grants' },
-- { title: 'What shipped on OpenVibe.Play', url: abs('/updates') },
-- ],
-- }));
-- });
--
-- r.get('/llms-full.txt', async (_req, res) => {
-- const titles = await play.titles();
-- const pages = publicPages().map((e) => ({ url: e.path, title: PAGE_TEXT[e.path][0], text: PAGE_TEXT[e.path][1] }));
-- const sections = titles.map((t) => ({
-- title: t.name,
-- 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' : 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({
-- site: SITE_NAME, summary: 'Every public page of OpenVibe.Play, one line each.', base: site, maxBytes: 64 * 1024,
-- sections: [{ title: 'Pages', pages }, ...sections],
}));
});
-- r.get('/sitemap.xml', async (_req, res) => {
-- const lastmod = siteUpdated();
-- const titles = await play.titles();
-- const entries = [
-- ...publicPages(),
-- ...titles.flatMap((t) => [
-- { path: `/t/${t.id}`, changefreq: 'daily', priority: 0.8 },
-- ...t.boards.flatMap((b) => [{ path: boardPath(t.id, b.id, 'week'), changefreq: 'hourly', priority: 0.6 }, { path: boardPath(t.id, b.id, 'all'), changefreq: 'daily', priority: 0.5 }]),
-- ]),
-- ];
-- res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(entries.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }))));
-- });
--
return r;
}
--module.exports = { createDiscoveryRoutes, homeJsonLd, publicPages, DESCRIPTION, SITE_NAME };
++module.exports = { createDiscoveryRoutes, DESCRIPTION, SITE_NAME, GAMES_SITE };
ddiff --git a/server/http/moved.js b/server/http/moved.js
nnew file mode 100644
iindex 0000000..7378b7d
---- /dev/null
++++ b/server/http/moved.js
@@@ -0,0 +1,51 @@
++'use strict';
++
++/**
++ * The Play pages moved into OpenVibe.Games (2026-11): achievements and leaderboards are Games pages now, and the
++ * Play API for game makers is openvibe.games/developers. Every old page answers 301 to its counterpart there; the
++ * API at /api/v1 stays exactly where it is, because every game calls it.
++ *
++ * / → openvibe.games/achievements
++ * /achievements, /me → openvibe.games/achievements
++ * /leaderboards → openvibe.games/leaderboards
++ * /developers, /docs → openvibe.games/developers
++ * /updates → openvibe.games/updates
++ * /t/:title → that game on openvibe.games (or the leaderboards for an unknown title)
++ * /t/:title/b/:board → openvibe.games/leaderboards?game=…&board=… (its period kept)
++ */
++const express = require('express');
++
++const GAMES = 'https://openvibe.games';
++/** The games Games plays, by Play title id. A title without a page of its own lands on the leaderboards. */
++const GAME_PAGES = {
++ scraplandia: `${GAMES}/play`,
++ 'salvage-rush': `${GAMES}/play?game=salvage-rush`,
++ 'vibe-heroes': `${GAMES}/play/vibe-heroes`,
++};
++
++function createMovedRoutes() {
++ const r = express.Router();
++ const go = (target) => (_req, res) => res.redirect(301, target);
++
++ r.get('/', go(`${GAMES}/achievements`));
++ r.get('/achievements', go(`${GAMES}/achievements`));
++ r.get('/me', go(`${GAMES}/achievements`));
++ r.get('/leaderboards', go(`${GAMES}/leaderboards`));
++ r.get('/developers', go(`${GAMES}/developers`));
++ r.get('/docs', go(`${GAMES}/developers`));
++ r.get('/updates', go(`${GAMES}/updates`));
++
++ r.get('/t/:title', (req, res) => {
++ const title = String(req.params.title || '').toLowerCase();
++ res.redirect(301, GAME_PAGES[title] || `${GAMES}/leaderboards?game=${encodeURIComponent(title)}`);
++ });
++ r.get('/t/:title/b/:board', (req, res) => {
++ const title = String(req.params.title || '').toLowerCase();
++ const query = new URLSearchParams({ game: title, board: String(req.params.board || '') });
++ if (req.query.period === 'week') query.set('period', 'week');
++ res.redirect(301, `${GAMES}/leaderboards?${query}`);
++ });
++ return r;
++}
++
++module.exports = { createMovedRoutes, GAME_PAGES, GAMES };
ddiff --git a/server/http/pages.js b/server/http/pages.js
ddeleted file mode 100644
iindex 706b613..0000000
---- a/server/http/pages.js
++++ /dev/null
@@@ -1,305 +0,0 @@
--'use strict';
--
--/**
-- * OpenVibe.Play's pages, server-rendered and usable without JavaScript:
-- *
-- * / the titles, each with its newest achievements and this week's leaders
-- * /t/:title one title: every achievement (how many players have it) and its boards
-- * /t/:title/b/:board one leaderboard, this week or all time, 25 a page
-- * /me a signed-in person's achievements across titles
-- * /docs the API for a game's developer
-- * /updates the update log
-- *
-- * A board shows a name and a score, never a player id.
-- */
--const ovServe = require('openvibe-shared/serve');
--const frame = require('openvibe-shared/frame');
--const showcase = require('openvibe-shared/showcase');
--const cache = require('openvibe-shared/cache-policy');
--const { asyncRouter } = require('./router');
--const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
--const { html, raw } = require('../render/html');
--const { send } = require('../render/layout');
--const { PlayError, PLAYER_RE, MAX_SLOTS, MAX_SAVE_BYTES, MAX_SANDBOX_TITLES, isSandbox, PER_PAGE } = require('../play/store');
--
--const SITE_NAME = 'OpenVibe.Play';
--const num = (n) => Number(n).toLocaleString('en-US');
--const plural = (n, one, many) => `${num(n)} ${n === 1 ? one : many}`;
--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, 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." },
-- { q: 'Can I add my own game?', a: `Yes. Make a project on openvibe.services with a sandbox app holding Play's three capabilities: it registers up to ${MAX_SANDBOX_TITLES} sandbox- titles (unlisted, guest players) to try everything. Once staff turn production on for the project, its production app takes the real name.` },
-- { q: 'Where do I see my own achievements?', a: 'Sign in and open Your achievements: every game, every unlock and when it happened.' },
--];
--function homeFaqJsonLd() {
-- return { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: HOME_FAQ.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })) };
--}
--
--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' : 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 } = {}) {
-- if (!list.length) return html`<p class="pl-empty">No achievements yet.</p>`;
-- return html`<ul class="pl-ach">${list.map((a) => html`<li class="${a.hidden ? 'pl-ach-hidden' : ''}"><span class="pl-ach-name">${a.name}</span>${a.description ? html`<span class="pl-ach-desc">${a.description}</span>` : ''}${a.unlocked_by !== undefined ? html`<span class="pl-ach-meta">${plural(a.unlocked_by, 'player', 'players')}${players ? ` · ${pct(a.unlocked_by, players)}` : ''}</span>` : ''}${a.unlocked_at ? html`<span class="pl-ach-meta">Unlocked ${a.unlocked_at.slice(0, 10)}</span>` : ''}</li>`)}</ul>`;
--}
--
--function createPageRoutes(ctx) {
-- const { config, play } = ctx;
-- const r = asyncRouter();
-- const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 120 });
-- const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
-- const signedIn = (req) => (req.viewer && req.viewer.kind === 'user' && PLAYER_RE.test(String(req.viewer.subject || '')) && String(req.viewer.subject).startsWith('usr_') ? req.viewer.subject : null);
-- const notFound = (req, res, what) => page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>${what} <a href="/">See every game</a>.</p>` }, 404);
--
-- // ── Home ─────────────────────────────────────────────────
-- async function homePage({ signed = false } = {}) {
-- const titles = await play.titles();
-- const me = signed;
-- const leaders = new Map();
-- for (const t of titles) {
-- if (t.boards.length) leaders.set(t.id, await play.board(t.id, t.boards[0].id, { period: 'week' }));
-- }
-- // Real figures only: hidden achievements count toward unlocks but are never named on the home.
-- const shown = titles.flatMap((t) => t.achievements.filter((a) => !a.hidden).map((a) => ({ ...a, game: t })));
-- const unlocks = titles.reduce((n, t) => n + t.achievements.reduce((m, a) => m + (Number(a.unlocked_by) || 0), 0), 0);
-- const achievementCount = titles.reduce((n, t) => n + t.achievements.length, 0);
-- const popular = shown.filter((a) => a.unlocked_by > 0).sort((x, y) => y.unlocked_by - x.unlocked_by || x.name.localeCompare(y.name)).slice(0, 6);
-- const stats = [];
-- if (titles.length) stats.push([plural(titles.length, 'game', 'games'), 'use Play today']);
-- if (achievementCount) stats.push([plural(achievementCount, 'achievement', 'achievements'), 'to earn across them']);
-- if (unlocks) stats.push([plural(unlocks, 'unlock', 'unlocks'), 'earned by players so far']);
-- stats.push([`${MAX_SLOTS} save slots`, `per player per game, ${MAX_SAVE_BYTES / 1024} KB each`]);
-- const hero = showcase.hero({
-- eyebrow: `${SITE_NAME} · part of OpenVibe Services`,
-- title: 'Achievements, leaderboards',
-- accent: 'and cloud saves for every game.',
-- lede: 'The game services behind OpenVibe games. Guests play at once, and their progress moves to their OpenVibe account when they sign in.',
-- actions: me
-- // Two, so they sit side by side on a phone; the API is under "For game makers".
-- ? [{ label: 'Your achievements', href: '/me', primary: true }, { label: 'Play on OpenVibe.Games', href: 'https://openvibe.games/' }]
-- : [{ label: 'Play on OpenVibe.Games', href: 'https://openvibe.games/', primary: true }, { label: 'Sign in to see yours', href: '/auth/login?next=%2Fme' }],
-- note: 'Boards show a name and a score, never a player id.',
-- html: `<dl class="pl-stats" tabindex="0" aria-label="Play in numbers">${stats.map(([dt, dd]) => `<div><dt>${dt}</dt><dd>${dd}</dd></div>`).join('')}</dl>`,
-- });
-- return {
-- agent: true, index: true, cache: me ? null : PUBLIC_CACHE,
-- description: 'Game services for OpenVibe games: achievements, weekly and all-time leaderboards and cloud saves, with guests welcome and an SDK for developers.',
-- jsonLd: [...homeJsonLd(config), homeFaqJsonLd()],
-- styles: [showcase.STYLESHEET],
-- body: html`${raw(hero)}
--<section class="pl-titles" aria-labelledby="pl-titles-h"><h2 id="pl-titles-h">Games on Play</h2>
--${titles.length ? html`<div class="pl-grid">${titles.map((t) => html`<article class="pl-card">
--<h3><a href="/t/${t.id}">${t.name}</a></h3>
--${t.summary ? html`<p class="pl-muted">${t.summary}</p>` : ''}
--<p class="pl-meta">${plural(t.achievements.length, 'achievement', 'achievements')} · ${plural(t.players, 'player', 'players')} with one${t.url ? html` · <a href="${t.url}">Play</a>` : ''}</p>
--${leaders.has(t.id) ? html`<h4>This week: ${leaders.get(t.id).name}</h4>${boardTable({ ...leaders.get(t.id), entries: leaders.get(t.id).entries.slice(0, 5) }, { compact: true })}<p><a href="/t/${t.id}/b/${leaders.get(t.id).board}?period=week">The whole board</a></p>` : ''}
--</article>`)}</div>` : html`<p class="pl-empty">No game has joined yet.</p>`}
--</section>
--${popular.length ? html`<section class="pl-popular" aria-labelledby="pl-popular-h"><h2 id="pl-popular-h">Most earned</h2><p class="pl-muted">The achievements the most players have unlocked.</p>
--<ul class="pl-ach">${popular.map((a) => html`<li><span class="pl-ach-name">${a.name}</span>${a.description ? html`<span class="pl-ach-desc">${a.description}</span>` : ''}<span class="pl-ach-meta"><a href="/t/${a.game.id}">${a.game.name}</a> · ${plural(a.unlocked_by, 'player', 'players')}${a.game.players ? ` · ${pct(a.unlocked_by, a.game.players)}` : ''}</span></li>`)}</ul></section>` : ''}
--<section class="pl-makers" aria-labelledby="pl-makers-h"><details class="pl-fold"><summary><h2 id="pl-makers-h">For game makers</h2><span>what a game gets, the SDK, the limits</span></summary>${raw(showcase.features({
-- title: 'What a game gets',
-- 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, 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.' },
-- ],
-- }))}
--${raw(showcase.steps({
-- title: 'How a game uses Play',
-- 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, 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' },
-- ],
-- }))}
--${raw(showcase.code({
-- id: 'sdk',
-- title: 'From your game server',
-- lede: 'The openvibe-sdk/play client, with a token for your app, or plain HTTPS: boards and titles need no token at all.',
-- samples: [
-- {
-- label: 'Node (openvibe-sdk)', lang: 'js',
-- code: `const { createClient } = require('openvibe-sdk/core');
--const { createPlayClient } = require('openvibe-sdk/play');
--
--const play = createPlayClient(createClient({ tokenProvider: tokens })); // your app's OpenVibe 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'] }); // → the new ones
--await play.saves.put('gem-run', 'usr_…', 'main', { data: { level: 3 }, version: 0 });`,
-- },
-- {
-- label: 'Any language (HTTPS)', lang: 'bash',
-- code: `# A public board: rank, name and score, this week
--curl -s 'https://play.openvibe.network/api/v1/titles/scraplandia/boards/haul?period=week'`,
-- },
-- ],
-- }))}
--${raw(showcase.limits({
-- title: 'Limits',
-- lede: 'What one game and one player get today.',
-- columns: ['Today'],
-- rows: [
-- { label: 'Save slots per player, per game', values: [String(MAX_SLOTS)] },
-- { label: 'Size of one save', values: [`${MAX_SAVE_BYTES / 1024} KB`] },
-- { label: 'Scores in one post', values: ['100'] },
-- { label: 'Board entries per page', values: [String(PER_PAGE)] },
-- { label: 'Requests per caller', values: [`${num(config.limits.minute)} a minute, ${num(config.limits.hour)} an hour`] },
-- ],
-- }))}
--<p class="pl-fold-go"><a class="sc-btn" href="/docs">Read the API</a></p></details></section>
--<section class="pl-faq" aria-labelledby="pl-faq-h"><h2 id="pl-faq-h">Questions</h2>${HOME_FAQ.map((f) => html`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</section>`,
-- };
-- }
-- r.get('/', async (req, res) => page(req, res, await homePage({ signed: !!signedIn(req) })));
--
-- // ── A title ──────────────────────────────────────────────
-- async function titlePage(title) {
-- const t = await play.title(title);
-- if (!t) return null;
-- const boards = [];
-- for (const b of t.boards) boards.push(await play.board(t.id, b.id, { period: 'week' }));
-- return {
-- agent: !t.sandbox, index: !t.sandbox, cache: PUBLIC_CACHE, title: `${t.name}: achievements and leaderboards`,
-- description: `${t.name} on OpenVibe.Play: ${plural(t.achievements.length, 'achievement', 'achievements')} and ${plural(t.boards.length, 'leaderboard', 'leaderboards')}, this week and all time.`,
-- body: html`<p class="pl-crumbs"><a href="/">Games</a> /</p>
--<h1>${t.name}</h1>
--${t.sandbox ? html`<p class="pl-muted">Sandbox: a developer is trying Play with this game. It is not listed anywhere, and its players are guests.</p>` : ''}
--${t.summary ? html`<p class="pl-lede">${t.summary}</p>` : ''}
--${t.url ? html`<p><a class="pl-button" href="${t.url}">Play ${t.name}</a></p>` : ''}
--${boards.length ? html`<section aria-labelledby="pl-boards-h"><h2 id="pl-boards-h">Leaderboards this week</h2>${boards.map((b) => html`<h3>${b.name}</h3>${boardTable({ ...b, entries: b.entries.slice(0, 10) })}<p><a href="/t/${t.id}/b/${b.board}?period=week">This week</a> · <a href="/t/${t.id}/b/${b.board}">All time</a></p>`)}</section>` : ''}
--<section aria-labelledby="pl-ach-h"><h2 id="pl-ach-h">Achievements</h2><p class="pl-muted">${plural(t.players, 'player has', 'players have')} unlocked at least one.</p>${achievementList(t.achievements, { players: t.players })}</section>`,
-- };
-- }
-- r.get('/t/:title', async (req, res) => {
-- const out = await titlePage(req.params.title);
-- if (!out) return notFound(req, res, 'No game here.');
-- page(req, res, out);
-- });
--
-- // ── A board ──────────────────────────────────────────────
-- async function boardPage(title, board, { period = 'all', page: pageNo = 1 } = {}) {
-- let b;
-- try { b = await play.board(title, board, { period, page: pageNo }); } catch (err) {
-- if (err instanceof PlayError) return null;
-- throw err;
-- }
-- // All time is the board's plain address and this week is ?period=week: two pages, each its own canonical (the
-- // week board named the all-time one while the sitemap listed both).
-- const link = (o) => {
-- const q = [(o.period || period) === 'week' ? 'period=week' : null, (o.page || 1) > 1 ? `page=${o.page}` : null].filter(Boolean);
-- return `/t/${b.title}/b/${b.board}${q.length ? `?${q.join('&')}` : ''}`;
-- };
-- return {
-- agent: !isSandbox(b.title),
-- 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' : 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' : 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>` : ''}`,
-- };
-- }
-- r.get('/t/:title/b/:board', async (req, res) => {
-- const period = req.query.period === 'week' ? 'week' : 'all';
-- const pageNo = Math.max(1, Math.min(10_000, parseInt(req.query.page, 10) || 1));
-- const out = await boardPage(req.params.title, req.params.board, { period, page: pageNo });
-- if (!out) return notFound(req, res, 'No board here.');
-- page(req, res, out);
-- });
--
-- // ── Yours ────────────────────────────────────────────────
-- r.get('/me', async (req, res) => {
-- const me = signedIn(req);
-- if (!me) {
-- return page(req, res, { title: 'Your achievements', body: html`<h1>Your achievements</h1><p>Sign in with your OpenVibe account to see what you have unlocked in every game.</p><p><a class="pl-button" href="/auth/login?next=%2Fme">Sign in with OpenVibe</a></p>` });
-- }
-- const [titles, saves] = await Promise.all([play.playerAchievements(me), play.mySaves(me)]);
-- res.set('Cache-Control', cache.htmlHeaders({ private: true }));
-- page(req, res, {
-- title: 'Your achievements',
-- body: html`<h1>Your achievements</h1>
--${titles.length ? titles.map((t) => html`<section><h2><a href="/t/${t.title}">${t.name}</a></h2><p class="pl-muted">${plural(t.achievements.length, 'achievement', 'achievements')}</p>${achievementList(t.achievements)}</section>`)
-- : html`<p class="pl-empty">Nothing yet. <a href="https://openvibe.games/">Play a game</a> signed in, and what you unlock shows here.</p>`}
--${saves.length ? html`<section><h2>Your saves</h2><p class="pl-muted">What games keep for you in the cloud, so your progress follows you to any device.</p><ul>${saves.map((x) => html`<li><a href="/t/${x.title}">${x.title_name}</a>: <code>${x.slot}</code> <span class="pl-muted">· ${Math.max(1, Math.round(x.size / 1024))} KB · saved ${x.updated_at.slice(0, 10)}</span></li>`)}</ul></section>` : ''}`,
-- });
-- });
--
-- // ── For developers ───────────────────────────────────────
-- function docsPage() { return {
-- agent: true, index: true, cache: PUBLIC_CACHE, title: 'The Play API',
-- description: 'Register a game\'s achievements and leaderboards, post scores and unlocks, and read the boards: the OpenVibe.Play API.',
-- body: html`<h1>The Play API</h1>
--<p class="pl-lede">A game registers its title with its own OpenVibe token, posts what its players did, and reads its boards. Base address: <code>https://play.openvibe.network/api/v1</code>. Errors are RFC 9457 problem+json with a stable <code>code</code>.</p>
--<h2>Reading (anyone, any site)</h2>
--<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; <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" | "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>
--</ul>
--<h2>Cloud saves</h2>
--<p>A player's progress follows them to any device: up to 8 slots per player in your game, each any JSON value of at most 64 KB. Slots move with a guest who signs in (the newer save wins a clash) and with an account merge.</p>
--<ul>
--<li><code>PUT /titles/:title/players/:player/saves/:slot</code> (<code>play.result.write</code>): <code>{ data, version }</code>. <code>version</code> is the version you read (0 for a new slot): if the slot moved on since, the write is refused with <code>409 play.save_conflict</code>, so two devices never overwrite each other silently. Leave it out to overwrite.</li>
--<li><code>GET /titles/:title/players/:player/saves/:slot</code> (<code>play.player.read</code>): <code>{ slot, version, data, created_at, updated_at }</code>; <code>GET …/saves</code> lists the slots without their data; <code>DELETE …/saves/:slot</code> (<code>play.result.write</code>).</li>
--<li><code>GET /me/saves</code> and <code>DELETE /me/saves/:title/:slot</code>: with a person's own Network token, their slots in every game.</li>
--</ul>
--<p>A <code>player</code> is a person's OpenVibe subject (<code>usr_…</code>) or a guest your game names, <code>guest:</code> followed by 8 to 128 letters, digits, <code>_</code> or <code>-</code>. Only people's rows are personal data: they are in the person's account export and erased with their account.</p>
--<h2 id="sandbox">Your own game: 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> (client credentials) and <code>PUT /titles/sandbox-your-game</code>.</li>
--</ol>
--<p>A sandbox app registers up to ${String(MAX_SANDBOX_TITLES)} titles, each id starting with <code>sandbox-</code>, and deletes one with everything in it by <code>DELETE /titles/:title</code> (<code>play.title.manage</code>; a production title is never deleted) (anything else answers <code>400 play.sandbox_title</code>). Their players are guests only (<code>400 play.sandbox_guests_only</code> for a person), nothing is adopted, and they are never listed, counted or indexed: their pages and JSON answer at their id, marked <code>sandbox: true</code>, so you can check your boards. When your game is ready, staff turn production on for your project; its production app registers the real name and real players' progress counts on their profiles.</p>`,
-- }; }
-- r.get('/docs', (req, res) => page(req, res, docsPage()));
--
-- const updatesPage = () => ({
-- agent: true, index: true, cache: PUBLIC_CACHE,
-- title: `What shipped on ${SITE_NAME}`,
-- body: raw(frame.updatesBody({ service: 'play', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`),
-- });
-- r.get('/updates', (req, res) => page(req, res, updatesPage()));
--
-- // The public pages the agent layer's Markdown twins (server/agent-pages.js) render with the same builders.
-- // A sandbox title's pages stay unwritten: they are not listed, not indexed and not advertised.
-- r.publicViews = { home: homePage, title: titlePage, board: boardPage, docs: docsPage, updates: updatesPage };
--
-- // ── Discovery: robots.txt, sitemap.xml, llms.txt, llms-full.txt ──
-- r.use(createDiscoveryRoutes(ctx));
-- return r;
--}
--
--module.exports = { createPageRoutes };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex f05ea1d..7c20ff7 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -86,8 +86,7 @@ function renderPage(o) {
description: o.description || `${SITE_NAME} — ${TAGLINE}`,
canonical,
robots: o.index ? 'index, follow' : 'noindex, nofollow',
-- // A page with a Markdown twin (o.agent) advertises it in the head and the Link header; the twin itself
-- // is server/agent-pages.js.
++ // A page with a Markdown twin (o.agent) advertises it in the head and the Link header.
agentLayer: o.agent === true,
llms: `${String(o.config.baseUrl).replace(/\/+$/, '')}/llms.txt`,
jsonLd: (o.jsonLd || []).filter(Boolean),
ddiff --git a/test/auth-ops.test.js b/test/auth-ops.test.js
iindex 8b0abe1..017431f 100644
---- a/test/auth-ops.test.js
++++ b/test/auth-ops.test.js
@@@ -108,14 +108,18 @@ const cookieHeader = (res) => res.headers.getSetCookie().map((c) => c.split(';')
assert.notStrictEqual(proxied.status, 200, 'a proxied request is not loopback');
});
-- await check('public pages are complete without JavaScript (noscript nav, SSR footer)', async () => {
-- for (const p of ['/', '/updates']) {
-- const r = await t.get(p);
-- assert.strictEqual(r.status, 200, p);
-- assert.match(r.text, /<noscript><nav/);
-- assert.match(r.text, /<footer id="ov-footer"/);
-- assert.match(r.text, /<main id="main"/);
++ await check('the old player pages 301 to OpenVibe.Games; the API is still complete without JavaScript', async () => {
++ for (const [from, to] of [
++ ['/', 'https://openvibe.games/achievements'],
++ ['/updates', 'https://openvibe.games/updates'],
++ ]) {
++ const r = await t.get(from);
++ assert.strictEqual(r.status, 301, from);
++ assert.strictEqual(r.headers.get('location'), to, from);
}
++ const ping = await t.get('/api/v1/ping');
++ assert.strictEqual(ping.status, 200);
++ assert.deepStrictEqual(ping.json(), { ok: true, service: 'play' });
});
await check('the API answers /ping and unknown routes are problem+json 404', async () => {
ddiff --git a/test/discovery.test.js b/test/discovery.test.js
ddeleted file mode 100644
iindex 04e9442..0000000
---- a/test/discovery.test.js
++++ /dev/null
@@@ -1,50 +0,0 @@
--'use strict';
--// Crawl artifacts (server/http/discovery.js): robots.txt keeps crawlers off sign-in, the API and /me; the sitemap lists
--// the public pages, each game and both periods of each board; llms.txt maps them with the read API; llms-full.txt
--// spells out the visible achievements and never a secret one.
--const assert = require('assert');
--const { boot, check, done } = require('./helpers/boot');
--
--(async () => {
-- const t = await boot();
-- try {
-- const games = { bearer: t.network.serviceToken('games', ['play.title.manage']) };
-- const put = await t.get('/api/v1/titles/scraplandia', { ...games, method: 'PUT', json: {
-- name: 'Scraplandia', url: 'https://openvibe.games/play', summary: 'Scrappy multiplayer survival-building.',
-- achievements: [{ id: 'first-haul', name: 'First Haul', description: 'Extract a valuable.' }, { id: 'hidden-one', name: 'Ghost', description: 'Shh.', hidden: true }],
-- boards: [{ id: 'haul', name: 'Haul', kind: 'sum' }],
-- } });
-- assert.strictEqual(put.status, 201, put.text);
--
-- await check('robots.txt keeps crawlers off sign-in, the API and /me, and names the sitemap', async () => {
-- const r = await t.get('/robots.txt');
-- assert.strictEqual(r.status, 200);
-- for (const p of ['/auth/', '/api/', '/me']) assert.ok(r.text.includes(`Disallow: ${p}`), p);
-- assert.match(r.text, /Sitemap: https:\/\/play\.openvibe\.network\/sitemap\.xml/);
-- });
--
-- await check('the sitemap lists the pages, the game and both periods of its board', async () => {
-- const r = await t.get('/sitemap.xml');
-- assert.match(r.headers.get('content-type'), /xml/);
-- for (const loc of ['/', '/docs', '/updates', '/t/scraplandia', '/t/scraplandia/b/haul?period=week', '/t/scraplandia/b/haul']) {
-- assert.ok(r.text.includes(`<loc>https://play.openvibe.network${loc.replace('&', '&amp;')}</loc>`), loc);
-- }
-- });
--
-- await check('llms.txt maps the game, its board and the read API', async () => {
-- const r = await t.get('/llms.txt');
-- assert.match(r.text, /^# OpenVibe\.Play/m);
-- assert.match(r.text, /Scraplandia: achievements and leaderboards/);
-- assert.match(r.text, /\/api\/v1\/titles\/scraplandia\/boards\/haul\?period=all/);
-- assert.ok(r.text.includes('(https://play.openvibe.network/index.md)'), 'home link points at its Markdown twin');
-- assert.ok(r.text.includes('\n## Optional\n'), 'llms.txt has an Optional section');
-- });
--
-- await check('llms-full.txt spells out the visible achievements, never a secret one', async () => {
-- const r = await t.get('/llms-full.txt');
-- assert.match(r.text, /First Haul/);
-- assert.doesNotMatch(r.text, /Ghost|Shh/);
-- });
-- } finally { await t.close(); }
-- done();
--})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/layout.test.js b/test/layout.test.js
ddeleted file mode 100644
iindex 437f2c2..0000000
---- a/test/layout.test.js
++++ /dev/null
@@@ -1,74 +0,0 @@
--'use strict';
--/**
-- * Every page carries the openvibe-shared boost marker and self-starting script (plan T11), so a
-- * same-site click swaps <main> in place without a reload. The marker names the layout's release,
-- * which the app sets from openvibe-shared/release (a swap only happens between pages of the same
-- * release; across a deploy it is a normal load), the boost tag names #main in data-main, and the
-- * shared navbar's sign-in follows the current page through the {path} template.
-- */
--const assert = require('assert');
--const { boot, check, done } = require('./helpers/boot');
--
--const MARKER = /<meta name="ov-boost" content="(play@[0-9A-Za-z._+-]{1,64})">/;
--const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#main" defer><\/script>/;
--
--(async () => {
-- const t = await boot();
-- const user = t.network.addUser('kim');
-- const release = (await t.get('/release.json')).json().release;
--
-- await check('every rendered page carries the ov-boost marker (the app\'s release) and the data-main boost script', async () => {
-- for (const p of ['/', '/updates']) {
-- const r = await t.get(p);
-- assert.strictEqual(r.status, 200, p);
-- const m = r.text.match(MARKER);
-- assert.ok(m, `${p}: no ov-boost marker`);
-- assert.strictEqual(m[1], `play@${release}`, `${p}: the marker is not the app's release`);
-- assert.match(r.text, BOOST, `${p}: no boost script with data-main`);
-- assert.match(r.text, /<main id="main"/, `${p}: the page's main is not #main`);
-- }
-- const signedIn = await t.get('/updates', { as: user });
-- assert.strictEqual(signedIn.status, 200);
-- assert.match(signedIn.text, MARKER, '/updates signed in: no ov-boost marker');
-- assert.match(signedIn.text, BOOST, '/updates signed in: no boost script');
-- });
--
-- await check('the home page head comes from openvibe-shared/shell with the same title, canonical, robots and JSON-LD', async () => {
-- const r = await t.get('/');
-- assert.strictEqual(r.status, 200);
-- const head = r.text.slice(0, r.text.indexOf('</head>'));
-- assert.strictEqual((r.text.match(/<title>/g) || []).length, 1, 'exactly one <title>');
-- assert.ok(head.includes('<title>OpenVibe.Play: Achievements, leaderboards and cloud saves</title>'), 'home title');
-- assert.ok(head.includes('<link rel="canonical" href="https://play.openvibe.network/">'), 'canonical');
-- assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
-- const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);
-- assert.deepStrictEqual(ld, ['WebSite', 'WebPage', 'FAQPage', 'Organization'], 'the home JSON-LD: the site, the page and the questions it shows');
-- const faq = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])).find((j) => j['@type'] === 'FAQPage');
-- for (const q of faq.mainEntity) assert.ok(r.text.includes(`<summary>${q.name.replace(/'/g, '&#39;')}</summary>`) || r.text.includes(`<summary>${q.name}</summary>`), `the page shows "${q.name}"`);
-- for (const tag of ['<meta property="og:title"', 'data-ov-icon="play"', '/css/app.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {
-- assert.ok(head.includes(tag), `head has ${tag}`);
-- }
-- assert.ok(r.text.includes('id="ov-footer"'), 'the server-rendered footer');
-- assert.ok(r.text.includes('OpenVibeFooter.init(window.__OV_PAGE.footer)'), 'the footer is initialised');
-- });
--
-- await check('the navbar config uses the {path} login template, not the path baked in', async () => {
-- const r = await t.get('/updates');
-- assert.match(r.text, /"loginUrl":"\/auth\/login\?next=\{path\}"/);
-- assert.ok(!/"loginUrl":"\/auth\/login\?next=%2F/.test(r.text), 'loginUrl still has the current path baked in');
-- });
--
-- await check('the home page shows the service name and the tagline server-side, with no local path', async () => {
-- const r = await t.get('/');
-- assert.strictEqual(r.status, 200, r.text.slice(0, 300));
-- assert.match(r.headers.get('content-type'), /^text\/html/);
-- assert.ok(r.text.includes('OpenVibe.Play'), 'the service name is on the page');
-- assert.ok(r.text.includes('Achievements, leaderboards'), 'what it is, on the page');
-- for (const id of ['pl-titles-h', 'sc-what-a-game-gets', 'sdk', 'sc-limits', 'pl-faq-h']) assert.ok(r.text.includes(`id="${id}"`), `the home has ${id}`);
-- assert.ok(r.text.includes('/shared/showcase.css'), 'the showcase stylesheet');
-- assert.ok(!r.text.includes('/home/') && !r.text.includes('/mnt/'), 'no local filesystem path on the page');
-- });
--
-- await t.close();
-- done();
--})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/moved-pages.test.js b/test/moved-pages.test.js
nnew file mode 100644
iindex 0000000..b3d1732
---- /dev/null
++++ b/test/moved-pages.test.js
@@@ -0,0 +1,94 @@
++'use strict';
++/**
++ * The Play pages moved into OpenVibe.Games: every old page address answers 301 to its counterpart there,
++ * and the API at /api/v1 does not move. The crawl files follow: Play's robots.txt names Games' sitemap,
++ * its sitemap and llms-full.txt redirect to Games', and its llms.txt says where the pages are and that the
++ * API stays on this host.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const GAMES = 'https://openvibe.games';
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('every page address answers 301 to its OpenVibe.Games counterpart', async () => {
++ const cases = [
++ ['/', `${GAMES}/achievements`],
++ ['/achievements', `${GAMES}/achievements`],
++ ['/me', `${GAMES}/achievements`],
++ ['/leaderboards', `${GAMES}/leaderboards`],
++ ['/developers', `${GAMES}/developers`],
++ ['/docs', `${GAMES}/developers`],
++ ['/updates', `${GAMES}/updates`],
++ ['/t/scraplandia', `${GAMES}/play`],
++ ['/t/salvage-rush', `${GAMES}/play?game=salvage-rush`],
++ ['/t/vibe-heroes', `${GAMES}/play/vibe-heroes`],
++ ];
++ for (const [from, to] of cases) {
++ const r = await t.get(from);
++ assert.strictEqual(r.status, 301, `${from}: ${r.status}`);
++ assert.strictEqual(r.headers.get('location'), to, from);
++ }
++ const unknown = await t.get('/t/sandbox-gem-run');
++ assert.strictEqual(unknown.status, 301);
++ assert.strictEqual(unknown.headers.get('location'), `${GAMES}/leaderboards?game=sandbox-gem-run`);
++ });
++
++ await check('a board page keeps its game, board and period on the Games leaderboards', async () => {
++ const r = await t.get('/t/salvage-rush/b/best?period=week');
++ assert.strictEqual(r.status, 301);
++ const loc = new URL(r.headers.get('location'));
++ assert.strictEqual(`${loc.origin}${loc.pathname}`, `${GAMES}/leaderboards`);
++ assert.deepStrictEqual(Object.fromEntries(loc.searchParams), {
++ game: 'salvage-rush',
++ board: 'best',
++ period: 'week',
++ });
++ });
++
++ await check('the API stays exactly where it is, under /api/v1', async () => {
++ const ping = await t.get('/api/v1/ping');
++ assert.strictEqual(ping.status, 200);
++ assert.strictEqual(ping.json().ok, true);
++ const titles = await t.get('/api/v1/titles');
++ assert.strictEqual(titles.status, 200);
++ assert.ok(Array.isArray(titles.json().titles));
++ });
++
++ await check('robots.txt names Games\' sitemap and keeps sign-in and the API out', async () => {
++ const r = await t.get('/robots.txt');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /Sitemap: https:\/\/openvibe\.games\/sitemap\.xml/);
++ assert.ok(r.text.includes('Disallow: /auth/'));
++ assert.ok(r.text.includes('Disallow: /api/'));
++ });
++
++ await check('sitemap.xml and llms-full.txt redirect to Games; llms.txt points at the moved pages and the API', async () => {
++ for (const [path, target] of [
++ ['/sitemap.xml', `${GAMES}/sitemap.xml`],
++ ['/llms-full.txt', `${GAMES}/llms-full.txt`],
++ ]) {
++ const r = await t.get(path);
++ assert.strictEqual(r.status, 301, path);
++ assert.strictEqual(r.headers.get('location'), target, path);
++ }
++ const llms = await t.get('/llms.txt');
++ assert.strictEqual(llms.status, 200);
++ assert.match(llms.text, /^# OpenVibe\.Play/m);
++ for (const page of ['/achievements', '/leaderboards', '/developers']) {
++ assert.ok(llms.text.includes(`${GAMES}${page}`), page);
++ }
++ assert.ok(llms.text.includes('https://play.openvibe.network/api/v1/titles'));
++ });
++
++ await check('an unknown page is a plain 404, not the old home page', async () => {
++ const r = await t.get('/nope');
++ assert.strictEqual(r.status, 404);
++ assert.ok(!r.text.includes('Achievements, leaderboards'));
++ assert.match(r.text, /openvibe\.games/);
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/no-internal-key.test.js b/test/no-internal-key.test.js
iindex 20761fe..185f79d 100644
---- a/test/no-internal-key.test.js
++++ b/test/no-internal-key.test.js
@@@ -38,7 +38,7 @@ function files(dir) {
const t = await boot();
await check('a whole session (sign-in, every page, the API) never sends an internal key to Network', async () => {
const u = t.network.addUser('nia');
-- for (const p of ['/', '/updates', '/api/v1/ping', '/auth/me']) await t.get(p, { as: u });
++ for (const p of ['/llms.txt', '/api/v1/ping', '/auth/me']) await t.get(p, { as: u });
const sent = t.network.requests.filter((r) => r.headers['x-internal-key']);
assert.strictEqual(sent.length, 0);
});
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
ddeleted file mode 100644
iindex cc40e62..0000000
---- a/test/perf-budget.test.js
++++ /dev/null
@@@ -1,64 +0,0 @@
--'use strict';
--// Size budgets for play.openvibe.network's home page (roadmap WS-T task 1, openvibe-shared/perf-budget): the
--// server as it runs (a fresh database), measured without a browser. Budgets sit about 10% above the
--// 2026-10-08 measurement (see below); raising one is a decision to state in the commit.
--// node test/perf-budget.test.js
--const assert = require('assert');
--const fs = require('fs');
--const net = require('net');
--const os = require('os');
--const path = require('path');
--const { spawn } = require('child_process');
--const { measure, check, format } = require('openvibe-shared/perf-budget');
--
--// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%
--// headroom; file counts are the measured counts and external files stay at 0.
--const BUDGETS = {
-- htmlRawKB: 36, // 35.1 with openvibe-shared 4.92.0: the page starts its footer (2026-11-01); 30.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; measured 24.9 (the home page: hero, features, steps, cta, JSON-LD); 27.8 with "Can I add my own game?" (10-25, also in the FAQPage JSON-LD)
-- htmlBrotliKB: 8.4, // 8.4 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 8.1 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 7 Shared 4.14 (4.13's home kit); 6.2
-- jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
-- jsRawKB: 353.9, // 353.9 with openvibe-shared 4.150.0 (2026-11-08: the bell opens the Dock on Activity, the Dock's phone header is in two rows, and the menu's orb is the close button with an open/close animation); 352.9 with openvibe-shared 4.149.0 (2026-11-08: the menu has tabs at the top; the phone's menu button is the orb of 4.148.0); 350.2 with openvibe-shared 4.142.0 (2026-11-07: the menu has a close button on a phone); 349.2 with openvibe-shared 4.139.0 (2026-11-07: the menu's feed rows lead with their picture, the footer and glyphs defer 38 KB); 348.2 with openvibe-shared 4.137.0 (2026-11-07: every script by its content hash, one menu button on a phone, the menu warms at idle); 346.1 with openvibe-shared 4.135.0 (2026-11-07: Ask your agent from any page (kit.askAgent), the Dock's Agent panel reads the timeline again, and the menu's feeds follow you); 345.4 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 345.4 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 343 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 342 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 340.4 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 338.4 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 337.1 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 335.8 with openvibe-shared 4.109.2 (2026-11-03: OpenVibe's own select, the navbar loads ov-forms); 333.1 with openvibe-shared 4.107.0 (2026-11-03: open.<tld> sites are named for their domain; the brand knows its front door); 332.5 with openvibe-shared 4.106.2 (2026-11-02: the site's name reads whole on a signed-in phone; the balances stack); 331.3 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 328.2 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 324.8 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 323.2 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 264.3 with Shared 4.13.0 (2026-10-26: account links); 239.8
-- jsBrotliKB: 84.4, // 84.4 with openvibe-shared 4.150.0 (2026-11-08: the bell opens the Dock on Activity, the Dock's phone header is in two rows, and the menu's orb is the close button with an open/close animation); 84.2 with openvibe-shared 4.149.0 (2026-11-08: the menu has tabs at the top; the phone's menu button is the orb of 4.148.0); 83.7 with openvibe-shared 4.142.0 (2026-11-07: the menu has a close button on a phone); 83.5 with openvibe-shared 4.139.0 (2026-11-07: the menu's feed rows lead with their picture, the footer and glyphs defer 38 KB); 83.1 with openvibe-shared 4.137.0 (2026-11-07: every script by its content hash, one menu button on a phone, the menu warms at idle); 82.6 with openvibe-shared 4.135.0 (2026-11-07: Ask your agent from any page (kit.askAgent), the Dock's Agent panel reads the timeline again, and the menu's feeds follow you); 82.5 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 82.5 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 81.9 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 81.7 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 81.5 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 81.1 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 80.9 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 80.2 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 79.5 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.5
-- cssFiles: 2, // 2 (app.css + the cached /shared/showcase.css)
-- cssRawKB: 41, // 40.3 with 2026-11-01: phone table cards (2026-11-01); 38.7 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 36.2 the home's phone row and fold (2026-10-29); 31.8 openvibe-shared 4.26.1 (hero visual); 26.3 with Shared 4.13.0 (2026-10-26: account links); 18.7 (app.css 7.1 with Play's cards, boards and achievement tiles, plus showcase.css; 2026-10-14)
-- cssBrotliKB: 9, // 8.6 with 2026-11-01: phone table cards (2026-11-01); 8.6 with 2026-11-01: phone table cards (2026-11-01); 8.3 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 7.8 the home's phone row and fold (2026-10-29); 5.8 with Shared 4.13.0 (2026-10-26: account links); 4.9 // raised 7 -> 8 for the openvibe-shared Kit 2 pin (kit.css grew) // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did
-- externalFiles: 0, // 0
--};
--
--const freePort = () => new Promise((resolve) => { const s = net.createServer(); s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)); }); });
--
--(async () => {
-- // A database of its own (not the shared dev PGlite in data/pglite, and not whatever the caller's
-- // DATABASE_URL names), so the measurement is the server on a fresh database and the run leaves nothing.
-- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-budget-'));
-- const pgliteDir = path.join(dir, 'pglite');
-- const port = await freePort();
-- const child = spawn(process.execPath, [path.join(__dirname, '..', 'server', 'index.js')], {
-- cwd: path.join(__dirname, '..'),
-- env: {
-- ...process.env, PORT: String(port), HOST: '127.0.0.1', NODE_ENV: 'test',
-- DATABASE_URL: '', DATABASE_DIRECT_URL: '', VALKEY_URL: '', PLAY_PGLITE_DIR: pgliteDir,
-- },
-- stdio: ['ignore', 'ignore', 'pipe'],
-- });
-- let stderr = '';
-- child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-2000); });
-- const base = `http://127.0.0.1:${port}`;
-- try {
-- let up = false;
-- for (let i = 0; i < 600 && !up; i++) {
-- up = await fetch(`${base}/api/health`).then((r) => r.ok).catch(() => false);
-- if (!up) await new Promise((r) => setTimeout(r, 100));
-- }
-- assert.ok(up, `the server did not start:\n${stderr}`);
-- assert.ok(fs.existsSync(pgliteDir), 'the server did not use the isolated database (PLAY_PGLITE_DIR)');
-- const m = await measure({ base });
-- const over = check(m, BUDGETS);
-- assert.deepStrictEqual(over, [], format(m, over));
-- console.log(format(m));
-- console.log('perf budget: all checks passed');
-- } finally {
-- child.kill();
-- fs.rmSync(dir, { recursive: true, force: true });
-- }
--})().catch((err) => { console.error(err); process.exitCode = 1; });
ddiff --git a/test/play-api.test.js b/test/play-api.test.js
iindex ffffc63..d10bdb4 100644
---- a/test/play-api.test.js
++++ b/test/play-api.test.js
@@@ -142,27 +142,22 @@ const DAY = 86_400_000;
assert.strictEqual((await t.get('/api/v1/people/not-a-subject/achievements')).status, 404);
});
-- await check('the pages: home, a title, a board with its tabs, the docs, and llms.txt name the game', async () => {
-- const home = await t.get('/');
-- assert.strictEqual(home.status, 200);
-- assert.match(home.text, /Salvage Rush/);
-- const title = await t.get('/t/salvage-rush');
-- assert.match(title.text, /<h1>Salvage Rush<\/h1>/);
-- assert.match(title.text, /A secret achievement/);
-- assert.doesNotMatch(title.text, /Nobody knows/, 'a secret achievement\'s text never shows');
-- const board = await t.get('/t/salvage-rush/b/best?period=week');
-- assert.match(board.text, /aria-current="page">This week</);
-- // Each period is its own page: the week board names itself, all time is the plain address.
-- assert.match(board.text, /<link rel="canonical" href="[^"]*\/t\/salvage-rush\/b\/best\?period=week">/);
-- const allTime = await t.get('/t/salvage-rush/b/best?period=all');
-- assert.match(allTime.text, /<link rel="canonical" href="[^"]*\/t\/salvage-rush\/b\/best">/);
-- assert.match(allTime.text, /<a href="\/t\/salvage-rush\/b\/best\?period=week">This week<\/a>/);
-- assert.doesNotMatch(board.text, /usr_|guest:/);
-- assert.strictEqual((await t.get('/t/nope')).status, 404);
-- assert.strictEqual((await t.get('/t/salvage-rush/b/nope')).status, 404);
-- assert.match((await t.get('/docs')).text, /POST \/titles\/:title\/scores/);
-- assert.match((await t.get('/llms.txt')).text, /Salvage Rush: achievements and leaderboards/);
-- assert.match((await t.get('/sitemap.xml')).text, /\/t\/salvage-rush\/b\/best\?period=week/);
++ await check('the player pages moved to OpenVibe.Games; llms.txt says where and keeps the API links', async () => {
++ for (const [from, to] of [
++ ['/', 'https://openvibe.games/achievements'],
++ ['/t/salvage-rush', 'https://openvibe.games/play?game=salvage-rush'],
++ ['/t/salvage-rush/b/best?period=week', 'https://openvibe.games/leaderboards?game=salvage-rush&board=best&period=week'],
++ ['/docs', 'https://openvibe.games/developers'],
++ ]) {
++ const r = await t.get(from);
++ assert.strictEqual(r.status, 301, from);
++ assert.strictEqual(r.headers.get('location'), to, from);
++ }
++ const llms = await t.get('/llms.txt');
++ assert.match(llms.text, /https:\/\/openvibe\.games\/achievements/);
++ assert.match(llms.text, /https:\/\/openvibe\.games\/leaderboards/);
++ assert.match(llms.text, /https:\/\/openvibe\.games\/developers/);
++ assert.match(llms.text, /https:\/\/play\.openvibe\.network\/api\/v1\/titles/);
});
} finally { await t.close(); }
done();
ddiff --git a/test/sandbox.test.js b/test/sandbox.test.js
iindex d025e72..2a69dd0 100644
---- a/test/sandbox.test.js
++++ b/test/sandbox.test.js
@@@ -50,18 +50,14 @@ const CAPS = ['play.title.manage', 'play.result.write', 'play.player.read'];
assert.strictEqual((await save(G1)).status, 200);
});
-- await check('a sandbox title is never listed or indexed, and answers at its id', async () => {
++ await check('a sandbox title is never listed, and its API still answers at its id', async () => {
const listed = (await t.get('/api/v1/titles')).json().titles.map((x) => x.id);
assert.ok(listed.includes('gem-run') && !listed.some((id) => id.startsWith('sandbox-')), listed.join(','));
assert.strictEqual((await t.get('/api/v1/titles/sandbox-gem-run')).json().title.sandbox, true);
-- const pageOf = await t.get('/t/sandbox-gem-run');
-- assert.strictEqual(pageOf.status, 200);
-- assert.match(pageOf.text, /<meta name="robots" content="noindex/);
-- assert.match(pageOf.text, /Sandbox: a developer is trying Play/);
-- assert.match((await t.get('/t/sandbox-gem-run/b/best')).text, /<meta name="robots" content="noindex/);
-- assert.match((await t.get('/t/gem-run')).text, /<meta name="robots" content="index/);
-- assert.ok(!(await t.get('/')).text.includes('sandbox-gem-run'), 'not on the home');
-- assert.ok(!(await t.get('/sitemap.xml')).text.includes('sandbox-'), 'not in the sitemap');
++ // The player pages moved to Games; the API did not, and a sandbox title is still readable by its app.
++ assert.strictEqual((await t.get('/t/sandbox-gem-run')).status, 301);
++ assert.strictEqual((await t.get('/t/gem-run')).status, 301);
++ assert.strictEqual((await t.get('/t/sandbox-gem-run/b/best')).status, 301);
});
await check('a sandbox title is deleted by its app with everything in it; a production title never', async () => {
@@@ -70,15 +66,13 @@ const CAPS = ['play.title.manage', 'play.result.write', 'play.player.read'];
assert.deepStrictEqual(await code(del('sandbox-gem-run', production)), [404, 'play.unknown_title'], 'another app is told it does not exist');
assert.strictEqual((await del('sandbox-gem-run', sandbox)).status, 204);
assert.strictEqual((await t.get('/api/v1/titles/sandbox-gem-run')).status, 404);
-- assert.strictEqual((await t.get('/t/sandbox-gem-run/b/best')).status, 404, 'its boards went with it');
assert.strictEqual((await put('sandbox-four', sandbox)).status, 201, 'the room it took is free again');
});
-- await check('the docs and the home say how to start', async () => {
++ await check('the API guide moved to the Games developers page', async () => {
const docs = await t.get('/docs');
-- assert.match(docs.text, /<h2 id="sandbox">Your own game: start in the sandbox<\/h2>/);
-- assert.match(docs.text, /https:\/\/openvibe\.services\/projects/);
-- assert.match((await t.get('/')).text, /Can I add my own game\?/);
++ assert.strictEqual(docs.status, 301);
++ assert.strictEqual(docs.headers.get('location'), 'https://openvibe.games/developers');
});
} finally { await t.close(); }
done();
ddiff --git a/test/telemetry.test.js b/test/telemetry.test.js
iindex bd69561..221aa08 100644
---- a/test/telemetry.test.js
++++ b/test/telemetry.test.js
@@@ -21,13 +21,13 @@ const path = require('path');
const t = await boot({ telemetrySink: async (batch) => { samples.push(...batch); } });
const requests = () => samples.filter((s) => s.operation === 'http.request');
try {
-- await check('five hits on the home page flush as one http.request sample: template, 2xx, count 5', async () => {
-- for (let i = 0; i < 5; i++) assert.strictEqual((await t.get('/')).status, 200);
++ await check('five hits on an API route flush as one http.request sample: template, 2xx, count 5', async () => {
++ for (let i = 0; i < 5; i++) assert.strictEqual((await t.get('/api/v1/ping')).status, 200);
await t.ctx.telemetry.flush();
-- const home = requests().filter((s) => s.resource === '/' && s.status === '2xx');
-- assert.strictEqual(home.length, 1, JSON.stringify(requests()));
-- assert.strictEqual(home[0].service, 'play');
-- assert.strictEqual(home[0].extra.count, 5);
++ const page = requests().filter((s) => s.resource === '/api/v1/ping' && s.status === '2xx');
++ assert.strictEqual(page.length, 1, JSON.stringify(requests()));
++ assert.strictEqual(page[0].service, 'play');
++ assert.strictEqual(page[0].extra.count, 5);
});
await check('/metrics and a /shared file make no sample', async () => {