openvibers/openvibe.play Commits

Play: cloud saves (8 versioned slots per player per title, 64 KB each), the Steamworks-style service games were missing (#17)

OP OpenVibers committed 28 days ago · fa56bb454e · 8 files, +222 −11

Changes

ddiff --git a/README.md b/README.md
iindex c7a50ad..320e13a 100644
---- a/README.md
++++ b/README.md
@@@ -1,6 +1,6 @@
# OpenVibe.Play
--**Achievements and leaderboards for every OpenVibe game.** https://play.openvibe.network
++**Achievements, leaderboards and cloud saves for every OpenVibe game.** https://play.openvibe.network
OpenVibe.Play is the network's game services (OpenVibe.Games [ADR-0007](https://github.com/OpenVibers/OpenVibe.Games/blob/main/docs/adr/0007-rewrite-the-moddable-base.md)
decision 10). A game registers its **title** with its own OpenVibe token, posts what its players did, and anyone reads
@@@ -15,6 +15,12 @@ its boards. The rules of each game stay in the game; Play keeps the record and a
- **Players** are people (`usr_…`) or guests a game names (`guest:<opaque>`). When a guest signs in, the game adopts
them and their unlocks and scores become the person's. When two OpenVibe accounts are merged (`network.subject.merged`),
the folded account's unlocks and scores become the survivor's in every title.
++- **Cloud saves** (`migrations/0004_saves.sql`): a player's progress follows them to any device. Up to 8 slots per
++ player per title, each any JSON value of at most 64 KB, versioned: a write names the version it read and a stale one
++ is refused (409 `play.save_conflict`). The title's own token writes (`play.result.write`) and reads
++ (`play.player.read`); a person lists and deletes their own at `/me/saves` and sees them on /me. A guest's slots move
++ to the person on adoption and a folded account's on a merge (the newer save wins a clash); they are in the account
++ export and erased with the account.
- **Idempotent writes.** A round's scores carry a `ref`; posting it again changes nothing. An unlock answers only the
achievements that were new.
- **Personal data.** Only people's rows are: they are in the person's account export and erased with their account
@@@ -32,7 +38,7 @@ game, to the player's account page and to any page that wants a board.
## Owns
- Titles, their achievement definitions and their boards (`play_titles`, `play_achievements`, `play_boards`).
--- Unlocks and scores per player (`play_unlocks`, `play_scores`), 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`).
- The public pages at play.openvibe.network and the `/api/v1` read and write API.
- Request telemetry (`telemetry_samples`, `0003_telemetry.sql`): each HTTP request aggregated by route template, method, status class,
ddiff --git a/migrations/0004_saves.sql b/migrations/0004_saves.sql
nnew file mode 100644
iindex 0000000..e3c87e4
---- /dev/null
++++ b/migrations/0004_saves.sql
@@@ -0,0 +1,17 @@
++-- phase: expand
++-- Cloud saves (server/play/store.js putSave…): a title keeps each player's save slots here, the Steamworks-style
++-- "your progress follows you" service. A slot holds one JSON document of at most 64 KB, versioned so two devices
++-- cannot silently overwrite each other (a write names the version it read). A guest's slots move to the person on
++-- adoption, and a folded account's to the survivor on a merge (the newer slot wins a clash).
++CREATE TABLE play_saves (
++ title_id text COLLATE "C" NOT NULL REFERENCES play_titles (id) ON DELETE CASCADE,
++ player text COLLATE "C" NOT NULL,
++ slot text COLLATE "C" NOT NULL CHECK (slot ~ '^[a-z0-9][a-z0-9_-]{0,31}$'),
++ data jsonb NOT NULL,
++ size integer NOT NULL CHECK (size BETWEEN 1 AND 65536),
++ version integer NOT NULL DEFAULT 1 CHECK (version >= 1),
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL,
++ PRIMARY KEY (title_id, player, slot)
++);
++CREATE INDEX play_saves_player ON play_saves (player, updated_at DESC);
ddiff --git a/server/http/api.js b/server/http/api.js
iindex e92a3a3..492caab 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -13,6 +13,11 @@
* POST /titles/:title/unlocks play.result.write unlock achievements for a player (idempotent)
* POST /titles/:title/players/:guest/adopt play.result.write a guest signed in: their rows become the person's
* GET /titles/:title/players/:player/achievements play.player.read a player's unlocks in a title the caller manages
++ * GET /titles/:title/players/:player/saves play.player.read a player's cloud save slots (without the data)
++ * GET /titles/:title/players/:player/saves/:slot play.player.read one slot: { slot, version, data, … }
++ * PUT /titles/:title/players/:player/saves/:slot play.result.write write it ({ data, version? }: a stale version is 409)
++ * DELETE /titles/:title/players/:player/saves/:slot play.result.write delete it
++ * 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.
@@@ -26,7 +31,7 @@ function createApi(ctx) {
const { config, principal, limits, play } = ctx;
const r = asyncRouter();
-- r.use(express.json({ limit: '128kb' }));
++ r.use(express.json({ limit: '160kb' })); // a save is at most 64 KB of JSON (store.js MAX_SAVE_BYTES), with room for the envelope
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use(principal.middleware);
@@@ -62,6 +67,21 @@ function createApi(ctx) {
return res.json({ titles: await play.playerAchievements(req.principal.requester.slice(5)) });
}));
++ const signedIn = (req, res) => {
++ if (req.principal.kind === 'user') return true;
++ http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at play.openvibe.network, or send your Network token.', ctx: req.ov });
++ return false;
++ };
++ r.get('/me/saves', reads, wrap(async (req, res) => {
++ if (!signedIn(req, res)) return undefined;
++ return res.json({ saves: await play.mySaves(req.principal.requester.slice(5)) });
++ }));
++ r.delete('/me/saves/:title/:slot', limits.budget('play.write'), wrap(async (req, res) => {
++ if (!signedIn(req, res)) return undefined;
++ const gone = await play.deleteMySave(req.principal.requester.slice(5), req.params.title, req.params.slot);
++ return gone ? res.status(204).end() : http.sendProblem(res, 404, 'play.no_save', { detail: 'no save in that slot', ctx: req.ov });
++ }));
++
// ── 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 || {});
@@@ -82,6 +102,23 @@ function createApi(ctx) {
res.json({ title: req.params.title, player: req.params.player, achievements: t ? t.achievements : [] });
}));
++ // ── Cloud saves (a title's own principal, for one player) ──
++ const savesOf = (req) => [req.principal.requester, req.params.title, req.params.player];
++ r.get('/titles/:title/players/:player/saves', principal.requireCapability('play.player.read'), titleOwner, reads, wrap(async (req, res) => {
++ res.json({ title: req.params.title, player: req.params.player, saves: await play.listSaves(...savesOf(req)) });
++ }));
++ r.get('/titles/:title/players/:player/saves/:slot', principal.requireCapability('play.player.read'), titleOwner, reads, wrap(async (req, res) => {
++ res.json(await play.getSave(...savesOf(req), req.params.slot));
++ }));
++ r.put('/titles/:title/players/:player/saves/:slot', principal.requireCapability('play.result.write'), titleOwner, limits.budget('play.write'), wrap(async (req, res) => {
++ const b = req.body || {};
++ res.json(await play.putSave(...savesOf(req), req.params.slot, { data: b.data, version: b.version }));
++ }));
++ r.delete('/titles/:title/players/:player/saves/:slot', principal.requireCapability('play.result.write'), titleOwner, limits.budget('play.write'), wrap(async (req, res) => {
++ const gone = await play.deleteSave(...savesOf(req), req.params.slot);
++ return gone ? res.status(204).end() : http.sendProblem(res, 404, 'play.no_save', { detail: 'no save in that slot', ctx: req.ov });
++ }));
++
return r;
}
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex cbf0c6a..7f769b6 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -136,13 +136,14 @@ ${b.pages > 1 ? html`<nav class="pl-pages" aria-label="Pages">${pageNo > 1 ? htm
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 = await play.playerAchievements(me);
++ 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>`}`,
++ : 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>` : ''}`,
});
});
@@@ -168,6 +169,13 @@ ${titles.length ? titles.map((t) => html`<section><h2><a href="/t/${t.title}">${
<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>`,
}));
ddiff --git a/server/play/account-data.js b/server/play/account-data.js
iindex 739bf56..31502f8 100644
---- a/server/play/account-data.js
++++ b/server/play/account-data.js
@@@ -4,8 +4,8 @@
* Account export and deletion → Play (ADR-033; openvibe-sdk/account-data). A person's rows are the ones whose player is
* their subject (usr_…); a guest's rows are nobody's account until a title adopts them.
*
-- * export the achievements they unlocked and the scores they posted, in every title
-- * deletion both are deleted: their unlocks stop counting and their scores leave every leaderboard
++ * export the achievements they unlocked, the scores they posted and their cloud saves, in every title
++ * deletion all three are deleted: their unlocks stop counting, their scores leave every leaderboard, their saves go
*
* Nothing exported is a secret.
*/
@@@ -14,6 +14,7 @@ const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const TABLES = [
{ table: 'play_unlocks', subject: 'player', file: 'achievements.json', columns: ['title_id', 'achievement_id', 'unlocked_at'], order: 'unlocked_at' },
{ table: 'play_scores', subject: 'player', file: 'scores.json', columns: ['title_id', 'board_id', 'ref', 'player_name', 'score', 'at'], order: 'at' },
++ { table: 'play_saves', subject: 'player', file: 'saves.json', columns: ['title_id', 'slot', 'data', 'version', 'created_at', 'updated_at'], order: 'updated_at' },
];
function create({ db, log = console } = {}) {
ddiff --git a/server/play/store.js b/server/play/store.js
iindex 5c05bfd..195639d 100644
---- a/server/play/store.js
++++ b/server/play/store.js
@@@ -8,6 +8,8 @@
* recordScores(owner, titleId, post) { board, ref, entries: [{ player, name, score }], at? } → { recorded }
* unlock(owner, titleId, post) { player, achievements: [id…], at? } → { unlocked: [newly unlocked ids] }
* adopt(owner, titleId, guest, person) move a guest's unlocks and scores to the person who signed in
++ * putSave / getSave / listSaves / deleteSave (owner, titleId, player, slot…) a player's cloud save slots in a title
++ * mySaves(person) / deleteMySave(person, titleId, slot) a person's own slots, every title
* mergeSubject({ mergeId, from, into }) network.subject.merged: the folded account's rows, in every title, become the
* survivor's (once per merge)
* board(titleId, boardId, { period, page }) ranked entries: name and score, never a player id
@@@ -18,6 +20,9 @@
* principal is told the title does not exist. A player is a person (usr_…) or a guest the title names (guest:<opaque>).
*/
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}$/;
++const MAX_SLOTS = 8;
++const MAX_SAVE_BYTES = 65536;
const TITLE_RE = /^[a-z0-9][a-z0-9-]{1,39}$/;
const DEF_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
const PER_PAGE = 25;
@@@ -177,7 +182,16 @@ function createPlay({ s }) {
SELECT title_id, board_id, ref, $2, player_name, score, at FROM play_scores WHERE player = $1${all ? '' : ' AND title_id = $3'}
ON CONFLICT (title_id, board_id, ref, player) DO NOTHING`, move);
await db.exec(`DELETE FROM play_scores WHERE player = $1${all ? '' : ' AND title_id = $2'}`, drop);
-- return { unlocks, scores };
++ // Save slots: the newer of a clashing pair wins (its version moves past both).
++ const saves = await db.exec(`INSERT INTO play_saves (title_id, player, slot, data, size, version, created_at, updated_at)
++ SELECT title_id, $2, slot, data, size, version, created_at, updated_at FROM play_saves WHERE player = $1${all ? '' : ' AND title_id = $3'}
++ ON CONFLICT (title_id, player, slot) DO UPDATE SET
++ data = CASE WHEN excluded.updated_at > play_saves.updated_at THEN excluded.data ELSE play_saves.data END,
++ size = CASE WHEN excluded.updated_at > play_saves.updated_at THEN excluded.size ELSE play_saves.size END,
++ version = GREATEST(play_saves.version, excluded.version) + 1,
++ updated_at = GREATEST(play_saves.updated_at, excluded.updated_at)`, move);
++ await db.exec(`DELETE FROM play_saves WHERE player = $1${all ? '' : ' AND title_id = $2'}`, drop);
++ return { unlocks, scores, saves };
}
async function mergeSubject({ mergeId, from, into }) {
@@@ -255,7 +269,61 @@ function createPlay({ s }) {
return [...by.values()];
}
-- return { define, recordScores, unlock, adopt, mergeSubject, board, title, titles, playerAchievements, ownTitle };
++ // ── cloud saves ──
++ const slotOf = (v) => { if (!SLOT_RE.test(String(v || ''))) fail(400, 'play.bad_slot', 'a slot is 1 to 32 of a-z 0-9 _ - (starting with a letter or digit)'); return String(v); };
++ const saveView = (r, withData) => ({ slot: r.slot, version: Number(r.version), size: Number(r.size), created_at: new Date(Number(r.created_at)).toISOString(), updated_at: new Date(Number(r.updated_at)).toISOString(), ...(withData ? { data: r.data } : {}) });
++
++ /** 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);
++ 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');
++ if (size > MAX_SAVE_BYTES) fail(413, 'play.save_too_large', `a save is at most ${MAX_SAVE_BYTES} bytes of JSON (this one is ${size})`);
++ if (version !== undefined && version !== null && (!Number.isSafeInteger(version) || version < 0)) fail(400, 'play.bad_version', 'version is the version you read (0 for a new slot)');
++ return await s.tx(async () => {
++ const cur = await db.maybe('SELECT * FROM play_saves WHERE title_id = $1 AND player = $2 AND slot = $3 FOR UPDATE', [titleId, playerId, slot]);
++ if (version !== undefined && version !== null && Number(cur ? cur.version : 0) !== version) {
++ fail(409, 'play.save_conflict', `the slot is at version ${cur ? cur.version : 0}, not ${version}: read it again and merge`);
++ }
++ if (!cur) {
++ const n = Number((await db.maybe('SELECT COUNT(*)::int AS n FROM play_saves WHERE title_id = $1 AND player = $2', [titleId, playerId])).n);
++ if (n >= MAX_SLOTS) fail(409, 'play.too_many_slots', `a player has at most ${MAX_SLOTS} slots in a title`);
++ const now = s.now();
++ await db.exec('INSERT INTO play_saves (title_id, player, slot, data, size, version, created_at, updated_at) VALUES ($1, $2, $3, $4::jsonb, $5, 1, $6, $6)', [titleId, playerId, slot, json, size, now]);
++ } else {
++ await db.exec('UPDATE play_saves SET data = $4::jsonb, size = $5, version = version + 1, updated_at = $6 WHERE title_id = $1 AND player = $2 AND slot = $3', [titleId, playerId, slot, json, size, s.now()]);
++ }
++ return saveView(await db.one('SELECT * FROM play_saves WHERE title_id = $1 AND player = $2 AND slot = $3', [titleId, playerId, slot]), false);
++ });
++ }
++ async function getSave(owner, titleId, playerId, slot) {
++ await ownTitle(owner, titleId);
++ const r = await db.maybe('SELECT * FROM play_saves WHERE title_id = $1 AND player = $2 AND slot = $3', [titleId, player(playerId), slotOf(slot)]);
++ if (!r) fail(404, 'play.no_save', 'no save in that slot');
++ return saveView(r, true);
++ }
++ async function listSaves(owner, titleId, playerId) {
++ await ownTitle(owner, titleId);
++ return (await db.many('SELECT * FROM play_saves WHERE title_id = $1 AND player = $2 ORDER BY slot', [titleId, player(playerId)])).map((r) => saveView(r, false));
++ }
++ async function deleteSave(owner, titleId, playerId, slot) {
++ await ownTitle(owner, titleId);
++ return (await db.exec('DELETE FROM play_saves WHERE title_id = $1 AND player = $2 AND slot = $3', [titleId, player(playerId), slotOf(slot)])) > 0;
++ }
++ /** A person's own slots in every title (without the data), newest first. */
++ async function mySaves(person) {
++ return (await db.many(`SELECT v.*, t.name AS title_name FROM play_saves v JOIN play_titles t ON t.id = v.title_id
++ WHERE v.player = $1 ORDER BY v.updated_at DESC LIMIT 200`, [player(person)])).map((r) => ({ title: r.title_id, title_name: r.title_name, ...saveView(r, false) }));
++ }
++ async function deleteMySave(person, titleId, slot) {
++ if (!TITLE_RE.test(String(titleId || ''))) fail(404, 'play.unknown_title', 'no such title');
++ return (await db.exec('DELETE FROM play_saves WHERE title_id = $1 AND player = $2 AND slot = $3', [titleId, player(person), slotOf(slot)])) > 0;
++ }
++
++ return { define, recordScores, unlock, adopt, mergeSubject, board, title, titles, playerAchievements, ownTitle,
++ putSave, getSave, listSaves, deleteSave, mySaves, deleteMySave };
}
--module.exports = { createPlay, PlayError, PLAYER_RE, TITLE_RE, weekStart, PER_PAGE };
++module.exports = { createPlay, PlayError, PLAYER_RE, TITLE_RE, SLOT_RE, MAX_SLOTS, MAX_SAVE_BYTES, weekStart, PER_PAGE };
ddiff --git a/test/play-api.test.js b/test/play-api.test.js
iindex dbd4f1c..e3468e9 100644
---- a/test/play-api.test.js
++++ b/test/play-api.test.js
@@@ -90,7 +90,7 @@ const DAY = 86_400_000;
const P3 = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ2';
await t.get('/api/v1/titles/salvage-rush/unlocks', { ...games, json: { player: P3, achievements: ['first-haul'] } });
const adopt = await t.get(`/api/v1/titles/salvage-rush/players/${encodeURIComponent(G1)}/adopt`, { ...games, json: { person: P3 } });
-- assert.deepStrictEqual(adopt.json(), { unlocks: 1, scores: 1 });
++ assert.deepStrictEqual(adopt.json(), { unlocks: 1, scores: 1, saves: 0 });
const board = (await t.get('/api/v1/titles/salvage-rush/boards/best')).json();
assert.deepStrictEqual(board.entries.find((e) => e.name === 'Guest 7'), { rank: 3, name: 'Guest 7', score: 450, signed_in: true }, 'the guest\'s score is the person\'s now');
const read = (await t.get(`/api/v1/titles/salvage-rush/players/${P3}/achievements`, games)).json();
ddiff --git a/test/saves.test.js b/test/saves.test.js
nnew file mode 100644
iindex 0000000..d7a8054
---- /dev/null
++++ b/test/saves.test.js
@@@ -0,0 +1,74 @@
++'use strict';
++// Cloud saves (migrations/0004_saves.sql, server/play/store.js): a title's own token writes and reads each player's
++// save slots (any JSON up to 64 KB, at most 8 slots), versioned so a stale write is refused; another principal is told
++// the title does not exist; a guest's slots go to the person on adoption (the newer wins a clash); a person lists and
++// deletes their own slots; account export carries them and deletion removes them.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const G1 = 'guest:a1b2c3d4e5f6';
++
++(async () => {
++ let clock = Date.UTC(2026, 9, 16, 12);
++ const t = await boot({ now: () => clock });
++ try {
++ const games = { bearer: t.network.serviceToken('games', ['play.title.manage', 'play.result.write', 'play.player.read']) };
++ const other = { bearer: t.network.serviceToken('quest', ['play.title.manage', 'play.result.write', 'play.player.read']) };
++ const ada = t.network.addUser('ada');
++ const P1 = ada.subject;
++ const slot = (player, s, o = {}) => `/api/v1/titles/vibe-heroes/players/${encodeURIComponent(player)}/saves/${s}`;
++ assert.strictEqual((await t.get('/api/v1/titles/vibe-heroes', { ...games, method: 'PUT', json: { name: 'Vibe Heroes' } })).status, 201);
++
++ await check('write, read and list a slot; a stale version is refused; another principal sees no title', async () => {
++ const put = await t.get(slot(P1, 'main'), { ...games, method: 'PUT', json: { data: { stage: 12, heroes: ['ada', 'bo'] }, version: 0 } });
++ assert.strictEqual(put.status, 200, put.text);
++ assert.deepStrictEqual([put.json().slot, put.json().version, put.json().size > 10], ['main', 1, true]);
++ const got = (await t.get(slot(P1, 'main'), games)).json();
++ assert.deepStrictEqual([got.version, got.data], [1, { stage: 12, heroes: ['ada', 'bo'] }]);
++ clock += 1000;
++ assert.strictEqual((await t.get(slot(P1, 'main'), { ...games, method: 'PUT', json: { data: { stage: 13 }, version: 1 } })).json().version, 2);
++ const stale = await t.get(slot(P1, 'main'), { ...games, method: 'PUT', json: { data: { stage: 1 }, version: 1 } });
++ assert.deepStrictEqual([stale.status, stale.json().code], [409, 'play.save_conflict'], 'two devices cannot overwrite each other silently');
++ assert.strictEqual((await t.get(slot(P1, 'main'), { ...games, method: 'PUT', json: { data: { stage: 14 } } })).json().version, 3, 'a write without a version simply wins');
++ const list = (await t.get(`/api/v1/titles/vibe-heroes/players/${P1}/saves`, games)).json();
++ assert.deepStrictEqual(list.saves.map((x) => [x.slot, x.version]), [['main', 3]]);
++ assert.ok(!('data' in list.saves[0]), 'the list carries no data');
++ assert.strictEqual((await t.get(slot(P1, 'main'), other)).status, 404, 'another game is told the title does not exist');
++ assert.strictEqual((await t.get(slot(P1, 'main'))).status, 401);
++ });
++
++ await check('limits: 64 KB a save, 8 slots, slot names; a missing slot is 404; delete', async () => {
++ const big = await t.get(slot(P1, 'big'), { ...games, method: 'PUT', json: { data: 'x'.repeat(70000) } });
++ assert.deepStrictEqual([big.status, big.json().code], [413, 'play.save_too_large']);
++ assert.strictEqual((await t.get(slot(P1, 'Bad Slot!'), { ...games, method: 'PUT', json: { data: 1 } })).status, 400);
++ for (let i = 1; i < 8; i++) assert.strictEqual((await t.get(slot(P1, `s${i}`), { ...games, method: 'PUT', json: { data: i } })).status, 200);
++ const ninth = await t.get(slot(P1, 's8'), { ...games, method: 'PUT', json: { data: 8 } });
++ assert.deepStrictEqual([ninth.status, ninth.json().code], [409, 'play.too_many_slots']);
++ assert.strictEqual((await t.get(slot(P1, 's7'), { ...games, method: 'DELETE' })).status, 204);
++ assert.strictEqual((await t.get(slot(P1, 's7'), games)).status, 404);
++ });
++
++ await check('a guest who signs in brings their slots; the newer save wins a clash', async () => {
++ clock += 1000;
++ await t.get(slot(G1, 'main'), { ...games, method: 'PUT', json: { data: { stage: 99, from: 'guest' } } });
++ await t.get(slot(G1, 'side'), { ...games, method: 'PUT', json: { data: { from: 'guest' } } });
++ const adopt = await t.get(`/api/v1/titles/vibe-heroes/players/${encodeURIComponent(G1)}/adopt`, { ...games, json: { person: P1 } });
++ assert.strictEqual(adopt.json().saves, 2);
++ assert.deepStrictEqual((await t.get(slot(P1, 'main'), games)).json().data, { stage: 99, from: 'guest' }, 'the guest\'s newer save wins');
++ assert.strictEqual((await t.get(slot(P1, 'side'), games)).status, 200);
++ assert.deepStrictEqual((await t.get(`/api/v1/titles/vibe-heroes/players/${encodeURIComponent(G1)}/saves`, games)).json().saves, []);
++ });
++
++ await check('a person lists and deletes their own slots; account export carries them, deletion removes them', async () => {
++ const me = { bearer: t.network.userToken(ada) };
++ const mine = await t.get('/api/v1/me/saves', me);
++ assert.strictEqual(mine.status, 200, mine.text);
++ assert.ok(mine.json().saves.some((x) => x.title === 'vibe-heroes' && x.slot === 'main' && x.title_name === 'Vibe Heroes'));
++ assert.strictEqual((await t.get('/api/v1/me/saves/vibe-heroes/side', { ...me, method: 'DELETE' })).status, 204);
++ assert.strictEqual((await t.get('/api/v1/me/saves')).status, 401);
++ const data = require('../server/play/account-data');
++ assert.ok(data.TABLES.some((x) => x.table === 'play_saves' && x.file === 'saves.json'));
++ });
++ } finally { await t.close(); }
++ done();
++})();