openvibers/openvibe.live Commits

Read Network levels in Live user cards (#765)

OP OpenVibers committed 3 days ago · 66f4b366a9 · 4 files, +47 −2

Changes

ddiff --git a/public/js/chat-shared.js b/public/js/chat-shared.js
iindex 742e3a79..d169bd5d 100644
---- a/public/js/chat-shared.js
++++ b/public/js/chat-shared.js
@@@ -474,6 +474,7 @@
${avatarHtml}
<div class="ctx-info">
<span class="ctx-name">${badge}${esc(p.display_name || username)}</span>
++ ${Number.isInteger(p.level) && p.level > 0 ? `<a href="https://openvibe.quest/levels">Level ${p.level}</a>` : ''}
<span class="ctx-meta">@${esc(username)} &middot; ${joined}</span>
</div>
</div>
ddiff --git a/server/chat/live-context-routes.js b/server/chat/live-context-routes.js
iindex b9d6fd69..78c4ceec 100644
---- a/server/chat/live-context-routes.js
++++ b/server/chat/live-context-routes.js
@@@ -124,6 +124,8 @@ contextRouter.get('/users/profile', async (req, res) => {
// Legacy game skills, read-only (never creates a game_players row)
const game = await db.getLegacyGameProfile(user.id);
if (game) profile.game = game;
++ const levels = await require('../levels/client').batchForUsers([user.id]);
++ if (levels[user.id]) profile.level = levels[user.id];
res.json(profile);
} catch (err) {
fail(res, 500, 'Failed to get profile');
@@@ -215,9 +217,9 @@ contextRouter.get('/bans', async (req, res) => {
// (OpenVibe.Chat reads a missing tag as none).
contextRouter.post('/decor', async (req, res) => {
const ids = [...new Set((Array.isArray(req.body?.user_ids) ? req.body.user_ids : []).map(Number).filter(Number.isInteger))].slice(0, 500);
-- const profiles = await require('../monetization/cosmetics').getCosmeticProfiles(ids);
++ const [profiles, levels] = await Promise.all([require('../monetization/cosmetics').getCosmeticProfiles(ids), require('../levels/client').batchForUsers(ids)]);
const decor = {};
-- for (const id of ids) decor[id] = { cosmetic: profiles[id] || {} };
++ for (const id of ids) decor[id] = { cosmetic: profiles[id] || {}, ...(levels[id] ? { level: levels[id] } : {}) };
res.json({ decor });
});
ddiff --git a/server/levels/client.js b/server/levels/client.js
nnew file mode 100644
iindex 00000000..bc9697ec
---- /dev/null
++++ b/server/levels/client.js
@@@ -0,0 +1,23 @@
++'use strict';
++const identity = require('../auth/identity-sync');
++const hits = new Map();
++const TTL = 30000;
++/** The same public 100-subject read every site uses; missing/down is omitted, never a guessed level. */
++async function batchForUsers(ids, { fetchImpl = globalThis.fetch, subjectOf = identity.subjectOf, now = Date.now } = {}) {
++ const unique = [...new Set(ids.map(Number).filter(Number.isSafeInteger))];
++ const pairs = await Promise.all(unique.map(async id => [id, await subjectOf(id).catch(() => null)]));
++ const missing = [...new Set(pairs.map(([, s]) => s).filter(s => s && (!hits.has(s) || now() - hits.get(s).at >= TTL)))];
++ for (let i = 0; i < missing.length; i += 100) {
++ const subjects = missing.slice(i, i + 100);
++ try {
++ const base = process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000';
++ const res = await fetchImpl(`${base}/api/v1/levels/batch?subjects=${encodeURIComponent(subjects.join(','))}`, { signal: AbortSignal.timeout(1500) });
++ if (!res.ok) continue;
++ const data = await res.json();
++ for (const s of subjects) { const level = data.levels?.[s]; if (Number.isInteger(level) && level > 0) hits.set(s, { level, at: now() }); else hits.delete(s); }
++ } catch { /* chat cards still work during a Network outage */ }
++ }
++ if (hits.size > 10000) for (const [s, h] of hits) if (now() - h.at >= TTL) hits.delete(s);
++ return Object.fromEntries(pairs.filter(([, s]) => hits.has(s) && now() - hits.get(s).at < TTL).map(([id, s]) => [id, hits.get(s).level]));
++}
++module.exports = { batchForUsers };
ddiff --git a/test/levels.test.js b/test/levels.test.js
nnew file mode 100644
iindex 00000000..303caf42
---- /dev/null
++++ b/test/levels.test.js
@@@ -0,0 +1,19 @@
++'use strict';
++const assert = require('assert');
++const { batchForUsers } = require('../server/levels/client');
++(async () => {
++ const calls = [];
++ const subjectOf = async id => `usr_${String(id).padStart(26, '0')}`;
++ const fetchImpl = async url => {
++ const subjects = new URL(url).searchParams.get('subjects').split(','); calls.push(subjects);
++ return { ok: true, json: async () => ({ levels: Object.fromEntries(subjects.map(s => [s, 3])) }) };
++ };
++ const ids = Array.from({ length: 205 }, (_, i) => i + 1);
++ const first = await batchForUsers(ids, { subjectOf, fetchImpl, now: () => 1000 });
++ assert.equal(first[1], 3); assert.equal(first[205], 3);
++ assert.deepEqual(calls.map(c => c.length), [100, 100, 5]);
++ await batchForUsers(ids, { subjectOf, fetchImpl, now: () => 2000 }); assert.equal(calls.length, 3);
++ const down = await batchForUsers(ids, { subjectOf, fetchImpl: async () => { throw new Error('down'); }, now: () => 40000 });
++ assert.deepEqual(down, {});
++ console.log('levels: 205 people in three bounded batches, 30-second cache and graceful outage passed');
++})().catch(e => { console.error(e); process.exitCode = 1; });