openvibers/openvibe.play Commits
The player pages moved to OpenVibe.Games: every old page 301s, the API stays (#175)
Changes
@@@ -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 existingboards), `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, adopserver/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)```@@@ -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;@@@ -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)'],ddeleted file mode 100644@@@ -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 };@@@ -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-varsapp.use((err, req, res, _next) => {@@@ -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 };nnew file mode 100644@@@ -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 };ddeleted file mode 100644@@@ -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&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 };@@@ -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),@@@ -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 () => {ddeleted file mode 100644@@@ -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('&', '&')}</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); });ddeleted file mode 100644@@@ -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, ''')}</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); });nnew file mode 100644@@@ -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); });@@@ -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);});ddeleted file mode 100644@@@ -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; });@@@ -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();@@@ -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();@@@ -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 () => {
@@ -48,7 +48,7 @@ game, to the player's account page and to any page that wants a board.
@@ -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`).
- 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
- Unlocks, scores and cloud saves per player (`play_unlocks`, `play_scores`, `play_saves`), guest adoption, and the merges applied
(`play_subject_merges`).
(`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,
- 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,
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
`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.
@@ -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):
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
`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
(time boards, their ties, validation and units), `time-migration.test.js` (the kind CHECK widening over existing
boards), `account-data.test.js`
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
## Security
@@ -140,7 +140,8 @@ server/play/store.js titles, achievements, boards, scores, unlocks, adop
@@ -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/account-data.js account export and deletion (ADR-033)
server/play/subject-merge.js account merges (ADR-029)
server/play/subject-merge.js account merges (ADR-029)
server/http/api.js /api/v1
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)
migrations/ the schema (0001 the store, 0002 the merges applied)
```
```
@@ -2,14 +2,14 @@
@@ -2,14 +2,14 @@
## play.openvibe.network — Nginx configuration (REFERENCE)
## 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
## 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
## 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:
## /internal/ (OpenVibe.Events deliveries) off the internet, and compresses text. There is no www. name:
## the wildcard does not cover a second level.
## 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;
limit_req_zone $binary_remote_addr zone=play_auth:10m rate=10r/m;
@@ -1,15 +1,16 @@
@@ -1,15 +1,16 @@
'use strict';
'use strict';
// What openvibe.play tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
// 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 = {
module.exports = {
id: 'play',
id: 'play',
name: 'OpenVibe.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.',
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',
site: 'play.openvibe.network',
api: '/api/v1',
api: '/api/v1',
- docs: '/docs',
+ docs: 'https://openvibe.games/developers',
openapi: '/openapi.json',
openapi: '/openapi.json',
policy: {
policy: {
auth: ['none', 'Network OIDC', 'Network service token (Bearer, per game)'],
auth: ['none', 'Network OIDC', 'Network service token (Bearer, per game)'],
deleted file mode 100644
deleted file mode 100644
@@ -1,30 +0,0 @@
@@ -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 };
@@ -4,13 +4,14 @@
@@ -4,13 +4,14 @@
* OpenVibe.Play — Express app factory; server/index.js listens, tests build their own instance with a temp database
* OpenVibe.Play — Express app factory; server/index.js listens, tests build their own instance with a temp database
* and a mock Network.
* and a mock Network.
*
*
- * /, /t/:title, /me, /docs, /updates the pages (http/pages.js)
* /api/v1/* the API (http/api.js)
* /api/v1/* the API (http/api.js)
* /auth/* Network SSO with PKCE (auth/sso.js)
* /auth/* Network SSO with PKCE (auth/sso.js)
* /api/health, /api/ready, /release.json, /metrics (loopback only)
* /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 path = require('path');
const express = require('express');
const express = require('express');
@@ -26,15 +27,15 @@ const { createKeyStore } = require('./auth/keys');
@@ -26,15 +27,15 @@ const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createPrincipal } = require('./http/principal');
const { createApi } = require('./http/api');
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 { createServiceReadiness } = require('./observability');
const { createCallerLimits } = require('./http/caller-limits');
const { createCallerLimits } = require('./http/caller-limits');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createPlay } = require('./play/store');
const { createPlay } = require('./play/store');
const subjectMerge = require('./play/subject-merge');
const subjectMerge = require('./play/subject-merge');
const accountDataLib = require('./play/account-data');
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 PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
@@ -182,24 +183,23 @@ async function createApp(opts = {}) {
@@ -182,24 +183,23 @@ async function createApp(opts = {}) {
app.use('/api/v1', createApi(ctx));
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 }));
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();
const agentLayer = express();
require('openvibe-shared/agent-layer').mount(agentLayer, {
require('openvibe-shared/agent-layer').mount(agentLayer, {
descriptor: require('./agent-descriptor'),
descriptor: require('./agent-descriptor'),
- render: require('./agent-pages').createAgentPages(ctx, pageRoutes.publicViews).renderPublicPage,
+ render: async () => null,
});
});
app.use((req, res, next) => {
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
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, _next) => {
app.use((err, req, res, _next) => {
@@ -1,135 +1,73 @@
@@ -1,135 +1,73 @@
'use strict';
'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 seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const SITE_NAME = 'OpenVibe.Play';
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 r = asyncRouter();
- const site = String(config.baseUrl).replace(/\/+$/, '');
- const abs = (p) => `${site}${p}`;
const TEXT = cache.htmlHeaders({ maxAge: 3600 });
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) => {
r.get('/robots.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(
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({
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
name: SITE_NAME,
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: [
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;
return r;
}
}
-module.exports = { createDiscoveryRoutes, homeJsonLd, publicPages, DESCRIPTION, SITE_NAME };
+module.exports = { createDiscoveryRoutes, DESCRIPTION, SITE_NAME, GAMES_SITE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -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 };
deleted file mode 100644
deleted file mode 100644
@@ -1,305 +0,0 @@
@@ -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&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 };
@@ -86,8 +86,7 @@ function renderPage(o) {
@@ -86,8 +86,7 @@ function renderPage(o) {
description: o.description || `${SITE_NAME} — ${TAGLINE}`,
description: o.description || `${SITE_NAME} — ${TAGLINE}`,
canonical,
canonical,
robots: o.index ? 'index, follow' : 'noindex, nofollow',
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,
agentLayer: o.agent === true,
llms: `${String(o.config.baseUrl).replace(/\/+$/, '')}/llms.txt`,
llms: `${String(o.config.baseUrl).replace(/\/+$/, '')}/llms.txt`,
jsonLd: (o.jsonLd || []).filter(Boolean),
jsonLd: (o.jsonLd || []).filter(Boolean),
@@ -108,14 +108,18 @@ const cookieHeader = (res) => res.headers.getSetCookie().map((c) => c.split(';')
@@ -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');
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 () => {
await check('the API answers /ping and unknown routes are problem+json 404', async () => {
deleted file mode 100644
deleted file mode 100644
@@ -1,50 +0,0 @@
@@ -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('&', '&')}</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); });
deleted file mode 100644
deleted file mode 100644
@@ -1,74 +0,0 @@
@@ -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, ''')}</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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -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); });
@@ -38,7 +38,7 @@ function files(dir) {
@@ -38,7 +38,7 @@ function files(dir) {
const t = await boot();
const t = await boot();
await check('a whole session (sign-in, every page, the API) never sends an internal key to Network', async () => {
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');
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']);
const sent = t.network.requests.filter((r) => r.headers['x-internal-key']);
assert.strictEqual(sent.length, 0);
assert.strictEqual(sent.length, 0);
});
});
deleted file mode 100644
deleted file mode 100644
@@ -1,64 +0,0 @@
@@ -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; });
@@ -142,27 +142,22 @@ const DAY = 86_400_000;
@@ -142,27 +142,22 @@ const DAY = 86_400_000;
assert.strictEqual((await t.get('/api/v1/people/not-a-subject/achievements')).status, 404);
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(); }
} finally { await t.close(); }
done();
done();
@@ -50,18 +50,14 @@ const CAPS = ['play.title.manage', 'play.result.write', 'play.player.read'];
@@ -50,18 +50,14 @@ const CAPS = ['play.title.manage', 'play.result.write', 'play.player.read'];
assert.strictEqual((await save(G1)).status, 200);
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);
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.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);
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 () => {
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'];
@@ -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.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 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('/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');
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');
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(); }
} finally { await t.close(); }
done();
done();
@@ -21,13 +21,13 @@ const path = require('path');
@@ -21,13 +21,13 @@ const path = require('path');
const t = await boot({ telemetrySink: async (batch) => { samples.push(...batch); } });
const t = await boot({ telemetrySink: async (batch) => { samples.push(...batch); } });
const requests = () => samples.filter((s) => s.operation === 'http.request');
const requests = () => samples.filter((s) => s.operation === 'http.request');
try {
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();
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 () => {
await check('/metrics and a /shared file make no sample', async () => {