openvibers/openvibe.play Commits

Play 0.2.0: merged accounts become one player (network.subject.merged) (#2)

OP OpenVibers committed 30 days ago · 33da87a0af · 10 files, +110 −20

Play folds merged accounts (ADR-029): it subscribes to `network.subject.merged` with the account topics, and the folded account's unlocks (earliest time wins) and scores (a round both posted keeps the survivor's) become the survivor's in every title, once per merge (`play_subject_merges`, migration 0002 expand).

- `server/play/subject-merge.js`: the event → `store.mergeSubject`; a bad payload or another source is ignored.
- `store.moveRows`: shared by guest adoption (one title) and merges (every title).
- The account-data consumer hands the topic over through `onEvent`; `startSubscriptions` adds it to the topics.
- 0.2.0; README and CHANGELOG.

Tests: account-data.test.js adds the merge (moved once, overlap kept, redelivery unchanged, bad payload ignored). `npm test` 15/15.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 2b7fa6b..1605371 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,12 @@
All notable changes to OpenVibe.Play.
++## 0.2.0 — 2026-10-14
++
++- **Account merges (ADR-029).** Play subscribes to `network.subject.merged`: the folded account's unlocks (the earliest
++ time wins) and scores (a round both accounts posted keeps the survivor's) become the survivor's in every title, once
++ per merge (`play_subject_merges`, migration 0002). Before it, a merged person showed as two players on the boards.
++
## 0.1.0 — 2026-10-14
- First release (OpenVibe.Games ADR-0007 decision 10, contracts 0.172.0): titles registered by their own principal,
ddiff --git a/README.md b/README.md
iindex 45a42c1..c7298aa 100644
---- a/README.md
++++ b/README.md
@@@ -13,7 +13,8 @@ its boards. The rules of each game stay in the game; Play keeps the record and a
- **Leaderboards** of two kinds: `best` ranks each player's best score, `sum` the total of theirs. Each reads this week
(from Monday 00:00 UTC) or all time, 25 a page. A tie goes to whoever got there first.
- **Players** are people (`usr_…`) or guests a game names (`guest:<opaque>`). When a guest signs in, the game adopts
-- them and their unlocks and scores become the person's.
++ them and their unlocks and scores become the person's. When two OpenVibe accounts are merged (`network.subject.merged`),
++ the folded account's unlocks and scores become the survivor's in every title.
- **Idempotent writes.** A round's scores carry a `ref`; posting it again changes nothing. An unlock answers only the
achievements that were new.
- **Personal data.** Only people's rows are: they are in the person's account export and erased with their account
@@@ -55,9 +56,10 @@ wildcard certificate, deployed with `sudo ovhost deploy play`.
```
server/play/store.js titles, achievements, boards, scores, unlocks, adoption
server/play/account-data.js account export and deletion (ADR-033)
++server/play/subject-merge.js account merges (ADR-029)
server/http/api.js /api/v1
server/http/pages.js the pages; discovery.js robots.txt, sitemap.xml, llms.txt
--migrations/0001_initial.sql the schema
++migrations/ the schema (0001 the store, 0002 the merges applied)
```
## License
ddiff --git a/migrations/0002_subject_merges.sql b/migrations/0002_subject_merges.sql
nnew file mode 100644
iindex 0000000..ba41f99
---- /dev/null
++++ b/migrations/0002_subject_merges.sql
@@@ -0,0 +1,11 @@
++-- phase: expand
++-- network.subject.merged (ADR-029): two Network accounts became one, and every unlock and score of the folded account
++-- moved to the survivor. One row per merge, so a redelivery changes nothing.
++CREATE TABLE play_subject_merges (
++ merge_id text COLLATE "C" PRIMARY KEY,
++ from_subject text COLLATE "C" NOT NULL,
++ into_subject text COLLATE "C" NOT NULL,
++ unlocks integer NOT NULL,
++ scores integer NOT NULL,
++ merged_at bigint NOT NULL
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex 0253ac9..27d123d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-play",
-- "version": "0.1.0",
++ "version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-play",
-- "version": "0.1.0",
++ "version": "0.2.0",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.20.0",
ddiff --git a/package.json b/package.json
iindex 5637568..8d673ca 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-play",
-- "version": "0.1.0",
++ "version": "0.2.0",
"private": true,
"description": "OpenVibe.Play — achievements and leaderboards for every OpenVibe game.",
"main": "server/index.js",
ddiff --git a/server/app.js b/server/app.js
iindex 1176a37..e54b268 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -31,6 +31,7 @@ const { createServiceReadiness } = require('./observability');
const { createCallerLimits } = require('./http/caller-limits');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createPlay } = require('./play/store');
++const subjectMerge = require('./play/subject-merge');
const accountDataLib = require('./play/account-data');
const { assetVersion, send } = require('./render/layout');
const { html } = require('./render/html');
@@@ -99,8 +100,12 @@ async function createApp(opts = {}) {
// ── OpenVibe.Events → Play (loopback only: nginx answers 404 for /internal/) ──
// network.account.export_requested and network.account.deleted, answered by openvibe-sdk/account-data's consumer,
-- // which reads the raw body itself (the v2 signature covers it) and refuses a request that came through a proxy.
-- app.post('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log }));
++ // which reads the raw body itself (the v2 signature covers it) and refuses a request that came through a proxy;
++ // network.subject.merged (ADR-029) reaches it as another topic and folds the merged account's rows (subject-merge.js).
++ app.post('/internal/events', accountData.consumer({
++ secrets: config.events.secrets, send: accountSend, log,
++ onEvent: (ev) => subjectMerge.apply(ev, { play, log }),
++ }));
// ── Machine endpoints ───────────────────────────────────
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-play', version: VERSION }));
ddiff --git a/server/index.js b/server/index.js
iindex d2143dd..cae3e86 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -6,7 +6,8 @@
*/
const { createApp } = require('./app');
const { gracefulStop } = require('openvibe-sdk/service');
--const { startSubscriptions } = require('openvibe-sdk/account-data');
++const { startSubscriptions, TOPICS } = require('openvibe-sdk/account-data');
++const subjectMerge = require('./play/subject-merge');
/**
* The process stop (openvibe-sdk/service): the HTTP drain runs, then the timers clear, the Events subscriptions stop,
@@@ -29,10 +30,11 @@ async function start() {
});
server.keepAliveTimeout = 65_000;
ctx.keys.client.start();
-- // The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
-- // PLAY_EVENTS_SECRET or the client secret.
++ // The account subscriptions at OpenVibe.Events (ADR-033) and account merges (ADR-029), created when missing; off
++ // without EVENTS_URL, PLAY_EVENTS_SECRET or the client secret.
const subscriptions = startSubscriptions({
eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.secrets[0],
++ topics: [...TOPICS, subjectMerge.TOPIC],
networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
});
createLifecycle({ server, ctx, extra: [() => { if (subscriptions) subscriptions.stop(); }] });
ddiff --git a/server/play/store.js b/server/play/store.js
iindex a078ecf..5c05bfd 100644
---- a/server/play/store.js
++++ b/server/play/store.js
@@@ -8,6 +8,8 @@
* recordScores(owner, titleId, post) { board, ref, entries: [{ player, name, score }], at? } → { recorded }
* unlock(owner, titleId, post) { player, achievements: [id…], at? } → { unlocked: [newly unlocked ids] }
* adopt(owner, titleId, guest, person) move a guest's unlocks and scores to the person who signed in
++ * mergeSubject({ mergeId, from, into }) network.subject.merged: the folded account's rows, in every title, become the
++ * survivor's (once per merge)
* board(titleId, boardId, { period, page }) ranked entries: name and score, never a player id
* title(id) / titles() the public definitions, with how many players unlocked each achievement
* playerAchievements(player, { titleId }) a player's unlocks across titles (or in one)
@@@ -158,16 +160,33 @@ function createPlay({ s }) {
await ownTitle(owner, titleId);
if (!/^guest:/.test(player(guest))) fail(400, 'play.bad_player', 'only a guest is adopted');
if (!/^usr_/.test(player(person))) fail(400, 'play.bad_player', 'a guest is adopted by a person (usr_…)');
++ return await s.tx(() => moveRows(titleId, guest, person));
++ }
++
++ /** One player's unlocks (the earliest time wins) and scores (a round both posted keeps the survivor's) become
++ * another's: in one title, or in every title when titleId is null. Runs inside the caller's transaction. */
++ async function moveRows(titleId, from, into) {
++ const all = titleId === null;
++ const move = all ? [from, into] : [from, into, titleId];
++ const drop = all ? [from] : [from, titleId];
++ const unlocks = await db.exec(`INSERT INTO play_unlocks (title_id, achievement_id, player, unlocked_at)
++ SELECT title_id, achievement_id, $2, unlocked_at FROM play_unlocks WHERE player = $1${all ? '' : ' AND title_id = $3'}
++ ON CONFLICT (title_id, achievement_id, player) DO UPDATE SET unlocked_at = LEAST(play_unlocks.unlocked_at, excluded.unlocked_at)`, move);
++ await db.exec(`DELETE FROM play_unlocks WHERE player = $1${all ? '' : ' AND title_id = $2'}`, drop);
++ const scores = await db.exec(`INSERT INTO play_scores (title_id, board_id, ref, player, player_name, score, at)
++ SELECT title_id, board_id, ref, $2, player_name, score, at FROM play_scores WHERE player = $1${all ? '' : ' AND title_id = $3'}
++ ON CONFLICT (title_id, board_id, ref, player) DO NOTHING`, move);
++ await db.exec(`DELETE FROM play_scores WHERE player = $1${all ? '' : ' AND title_id = $2'}`, drop);
++ return { unlocks, scores };
++ }
++
++ async function mergeSubject({ mergeId, from, into }) {
return await s.tx(async () => {
-- const unlocks = await db.exec(`INSERT INTO play_unlocks (title_id, achievement_id, player, unlocked_at)
-- SELECT title_id, achievement_id, $3, unlocked_at FROM play_unlocks WHERE title_id = $1 AND player = $2
-- ON CONFLICT (title_id, achievement_id, player) DO UPDATE SET unlocked_at = LEAST(play_unlocks.unlocked_at, excluded.unlocked_at)`, [titleId, guest, person]);
-- await db.exec('DELETE FROM play_unlocks WHERE title_id = $1 AND player = $2', [titleId, guest]);
-- const scores = await db.exec(`INSERT INTO play_scores (title_id, board_id, ref, player, player_name, score, at)
-- SELECT title_id, board_id, ref, $3, player_name, score, at FROM play_scores WHERE title_id = $1 AND player = $2
-- ON CONFLICT (title_id, board_id, ref, player) DO NOTHING`, [titleId, guest, person]);
-- await db.exec('DELETE FROM play_scores WHERE title_id = $1 AND player = $2', [titleId, guest]);
-- return { unlocks, scores };
++ if (await db.maybe('SELECT 1 FROM play_subject_merges WHERE merge_id = $1', [mergeId])) return { already: true, unlocks: 0, scores: 0 };
++ const moved = await moveRows(null, from, into);
++ await db.exec('INSERT INTO play_subject_merges (merge_id, from_subject, into_subject, unlocks, scores, merged_at) VALUES ($1, $2, $3, $4, $5, $6)',
++ [mergeId, from, into, moved.unlocks, moved.scores, s.now()]);
++ return { already: false, ...moved };
});
}
@@@ -236,7 +255,7 @@ function createPlay({ s }) {
return [...by.values()];
}
-- return { define, recordScores, unlock, adopt, board, title, titles, playerAchievements, ownTitle };
++ return { define, recordScores, unlock, adopt, mergeSubject, board, title, titles, playerAchievements, ownTitle };
}
module.exports = { createPlay, PlayError, PLAYER_RE, TITLE_RE, weekStart, PER_PAGE };
ddiff --git a/server/play/subject-merge.js b/server/play/subject-merge.js
nnew file mode 100644
iindex 0000000..7b822ae
---- /dev/null
++++ b/server/play/subject-merge.js
@@@ -0,0 +1,26 @@
++'use strict';
++
++/**
++ * network.subject.merged → Play (ADR-029). Two Network accounts became one: `from` is now an alias of `into`. Every
++ * unlock and score of `from`, in every title, becomes `into`'s (store.mergeSubject: the earliest unlock time wins, and
++ * a round both accounts posted keeps the survivor's score), once per merge_id. Delivered to POST /internal/events with
++ * the account topics (openvibe-sdk/account-data's consumer hands it here as an event of another topic).
++ */
++const TOPIC = 'network.subject.merged';
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++/** One envelope → 'merged' | 'unchanged' | 'ignored:<why>'. */
++async function apply(ev, { play, log = console }) {
++ if (!ev || ev.event_type !== TOPIC) return 'ignored:type';
++ if (ev.source !== 'network') return 'ignored:source';
++ const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};
++ const mergeId = String(p.merge_id || ''), from = String(p.from || ''), into = String(p.into || '');
++ if (!MERGE_RE.test(mergeId) || !SUBJECT_RE.test(from) || !SUBJECT_RE.test(into) || from === into) return 'ignored:payload';
++ const r = await play.mergeSubject({ mergeId, from, into });
++ if (r.already) return 'unchanged';
++ log.log(`[Merge] ${mergeId}: ${r.unlocks} unlock(s) and ${r.scores} score(s) now the survivor's`);
++ return 'merged';
++}
++
++module.exports = { apply, TOPIC };
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex ee78959..ba564ea 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -78,6 +78,25 @@ async function startNetworkStub() {
assert.strictEqual((await deliver(event)).json.outcome, 'unchanged');
});
++ await check('a merge folds bob into carol in every title, once; a payload that is not one is ignored', async () => {
++ const carol = 'usr_01JZ0000000000000000000CA1';
++ await play.recordScores('service:games', 'salvage-rush', { board: 'best', ref: 'r2', entries: [
++ { player: bob.subject, name: 'Bob', score: 350 }, { player: carol, name: 'Carol', score: 500 },
++ ] });
++ await play.unlock('service:games', 'salvage-rush', { player: carol, achievements: ['first-haul'] });
++ const event = ev('evt_01JZ0000000000000000000M01', 'network.subject.merged', { merge_id: 'mrg_01JZ0000000000000000000MG1', from: bob.subject, into: carol });
++ const r = await deliver(event);
++ assert.deepStrictEqual([r.status, r.json && r.json.outcome], [200, 'merged'], JSON.stringify(r.json));
++ assert.strictEqual(await count('SELECT count(*) FROM play_scores WHERE player = $1', [bob.subject]), 0);
++ assert.strictEqual(await count('SELECT count(*) FROM play_unlocks WHERE player = $1', [bob.subject]), 0);
++ assert.strictEqual(await count('SELECT count(*) FROM play_unlocks WHERE player = $1', [carol]), 1, 'one unlock of first-haul: the two accounts\' are one');
++ assert.deepStrictEqual((await t.ctx.s.db.many('SELECT ref, score FROM play_scores WHERE player = $1 ORDER BY ref', [carol])).map((x) => [x.ref, Number(x.score)]),
++ [['r1', 300], ['r2', 500]], 'bob\'s round 1 is carol\'s now; in round 2 both played and carol\'s own score stays');
++ assert.strictEqual((await deliver(event)).json.outcome, 'unchanged');
++ const bad = ev('evt_01JZ0000000000000000000M02', 'network.subject.merged', { merge_id: 'mrg_01JZ0000000000000000000MG2', from: carol, into: carol });
++ assert.strictEqual((await deliver(bad)).json.outcome, 'ignored:payload');
++ });
++
await check('the route refuses a bad signature and a request that came through a proxy', async () => {
const event = ev('evt_01JZ0000000000000000000E02', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX2', subject: alice.subject });
assert.strictEqual((await deliver(event, { secret: `whsec_${'mismatch'.repeat(5)}` })).status, 401);