openvibers/openvibe.play Commits

Your own game, from the sandbox: a developer's sandbox app registers up to 3 sandbox- titles (guest players only, never adopted, listed, counted or indexed); a production app takes the real name (#41)

OP OpenVibers committed 19 days ago · afb065cebc · 7 files, +134 −19

Changes

ddiff --git a/README.md b/README.md
iindex 6043b60..af4d370 100644
---- a/README.md
++++ b/README.md
@@@ -8,6 +8,11 @@ its boards. The rules of each game stay in the game; Play keeps the record and a
- **Titles** belong to the principal that registered them (a game's service or app token). Only that principal writes to
a title; to anyone else it does not exist.
++- **Your own game, from the sandbox** (ADR-014 opt-in): any developer makes a project on openvibe.services and a sandbox
++ app with Play's three capabilities (in Network's default sandbox allowance). A sandbox app registers up to 3 titles
++ whose ids start with `sandbox-`, with guest players only (nothing adopted, no person's record touched), never listed,
++ counted or indexed: readable at their id, marked `sandbox: true`. A production app (staff turn production on for the
++ project) registers the real name; it never takes a `sandbox-` id.
- **Achievements**, including hidden ones whose name shows only once unlocked. A definition left out of an update is
retired, never deleted: what players earned stays.
- **Leaderboards** of two kinds: `best` ranks each player's best score, `sum` the total of theirs. Each reads this week
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 3dfa465..e4e2a10 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -22,7 +22,8 @@
* GET /me/saves, DELETE /me/saves/:title/:slot a person their own slots in every title; delete one
*
* Writes come from the principal that registered the title (a game's service token); another one is told the title
-- * does not exist. Public reads answer every origin without credentials. A refusal is RFC 9457 problem+json.
++ * does not exist. A developer's sandbox app manages sandbox-… titles only (play/store.js). Public reads answer every
++ * origin without credentials. A refusal is RFC 9457 problem+json.
*/
const express = require('express');
const { http } = require('openvibe-contracts');
@@@ -97,7 +98,7 @@ function createApi(ctx) {
// ── A title's own principal ───────────────────────────
r.put('/titles/:title', principal.requireCapability('play.title.manage'), titleOwner, limits.budget('play.define'), wrap(async (req, res) => {
-- const out = await play.define(req.principal.requester, req.params.title, req.body || {});
++ const out = await play.define(req.principal.requester, req.params.title, req.body || {}, { sandbox: req.principal.sandbox === true });
return res.status(out.created ? 201 : 200).json(out);
}));
r.post('/titles/:title/scores', principal.requireCapability('play.result.write'), titleOwner, limits.budget('play.write'), wrap(async (req, res) => {
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex a9e9267..f2c3d3e 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -20,7 +20,7 @@ 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, PER_PAGE } = require('../play/store');
++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');
@@@ -34,6 +34,7 @@ const HOME_FAQ = [
{ 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() {
@@@ -118,7 +119,7 @@ ${raw(showcase.features({
${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.' },
++ { title: 'Register the title', text: 'A game\'s server registers its title with its own OpenVibe token: its achievements and its boards. Yours starts in the sandbox.', href: '/docs#sandbox' },
{ title: 'Post what happened', text: 'Scores for each round and the achievements a player unlocked, each idempotent, so a retry never counts twice.' },
{ title: 'Read the boards anywhere', text: 'Boards are public JSON for any page: a best score or a total, this week or all time.' },
{ title: 'Guests count', text: 'A guest plays at once; when they sign in, the game moves their progress to their account.', href: '/docs' },
@@@ -173,10 +174,11 @@ ${raw(showcase.limits({
const boards = [];
for (const b of t.boards) boards.push(await play.board(t.id, b.id, { period: 'week' }));
page(req, res, {
-- index: true, cache: PUBLIC_CACHE, title: `${t.name}: achievements and leaderboards`,
++ 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>` : ''}
@@@ -201,7 +203,7 @@ ${boards.length ? html`<section aria-labelledby="pl-boards-h"><h2 id="pl-boards-
};
page(req, res, {
canonicalPath: link({ page: pageNo }),
-- index: true, cache: PUBLIC_CACHE, title: `${b.name}, ${period === 'week' ? 'this week' : 'all time'} · ${b.title_name}`,
++ index: !isSandbox(b.title), cache: PUBLIC_CACHE, title: `${b.name}, ${period === 'week' ? 'this week' : 'all time'} · ${b.title_name}`,
description: `The ${b.name} leaderboard of ${b.title_name}, ${period === 'week' ? 'this week' : 'all time'}: ${b.kind === 'sum' ? 'each player\'s total' : 'each player\'s best score'}.`,
body: html`<p class="pl-crumbs"><a href="/">Games</a> / <a href="/t/${b.title}">${b.title_name}</a> /</p>
<h1>${b.name}</h1>
@@@ -259,7 +261,14 @@ ${saves.length ? html`<section><h2>Your saves</h2><p class="pl-muted">What games
<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>`,
++<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> (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('/updates', (req, res) => page(req, res, {
ddiff --git a/server/http/principal.js b/server/http/principal.js
iindex 6bc8732..c8e0abe 100644
---- a/server/http/principal.js
++++ b/server/http/principal.js
@@@ -5,10 +5,13 @@
*
* { kind: 'user', requester: 'user:usr_…', project: null, viaSession } a person: their Network token as a Bearer,
* or this site's session cookie
-- * { kind: 'app', requester: 'app:app_…' | 'agent:agt_…' | 'service:x', project: 'prj_…' | null, claims }
++ * { kind: 'app', requester: 'app:app_…' | 'agent:agt_…' | 'service:x', project: 'prj_…' | null, sandbox, claims }
* a Network app, agent or service token for
* audience openvibe.play; each route names
-- * ONE capability it needs
++ * ONE capability it needs. `sandbox`: an
++ * outside developer's sandbox app (ADR-014
++ * opt-in), which manages sandbox titles only
++ * (play/store.js)
* { kind: 'anonymous' }
*
* A request that presents a token is judged on that token alone: a bad one is refused, never downgraded to the
@@@ -52,10 +55,12 @@ function createPrincipal({ config, keys }) {
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
const publicKey = await keys.pemForToken(token);
if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
-- const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });
++ // Sandbox app tokens are accepted (ADR-014 opt-in): a sandbox app registers and writes sandbox titles only,
++ // whose players are guests and which are never listed or counted (play/store.js).
++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience, acceptSandbox: true });
if (!r.ok) return { error: [401, r.code || 'token.invalid', r.reason || 'the token does not verify'] };
const project = typeof r.claims.project_id === 'string' && PROJECT_RE.test(r.claims.project_id) ? r.claims.project_id : null;
-- return { principal: { kind: 'app', requester: requesterOfSub(r.claims.sub), project, claims: r.claims } };
++ return { principal: { kind: 'app', requester: requesterOfSub(r.claims.sub), project, sandbox: r.claims.env === 'sandbox', claims: r.claims } };
}
// A person's own Network token (the same kind this site's session holds).
const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience });
ddiff --git a/server/play/store.js b/server/play/store.js
iindex 195639d..5326a83 100644
---- a/server/play/store.js
++++ b/server/play/store.js
@@@ -18,6 +18,11 @@
*
* Every write names the title it is for, and only the principal that registered a title writes to it: another
* principal is told the title does not exist. A player is a person (usr_…) or a guest the title names (guest:<opaque>).
++ *
++ * The sandbox (ADR-014 opt-in): any developer's sandbox app tries Play on titles of its own. Their ids start with
++ * `sandbox-` (a sandbox app registers nothing else and a production app never one), an app has at most
++ * MAX_SANDBOX_TITLES, their players are guests only (no person's record changes, nothing is adopted), and they are never
++ * listed or counted: readable by their id, marked `sandbox: true`. The real name comes with a production app.
*/
const PLAYER_RE = /^(usr_[0-9A-HJKMNP-TV-Z]{26}|guest:[A-Za-z0-9_-]{8,128})$/;
const SLOT_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
@@@ -29,6 +34,9 @@ const PER_PAGE = 25;
const MAX_ENTRIES = 100;
const MAX_DEFS = 200;
const EARLIEST = Date.UTC(2020, 0, 1);
++const SANDBOX_PREFIX = 'sandbox-';
++const MAX_SANDBOX_TITLES = 3;
++const isSandbox = (titleId) => String(titleId || '').startsWith(SANDBOX_PREFIX);
class PlayError extends Error {
constructor(status, code, detail) { super(detail || code); this.status = status; this.code = code; this.detail = detail; }
@@@ -60,6 +68,12 @@ function createPlay({ s }) {
if (!PLAYER_RE.test(String(p || ''))) fail(400, 'play.bad_player', 'player is a person (usr_…) or guest:<opaque> (8 to 128 of A-Z a-z 0-9 _ -)');
return p;
};
++ /** A player of this title: a sandbox title's are guests only. */
++ const playerIn = (titleId, p) => {
++ player(p);
++ if (isSandbox(titleId) && !String(p).startsWith('guest:')) fail(400, 'play.sandbox_guests_only', 'a sandbox title\'s players are guests (guest:<opaque>): no person\'s record changes until your game has a production app');
++ return p;
++ };
function defsOf(list, what, extra) {
if (list === undefined) return null;
@@@ -75,8 +89,10 @@ function createPlay({ s }) {
});
}
-- async function define(owner, titleId, doc = {}) {
++ async function define(owner, titleId, doc = {}, { sandbox = false } = {}) {
if (!TITLE_RE.test(String(titleId || ''))) fail(400, 'play.bad_title', 'a title id is 2 to 40 of a-z, 0-9 and -');
++ if (sandbox && !isSandbox(titleId)) fail(400, 'play.sandbox_title', `a sandbox app's title id starts with ${SANDBOX_PREFIX} (the real name comes with a production app)`);
++ if (!sandbox && isSandbox(titleId)) fail(400, 'play.sandbox_title', `${SANDBOX_PREFIX} ids are for sandbox apps`);
const name = str(doc.name, 80);
if (!name) fail(400, 'play.bad_title', 'a title needs a name');
const url = doc.url == null || doc.url === '' ? null : String(doc.url);
@@@ -90,6 +106,9 @@ function createPlay({ s }) {
return await s.tx(async () => {
const cur = await db.maybe('SELECT * FROM play_titles WHERE id = $1 FOR UPDATE', [titleId]);
if (cur && cur.owner !== owner) fail(409, 'play.title_taken', 'another principal manages this title');
++ if (!cur && sandbox && Number(await db.value('SELECT COUNT(*) FROM play_titles WHERE owner = $1', [owner])) >= MAX_SANDBOX_TITLES) {
++ fail(409, 'play.sandbox_limit', `a sandbox app has at most ${MAX_SANDBOX_TITLES} titles`);
++ }
if (cur) await db.exec('UPDATE play_titles SET name = $2, url = $3, summary = $4, updated_at = $5 WHERE id = $1', [titleId, name, url, str(doc.summary, 300), now]);
else await db.exec('INSERT INTO play_titles (id, owner, name, url, summary, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $6)', [titleId, owner, name, url, str(doc.summary, 300), now]);
if (achievements) {
@@@ -128,7 +147,7 @@ function createPlay({ s }) {
if (!Number.isSafeInteger(score) || score < 0) fail(400, 'play.bad_scores', `entries[${i}].score is a whole number, 0 or more`);
const name = str(e.name, 40);
if (!name) fail(400, 'play.bad_scores', `entries[${i}].name is the name to show`);
-- return [player(e.player), name, score];
++ return [playerIn(titleId, e.player), name, score];
});
let recorded = 0;
await s.tx(async () => {
@@@ -142,7 +161,7 @@ function createPlay({ s }) {
async function unlock(owner, titleId, post = {}) {
await ownTitle(owner, titleId);
-- const p = player(post.player);
++ const p = playerIn(titleId, post.player);
const ids = post.achievements;
if (!Array.isArray(ids) || !ids.length || ids.length > MAX_DEFS || !ids.every((x) => DEF_RE.test(String(x)))) fail(400, 'play.bad_unlock', 'achievements is a list of achievement ids');
const known = new Set((await db.many('SELECT id FROM play_achievements WHERE title_id = $1 AND NOT retired', [titleId])).map((r) => r.id));
@@@ -163,6 +182,7 @@ function createPlay({ s }) {
/** A guest signed in: their unlocks and scores in this title become the person's (the earliest unlock wins). */
async function adopt(owner, titleId, guest, person) {
await ownTitle(owner, titleId);
++ if (isSandbox(titleId)) fail(400, 'play.sandbox_guests_only', 'a sandbox title never adopts: its players stay guests');
if (!/^guest:/.test(player(guest))) fail(400, 'play.bad_player', 'only a guest is adopted');
if (!/^usr_/.test(player(person))) fail(400, 'play.bad_player', 'a guest is adopted by a person (usr_…)');
return await s.tx(() => moveRows(titleId, guest, person));
@@@ -240,7 +260,7 @@ function createPlay({ s }) {
WHERE a.title_id = $1 AND NOT a.retired GROUP BY a.title_id, a.id ORDER BY a.position, a.id`, [t.id]);
const boards = await db.many('SELECT id, name, kind FROM play_boards WHERE title_id = $1 AND NOT retired ORDER BY position, id', [t.id]);
return {
-- id: t.id, name: t.name, url: t.url, summary: t.summary, players,
++ id: t.id, name: t.name, url: t.url, summary: t.summary, players, ...(isSandbox(t.id) ? { sandbox: true } : {}),
achievements: achievements.map((a) => (a.hidden
? { id: a.id, hidden: true, unlocked_by: a.unlocked_by, name: 'A secret achievement', description: '' }
: { id: a.id, name: a.name, description: a.description, unlocked_by: a.unlocked_by })),
@@@ -250,7 +270,7 @@ function createPlay({ s }) {
}
async function titles() {
-- const ids = (await db.many('SELECT id FROM play_titles ORDER BY created_at, id')).map((r) => r.id);
++ const ids = (await db.many("SELECT id FROM play_titles WHERE id NOT LIKE 'sandbox-%' ORDER BY created_at, id")).map((r) => r.id);
const out = [];
for (const id of ids) out.push(await title(id));
return out;
@@@ -276,7 +296,7 @@ function createPlay({ s }) {
/** Writes a slot. `version` (optional) is the version the writer read: a stale one is refused (409), a new slot takes 0. */
async function putSave(owner, titleId, playerId, slot, { data, version } = {}) {
await ownTitle(owner, titleId);
-- player(playerId); slotOf(slot);
++ playerIn(titleId, playerId); slotOf(slot);
if (data === undefined) fail(400, 'play.bad_save', 'data is the save, any JSON value');
const json = JSON.stringify(data);
const size = Buffer.byteLength(json, 'utf8');
@@@ -326,4 +346,4 @@ function createPlay({ s }) {
putSave, getSave, listSaves, deleteSave, mySaves, deleteMySave };
}
--module.exports = { createPlay, PlayError, PLAYER_RE, TITLE_RE, SLOT_RE, MAX_SLOTS, MAX_SAVE_BYTES, weekStart, PER_PAGE };
++module.exports = { createPlay, PlayError, PLAYER_RE, TITLE_RE, SLOT_RE, MAX_SLOTS, MAX_SAVE_BYTES, MAX_SANDBOX_TITLES, SANDBOX_PREFIX, isSandbox, weekStart, PER_PAGE };
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex f2da8c2..f017486 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -14,7 +14,7 @@ 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: 27.5, // measured 24.9 (the home page: hero, features, steps, cta, JSON-LD)
++ htmlRawKB: 28.5, // 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: 6.9, // 6.2
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 264, // 239.8
ddiff --git a/test/sandbox.test.js b/test/sandbox.test.js
nnew file mode 100644
iindex 0000000..03218b6
---- /dev/null
++++ b/test/sandbox.test.js
@@@ -0,0 +1,75 @@
++'use strict';
++// Any developer tries Play from a sandbox app (ADR-014 opt-in): its titles' ids start with sandbox-, it has at most
++// three, their players are guests only and never adopted, and they are never listed, counted or indexed (readable by
++// their id, marked sandbox). A production app never takes a sandbox- id.
++const assert = require('assert');
++const crypto = require('crypto');
++const { serviceAuth } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++
++const PROJECT = 'prj_01JABCDEFGHJKMNPQRSTVWXYZ0';
++const P1 = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0';
++const G1 = 'guest:a1b2c3d4e5f6';
++const CAPS = ['play.title.manage', 'play.result.write', 'play.player.read'];
++
++(async () => {
++ const t = await boot();
++ try {
++ const app = (id, env) => {
++ const now = Math.floor(Date.now() / 1000);
++ return { bearer: serviceAuth.signServiceToken({ iss: t.network.url, sub: `app:${id}`, actor_type: 'app', aud: ['openvibe.play'], cap: CAPS, ns: [PROJECT], project_id: PROJECT, env, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` }, t.network.privatePem) };
++ };
++ const sandbox = app('app_01JABCDEFGHJKMNPQRSTVWXYZ0', 'sandbox');
++ const production = app('app_01JABCDEFGHJKMNPQRSTVWXYZ1', 'production');
++ const DOC = { name: 'Gem Run', achievements: [{ id: 'first-gem', name: 'First Gem' }], boards: [{ id: 'best', name: 'Best', kind: 'best' }] };
++ const put = (id, auth, body = DOC) => t.get(`/api/v1/titles/${id}`, { ...auth, method: 'PUT', json: body });
++ const code = async (p) => { const r = await p; return [r.status, r.json().code]; };
++
++ await check('a sandbox app registers sandbox- titles only, at most three; a production app never one', async () => {
++ assert.deepStrictEqual(await code(put('gem-run', sandbox)), [400, 'play.sandbox_title']);
++ const made = await put('sandbox-gem-run', sandbox);
++ assert.strictEqual(made.status, 201, made.text);
++ assert.strictEqual(made.json().title.sandbox, true);
++ assert.deepStrictEqual(await code(put('sandbox-other', production)), [400, 'play.sandbox_title']);
++ assert.strictEqual((await put('gem-run', production)).status, 201, 'a production app takes the real name');
++ assert.strictEqual((await put('sandbox-two', sandbox)).status, 201);
++ assert.strictEqual((await put('sandbox-three', sandbox)).status, 201);
++ assert.deepStrictEqual(await code(put('sandbox-four', sandbox)), [409, 'play.sandbox_limit']);
++ assert.strictEqual((await put('sandbox-gem-run', sandbox, { ...DOC, summary: 'Updated.' })).status, 200, 'updating one is not a new one');
++ });
++
++ await check('a sandbox title\'s players are guests only, and none is adopted', async () => {
++ const post = (path, json) => t.get(`/api/v1/titles/sandbox-gem-run/${path}`, { ...sandbox, json });
++ assert.deepStrictEqual(await code(post('scores', { board: 'best', ref: 'r1', entries: [{ player: P1, name: 'Ada', score: 5 }] })), [400, 'play.sandbox_guests_only']);
++ assert.deepStrictEqual((await post('scores', { board: 'best', ref: 'r1', entries: [{ player: G1, name: 'Tester', score: 5 }] })).json(), { recorded: 1, repeated: 0 });
++ assert.deepStrictEqual(await code(post('unlocks', { player: P1, achievements: ['first-gem'] })), [400, 'play.sandbox_guests_only']);
++ assert.deepStrictEqual((await post('unlocks', { player: G1, achievements: ['first-gem'] })).json(), { unlocked: ['first-gem'] });
++ assert.deepStrictEqual(await code(post(`players/${G1}/adopt`, { person: P1 })), [400, 'play.sandbox_guests_only']);
++ const save = (player) => t.get(`/api/v1/titles/sandbox-gem-run/players/${player}/saves/main`, { ...sandbox, method: 'PUT', json: { data: { level: 1 } } });
++ assert.deepStrictEqual(await code(save(P1)), [400, 'play.sandbox_guests_only']);
++ assert.strictEqual((await save(G1)).status, 200);
++ });
++
++ await check('a sandbox title is never listed or indexed, and 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');
++ });
++
++ await check('the docs and the home say how to start', 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\?/);
++ });
++ } finally { await t.close(); }
++ done();
++})();