openvibers/openvibe.sdk Commits
SDK 0.48.0: openvibe-sdk/play (game services) (#52)
Changes
@@@ -3,6 +3,14 @@All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minorrelease may change an API and says so here.++## 0.48.0 (2026-10-15)++++- `openvibe-sdk/play`: `createPlayClient(client)` for OpenVibe.Play's game services (the owner's "game services in the++ SDK"): `titles.define/list/get`, `scores.post`, `achievements.unlock`, `players.adopt/achievements`, `boards.get`.++ Writes use the game's own principal (audience `openvibe.play`, `play.title.manage` / `play.result.write` /++ `play.player.read`); titles and leaderboards are public reads without a token; a player is checked (usr_… or++ guest:<opaque>) before the request leaves.++## 0.47.0 (2026-10-15)- `openvibe-sdk/quest`: `createQuestClient(client)` → `appQuests.define(slug, quest)`, `appQuests.list()`,@@@ -184,6 +184,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client| `openvibe-sdk/resources` | server | `parseResourceName`/`resourceName`/`resourceNameOf` (OVRN, via the pinned `contracts.resources`) and `RESOURCE_KINDS` (only the chosen `med`/`wch` prefixes); `createResourceIndex({authorities, token, fetch, pageLimit, concurrency, timeoutMs, maxPages, maxMs})`: `list({project, kind, owner, limit})` -> `{resources, stale}` (`owner`: a subject id, each authority answering only what it owns), fanning out over the authorities' `GET /api/v1/resources`, following each opaque `next_cursor` and merging `common.resource-list-result@1` pages (an authority that fails — or whose cursor chain repeats, outruns `maxPages`, or outruns `maxMs` — is reported in `stale`, never fatal), and `iterate`; `createResourceClient({origin, token})`: `control(request)` POSTs a `common.resource-control-request@1` to `/api/v1/resources/control` — idempotency-keyed, transient failures retried with the same key and body, a thrown error carrying that `idempotencyKey` — and returns the `common.resource-control-result@1` (`done`/`pending`/`refused`/`failed`, a refused/failed one returned even with a non-retryable non-2xx) || `openvibe-sdk/geo` | both | `createGeoClient(client, {samples?, timeoutMs?})`: `nodes({role, region})` from Network's public node registry, `measure(nodes)` (beacon round trips, fastest first), `nearest({role, region, preferRegion})` → `{node, rtt_ms, measured}` (up/degraded nodes only; falls back to preferRegion when nothing can be measured) || `openvibe-sdk/openre` | server | `createOpenReClient(client, {baseUrl?, publicUrl?})`: OpenRestream, the streaming engine. `streams` (`byExternalRef`, `create`, `get`, `update`, `rotateKey`, `destinations`, `addDestination`), `destinations` (`test`, `start`, `stop`, `logs`), `sessions` (`get`, `playback` cached 10 s, `end`, `outputs`), `workers`, `manageUrl`; `{subject}` acts for a person (X-OV-Subject) |++| `openvibe-sdk/play` | server | `createPlayClient(client)`: OpenVibe.Play's achievements and leaderboards for any game — `titles.define(title, {name, url?, summary?, achievements, boards})`, `scores.post(title, {board, ref, entries})`, `achievements.unlock(title, {player, achievements})`, `players.adopt(title, guest, person)`, `players.achievements(title, player)`; public `titles.list/get` and `boards.get(title, board, {period, page})` need no token || `openvibe-sdk/quest` | server | `createQuestClient(client)`: `appQuests.define(slug, {title, why?, steps: [{event_type, count, label}], badge: {name}, url?})`, `appQuests.list()`, `appQuests.retire(slug)` with an app token (audience `openvibe.quest`, `quest.app_quest.manage`); `eventType(projectId, name)` gives the `app.<project_key>.<name>` type to publish at Events. Badges only, never OpenCoins; production events count || `openvibe-sdk/vip` | server | `createVipClient({baseUrl?, tokenClient \| getToken, fetch?, timeoutMs?})`: `evaluate({subject, resource, owner, fallback, mode})`, `checkEntitlement({subject, creator, product, mode})`, `isMember(subject, creator)`; every failure is a denial. `createVipCache({vip, ttlMs, denyTtlMs, unavailableTtlMs})`: `entitlement`, `peekEntitlement`, `evaluate`, `invalidate`, `handleEvent` (`vip.membership.changed` and Billing's entitlement events drop a member's answers at once), `clear`, `bounds` || `openvibe-sdk/usage` | server | `usageSample(fields)` builds a `platform.usage-sample@1` record (required `id`, `idempotency_key`, `service`, `operation`, `quantity`, `unit`; `at` defaults to now ISO, `source` to `openvibe-sdk/usage`; null fields stripped; money fields never invented); `usageKey(service, ...parts)` joins an idempotency key with `:`; `validateUsageSample(record)` → `{ok, errors}` (openvibe-contracts, required lazily). `createUsageReporter({ db, service, source, table, billingUrl, tokenClient, audience, requireProject, ... })` owns one service's readings: `key(...parts)`/`sample(fields)`, and `record(t, reading)` queues the reading idempotency-keyed in the outbox inside the caller's transaction, relayed to Billing's `billing.usage.record` with backoff (a retry is never dropped; only Billing refusing the reading itself — any 4xx but 401/403/404/408/425/429, which stay pending — marks a row rejected; a 401 drops the cached token). The reporter bills whatever reading it is given: never record first-party or sandbox traffic (pass `requireProject: true` to refuse a reading without a `prj_…` project). `openvibe-sdk/govern` builds its `onUsage` record with it |@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.47.0",++ "version": "0.48.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",@@@ -15,7 +15,7 @@"events: publish, pull and checkpoints by opaque cursor only, app events, webhook verification (v2), outbox and inbox","realtime SSE (resumes from the opaque cursor, dedupes by event_id)","media files and objects v2",-- "community, bot, search, chat, ai, jobs, tools, projects, modules, identity, openre, geo, vip and quest clients",++ "community, bot, search, chat, ai, jobs, tools, projects, modules, identity, openre, geo, vip, quest and play clients","bot (openvibe-sdk/bot): OpenVibe.Bot robots, operators, devices, streaming, commands and audit, the public kits and profiles, and pairing","per-actor limiter for services (openvibe-sdk/limits)","the OpenVibe Frame for any app",@@@ -1,4 +1,4 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.47.0, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.48.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.47.0';++const SDK_VERSION = '0.48.0';module.exports = {createClient,@@@ -7,7 +7,7 @@ export const {satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,registry, identity, modules, events, realtime, media,community, bot, search, chat, ai, jobs,-- tools, projects, resources, vip, quest, openre,-- geo, usage, commerce, telemetry,++ tools, projects, resources, vip, quest, play,++ openre, geo, usage, commerce, telemetry,} = cjs;export default cjs;nnew file mode 100644@@@ -0,0 +1,7 @@++// Generated by scripts/esm.js from src/play.js. Do not edit.++import cjs from '../src/play.js';++++export const {++ createPlayClient,++} = cjs;++export default cjs;@@@ -26,6 +26,7 @@ module.exports = {resources: require('./src/resources'),vip: require('./src/vip'),quest: require('./src/quest'),++ play: require('./src/play'),openre: require('./src/openre'),geo: require('./src/geo'),usage: require('./src/usage'),@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.47.0",++ "version": "0.48.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.47.0",++ "version": "0.48.0","license": "MIT","devDependencies": {"@electric-sql/pglite": "^0.5.8",@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.47.0",++ "version": "0.48.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",@@@ -236,6 +236,12 @@"import": "./esm/openre.mjs","default": "./src/openre.js"},++ "./play": {++ "types": "./types/play.d.ts",++ "browser": null,++ "import": "./esm/play.mjs",++ "default": "./src/play.js"++ },"./quest": {"types": "./types/quest.d.ts","browser": null,@@@ -52,6 +52,7 @@ const ENTRIES = {'projects.mjs': '../src/projects.js','geo.mjs': '../src/geo.js','openre.mjs': '../src/openre.js',++ 'play.mjs': '../src/play.js','quest.mjs': '../src/quest.js','vip.mjs': '../src/vip.js','frame.mjs': '../src/frame.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.47.0';++const SDK_VERSION = '0.48.0';module.exports = {createClient,nnew file mode 100644@@@ -0,0 +1,72 @@++'use strict';++/**++ * openvibe-sdk/play — OpenVibe.Play, the network's game services (achievements and leaderboards, 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++ * writer; another one is told the title does not exist. Leaderboards and titles are public reads (no token).++ *++ * const play = createPlayClient(createClient({ tokenProvider: tokens }));++ * await play.titles.define('gem-run', { // play.title-definition@1++ * name: 'Gem Run', url: 'https://example.com/gem-run', summary: 'Collect gems.',++ * achievements: [{ id: 'first-gem', name: 'First Gem', description: 'Find a gem.' }],++ * boards: [{ id: 'high-score', name: 'High score', kind: 'best' }],++ * });++ * await play.scores.post('gem-run', { board: 'high-score', ref: 'round-42', entries: [{ player: 'usr_…', name: 'kim', score: 1200 }] });++ * await play.achievements.unlock('gem-run', { player: 'guest:abc12345', achievements: ['first-gem'] }); // → the new ones++ * 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)++ *++ * 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().++ */++const { isOpenVibeError } = require('./core/errors');++++const enc = encodeURIComponent;++const PLAYER_RE = /^(usr_[0-9A-HJKMNP-TV-Z]{26}|guest:[A-Za-z0-9_-]{8,128})$/;++++function createPlayClient(client, { baseUrl } = {}) {++ if (!client || typeof client.json !== 'function') throw new TypeError('createPlayClient: pass an openvibe-sdk/core client');++ const call = (opts) => client.json({ service: 'play', baseUrl, audience: 'openvibe.play', ...opts });++ const open = (opts) => client.json({ service: 'play', baseUrl, auth: false, ...opts });++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });++ const player = (p) => { if (!PLAYER_RE.test(String(p || ''))) throw new TypeError('player: a usr_… subject or guest:<8–128 of A-Z a-z 0-9 _ ->'); return p; };++++ return {++ titles: {++ /** Register or update a title you manage (play.title.manage). */++ define: (title, definition, { signal } = {}) => call({ method: 'PUT', path: `/api/v1/titles/${enc(title)}`, json: definition, signal, idempotencyKey: false }),++ /** Public: every title with its achievements and boards. */++ list: ({ signal } = {}) => open({ path: '/api/v1/titles', signal }),++ /** Public: one title, or null. */++ get: (title, { signal } = {}) => orNull(open({ path: `/api/v1/titles/${enc(title)}`, signal })),++ },++ scores: {++ /** A round's scores to one board (play.result.write); idempotent by ref, up to 100 entries. */++ post: (title, { board, ref, at, entries }, { signal } = {}) => call({++ method: 'POST', path: `/api/v1/titles/${enc(title)}/scores`, signal, idempotencyKey: false,++ json: { board, ref, ...(at ? { at } : {}), entries: (entries || []).map((e) => ({ ...e, player: player(e.player) })) },++ }),++ },++ achievements: {++ /** Unlock achievements for a player (play.result.write); idempotent, answers the new ones. */++ unlock: (title, { player: who, achievements, at }, { signal } = {}) => call({++ method: 'POST', path: `/api/v1/titles/${enc(title)}/unlocks`, signal, idempotencyKey: false,++ json: { player: player(who), achievements, ...(at ? { at } : {}) },++ }),++ },++ players: {++ /** A guest signed in: their scores and unlocks become the person's (play.result.write). `guest` is the opaque part. */++ adopt: (title, guest, person, { signal } = {}) => call({ method: 'POST', path: `/api/v1/titles/${enc(title)}/players/${enc(guest)}/adopt`, json: { person }, signal, idempotencyKey: false }),++ /** 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 }),++ },++ 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 })),++ },++ };++}++++module.exports = { createPlayClient };@@@ -76,7 +76,7 @@ run([for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);}assert.deepEqual(problems, []);-- assert.deepEqual(blocked.sort(), ['./account-data', './cache', './commerce', './cost', './db', './delivery', './events', './govern', './identity', './limits', './node', './notifications', './openre', './placement', './pubsub', './quest', './queue', './resources', './runtime', './service', './sso', './storage', './telemetry', './testing', './usage', './valkey', './vip']);++ assert.deepEqual(blocked.sort(), ['./account-data', './cache', './commerce', './cost', './db', './delivery', './events', './govern', './identity', './limits', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './runtime', './service', './sso', './storage', './telemetry', './testing', './usage', './valkey', './vip']);const rel = [...scanned].map((f) => path.relative(ROOT, f));for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {assert.ok(rel.includes(must), `${must} is scanned`);@@@ -66,7 +66,7 @@ run([})().catch((err) => { console.error(err); process.exit(1); });`;const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));-- assert.equal(subpaths.length, 45);++ assert.equal(subpaths.length, 46);assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');} finally {fs.rmSync(tmp, { recursive: true, force: true });nnew file mode 100644@@@ -0,0 +1,40 @@++'use strict';++// openvibe-sdk/play: OpenVibe.Play's game services — the routes and audience each call uses, public reads without a token,++// players checked before a request leaves, and a missing title or board as null.++const assert = require('assert');++const { createPlayClient } = require('../src/play');++const { OpenVibeError } = require('../src/core/errors');++++(async () => {++ const calls = [];++ let missing = false;++ const client = { json: async (opts) => { calls.push(opts); if (missing) throw new OpenVibeError({ status: 404, code: 'play.not_found', message: 'no' }); return { ok: true }; } };++ const play = createPlayClient(client, { baseUrl: 'http://play.test' });++ const KIM = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++++ await play.titles.define('gem-run', { name: 'Gem Run', boards: [{ id: 'high-score', name: 'High score', kind: 'best' }] });++ await play.scores.post('gem-run', { board: 'high-score', ref: 'round-1', entries: [{ player: KIM, name: 'kim', score: 1200 }] });++ await play.achievements.unlock('gem-run', { player: 'guest:abc12345', achievements: ['first-gem'] });++ await play.players.adopt('gem-run', 'abc12345', KIM);++ await play.players.achievements('gem-run', KIM);++ await play.boards.get('gem-run', 'high-score', { period: 'week' });++ await play.titles.list();++ assert.deepStrictEqual(calls.map((c) => [c.method || 'GET', c.path, c.audience || null, c.auth === false]), [++ ['PUT', '/api/v1/titles/gem-run', 'openvibe.play', false],++ ['POST', '/api/v1/titles/gem-run/scores', 'openvibe.play', false],++ ['POST', '/api/v1/titles/gem-run/unlocks', 'openvibe.play', false],++ ['POST', '/api/v1/titles/gem-run/players/abc12345/adopt', 'openvibe.play', false],++ ['GET', `/api/v1/titles/gem-run/players/${KIM}/achievements`, 'openvibe.play', false],++ ['GET', '/api/v1/titles/gem-run/boards/high-score', null, true],++ ['GET', '/api/v1/titles', null, true],++ ]);++ assert.deepStrictEqual(calls[1].json, { board: 'high-score', ref: 'round-1', entries: [{ player: KIM, name: 'kim', score: 1200 }] });++ 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/);++ missing = true;++ 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');++ console.log('play: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });@@@ -17,6 +17,7 @@ export * as projects from './projects';export * as resources from './resources';export * as vip from './vip';export * as quest from './quest';++export * as play from './play';export * as openre from './openre';export * as geo from './geo';export * as usage from './usage';nnew file mode 100644@@@ -0,0 +1,26 @@++/** openvibe-sdk/play: OpenVibe.Play game services — titles, scores, achievements and leaderboards. */++export interface PlayAchievementDef { id: string; name: string; description?: string; hidden?: boolean }++export interface PlayBoardDef { id: string; name: string; kind: 'best' | 'sum' }++export interface PlayTitleDefinition { name: string; url?: string; summary?: string; achievements?: PlayAchievementDef[]; boards?: PlayBoardDef[] }++export interface PlayScoreEntry { player: string; name: string; score: number; [k: string]: unknown }++export interface PlayClient {++ titles: {++ define(title: string, definition: PlayTitleDefinition, opts?: { signal?: AbortSignal }): Promise<any>;++ list(opts?: { signal?: AbortSignal }): Promise<any>;++ get(title: string, opts?: { signal?: AbortSignal }): Promise<any | null>;++ };++ scores: {++ post(title: string, round: { board: string; ref: string; at?: string; entries: PlayScoreEntry[] }, opts?: { signal?: AbortSignal }): Promise<any>;++ };++ achievements: {++ unlock(title: string, input: { player: string; achievements: string[]; at?: string }, opts?: { signal?: AbortSignal }): Promise<any>;++ };++ players: {++ adopt(title: string, guest: string, person: string, opts?: { signal?: AbortSignal }): Promise<any>;++ achievements(title: string, player: string, opts?: { signal?: AbortSignal }): Promise<any>;++ };++ boards: {++ get(title: string, board: string, opts?: { period?: string; page?: number; signal?: AbortSignal }): Promise<any | null>;++ };++}++export function createPlayClient(client: { json(opts: Record<string, unknown>): Promise<any> }, options?: { baseUrl?: string }): PlayClient;
@@ -3,6 +3,14 @@
@@ -3,6 +3,14 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
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.
release may change an API and says so here.
+## 0.48.0 (2026-10-15)
+
+- `openvibe-sdk/play`: `createPlayClient(client)` for OpenVibe.Play's game services (the owner's "game services in the
+ SDK"): `titles.define/list/get`, `scores.post`, `achievements.unlock`, `players.adopt/achievements`, `boards.get`.
+ Writes use the game's own principal (audience `openvibe.play`, `play.title.manage` / `play.result.write` /
+ `play.player.read`); titles and leaderboards are public reads without a token; a player is checked (usr_… or
+ guest:<opaque>) before the request leaves.
+
## 0.47.0 (2026-10-15)
## 0.47.0 (2026-10-15)
- `openvibe-sdk/quest`: `createQuestClient(client)` → `appQuests.define(slug, quest)`, `appQuests.list()`,
- `openvibe-sdk/quest`: `createQuestClient(client)` → `appQuests.define(slug, quest)`, `appQuests.list()`,
@@ -184,6 +184,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
@@ -184,6 +184,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/resources` | server | `parseResourceName`/`resourceName`/`resourceNameOf` (OVRN, via the pinned `contracts.resources`) and `RESOURCE_KINDS` (only the chosen `med`/`wch` prefixes); `createResourceIndex({authorities, token, fetch, pageLimit, concurrency, timeoutMs, maxPages, maxMs})`: `list({project, kind, owner, limit})` -> `{resources, stale}` (`owner`: a subject id, each authority answering only what it owns), fanning out over the authorities' `GET /api/v1/resources`, following each opaque `next_cursor` and merging `common.resource-list-result@1` pages (an authority that fails — or whose cursor chain repeats, outruns `maxPages`, or outruns `maxMs` — is reported in `stale`, never fatal), and `iterate`; `createResourceClient({origin, token})`: `control(request)` POSTs a `common.resource-control-request@1` to `/api/v1/resources/control` — idempotency-keyed, transient failures retried with the same key and body, a thrown error carrying that `idempotencyKey` — and returns the `common.resource-control-result@1` (`done`/`pending`/`refused`/`failed`, a refused/failed one returned even with a non-retryable non-2xx) |
| `openvibe-sdk/resources` | server | `parseResourceName`/`resourceName`/`resourceNameOf` (OVRN, via the pinned `contracts.resources`) and `RESOURCE_KINDS` (only the chosen `med`/`wch` prefixes); `createResourceIndex({authorities, token, fetch, pageLimit, concurrency, timeoutMs, maxPages, maxMs})`: `list({project, kind, owner, limit})` -> `{resources, stale}` (`owner`: a subject id, each authority answering only what it owns), fanning out over the authorities' `GET /api/v1/resources`, following each opaque `next_cursor` and merging `common.resource-list-result@1` pages (an authority that fails — or whose cursor chain repeats, outruns `maxPages`, or outruns `maxMs` — is reported in `stale`, never fatal), and `iterate`; `createResourceClient({origin, token})`: `control(request)` POSTs a `common.resource-control-request@1` to `/api/v1/resources/control` — idempotency-keyed, transient failures retried with the same key and body, a thrown error carrying that `idempotencyKey` — and returns the `common.resource-control-result@1` (`done`/`pending`/`refused`/`failed`, a refused/failed one returned even with a non-retryable non-2xx) |
| `openvibe-sdk/geo` | both | `createGeoClient(client, {samples?, timeoutMs?})`: `nodes({role, region})` from Network's public node registry, `measure(nodes)` (beacon round trips, fastest first), `nearest({role, region, preferRegion})` → `{node, rtt_ms, measured}` (up/degraded nodes only; falls back to preferRegion when nothing can be measured) |
| `openvibe-sdk/geo` | both | `createGeoClient(client, {samples?, timeoutMs?})`: `nodes({role, region})` from Network's public node registry, `measure(nodes)` (beacon round trips, fastest first), `nearest({role, region, preferRegion})` → `{node, rtt_ms, measured}` (up/degraded nodes only; falls back to preferRegion when nothing can be measured) |
| `openvibe-sdk/openre` | server | `createOpenReClient(client, {baseUrl?, publicUrl?})`: OpenRestream, the streaming engine. `streams` (`byExternalRef`, `create`, `get`, `update`, `rotateKey`, `destinations`, `addDestination`), `destinations` (`test`, `start`, `stop`, `logs`), `sessions` (`get`, `playback` cached 10 s, `end`, `outputs`), `workers`, `manageUrl`; `{subject}` acts for a person (X-OV-Subject) |
| `openvibe-sdk/openre` | server | `createOpenReClient(client, {baseUrl?, publicUrl?})`: OpenRestream, the streaming engine. `streams` (`byExternalRef`, `create`, `get`, `update`, `rotateKey`, `destinations`, `addDestination`), `destinations` (`test`, `start`, `stop`, `logs`), `sessions` (`get`, `playback` cached 10 s, `end`, `outputs`), `workers`, `manageUrl`; `{subject}` acts for a person (X-OV-Subject) |
+| `openvibe-sdk/play` | server | `createPlayClient(client)`: OpenVibe.Play's achievements and leaderboards for any game — `titles.define(title, {name, url?, summary?, achievements, boards})`, `scores.post(title, {board, ref, entries})`, `achievements.unlock(title, {player, achievements})`, `players.adopt(title, guest, person)`, `players.achievements(title, player)`; public `titles.list/get` and `boards.get(title, board, {period, page})` need no token |
| `openvibe-sdk/quest` | server | `createQuestClient(client)`: `appQuests.define(slug, {title, why?, steps: [{event_type, count, label}], badge: {name}, url?})`, `appQuests.list()`, `appQuests.retire(slug)` with an app token (audience `openvibe.quest`, `quest.app_quest.manage`); `eventType(projectId, name)` gives the `app.<project_key>.<name>` type to publish at Events. Badges only, never OpenCoins; production events count |
| `openvibe-sdk/quest` | server | `createQuestClient(client)`: `appQuests.define(slug, {title, why?, steps: [{event_type, count, label}], badge: {name}, url?})`, `appQuests.list()`, `appQuests.retire(slug)` with an app token (audience `openvibe.quest`, `quest.app_quest.manage`); `eventType(projectId, name)` gives the `app.<project_key>.<name>` type to publish at Events. Badges only, never OpenCoins; production events count |
| `openvibe-sdk/vip` | server | `createVipClient({baseUrl?, tokenClient \| getToken, fetch?, timeoutMs?})`: `evaluate({subject, resource, owner, fallback, mode})`, `checkEntitlement({subject, creator, product, mode})`, `isMember(subject, creator)`; every failure is a denial. `createVipCache({vip, ttlMs, denyTtlMs, unavailableTtlMs})`: `entitlement`, `peekEntitlement`, `evaluate`, `invalidate`, `handleEvent` (`vip.membership.changed` and Billing's entitlement events drop a member's answers at once), `clear`, `bounds` |
| `openvibe-sdk/vip` | server | `createVipClient({baseUrl?, tokenClient \| getToken, fetch?, timeoutMs?})`: `evaluate({subject, resource, owner, fallback, mode})`, `checkEntitlement({subject, creator, product, mode})`, `isMember(subject, creator)`; every failure is a denial. `createVipCache({vip, ttlMs, denyTtlMs, unavailableTtlMs})`: `entitlement`, `peekEntitlement`, `evaluate`, `invalidate`, `handleEvent` (`vip.membership.changed` and Billing's entitlement events drop a member's answers at once), `clear`, `bounds` |
| `openvibe-sdk/usage` | server | `usageSample(fields)` builds a `platform.usage-sample@1` record (required `id`, `idempotency_key`, `service`, `operation`, `quantity`, `unit`; `at` defaults to now ISO, `source` to `openvibe-sdk/usage`; null fields stripped; money fields never invented); `usageKey(service, ...parts)` joins an idempotency key with `:`; `validateUsageSample(record)` → `{ok, errors}` (openvibe-contracts, required lazily). `createUsageReporter({ db, service, source, table, billingUrl, tokenClient, audience, requireProject, ... })` owns one service's readings: `key(...parts)`/`sample(fields)`, and `record(t, reading)` queues the reading idempotency-keyed in the outbox inside the caller's transaction, relayed to Billing's `billing.usage.record` with backoff (a retry is never dropped; only Billing refusing the reading itself — any 4xx but 401/403/404/408/425/429, which stay pending — marks a row rejected; a 401 drops the cached token). The reporter bills whatever reading it is given: never record first-party or sandbox traffic (pass `requireProject: true` to refuse a reading without a `prj_…` project). `openvibe-sdk/govern` builds its `onUsage` record with it |
| `openvibe-sdk/usage` | server | `usageSample(fields)` builds a `platform.usage-sample@1` record (required `id`, `idempotency_key`, `service`, `operation`, `quantity`, `unit`; `at` defaults to now ISO, `source` to `openvibe-sdk/usage`; null fields stripped; money fields never invented); `usageKey(service, ...parts)` joins an idempotency key with `:`; `validateUsageSample(record)` → `{ok, errors}` (openvibe-contracts, required lazily). `createUsageReporter({ db, service, source, table, billingUrl, tokenClient, audience, requireProject, ... })` owns one service's readings: `key(...parts)`/`sample(fields)`, and `record(t, reading)` queues the reading idempotency-keyed in the outbox inside the caller's transaction, relayed to Billing's `billing.usage.record` with backoff (a retry is never dropped; only Billing refusing the reading itself — any 4xx but 401/403/404/408/425/429, which stay pending — marks a row rejected; a 401 drops the cached token). The reporter bills whatever reading it is given: never record first-party or sandbox traffic (pass `requireProject: true` to refuse a reading without a `prj_…` project). `openvibe-sdk/govern` builds its `onUsage` record with it |
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.47.0",
+ "version": "0.48.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"events: publish, pull and checkpoints by opaque cursor only, app events, webhook verification (v2), outbox and inbox",
"events: publish, pull and checkpoints by opaque cursor only, app events, webhook verification (v2), outbox and inbox",
"realtime SSE (resumes from the opaque cursor, dedupes by event_id)",
"realtime SSE (resumes from the opaque cursor, dedupes by event_id)",
"media files and objects v2",
"media files and objects v2",
- "community, bot, search, chat, ai, jobs, tools, projects, modules, identity, openre, geo, vip and quest clients",
+ "community, bot, search, chat, ai, jobs, tools, projects, modules, identity, openre, geo, vip, quest and play clients",
"bot (openvibe-sdk/bot): OpenVibe.Bot robots, operators, devices, streaming, commands and audit, the public kits and profiles, and pairing",
"bot (openvibe-sdk/bot): OpenVibe.Bot robots, operators, devices, streaming, commands and audit, the public kits and profiles, and pairing",
"per-actor limiter for services (openvibe-sdk/limits)",
"per-actor limiter for services (openvibe-sdk/limits)",
"the OpenVibe Frame for any app",
"the OpenVibe Frame for any app",
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.47.0, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.48.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// 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.
// (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 */
/* eslint-disable */
@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.47.0';
+const SDK_VERSION = '0.48.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -7,7 +7,7 @@ export const {
@@ -7,7 +7,7 @@ export const {
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, identity, modules, events, realtime, media,
registry, identity, modules, events, realtime, media,
community, bot, search, chat, ai, jobs,
community, bot, search, chat, ai, jobs,
- tools, projects, resources, vip, quest, openre,
- geo, usage, commerce, telemetry,
+ tools, projects, resources, vip, quest, play,
+ openre, geo, usage, commerce, telemetry,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+// Generated by scripts/esm.js from src/play.js. Do not edit.
+import cjs from '../src/play.js';
+
+export const {
+ createPlayClient,
+} = cjs;
+export default cjs;
@@ -26,6 +26,7 @@ module.exports = {
@@ -26,6 +26,7 @@ module.exports = {
resources: require('./src/resources'),
resources: require('./src/resources'),
vip: require('./src/vip'),
vip: require('./src/vip'),
quest: require('./src/quest'),
quest: require('./src/quest'),
+ play: require('./src/play'),
openre: require('./src/openre'),
openre: require('./src/openre'),
geo: require('./src/geo'),
geo: require('./src/geo'),
usage: require('./src/usage'),
usage: require('./src/usage'),
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.47.0",
+ "version": "0.48.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.47.0",
+ "version": "0.48.0",
"license": "MIT",
"license": "MIT",
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.47.0",
+ "version": "0.48.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.",
"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",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -236,6 +236,12 @@
@@ -236,6 +236,12 @@
"import": "./esm/openre.mjs",
"import": "./esm/openre.mjs",
"default": "./src/openre.js"
"default": "./src/openre.js"
},
},
+ "./play": {
+ "types": "./types/play.d.ts",
+ "browser": null,
+ "import": "./esm/play.mjs",
+ "default": "./src/play.js"
+ },
"./quest": {
"./quest": {
"types": "./types/quest.d.ts",
"types": "./types/quest.d.ts",
"browser": null,
"browser": null,
@@ -52,6 +52,7 @@ const ENTRIES = {
@@ -52,6 +52,7 @@ const ENTRIES = {
'projects.mjs': '../src/projects.js',
'projects.mjs': '../src/projects.js',
'geo.mjs': '../src/geo.js',
'geo.mjs': '../src/geo.js',
'openre.mjs': '../src/openre.js',
'openre.mjs': '../src/openre.js',
+ 'play.mjs': '../src/play.js',
'quest.mjs': '../src/quest.js',
'quest.mjs': '../src/quest.js',
'vip.mjs': '../src/vip.js',
'vip.mjs': '../src/vip.js',
'frame.mjs': '../src/frame.js',
'frame.mjs': '../src/frame.js',
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.47.0';
+const SDK_VERSION = '0.48.0';
module.exports = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+'use strict';
+/**
+ * openvibe-sdk/play — OpenVibe.Play, the network's game services (achievements and leaderboards, 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
+ * writer; another one is told the title does not exist. Leaderboards and titles are public reads (no token).
+ *
+ * const play = createPlayClient(createClient({ tokenProvider: tokens }));
+ * await play.titles.define('gem-run', { // play.title-definition@1
+ * name: 'Gem Run', url: 'https://example.com/gem-run', summary: 'Collect gems.',
+ * achievements: [{ id: 'first-gem', name: 'First Gem', description: 'Find a gem.' }],
+ * boards: [{ id: 'high-score', name: 'High score', kind: 'best' }],
+ * });
+ * await play.scores.post('gem-run', { board: 'high-score', ref: 'round-42', entries: [{ player: 'usr_…', name: 'kim', score: 1200 }] });
+ * await play.achievements.unlock('gem-run', { player: 'guest:abc12345', achievements: ['first-gem'] }); // → the new ones
+ * 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)
+ *
+ * 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().
+ */
+const { isOpenVibeError } = require('./core/errors');
+
+const enc = encodeURIComponent;
+const PLAYER_RE = /^(usr_[0-9A-HJKMNP-TV-Z]{26}|guest:[A-Za-z0-9_-]{8,128})$/;
+
+function createPlayClient(client, { baseUrl } = {}) {
+ if (!client || typeof client.json !== 'function') throw new TypeError('createPlayClient: pass an openvibe-sdk/core client');
+ const call = (opts) => client.json({ service: 'play', baseUrl, audience: 'openvibe.play', ...opts });
+ const open = (opts) => client.json({ service: 'play', baseUrl, auth: false, ...opts });
+ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
+ const player = (p) => { if (!PLAYER_RE.test(String(p || ''))) throw new TypeError('player: a usr_… subject or guest:<8–128 of A-Z a-z 0-9 _ ->'); return p; };
+
+ return {
+ titles: {
+ /** Register or update a title you manage (play.title.manage). */
+ define: (title, definition, { signal } = {}) => call({ method: 'PUT', path: `/api/v1/titles/${enc(title)}`, json: definition, signal, idempotencyKey: false }),
+ /** Public: every title with its achievements and boards. */
+ list: ({ signal } = {}) => open({ path: '/api/v1/titles', signal }),
+ /** Public: one title, or null. */
+ get: (title, { signal } = {}) => orNull(open({ path: `/api/v1/titles/${enc(title)}`, signal })),
+ },
+ scores: {
+ /** A round's scores to one board (play.result.write); idempotent by ref, up to 100 entries. */
+ post: (title, { board, ref, at, entries }, { signal } = {}) => call({
+ method: 'POST', path: `/api/v1/titles/${enc(title)}/scores`, signal, idempotencyKey: false,
+ json: { board, ref, ...(at ? { at } : {}), entries: (entries || []).map((e) => ({ ...e, player: player(e.player) })) },
+ }),
+ },
+ achievements: {
+ /** Unlock achievements for a player (play.result.write); idempotent, answers the new ones. */
+ unlock: (title, { player: who, achievements, at }, { signal } = {}) => call({
+ method: 'POST', path: `/api/v1/titles/${enc(title)}/unlocks`, signal, idempotencyKey: false,
+ json: { player: player(who), achievements, ...(at ? { at } : {}) },
+ }),
+ },
+ players: {
+ /** A guest signed in: their scores and unlocks become the person's (play.result.write). `guest` is the opaque part. */
+ adopt: (title, guest, person, { signal } = {}) => call({ method: 'POST', path: `/api/v1/titles/${enc(title)}/players/${enc(guest)}/adopt`, json: { person }, signal, idempotencyKey: false }),
+ /** 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 }),
+ },
+ 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 })),
+ },
+ };
+}
+
+module.exports = { createPlayClient };
@@ -76,7 +76,7 @@ run([
@@ -76,7 +76,7 @@ run([
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
}
}
assert.deepEqual(problems, []);
assert.deepEqual(problems, []);
- assert.deepEqual(blocked.sort(), ['./account-data', './cache', './commerce', './cost', './db', './delivery', './events', './govern', './identity', './limits', './node', './notifications', './openre', './placement', './pubsub', './quest', './queue', './resources', './runtime', './service', './sso', './storage', './telemetry', './testing', './usage', './valkey', './vip']);
+ assert.deepEqual(blocked.sort(), ['./account-data', './cache', './commerce', './cost', './db', './delivery', './events', './govern', './identity', './limits', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './runtime', './service', './sso', './storage', './telemetry', './testing', './usage', './valkey', './vip']);
const rel = [...scanned].map((f) => path.relative(ROOT, f));
const rel = [...scanned].map((f) => path.relative(ROOT, f));
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
assert.ok(rel.includes(must), `${must} is scanned`);
assert.ok(rel.includes(must), `${must} is scanned`);
@@ -66,7 +66,7 @@ run([
@@ -66,7 +66,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
- assert.equal(subpaths.length, 45);
+ assert.equal(subpaths.length, 46);
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
+'use strict';
+// openvibe-sdk/play: OpenVibe.Play's game services — the routes and audience each call uses, public reads without a token,
+// players checked before a request leaves, and a missing title or board as null.
+const assert = require('assert');
+const { createPlayClient } = require('../src/play');
+const { OpenVibeError } = require('../src/core/errors');
+
+(async () => {
+ const calls = [];
+ let missing = false;
+ const client = { json: async (opts) => { calls.push(opts); if (missing) throw new OpenVibeError({ status: 404, code: 'play.not_found', message: 'no' }); return { ok: true }; } };
+ const play = createPlayClient(client, { baseUrl: 'http://play.test' });
+ const KIM = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+
+ await play.titles.define('gem-run', { name: 'Gem Run', boards: [{ id: 'high-score', name: 'High score', kind: 'best' }] });
+ await play.scores.post('gem-run', { board: 'high-score', ref: 'round-1', entries: [{ player: KIM, name: 'kim', score: 1200 }] });
+ await play.achievements.unlock('gem-run', { player: 'guest:abc12345', achievements: ['first-gem'] });
+ await play.players.adopt('gem-run', 'abc12345', KIM);
+ await play.players.achievements('gem-run', KIM);
+ await play.boards.get('gem-run', 'high-score', { period: 'week' });
+ await play.titles.list();
+ assert.deepStrictEqual(calls.map((c) => [c.method || 'GET', c.path, c.audience || null, c.auth === false]), [
+ ['PUT', '/api/v1/titles/gem-run', 'openvibe.play', false],
+ ['POST', '/api/v1/titles/gem-run/scores', 'openvibe.play', false],
+ ['POST', '/api/v1/titles/gem-run/unlocks', 'openvibe.play', false],
+ ['POST', '/api/v1/titles/gem-run/players/abc12345/adopt', 'openvibe.play', false],
+ ['GET', `/api/v1/titles/gem-run/players/${KIM}/achievements`, 'openvibe.play', false],
+ ['GET', '/api/v1/titles/gem-run/boards/high-score', null, true],
+ ['GET', '/api/v1/titles', null, true],
+ ]);
+ assert.deepStrictEqual(calls[1].json, { board: 'high-score', ref: 'round-1', entries: [{ player: KIM, name: 'kim', score: 1200 }] });
+ 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/);
+ missing = true;
+ 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');
+ console.log('play: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -17,6 +17,7 @@ export * as projects from './projects';
@@ -17,6 +17,7 @@ export * as projects from './projects';
export * as resources from './resources';
export * as resources from './resources';
export * as vip from './vip';
export * as vip from './vip';
export * as quest from './quest';
export * as quest from './quest';
+export * as play from './play';
export * as openre from './openre';
export * as openre from './openre';
export * as geo from './geo';
export * as geo from './geo';
export * as usage from './usage';
export * as usage from './usage';
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+/** openvibe-sdk/play: OpenVibe.Play game services — titles, scores, achievements and leaderboards. */
+export interface PlayAchievementDef { id: string; name: string; description?: string; hidden?: boolean }
+export interface PlayBoardDef { id: string; name: string; kind: 'best' | 'sum' }
+export interface PlayTitleDefinition { name: string; url?: string; summary?: string; achievements?: PlayAchievementDef[]; boards?: PlayBoardDef[] }
+export interface PlayScoreEntry { player: string; name: string; score: number; [k: string]: unknown }
+export interface PlayClient {
+ titles: {
+ define(title: string, definition: PlayTitleDefinition, opts?: { signal?: AbortSignal }): Promise<any>;
+ list(opts?: { signal?: AbortSignal }): Promise<any>;
+ get(title: string, opts?: { signal?: AbortSignal }): Promise<any | null>;
+ };
+ scores: {
+ post(title: string, round: { board: string; ref: string; at?: string; entries: PlayScoreEntry[] }, opts?: { signal?: AbortSignal }): Promise<any>;
+ };
+ achievements: {
+ unlock(title: string, input: { player: string; achievements: string[]; at?: string }, opts?: { signal?: AbortSignal }): Promise<any>;
+ };
+ players: {
+ adopt(title: string, guest: string, person: string, opts?: { signal?: AbortSignal }): Promise<any>;
+ achievements(title: string, player: string, opts?: { signal?: AbortSignal }): Promise<any>;
+ };
+ boards: {
+ get(title: string, board: string, opts?: { period?: string; page?: number; signal?: AbortSignal }): Promise<any | null>;
+ };
+}
+export function createPlayClient(client: { json(opts: Record<string, unknown>): Promise<any> }, options?: { baseUrl?: string }): PlayClient;