openvibers/openvibe.sdk Commits

SDK 0.48.0: openvibe-sdk/play (game services) (#52)

OP OpenVibers committed 29 days ago · ae3fb9ba31 · 17 files, +175 −12

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 4314ed5..ae4b81e 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,14 @@
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.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()`,
ddiff --git a/README.md b/README.md
iindex 5dec08a..4d3f970 100644
---- a/README.md
++++ b/README.md
@@@ -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 |
ddiff --git a/STATUS.json b/STATUS.json
iindex 8c7d8be..7c8dfbe 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex fc054a6..1a3ee63 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.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,
ddiff --git a/esm/index.mjs b/esm/index.mjs
iindex b11d979..22d68d8 100644
---- a/esm/index.mjs
++++ b/esm/index.mjs
@@@ -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;
ddiff --git a/esm/play.mjs b/esm/play.mjs
nnew file mode 100644
iindex 0000000..f476baf
---- /dev/null
++++ b/esm/play.mjs
@@@ -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;
ddiff --git a/index.js b/index.js
iindex 6c2ddf2..9810473 100644
---- a/index.js
++++ b/index.js
@@@ -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'),
ddiff --git a/package-lock.json b/package-lock.json
iindex 2deb987..66c8634 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 08cc4cb..c5a7358 100644
---- a/package.json
++++ b/package.json
@@@ -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,
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex a7fdd7f..73bf60b 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -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',
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 8569f9b..7eabda5 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.47.0';
++const SDK_VERSION = '0.48.0';
module.exports = {
createClient,
ddiff --git a/src/play.js b/src/play.js
nnew file mode 100644
iindex 0000000..b678054
---- /dev/null
++++ b/src/play.js
@@@ -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 };
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex e36975a..c751b3c 100644
---- a/test/browser-entries.test.js
++++ b/test/browser-entries.test.js
@@@ -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`);
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex 45e6519..968c506 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -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 });
ddiff --git a/test/play.test.js b/test/play.test.js
nnew file mode 100644
iindex 0000000..3902165
---- /dev/null
++++ b/test/play.test.js
@@@ -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); });
ddiff --git a/types/index.d.ts b/types/index.d.ts
iindex 702b57a..40f402d 100644
---- a/types/index.d.ts
++++ b/types/index.d.ts
@@@ -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';
ddiff --git a/types/play.d.ts b/types/play.d.ts
nnew file mode 100644
iindex 0000000..f86febd
---- /dev/null
++++ b/types/play.d.ts
@@@ -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;