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)
Changes
@@@ -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.networkOpenVibe.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 adoptsthem 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 theachievements 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,nnew file mode 100644@@@ -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);@@@ -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 enveloper.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;}@@@ -136,13 +136,14 @@ ${b.pages > 1 ? html`<nav class="pl-pages" aria-label="Pages">${pageNo > 1 ? htmif (!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>`,}));@@@ -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 } = {}) {@@@ -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 };@@@ -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();nnew file mode 100644@@@ -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();++})();
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
# OpenVibe.Play
# 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)
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
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
@@ -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
- **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`),
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.
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
- **Idempotent writes.** A round's scores carry a `ref`; posting it again changes nothing. An unlock answers only the
achievements that were new.
achievements that were new.
- **Personal data.** Only people's rows are: they are in the person's account export and erased with their account
- **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.
@@ -32,7 +38,7 @@ game, to the player's account page and to any page that wants a board.
## Owns
## Owns
- 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 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`).
(`play_subject_merges`).
- The public pages at play.openvibe.network and the `/api/v1` read and write API.
- 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,
- Request telemetry (`telemetry_samples`, `0003_telemetry.sql`): each HTTP request aggregated by route template, method, status class,
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -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);
@@ -13,6 +13,11 @@
@@ -13,6 +13,11 @@
* POST /titles/:title/unlocks play.result.write unlock achievements for a player (idempotent)
* 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
* 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/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
* 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. Public reads answer every origin without credentials. A refusal is RFC 9457 problem+json.
@@ -26,7 +31,7 @@ function createApi(ctx) {
@@ -26,7 +31,7 @@ function createApi(ctx) {
const { config, principal, limits, play } = ctx;
const { config, principal, limits, play } = ctx;
const r = asyncRouter();
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((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use(principal.middleware);
r.use(principal.middleware);
@@ -62,6 +67,21 @@ function createApi(ctx) {
@@ -62,6 +67,21 @@ function createApi(ctx) {
return res.json({ titles: await play.playerAchievements(req.principal.requester.slice(5)) });
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 ───────────────────────────
// ── A title's own principal ───────────────────────────
r.put('/titles/:title', principal.requireCapability('play.title.manage'), titleOwner, limits.budget('play.define'), wrap(async (req, res) => {
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 || {});
@@ -82,6 +102,23 @@ function createApi(ctx) {
@@ -82,6 +102,23 @@ function createApi(ctx) {
res.json({ title: req.params.title, player: req.params.player, achievements: t ? t.achievements : [] });
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;
return r;
}
}
@@ -136,13 +136,14 @@ ${b.pages > 1 ? html`<nav class="pl-pages" aria-label="Pages">${pageNo > 1 ? htm
@@ -136,13 +136,14 @@ ${b.pages > 1 ? html`<nav class="pl-pages" aria-label="Pages">${pageNo > 1 ? htm
if (!me) {
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>` });
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 }));
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
page(req, res, {
page(req, res, {
title: 'Your achievements',
title: 'Your achievements',
body: html`<h1>Your achievements</h1>
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>`)
${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}">${
@@ -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>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>
<li><code>GET /titles/:title/players/:player/achievements</code> (<code>play.player.read</code>): a player's unlocks in your game.</li>
</ul>
</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>`,
<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>`,
}));
}));
@@ -4,8 +4,8 @@
@@ -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
* 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.
* 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.
* Nothing exported is a secret.
*/
*/
@@ -14,6 +14,7 @@ const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
@@ -14,6 +14,7 @@ const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const TABLES = [
const TABLES = [
{ table: 'play_unlocks', subject: 'player', file: 'achievements.json', columns: ['title_id', 'achievement_id', 'unlocked_at'], order: 'unlocked_at' },
{ 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_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 } = {}) {
function create({ db, log = console } = {}) {
@@ -8,6 +8,8 @@
@@ -8,6 +8,8 @@
* recordScores(owner, titleId, post) { board, ref, entries: [{ player, name, score }], at? } → { recorded }
* recordScores(owner, titleId, post) { board, ref, entries: [{ player, name, score }], at? } → { recorded }
* unlock(owner, titleId, post) { player, achievements: [id…], at? } → { unlocked: [newly unlocked ids] }
* 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
* 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
* mergeSubject({ mergeId, from, into }) network.subject.merged: the folded account's rows, in every title, become the
* survivor's (once per merge)
* survivor's (once per merge)
* board(titleId, boardId, { period, page }) ranked entries: name and score, never a player id
* board(titleId, boardId, { period, page }) ranked entries: name and score, never a player id
@@ -18,6 +20,9 @@
@@ -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>).
* 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 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 TITLE_RE = /^[a-z0-9][a-z0-9-]{1,39}$/;
const DEF_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
const DEF_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
const PER_PAGE = 25;
const PER_PAGE = 25;
@@ -177,7 +182,16 @@ function createPlay({ s }) {
@@ -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'}
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);
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);
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 }) {
async function mergeSubject({ mergeId, from, into }) {
@@ -255,7 +269,61 @@ function createPlay({ s }) {
@@ -255,7 +269,61 @@ function createPlay({ s }) {
return [...by.values()];
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 };
@@ -90,7 +90,7 @@ const DAY = 86_400_000;
@@ -90,7 +90,7 @@ const DAY = 86_400_000;
const P3 = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ2';
const P3 = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ2';
await t.get('/api/v1/titles/salvage-rush/unlocks', { ...games, json: { player: P3, achievements: ['first-haul'] } });
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 } });
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();
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');
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();
const read = (await t.get(`/api/v1/titles/salvage-rush/players/${P3}/achievements`, games)).json();
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -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();
+})();