openvibers/openvibe.live Commits

Channel pages show the owner's game progress (roadmap WS-M task 2, PF7): GET /api/streams/channel/:username/game reads the public fields of their games.progress.summary user module (level, achievements, playtime) from Network's public module endpoint. That needs no token or grant; it is what Network shows anyone. Answers and misses are cached 10 minutes; Network trouble is not cached; 204 when there is none. A small 'Scraplandia · Lv 12 · …' badge next to the followers, live and offline, built with DOM nodes. test/game-summary.test.js

OP OpenVibers committed 26 Sept 2026 · f9599c631b · 6 files, +163 −0


Changes

ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex 890bef0c..75af0ae5 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -1384,3 +1384,8 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
/* An AI-inferred category says so ("Gaming · inferred", roadmap 33.4). */
.ch-category-badge .cat-inferred { font-weight: 500; opacity: .78; font-size: .9em; }
++
++/* The owner's public game progress (app-channel.js _loadGameBadge). */
++.ch-game-badge { display: inline-flex; align-items: center; gap: 5px; font-size: 0.75rem; color: var(--text-muted); text-decoration: none; padding: 2px 8px; border: 1px solid var(--border); border-radius: 999px; white-space: nowrap; }
++.ch-game-badge:hover { color: var(--text-primary); border-color: var(--accent); }
++.ch-game-badge[hidden] { display: none; }
ddiff --git a/public/fragments/channel.html b/public/fragments/channel.html
iindex 26acf116..877fa196 100644
---- a/public/fragments/channel.html
++++ b/public/fragments/channel.html
@@@ -127,6 +127,7 @@
</div>
<div class="ch-info-bar-right">
<span class="ch-follower-count" id="ch-follower-count">0 followers</span>
++ <a class="ch-game-badge" id="ch-game-badge" href="https://openvibe.games/" target="_blank" rel="noopener" hidden></a>
<button class="btn btn-follow" id="ch-btn-follow">
<i class="fa-solid fa-heart"></i> Follow
</button>
@@@ -246,6 +247,7 @@
<p class="ch-description" id="ch-description-offline"></p>
<div class="ch-meta">
<span class="ch-follower-count" id="ch-follower-count-offline">0 followers</span>
++ <a class="ch-game-badge" id="ch-game-badge-offline" href="https://openvibe.games/" target="_blank" rel="noopener" hidden></a>
<span class="ch-category-badge badge" id="ch-category-badge-offline">Offline</span>
</div>
<div class="ch-weather-widget" id="ch-weather-widget-offline" style="display:none"></div>
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 0b97cd7b..cf73a615 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -695,6 +695,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
_setCategoryBadge(document.getElementById('ch-category-badge'), _liveCat || ch.ai_category || ch.category || 'Live',
_liveCat ? _isInferredCategory(_ls0) : !!ch.ai_category); }
document.getElementById('ch-follower-count').textContent = `${ch.follower_count || 0} followers`;
++ _loadGameBadge(ch.username);
setupFollowBtn(document.getElementById('ch-btn-follow'));
setupBanBtn(document.getElementById('ch-btn-ban'));
@@@ -754,6 +755,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
document.getElementById('ch-username-offline').textContent = '@' + ch.username;
document.getElementById('ch-description-offline').textContent = ch.description || '';
document.getElementById('ch-follower-count-offline').textContent = `${ch.follower_count || 0} followers`;
++ _loadGameBadge(ch.username);
_setCategoryBadge(document.getElementById('ch-category-badge-offline'), ch.ai_category || ch.category || 'Offline', !!ch.ai_category);
setupFollowBtn(document.getElementById('ch-btn-follow-offline'));
setupBanBtn(document.getElementById('ch-btn-ban-offline'));
@@@ -1679,6 +1681,28 @@ function switchChannelTab(tab, btn) {
}
}
++// The owner's public game progress (roadmap WS-M task 2): GET /api/streams/channel/:username/game answers
++// { game, url, level, achievements?, playtime_hours? } or 204. Shown as a small badge next to the followers.
++async function _loadGameBadge(username) {
++ const els = [document.getElementById('ch-game-badge'), document.getElementById('ch-game-badge-offline')].filter(Boolean);
++ els.forEach((el) => { el.hidden = true; el.textContent = ''; });
++ let g = null;
++ try { g = await api(`/streams/channel/${encodeURIComponent(username)}/game`); } catch { return; }
++ if (!g || typeof g.level !== 'number' || username !== currentChannelUsername) return;
++ const bits = [`Lv ${g.level}`];
++ if (g.achievements) bits.push(`${g.achievements} achievement${g.achievements === 1 ? '' : 's'}`);
++ if (g.playtime_hours) bits.push(`${g.playtime_hours} h`);
++ for (const el of els) {
++ const icon = document.createElement('i');
++ icon.className = 'fa-solid fa-gamepad';
++ icon.setAttribute('aria-hidden', 'true');
++ el.append(icon, ` ${g.game} · ${bits.join(' · ')}`);
++ el.title = `${g.game} on OpenVibe.Games`;
++ el.href = g.url || 'https://openvibe.games/';
++ el.hidden = false;
++ }
++}
++
// A URL hash like #ai-timeline / #about / #videos auto-opens that channel tab on load and
// scrolls the tab content into view (used by "view full AI timeline" links, deep links, etc.).
const CHANNEL_TAB_HASHES = ['about', 'videos', 'clips', 'clips-taken', 'pastes', 'media', 'analytics', 'ai-timeline'];
ddiff --git a/server/auth/game-summary.js b/server/auth/game-summary.js
nnew file mode 100644
iindex 00000000..3b78fe46
---- /dev/null
++++ b/server/auth/game-summary.js
@@@ -0,0 +1,52 @@
++'use strict';
++/**
++ * A channel's game summary (roadmap WS-M task 2, PF7): the public fields of the owner's
++ * games.progress.summary user module (level, achievements, playtime_hours; Contracts declares them
++ * public), read from Network's public module endpoint GET /api/modules/:ns/public/:subject. No token
++ * and no grant: it is what Network shows anyone. Games writes the record when a character leaves.
++ *
++ * forUser(userId) → { game, url, level, achievements?, playtime_hours? } | null
++ *
++ * Null when the account has no Network subject, has no record, or Network does not answer in 3 s.
++ * Answers (misses too) are cached 10 minutes per subject, at most 2000 entries.
++ */
++const identity = require('./identity-sync');
++
++const NETWORK_INTERNAL_URL = (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');
++const NAMESPACE = 'games.progress.summary';
++const TTL_MS = 10 * 60 * 1000;
++const MAX = 2000;
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const cache = new Map();
++
++const nonNegInt = (v) => (Number.isInteger(v) && v >= 0 ? v : null);
++
++function shape(data) {
++ const level = nonNegInt(data && data.level);
++ if (level == null) return null;
++ const out = { game: 'Scraplandia', url: 'https://openvibe.games/', level };
++ const ach = nonNegInt(data.achievements);
++ if (ach != null) out.achievements = ach;
++ if (typeof data.playtime_hours === 'number' && data.playtime_hours >= 0 && Number.isFinite(data.playtime_hours)) out.playtime_hours = Math.round(data.playtime_hours * 10) / 10;
++ return out;
++}
++
++async function forUser(userId, { fetchImpl = globalThis.fetch, now = Date.now() } = {}) {
++ const subject = identity.subjectOf(userId);
++ if (!SUBJECT_RE.test(subject || '')) return null;
++ const hit = cache.get(subject);
++ if (hit && now - hit.at < TTL_MS) return hit.value;
++ let value = null;
++ try {
++ const r = await fetchImpl(`${NETWORK_INTERNAL_URL}/api/modules/${encodeURIComponent(NAMESPACE)}/public/${subject}`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(3000) });
++ if (r.status === 200) value = shape((await r.json()).data);
++ else if (r.status !== 404) return null; // not cached: Network trouble is not an answer
++ } catch { return null; }
++ if (cache.size >= MAX) cache.delete(cache.keys().next().value);
++ cache.set(subject, { at: now, value });
++ return value;
++}
++
++function _reset() { cache.clear(); }
++
++module.exports = { forUser, shape, NAMESPACE, _reset };
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex bcb63db8..f9886527 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -1015,6 +1015,17 @@ async function fetchWeather(zip) {
}
}
++// ── Game summary (roadmap WS-M task 2, PF7) ──────────────────
++// The channel owner's public games.progress.summary (level, achievements, playtime) from Network; 204 when none.
++router.get('/channel/:username/game', async (req, res) => {
++ const channel = db.getChannelByUsername(req.params.username);
++ if (!channel) return res.status(404).json({ error: 'Channel not found' });
++ const summary = await require('../auth/game-summary').forUser(channel.user_id);
++ res.set('Cache-Control', 'public, max-age=300');
++ if (!summary) return res.status(204).end();
++ res.json(summary);
++});
++
router.get('/channel/:username/weather', async (req, res) => {
try {
const channel = db.getChannelByUsername(req.params.username);
ddiff --git a/test/game-summary.test.js b/test/game-summary.test.js
nnew file mode 100644
iindex 00000000..7e35038a
---- /dev/null
++++ b/test/game-summary.test.js
@@@ -0,0 +1,69 @@
++'use strict';
++// A channel's game summary (server/auth/game-summary.js, roadmap WS-M task 2): the owner's public
++// games.progress.summary fields from Network's public module endpoint; misses are cached, Network
++// trouble is not; nothing without a usr_ subject.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-game-summary-'));
++process.env.DB_PATH = path.join(tmp, 'live.db');
++process.env.OV_NETWORK_INTERNAL_URL = 'http://network.test';
++const log = console.log; console.log = () => {};
++const identity = require('../server/auth/identity-sync');
++const game = require('../server/auth/game-summary');
++console.log = log;
++
++const SUBJECTS = { 1: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', 2: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR', 3: null };
++identity.subjectOf = (id) => SUBJECTS[id] || null;
++
++(async () => {
++ const calls = [];
++ const answers = {};
++ const fetchImpl = async (url) => {
++ calls.push(url);
++ const a = answers[url.split('/').pop()];
++ if (a instanceof Error) throw a;
++ return { status: a ? a.status : 404, json: async () => (a && a.body) || {} };
++ };
++ answers[SUBJECTS[1]] = { status: 200, body: { subject: { type: 'user', id: SUBJECTS[1] }, namespace: 'games.progress.summary', version: 1, data: { level: 12, achievements: 3, playtime_hours: 7.26 } } };
++
++ let r = await game.forUser(1, { fetchImpl, now: 1000 });
++ assert.deepStrictEqual(r, { game: 'Scraplandia', url: 'https://openvibe.games/', level: 12, achievements: 3, playtime_hours: 7.3 });
++ assert.strictEqual(calls[0], `http://network.test/api/modules/games.progress.summary/public/${SUBJECTS[1]}`, 'the public endpoint, no token');
++ await game.forUser(1, { fetchImpl, now: 2000 });
++ assert.strictEqual(calls.length, 1, 'cached');
++ await game.forUser(1, { fetchImpl, now: 1000 + 11 * 60 * 1000 });
++ assert.strictEqual(calls.length, 2, 'refreshed after 10 minutes');
++
++ assert.strictEqual(await game.forUser(2, { fetchImpl, now: 1000 }), null, 'no record');
++ await game.forUser(2, { fetchImpl, now: 2000 });
++ assert.strictEqual(calls.filter((u) => u.endsWith(SUBJECTS[2])).length, 1, 'a miss is cached too');
++
++ assert.strictEqual(await game.forUser(3, { fetchImpl }), null, 'no Network subject: nothing asked');
++ assert.ok(!calls.some((u) => u.endsWith('null')));
++
++ game._reset();
++ answers[SUBJECTS[1]] = new Error('ECONNREFUSED');
++ assert.strictEqual(await game.forUser(1, { fetchImpl, now: 5000 }), null);
++ answers[SUBJECTS[1]] = { status: 200, body: { data: { level: 13 } } };
++ assert.strictEqual((await game.forUser(1, { fetchImpl, now: 6000 })).level, 13, 'Network trouble is not cached');
++ answers[SUBJECTS[1]] = { status: 503 };
++ game._reset();
++ assert.strictEqual(await game.forUser(1, { fetchImpl, now: 7000 }), null);
++
++ assert.strictEqual(game.shape({ level: -1 }), null);
++ assert.strictEqual(game.shape({ achievements: 3 }), null, 'no level, no badge');
++ assert.deepStrictEqual(game.shape({ level: 0, playtime_hours: 'x', achievements: 1.5 }), { game: 'Scraplandia', url: 'https://openvibe.games/', level: 0 });
++
++ // The route and the page wiring.
++ const routes = fs.readFileSync(path.join(__dirname, '..', 'server', 'streaming', 'routes.js'), 'utf8');
++ assert.match(routes, /router\.get\('\/channel\/:username\/game'/);
++ const page = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-channel.js'), 'utf8');
++ assert.match(page, /\/streams\/channel\/\$\{encodeURIComponent\(username\)\}\/game/);
++ assert.ok(!/_loadGameBadge[\s\S]{0,1200}innerHTML/.test(page.slice(page.indexOf('async function _loadGameBadge'))), 'the badge is built with DOM nodes');
++ fs.rmSync(tmp, { recursive: true, force: true });
++ console.log('game summary: all checks passed');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });