openvibers/openvibe.examples Commits

game-scores: achievements and a leaderboard for your game on OpenVibe.Play from a sandbox app; the developer path's play step runs it (13 steps) (#41)

OP OpenVibers committed 17 days ago · 6b856b536d · 11 files, +381 −26

Changes

ddiff --git a/README.md b/README.md
iindex 5a3f463..4b636cb 100644
---- a/README.md
++++ b/README.md
@@@ -3,7 +3,7 @@
> Executable public integration examples for the OpenVibe platform: the SDK, scoped credentials
> and public APIs, nothing else.
--**Status:** alpha, 0.3.0 (roadmap Wave 20; the `create-openvibe-app` scaffolder, WS-N task 6). Fourteen examples, each with a smoke test that CI runs on
++**Status:** alpha, 0.3.0 (roadmap Wave 20; the `create-openvibe-app` scaffolder, WS-N task 6). Fifteen examples, each with a smoke test that CI runs on
Node 22.22.1 against `openvibe-sdk/testing`'s mock platform (no network). `npm run e2e` runs three
of them (Media, Events pull, a Tools job) against the real platform with your own sandbox app; it
is not part of CI or `npm test`, and it was not run against production as part of this release.
@@@ -36,6 +36,7 @@ Each example is small, runs on its own and proves one thing with a smoke test.
| [chat-bot](examples/chat-bot) | A bot on Chat's WebSocket protocol that stays in its one configured room and always identifies as a bot | Chat `/ws/chat` protocol (no SDK client: Chat is first-party) |
| [mod-manifest](examples/mod-manifest) | A `mods.mod-manifest@1` validated against the schema, the capability catalog and compatibility ranges | `openvibe-contracts`, `openvibe-sdk/registry` |
| [go-live](examples/go-live) | Go live on OpenVibe.Live from code: a Live API token finds or creates a WHIP slot and reads its WHIP URL, FFmpeg 8+ publishes (test pattern, a file or a camera), the session is named once media flows and ended when FFmpeg stops; the stream key is never printed | `openvibe-sdk/live` (`slots`, `streams`, `channels.url`), `/core`, FFmpeg's WHIP muxer |
++| [game-scores](examples/game-scores) | Achievements and a leaderboard for your game on OpenVibe.Play, from a sandbox app any developer can make today: register a `sandbox-` title, post a round (counted once on a retry), unlock, read the public board, delete the title | `openvibe-sdk/play` (`titles`, `scores`, `achievements`, `boards`) |
| [inventory-wear](examples/inventory-wear) | Show what people wear on OpenVibe (hats, chat tags, badges, name effects) next to names in your own app: the public catalog and who wears what, 100 people per call, with no token; text and escaped HTML | `openvibe-sdk/inventory` (`kinds`, `definitions`, `equipped`) |
| [space-deploy](examples/space-deploy) | Deploy a static site to OpenVibe.Space from CI with an app token (space.deploy.create, deployer role): the built folder as one deploy, live on main or a preview for a pull request, a refusal that changes nothing, rollback; a ready GitHub Actions workflow | `openvibe-sdk/space` (`deploys.uploadFiles`, `rollback`), `/auth` |
| [zone-s3](examples/zone-s3) | OpenVibe.Zone's private buckets over S3 with the official AWS SDK v3: put, get, a paged list, a presigned share link (at most 7 days) and delete; path-style, us-east-1, SigV4, an access key scoped to the zone | `@aws-sdk/client-s3`, `@aws-sdk/s3-request-presigner` |
@@@ -167,21 +168,22 @@ against production every day (`openvibe-devpath.timer`, shown on openvibe.networ
| 2. discovery | reads `/.well-known/openvibe` as any origin may |
| 3. project | creates a sandbox project with `openvibe-sdk/projects` |
| 4. app | creates a confidential sandbox app; its secret stays in memory |
--| 5. grants | requests `media.object.upload`, `media.object.read`, `events.app.publish`, `events.app.read`, `ai.app.run`, `services.release.manage`; all must be approved at once |
++| 5. grants | requests `media.object.upload`, `media.object.read`, `events.app.publish`, `events.app.read`, `ai.app.run`, `play.title.manage`, `play.result.write`, `services.release.manage`; all must be approved at once |
| 6. media | [media-uploader](examples/media-uploader): upload a small file, read it back, delete it |
| 7. events | [event-subscriber](examples/event-subscriber): publish `app.<project_key>.developer_path.ran`, pull it with a cursor |
| 8. ai | [ai-chat](examples/ai-chat): the official `openai` client on OpenVibe.AI's OpenAI-compatible endpoint (`OV_AI_URL`) with an app token for `openvibe.ai`; one short answer, which must carry its `x_openvibe` run id |
--| 9. export | as the project's owner: Network mints 5-minute read-only export tokens; Media lists the project's sandbox files with one and refuses an upload; Events returns the event from step 7 with the other |
--| 10. release | as the app, on OpenVibe.Services (`OV_SERVICES_URL`): a sandbox release of itself goes draft → published → deprecated → revoked, checked in the public list each time |
--| 11. credentials | rotates with no overlap and revokes the first credential; the old secret must be refused at `/oauth/token`, the new one must work |
--| 12. cleanup | archives the project, even when an earlier step failed; the app's secret must then be refused |
++| 9. play | [game-scores](examples/game-scores): a `sandbox-` title on OpenVibe.Play (`OV_PLAY_URL`), a round posted twice and recorded once, an unlock, the public board read without a token, the title deleted |
++| 10. export | as the project's owner: Network mints 5-minute read-only export tokens; Media lists the project's sandbox files with one and refuses an upload; Events returns the event from step 7 with the other |
++| 11. release | as the app, on OpenVibe.Services (`OV_SERVICES_URL`): a sandbox release of itself goes draft → published → deprecated → revoked, checked in the public list each time |
++| 12. credentials | rotates with no overlap and revokes the first credential; the old secret must be refused at `/oauth/token`, the new one must work |
++| 13. cleanup | archives the project, even when an earlier step failed; the app's secret must then be refused |
**In CI**, on every push, `npm run developer-path:mock` runs the same code against
`openvibe-sdk/testing`'s mock platform (the `developer-path` job; `test/developer-path.test.js`
also checks it leaves nothing behind, never prints a secret, and still cleans up after a failure).
The mock has no account API, so `scripts/developer-path-mock.js` adds register and login routes
--that mint mock user tokens, mints export tokens, and stands in for Services' release API and AI's
--`/v1/chat/completions`; it hands the mock the contract's public capabilities, as Network reads them. Every
++that mint mock user tokens, mints export tokens, and stands in for Services' release API, AI's
++`/v1/chat/completions` and Play's title, score, unlock and board routes (with its sandbox rules); it hands the mock the contract's public capabilities, as Network reads them. Every
other step is the platform mock's own.
**Against production** it runs only when a person starts it. Credentials come from the
@@@ -198,7 +200,7 @@ It refuses to start without credentials, and refuses in CI against the productio
set and `OV_NETWORK_URL` unset or `https://openvibe.network`), so it can later run in CI against
an integration environment but never against production by accident. It masks every password,
secret and token in its output and prints signed URLs without their signature. Exit `0` when all
--twelve steps passed, `1` when one failed, `2` when it refused to start.
++thirteen steps passed, `1` when one failed, `2` when it refused to start.
It does not yet cover webhook delivery to an external endpoint (that needs a public https host)
or publishing an app release in OpenVibe.Services.
ddiff --git a/STATUS.json b/STATUS.json
iindex 81e0a1f..1aa3ea4 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,7 +9,7 @@
"deployed": false,
"deployment": "none: a repository with CI, not a runtime and not a published package",
"features": [
-- "fourteen copyable examples: browser app, Node server app, Media uploader, event subscriber, webhook consumer, tool job, OAuth app, chat bot, mod manifest, AI chat (the official openai client on ai.openvibe.services/v1, plain, streamed and the project's own provider), go live (a Live API token sets up a WHIP slot; FFmpeg 8+ publishes; the session is named and ended), Zone over S3 (the official AWS SDK v3: put, get, paged list, presigned share link, delete), Space deploys from CI (an app token, live or preview, rollback, a GitHub Actions workflow), what people wear (Inventory's public catalog and equipped reads, no token)",
++ "fifteen copyable examples: browser app, Node server app, Media uploader, event subscriber, webhook consumer, tool job, OAuth app, chat bot, mod manifest, AI chat (the official openai client on ai.openvibe.services/v1, plain, streamed and the project's own provider), go live (a Live API token sets up a WHIP slot; FFmpeg 8+ publishes; the session is named and ended), Zone over S3 (the official AWS SDK v3: put, get, paged list, presigned share link, delete), Space deploys from CI (an app token, live or preview, rollback, a GitHub Actions workflow), what people wear (Inventory's public catalog and equipped reads, no token), game scores (a sandbox- title on OpenVibe.Play: a round counted once, an unlock, the public board)",
"smoke tests against openvibe-sdk/testing's mock platform, offline",
"create-openvibe-app scaffolder",
"npm run e2e against the real platform (on demand)",
ddiff --git a/examples/game-scores/.env.example b/examples/game-scores/.env.example
nnew file mode 100644
iindex 0000000..56bc97a
---- /dev/null
++++ b/examples/game-scores/.env.example
@@@ -0,0 +1,8 @@
++# A sandbox app in your project on https://openvibe.services/projects, granted play.title.manage and play.result.write
++# (a sandbox app gets both without waiting for anyone).
++OV_CLIENT_ID=
++OV_CLIENT_SECRET=
++# Optional:
++# OV_PLAY_TITLE=sandbox-gem-run # a sandbox app's title id starts with sandbox- (default: sandbox-gem-run-<random>)
++# OV_NETWORK_URL=https://openvibe.network
++# OV_PLAY_URL=https://play.openvibe.network
ddiff --git a/examples/game-scores/README.md b/examples/game-scores/README.md
nnew file mode 100644
iindex 0000000..a106e9e
---- /dev/null
++++ b/examples/game-scores/README.md
@@@ -0,0 +1,72 @@
++# Achievements and a leaderboard for your game
++
++[OpenVibe.Play](https://play.openvibe.network) keeps the record of what players did in any game: achievements (secret
++ones too), leaderboards (best score or total, this week and all time) and cloud saves, with one OpenVibe account for
++every player. This example is your game's server: it registers a title, posts a round, unlocks an achievement, reads
++the public board and, unless told to keep it, deletes the title again.
++
++```bash
++node --env-file=.env scores.js
++# registered sandbox-gem-run-3f9a1c (sandbox: unlisted, guests only)
++# scores: recorded 1; the same round again recorded 0
++# unlocked: first-gem
++# #1 Example player 1200
++# deleted the title
++```
++
++## What it proves
++
++- **Any developer can start today, from a sandbox app.**
++ - A sandbox app in your project gets `play.title.manage` and `play.result.write` without waiting for anyone.
++ - It registers up to 3 titles whose ids start with `sandbox-`.
++ - Those titles are never listed, counted or indexed. Their players are guests only, so nobody's record changes.
++ - When your game is ready, staff turn production on for the project, and its production app registers the real name.
++- **One token, refreshed for you.** `createServiceTokenClient` asks Network for an app token for audience `openvibe.play`
++ with exactly the two capabilities, caches it and gets a new one before it expires.
++- **Writes are safe to retry.** A round's scores carry a `ref`: posting the same round again records nothing. An unlock
++ answers only the achievements that were new.
++- **Boards are public.** `boards.get()` sends no token: any page can show rank, name and score (never a player id).
++- **Clean up.** `titles.remove()` deletes a sandbox title with everything in it. A production title is never deleted:
++ what players earned stays on their profiles.
++
++## Files
++
++| File | What |
++|---|---|
++| `scores.js` | `loadConfig()`, `createGame()`, `playRound()`, `DEFINITION`, CLI (`--keep`) |
++| `test/smoke.test.js` | the whole round against a stand-in for Network's token endpoint and Play's API |
++
++## Run the smoke test
++
++```bash
++npm test
++```
++
++The test hands the SDK a `fetch` that answers like Network's `/oauth/token` and Play's API (`server/http/api.js` on
++OpenVibe.Play's main, with the sandbox rules of Play#41 and #42). It checks:
++- the token asked for (`openvibe.play`, the two capabilities);
++- a sandbox title registered;
++- the same round posted twice and counted once;
++- the unlock;
++- the public board read without a token;
++- the title deleted, and kept with `--keep`;
++- a real name from a sandbox app refused with `play.sandbox_title`.
++
++Nothing reaches the network.
++
++## Run it against the real platform
++
++1. Sign in on [OpenVibe Services](https://openvibe.services/projects) and make a project. New projects are
++ sandbox-only.
++2. Add a confidential **sandbox** app and request `play.title.manage` and `play.result.write`. The owner's requests
++ are approved at once.
++3. Put its client id and secret in `.env` (see `.env.example`), then:
++
++```bash
++npm install
++node --env-file=.env scores.js --keep # then open https://play.openvibe.network/t/<the id it printed>
++```
++
++Run it without `--keep` (or with the same `OV_PLAY_TITLE`) to delete the title again. In production a player is a
++person's `usr_…` id, which your game learns when they sign in with OpenVibe (see [oauth-app](../oauth-app)). A guest
++plays at once as `guest:<opaque>`, and `players.adopt()` moves their progress to their account when they sign in.
ddiff --git a/examples/game-scores/package.json b/examples/game-scores/package.json
nnew file mode 100644
iindex 0000000..3dce056
---- /dev/null
++++ b/examples/game-scores/package.json
@@@ -0,0 +1,18 @@
++{
++ "name": "openvibe-example-game-scores",
++ "version": "0.1.0",
++ "private": true,
++ "description": "Achievements and a leaderboard for your game on OpenVibe.Play, from a sandbox app: register a title, post a round, unlock, read the public board, delete it.",
++ "license": "MIT",
++ "main": "scores.js",
++ "scripts": {
++ "start": "node --env-file-if-exists=.env scores.js",
++ "test": "node test/smoke.test.js"
++ },
++ "engines": {
++ "node": ">=22"
++ },
++ "dependencies": {
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.0"
++ }
++}
ddiff --git a/examples/game-scores/scores.js b/examples/game-scores/scores.js
nnew file mode 100644
iindex 0000000..7a5b957
---- /dev/null
++++ b/examples/game-scores/scores.js
@@@ -0,0 +1,86 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Game scores: achievements and a leaderboard for your game on OpenVibe.Play, from your game's server.
++ *
++ * node --env-file=.env scores.js # register the title, post a round, unlock, read the board, delete it
++ * node --env-file=.env scores.js --keep # keep the title (see it at play.openvibe.network/t/<id>)
++ *
++ * 1. App token for openvibe.play (client credentials, play.title.manage and play.result.write): the SDK's
++ * service-token client caches it and gets a new one before it expires.
++ * 2. titles.define(): the game's achievements and boards. A sandbox app's title id starts with `sandbox-` (up to
++ * three, unlisted, guest players only); your production app registers the real name.
++ * 3. scores.post() (idempotent by `ref`: a retry never counts twice) and achievements.unlock() (answers the new
++ * ones) for a player: a guest your game names (`guest:<opaque>`), or a person's `usr_…` id in production.
++ * 4. boards.get(): the public leaderboard (rank, name, score, never a player id), readable by any page, no token.
++ * 5. titles.remove(): a sandbox title goes with everything in it (a production title is never deleted).
++ */
++const crypto = require('node:crypto');
++const { createClient } = require('openvibe-sdk/core');
++const { createServiceTokenClient } = require('openvibe-sdk/auth');
++const { createPlayClient } = require('openvibe-sdk/play');
++
++const TITLE_RE = /^[a-z0-9][a-z0-9-]{1,39}$/;
++
++function loadConfig(env = process.env) {
++ const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET'].filter((k) => !env[k]);
++ if (missing.length) {
++ throw Object.assign(new Error(`missing environment variables: ${missing.join(', ')} (see .env.example)`), { code: 'config.missing' });
++ }
++ const title = env.OV_PLAY_TITLE || `sandbox-gem-run-${crypto.randomBytes(3).toString('hex')}`;
++ if (!TITLE_RE.test(title)) throw Object.assign(new Error('OV_PLAY_TITLE is 2 to 40 of a-z, 0-9 and - (a sandbox app\'s starts with sandbox-)'), { code: 'config.invalid' });
++ return {
++ network: env.OV_NETWORK_URL || 'https://openvibe.network',
++ clientId: env.OV_CLIENT_ID,
++ clientSecret: env.OV_CLIENT_SECRET,
++ playUrl: env.OV_PLAY_URL || 'https://play.openvibe.network',
++ title,
++ };
++}
++
++function createGame(config, { fetch } = {}) {
++ const tokens = createServiceTokenClient({
++ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch,
++ scope: { 'openvibe.play': 'play.title.manage play.result.write' },
++ });
++ return createPlayClient(createClient({ network: config.network, fetch, tokenProvider: tokens, baseUrls: { play: config.playUrl } }), { baseUrl: config.playUrl });
++}
++
++/** The title's definition: two achievements (one secret) and a best-score board. */
++const DEFINITION = {
++ name: 'Gem Run', summary: 'Collect gems before the timer runs out.',
++ achievements: [
++ { id: 'first-gem', name: 'First Gem', description: 'Pick up a gem.' },
++ { id: 'perfect-run', name: 'Perfect Run', description: 'Finish without missing a gem.', hidden: true },
++ ],
++ boards: [{ id: 'high-score', name: 'High score', kind: 'best' }],
++};
++
++/** One round, start to finish: define, post, unlock, read the board; then delete the title unless asked to keep it. */
++async function playRound(play, config, { player = 'guest:example-player-1', name = 'Example player', score = 1200, ref = 'round-1', keep = false } = {}) {
++ const defined = await play.titles.define(config.title, DEFINITION);
++ const title = defined.title || defined;
++ const posted = await play.scores.post(config.title, { board: 'high-score', ref, entries: [{ player, name, score }] });
++ const again = await play.scores.post(config.title, { board: 'high-score', ref, entries: [{ player, name, score }] });
++ const unlocked = await play.achievements.unlock(config.title, { player, achievements: ['first-gem'] });
++ const board = await play.boards.get(config.title, 'high-score', { period: 'week' });
++ if (!keep) await play.titles.remove(config.title);
++ return { title, created: !!defined.created, posted, again, unlocked: unlocked.unlocked || [], board, removed: !keep };
++}
++
++async function main(argv = process.argv.slice(2)) {
++ const config = loadConfig();
++ const keep = argv.includes('--keep');
++ const out = await playRound(createGame(config), config, { keep });
++ console.log(`${out.created ? 'registered' : 'updated'} ${config.title}${out.title.sandbox ? ' (sandbox: unlisted, guests only)' : ''}`);
++ console.log(`scores: recorded ${out.posted.recorded}; the same round again recorded ${out.again.recorded}`);
++ console.log(`unlocked: ${out.unlocked.join(', ') || 'nothing new'}`);
++ for (const e of out.board.entries) console.log(` #${e.rank} ${e.name} ${e.score}`);
++ console.log(out.removed ? 'deleted the title' : `kept: ${config.playUrl}/t/${config.title}`);
++}
++
++module.exports = { loadConfig, createGame, playRound, DEFINITION };
++
++if (require.main === module) {
++ main().catch((err) => { console.error(`game-scores: ${err.code ? `${err.code}: ` : ''}${err.message}`); process.exitCode = 1; });
++}
ddiff --git a/examples/game-scores/test/smoke.test.js b/examples/game-scores/test/smoke.test.js
nnew file mode 100644
iindex 0000000..69f87ee
---- /dev/null
++++ b/examples/game-scores/test/smoke.test.js
@@@ -0,0 +1,90 @@
++'use strict';
++/**
++ * Smoke test: scores.js against a stand-in for Network's /oauth/token and OpenVibe.Play's API, answering in the shapes
++ * Play's server/http/api.js answers (sandbox rules from Play#41/#42): the token asked for openvibe.play with the two
++ * capabilities, a sandbox title registered, a round posted twice (counted once), an unlock, the public board read
++ * without a token, the title deleted, and a real name refused from a sandbox app. No network.
++ */
++const assert = require('node:assert/strict');
++const { loadConfig, createGame, playRound } = require('../scores');
++
++globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
++
++const calls = [];
++const titles = new Map();
++const json = (status, o) => new Response(o === null ? null : JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } });
++const problem = (status, code, detail) => new Response(JSON.stringify({ type: 'about:blank', title: code, status, code, detail }), { status, headers: { 'content-type': 'application/problem+json' } });
++
++async function fakeFetch(url, init = {}) {
++ const u = new URL(String(url));
++ const method = (init.method || 'GET').toUpperCase();
++ const auth = new Headers(init.headers).get('authorization');
++ if (u.href === 'https://openvibe.network/oauth/token') {
++ const form = new URLSearchParams(String(init.body));
++ calls.push({ kind: 'token', audience: form.get('audience'), scope: form.get('scope') });
++ return json(200, { access_token: 'app-token-1', token_type: 'Bearer', expires_in: 300, scope: form.get('scope') });
++ }
++ assert.equal(u.origin, 'https://play.openvibe.network');
++ calls.push({ kind: 'play', method, path: u.pathname, auth });
++ const m = u.pathname.match(/^\/api\/v1\/titles\/([a-z0-9-]+)(?:\/(scores|unlocks|boards\/([a-z0-9_-]+)))?$/);
++ if (!m) return problem(404, 'route.not_found', 'no such route');
++ const [, id, what, board] = m;
++ const writing = method !== 'GET';
++ if (writing && auth !== 'Bearer app-token-1') return problem(401, 'token.required', 'a token for openvibe.play');
++ if (method === 'PUT' && !what) {
++ if (!id.startsWith('sandbox-')) return problem(400, 'play.sandbox_title', 'a sandbox app\'s title id starts with sandbox- (the real name comes with a production app)');
++ const created = !titles.has(id);
++ const body = JSON.parse(init.body);
++ titles.set(id, { id, name: body.name, sandbox: true, rounds: new Set(), scores: [], unlocked: new Set() });
++ return json(created ? 201 : 200, { title: { id, name: body.name, sandbox: true, achievements: body.achievements, boards: body.boards }, created });
++ }
++ const t = titles.get(id);
++ if (!t) return problem(404, 'play.unknown_title', 'no such title');
++ if (method === 'DELETE' && !what) { titles.delete(id); return new Response(null, { status: 204 }); }
++ if (what === 'scores') {
++ const b = JSON.parse(init.body);
++ if (b.entries.some((e) => !e.player.startsWith('guest:'))) return problem(400, 'play.sandbox_guests_only', 'a sandbox title\'s players are guests');
++ if (t.rounds.has(b.ref)) return json(200, { recorded: 0, repeated: b.entries.length });
++ t.rounds.add(b.ref);
++ t.scores.push(...b.entries);
++ return json(200, { recorded: b.entries.length, repeated: 0 });
++ }
++ if (what === 'unlocks') {
++ const b = JSON.parse(init.body);
++ const fresh = b.achievements.filter((a) => !t.unlocked.has(a));
++ fresh.forEach((a) => t.unlocked.add(a));
++ return json(200, { unlocked: fresh });
++ }
++ if (board) {
++ const entries = [...t.scores].sort((a, b) => b.score - a.score).map((e, i) => ({ rank: i + 1, name: e.name, score: e.score, signed_in: false }));
++ return json(200, { title: id, title_name: t.name, board, name: 'High score', kind: 'best', period: 'week', page: 1, pages: 1, total: entries.length, entries });
++ }
++ return problem(404, 'route.not_found', 'no such route');
++}
++
++(async () => {
++ assert.throws(() => loadConfig({}), /OV_CLIENT_ID, OV_CLIENT_SECRET/);
++ assert.throws(() => loadConfig({ OV_CLIENT_ID: 'a', OV_CLIENT_SECRET: 'b', OV_PLAY_TITLE: 'Not An Id' }), /OV_PLAY_TITLE/);
++ const config = loadConfig({ OV_CLIENT_ID: 'app_01TEST', OV_CLIENT_SECRET: 'secret' });
++ assert.match(config.title, /^sandbox-gem-run-[0-9a-f]{6}$/, 'a sandbox title by default');
++ const play = createGame(config, { fetch: fakeFetch });
++
++ const out = await playRound(play, config);
++ assert.deepEqual(calls.filter((c) => c.kind === 'token').map((c) => [c.audience, c.scope]), [['openvibe.play', 'play.title.manage play.result.write']]);
++ assert.equal(out.created, true);
++ assert.equal(out.title.sandbox, true);
++ assert.deepEqual([out.posted.recorded, out.again.recorded], [1, 0], 'the same round again counts once');
++ assert.deepEqual(out.unlocked, ['first-gem']);
++ assert.deepEqual(out.board.entries.map((e) => [e.rank, e.name, e.score]), [[1, 'Example player', 1200]]);
++ assert.equal(calls.find((c) => c.kind === 'play' && c.path.endsWith('/boards/high-score')).auth, null, 'the board is a public read');
++ assert.equal(out.removed, true);
++ assert.equal(titles.size, 0, 'the sandbox title is gone');
++
++ // --keep leaves it; a real name from a sandbox app is refused with Play's code.
++ const kept = await playRound(play, config, { keep: true });
++ assert.equal(kept.removed, false);
++ assert.ok(titles.has(config.title));
++ await assert.rejects(play.titles.define('gem-run', { name: 'Gem Run' }), (e) => e.status === 400 && e.code === 'play.sandbox_title');
++
++ console.log('game-scores smoke: an openvibe.play token, a sandbox title, a round counted once, an unlock, the public board, the title deleted');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/package-lock.json b/package-lock.json
iindex 21a8db2..398b9d4 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -66,6 +66,17 @@
"node": ">=22"
}
},
++ "examples/game-scores": {
++ "name": "openvibe-example-game-scores",
++ "version": "0.1.0",
++ "license": "MIT",
++ "dependencies": {
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.0"
++ },
++ "engines": {
++ "node": ">=22"
++ }
++ },
"examples/go-live": {
"name": "openvibe-example-go-live",
"version": "0.1.0",
@@@ -732,6 +743,10 @@
"resolved": "examples/event-subscriber",
"link": true
},
++ "node_modules/openvibe-example-game-scores": {
++ "resolved": "examples/game-scores",
++ "link": true
++ },
"node_modules/openvibe-example-go-live": {
"resolved": "examples/go-live",
"link": true
ddiff --git a/scripts/developer-path-mock.js b/scripts/developer-path-mock.js
iindex c43c4d0..5878235 100644
---- a/scripts/developer-path-mock.js
++++ b/scripts/developer-path-mock.js
@@@ -15,11 +15,14 @@
* draft → published → deprecated → revoked, and the public list shows every release but drafts, like Services.
* And it stands in for OpenVibe.AI's OpenAI-compatible endpoint (the ai step): an app token for openvibe.ai carrying
* ai.app.run gets a chat.completion with x_openvibe, in OpenAI's shapes, errors included.
++ * And for OpenVibe.Play (the play step): a sandbox app's token for openvibe.play registers sandbox- titles only, posts
++ * guests' rounds (a repeated ref records nothing) and unlocks, reads the public board without a token, and deletes its
++ * title, with Play's problem codes (Play#41/#42).
* Everything else is the platform mock's own.
*/
const { createMockPlatform } = require('openvibe-sdk/testing');
const contracts = require('openvibe-contracts');
--const { runDeveloperPath, PRODUCTION_NETWORK, PRODUCTION_SERVICES, PRODUCTION_AI } = require('./developer-path');
++const { runDeveloperPath, PRODUCTION_NETWORK, PRODUCTION_SERVICES, PRODUCTION_AI, PRODUCTION_PLAY } = require('./developer-path');
function withAccounts(platform, network = PRODUCTION_NETWORK) {
const byName = new Map();
@@@ -73,6 +76,48 @@ function withAccounts(platform, network = PRODUCTION_NETWORK) {
});
}
++ const titles = new Map();
++ async function play(url, method, init) {
++ const problem = (status, code, detail) => new Response(JSON.stringify({ type: 'about:blank', title: code, status, code, detail }), { status, headers: { 'Content-Type': 'application/problem+json' } });
++ const m = url.pathname.match(/^\/api\/v1\/titles\/([a-z0-9-]+)(?:\/(scores|unlocks|boards\/([a-z0-9_-]+)))?$/);
++ if (!m) return problem(404, 'route.not_found', 'no such route');
++ const [, id, what, board] = m;
++ const t = titles.get(id);
++ if (method === 'GET' && board) {
++ if (!t) return problem(404, 'play.unknown_board', 'no such board');
++ const entries = [...t.scores].sort((a, b) => b.score - a.score).map((e, i) => ({ rank: i + 1, name: e.name, score: e.score, signed_in: e.player.startsWith('usr_') }));
++ return reply(200, { title: id, title_name: t.name, board, name: 'High score', kind: 'best', period: url.searchParams.get('period') || 'all', page: 1, pages: 1, total: entries.length, entries });
++ }
++ const claims = claimsOf(new Headers(init.headers || {}).get('authorization'));
++ if (!claims) return problem(401, 'token.required', 'a token for openvibe.play');
++ const need = method === 'PUT' || method === 'DELETE' ? 'play.title.manage' : 'play.result.write';
++ if (!(claims.aud || []).includes('openvibe.play') || !(claims.cap || []).includes(need)) return problem(403, 'capability.denied', `${need} is required`);
++ const sandbox = claims.env === 'sandbox';
++ if (method === 'PUT' && !what) {
++ if (sandbox !== id.startsWith('sandbox-')) return problem(400, 'play.sandbox_title', sandbox ? 'a sandbox app\'s title id starts with sandbox-' : 'sandbox- ids are for sandbox apps');
++ if (t && t.owner !== claims.sub) return problem(409, 'play.title_taken', 'another principal manages this title');
++ const body = JSON.parse(init.body || '{}');
++ titles.set(id, { owner: claims.sub, name: body.name, rounds: new Set(), scores: [], unlocked: new Set(), ...(t || {}) });
++ return reply(t ? 200 : 201, { title: { id, name: body.name, ...(sandbox ? { sandbox: true } : {}), achievements: body.achievements || [], boards: body.boards || [] }, created: !t });
++ }
++ if (!t || t.owner !== claims.sub) return problem(404, 'play.unknown_title', 'no such title of yours');
++ if (method === 'DELETE' && !what) { titles.delete(id); return new Response(null, { status: 204 }); }
++ const body = JSON.parse(init.body || '{}');
++ if (what === 'scores') {
++ if (id.startsWith('sandbox-') && body.entries.some((e) => !String(e.player).startsWith('guest:'))) return problem(400, 'play.sandbox_guests_only', 'a sandbox title\'s players are guests');
++ if (t.rounds.has(body.ref)) return reply(200, { recorded: 0, repeated: body.entries.length });
++ t.rounds.add(body.ref);
++ t.scores.push(...body.entries);
++ return reply(200, { recorded: body.entries.length, repeated: 0 });
++ }
++ if (what === 'unlocks') {
++ const fresh = body.achievements.filter((a) => !t.unlocked.has(a));
++ fresh.forEach((a) => t.unlocked.add(a));
++ return reply(200, { unlocked: fresh });
++ }
++ return problem(404, 'route.not_found', 'no such route');
++ }
++
return async (input, init = {}) => {
const url = new URL(typeof input === 'string' ? input : input.url);
const method = (init.method || (typeof input === 'string' ? 'GET' : input.method) || 'GET').toUpperCase();
@@@ -100,6 +145,7 @@ function withAccounts(platform, network = PRODUCTION_NETWORK) {
}
if (url.origin === PRODUCTION_SERVICES) return services(url, method, init);
if (url.origin === PRODUCTION_AI) return ai(url, method, init);
++ if (url.origin === PRODUCTION_PLAY) return play(url, method, init);
return platform.fetch(input, init);
};
}
ddiff --git a/scripts/developer-path.js b/scripts/developer-path.js
iindex c843b6d..83359b7 100644
---- a/scripts/developer-path.js
++++ b/scripts/developer-path.js
@@@ -9,22 +9,24 @@
* 2. discovery GET /.well-known/openvibe, as any origin may
* 3. project a new project (sandbox) through /api/v1/projects (openvibe-sdk/projects)
* 4. app a confidential sandbox app; its secret is held in memory only
-- * 5. grants media.object.upload|read, events.app.publish|read, ai.app.run and
-- * services.release.manage, approved at once for the project owner inside the sandbox allowance
++ * 5. grants media.object.upload|read, events.app.publish|read, ai.app.run, play.title.manage|play.result.write
++ * and services.release.manage, approved at once for the project owner inside the sandbox allowance
* 6. media examples/media-uploader: upload a small file, read it back, delete it
* 7. events examples/event-subscriber: publish app.<project_key>.developer_path.ran, pull it
* 8. ai examples/ai-chat: the official openai client on OpenVibe.AI's OpenAI-compatible endpoint
* with an app token for openvibe.ai; one short answer, with its x_openvibe run id
-- * 9. export as the project's owner: Network mints read-only export tokens (5 minutes); Media
++ * 9. play examples/game-scores: a sandbox- title on OpenVibe.Play, a round posted twice (recorded once), an
++ * unlock, the public board read without a token, the title deleted
++ * 10. export as the project's owner: Network mints read-only export tokens (5 minutes); Media
* lists the project's sandbox files with one and refuses an upload; Events returns the
* event from step 7 with the other (roadmap WS-N task 9, the project export)
-- * 10. release as the app, on OpenVibe.Services: a sandbox release of itself goes draft →
++ * 11. release as the app, on OpenVibe.Services: a sandbox release of itself goes draft →
* published → deprecated → revoked, checked in the public list at each step (a
* draft is never listed; a revoked release stays listed, marked revoked)
* (roadmap WS-N task 5, the app release flow in production)
-- * 11. credentials rotate with no overlap, revoke the first credential; the old secret is refused
++ * 12. credentials rotate with no overlap, revoke the first credential; the old secret is refused
* at /oauth/token and the new one works
-- * 12. cleanup archive the project (always attempted once it exists); the app's secret is
++ * 13. cleanup archive the project (always attempted once it exists); the app's secret is
* refused afterwards
*
* The same flow runs in CI against openvibe-sdk/testing's mock platform (scripts/developer-path-mock.js,
@@@ -37,7 +39,7 @@
*
* Credentials come from the environment only (or ./.env). It refuses to start without them, and
* refuses to run in CI against the production Network (CI set and OV_NETWORK_URL unset or
-- * openvibe.network). Optional: OV_NETWORK_URL, OV_SERVICES_URL, OV_AI_URL. It never prints a password, secret or token: they
++ * openvibe.network). Optional: OV_NETWORK_URL, OV_SERVICES_URL, OV_AI_URL, OV_PLAY_URL. It never prints a password, secret or token: they
* are masked in all output, and signed URLs are shown without their signature. Exit 0 when every
* step passed, 1 when one failed, 2 when it refused to start.
*/
@@@ -53,14 +55,16 @@ const { createUploader, explain, loadConfig: mediaConfig } = require(path.join(R
const { publishAppEvent, loadConfig: publishConfig } = require(path.join(ROOT, 'examples/event-subscriber/publish'));
const { createSubscriber, createCursorStore, loadConfig: subscriberConfig } = require(path.join(ROOT, 'examples/event-subscriber/subscriber'));
const { createAiClient, ask, loadConfig: aiConfig } = require(path.join(ROOT, 'examples/ai-chat/chat'));
++const { createGame, playRound, loadConfig: gameConfig } = require(path.join(ROOT, 'examples/game-scores/scores'));
const PRODUCTION_NETWORK = 'https://openvibe.network';
const PRODUCTION_SERVICES = 'https://openvibe.services';
const PRODUCTION_MEDIA = 'https://openvibe.media';
const PRODUCTION_EVENTS = 'https://openvibe.events';
const PRODUCTION_AI = 'https://ai.openvibe.services';
--const GRANTS = ['media.object.upload', 'media.object.read', 'events.app.publish', 'events.app.read', 'ai.app.run', 'services.release.manage'];
--const STEPS = ['account', 'discovery', 'project', 'app', 'grants', 'media', 'events', 'ai', 'export', 'release', 'credentials', 'cleanup'];
++const PRODUCTION_PLAY = 'https://play.openvibe.network';
++const GRANTS = ['media.object.upload', 'media.object.read', 'events.app.publish', 'events.app.read', 'ai.app.run', 'play.title.manage', 'play.result.write', 'services.release.manage'];
++const STEPS = ['account', 'discovery', 'project', 'app', 'grants', 'media', 'events', 'ai', 'play', 'export', 'release', 'credentials', 'cleanup'];
const STEP_TIMEOUT_MS = 120000;
const withoutQuery = (u) => { try { const x = new URL(u); return `${x.origin}${x.pathname}${x.search ? ' (signed)' : ''}`; } catch { return u; } };
@@@ -102,11 +106,11 @@ async function tryToken(fetchImpl, network, clientId, clientSecret, audience) {
}
/**
-- * Runs the twelve steps. account: { token } | { username, password, register }. onSecret(value) is
++ * Runs the thirteen steps. account: { token } | { username, password, register }. onSecret(value) is
* called with every password, secret and token as soon as it is known, so the caller can mask it.
* → { ok, steps: [{ name, ok, skipped?, error? }], projectId, appId }
*/
--async function runDeveloperPath({ network = PRODUCTION_NETWORK, services = PRODUCTION_SERVICES, media = PRODUCTION_MEDIA, events = PRODUCTION_EVENTS, ai = PRODUCTION_AI, fetch: fetchImpl = globalThis.fetch, account, log = (m) => console.log(m), onSecret = () => {}, now = () => new Date() }) {
++async function runDeveloperPath({ network = PRODUCTION_NETWORK, services = PRODUCTION_SERVICES, media = PRODUCTION_MEDIA, events = PRODUCTION_EVENTS, ai = PRODUCTION_AI, play = PRODUCTION_PLAY, fetch: fetchImpl = globalThis.fetch, account, log = (m) => console.log(m), onSecret = () => {}, now = () => new Date() }) {
network = network.replace(/\/+$/, '');
services = services.replace(/\/+$/, '');
const say = (m) => log(` ${m}`);
@@@ -242,6 +246,20 @@ async function runDeveloperPath({ network = PRODUCTION_NETWORK, services = PRODU
say(`chat.completions.create with the openai client and ai.app.run: ${JSON.stringify(text.slice(0, 40))} from ${out.model} (run ${run.run_id} on ${run.provider}, $${run.cost_usd})`);
});
++ await step('play', async () => {
++ // examples/game-scores, as written: a sandbox app's title starts with sandbox- (unlisted, guests only, at most 3).
++ const config = gameConfig({ ...creds(), OV_PLAY_URL: play.replace(/\/+$/, '') });
++ const game = createGame(config, { fetch: fetchImpl });
++ let out;
++ try { out = await playRound(game, config); } catch (err) { await game.titles.remove(config.title).catch(() => {}); throw err; }
++ if (!out.title.sandbox) throw new Error(`${config.title} is not marked sandbox`);
++ if (out.posted.recorded !== 1 || out.again.recorded !== 0) throw new Error(`the round was recorded ${out.posted.recorded} then ${out.again.recorded} times, not once`);
++ if (!out.unlocked.includes('first-gem')) throw new Error('first-gem was not unlocked');
++ const top = out.board.entries[0];
++ if (!top) throw new Error('the public board is empty');
++ say(`registered ${config.title} (sandbox), a round recorded once (its repeat changed nothing), first-gem unlocked, the public board shows #${top.rank} ${top.name} ${top.score}; deleted`);
++ });
++
await step('export', async () => {
const mint = async (audience) => {
const res = await fetchImpl(`${network}/api/v1/projects/${st.projectId}/export-tokens`, {
@@@ -375,7 +393,7 @@ async function main(argv = process.argv.slice(2), env = process.env) {
console.log(`OpenVibe developer path against ${network}\n`);
const started = Date.now();
const services = (env.OV_SERVICES_URL || PRODUCTION_SERVICES).replace(/\/+$/, '');
-- const result = await runDeveloperPath({ network, services, media: env.OV_MEDIA_URL || PRODUCTION_MEDIA, events: env.OV_EVENTS_URL || PRODUCTION_EVENTS, ai: env.OV_AI_URL || PRODUCTION_AI, account, onSecret });
++ const result = await runDeveloperPath({ network, services, media: env.OV_MEDIA_URL || PRODUCTION_MEDIA, events: env.OV_EVENTS_URL || PRODUCTION_EVENTS, ai: env.OV_AI_URL || PRODUCTION_AI, play: env.OV_PLAY_URL || PRODUCTION_PLAY, account, onSecret });
// --result <file>: a JSON record for the scheduled production run (OpenVibe.Host openvibe-devpath.timer,
// shown on openvibe.network/status): step names and outcomes only — never an error text, URL, id or secret.
const out = argv.indexOf('--result');
@@@ -394,7 +412,7 @@ function writeResult(file, result, { network, started = Date.now(), now = Date.n
return record;
}
--module.exports = { runDeveloperPath, writeResult, GRANTS, STEPS, PRODUCTION_NETWORK, PRODUCTION_SERVICES, PRODUCTION_AI };
++module.exports = { runDeveloperPath, writeResult, GRANTS, STEPS, PRODUCTION_NETWORK, PRODUCTION_SERVICES, PRODUCTION_AI, PRODUCTION_PLAY };
if (require.main === module) {
main().then((code) => { process.exitCode = code; }, (err) => { console.error(`developer-path: ${describeError(err)}`); process.exitCode = 1; });
ddiff --git a/test/structure.test.js b/test/structure.test.js
iindex ab284b6..c7a7e3c 100644
---- a/test/structure.test.js
++++ b/test/structure.test.js
@@@ -11,13 +11,13 @@ const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
--const EXPECTED = ['ai-chat', 'browser-app', 'chat-bot', 'event-subscriber', 'go-live', 'inventory-wear', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'space-deploy', 'tool-job', 'webhook-consumer', 'zone-s3'];
++const EXPECTED = ['ai-chat', 'browser-app', 'chat-bot', 'event-subscriber', 'game-scores', 'go-live', 'inventory-wear', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'space-deploy', 'tool-job', 'webhook-consumer', 'zone-s3'];
// The pin every example must share: the repository's own (the pin bot moves them all at once).
const ROOT_PKG = require(path.join(ROOT, 'package.json'));
const SDK = (ROOT_PKG.dependencies || {})['openvibe-sdk'] || (ROOT_PKG.devDependencies || {})['openvibe-sdk'];
const dirs = fs.readdirSync(path.join(ROOT, 'examples')).filter((d) => fs.statSync(path.join(ROOT, 'examples', d)).isDirectory()).sort();
--assert.deepEqual(dirs, EXPECTED, 'the nine charter examples, ai-chat, go-live, inventory-wear, space-deploy and zone-s3');
++assert.deepEqual(dirs, EXPECTED, 'the nine charter examples, ai-chat, game-scores, go-live, inventory-wear, space-deploy and zone-s3');
function sources(dir) {
const out = [];