openvibers/openvibe.community Commits
Pulse shows game milestones (roadmap WS-M task 2, PF7): Community subscribes to network.module.updated, and a person's games.progress.summary write (level is a public field in Contracts) updates game_progress. A later write crossing a multiple of 5 becomes one Pulse item, 'Reached level 10 in Scraplandia' (Games · level), with the person as actor. The first write seen is only a baseline, so there is no stale announcement; a level going down (another character) announces nothing; exactly once through the inbox; only the record's public fields are read.
Changes
@@@ -482,6 +482,12 @@ per source service/type/id).`public` is refused, `X-OV-Subject` names the person for origin `user`. Re-posting a sourceupdates its title and link; origin, actor and time stay the first record's.`DELETE /api/v1/pulse/items/:service/:type/:id` retracts one.++- **From Events** (`POST /internal/events`, `server/pulse/consumer.js`):++ - go-lives (`live.stream.started`), plus public, indexable Blog posts, Wiki pages and News stories;++ - game milestones (roadmap WS-M task 2): Network's `network.module.updated` for a person's++ `games.progress.summary`, whose `level` is a public field. The first record seen for a person is a++ baseline. Each later record that crosses a multiple of 5 becomes one item, "Reached level 10 in Scraplandia"++ (Games · level), with the person as actor. The last level per person is kept in `game_progress`.- **Reading**: `GET /api/v1/pulse?origin=user|ai|system&after=<cursor>&limit=` (newest first,keyset cursor), and the `/pulse` page. AI items carry `label: 'AI'` and never an actor —they are never attributed to a person (roadmap §33); system items name no one either.@@@ -420,6 +420,12 @@ CREATE TABLE IF NOT EXISTS pulse_items (UNIQUE (source_service, source_type, source_id));CREATE INDEX IF NOT EXISTS idx_pulse_recent ON pulse_items(occurred_at DESC, id DESC);++-- The last games.progress.summary level seen per person (Pulse milestones, server/pulse/consumer.js).++CREATE TABLE IF NOT EXISTS game_progress (++ subject_id TEXT PRIMARY KEY,++ level INTEGER NOT NULL,++ updated_at TEXT NOT NULL++);CREATE INDEX IF NOT EXISTS idx_pulse_origin ON pulse_items(origin, occurred_at DESC, id DESC);CREATE TABLE IF NOT EXISTS import_hold (@@@ -9,6 +9,13 @@* wiki.page.published "Wiki: <space> / <slug>" → the page (public, indexable only)* news.story.published "News: <topic>" → the story (public, indexable only)*++ * And game progress (roadmap WS-M task 2, PF7): network.module.updated (source network) for a person's++ * games.progress.summary record, whose `level` Contracts declares public. game_progress keeps the last level++ * seen per person; a later record that crosses a multiple of GAME_MILESTONE becomes one Pulse item,++ * "Reached level <n> in Scraplandia" (Games · level), with the person as its actor. The first record++ * seen for a person is only a baseline (no stale "reached" item for a level reached long ago); levels++ * that go down or stay put announce nothing; only the public fields are read.++ ** And revocation (WS-B task 4): network.user.token_valid_after (source network) moves the person's token* cutoff (openvibe-sdk createRevocationStore); the viewer resolver refuses their older tokens.*@@@ -30,7 +37,10 @@ const store = require('./store');const blocks = require('../identity/blocks');const CONSUMER = 'community';--const TOPICS = Object.freeze(['live.stream.started', 'blog.post.published', 'wiki.page.published', 'news.story.published', 'vip.membership.changed', 'network.user.token_valid_after', 'network.block.changed']);++const TOPICS = Object.freeze(['live.stream.started', 'blog.post.published', 'wiki.page.published', 'news.story.published', 'vip.membership.changed', 'network.user.token_valid_after', 'network.block.changed', 'network.module.updated']);++const GAME_NAMESPACE = 'games.progress.summary';++const GAME_MILESTONE = 5;++const GAME_URL = 'https://openvibe.games/';const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;const clean = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);const httpsUrl = (u) => (typeof u === 'string' && /^https:\/\/[a-z0-9.-]+\.[a-z]{2,}(\/|$)/i.test(u) ? u.slice(0, 500) : null);@@@ -65,6 +75,31 @@ function itemFor(event) {return { source_service: k[0], source_type: k[1], source_id: subjectId, title, url, actor_subject: actor, origin: actor ? 'user' : 'system', occurred_at: at };}++/** network.module.updated → { subject, level, at } for a games.progress.summary write, or an 'ignored:*' reason. */++function gameProgressOf(event) {++ if (event.source !== 'network') return 'ignored:source';++ const p = event.payload && typeof event.payload === 'object' ? event.payload : {};++ if (p.namespace !== GAME_NAMESPACE) return 'ignored:namespace';++ if (p.change !== 'created' && p.change !== 'updated') return 'ignored:change';++ const subject = subjectOf(p.owner);++ const level = p.public && Number.isInteger(p.public.level) ? p.public.level : null;++ if (!subject || level == null || level < 0 || level > 100000) return 'ignored:payload';++ return { subject, level, at: event.occurred_at || event.timestamp || new Date().toISOString() };++}++++/** Record the level; a crossed milestone becomes a Pulse item. Inside the inbox claim. → outcome */++function applyGameProgress(db, g) {++ const prev = db.prepare('SELECT level FROM game_progress WHERE subject_id = ?').get(g.subject);++ db.prepare(`INSERT INTO game_progress (subject_id, level, updated_at) VALUES (?, ?, ?)++ ON CONFLICT(subject_id) DO UPDATE SET level = MAX(game_progress.level, excluded.level), updated_at = excluded.updated_at`).run(g.subject, g.level, g.at);++ if (!prev) return 'games:baseline';++ const milestone = Math.floor(g.level / GAME_MILESTONE) * GAME_MILESTONE;++ if (milestone <= prev.level || milestone < GAME_MILESTONE) return 'games:no_milestone';++ const r = store.upsertItem(db, { source_service: 'games', source_type: 'level', source_id: `${g.subject}:${milestone}`, title: `Reached level ${milestone} in Scraplandia`,++ url: GAME_URL, actor_subject: g.subject, origin: 'user', occurred_at: g.at });++ return r.created ? 'pulse:created' : 'pulse:updated';++}++function createPulseConsumer({ db, secrets = [], vipCache = null, revocations = null, now = () => Date.now(), log = console } = {}) {const keys = (secrets || []).filter((s) => typeof s === 'string' && s.length >= 32);const inbox = createInbox(db, { table: 'community_event_inbox', now });@@@ -108,6 +143,19 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =if (r.duplicate) stats.duplicates++; else stats.applied++;return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });}++ if (event.event_type === 'network.module.updated') {++ const g = gameProgressOf(event);++ if (typeof g === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: g }); }++ try {++ const r = inbox.once(CONSUMER, event.event_id, () => ({ outcome: applyGameProgress(db, g) }));++ if (r.duplicate) stats.duplicates++; else stats.applied++;++ return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });++ } catch (err) {++ stats.failed++;++ log.error(`[Pulse consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);++ return problem(500, 'community.event_failed', 'processing failed; it will be retried');++ }++ }const item = itemFor(event);if (typeof item === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: item }); }try {@@@ -158,4 +206,4 @@ function startSubscriptions({ config, port, secret, eventsUrl = process.env.EVENreturn { topics: TOPICS, endpoint, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; } };}--module.exports = { createPulseConsumer, startSubscriptions, itemFor, TOPICS };++module.exports = { createPulseConsumer, startSubscriptions, itemFor, gameProgressOf, TOPICS, GAME_MILESTONE };@@@ -60,6 +60,30 @@ const evt = (over) => ({ event_id: `evt_01JAB2C3D4E5F6G7H8J9K0M${String(++seq).passert.strictEqual(r.json.outcome, 'ignored:source');assert.deepStrictEqual(vipDrops, [v1.event_id]);assert.strictEqual(items().length, 2, 'no Pulse item for a membership');++ // Game progress (WS-M task 2): games.progress.summary writes through network.module.updated.++ const USR2 = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';++ const mod = (level, over = {}) => evt({ event_type: 'network.module.updated', source: 'network', visibility: 'internal', subject: { type: 'user', id: USR2 }, actor: { type: 'service', id: 'games' },++ payload: { owner: { type: 'user', id: USR2 }, namespace: 'games.progress.summary', namespace_owner: 'games', schema_version: 1, revision: 1, change: 'updated', reason: 'write', keys: ['level'], public: { level, achievements: 3, playtime_hours: 2 } }, ...over });++ r = await post(mod(7));++ assert.strictEqual(r.json.outcome, 'games:baseline', 'the first record seen is a baseline, not a stale announcement');++ r = await post(mod(9));++ assert.strictEqual(r.json.outcome, 'games:no_milestone');++ const m11 = mod(11);++ r = await post(m11);++ assert.strictEqual(r.json.outcome, 'pulse:created');++ assert.deepStrictEqual(items()[2], { source_service: 'games', source_type: 'level', source_id: `${USR2}:10`, title: 'Reached level 10 in Scraplandia', url: 'https://openvibe.games/', actor_subject: USR2, origin: 'user' });++ assert.strictEqual((await post(m11)).json.duplicate, true);++ r = await post(mod(6));++ assert.strictEqual(r.json.outcome, 'games:no_milestone', 'a level going down (another character) announces nothing');++ r = await post(mod(12));++ assert.strictEqual(r.json.outcome, 'games:no_milestone', 'the stored level never went down, so 10 is not crossed again');++ r = await post(mod(15));++ assert.strictEqual(items()[3].title, 'Reached level 15 in Scraplandia');++ assert.strictEqual((await post(mod(20, { payload: { owner: { type: 'user', id: USR2 }, namespace: 'ai.usage_summary', change: 'updated', public: { level: 20 } } }))).json.outcome, 'ignored:namespace');++ assert.strictEqual((await post(mod(20, { source: 'games' }))).json.outcome, 'ignored:source', 'only Network speaks for user modules');++ assert.strictEqual((await post(mod(20, { payload: { owner: { type: 'user', id: USR2 }, namespace: 'games.progress.summary', change: 'delete' } }))).json.outcome, 'ignored:change');++ assert.strictEqual((await post(mod(20, { payload: { owner: { type: 'user', id: USR2 }, namespace: 'games.progress.summary', change: 'updated', keys: ['level'] } }))).json.outcome, 'ignored:payload', 'no public level, nothing read');++ assert.strictEqual(items().length, 4);srv.close();console.log('pulse consumer: all checks passed');process.exit(0);
@@ -482,6 +482,12 @@ per source service/type/id).
@@ -482,6 +482,12 @@ per source service/type/id).
`public` is refused, `X-OV-Subject` names the person for origin `user`. Re-posting a source
`public` is refused, `X-OV-Subject` names the person for origin `user`. Re-posting a source
updates its title and link; origin, actor and time stay the first record's.
updates its title and link; origin, actor and time stay the first record's.
`DELETE /api/v1/pulse/items/:service/:type/:id` retracts one.
`DELETE /api/v1/pulse/items/:service/:type/:id` retracts one.
+- **From Events** (`POST /internal/events`, `server/pulse/consumer.js`):
+ - go-lives (`live.stream.started`), plus public, indexable Blog posts, Wiki pages and News stories;
+ - game milestones (roadmap WS-M task 2): Network's `network.module.updated` for a person's
+ `games.progress.summary`, whose `level` is a public field. The first record seen for a person is a
+ baseline. Each later record that crosses a multiple of 5 becomes one item, "Reached level 10 in Scraplandia"
+ (Games · level), with the person as actor. The last level per person is kept in `game_progress`.
- **Reading**: `GET /api/v1/pulse?origin=user|ai|system&after=<cursor>&limit=` (newest first,
- **Reading**: `GET /api/v1/pulse?origin=user|ai|system&after=<cursor>&limit=` (newest first,
keyset cursor), and the `/pulse` page. AI items carry `label: 'AI'` and never an actor —
keyset cursor), and the `/pulse` page. AI items carry `label: 'AI'` and never an actor —
they are never attributed to a person (roadmap §33); system items name no one either.
they are never attributed to a person (roadmap §33); system items name no one either.
@@ -420,6 +420,12 @@ CREATE TABLE IF NOT EXISTS pulse_items (
@@ -420,6 +420,12 @@ CREATE TABLE IF NOT EXISTS pulse_items (
UNIQUE (source_service, source_type, source_id)
UNIQUE (source_service, source_type, source_id)
);
);
CREATE INDEX IF NOT EXISTS idx_pulse_recent ON pulse_items(occurred_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_pulse_recent ON pulse_items(occurred_at DESC, id DESC);
+-- The last games.progress.summary level seen per person (Pulse milestones, server/pulse/consumer.js).
+CREATE TABLE IF NOT EXISTS game_progress (
+ subject_id TEXT PRIMARY KEY,
+ level INTEGER NOT NULL,
+ updated_at TEXT NOT NULL
+);
CREATE INDEX IF NOT EXISTS idx_pulse_origin ON pulse_items(origin, occurred_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_pulse_origin ON pulse_items(origin, occurred_at DESC, id DESC);
CREATE TABLE IF NOT EXISTS import_hold (
CREATE TABLE IF NOT EXISTS import_hold (
@@ -9,6 +9,13 @@
@@ -9,6 +9,13 @@
* wiki.page.published "Wiki: <space> / <slug>" → the page (public, indexable only)
* wiki.page.published "Wiki: <space> / <slug>" → the page (public, indexable only)
* news.story.published "News: <topic>" → the story (public, indexable only)
* news.story.published "News: <topic>" → the story (public, indexable only)
*
*
+ * And game progress (roadmap WS-M task 2, PF7): network.module.updated (source network) for a person's
+ * games.progress.summary record, whose `level` Contracts declares public. game_progress keeps the last level
+ * seen per person; a later record that crosses a multiple of GAME_MILESTONE becomes one Pulse item,
+ * "Reached level <n> in Scraplandia" (Games · level), with the person as its actor. The first record
+ * seen for a person is only a baseline (no stale "reached" item for a level reached long ago); levels
+ * that go down or stay put announce nothing; only the public fields are read.
+ *
* And revocation (WS-B task 4): network.user.token_valid_after (source network) moves the person's token
* And revocation (WS-B task 4): network.user.token_valid_after (source network) moves the person's token
* cutoff (openvibe-sdk createRevocationStore); the viewer resolver refuses their older tokens.
* cutoff (openvibe-sdk createRevocationStore); the viewer resolver refuses their older tokens.
*
*
@@ -30,7 +37,10 @@ const store = require('./store');
@@ -30,7 +37,10 @@ const store = require('./store');
const blocks = require('../identity/blocks');
const blocks = require('../identity/blocks');
const CONSUMER = 'community';
const CONSUMER = 'community';
-const TOPICS = Object.freeze(['live.stream.started', 'blog.post.published', 'wiki.page.published', 'news.story.published', 'vip.membership.changed', 'network.user.token_valid_after', 'network.block.changed']);
+const TOPICS = Object.freeze(['live.stream.started', 'blog.post.published', 'wiki.page.published', 'news.story.published', 'vip.membership.changed', 'network.user.token_valid_after', 'network.block.changed', 'network.module.updated']);
+const GAME_NAMESPACE = 'games.progress.summary';
+const GAME_MILESTONE = 5;
+const GAME_URL = 'https://openvibe.games/';
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
const clean = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
const clean = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
const httpsUrl = (u) => (typeof u === 'string' && /^https:\/\/[a-z0-9.-]+\.[a-z]{2,}(\/|$)/i.test(u) ? u.slice(0, 500) : null);
const httpsUrl = (u) => (typeof u === 'string' && /^https:\/\/[a-z0-9.-]+\.[a-z]{2,}(\/|$)/i.test(u) ? u.slice(0, 500) : null);
@@ -65,6 +75,31 @@ function itemFor(event) {
@@ -65,6 +75,31 @@ function itemFor(event) {
return { source_service: k[0], source_type: k[1], source_id: subjectId, title, url, actor_subject: actor, origin: actor ? 'user' : 'system', occurred_at: at };
return { source_service: k[0], source_type: k[1], source_id: subjectId, title, url, actor_subject: actor, origin: actor ? 'user' : 'system', occurred_at: at };
}
}
+/** network.module.updated → { subject, level, at } for a games.progress.summary write, or an 'ignored:*' reason. */
+function gameProgressOf(event) {
+ if (event.source !== 'network') return 'ignored:source';
+ const p = event.payload && typeof event.payload === 'object' ? event.payload : {};
+ if (p.namespace !== GAME_NAMESPACE) return 'ignored:namespace';
+ if (p.change !== 'created' && p.change !== 'updated') return 'ignored:change';
+ const subject = subjectOf(p.owner);
+ const level = p.public && Number.isInteger(p.public.level) ? p.public.level : null;
+ if (!subject || level == null || level < 0 || level > 100000) return 'ignored:payload';
+ return { subject, level, at: event.occurred_at || event.timestamp || new Date().toISOString() };
+}
+
+/** Record the level; a crossed milestone becomes a Pulse item. Inside the inbox claim. → outcome */
+function applyGameProgress(db, g) {
+ const prev = db.prepare('SELECT level FROM game_progress WHERE subject_id = ?').get(g.subject);
+ db.prepare(`INSERT INTO game_progress (subject_id, level, updated_at) VALUES (?, ?, ?)
+ ON CONFLICT(subject_id) DO UPDATE SET level = MAX(game_progress.level, excluded.level), updated_at = excluded.updated_at`).run(g.subject, g.level, g.at);
+ if (!prev) return 'games:baseline';
+ const milestone = Math.floor(g.level / GAME_MILESTONE) * GAME_MILESTONE;
+ if (milestone <= prev.level || milestone < GAME_MILESTONE) return 'games:no_milestone';
+ const r = store.upsertItem(db, { source_service: 'games', source_type: 'level', source_id: `${g.subject}:${milestone}`, title: `Reached level ${milestone} in Scraplandia`,
+ url: GAME_URL, actor_subject: g.subject, origin: 'user', occurred_at: g.at });
+ return r.created ? 'pulse:created' : 'pulse:updated';
+}
+
function createPulseConsumer({ db, secrets = [], vipCache = null, revocations = null, now = () => Date.now(), log = console } = {}) {
function createPulseConsumer({ db, secrets = [], vipCache = null, revocations = null, now = () => Date.now(), log = console } = {}) {
const keys = (secrets || []).filter((s) => typeof s === 'string' && s.length >= 32);
const keys = (secrets || []).filter((s) => typeof s === 'string' && s.length >= 32);
const inbox = createInbox(db, { table: 'community_event_inbox', now });
const inbox = createInbox(db, { table: 'community_event_inbox', now });
@@ -108,6 +143,19 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
@@ -108,6 +143,19 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
if (r.duplicate) stats.duplicates++; else stats.applied++;
if (r.duplicate) stats.duplicates++; else stats.applied++;
return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
}
}
+ if (event.event_type === 'network.module.updated') {
+ const g = gameProgressOf(event);
+ if (typeof g === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: g }); }
+ try {
+ const r = inbox.once(CONSUMER, event.event_id, () => ({ outcome: applyGameProgress(db, g) }));
+ if (r.duplicate) stats.duplicates++; else stats.applied++;
+ return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
+ } catch (err) {
+ stats.failed++;
+ log.error(`[Pulse consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);
+ return problem(500, 'community.event_failed', 'processing failed; it will be retried');
+ }
+ }
const item = itemFor(event);
const item = itemFor(event);
if (typeof item === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: item }); }
if (typeof item === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: item }); }
try {
try {
@@ -158,4 +206,4 @@ function startSubscriptions({ config, port, secret, eventsUrl = process.env.EVEN
@@ -158,4 +206,4 @@ function startSubscriptions({ config, port, secret, eventsUrl = process.env.EVEN
return { topics: TOPICS, endpoint, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; } };
return { topics: TOPICS, endpoint, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; } };
}
}
-module.exports = { createPulseConsumer, startSubscriptions, itemFor, TOPICS };
+module.exports = { createPulseConsumer, startSubscriptions, itemFor, gameProgressOf, TOPICS, GAME_MILESTONE };
@@ -60,6 +60,30 @@ const evt = (over) => ({ event_id: `evt_01JAB2C3D4E5F6G7H8J9K0M${String(++seq).p
@@ -60,6 +60,30 @@ const evt = (over) => ({ event_id: `evt_01JAB2C3D4E5F6G7H8J9K0M${String(++seq).p
assert.strictEqual(r.json.outcome, 'ignored:source');
assert.strictEqual(r.json.outcome, 'ignored:source');
assert.deepStrictEqual(vipDrops, [v1.event_id]);
assert.deepStrictEqual(vipDrops, [v1.event_id]);
assert.strictEqual(items().length, 2, 'no Pulse item for a membership');
assert.strictEqual(items().length, 2, 'no Pulse item for a membership');
+ // Game progress (WS-M task 2): games.progress.summary writes through network.module.updated.
+ const USR2 = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
+ const mod = (level, over = {}) => evt({ event_type: 'network.module.updated', source: 'network', visibility: 'internal', subject: { type: 'user', id: USR2 }, actor: { type: 'service', id: 'games' },
+ payload: { owner: { type: 'user', id: USR2 }, namespace: 'games.progress.summary', namespace_owner: 'games', schema_version: 1, revision: 1, change: 'updated', reason: 'write', keys: ['level'], public: { level, achievements: 3, playtime_hours: 2 } }, ...over });
+ r = await post(mod(7));
+ assert.strictEqual(r.json.outcome, 'games:baseline', 'the first record seen is a baseline, not a stale announcement');
+ r = await post(mod(9));
+ assert.strictEqual(r.json.outcome, 'games:no_milestone');
+ const m11 = mod(11);
+ r = await post(m11);
+ assert.strictEqual(r.json.outcome, 'pulse:created');
+ assert.deepStrictEqual(items()[2], { source_service: 'games', source_type: 'level', source_id: `${USR2}:10`, title: 'Reached level 10 in Scraplandia', url: 'https://openvibe.games/', actor_subject: USR2, origin: 'user' });
+ assert.strictEqual((await post(m11)).json.duplicate, true);
+ r = await post(mod(6));
+ assert.strictEqual(r.json.outcome, 'games:no_milestone', 'a level going down (another character) announces nothing');
+ r = await post(mod(12));
+ assert.strictEqual(r.json.outcome, 'games:no_milestone', 'the stored level never went down, so 10 is not crossed again');
+ r = await post(mod(15));
+ assert.strictEqual(items()[3].title, 'Reached level 15 in Scraplandia');
+ assert.strictEqual((await post(mod(20, { payload: { owner: { type: 'user', id: USR2 }, namespace: 'ai.usage_summary', change: 'updated', public: { level: 20 } } }))).json.outcome, 'ignored:namespace');
+ assert.strictEqual((await post(mod(20, { source: 'games' }))).json.outcome, 'ignored:source', 'only Network speaks for user modules');
+ assert.strictEqual((await post(mod(20, { payload: { owner: { type: 'user', id: USR2 }, namespace: 'games.progress.summary', change: 'delete' } }))).json.outcome, 'ignored:change');
+ assert.strictEqual((await post(mod(20, { payload: { owner: { type: 'user', id: USR2 }, namespace: 'games.progress.summary', change: 'updated', keys: ['level'] } }))).json.outcome, 'ignored:payload', 'no public level, nothing read');
+ assert.strictEqual(items().length, 4);
srv.close();
srv.close();
console.log('pulse consumer: all checks passed');
console.log('pulse consumer: all checks passed');
process.exit(0);
process.exit(0);