openvibers/openvibe.sdk Commits

SDK 0.50.0: openvibe-sdk/play cloud saves (put, get, list, remove) (#55)

OP OpenVibers committed 28 days ago · 5f24bb597f · 8 files, +49 −8

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 51360de..12e7d29 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,12 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
++## 0.50.0 (2026-10-16)
++
++- `openvibe-sdk/play`: **cloud saves** — `play.saves.put(title, player, slot, { data, version })`, `get` (null when the
++ slot is empty), `list` and `remove`, for OpenVibe.Play's new save slots (8 per player per title, 64 KB of JSON each,
++ versioned: a stale `version` is `409 play.save_conflict`). Same token and capabilities as scores and achievements.
++
## 0.49.1 (2026-10-16)
- Outbox: Events' `409 events.loop_detected` is a permanent refusal: the row is marked rejected instead of being
ddiff --git a/STATUS.json b/STATUS.json
iindex 7016fca..5daa90b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.49.1",
++ "version": "0.50.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 386c560..7104df3 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.49.1, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.50.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.49.1';
++const SDK_VERSION = '0.50.0';
module.exports = {
createClient,
ddiff --git a/package-lock.json b/package-lock.json
iindex 2218d5c..1f59350 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.49.1",
++ "version": "0.50.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.49.1",
++ "version": "0.50.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex d375783..3870f31 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.49.1",
++ "version": "0.50.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
ddiff --git a/src/core/index.js b/src/core/index.js
iindex cd3d087..d03cd78 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.49.1';
++const SDK_VERSION = '0.50.0';
module.exports = {
createClient,
ddiff --git a/src/play.js b/src/play.js
iindex b678054..8d6156b 100644
---- a/src/play.js
++++ b/src/play.js
@@@ -1,6 +1,6 @@
'use strict';
/**
-- * openvibe-sdk/play — OpenVibe.Play, the network's game services (achievements and leaderboards, Steamworks-style),
++ * openvibe-sdk/play — OpenVibe.Play, the network's game services (achievements, leaderboards and cloud saves, Steamworks-style),
* for any game: register a title, post scores and unlocks, read a player's achievements. Server-side: a game posts
* with its own principal's token (audience openvibe.play): a first-party service token, or a developer app's token
* granted play.title.manage / play.result.write / play.player.read. The principal that registers a title is its only
@@@ -17,6 +17,9 @@
* await play.players.adopt('gem-run', 'abc12345', 'usr_…'); // a guest signed in: their rows become the person's
* await play.players.achievements('gem-run', 'usr_…'); // play.player-achievements@1
* await play.boards.get('gem-run', 'high-score', { period: 'week' }); // public: rank, name, score (never a player id)
++ * await play.saves.put('gem-run', 'usr_…', 'main', { data: { level: 3 }, version: 0 }); // → { slot, version: 1, … }
++ * await play.saves.get('gem-run', 'usr_…', 'main'); // → { slot, version, data, … } or null
++ * // a stale version is OpenVibeError 409 play.save_conflict: read again, merge, write with the new version
*
* Shapes: Contracts play.title-definition@1, play.result-request@1, play.result-response@1, play.title-result@1,
* play.player-achievements@1. Errors are OpenVibeError (status, code); a missing title or board is null from get().
@@@ -62,6 +65,20 @@ function createPlayClient(client, { baseUrl } = {}) {
/** A player's unlocks in a title you manage (play.player.read). */
achievements: (title, who, { signal } = {}) => call({ path: `/api/v1/titles/${enc(title)}/players/${enc(player(who))}/achievements`, signal }),
},
++ saves: {
++ /** Write a player's save slot (play.result.write): any JSON up to 64 KB. `version` is the one you read (0 for a
++ * new slot); a stale one is refused (409 play.save_conflict). Leave it out to overwrite. At most 8 slots. */
++ put: (title, who, slot, { data, version } = {}, { signal } = {}) => call({
++ method: 'PUT', path: `/api/v1/titles/${enc(title)}/players/${enc(player(who))}/saves/${enc(slot)}`, signal, idempotencyKey: false,
++ json: { data, ...(version !== undefined && version !== null ? { version } : {}) },
++ }),
++ /** One slot with its data (play.player.read), or null when the slot is empty. */
++ get: (title, who, slot, { signal } = {}) => orNull(call({ path: `/api/v1/titles/${enc(title)}/players/${enc(player(who))}/saves/${enc(slot)}`, signal })),
++ /** A player's slots in your title, without their data (play.player.read). */
++ list: (title, who, { signal } = {}) => call({ path: `/api/v1/titles/${enc(title)}/players/${enc(player(who))}/saves`, signal }),
++ /** Delete a slot (play.result.write). */
++ remove: (title, who, slot, { signal } = {}) => call({ method: 'DELETE', path: `/api/v1/titles/${enc(title)}/players/${enc(player(who))}/saves/${enc(slot)}`, signal, idempotencyKey: false }),
++ },
boards: {
/** Public: a leaderboard page (rank, name, score; never a player id), or null. period: e.g. 'all', 'week'. */
get: (title, board, { period, page, signal } = {}) => orNull(open({ path: `/api/v1/titles/${enc(title)}/boards/${enc(board)}`, query: { period, page }, signal })),
ddiff --git a/test/play.test.js b/test/play.test.js
iindex 3902165..2b6fa13 100644
---- a/test/play.test.js
++++ b/test/play.test.js
@@@ -32,7 +32,25 @@ const { OpenVibeError } = require('../src/core/errors');
assert.deepStrictEqual(calls[3].json, { person: KIM });
assert.deepStrictEqual(calls[5].query, { period: 'week', page: undefined });
assert.throws(() => play.achievements.unlock('gem-run', { player: 'kim', achievements: ['x'] }), /player/);
++
++ // Cloud saves: the title's own token, the slot in the path, the version only when given.
++ calls.length = 0;
++ await play.saves.put('gem-run', KIM, 'main', { data: { level: 3 }, version: 0 });
++ await play.saves.put('gem-run', KIM, 'main', { data: { level: 4 } });
++ await play.saves.get('gem-run', KIM, 'main');
++ await play.saves.list('gem-run', KIM);
++ await play.saves.remove('gem-run', KIM, 'main');
++ assert.deepStrictEqual(calls.map((c) => [c.method || 'GET', c.path, c.audience]), [
++ ['PUT', `/api/v1/titles/gem-run/players/${KIM}/saves/main`, 'openvibe.play'],
++ ['PUT', `/api/v1/titles/gem-run/players/${KIM}/saves/main`, 'openvibe.play'],
++ ['GET', `/api/v1/titles/gem-run/players/${KIM}/saves/main`, 'openvibe.play'],
++ ['GET', `/api/v1/titles/gem-run/players/${KIM}/saves`, 'openvibe.play'],
++ ['DELETE', `/api/v1/titles/gem-run/players/${KIM}/saves/main`, 'openvibe.play'],
++ ]);
++ assert.deepStrictEqual([calls[0].json, calls[1].json], [{ data: { level: 3 }, version: 0 }, { data: { level: 4 } }]);
++ assert.throws(() => play.saves.get('gem-run', 'kim', 'main'), /player/);
missing = true;
++ assert.strictEqual(await play.saves.get('gem-run', KIM, 'empty'), null, 'an empty slot is null');
assert.strictEqual(await play.titles.get('nope'), null);
assert.strictEqual(await play.boards.get('gem-run', 'nope'), null);
await assert.rejects(play.titles.define('nope', { name: 'x' }), (e) => e.status === 404, 'a write is not swallowed');