openvibers/openvibe.live Commits

Loyalty summary (WS-K task 9): Live writes its private live.loyalty user module on Network: channel points across channels and the top ten by points, the Arena Trash Level and XP (loyalty, never money; contracts 0.56.0). Viewers with points get it without streaming; points change while people watch, so at most one write per half hour except the daily refresh; the scan picks up changed points and Arena XP; a record whose points are gone is zeroed, not left stale. Pins: openvibe-contracts 0.52.0 -> 0.56.0

OP OpenVibers committed 26 Sept 2026 · bf068778f5 · 4 files, +77 −16


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 82fec607..3ba6e8d6 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -25,7 +25,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.52.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.56.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.14.0",
"sanitize-html": "^2.17.7",
@@@ -2814,9 +2814,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.52.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.52.0",
-- "integrity": "sha512-EVkhyQwoA291JmH9K1Yt9lddL1SJgjJxXB2ej7mkCjGaHnn7GHuSyXddwmd8SS6WWxvZvrV8w+glZaGTRwz/Pg==",
++ "version": "0.56.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.56.0",
++ "integrity": "sha512-g8i+4dKCwaUyfBDsxYm+WodSn2UFtGgV0QZvj6hAqHCKK8+W1scyZ1/YyrFJTS4OWaMkaOXepkUFuUmBYVdD6A==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 0c2bb62d..6e2593bd 100644
---- a/package.json
++++ b/package.json
@@@ -43,7 +43,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.52.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.56.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.14.0",
"sanitize-html": "^2.17.7",
ddiff --git a/server/auth/module-summaries.js b/server/auth/module-summaries.js
iindex fa8db8c0..dd339446 100644
---- a/server/auth/module-summaries.js
++++ b/server/auth/module-summaries.js
@@@ -5,6 +5,8 @@
* live.profile channel_url, followers, is_streamer, last_live_at (public), stream_minutes_30d
* live.stats streams_30d, stream_minutes_30d, peak_viewers_30d (public), avg_viewers_30d,
* new_followers_30d, computed_at
++ * live.loyalty channel_points_total, channels (top ten by points), arena_level, arena_xp,
++ * computed_at; private (contracts 0.56.0, WS-K task 9). Loyalty, never money.
*
* Summaries only: streams and follows stay Live's truth. Written with Live's service token (grants
* network.modules.read/write on live.profile and live.stats), unconditionally, as the owner.
@@@ -78,6 +80,27 @@ function summarize(userId, { now = Date.now() } = {}) {
};
}
++const LOYALTY_MIN_INTERVAL_MS = 30 * 60 * 1000; // points move while people watch: at most one write per half hour
++
++/** live.loyalty for one account: channel points (top ten channels) and the Arena level, or null when
++ * there is nothing to say. Once a record was written, an account whose points are gone gets zeros. */
++function loyaltyOf(userId) {
++ const d = db.getDb();
++ const channels = d.prepare(`SELECT u.username AS channel, cp.balance AS points FROM channel_points cp JOIN users u ON u.id = cp.streamer_id
++ WHERE cp.user_id = ? AND cp.balance > 0 ORDER BY cp.balance DESC, u.username LIMIT 10`).all(userId)
++ .map((r) => ({ channel: String(r.channel).slice(0, 64), points: r.points }));
++ const total = d.prepare('SELECT COALESCE(SUM(balance), 0) AS n FROM channel_points WHERE user_id = ? AND balance > 0').get(userId).n;
++ let arena = null;
++ try { arena = d.prepare('SELECT xp, level FROM arena_trash_levels WHERE user_id = ?').get(userId) || null; } catch { /* the arena tables do not exist yet */ }
++ if (!total && !arena) {
++ const had = d.prepare("SELECT 1 FROM module_summary_pushes WHERE user_id = ? AND namespace = 'live.loyalty'").get(userId);
++ return had ? { channel_points_total: 0, channels: [] } : null;
++ }
++ const out = { channel_points_total: total, channels };
++ if (arena) { out.arena_level = Math.max(1, Number(arena.level) || 1); out.arena_xp = Math.max(0, Number(arena.xp) || 0); }
++ return out;
++}
++
function client() {
if (modulesClient) return modulesClient;
const clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '';
@@@ -94,33 +117,41 @@ function client() {
const hashOf = (data) => crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex').slice(0, 32);
/** Write one person's records where they changed. Returns the namespaces written. */
--async function push(userId, { modules = client(), now = Date.now() } = {}) {
++async function push(userId, { modules = client(), now = Date.now(), force = false } = {}) {
if (!modules) return [];
const subject = identity.subjectOf(userId);
if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject || '')) return [];
const sum = summarize(userId, { now });
-- if (!sum) return [];
++ const loyalty = loyaltyOf(userId);
++ const pushes = [];
++ if (sum) pushes.push(['live.profile', sum.profile], ['live.stats', sum.stats]);
++ if (loyalty) pushes.push(['live.loyalty', loyalty]);
const written = [];
-- const pushes = [['live.profile', sum.profile], ['live.stats', sum.stats]];
for (const [ns, data] of pushes) {
const hash = hashOf(data);
-- const last = db.getDb().prepare('SELECT hash FROM module_summary_pushes WHERE user_id = ? AND namespace = ?').get(userId, ns);
++ const last = db.getDb().prepare("SELECT hash, strftime('%s', pushed_at) * 1000 AS at FROM module_summary_pushes WHERE user_id = ? AND namespace = ?").get(userId, ns);
if (last && last.hash === hash) { stats.unchanged++; continue; }
-- const body = ns === 'live.stats' ? { ...data, computed_at: new Date(now).toISOString() } : data;
++ if (ns === 'live.loyalty' && last && !force && now - Number(last.at) < LOYALTY_MIN_INTERVAL_MS) { stats.unchanged++; continue; }
++ const body = ns === 'live.profile' ? data : { ...data, computed_at: new Date(now).toISOString() };
try {
await modules.put(ns, subject, body);
} catch (err) {
stats.failed++; stats.lastError = `${ns}: ${err.message}`;
continue;
}
-- db.getDb().prepare(`INSERT INTO module_summary_pushes (user_id, namespace, hash, pushed_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)
-- ON CONFLICT(user_id, namespace) DO UPDATE SET hash = excluded.hash, pushed_at = excluded.pushed_at`).run(userId, ns, hash);
++ // pushed_at on the same clock the half-hour loyalty throttle reads (Date.now() in production).
++ db.getDb().prepare(`INSERT INTO module_summary_pushes (user_id, namespace, hash, pushed_at) VALUES (?, ?, ?, ?)
++ ON CONFLICT(user_id, namespace) DO UPDATE SET hash = excluded.hash, pushed_at = excluded.pushed_at`).run(userId, ns, hash, new Date(now).toISOString().replace('T', ' ').slice(0, 19));
stats.written++;
written.push(ns);
}
return written;
}
++const tableExists = (name) => Boolean(db.getDb().prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
++const arenaLog = () => tableExists('arena_xp_log');
++const arenaLevels = () => tableExists('arena_trash_levels');
++
let lastScan = null;
/** People whose stream ended, or who gained a follower, since the previous scan. */
async function scan({ modules = client(), now = Date.now() } = {}) {
@@@ -129,7 +160,9 @@ async function scan({ modules = client(), now = Date.now() } = {}) {
const to = new Date(now).toISOString().replace('T', ' ').slice(0, 19);
const ids = db.getDb().prepare(`SELECT user_id AS id FROM streams WHERE ended_at >= ? AND ended_at < ?
UNION SELECT streamer_id AS id FROM follows WHERE created_at >= ? AND created_at < ?
-- UNION SELECT user_id AS id FROM streams WHERE started_at >= ? AND started_at < ?`).all(from, to, from, to, from, to).map((r) => r.id);
++ UNION SELECT user_id AS id FROM streams WHERE started_at >= ? AND started_at < ?
++ UNION SELECT user_id AS id FROM channel_points WHERE updated_at >= ? AND updated_at < ?${arenaLog() ? `
++ UNION SELECT user_id AS id FROM arena_xp_log WHERE created_at >= ? AND created_at < ?` : ''}`).all(...Array(arenaLog() ? 5 : 4).fill([from, to]).flat()).map((r) => r.id);
for (const id of ids) await push(id, { modules, now });
lastScan = to;
stats.lastScanAt = new Date(now).toISOString();
@@@ -141,8 +174,11 @@ async function refresh({ modules = client(), now = Date.now() } = {}) {
if (!modules) return 0;
const since = new Date(now - 31 * DAY_MS).toISOString().replace('T', ' ').slice(0, 19);
const ids = db.getDb().prepare(`SELECT DISTINCT user_id AS id FROM streams WHERE started_at >= ?
-- UNION SELECT DISTINCT streamer_id AS id FROM follows`).all(since).map((r) => r.id);
-- for (const id of ids) await push(id, { modules, now });
++ UNION SELECT DISTINCT streamer_id AS id FROM follows
++ UNION SELECT DISTINCT user_id AS id FROM channel_points WHERE balance > 0
++ UNION SELECT user_id AS id FROM module_summary_pushes WHERE namespace = 'live.loyalty'${arenaLevels() ? `
++ UNION SELECT user_id AS id FROM arena_trash_levels` : ''}`).all(since).map((r) => r.id);
++ for (const id of ids) await push(id, { modules, now, force: true });
return ids.length;
}
@@@ -158,4 +194,4 @@ function init() {
function status() { return { ...stats }; }
function _reset() { modulesClient = null; lastScan = null; Object.assign(stats, { written: 0, unchanged: 0, failed: 0, lastError: null, lastScanAt: null }); }
--module.exports = { init, ensureSchema, summarize, push, scan, refresh, status, _reset };
++module.exports = { init, ensureSchema, summarize, loyaltyOf, push, scan, refresh, status, _reset };
ddiff --git a/test/module-summaries.test.js b/test/module-summaries.test.js
iindex 32d6bd26..106f4c03 100644
---- a/test/module-summaries.test.js
++++ b/test/module-summaries.test.js
@@@ -84,6 +84,31 @@ d.prepare('INSERT INTO follows (follower_id, streamer_id, created_at) VALUES (2,
assert.strictEqual(await live.scan({ now: now + 5 * 60000 }), 0, 'the next scan starts where this one ended');
assert.strictEqual(await live.refresh({ now: now + DAY }), 1, 'the daily refresh visits everyone with recent streams or followers');
++ // ── live.loyalty (contracts 0.56.0, WS-K task 9): channel points and the Arena level, private ──
++ d.exec('CREATE TABLE IF NOT EXISTS arena_trash_levels (user_id INTEGER PRIMARY KEY, xp INTEGER DEFAULT 0, level INTEGER DEFAULT 1)');
++ d.prepare("INSERT INTO channel_points (user_id, streamer_id, balance, updated_at) VALUES (3, 1, 300, ?), (3, 2, 50, ?), (3, 3, 0, ?)").run(at(DAY), at(DAY), at(DAY));
++ d.prepare('INSERT INTO arena_trash_levels (user_id, xp, level) VALUES (3, 450, 3)').run();
++ assert.deepStrictEqual(live.loyaltyOf(3), { channel_points_total: 350, channels: [{ channel: 'ann', points: 300 }, { channel: 'bob', points: 50 }], arena_level: 3, arena_xp: 450 });
++ assert.strictEqual(live.loyaltyOf(2), null, 'no points and no Arena: nothing');
++ const before = puts.length;
++ assert.deepStrictEqual(await live.push(3, { now }), ['live.loyalty'], 'a viewer with points gets only the loyalty record');
++ const loyal = puts[before];
++ assert.strictEqual(loyal.ns, 'live.loyalty'); assert.strictEqual(loyal.subject, 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC');
++ assert.ok(modules.validateData('live.loyalty', loyal.data).valid, 'live.loyalty matches its schema');
++ assert.deepStrictEqual(modules.publicView('live.loyalty', loyal.data), {}, 'nothing of it is public');
++ assert.ok(!Object.keys(loyal.data).some((k) => /usd|cents|cash|payout|withdraw|vibes/i.test(k)), 'loyalty is never money');
++ // Points move while people watch: at most one write per half hour, except the daily refresh.
++ d.prepare('UPDATE channel_points SET balance = 310, updated_at = ? WHERE user_id = 3 AND streamer_id = 1').run(at(0));
++ assert.deepStrictEqual(await live.push(3, { now: now + 60000 }), [], 'a change within 30 minutes waits');
++ assert.deepStrictEqual(await live.push(3, { now: now + 60000, force: true }), ['live.loyalty'], 'the refresh writes it');
++ assert.strictEqual(puts[puts.length - 1].data.channel_points_total, 360);
++ // The scan picks up changed points; points gone after a write leave zeros, not a stale record.
++ d.prepare('UPDATE channel_points SET balance = 0, updated_at = ? WHERE user_id = 3').run(at(-10 * 60000)); // after the previous scan's window
++ d.prepare('DELETE FROM arena_trash_levels WHERE user_id = 3').run();
++ assert.deepStrictEqual(live.loyaltyOf(3), { channel_points_total: 0, channels: [] });
++ assert.ok((await live.scan({ now: now + 45 * 60000 })) >= 1, 'the scan finds the changed points');
++ assert.strictEqual(puts[puts.length - 1].data.channel_points_total, 0);
++
// ── Never under LIVE_DRILL: index.js starts it inside the non-drill boot, like the other jobs ──
const idx = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');
assert.ok(/require\('\.\/auth\/module-summaries'\)\.init\(\)/.test(idx), 'index.js starts it');