openvibers/openvibe.live Commits
Live: setup hub tasks emit live.setup.task_completed for Quest (contracts 0.190.0) (#139)
Changes
@@@ -217,6 +217,7 @@ transaction as the change it describes.|---|---|---|| `live.stream.started` / `live.stream.ended` | a `streams` row goes live / ends | `stream <id>` || `live.release.deployed` | the first boot that runs new commits ([server/events/release-events.js](../server/events/release-events.js)), queued with the `deploy_last_announced` update; payload: `service`, `release`, `commit`, `previous`, `commit_count`, `commits[]` (≤ 40), `deployed_at`, `notes_url` | `release <head sha>` |++| `live.setup.task_completed` | a go-live setup hub task reads as done for the first time ([server/streaming/setup-progress.js](../server/streaming/setup-progress.js)), queued with its `live_setup_tasks` row; payload: `task`, `user`, `title`, `group`, `completed_at`, `done`, `total`. OpenVibe.Quest turns each task into a quest | `user <usr_…>` |The chat deploy notice is Chat's: Live queues `live.release.deployed` and OpenVibe.Chat folds andstores the rolling card (C-84). Live keeps no chat table of its own.nnew file mode 100644@@@ -0,0 +1,10 @@++-- phase: expand++-- The go-live setup hub's tasks each person has finished, once per task (server/streaming/setup-progress.js): the first++-- time a task reads as done, Live records it here and queues live.setup.task_completed in the same transaction, which++-- OpenVibe.Quest turns into quests. A task undone later (an emote deleted) stays recorded: the quest was earned.++CREATE TABLE IF NOT EXISTS live_setup_tasks (++ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,++ task text NOT NULL,++ completed_at timestamptz NOT NULL DEFAULT now(),++ PRIMARY KEY (user_id, task)++);@@@ -23,7 +23,7 @@"multer": "^1.4.5-lts.1","node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.2",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.190.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1","pg": "^8.23.1",@@@ -2644,9 +2644,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.188.2",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.2",-- "integrity": "sha512-E64gQI7roKudmmJFnenyh1BjViJyI0j0MoVT4o35/jUD4rpzMkElwEh/fGbsPpnstdDFIcJR6gEcScbL0ZQhbg==",++ "version": "0.190.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.190.0",++ "integrity": "sha512-zh71oYBT6xHuyIj4OirWbWmJ33Xe5HeBAG6Q4Sq2S0aqA6gG4LVK5+k9P/UEV0AuSkcqqlQm84PjBF8ZTY90Ow==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -42,7 +42,7 @@"multer": "^1.4.5-lts.1","node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.2",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.190.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1","pg": "^8.23.1",@@@ -1516,6 +1516,9 @@ router.get('/setup-progress', requireAuth, async (req, res) => {];const done = tasks.filter(t => t.done).length;const next = tasks.find(t => !t.done) || null;++ // A task done for the first time is recorded once, with live.setup.task_completed for OpenVibe.Quest. The hub++ // answers even when that fails: the next visit records it.++ await require('./setup-progress').record(uid, tasks).catch((err) => console.warn('[SetupProgress] record failed:', err.message));res.set('Cache-Control', cache.htmlHeaders({ private: true }));res.json({ tasks, done, total: tasks.length, next, username: user.username });});nnew file mode 100644@@@ -0,0 +1,71 @@++'use strict';++/**++ * The go-live setup hub's finished tasks, recorded once per person, and live.setup.task_completed for each one++ * (openvibe-contracts 0.190.0). OpenVibe.Quest turns every task into a quest (owner Q16: Live's setup steps become++ * quests), so finishing "Upload a custom emote" here completes the matching quest there.++ *++ * GET /api/streams/setup-progress works the tasks out (server/streaming/routes.js) and hands them to record(). A task++ * that reads as done and has no live_setup_tasks row yet gets one, and its event is queued in Live's outbox in the++ * same transaction, so the row and the event commit together. A task is recorded at most once: the primary key++ * (user_id, task) makes a second request that raced this one insert nothing and queue nothing.++ *++ * Nothing is recorded for a person Live cannot name on the network (no linked subject yet): the next visit tries++ * again, so the quest is not lost. The event says which task it was and how far along the person is, never what++ * they made.++ *++ * envelope subject { type: 'user', id: usr_… } (the streamer), actor the same, visibility internal++ * payload { task, user: { type: 'user', id }, title, group, completed_at, done, total }++ */++const db = require('../db/database');++const identity = require('../auth/identity-sync');++const streamEvents = require('../events/stream-events');++++const EVENT_TYPE = 'live.setup.task_completed';++++function toIso(v) {++ const d = v instanceof Date ? v : new Date(v);++ return Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString();++}++++/**++ * Record the newly finished tasks of `tasks` (the hub's list: { id, group, title, done }) for `userId`.++ * Returns the ids it recorded now (empty when nothing was new).++ */++async function record(userId, tasks) {++ const finished = (tasks || []).filter((t) => t && t.done);++ if (!finished.length) return [];++ const known = new Set((await db.all('SELECT task FROM live_setup_tasks WHERE user_id = ?', [userId])).map((r) => r.task));++ const fresh = finished.filter((t) => !known.has(t.id));++ if (!fresh.length) return [];++ const subjectId = await identity.subjectOf(userId);++ if (!subjectId) return [];++ const person = { type: 'user', id: subjectId };++ const recorded = [];++ await db.tx(async () => {++ for (const t of fresh) {++ const row = await db.get('INSERT INTO live_setup_tasks (user_id, task) VALUES (?, ?) ON CONFLICT DO NOTHING RETURNING completed_at', [userId, t.id]);++ if (!row) continue; // another request recorded it first, and queued its event++ recorded.push(t.id);++ await streamEvents.enqueue({++ event_type: EVENT_TYPE,++ actor: person,++ subject: person,++ visibility: 'internal',++ priority: 'low',++ payload: {++ task: t.id,++ user: person,++ title: String(t.title || '').slice(0, 120),++ group: String(t.group || '').slice(0, 40),++ completed_at: toIso(row.completed_at),++ done: finished.length,++ total: tasks.length,++ },++ });++ }++ });++ if (recorded.length) streamEvents.kick();++ return recorded;++}++++module.exports = { EVENT_TYPE, record };nnew file mode 100644@@@ -0,0 +1,89 @@++'use strict';++++// The go-live setup hub → OpenVibe.Quest (owner Q16): server/streaming/setup-progress.js records each task the first++// time it reads as done (live_setup_tasks) and queues live.setup.task_completed in the same transaction; a second++// look, or a task undone later, queues nothing; a person Live cannot name on the network gets nothing recorded yet.++// Stub Network (token) + stub Events (publish), as test/stream-events.test.js.++++const assert = require('assert');++const http = require('http');++++const published = [];++const stub = http.createServer((req, res) => {++ let body = '';++ req.on('data', (c) => { body += c; });++ req.on('end', () => {++ res.setHeader('Content-Type', 'application/json');++ if (req.url === '/oauth/token') return res.end(JSON.stringify({ access_token: 'tok', token_type: 'Bearer', expires_in: 300 }));++ if (req.url === '/api/v1/events' && req.method === 'POST') {++ const parsed = JSON.parse(body);++ const list = parsed.events || [parsed];++ const results = list.map((e) => { published.push(e); return { event_id: e.event_id, seq: published.length, duplicate: false }; });++ return res.end(JSON.stringify(parsed.events ? { results } : results[0]));++ }++ res.statusCode = 404; res.end('{}');++ });++});++++const SUBJECT = 'usr_01J8Z3V9Q6N1X2Y3Z4A5B6C7D8';++const task = (id, done) => ({ id, group: 'Look & feel', title: `Task ${id}`, done });++++(async () => {++ await new Promise((r) => stub.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${stub.address().port}`;++ process.env.OV_NETWORK_INTERNAL_URL = base;++++ const db = require('../server/db/database');++ await db.initDb();++ const d = db.getDb();++ await d.prepare("INSERT INTO users (id, username, display_name) OVERRIDING SYSTEM VALUE VALUES (611, 'setupper', 'Setupper'), (612, 'unlinked', 'Unlinked')").run();++ await d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (611, 'network', '611', ?)").run(SUBJECT);++++ const outbox = require('../server/events/stream-events').init({ eventsUrl: base, clientSecret: 's3cret', intervalMs: 50 });++ assert.ok(outbox);++ const setup = require('../server/streaming/setup-progress');++++ // Two of three done: both recorded, both queued with the person, the task and the progress.++ const first = [task('slot', true), task('emote', true), task('sound', false)];++ assert.deepStrictEqual(await setup.record(611, first), ['slot', 'emote']);++ await outbox.flush();++ assert.strictEqual(published.length, 2);++ const ev = published.find((e) => e.payload.task === 'emote');++ assert.strictEqual(ev.event_type, 'live.setup.task_completed');++ assert.strictEqual(ev.source, 'live');++ assert.strictEqual(ev.visibility, 'internal');++ assert.deepStrictEqual(ev.subject, { type: 'user', id: SUBJECT });++ assert.deepStrictEqual(ev.actor, { type: 'user', id: SUBJECT });++ assert.deepStrictEqual(ev.payload.user, { type: 'user', id: SUBJECT });++ assert.strictEqual(ev.payload.done, 2);++ assert.strictEqual(ev.payload.total, 3);++ assert.strictEqual(ev.payload.title, 'Task emote');++ assert.match(ev.payload.completed_at, /^\d{4}-\d\d-\d\dT/);++ require('openvibe-contracts').assertValid('live.setup.task_completed@1', ev.payload);++++ // The same look again: nothing new. The third task done: only it. An emote deleted later stays recorded.++ assert.deepStrictEqual(await setup.record(611, first), []);++ assert.deepStrictEqual(await setup.record(611, [task('slot', true), task('emote', false), task('sound', true)]), ['sound']);++ await outbox.flush();++ assert.deepStrictEqual(published.map((e) => e.payload.task).sort(), ['emote', 'slot', 'sound']);++ assert.strictEqual((await d.prepare('SELECT COUNT(*) AS n FROM live_setup_tasks WHERE user_id = 611').get()).n, 3);++++ // Two looks at once record and queue a task once.++ const both = await Promise.all([setup.record(611, [task('goal', true)]), setup.record(611, [task('goal', true)])]);++ assert.deepStrictEqual(both.flat(), ['goal']);++ await outbox.flush();++ assert.strictEqual(published.filter((e) => e.payload.task === 'goal').length, 1);++++ // No network subject yet: nothing recorded, so the next visit after linking still earns the quest.++ assert.deepStrictEqual(await setup.record(612, [task('slot', true)]), []);++ assert.strictEqual((await d.prepare('SELECT COUNT(*) AS n FROM live_setup_tasks WHERE user_id = 612').get()).n, 0);++++ // The route records through it.++ const src = require('fs').readFileSync(require('path').join(__dirname, '../server/streaming/routes.js'), 'utf8');++ assert.ok(/require\('\.\/setup-progress'\)\.record\(uid, tasks\)/.test(src), 'GET /setup-progress records the tasks');++++ await outbox.stop();++ stub.close();++ console.log('setup-progress-events: ok');++ process.exit(0);++})().catch((err) => { console.error(err); process.exit(1); });
@@ -217,6 +217,7 @@ transaction as the change it describes.
@@ -217,6 +217,7 @@ transaction as the change it describes.
|---|---|---|
|---|---|---|
| `live.stream.started` / `live.stream.ended` | a `streams` row goes live / ends | `stream <id>` |
| `live.stream.started` / `live.stream.ended` | a `streams` row goes live / ends | `stream <id>` |
| `live.release.deployed` | the first boot that runs new commits ([server/events/release-events.js](../server/events/release-events.js)), queued with the `deploy_last_announced` update; payload: `service`, `release`, `commit`, `previous`, `commit_count`, `commits[]` (≤ 40), `deployed_at`, `notes_url` | `release <head sha>` |
| `live.release.deployed` | the first boot that runs new commits ([server/events/release-events.js](../server/events/release-events.js)), queued with the `deploy_last_announced` update; payload: `service`, `release`, `commit`, `previous`, `commit_count`, `commits[]` (≤ 40), `deployed_at`, `notes_url` | `release <head sha>` |
+| `live.setup.task_completed` | a go-live setup hub task reads as done for the first time ([server/streaming/setup-progress.js](../server/streaming/setup-progress.js)), queued with its `live_setup_tasks` row; payload: `task`, `user`, `title`, `group`, `completed_at`, `done`, `total`. OpenVibe.Quest turns each task into a quest | `user <usr_…>` |
The chat deploy notice is Chat's: Live queues `live.release.deployed` and OpenVibe.Chat folds and
The chat deploy notice is Chat's: Live queues `live.release.deployed` and OpenVibe.Chat folds and
stores the rolling card (C-84). Live keeps no chat table of its own.
stores the rolling card (C-84). Live keeps no chat table of its own.
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+-- phase: expand
+-- The go-live setup hub's tasks each person has finished, once per task (server/streaming/setup-progress.js): the first
+-- time a task reads as done, Live records it here and queues live.setup.task_completed in the same transaction, which
+-- OpenVibe.Quest turns into quests. A task undone later (an emote deleted) stays recorded: the quest was earned.
+CREATE TABLE IF NOT EXISTS live_setup_tasks (
+ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ task text NOT NULL,
+ completed_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (user_id, task)
+);
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.2",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.190.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
"pg": "^8.23.1",
"pg": "^8.23.1",
@@ -2644,9 +2644,9 @@
@@ -2644,9 +2644,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.188.2",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.2",
- "integrity": "sha512-E64gQI7roKudmmJFnenyh1BjViJyI0j0MoVT4o35/jUD4rpzMkElwEh/fGbsPpnstdDFIcJR6gEcScbL0ZQhbg==",
+ "version": "0.190.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.190.0",
+ "integrity": "sha512-zh71oYBT6xHuyIj4OirWbWmJ33Xe5HeBAG6Q4Sq2S0aqA6gG4LVK5+k9P/UEV0AuSkcqqlQm84PjBF8ZTY90Ow==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -42,7 +42,7 @@
@@ -42,7 +42,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.2",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.190.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
"pg": "^8.23.1",
"pg": "^8.23.1",
@@ -1516,6 +1516,9 @@ router.get('/setup-progress', requireAuth, async (req, res) => {
@@ -1516,6 +1516,9 @@ router.get('/setup-progress', requireAuth, async (req, res) => {
];
];
const done = tasks.filter(t => t.done).length;
const done = tasks.filter(t => t.done).length;
const next = tasks.find(t => !t.done) || null;
const next = tasks.find(t => !t.done) || null;
+ // A task done for the first time is recorded once, with live.setup.task_completed for OpenVibe.Quest. The hub
+ // answers even when that fails: the next visit records it.
+ await require('./setup-progress').record(uid, tasks).catch((err) => console.warn('[SetupProgress] record failed:', err.message));
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.json({ tasks, done, total: tasks.length, next, username: user.username });
res.json({ tasks, done, total: tasks.length, next, username: user.username });
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+/**
+ * The go-live setup hub's finished tasks, recorded once per person, and live.setup.task_completed for each one
+ * (openvibe-contracts 0.190.0). OpenVibe.Quest turns every task into a quest (owner Q16: Live's setup steps become
+ * quests), so finishing "Upload a custom emote" here completes the matching quest there.
+ *
+ * GET /api/streams/setup-progress works the tasks out (server/streaming/routes.js) and hands them to record(). A task
+ * that reads as done and has no live_setup_tasks row yet gets one, and its event is queued in Live's outbox in the
+ * same transaction, so the row and the event commit together. A task is recorded at most once: the primary key
+ * (user_id, task) makes a second request that raced this one insert nothing and queue nothing.
+ *
+ * Nothing is recorded for a person Live cannot name on the network (no linked subject yet): the next visit tries
+ * again, so the quest is not lost. The event says which task it was and how far along the person is, never what
+ * they made.
+ *
+ * envelope subject { type: 'user', id: usr_… } (the streamer), actor the same, visibility internal
+ * payload { task, user: { type: 'user', id }, title, group, completed_at, done, total }
+ */
+const db = require('../db/database');
+const identity = require('../auth/identity-sync');
+const streamEvents = require('../events/stream-events');
+
+const EVENT_TYPE = 'live.setup.task_completed';
+
+function toIso(v) {
+ const d = v instanceof Date ? v : new Date(v);
+ return Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString();
+}
+
+/**
+ * Record the newly finished tasks of `tasks` (the hub's list: { id, group, title, done }) for `userId`.
+ * Returns the ids it recorded now (empty when nothing was new).
+ */
+async function record(userId, tasks) {
+ const finished = (tasks || []).filter((t) => t && t.done);
+ if (!finished.length) return [];
+ const known = new Set((await db.all('SELECT task FROM live_setup_tasks WHERE user_id = ?', [userId])).map((r) => r.task));
+ const fresh = finished.filter((t) => !known.has(t.id));
+ if (!fresh.length) return [];
+ const subjectId = await identity.subjectOf(userId);
+ if (!subjectId) return [];
+ const person = { type: 'user', id: subjectId };
+ const recorded = [];
+ await db.tx(async () => {
+ for (const t of fresh) {
+ const row = await db.get('INSERT INTO live_setup_tasks (user_id, task) VALUES (?, ?) ON CONFLICT DO NOTHING RETURNING completed_at', [userId, t.id]);
+ if (!row) continue; // another request recorded it first, and queued its event
+ recorded.push(t.id);
+ await streamEvents.enqueue({
+ event_type: EVENT_TYPE,
+ actor: person,
+ subject: person,
+ visibility: 'internal',
+ priority: 'low',
+ payload: {
+ task: t.id,
+ user: person,
+ title: String(t.title || '').slice(0, 120),
+ group: String(t.group || '').slice(0, 40),
+ completed_at: toIso(row.completed_at),
+ done: finished.length,
+ total: tasks.length,
+ },
+ });
+ }
+ });
+ if (recorded.length) streamEvents.kick();
+ return recorded;
+}
+
+module.exports = { EVENT_TYPE, record };
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
+'use strict';
+
+// The go-live setup hub → OpenVibe.Quest (owner Q16): server/streaming/setup-progress.js records each task the first
+// time it reads as done (live_setup_tasks) and queues live.setup.task_completed in the same transaction; a second
+// look, or a task undone later, queues nothing; a person Live cannot name on the network gets nothing recorded yet.
+// Stub Network (token) + stub Events (publish), as test/stream-events.test.js.
+
+const assert = require('assert');
+const http = require('http');
+
+const published = [];
+const stub = http.createServer((req, res) => {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json');
+ if (req.url === '/oauth/token') return res.end(JSON.stringify({ access_token: 'tok', token_type: 'Bearer', expires_in: 300 }));
+ if (req.url === '/api/v1/events' && req.method === 'POST') {
+ const parsed = JSON.parse(body);
+ const list = parsed.events || [parsed];
+ const results = list.map((e) => { published.push(e); return { event_id: e.event_id, seq: published.length, duplicate: false }; });
+ return res.end(JSON.stringify(parsed.events ? { results } : results[0]));
+ }
+ res.statusCode = 404; res.end('{}');
+ });
+});
+
+const SUBJECT = 'usr_01J8Z3V9Q6N1X2Y3Z4A5B6C7D8';
+const task = (id, done) => ({ id, group: 'Look & feel', title: `Task ${id}`, done });
+
+(async () => {
+ await new Promise((r) => stub.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${stub.address().port}`;
+ process.env.OV_NETWORK_INTERNAL_URL = base;
+
+ const db = require('../server/db/database');
+ await db.initDb();
+ const d = db.getDb();
+ await d.prepare("INSERT INTO users (id, username, display_name) OVERRIDING SYSTEM VALUE VALUES (611, 'setupper', 'Setupper'), (612, 'unlinked', 'Unlinked')").run();
+ await d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (611, 'network', '611', ?)").run(SUBJECT);
+
+ const outbox = require('../server/events/stream-events').init({ eventsUrl: base, clientSecret: 's3cret', intervalMs: 50 });
+ assert.ok(outbox);
+ const setup = require('../server/streaming/setup-progress');
+
+ // Two of three done: both recorded, both queued with the person, the task and the progress.
+ const first = [task('slot', true), task('emote', true), task('sound', false)];
+ assert.deepStrictEqual(await setup.record(611, first), ['slot', 'emote']);
+ await outbox.flush();
+ assert.strictEqual(published.length, 2);
+ const ev = published.find((e) => e.payload.task === 'emote');
+ assert.strictEqual(ev.event_type, 'live.setup.task_completed');
+ assert.strictEqual(ev.source, 'live');
+ assert.strictEqual(ev.visibility, 'internal');
+ assert.deepStrictEqual(ev.subject, { type: 'user', id: SUBJECT });
+ assert.deepStrictEqual(ev.actor, { type: 'user', id: SUBJECT });
+ assert.deepStrictEqual(ev.payload.user, { type: 'user', id: SUBJECT });
+ assert.strictEqual(ev.payload.done, 2);
+ assert.strictEqual(ev.payload.total, 3);
+ assert.strictEqual(ev.payload.title, 'Task emote');
+ assert.match(ev.payload.completed_at, /^\d{4}-\d\d-\d\dT/);
+ require('openvibe-contracts').assertValid('live.setup.task_completed@1', ev.payload);
+
+ // The same look again: nothing new. The third task done: only it. An emote deleted later stays recorded.
+ assert.deepStrictEqual(await setup.record(611, first), []);
+ assert.deepStrictEqual(await setup.record(611, [task('slot', true), task('emote', false), task('sound', true)]), ['sound']);
+ await outbox.flush();
+ assert.deepStrictEqual(published.map((e) => e.payload.task).sort(), ['emote', 'slot', 'sound']);
+ assert.strictEqual((await d.prepare('SELECT COUNT(*) AS n FROM live_setup_tasks WHERE user_id = 611').get()).n, 3);
+
+ // Two looks at once record and queue a task once.
+ const both = await Promise.all([setup.record(611, [task('goal', true)]), setup.record(611, [task('goal', true)])]);
+ assert.deepStrictEqual(both.flat(), ['goal']);
+ await outbox.flush();
+ assert.strictEqual(published.filter((e) => e.payload.task === 'goal').length, 1);
+
+ // No network subject yet: nothing recorded, so the next visit after linking still earns the quest.
+ assert.deepStrictEqual(await setup.record(612, [task('slot', true)]), []);
+ assert.strictEqual((await d.prepare('SELECT COUNT(*) AS n FROM live_setup_tasks WHERE user_id = 612').get()).n, 0);
+
+ // The route records through it.
+ const src = require('fs').readFileSync(require('path').join(__dirname, '../server/streaming/routes.js'), 'utf8');
+ assert.ok(/require\('\.\/setup-progress'\)\.record\(uid, tasks\)/.test(src), 'GET /setup-progress records the tasks');
+
+ await outbox.stop();
+ stub.close();
+ console.log('setup-progress-events: ok');
+ process.exit(0);
+})().catch((err) => { console.error(err); process.exit(1); });