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
Changes
@@@ -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; }@@@ -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>@@@ -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, legacySessionIdocument.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'];nnew file mode 100644@@@ -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 };@@@ -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);nnew file mode 100644@@@ -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); });
@@ -1384,3 +1384,8 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
@@ -1384,3 +1384,8 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
/* An AI-inferred category says so ("Gaming · inferred", roadmap 33.4). */
/* An AI-inferred category says so ("Gaming · inferred", roadmap 33.4). */
.ch-category-badge .cat-inferred { font-weight: 500; opacity: .78; font-size: .9em; }
.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; }
@@ -127,6 +127,7 @@
@@ -127,6 +127,7 @@
</div>
</div>
<div class="ch-info-bar-right">
<div class="ch-info-bar-right">
<span class="ch-follower-count" id="ch-follower-count">0 followers</span>
<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">
<button class="btn btn-follow" id="ch-btn-follow">
<i class="fa-solid fa-heart"></i> Follow
<i class="fa-solid fa-heart"></i> Follow
</button>
</button>
@@ -246,6 +247,7 @@
@@ -246,6 +247,7 @@
<p class="ch-description" id="ch-description-offline"></p>
<p class="ch-description" id="ch-description-offline"></p>
<div class="ch-meta">
<div class="ch-meta">
<span class="ch-follower-count" id="ch-follower-count-offline">0 followers</span>
<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>
<span class="ch-category-badge badge" id="ch-category-badge-offline">Offline</span>
</div>
</div>
<div class="ch-weather-widget" id="ch-weather-widget-offline" style="display:none"></div>
<div class="ch-weather-widget" id="ch-weather-widget-offline" style="display:none"></div>
@@ -695,6 +695,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -695,6 +695,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
_setCategoryBadge(document.getElementById('ch-category-badge'), _liveCat || ch.ai_category || ch.category || 'Live',
_setCategoryBadge(document.getElementById('ch-category-badge'), _liveCat || ch.ai_category || ch.category || 'Live',
_liveCat ? _isInferredCategory(_ls0) : !!ch.ai_category); }
_liveCat ? _isInferredCategory(_ls0) : !!ch.ai_category); }
document.getElementById('ch-follower-count').textContent = `${ch.follower_count || 0} followers`;
document.getElementById('ch-follower-count').textContent = `${ch.follower_count || 0} followers`;
+ _loadGameBadge(ch.username);
setupFollowBtn(document.getElementById('ch-btn-follow'));
setupFollowBtn(document.getElementById('ch-btn-follow'));
setupBanBtn(document.getElementById('ch-btn-ban'));
setupBanBtn(document.getElementById('ch-btn-ban'));
@@ -754,6 +755,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -754,6 +755,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
document.getElementById('ch-username-offline').textContent = '@' + ch.username;
document.getElementById('ch-username-offline').textContent = '@' + ch.username;
document.getElementById('ch-description-offline').textContent = ch.description || '';
document.getElementById('ch-description-offline').textContent = ch.description || '';
document.getElementById('ch-follower-count-offline').textContent = `${ch.follower_count || 0} followers`;
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);
_setCategoryBadge(document.getElementById('ch-category-badge-offline'), ch.ai_category || ch.category || 'Offline', !!ch.ai_category);
setupFollowBtn(document.getElementById('ch-btn-follow-offline'));
setupFollowBtn(document.getElementById('ch-btn-follow-offline'));
setupBanBtn(document.getElementById('ch-btn-ban-offline'));
setupBanBtn(document.getElementById('ch-btn-ban-offline'));
@@ -1679,6 +1681,28 @@ function switchChannelTab(tab, btn) {
@@ -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
// 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.).
// 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'];
const CHANNEL_TAB_HASHES = ['about', 'videos', 'clips', 'clips-taken', 'pastes', 'media', 'analytics', 'ai-timeline'];
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -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 };
@@ -1015,6 +1015,17 @@ async function fetchWeather(zip) {
@@ -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) => {
router.get('/channel/:username/weather', async (req, res) => {
try {
try {
const channel = db.getChannelByUsername(req.params.username);
const channel = db.getChannelByUsername(req.params.username);
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -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); });