openvibers/openvibe.inventory Commits

Inventory: Games may grant six of Live's items as achievement rewards; their pages say which achievement (#36)

OP OpenVibers committed 12 Oct 2026 · cc8d570a8a · 4 files, +88 −14

Plan T8 M5, "cross-game inventory": OpenVibe.Games achievements give OpenVibe.Inventory items. Live is the issuer of those items. As it did for Quest (ADR-054 §3 grantors), Live now names Games a grantor of six of them, set at boot from `server/data/live-grantors.json`.

| Achievement | Item |
|---|---|
| First Haul in Scraplandia | Toxic Name (common) |
| Big Haul in Scraplandia | Golden Name (uncommon) |
| Haul Week in Scraplandia | Cowboy Hat (uncommon) |
| First Haul in Salvage Rush | Neon Name (uncommon) |
| Big Haul in Salvage Rush | Flames (uncommon) |
| Arena Winner in Salvage Rush | Stars (uncommon) |

- **Item pages:** "How to get it" names the achievement, with a link to openvibe.games. The text comes from a new `earn` map in the same JSON, keyed by Live's alias. The search document says so too.
- **"Who gives it"** no longer says "only its issuer" on an item that names grantors.
- None of these items is given away by Quest, and nothing is sold or traded (ADR-054 §5).

The grant (`inventory.item.grant` for `service:games`) is in Network#166, and the Games side is a separate PR. Until Games grants, this only changes six item pages.

Test: `grantors.test.js` now checks that the seed names Quest on its six items and Games on its six and no others, that a Games reward's page names openvibe.games and the achievement, and that an item nobody else gives still says only its issuer can. It passes.

Changes

ddiff --git a/server/data/live-grantors.json b/server/data/live-grantors.json
iindex b491f8c..84bee5b 100644
---- a/server/data/live-grantors.json
++++ b/server/data/live-grantors.json
@@@ -1,11 +1,67 @@
{
-- "_about": "Live's items that other services may grant (ADR-054 §3 grantors), by Live's item id. OpenVibe.Quest gives these as quest rewards: Sparkle for saying hello in chat, Hearts for a first follow, the Basic Cap for going live, Fire Name for streaming three times, Ice Name for starting a thread, Rainbow Name for improving a wiki page.",
++ "_about": "Live's items that other services may grant (ADR-054 §3 grantors), by Live's item id. OpenVibe.Quest gives these as quest rewards: Sparkle for saying hello in chat, Hearts for a first follow, the Basic Cap for going live, Fire Name for streaming three times, Ice Name for starting a thread, Rainbow Name for improving a wiki page. OpenVibe.Games gives six for achievements (`earn` names which): a person signed in to OpenVibe gets each once.",
"grantors": {
-- "px_sparkle": ["service:quest"],
-- "px_hearts": ["service:quest"],
-- "hat_basic_cap": ["service:quest"],
-- "fx_fire": ["service:quest"],
-- "fx_ice": ["service:quest"],
-- "fx_rainbow": ["service:quest"]
++ "px_sparkle": [
++ "service:quest"
++ ],
++ "px_hearts": [
++ "service:quest"
++ ],
++ "hat_basic_cap": [
++ "service:quest"
++ ],
++ "fx_fire": [
++ "service:quest"
++ ],
++ "fx_ice": [
++ "service:quest"
++ ],
++ "fx_rainbow": [
++ "service:quest"
++ ],
++ "fx_toxic": [
++ "service:games"
++ ],
++ "fx_golden": [
++ "service:games"
++ ],
++ "hat_cowboy": [
++ "service:games"
++ ],
++ "fx_neon": [
++ "service:games"
++ ],
++ "px_flames": [
++ "service:games"
++ ],
++ "px_stars": [
++ "service:games"
++ ]
++ },
++ "earn": {
++ "fx_toxic": {
++ "service": "service:games",
++ "how": "First Haul in Scraplandia (extract valuables to Scrap City)"
++ },
++ "fx_golden": {
++ "service": "service:games",
++ "how": "Big Haul in Scraplandia (500 haul points in one extraction)"
++ },
++ "hat_cowboy": {
++ "service": "service:games",
++ "how": "Haul Week in Scraplandia (2,000 haul points in a week)"
++ },
++ "fx_neon": {
++ "service": "service:games",
++ "how": "First Haul in Salvage Rush (score in a round)"
++ },
++ "px_flames": {
++ "service": "service:games",
++ "how": "Big Haul in Salvage Rush (five points in one round)"
++ },
++ "px_stars": {
++ "service": "service:games",
++ "how": "Arena Winner in Salvage Rush"
++ }
}
}
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex cb411eb..9d1f11e 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -33,6 +33,14 @@ const KIND_ICON = { 'live.name_effect': 'ov:text', 'live.particle': 'ov:theme',
const surfaceLabel = (s) => SURFACE_LABEL[s] || (s.startsWith('game:') ? `the game ${s.slice(5)}` : s);
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
++// What earns an item on another service (server/data/live-grantors.json `earn`, by Live's alias): the achievement a
++// game names, shown on the item's page.
++const EARN = (() => { try { return require('../data/live-grantors.json').earn || {}; } catch { return {}; } })();
++function earnHow(d) {
++ for (const a of (d.aliases || [])) { const alias = typeof a === 'string' ? a : a && (a.alias || a.value); if (alias && EARN[alias]) return EARN[alias].how; }
++ return null;
++}
++
/** The item's picture: its emoji, or a Media image, in a square tile tinted by rarity. */
function art(d, { size = 'md' } = {}) {
const a = d.art || {};
@@@ -152,10 +160,12 @@ ${shown.map((k) => html`<section class="inv-kind" aria-labelledby="k-${k.id.repl
</ul>
${(d.grantors || []).includes('service:quest') && d.status === 'published' ? html`<h2>How to get it</h2>
<p>Earn it on <a href="https://openvibe.quest/">OpenVibe.Quest</a>: it is the reward for one of the quests there, given once to everyone who completes it.</p>` : ''}
++${(d.grantors || []).includes('service:games') && d.status === 'published' ? html`<h2>How to get it</h2>
++<p>Earn it on <a href="https://openvibe.games/">OpenVibe.Games</a>${earnHow(d) ? html`: ${earnHow(d)}` : ''}. Every player signed in with their OpenVibe account gets it once, the first time.</p>` : ''}
<h2>Where it shows</h2>
<p>${k.surfaces.map((s) => surfaceLabel(s.surface)).join(', ').replace(/^./, (c) => c.toUpperCase())}.</p>
<h2>Who gives it</h2>
--<p>${issuer}. Only its issuer can give this item; it is never sold here.</p>
++<p>${issuer}${(d.grantors || []).length ? ', and the services it names (see How to get it)' : ''}. ${(d.grantors || []).length ? 'Nobody else' : 'Only its issuer'} can give this item; it is never sold here.</p>
<p class="muted small">Item <code>${d.id}</code>${(d.aliases || []).length ? html` · also known as <code>${d.aliases.join(', ')}</code>` : ''} · <a href="/api/v1/definitions/${d.id}">JSON</a></p>`,
});
});
ddiff --git a/server/search-index.js b/server/search-index.js
iindex 9c38e09..de3c112 100644
---- a/server/search-index.js
++++ b/server/search-index.js
@@@ -30,6 +30,7 @@ function describe(row) {
const rarity = RARITY_LABEL[row.rarity] || row.rarity;
const kind = String(row.kind_name || row.kind);
const fromQuest = listOf(row.grantors).includes('service:quest');
++ const fromGames = listOf(row.grantors).includes('service:games');
const wearable = listOf(row.kind_slots).length > 0;
return {
listed: row.status === 'published' || row.status === 'retired',
@@@ -37,7 +38,8 @@ function describe(row) {
title: `${row.name} · ${rarity} ${kind.toLowerCase()}`,
summary: row.description || `${row.name}, a ${rarity.toLowerCase()} ${kind.toLowerCase()} on OpenVibe.`,
body: [row.name, `${rarity} ${kind}`, row.description, wearable ? 'Wearable' : 'Collectible', `Given by ${issuerName(row)}`,
-- fromQuest ? 'Earn it on OpenVibe.Quest: the reward for one of the quests there.' : ''].filter(Boolean).join('\n'),
++ fromQuest ? 'Earn it on OpenVibe.Quest: the reward for one of the quests there.' : '',
++ fromGames ? 'Earn it on OpenVibe.Games: an achievement there gives it.' : ''].filter(Boolean).join('\n'),
facets: { kind: row.kind, rarity: row.rarity, issuer: row.issuer, wearable },
authorship: { mode: 'human' },
publishedAt: row.published_at || row.created_at,
ddiff --git a/test/grantors.test.js b/test/grantors.test.js
iindex 929476c..6cc31b8 100644
---- a/test/grantors.test.js
++++ b/test/grantors.test.js
@@@ -17,17 +17,23 @@ const { boot, check, done } = require('./helpers/boot');
const A = 'usr_01JZ0000000000000000000AAA';
const inv = t.ctx.inv;
try {
-- await check('the boot seed names Quest on the six reward items, and only those', async () => {
++ await check('the boot seed names Quest on its six reward items and Games on its six, and only those', async () => {
const live = (await inv.listDefinitions({ issuer: 'service:live', limit: 500 }));
-- const named = live.filter((d) => (d.grantors || []).length).map((d) => d.aliases[0]).sort();
-- assert.deepStrictEqual(named, ['fx_fire', 'fx_ice', 'fx_rainbow', 'hat_basic_cap', 'px_hearts', 'px_sparkle']);
-- assert.ok(live.filter((d) => d.grantors).every((d) => JSON.stringify(d.grantors) === '["service:quest"]'));
++ const by = (who) => live.filter((d) => JSON.stringify(d.grantors || []) === JSON.stringify([who])).map((d) => d.aliases[0]).sort();
++ assert.deepStrictEqual(by('service:quest'), ['fx_fire', 'fx_ice', 'fx_rainbow', 'hat_basic_cap', 'px_hearts', 'px_sparkle']);
++ assert.deepStrictEqual(by('service:games'), ['fx_golden', 'fx_neon', 'fx_toxic', 'hat_cowboy', 'px_flames', 'px_stars']);
++ assert.strictEqual(live.filter((d) => (d.grantors || []).length).length, 12, 'no other item names a grantor');
++ const named = by('service:quest');
const r = await api(`/definitions/${named.length && (await inv.byAlias('service:live', 'px_sparkle')).id}`);
assert.deepStrictEqual(r.json().definition.grantors, ['service:quest'], 'the public definition says who may grant it');
const page = await t.get(`/items/${(await inv.byAlias('service:live', 'px_sparkle')).id}`);
assert.ok(page.text.includes('How to get it') && page.text.includes('https://openvibe.quest/'), 'its page says where to earn it');
const golden = await t.get(`/items/${(await inv.byAlias('service:live', 'fx_golden')).id}`);
-- assert.ok(!golden.text.includes('How to get it'));
++ assert.ok(golden.text.includes('How to get it') && golden.text.includes('https://openvibe.games/'), 'a Games reward says where');
++ assert.ok(golden.text.includes('Big Haul in Scraplandia'), 'and which achievement');
++ const cap = await t.get(`/items/${(await inv.byAlias('service:live', 'hat_crown')).id}`);
++ assert.ok(!cap.text.includes('How to get it'), 'an item nobody else gives has no such section');
++ assert.ok(cap.text.includes('Only its issuer can give this item'));
const again = await require('../server/inventory/seed').seed(inv, { log: { log() {} } });
assert.strictEqual(again.grantors, 0, 'a second boot changes nothing');
});