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.

OP OpenVibers committed 26 Sept 2026 · 90edad62e3 · 4 files, +86 −2


Changes

ddiff --git a/README.md b/README.md
iindex 63a1c69..363d954 100644
---- a/README.md
++++ b/README.md
@@@ -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
updates 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.
ddiff --git a/server/db.js b/server/db.js
iindex a836ecb..315a153 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -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 (
ddiff --git a/server/pulse/consumer.js b/server/pulse/consumer.js
iindex bb5a662..e75d0c0 100644
---- a/server/pulse/consumer.js
++++ b/server/pulse/consumer.js
@@@ -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.EVEN
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 };
ddiff --git a/test/pulse-consumer.test.js b/test/pulse-consumer.test.js
iindex ca153c4..8f8b9aa 100644
---- a/test/pulse-consumer.test.js
++++ b/test/pulse-consumer.test.js
@@@ -60,6 +60,30 @@ const evt = (over) => ({ event_id: `evt_01JAB2C3D4E5F6G7H8J9K0M${String(++seq).p
assert.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);