openvibers/openvibe.live Commits
Read Network levels in Live user cards (#765)
Changes
@@@ -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)} · ${joined}</span></div></div>@@@ -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 });});nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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; });
@@ -474,6 +474,7 @@
@@ -474,6 +474,7 @@
${avatarHtml}
${avatarHtml}
<div class="ctx-info">
<div class="ctx-info">
<span class="ctx-name">${badge}${esc(p.display_name || username)}</span>
<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)} · ${joined}</span>
<span class="ctx-meta">@${esc(username)} · ${joined}</span>
</div>
</div>
</div>
</div>
@@ -124,6 +124,8 @@ contextRouter.get('/users/profile', async (req, res) => {
@@ -124,6 +124,8 @@ contextRouter.get('/users/profile', async (req, res) => {
// Legacy game skills, read-only (never creates a game_players row)
// Legacy game skills, read-only (never creates a game_players row)
const game = await db.getLegacyGameProfile(user.id);
const game = await db.getLegacyGameProfile(user.id);
if (game) profile.game = game;
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);
res.json(profile);
} catch (err) {
} catch (err) {
fail(res, 500, 'Failed to get profile');
fail(res, 500, 'Failed to get profile');
@@ -215,9 +217,9 @@ contextRouter.get('/bans', async (req, res) => {
@@ -215,9 +217,9 @@ contextRouter.get('/bans', async (req, res) => {
// (OpenVibe.Chat reads a missing tag as none).
// (OpenVibe.Chat reads a missing tag as none).
contextRouter.post('/decor', async (req, res) => {
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 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 = {};
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 });
res.json({ decor });
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -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; });