openvibers/openvibe.live Commits

Live: the inventory says where a locked item is earned (a quest on OpenVibe.Quest) instead of the retired game, and links the whole inventory (#60)

OP OpenVibers committed 9 Oct 2026 · 14471f4461 · 4 files, +39 −6

Changes

ddiff --git a/public/js/cosmetics-ui.js b/public/js/cosmetics-ui.js
iindex 0cef14f5..cf333973 100644
---- a/public/js/cosmetics-ui.js
++++ b/public/js/cosmetics-ui.js
@@@ -1,8 +1,8 @@
/**
* OpenVibe.Live — Cosmetics Inventory UI
* Opens from the OpenCoins badge in the navbar.
-- * Allows equipping/unequipping name effects, particles, hats, and voices.
-- * Supports activate (game item → global cosmetic) and deactivate (back to game item).
++ * Allows equipping/unequipping name effects, particles, hats, and voices. Items live in OpenVibe.Inventory; a locked
++ * item says where it is earned (a quest on OpenVibe.Quest) or that it cannot be earned yet.
*/
/* ── State ────────────────────────────────────────────────────── */
@@@ -128,7 +128,9 @@ function renderCosmeticsUI() {
`;
}
} else {
-- actionsHtml = `<span class="cosmetic-desc" style="font-style:italic;opacity:0.7">🎮 Unlock in OpenVibeGame, then activate from your game inventory</span>`;
++ actionsHtml = item.earn === 'quest'
++ ? `<span class="cosmetic-desc" style="opacity:0.85">🏅 Earn it with a quest on <a href="https://openvibe.quest/" target="_blank" rel="noopener">OpenVibe.Quest</a></span>`
++ : `<span class="cosmetic-desc" style="font-style:italic;opacity:0.7">Not available to earn yet</span>`;
}
const badgeHtml = isEquipped
@@@ -152,6 +154,17 @@ function renderCosmeticsUI() {
container.innerHTML = '';
container.appendChild(grid);
}
++
++ // Where the rest lives: the whole inventory on OpenVibe.Inventory, and the quests that give items.
++ const body = document.getElementById('cosmetics-body');
++ if (body && !document.getElementById('cosmetics-more')) {
++ const more = document.createElement('p');
++ more.id = 'cosmetics-more';
++ more.className = 'cosmetic-desc';
++ more.style.cssText = 'text-align:center;margin:12px 0 4px';
++ more.innerHTML = '<a href="https://inventory.openvibe.network/me" target="_blank" rel="noopener">Your whole inventory on OpenVibe.Inventory</a> · <a href="https://openvibe.quest/" target="_blank" rel="noopener">Earn items on OpenVibe.Quest</a>';
++ body.after(more);
++ }
}
/* ── API Actions ──────────────────────────────────────────────── */
ddiff --git a/server/monetization/cosmetics-routes.js b/server/monetization/cosmetics-routes.js
iindex 14f09d4c..22136747 100644
---- a/server/monetization/cosmetics-routes.js
++++ b/server/monetization/cosmetics-routes.js
@@@ -15,11 +15,13 @@ const cosmetics = require('./cosmetics');
const router = express.Router();
// ── Get Full Catalog ─────────────────────────────────────────
--router.get('/catalog', (req, res) => {
++router.get('/catalog', async (req, res) => {
++ // `earn: 'quest'` marks the items a quest on OpenVibe.Quest gives (Inventory's grantors for them).
++ const earn = await cosmetics.earnable();
const catalog = {};
for (const [id, c] of Object.entries(cosmetics.COSMETICS)) {
if (!catalog[c.category]) catalog[c.category] = [];
-- catalog[c.category].push({ itemId: id, ...c });
++ catalog[c.category].push({ itemId: id, ...c, ...(earn[id] ? { earn: earn[id] } : {}) });
}
// Sort each category by tier
for (const arr of Object.values(catalog)) arr.sort((a, b) => a.tier - b.tier);
ddiff --git a/server/monetization/cosmetics.js b/server/monetization/cosmetics.js
iindex 5dd8cc08..54be431e 100644
---- a/server/monetization/cosmetics.js
++++ b/server/monetization/cosmetics.js
@@@ -170,6 +170,19 @@ async function getCosmeticProfiles(userIds) {
return out;
}
++/**
++ * Where each item can be earned: { itemId → 'quest' } for the items Live lets OpenVibe.Quest give as quest rewards
++ * (Inventory's definition grantors, ADR-054 §3). Inventory down: {} (the shop just says nothing about earning).
++ */
++async function earnable() {
++ return await quietly(async () => {
++ const { byAlias } = await inventory.catalog();
++ const out = {};
++ for (const [alias, d] of byAlias) if (COSMETICS[alias] && (d.grantors || []).includes('service:quest')) out[alias] = 'quest';
++ return out;
++ }, {});
++}
++
// ── Check if user owns a cosmetic ────────────────────────────
async function ownsCosmetic(userId, itemId) {
return (await getUnlocked(userId)).some((u) => u.item_id === itemId);
@@@ -264,4 +277,5 @@ module.exports = {
equipCosmetic,
unequipSlot,
getFullInventory,
++ earnable,
};
ddiff --git a/test/cosmetics-inventory.test.js b/test/cosmetics-inventory.test.js
iindex d3dc2c97..391cd3fd 100644
---- a/test/cosmetics-inventory.test.js
++++ b/test/cosmetics-inventory.test.js
@@@ -13,7 +13,7 @@ console.warn = () => {};
const A = 'usr_01JZ0000000000000000000AAA';
const DEFS = [
-- { id: 'itd_01JZ00000000000000000001R1', kind: 'live.name_effect', aliases: ['fx_rainbow'] },
++ { id: 'itd_01JZ00000000000000000001R1', kind: 'live.name_effect', aliases: ['fx_rainbow'], grantors: ['service:quest'] },
{ id: 'itd_01JZ00000000000000000001R2', kind: 'live.hat', aliases: ['hat_crown'] },
{ id: 'itd_01JZ00000000000000000001R3', kind: 'live.particle', aliases: ['px_sparkle'] },
];
@@@ -103,6 +103,9 @@ function stub() {
assert.strictEqual((await cosmetics.revokeCosmetic(70, 'hat_crown')).success, true);
assert.ok(!(await cosmetics.getUnlocked(70)).some((u) => u.item_id === 'hat_crown'));
++ // The shop says where an item is earned: Quest gives the Rainbow (Inventory's grantors).
++ assert.deepStrictEqual(await cosmetics.earnable(), { fx_rainbow: 'quest' });
++
// OpenVibe.Chat's decor lookup: Ana and 250 others in three batched reads, never one per person; an account
// without a subject or an unknown id answers an empty profile.
await cosmetics.equipCosmetic(70, 'fx_rainbow');
@@@ -132,6 +135,7 @@ function stub() {
assert.match((await cosmetics.equipCosmetic(70, 'px_sparkle')).error, /unavailable|own/i);
inventory.reset();
assert.deepStrictEqual(await cosmetics.getCosmeticProfiles([70]), { 70: {} }, 'down: an empty profile, not an error');
++ assert.deepStrictEqual(await cosmetics.earnable(), {}, 'down: nothing said about earning');
state.down = false;
process.stdout.write('cosmetics through OpenVibe.Inventory: all checks passed\n');
} finally {