openvibers/openvibe.play Commits
Play 0.2.0: merged accounts become one player (network.subject.merged) (#2)
Changes
@@@ -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,@@@ -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 theachievements 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, adoptionserver/play/account-data.js account export and deletion (ADR-033)++server/play/subject-merge.js account merges (ADR-029)server/http/api.js /api/v1server/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)```## Licensennew file mode 100644@@@ -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++);@@@ -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",@@@ -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",@@@ -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 }));@@@ -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(); }] });@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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);
@@ -2,6 +2,12 @@
@@ -2,6 +2,12 @@
All notable changes to OpenVibe.Play.
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
## 0.1.0 — 2026-10-14
- First release (OpenVibe.Games ADR-0007 decision 10, contracts 0.172.0): titles registered by their own principal,
- First release (OpenVibe.Games ADR-0007 decision 10, contracts 0.172.0): titles registered by their own principal,
@@ -13,7 +13,8 @@ its boards. The rules of each game stay in the game; Play keeps the record and a
@@ -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
- **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.
(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
- **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
- **Idempotent writes.** A round's scores carry a `ref`; posting it again changes nothing. An unlock answers only the
achievements that were new.
achievements that were new.
- **Personal data.** Only people's rows are: they are in the person's account export and erased with their account
- **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`.
@@ -55,9 +56,10 @@ wildcard certificate, deployed with `sudo ovhost deploy play`.
```
```
server/play/store.js titles, achievements, boards, scores, unlocks, adoption
server/play/store.js titles, achievements, boards, scores, unlocks, adoption
server/play/account-data.js account export and deletion (ADR-033)
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/api.js /api/v1
server/http/pages.js the pages; discovery.js robots.txt, sitemap.xml, llms.txt
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
## License
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -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
+);
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-play",
"name": "openvibe-play",
- "version": "0.1.0",
+ "version": "0.2.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-play",
"name": "openvibe-play",
- "version": "0.1.0",
+ "version": "0.2.0",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.20.0",
"ajv": "^8.20.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-play",
"name": "openvibe-play",
- "version": "0.1.0",
+ "version": "0.2.0",
"private": true,
"private": true,
"description": "OpenVibe.Play — achievements and leaderboards for every OpenVibe game.",
"description": "OpenVibe.Play — achievements and leaderboards for every OpenVibe game.",
"main": "server/index.js",
"main": "server/index.js",
@@ -31,6 +31,7 @@ const { createServiceReadiness } = require('./observability');
@@ -31,6 +31,7 @@ const { createServiceReadiness } = require('./observability');
const { createCallerLimits } = require('./http/caller-limits');
const { createCallerLimits } = require('./http/caller-limits');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createPlay } = require('./play/store');
const { createPlay } = require('./play/store');
+const subjectMerge = require('./play/subject-merge');
const accountDataLib = require('./play/account-data');
const accountDataLib = require('./play/account-data');
const { assetVersion, send } = require('./render/layout');
const { assetVersion, send } = require('./render/layout');
const { html } = require('./render/html');
const { html } = require('./render/html');
@@ -99,8 +100,12 @@ async function createApp(opts = {}) {
@@ -99,8 +100,12 @@ async function createApp(opts = {}) {
// ── OpenVibe.Events → Play (loopback only: nginx answers 404 for /internal/) ──
// ── 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,
// 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 ───────────────────────────────────
// ── Machine endpoints ───────────────────────────────────
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-play', version: VERSION }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-play', version: VERSION }));
@@ -6,7 +6,8 @@
@@ -6,7 +6,8 @@
*/
*/
const { createApp } = require('./app');
const { createApp } = require('./app');
const { gracefulStop } = require('openvibe-sdk/service');
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,
* 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() {
@@ -29,10 +30,11 @@ async function start() {
});
});
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
ctx.keys.client.start();
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({
const subscriptions = startSubscriptions({
eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.secrets[0],
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,
networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
});
});
createLifecycle({ server, ctx, extra: [() => { if (subscriptions) subscriptions.stop(); }] });
createLifecycle({ server, ctx, extra: [() => { if (subscriptions) subscriptions.stop(); }] });
@@ -8,6 +8,8 @@
@@ -8,6 +8,8 @@
* recordScores(owner, titleId, post) { board, ref, entries: [{ player, name, score }], at? } → { recorded }
* recordScores(owner, titleId, post) { board, ref, entries: [{ player, name, score }], at? } → { recorded }
* unlock(owner, titleId, post) { player, achievements: [id…], at? } → { unlocked: [newly unlocked ids] }
* 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
* 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
* 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
* 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)
* playerAchievements(player, { titleId }) a player's unlocks across titles (or in one)
@@ -158,16 +160,33 @@ function createPlay({ s }) {
@@ -158,16 +160,33 @@ function createPlay({ s }) {
await ownTitle(owner, titleId);
await ownTitle(owner, titleId);
if (!/^guest:/.test(player(guest))) fail(400, 'play.bad_player', 'only a guest is adopted');
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_…)');
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 () => {
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 }) {
@@ -236,7 +255,7 @@ function createPlay({ s }) {
return [...by.values()];
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 };
module.exports = { createPlay, PlayError, PLAYER_RE, TITLE_RE, weekStart, PER_PAGE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -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 };
@@ -78,6 +78,25 @@ async function startNetworkStub() {
@@ -78,6 +78,25 @@ async function startNetworkStub() {
assert.strictEqual((await deliver(event)).json.outcome, 'unchanged');
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 () => {
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 });
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);
assert.strictEqual((await deliver(event, { secret: `whsec_${'mismatch'.repeat(5)}` })).status, 401);