openvibers/openvibe.quest Commits
Quest 0.3.0: item rewards — six quests also give one of Live's items in OpenVibe.Inventory (Sparkle, Hearts, Basic Cap, Fire, Ice and Rainbow Name), granted with Quest's own token after the completion commits, retried, and given to earlier finishers too (#3)
Changes
@@@ -43,3 +43,11 @@ QUEST_COINS=off#QUEST_COINS_DAILY_CAP=50#QUEST_COINS_GLOBAL_DAILY_CAP=5000#QUEST_COINS_RETRY_MS=300000++++# Item rewards (server/quests/items.js): six quests also give one of Live's items in OpenVibe.Inventory, granted with++# this service's client credentials (audience openvibe.inventory, inventory.item.grant; Live names Quest a grantor of++# those items). On by default: an item is free and never money. A failed grant is retried, and so is every completion++# from before items, so earlier finishers get theirs.++#QUEST_ITEMS=on++#OV_INVENTORY_INTERNAL_URL=http://127.0.0.1:5030++#QUEST_ITEMS_RETRY_MS=300000@@@ -2,6 +2,17 @@What changed in OpenVibe.Quest, newest first. Each site also publishes its patch notes at /updates.++## 0.3.0 — 2026-10-09++++- **Item rewards** (plan T21): six quests also give one of Live's items for the person's OpenVibe.Inventory: say hello++ in chat (Sparkle), follow someone (Hearts), go live (Basic Cap), stream three times (Fire Name), start a thread (Ice++ Name), improve a wiki page (Rainbow Name).++ - **How:** `server/quests/items.js` grants each in Inventory after the completion commits, with Quest's own token,++ origin `earned` and the completion id as the idempotency key.++ - **Failures:** a failed grant is recorded (`migrations/0004_item_rewards.sql`) and retried by a timer, which also++ reaches completions from before this release.++ - **Pages:** the quest pages, `/me` and `/api/v1/quests` name the item.++## 0.2.0 — 2026-10-08- **The product.** One quest log across the whole network:@@@ -28,6 +28,7 @@ subscribes to.| Events consumer | [server/events-consumer.js](server/events-consumer.js) | `POST /internal/events`, exactly as OpenVibe.Space does it: raw body, `parseDelivery(…, { requireV2: true })`, the `openvibe-sdk` inbox for once-only application, forwarding headers refused, 503 without `QUEST_EVENTS_SECRET`. It subscribes to the catalog's events at boot (idempotent, off without `QUEST_EVENTS_URL`) || What moves a quest | [server/quests/apply.js](server/quests/apply.js) | The person is taken from the field the event's own schema defines (`channel.subject`, `follower`, `author`, `user_subject`, `player.subject`, `owner`; the envelope actor only where the schema says the actor *is* that person). No subject, a `gst_` guest, or a service/app/mod principal → ignored, nothing written || Quest log | [server/quests/store.js](server/quests/store.js), [migrations/0002_quest.sql](migrations/0002_quest.sql) | Progress per step, one completion per person per quest (unique), one badge per person, and the Events receipts. Two deliveries of the same event change nothing; so does a replay of the whole request |++| Item rewards | [server/quests/items.js](server/quests/items.js) | Six quests also give one of Live's items: Sparkle, Hearts, Basic Cap, Fire Name, Ice Name and Rainbow Name. Each is granted in OpenVibe.Inventory with Quest's own token (Live names Quest a grantor of those items, ADR-054 §3) as `earned`, keyed by the completion id. Failures and completions from before items are retried by a timer. `QUEST_ITEMS=off` turns it off || OpenCoins | [server/quests/coins.js](server/quests/coins.js) | Credits a completion through OpenVibe.Network's wallet with `idempotency_key = <completion id>`. Off by default, per-person and network-wide daily caps, failures recorded with their reason and retried by a timer || API | [server/http/api.js](server/http/api.js) | `GET /quests`, `GET /quests/:id`, `GET /me/quests`, `GET /profiles/:subject/badges` (see the table below) || Pages | [server/http/pages.js](server/http/pages.js), [server/render/quests.js](server/render/quests.js) | Home (the quests grouped by site, how progress works), a quest, your quest log (plain `<progress>` bars), the rules, the update log — all server-rendered, all readable without JavaScript |nnew file mode 100644@@@ -0,0 +1,10 @@++-- phase: expand++-- Item rewards (plan T21): six quests also give one of Live's items, granted in OpenVibe.Inventory after the++-- completion commits (server/quests/items.js), the way OpenCoins are. item_state is NULL until decided, then++-- 'granted' (item_instance is the inv_… id) or 'failed' (item_error says why; the retry timer comes back to it).++-- A completion from before this release has NULL too, so the timer gives earlier finishers their item. Additive.++ALTER TABLE quest_completions ADD COLUMN IF NOT EXISTS item_state text COLLATE "C";++ALTER TABLE quest_completions ADD COLUMN IF NOT EXISTS item_instance text COLLATE "C";++ALTER TABLE quest_completions ADD COLUMN IF NOT EXISTS item_error text COLLATE "C";++ALTER TABLE quest_completions ADD COLUMN IF NOT EXISTS item_at text COLLATE "C";++ALTER TABLE quest_completions ADD COLUMN IF NOT EXISTS item_attempts integer NOT NULL DEFAULT 0;@@@ -1,12 +1,12 @@{"name": "openvibe-quest",-- "version": "0.2.0",++ "version": "0.3.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-quest",-- "version": "0.2.0",++ "version": "0.3.0","license": "AGPL-3.0-only","dependencies": {"cookie-parser": "^1.4.7",@@@ -1,6 +1,6 @@{"name": "openvibe-quest",-- "version": "0.2.0",++ "version": "0.3.0","private": true,"description": "OpenVibe.Quest — Quests across the whole network.","main": "server/index.js",@@@ -72,6 +72,8 @@ p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 7.reward-badge, .reward-coins { font-size: .78rem; padding: 3px 10px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); color: var(--text-secondary, #96a7c2); }.reward-badge strong { color: var(--text-primary, #e6edf7); }.reward-coins { color: var(--warning, #f59e0b); border-color: color-mix(in srgb, var(--warning, #f59e0b) 40%, transparent); }++.reward-item { font-size: .78rem; padding: 3px 10px; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); }++.reward-item small { color: var(--text-muted, #7386a3); }.reward-coins.soon { color: var(--text-muted, #7386a3); border-style: dashed; border-color: var(--border-light, #2c3d5c); }@media (prefers-reduced-motion: reduce) { .quest { transition: none; } }.steps { margin: .4rem 0; }@@@ -29,6 +29,7 @@ const { createPrincipal } = require('./http/principal');const { createApi } = require('./http/api');const { createPageRoutes } = require('./http/pages');const { createCoins } = require('./quests/coins');++const { createItems } = require('./quests/items');const { createEventsConsumer } = require('./events-consumer');const accountDataLib = require('./identity/account-data');const { createNetworkSender } = require('openvibe-sdk/account-data');@@@ -54,6 +55,7 @@ async function createApp(opts = {}) {const sso = createSso({ config, keys, fetchImpl, now: s.now, log });const principal = createPrincipal({ config, keys });const coins = createCoins({ config, s, fetchImpl, log });++ const items = createItems({ config, s, fetchImpl, log });// Account export and deletion (ADR-033, ./identity/account-data.js): the three tables that hold a person's rows.// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;// a test injects a stand-in through opts.accountSend. Without a client secret the service has no way to push a@@@ -63,8 +65,8 @@ async function createApp(opts = {}) {const accountSend = opts.accountSend || (config.oauth.clientSecret? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }): async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Quest cannot answer account events'); });-- const eventsConsumer = createEventsConsumer({ db: s.db, s, secrets: config.events.secrets, coins, accountData, accountSend, now: s.now, log });-- const ctx = { config, s, keys, sso, principal, coins, eventsConsumer, accountData, accountSend, log };++ const eventsConsumer = createEventsConsumer({ db: s.db, s, secrets: config.events.secrets, coins, items, accountData, accountSend, now: s.now, log });++ const ctx = { config, s, keys, sso, principal, coins, items, eventsConsumer, accountData, accountSend, log };const app = express();app.disable('x-powered-by');@@@ -72,6 +72,15 @@ function load(env = process.env) {globalDailyCap: Math.max(0, int(env.QUEST_COINS_GLOBAL_DAILY_CAP, 5000)),retryMs: Math.max(10_000, int(env.QUEST_COINS_RETRY_MS, 300_000)),},++++ // Item rewards (server/quests/items.js): some quests also give one of Live's items, granted in++ // OpenVibe.Inventory with this service's own token (Live names Quest a grantor of those items, ADR-054 §3).++ // On by default: an item is free and never money. QUEST_ITEMS=off records nothing and grants nothing.++ items: {++ mode: String(env.QUEST_ITEMS || 'on').toLowerCase() === 'off' ? 'off' : 'on',++ inventoryUrl: trim(env.OV_INVENTORY_INTERNAL_URL || 'http://127.0.0.1:5030'),++ retryMs: Math.max(10_000, int(env.QUEST_ITEMS_RETRY_MS, 300_000)),++ },};}@@@ -41,7 +41,7 @@ const CONSUMER = 'quest';const TOPICS = Object.freeze([...catalog.EVENT_TYPES, ...accountDataLib.TOPICS]);const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;--function createEventsConsumer({ db, s, secrets = [], coins = null, accountData = null, accountSend = null, now = () => Date.now(), log = console }) {++function createEventsConsumer({ db, s, secrets = [], coins = null, items = null, accountData = null, accountSend = null, now = () => Date.now(), log = console }) {const keys = (secrets || []).filter((x) => typeof x === 'string' && x.length >= 32);// Receipts (quest_event_inbox) are in migrations/0002_quest.sql.const inbox = createPgInbox(db, { table: 'quest_event_inbox', now });@@@ -95,6 +95,8 @@ function createEventsConsumer({ db, s, secrets = [], coins = null, accountData =// OpenCoins are settled after the transaction: the completion is committed first, so a wallet outage// cannot lose (or roll back) a completion the person really earned. A failure is recorded and retried.if (coins) for (const completion of outcome.completed) await coins.settle(completion);++ // An item reward the same way (server/quests/items.js): granted in OpenVibe.Inventory after the commit.++ if (items) for (const completion of outcome.completed) await items.settle(completion);return res.json({event_id: event.event_id, duplicate: false, outcome: outcome.outcome,completed: outcome.completed.map((c) => c.id),@@@ -30,7 +30,7 @@ const questWire = (q) => ({title: q.title,why: q.why,steps: q.steps.map((s) => ({ event_type: s.event_type, count: s.count, label: s.label })),-- reward: { badge: { id: q.reward.badge.id, name: q.reward.badge.name }, coins: q.reward.coins },++ reward: { badge: { id: q.reward.badge.id, name: q.reward.badge.name }, coins: q.reward.coins, ...(q.reward.item ? { item: { issuer: q.reward.item.issuer, alias: q.reward.item.alias, name: q.reward.item.name, kind: q.reward.item.kind } } : {}) },repeatable: q.repeatable,url: `/quests/${q.id}`,});@@@ -23,7 +23,7 @@ const PAGE_TEXT = {'/': ['OpenVibe.Quest home', 'OpenVibe.Quest: Quests across the whole network. The quests, grouped by the site whose events each one counts.'],'/how-it-works': ['How quests work', 'What OpenVibe.Quest counts, what it stores, and what its rewards are — badges and OpenCoins, never money and never Vibes.'],'/updates': ['What shipped on OpenVibe.Quest', 'This site\'s update log, from the network changelog feed.'],-- ...Object.fromEntries(catalog.all().map((q) => [`/quests/${q.id}`, [q.title, `${q.why} On ${(catalog.SITES[q.site] || {}).name || q.site}. Reward: the ${q.reward.badge.name} badge${q.reward.coins > 0 ? ` and ${q.reward.coins} OpenCoins` : ''}.`]])),++ ...Object.fromEntries(catalog.all().map((q) => [`/quests/${q.id}`, [q.title, `${q.why} On ${(catalog.SITES[q.site] || {}).name || q.site}. Reward: the ${q.reward.badge.name} badge${q.reward.item ? `, the ${q.reward.item.name} (${q.reward.item.kind.toLowerCase()}) for your inventory` : ''}${q.reward.coins > 0 ? ` and ${q.reward.coins} OpenCoins` : ''}.`]])),};function dayOf(ts) {@@@ -75,7 +75,7 @@ function createDiscoveryRoutes(ctx) {{ title: 'The catalog (JSON)', url: abs('/api/v1/quests'), note: 'every quest, grouped by site' },{ title: 'What shipped on OpenVibe.Quest', url: abs('/updates') },] },-- { title: 'The quests', links: catalog.all().map((q) => ({ title: q.title, url: abs(`/quests/${q.id}`), note: `${(catalog.SITES[q.site] || {}).name || q.site} — reward: the ${q.reward.badge.name} badge` })) },++ { title: 'The quests', links: catalog.all().map((q) => ({ title: q.title, url: abs(`/quests/${q.id}`), note: `${(catalog.SITES[q.site] || {}).name || q.site} — reward: the ${q.reward.badge.name} badge${q.reward.item ? ` and the ${q.reward.item.name}` : ''}` })) },{ title: 'Machine-readable', links: [{ title: 'Sitemap', url: abs('/sitemap.xml') },{ title: 'Full text for language models', url: abs('/llms-full.txt') },@@@ -177,7 +177,7 @@ ${q.steps.map((step, i) => quests.progress(step.label, (seen.get(q.id) || new Ma${finished.length ? html`<ul class="quest-list">${finished.map((q) => {const c = done.get(q.id);return html`<li class="quest"><a class="quest-title" href="/quests/${q.id}">${q.title}</a>--<p class="quest-why">Completed ${String(c.completed_at).slice(0, 10)}. Badge: <strong>${q.reward.badge.name}</strong>. ${c.coins > 0 ? (c.coins_state === 'credited' ? html`<strong>${c.coins}</strong> OpenCoins credited.` : c.coins_state === 'skipped' ? html`${c.coins} OpenCoins ${coinsUi.coinsEnabled ? html`were not credited: ${c.coins_error || 'the daily cap was reached'}` : html`start soon`}.` : html`${c.coins} OpenCoins could not be credited yet — this is recorded and retried.`) : ''}</p></li>`;++<p class="quest-why">Completed ${String(c.completed_at).slice(0, 10)}. Badge: <strong>${q.reward.badge.name}</strong>. ${q.reward.item ? (c.item_state === 'granted' ? html`${q.reward.item.emoji} <strong>${q.reward.item.name}</strong> is in <a href="https://inventory.openvibe.network/me">your inventory</a>. ` : c.item_state === 'failed' ? html`${q.reward.item.name} could not be given yet — this is recorded and retried. ` : html`${q.reward.item.name} is on its way to your inventory. `) : ''}${c.coins > 0 ? (c.coins_state === 'credited' ? html`<strong>${c.coins}</strong> OpenCoins credited.` : c.coins_state === 'skipped' ? html`${c.coins} OpenCoins ${coinsUi.coinsEnabled ? html`were not credited: ${c.coins_error || 'the daily cap was reached'}` : html`start soon`}.` : html`${c.coins} OpenCoins could not be credited yet — this is recorded and retried.`) : ''}</p></li>`;})}</ul>` : html`<p class="muted">No quests completed yet.</p>`}<h2>Your badges</h2>${badges.length ? html`<ul class="badges">${badges.map((b) => html`<li><strong>${badge(b.badge_id).name}</strong> <small>${String(b.awarded_at).slice(0, 10)}</small></li>`)}</ul>@@@ -37,8 +37,11 @@ async function start() {const timers = [];const coinsTimer = ctx.coins.start();if (coinsTimer) timers.push(coinsTimer);++ // Item rewards owed (a failed grant, or a completion from before items) are granted by the same kind of timer.++ const itemsTimer = ctx.items.start();++ if (itemsTimer) timers.push(itemsTimer);-- const extra = [() => { if (subscriptions) subscriptions.stop(); }, () => ctx.coins.stop()];++ const extra = [() => { if (subscriptions) subscriptions.stop(); }, () => ctx.coins.stop(), () => ctx.items.stop()];createLifecycle({ server, ctx, timers, extra });return { server, ctx };}@@@ -16,7 +16,9 @@* title one line, a thing a person would say* why one line on why it is worth doing (never a promise the sites do not keep)* steps [{ event_type, count, label }] — every step must reach its count-- * reward { badge: { id, name }, coins } — a text badge label (no emoji) and OpenCoins (loyalty, not money)++ * reward { badge: { id, name }, coins, item? } — a text badge label (no emoji), OpenCoins (loyalty, not money)++ * and, for some, an item for the person's OpenVibe.Inventory: { issuer, alias, name, kind, emoji }, one of++ * Live's items that Live lets Quest give (ADR-054 §3 grantors; server/quests/items.js grants it)* repeatable false: a quest completes once per person, and its badge is awarded once** Community-made quests (the product's third pillar) are not here yet: they need moderation, which this release@@@ -42,7 +44,7 @@ const QUESTS = Object.freeze([title: 'Go live once',why: 'A channel with nothing on it is a promise; one broadcast makes it real.',steps: [{ event_type: 'live.stream.started', count: 1, label: 'Start a stream' }],-- reward: { badge: { id: 'first-broadcast', name: 'First Broadcast' }, coins: 10 },++ reward: { badge: { id: 'first-broadcast', name: 'First Broadcast' }, coins: 10, item: { issuer: 'service:live', alias: 'hat_basic_cap', name: 'Basic Cap', kind: 'Hat', emoji: '🧢' } },repeatable: false,},{@@@ -51,7 +53,7 @@ const QUESTS = Object.freeze([title: 'Stream three times',why: 'Three broadcasts is a habit; the first one is only an attempt.',steps: [{ event_type: 'live.stream.started', count: 3, label: 'Start a stream' }],-- reward: { badge: { id: 'regular-broadcaster', name: 'Regular Broadcaster' }, coins: 25 },++ reward: { badge: { id: 'regular-broadcaster', name: 'Regular Broadcaster' }, coins: 25, item: { issuer: 'service:live', alias: 'fx_fire', name: 'Fire Name', kind: 'Name effect', emoji: '🔥' } },repeatable: false,},{@@@ -60,7 +62,7 @@ const QUESTS = Object.freeze([title: 'Follow someone',why: 'The network is a list of people you chose to hear from.',steps: [{ event_type: 'network.follow.created', count: 1, label: 'Follow a channel or a person' }],-- reward: { badge: { id: 'first-follow', name: 'First Follow' }, coins: 5 },++ reward: { badge: { id: 'first-follow', name: 'First Follow' }, coins: 5, item: { issuer: 'service:live', alias: 'px_hearts', name: 'Hearts', kind: 'Particle effect', emoji: '💖' } },repeatable: false,},{@@@ -69,7 +71,7 @@ const QUESTS = Object.freeze([title: 'Start a forum thread',why: 'Every thread on the forum started as somebody\'s question.',steps: [{ event_type: 'community.thread.created', count: 1, label: 'Start a thread' }],-- reward: { badge: { id: 'thread-starter', name: 'Thread Starter' }, coins: 10 },++ reward: { badge: { id: 'thread-starter', name: 'Thread Starter' }, coins: 10, item: { issuer: 'service:live', alias: 'fx_ice', name: 'Ice Name', kind: 'Name effect', emoji: '❄️' } },repeatable: false,},{@@@ -96,7 +98,7 @@ const QUESTS = Object.freeze([title: 'Say hello in chat',why: 'A public room is the fastest way to meet the people watching the same stream.',steps: [{ event_type: 'chat.room.message.created', count: 1, label: 'Send a message in a public room' }],-- reward: { badge: { id: 'said-hello', name: 'Said Hello' }, coins: 5 },++ reward: { badge: { id: 'said-hello', name: 'Said Hello' }, coins: 5, item: { issuer: 'service:live', alias: 'px_sparkle', name: 'Sparkle', kind: 'Particle effect', emoji: '✨' } },repeatable: false,},{@@@ -105,7 +107,7 @@ const QUESTS = Object.freeze([title: 'Improve a wiki page',why: 'One corrected sentence is a page someone else reads correctly.',steps: [{ event_type: 'wiki.revision.created', count: 1, label: 'Save a revision' }],-- reward: { badge: { id: 'wiki-editor', name: 'Wiki Editor' }, coins: 15 },++ reward: { badge: { id: 'wiki-editor', name: 'Wiki Editor' }, coins: 15, item: { issuer: 'service:live', alias: 'fx_rainbow', name: 'Rainbow Name', kind: 'Name effect', emoji: '🌈' } },repeatable: false,},{nnew file mode 100644@@@ -0,0 +1,129 @@++'use strict';++++/**++ * Item rewards: granting a completion's item in OpenVibe.Inventory (plan T21).++ *++ * Some quests give one of Live's items (catalog.js reward.item: Live's alias, the name and emoji the pages show).++ * Live is the item's issuer and names Quest a grantor of it (ADR-054 §3 amendment, Contracts 0.123.0), so Quest++ * grants it itself: POST /api/v1/grants with origin `earned`, the completion id (qcp_…) as the idempotency key and++ * the quest as the reason. A retry of a grant that did land answers the same instance, so nobody gets two. Nothing++ * is bought, sold or traded, and an item is never money.++ *++ * Auth: this service's own client credentials (`quest`) through openvibe-contracts' cached token client, for++ * audience openvibe.inventory and inventory.item.grant. Inventory's definition ids are read once by alias++ * (GET /api/v1/definitions?issuer=service:live, public) and kept for an hour.++ *++ * Like OpenCoins, a grant runs after the completion commits: an Inventory outage never holds up a delivery or rolls++ * back a completion. A failure is recorded (item_state 'failed' with a short reason) and the retry timer comes back++ * to it; the timer also picks up completions from before this release (item_state NULL), so everyone who already++ * finished a quest gets its item. QUEST_ITEMS=off turns all of it off.++ */++const { serviceAuth } = require('openvibe-contracts');++const store = require('./store');++const catalog = require('./catalog');++++const AUDIENCE = 'openvibe.inventory';++const SCOPES = 'inventory.item.grant';++const TIMEOUT_MS = 5000;++const DEFS_TTL_MS = 60 * 60 * 1000;++++function createItems({ config, s, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() }) {++ const enabled = config.items.mode === 'on';++ const base = String(config.items.inventoryUrl || '').replace(/\/+$/, '');++ const networkBase = String(config.networkInternalUrl || '').replace(/\/+$/, '');++ const tokens = serviceAuth.createTokenClient({++ tokenUrl: `${networkBase}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: AUDIENCE,++ scope: SCOPES,++ fetchImpl,++ });++ const rewarded = catalog.QUESTS.filter((q) => q.reward && q.reward.item).map((q) => q.id);++ let defs = null; // { at, byAlias: Map(alias → itd_ id) }++ let timer = null;++ const stats = { granted: 0, failed: 0, attempts: 0, last_at: null };++++ async function call(method, path, { body, auth = false } = {}) {++ const headers = { Accept: 'application/json', ...(auth ? await tokens.authHeaders() : {}) };++ if (body) headers['Content-Type'] = 'application/json';++ const res = await fetchImpl(`${base}/api/v1${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(TIMEOUT_MS) });++ const json = await res.json().catch(() => null);++ if (!res.ok) {++ // Inventory's own problem code (inventory.not_issuer, inventory.not_published), never the token.++ const err = new Error(`Inventory ${res.status}${json && json.code ? `: ${json.code}` : ''}`);++ err.status = res.status;++ throw err;++ }++ return json || {};++ }++++ /** The itd_ id of one of an issuer's items, by its alias (Live's item id). */++ async function definitionId(issuer, alias) {++ if (!defs || now() - defs.at > DEFS_TTL_MS || !defs.byAlias.has(`${issuer}|${alias}`)) {++ const out = await call('GET', `/definitions?issuer=${encodeURIComponent(issuer)}&limit=500`);++ const byAlias = new Map();++ for (const d of out.definitions || []) for (const a of d.aliases || []) byAlias.set(`${d.issuer}|${a}`, d.id);++ defs = { at: now(), byAlias };++ }++ return defs.byAlias.get(`${issuer}|${alias}`) || null;++ }++++ /** Grant one completion's item; → the instance id. Throws with a short, safe reason. */++ async function grant(completion) {++ const quest = catalog.byId(completion.quest_id);++ const item = quest && quest.reward.item;++ if (!item) throw new Error(`no item reward for ${completion.quest_id}`);++ const id = await definitionId(item.issuer, item.alias);++ if (!id) throw new Error(`OpenVibe.Inventory has no ${item.alias} from ${item.issuer}`);++ const out = await call('POST', '/grants', {++ auth: true,++ body: { definition_id: id, subject: completion.subject, idempotency_key: completion.id, origin: 'earned', reason: `Quest: ${quest.title}`.slice(0, 200) },++ });++ return out.instance && out.instance.id;++ }++++ /** Decide one completion's item now: grant it, or record the failure for the retry timer. Never throws. */++ async function settle(completion) {++ if (!enabled || !completion) return null;++ const quest = catalog.byId(completion.quest_id);++ if (!quest || !quest.reward.item) return null;++ if (completion.item_state === 'granted') return completion;++ stats.attempts++;++ try {++ const instance = await grant(completion);++ stats.granted++; stats.last_at = new Date(now()).toISOString();++ return store.setItem(s.db, completion.id, { state: 'granted', instance, error: null, at: new Date(now()).toISOString(), attempts: 1 });++ } catch (err) {++ stats.failed++;++ const message = String((err && err.message) || err).slice(0, 200);++ log.warn(`[Items] ${completion.id} (${completion.quest_id}) not granted: ${message}`);++ return store.setItem(s.db, completion.id, { state: 'failed', error: message, attempts: 1 });++ }++ }++++ /** Come back to every completion whose item is still owed: a failure, or a completion from before items. */++ async function retryPending({ limit = 50 } = {}) {++ if (!enabled) return { tried: 0, granted: 0, failed: 0 };++ const rows = await store.owedItems(s.db, rewarded, limit);++ const out = { tried: rows.length, granted: 0, failed: 0 };++ for (const row of rows) {++ const after = await settle(row);++ if (after && after.item_state === 'granted') out.granted++; else if (after) out.failed++;++ }++ if (rows.length) log.log(`[Items] retried ${rows.length}: ${out.granted} granted, ${out.failed} still failing`);++ return out;++ }++++ function start({ intervalMs = config.items.retryMs } = {}) {++ if (!enabled || timer) return null;++ timer = setInterval(() => { retryPending().catch((err) => log.warn('[Items] retry failed:', err.message)); }, intervalMs);++ if (timer.unref) timer.unref();++ return timer;++ }++ function stop() { if (timer) clearInterval(timer); timer = null; }++++ return { enabled, settle, retryPending, start, stop, grant, status: () => ({ enabled, ...stats }) };++}++++module.exports = { createItems, AUDIENCE, SCOPES };@@@ -102,6 +102,21 @@ const creditedAll = async (db, day) => Number(await db.value(WHERE coins_state = 'credited' AND coins_at IS NOT NULL AND left(coins_at, 10) = $1::text`,[day]));++/** Record what happened to a completion's item reward. */++const setItem = (db, id, { state, instance = null, error = null, at = null, attempts = 0 }) => db.maybe(++ `UPDATE quest_completions++ SET item_state = $2, item_instance = COALESCE($3, item_instance), item_error = $4, item_at = $5,++ item_attempts = item_attempts + $6++ WHERE id = $1++ RETURNING *`,++ [id, state, instance, error, at, attempts]);++++/** Completions of the given quests whose item is still owed (never decided, or failed), oldest first. */++const owedItems = (db, questIds, limit = 50) => (questIds.length ? db.many(++ `SELECT * FROM quest_completions WHERE quest_id = ANY($1::text[]) AND (item_state IS NULL OR item_state = 'failed')++ ORDER BY completed_at, id LIMIT $2`,++ [questIds, limit]) : Promise.resolve([]));++/** How many people have a badge — the count a quest page can show without naming anyone. */const badgeHolders = async (db, badgeId) => Number(await db.value('SELECT count(*) FROM quest_badges WHERE badge_id = $1', [badgeId]));@@@ -111,4 +126,5 @@ module.exports = {completion, completionsFor, completionCount, complete,awardBadge, badgesFor, badgesOf, badgeHolders,setCoins, owedCoins, creditedTo, creditedAll,++ setItem, owedItems,};@@@ -9,15 +9,18 @@ const catalog = require('../quests/catalog');const { html } = require('./html');/**-- * What a quest pays: its badge (a text label) and its OpenCoins. When OpenCoins are off the reward says so++ * What a quest pays: its badge (a text label), for some an item (OpenVibe.Inventory) and its OpenCoins. When OpenCoins are off the reward says so* instead of promising points that do not move — the sentence the product uses everywhere.*/function reward(quest, { coinsEnabled = false } = {}) {const badge = quest.reward.badge.name;const badgeChip = html`<span class="reward-badge">Badge: <strong>${badge}</strong></span>`;-- if (quest.reward.coins <= 0) return html`<span class="reward">${badgeChip}</span>`;-- if (!coinsEnabled) return html`<span class="reward">${badgeChip}<span class="reward-coins soon">OpenCoins rewards start soon</span></span>`;-- return html`<span class="reward">${badgeChip}<span class="reward-coins"><strong>${quest.reward.coins}</strong> OpenCoins</span></span>`;++ // An item for the person's OpenVibe.Inventory (server/quests/items.js), worn in chat and on their profile.++ const it = quest.reward.item;++ const itemChip = it ? html`<span class="reward-item" title="${it.kind} for your OpenVibe inventory"><span aria-hidden="true">${it.emoji}</span> <strong>${it.name}</strong> <small>${it.kind.toLowerCase()}</small></span>` : '';++ if (quest.reward.coins <= 0) return html`<span class="reward">${badgeChip}${itemChip}</span>`;++ if (!coinsEnabled) return html`<span class="reward">${badgeChip}${itemChip}<span class="reward-coins soon">OpenCoins rewards start soon</span></span>`;++ return html`<span class="reward">${badgeChip}${itemChip}<span class="reward-coins"><strong>${quest.reward.coins}</strong> OpenCoins</span></span>`;}/** A badge's colour, fixed by its quest id, so a badge looks the same wherever it is shown. */nnew file mode 100644@@@ -0,0 +1,132 @@++'use strict';++/**++ * Item rewards (server/quests/items.js, plan T21): six quests also give one of Live's items, granted in a stand-in++ * OpenVibe.Inventory with Quest's own token (audience openvibe.inventory, inventory.item.grant) after the completion++ * commits: origin earned, the completion id as the idempotency key, the quest as the reason. A refusal is recorded++ * and retried; a completion from before items is granted by the retry; a quest without an item asks nothing;++ * QUEST_ITEMS=off asks nothing at all. The pages and the API name the item.++ */++const assert = require('assert');++const http = require('http');++const { ids } = require('openvibe-contracts');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const { boot, check, done } = require('./helpers/boot');++++const SECRET = 'quest-events-secret-0123456789abcdef';++const SPARKLE = 'itd_01JZ00000000000000000001S1';++const HEARTS = 'itd_01JZ00000000000000000001H1';++const iso = () => new Date().toISOString();++const chatMessage = (user_subject) => ({++ event_id: ids.newId('event'), event_type: 'chat.room.message.created', version: 1, source: 'chat',++ actor: { type: 'user', id: user_subject }, timestamp: iso(), subject: { type: 'thing', id: '1' },++ payload: { message_id: 12, room: { id: 'room_1', slug: 'general', name: 'General' }, text: 'hello', created_at: iso(), user_subject, username: 'liv', display_name: 'Liv' },++});++const deliver = (t, event) => {++ const raw = JSON.stringify({ event, seq: 1 });++ return t.get('/internal/events', { method: 'POST', body: raw, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, SECRET) } });++};++const claims = (auth) => JSON.parse(Buffer.from(String(auth).replace(/^Bearer /, '').split('.')[1], 'base64url').toString('utf8'));++++function stubInventory() {++ const st = { grants: [], down: false, instances: 0 };++ const server = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ const json = (status, o) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };++ const u = new URL(req.url, 'http://x');++ if (u.pathname === '/api/v1/definitions') return json(200, { definitions: [{ id: SPARKLE, issuer: 'service:live', aliases: ['px_sparkle'] }, { id: HEARTS, issuer: 'service:live', aliases: ['px_hearts'] }] });++ if (u.pathname === '/api/v1/grants' && req.method === 'POST') {++ if (st.down) return json(503, { code: 'unavailable' });++ const body = JSON.parse(raw);++ st.grants.push({ body, auth: req.headers.authorization });++ const prior = st.grants.find((g) => g.body.idempotency_key === body.idempotency_key && g.instance);++ const instance = prior ? prior.instance : `inv_01JZ00000000000000000000${String(++st.instances).padStart(2, '0')}`;++ st.grants[st.grants.length - 1].instance = instance;++ return json(prior ? 200 : 201, { instance: { id: instance, definition_id: body.definition_id, owner: body.subject, origin: body.origin, state: 'owned' }, created: !prior });++ }++ return json(404, { code: 'route.not_found' });++ });++ });++ return new Promise((r) => server.listen(0, '127.0.0.1', () => r({ st, url: `http://127.0.0.1:${server.address().port}`, close: () => server.close() })));++}++++(async () => {++ const inv = await stubInventory();++ try {++ await check('saying hello in chat gives the Sparkle: earned, keyed by the completion, with Quest\'s token', async () => {++ const t = await boot({ env: { QUEST_EVENTS_SECRET: SECRET, OV_INVENTORY_INTERNAL_URL: inv.url } });++ try {++ const u = t.network.addUser('liv');++ const r = await deliver(t, chatMessage(u.subject));++ assert.strictEqual(r.status, 200, r.text);++ const c = (await t.ctx.s.db.many("SELECT * FROM quest_completions WHERE subject = $1 AND quest_id = 'say-hello-in-chat'", [u.subject]))[0];++ assert.ok(c, 'the quest completed');++ const g = inv.st.grants.find((x) => x.body.idempotency_key === c.id);++ assert.deepStrictEqual(g.body, { definition_id: SPARKLE, subject: u.subject, idempotency_key: c.id, origin: 'earned', reason: 'Quest: Say hello in chat' });++ const tok = claims(g.auth);++ assert.deepStrictEqual([tok.sub, tok.aud, tok.cap], ['svc:quest', ['openvibe.inventory'], ['inventory.item.grant']]);++ assert.deepStrictEqual([c.item_state, c.item_instance], ['granted', g.instance]);++ // The person's page says it is in their inventory.++ const me = await t.get('/me', { headers: { cookie: t.signIn(u) } });++ assert.ok(me.text.includes('Sparkle') && me.text.includes('your inventory'), 'the /me page names the item');++ } finally { await t.close(); }++ });++++ await check('a refused grant is recorded and retried; a completion from before items is granted by the retry; no item, no call', async () => {++ const t = await boot({ env: { QUEST_EVENTS_SECRET: SECRET, OV_INVENTORY_INTERNAL_URL: inv.url } });++ try {++ const u = t.network.addUser('ana');++ inv.st.down = true;++ await deliver(t, chatMessage(u.subject));++ let c = (await t.ctx.s.db.many("SELECT * FROM quest_completions WHERE subject = $1 AND quest_id = 'say-hello-in-chat'", [u.subject]))[0];++ assert.strictEqual(c.item_state, 'failed');++ assert.match(c.item_error, /Inventory 503/);++ inv.st.down = false;++ // A completion recorded before this release: item_state NULL.++ const old = 'qcp_01JZ0000000000000000000012';++ await t.ctx.s.db.exec("INSERT INTO quest_completions (id, subject, quest_id, completed_at, coins, coins_state) VALUES ($1, $2, 'follow-someone', $3, 5, 'skipped')", [old, u.subject, iso()]);++ const before = inv.st.grants.length;++ const out = await t.ctx.items.retryPending();++ assert.deepStrictEqual(out, { tried: 2, granted: 2, failed: 0 });++ c = (await t.ctx.s.db.many("SELECT * FROM quest_completions WHERE subject = $1 AND quest_id = 'say-hello-in-chat'", [u.subject]))[0];++ assert.strictEqual(c.item_state, 'granted');++ const hearts = inv.st.grants.slice(before).find((g) => g.body.idempotency_key === old);++ assert.strictEqual(hearts.body.definition_id, HEARTS, 'the earlier finisher gets their Hearts');++ assert.deepStrictEqual(await t.ctx.items.retryPending(), { tried: 0, granted: 0, failed: 0 }, 'nothing owed now');++ // A quest that gives no item is never asked about.++ const none = 'qcp_01JZ0000000000000000000034';++ await t.ctx.s.db.exec("INSERT INTO quest_completions (id, subject, quest_id, completed_at, coins, coins_state) VALUES ($1, $2, 'reply-in-a-thread', $3, 5, 'skipped')", [none, u.subject, iso()]);++ assert.deepStrictEqual(await t.ctx.items.retryPending(), { tried: 0, granted: 0, failed: 0 });++ } finally { await t.close(); }++ });++++ await check('QUEST_ITEMS=off: nothing is granted, nothing retried, no timer', async () => {++ const t = await boot({ env: { QUEST_EVENTS_SECRET: SECRET, OV_INVENTORY_INTERNAL_URL: inv.url, QUEST_ITEMS: 'off' } });++ try {++ const u = t.network.addUser('off');++ const before = inv.st.grants.length;++ await deliver(t, chatMessage(u.subject));++ assert.strictEqual(inv.st.grants.length, before);++ assert.deepStrictEqual(await t.ctx.items.retryPending(), { tried: 0, granted: 0, failed: 0 });++ assert.strictEqual(t.ctx.items.start(), null);++ } finally { await t.close(); }++ });++++ await check('the quest pages and the API name the item', async () => {++ const t = await boot({ env: { OV_INVENTORY_INTERNAL_URL: inv.url } });++ try {++ const page = await t.get('/quests/say-hello-in-chat');++ assert.ok(page.text.includes('class="reward-item"') && page.text.includes('Sparkle'), 'the reward chip');++ const api = (await t.get('/api/v1/quests')).json();++ const q = (api.quests || api).find((x) => x.id === 'say-hello-in-chat');++ assert.deepStrictEqual(q.reward.item, { issuer: 'service:live', alias: 'px_sparkle', name: 'Sparkle', kind: 'Particle effect' });++ const none = (api.quests || api).find((x) => x.id === 'reply-in-a-thread');++ assert.strictEqual(none.reward.item, undefined);++ } finally { await t.close(); }++ });++ } finally {++ inv.close();++ }++ done();++})();@@@ -35,7 +35,7 @@ const { render } = require('../server/render/html');const goLive = body.quests.find((q) => q.id === 'go-live');assert.strictEqual(goLive.title, 'Go live once');assert.deepStrictEqual(goLive.steps, [{ event_type: 'live.stream.started', count: 1, label: 'Start a stream' }]);-- assert.deepStrictEqual(goLive.reward, { badge: { id: 'first-broadcast', name: 'First Broadcast' }, coins: 10 });++ assert.deepStrictEqual(goLive.reward, { badge: { id: 'first-broadcast', name: 'First Broadcast' }, coins: 10, item: { issuer: 'service:live', alias: 'hat_basic_cap', name: 'Basic Cap', kind: 'Hat' } });assert.strictEqual(goLive.repeatable, false);assert.strictEqual(body.coins.enabled, false, 'OpenCoins are off by default');assert.strictEqual(body.coins.daily_cap, 50);
@@ -43,3 +43,11 @@ QUEST_COINS=off
@@ -43,3 +43,11 @@ QUEST_COINS=off
#QUEST_COINS_DAILY_CAP=50
#QUEST_COINS_DAILY_CAP=50
#QUEST_COINS_GLOBAL_DAILY_CAP=5000
#QUEST_COINS_GLOBAL_DAILY_CAP=5000
#QUEST_COINS_RETRY_MS=300000
#QUEST_COINS_RETRY_MS=300000
+
+# Item rewards (server/quests/items.js): six quests also give one of Live's items in OpenVibe.Inventory, granted with
+# this service's client credentials (audience openvibe.inventory, inventory.item.grant; Live names Quest a grantor of
+# those items). On by default: an item is free and never money. A failed grant is retried, and so is every completion
+# from before items, so earlier finishers get theirs.
+#QUEST_ITEMS=on
+#OV_INVENTORY_INTERNAL_URL=http://127.0.0.1:5030
+#QUEST_ITEMS_RETRY_MS=300000
@@ -2,6 +2,17 @@
@@ -2,6 +2,17 @@
What changed in OpenVibe.Quest, newest first. Each site also publishes its patch notes at /updates.
What changed in OpenVibe.Quest, newest first. Each site also publishes its patch notes at /updates.
+## 0.3.0 — 2026-10-09
+
+- **Item rewards** (plan T21): six quests also give one of Live's items for the person's OpenVibe.Inventory: say hello
+ in chat (Sparkle), follow someone (Hearts), go live (Basic Cap), stream three times (Fire Name), start a thread (Ice
+ Name), improve a wiki page (Rainbow Name).
+ - **How:** `server/quests/items.js` grants each in Inventory after the completion commits, with Quest's own token,
+ origin `earned` and the completion id as the idempotency key.
+ - **Failures:** a failed grant is recorded (`migrations/0004_item_rewards.sql`) and retried by a timer, which also
+ reaches completions from before this release.
+ - **Pages:** the quest pages, `/me` and `/api/v1/quests` name the item.
+
## 0.2.0 — 2026-10-08
## 0.2.0 — 2026-10-08
- **The product.** One quest log across the whole network:
- **The product.** One quest log across the whole network:
@@ -28,6 +28,7 @@ subscribes to.
@@ -28,6 +28,7 @@ subscribes to.
| Events consumer | [server/events-consumer.js](server/events-consumer.js) | `POST /internal/events`, exactly as OpenVibe.Space does it: raw body, `parseDelivery(…, { requireV2: true })`, the `openvibe-sdk` inbox for once-only application, forwarding headers refused, 503 without `QUEST_EVENTS_SECRET`. It subscribes to the catalog's events at boot (idempotent, off without `QUEST_EVENTS_URL`) |
| Events consumer | [server/events-consumer.js](server/events-consumer.js) | `POST /internal/events`, exactly as OpenVibe.Space does it: raw body, `parseDelivery(…, { requireV2: true })`, the `openvibe-sdk` inbox for once-only application, forwarding headers refused, 503 without `QUEST_EVENTS_SECRET`. It subscribes to the catalog's events at boot (idempotent, off without `QUEST_EVENTS_URL`) |
| What moves a quest | [server/quests/apply.js](server/quests/apply.js) | The person is taken from the field the event's own schema defines (`channel.subject`, `follower`, `author`, `user_subject`, `player.subject`, `owner`; the envelope actor only where the schema says the actor *is* that person). No subject, a `gst_` guest, or a service/app/mod principal → ignored, nothing written |
| What moves a quest | [server/quests/apply.js](server/quests/apply.js) | The person is taken from the field the event's own schema defines (`channel.subject`, `follower`, `author`, `user_subject`, `player.subject`, `owner`; the envelope actor only where the schema says the actor *is* that person). No subject, a `gst_` guest, or a service/app/mod principal → ignored, nothing written |
| Quest log | [server/quests/store.js](server/quests/store.js), [migrations/0002_quest.sql](migrations/0002_quest.sql) | Progress per step, one completion per person per quest (unique), one badge per person, and the Events receipts. Two deliveries of the same event change nothing; so does a replay of the whole request |
| Quest log | [server/quests/store.js](server/quests/store.js), [migrations/0002_quest.sql](migrations/0002_quest.sql) | Progress per step, one completion per person per quest (unique), one badge per person, and the Events receipts. Two deliveries of the same event change nothing; so does a replay of the whole request |
+| Item rewards | [server/quests/items.js](server/quests/items.js) | Six quests also give one of Live's items: Sparkle, Hearts, Basic Cap, Fire Name, Ice Name and Rainbow Name. Each is granted in OpenVibe.Inventory with Quest's own token (Live names Quest a grantor of those items, ADR-054 §3) as `earned`, keyed by the completion id. Failures and completions from before items are retried by a timer. `QUEST_ITEMS=off` turns it off |
| OpenCoins | [server/quests/coins.js](server/quests/coins.js) | Credits a completion through OpenVibe.Network's wallet with `idempotency_key = <completion id>`. Off by default, per-person and network-wide daily caps, failures recorded with their reason and retried by a timer |
| OpenCoins | [server/quests/coins.js](server/quests/coins.js) | Credits a completion through OpenVibe.Network's wallet with `idempotency_key = <completion id>`. Off by default, per-person and network-wide daily caps, failures recorded with their reason and retried by a timer |
| API | [server/http/api.js](server/http/api.js) | `GET /quests`, `GET /quests/:id`, `GET /me/quests`, `GET /profiles/:subject/badges` (see the table below) |
| API | [server/http/api.js](server/http/api.js) | `GET /quests`, `GET /quests/:id`, `GET /me/quests`, `GET /profiles/:subject/badges` (see the table below) |
| Pages | [server/http/pages.js](server/http/pages.js), [server/render/quests.js](server/render/quests.js) | Home (the quests grouped by site, how progress works), a quest, your quest log (plain `<progress>` bars), the rules, the update log — all server-rendered, all readable without JavaScript |
| Pages | [server/http/pages.js](server/http/pages.js), [server/render/quests.js](server/render/quests.js) | Home (the quests grouped by site, how progress works), a quest, your quest log (plain `<progress>` bars), the rules, the update log — all server-rendered, all readable without JavaScript |
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+-- phase: expand
+-- Item rewards (plan T21): six quests also give one of Live's items, granted in OpenVibe.Inventory after the
+-- completion commits (server/quests/items.js), the way OpenCoins are. item_state is NULL until decided, then
+-- 'granted' (item_instance is the inv_… id) or 'failed' (item_error says why; the retry timer comes back to it).
+-- A completion from before this release has NULL too, so the timer gives earlier finishers their item. Additive.
+ALTER TABLE quest_completions ADD COLUMN IF NOT EXISTS item_state text COLLATE "C";
+ALTER TABLE quest_completions ADD COLUMN IF NOT EXISTS item_instance text COLLATE "C";
+ALTER TABLE quest_completions ADD COLUMN IF NOT EXISTS item_error text COLLATE "C";
+ALTER TABLE quest_completions ADD COLUMN IF NOT EXISTS item_at text COLLATE "C";
+ALTER TABLE quest_completions ADD COLUMN IF NOT EXISTS item_attempts integer NOT NULL DEFAULT 0;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-quest",
"name": "openvibe-quest",
- "version": "0.2.0",
+ "version": "0.3.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-quest",
"name": "openvibe-quest",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-quest",
"name": "openvibe-quest",
- "version": "0.2.0",
+ "version": "0.3.0",
"private": true,
"private": true,
"description": "OpenVibe.Quest — Quests across the whole network.",
"description": "OpenVibe.Quest — Quests across the whole network.",
"main": "server/index.js",
"main": "server/index.js",
@@ -72,6 +72,8 @@ p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 7
@@ -72,6 +72,8 @@ p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 7
.reward-badge, .reward-coins { font-size: .78rem; padding: 3px 10px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); color: var(--text-secondary, #96a7c2); }
.reward-badge, .reward-coins { font-size: .78rem; padding: 3px 10px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); color: var(--text-secondary, #96a7c2); }
.reward-badge strong { color: var(--text-primary, #e6edf7); }
.reward-badge strong { color: var(--text-primary, #e6edf7); }
.reward-coins { color: var(--warning, #f59e0b); border-color: color-mix(in srgb, var(--warning, #f59e0b) 40%, transparent); }
.reward-coins { color: var(--warning, #f59e0b); border-color: color-mix(in srgb, var(--warning, #f59e0b) 40%, transparent); }
+.reward-item { font-size: .78rem; padding: 3px 10px; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); }
+.reward-item small { color: var(--text-muted, #7386a3); }
.reward-coins.soon { color: var(--text-muted, #7386a3); border-style: dashed; border-color: var(--border-light, #2c3d5c); }
.reward-coins.soon { color: var(--text-muted, #7386a3); border-style: dashed; border-color: var(--border-light, #2c3d5c); }
@media (prefers-reduced-motion: reduce) { .quest { transition: none; } }
@media (prefers-reduced-motion: reduce) { .quest { transition: none; } }
.steps { margin: .4rem 0; }
.steps { margin: .4rem 0; }
@@ -29,6 +29,7 @@ const { createPrincipal } = require('./http/principal');
@@ -29,6 +29,7 @@ const { createPrincipal } = require('./http/principal');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
const { createCoins } = require('./quests/coins');
const { createCoins } = require('./quests/coins');
+const { createItems } = require('./quests/items');
const { createEventsConsumer } = require('./events-consumer');
const { createEventsConsumer } = require('./events-consumer');
const accountDataLib = require('./identity/account-data');
const accountDataLib = require('./identity/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
@@ -54,6 +55,7 @@ async function createApp(opts = {}) {
@@ -54,6 +55,7 @@ async function createApp(opts = {}) {
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const principal = createPrincipal({ config, keys });
const principal = createPrincipal({ config, keys });
const coins = createCoins({ config, s, fetchImpl, log });
const coins = createCoins({ config, s, fetchImpl, log });
+ const items = createItems({ config, s, fetchImpl, log });
// Account export and deletion (ADR-033, ./identity/account-data.js): the three tables that hold a person's rows.
// Account export and deletion (ADR-033, ./identity/account-data.js): the three tables that hold a person's rows.
// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;
// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;
// a test injects a stand-in through opts.accountSend. Without a client secret the service has no way to push a
// a test injects a stand-in through opts.accountSend. Without a client secret the service has no way to push a
@@ -63,8 +65,8 @@ async function createApp(opts = {}) {
@@ -63,8 +65,8 @@ async function createApp(opts = {}) {
const accountSend = opts.accountSend || (config.oauth.clientSecret
const accountSend = opts.accountSend || (config.oauth.clientSecret
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Quest cannot answer account events'); });
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Quest cannot answer account events'); });
- const eventsConsumer = createEventsConsumer({ db: s.db, s, secrets: config.events.secrets, coins, accountData, accountSend, now: s.now, log });
- const ctx = { config, s, keys, sso, principal, coins, eventsConsumer, accountData, accountSend, log };
+ const eventsConsumer = createEventsConsumer({ db: s.db, s, secrets: config.events.secrets, coins, items, accountData, accountSend, now: s.now, log });
+ const ctx = { config, s, keys, sso, principal, coins, items, eventsConsumer, accountData, accountSend, log };
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -72,6 +72,15 @@ function load(env = process.env) {
@@ -72,6 +72,15 @@ function load(env = process.env) {
globalDailyCap: Math.max(0, int(env.QUEST_COINS_GLOBAL_DAILY_CAP, 5000)),
globalDailyCap: Math.max(0, int(env.QUEST_COINS_GLOBAL_DAILY_CAP, 5000)),
retryMs: Math.max(10_000, int(env.QUEST_COINS_RETRY_MS, 300_000)),
retryMs: Math.max(10_000, int(env.QUEST_COINS_RETRY_MS, 300_000)),
},
},
+
+ // Item rewards (server/quests/items.js): some quests also give one of Live's items, granted in
+ // OpenVibe.Inventory with this service's own token (Live names Quest a grantor of those items, ADR-054 §3).
+ // On by default: an item is free and never money. QUEST_ITEMS=off records nothing and grants nothing.
+ items: {
+ mode: String(env.QUEST_ITEMS || 'on').toLowerCase() === 'off' ? 'off' : 'on',
+ inventoryUrl: trim(env.OV_INVENTORY_INTERNAL_URL || 'http://127.0.0.1:5030'),
+ retryMs: Math.max(10_000, int(env.QUEST_ITEMS_RETRY_MS, 300_000)),
+ },
};
};
}
}
@@ -41,7 +41,7 @@ const CONSUMER = 'quest';
@@ -41,7 +41,7 @@ const CONSUMER = 'quest';
const TOPICS = Object.freeze([...catalog.EVENT_TYPES, ...accountDataLib.TOPICS]);
const TOPICS = Object.freeze([...catalog.EVENT_TYPES, ...accountDataLib.TOPICS]);
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
-function createEventsConsumer({ db, s, secrets = [], coins = null, accountData = null, accountSend = null, now = () => Date.now(), log = console }) {
+function createEventsConsumer({ db, s, secrets = [], coins = null, items = null, accountData = null, accountSend = null, now = () => Date.now(), log = console }) {
const keys = (secrets || []).filter((x) => typeof x === 'string' && x.length >= 32);
const keys = (secrets || []).filter((x) => typeof x === 'string' && x.length >= 32);
// Receipts (quest_event_inbox) are in migrations/0002_quest.sql.
// Receipts (quest_event_inbox) are in migrations/0002_quest.sql.
const inbox = createPgInbox(db, { table: 'quest_event_inbox', now });
const inbox = createPgInbox(db, { table: 'quest_event_inbox', now });
@@ -95,6 +95,8 @@ function createEventsConsumer({ db, s, secrets = [], coins = null, accountData =
@@ -95,6 +95,8 @@ function createEventsConsumer({ db, s, secrets = [], coins = null, accountData =
// OpenCoins are settled after the transaction: the completion is committed first, so a wallet outage
// OpenCoins are settled after the transaction: the completion is committed first, so a wallet outage
// cannot lose (or roll back) a completion the person really earned. A failure is recorded and retried.
// cannot lose (or roll back) a completion the person really earned. A failure is recorded and retried.
if (coins) for (const completion of outcome.completed) await coins.settle(completion);
if (coins) for (const completion of outcome.completed) await coins.settle(completion);
+ // An item reward the same way (server/quests/items.js): granted in OpenVibe.Inventory after the commit.
+ if (items) for (const completion of outcome.completed) await items.settle(completion);
return res.json({
return res.json({
event_id: event.event_id, duplicate: false, outcome: outcome.outcome,
event_id: event.event_id, duplicate: false, outcome: outcome.outcome,
completed: outcome.completed.map((c) => c.id),
completed: outcome.completed.map((c) => c.id),
@@ -30,7 +30,7 @@ const questWire = (q) => ({
@@ -30,7 +30,7 @@ const questWire = (q) => ({
title: q.title,
title: q.title,
why: q.why,
why: q.why,
steps: q.steps.map((s) => ({ event_type: s.event_type, count: s.count, label: s.label })),
steps: q.steps.map((s) => ({ event_type: s.event_type, count: s.count, label: s.label })),
- reward: { badge: { id: q.reward.badge.id, name: q.reward.badge.name }, coins: q.reward.coins },
+ reward: { badge: { id: q.reward.badge.id, name: q.reward.badge.name }, coins: q.reward.coins, ...(q.reward.item ? { item: { issuer: q.reward.item.issuer, alias: q.reward.item.alias, name: q.reward.item.name, kind: q.reward.item.kind } } : {}) },
repeatable: q.repeatable,
repeatable: q.repeatable,
url: `/quests/${q.id}`,
url: `/quests/${q.id}`,
});
});
@@ -23,7 +23,7 @@ const PAGE_TEXT = {
@@ -23,7 +23,7 @@ const PAGE_TEXT = {
'/': ['OpenVibe.Quest home', 'OpenVibe.Quest: Quests across the whole network. The quests, grouped by the site whose events each one counts.'],
'/': ['OpenVibe.Quest home', 'OpenVibe.Quest: Quests across the whole network. The quests, grouped by the site whose events each one counts.'],
'/how-it-works': ['How quests work', 'What OpenVibe.Quest counts, what it stores, and what its rewards are — badges and OpenCoins, never money and never Vibes.'],
'/how-it-works': ['How quests work', 'What OpenVibe.Quest counts, what it stores, and what its rewards are — badges and OpenCoins, never money and never Vibes.'],
'/updates': ['What shipped on OpenVibe.Quest', 'This site\'s update log, from the network changelog feed.'],
'/updates': ['What shipped on OpenVibe.Quest', 'This site\'s update log, from the network changelog feed.'],
- ...Object.fromEntries(catalog.all().map((q) => [`/quests/${q.id}`, [q.title, `${q.why} On ${(catalog.SITES[q.site] || {}).name || q.site}. Reward: the ${q.reward.badge.name} badge${q.reward.coins > 0 ? ` and ${q.reward.coins} OpenCoins` : ''}.`]])),
+ ...Object.fromEntries(catalog.all().map((q) => [`/quests/${q.id}`, [q.title, `${q.why} On ${(catalog.SITES[q.site] || {}).name || q.site}. Reward: the ${q.reward.badge.name} badge${q.reward.item ? `, the ${q.reward.item.name} (${q.reward.item.kind.toLowerCase()}) for your inventory` : ''}${q.reward.coins > 0 ? ` and ${q.reward.coins} OpenCoins` : ''}.`]])),
};
};
function dayOf(ts) {
function dayOf(ts) {
@@ -75,7 +75,7 @@ function createDiscoveryRoutes(ctx) {
@@ -75,7 +75,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'The catalog (JSON)', url: abs('/api/v1/quests'), note: 'every quest, grouped by site' },
{ title: 'The catalog (JSON)', url: abs('/api/v1/quests'), note: 'every quest, grouped by site' },
{ title: 'What shipped on OpenVibe.Quest', url: abs('/updates') },
{ title: 'What shipped on OpenVibe.Quest', url: abs('/updates') },
] },
] },
- { title: 'The quests', links: catalog.all().map((q) => ({ title: q.title, url: abs(`/quests/${q.id}`), note: `${(catalog.SITES[q.site] || {}).name || q.site} — reward: the ${q.reward.badge.name} badge` })) },
+ { title: 'The quests', links: catalog.all().map((q) => ({ title: q.title, url: abs(`/quests/${q.id}`), note: `${(catalog.SITES[q.site] || {}).name || q.site} — reward: the ${q.reward.badge.name} badge${q.reward.item ? ` and the ${q.reward.item.name}` : ''}` })) },
{ title: 'Machine-readable', links: [
{ title: 'Machine-readable', links: [
{ title: 'Sitemap', url: abs('/sitemap.xml') },
{ title: 'Sitemap', url: abs('/sitemap.xml') },
{ title: 'Full text for language models', url: abs('/llms-full.txt') },
{ title: 'Full text for language models', url: abs('/llms-full.txt') },
@@ -177,7 +177,7 @@ ${q.steps.map((step, i) => quests.progress(step.label, (seen.get(q.id) || new Ma
@@ -177,7 +177,7 @@ ${q.steps.map((step, i) => quests.progress(step.label, (seen.get(q.id) || new Ma
${finished.length ? html`<ul class="quest-list">${finished.map((q) => {
${finished.length ? html`<ul class="quest-list">${finished.map((q) => {
const c = done.get(q.id);
const c = done.get(q.id);
return html`<li class="quest"><a class="quest-title" href="/quests/${q.id}">${q.title}</a>
return html`<li class="quest"><a class="quest-title" href="/quests/${q.id}">${q.title}</a>
-<p class="quest-why">Completed ${String(c.completed_at).slice(0, 10)}. Badge: <strong>${q.reward.badge.name}</strong>. ${c.coins > 0 ? (c.coins_state === 'credited' ? html`<strong>${c.coins}</strong> OpenCoins credited.` : c.coins_state === 'skipped' ? html`${c.coins} OpenCoins ${coinsUi.coinsEnabled ? html`were not credited: ${c.coins_error || 'the daily cap was reached'}` : html`start soon`}.` : html`${c.coins} OpenCoins could not be credited yet — this is recorded and retried.`) : ''}</p></li>`;
+<p class="quest-why">Completed ${String(c.completed_at).slice(0, 10)}. Badge: <strong>${q.reward.badge.name}</strong>. ${q.reward.item ? (c.item_state === 'granted' ? html`${q.reward.item.emoji} <strong>${q.reward.item.name}</strong> is in <a href="https://inventory.openvibe.network/me">your inventory</a>. ` : c.item_state === 'failed' ? html`${q.reward.item.name} could not be given yet — this is recorded and retried. ` : html`${q.reward.item.name} is on its way to your inventory. `) : ''}${c.coins > 0 ? (c.coins_state === 'credited' ? html`<strong>${c.coins}</strong> OpenCoins credited.` : c.coins_state === 'skipped' ? html`${c.coins} OpenCoins ${coinsUi.coinsEnabled ? html`were not credited: ${c.coins_error || 'the daily cap was reached'}` : html`start soon`}.` : html`${c.coins} OpenCoins could not be credited yet — this is recorded and retried.`) : ''}</p></li>`;
})}</ul>` : html`<p class="muted">No quests completed yet.</p>`}
})}</ul>` : html`<p class="muted">No quests completed yet.</p>`}
<h2>Your badges</h2>
<h2>Your badges</h2>
${badges.length ? html`<ul class="badges">${badges.map((b) => html`<li><strong>${badge(b.badge_id).name}</strong> <small>${String(b.awarded_at).slice(0, 10)}</small></li>`)}</ul>
${badges.length ? html`<ul class="badges">${badges.map((b) => html`<li><strong>${badge(b.badge_id).name}</strong> <small>${String(b.awarded_at).slice(0, 10)}</small></li>`)}</ul>
@@ -37,8 +37,11 @@ async function start() {
@@ -37,8 +37,11 @@ async function start() {
const timers = [];
const timers = [];
const coinsTimer = ctx.coins.start();
const coinsTimer = ctx.coins.start();
if (coinsTimer) timers.push(coinsTimer);
if (coinsTimer) timers.push(coinsTimer);
+ // Item rewards owed (a failed grant, or a completion from before items) are granted by the same kind of timer.
+ const itemsTimer = ctx.items.start();
+ if (itemsTimer) timers.push(itemsTimer);
- const extra = [() => { if (subscriptions) subscriptions.stop(); }, () => ctx.coins.stop()];
+ const extra = [() => { if (subscriptions) subscriptions.stop(); }, () => ctx.coins.stop(), () => ctx.items.stop()];
createLifecycle({ server, ctx, timers, extra });
createLifecycle({ server, ctx, timers, extra });
return { server, ctx };
return { server, ctx };
}
}
@@ -16,7 +16,9 @@
@@ -16,7 +16,9 @@
* title one line, a thing a person would say
* title one line, a thing a person would say
* why one line on why it is worth doing (never a promise the sites do not keep)
* why one line on why it is worth doing (never a promise the sites do not keep)
* steps [{ event_type, count, label }] — every step must reach its count
* steps [{ event_type, count, label }] — every step must reach its count
- * reward { badge: { id, name }, coins } — a text badge label (no emoji) and OpenCoins (loyalty, not money)
+ * reward { badge: { id, name }, coins, item? } — a text badge label (no emoji), OpenCoins (loyalty, not money)
+ * and, for some, an item for the person's OpenVibe.Inventory: { issuer, alias, name, kind, emoji }, one of
+ * Live's items that Live lets Quest give (ADR-054 §3 grantors; server/quests/items.js grants it)
* repeatable false: a quest completes once per person, and its badge is awarded once
* repeatable false: a quest completes once per person, and its badge is awarded once
*
*
* Community-made quests (the product's third pillar) are not here yet: they need moderation, which this release
* Community-made quests (the product's third pillar) are not here yet: they need moderation, which this release
@@ -42,7 +44,7 @@ const QUESTS = Object.freeze([
@@ -42,7 +44,7 @@ const QUESTS = Object.freeze([
title: 'Go live once',
title: 'Go live once',
why: 'A channel with nothing on it is a promise; one broadcast makes it real.',
why: 'A channel with nothing on it is a promise; one broadcast makes it real.',
steps: [{ event_type: 'live.stream.started', count: 1, label: 'Start a stream' }],
steps: [{ event_type: 'live.stream.started', count: 1, label: 'Start a stream' }],
- reward: { badge: { id: 'first-broadcast', name: 'First Broadcast' }, coins: 10 },
+ reward: { badge: { id: 'first-broadcast', name: 'First Broadcast' }, coins: 10, item: { issuer: 'service:live', alias: 'hat_basic_cap', name: 'Basic Cap', kind: 'Hat', emoji: '🧢' } },
repeatable: false,
repeatable: false,
},
},
{
{
@@ -51,7 +53,7 @@ const QUESTS = Object.freeze([
@@ -51,7 +53,7 @@ const QUESTS = Object.freeze([
title: 'Stream three times',
title: 'Stream three times',
why: 'Three broadcasts is a habit; the first one is only an attempt.',
why: 'Three broadcasts is a habit; the first one is only an attempt.',
steps: [{ event_type: 'live.stream.started', count: 3, label: 'Start a stream' }],
steps: [{ event_type: 'live.stream.started', count: 3, label: 'Start a stream' }],
- reward: { badge: { id: 'regular-broadcaster', name: 'Regular Broadcaster' }, coins: 25 },
+ reward: { badge: { id: 'regular-broadcaster', name: 'Regular Broadcaster' }, coins: 25, item: { issuer: 'service:live', alias: 'fx_fire', name: 'Fire Name', kind: 'Name effect', emoji: '🔥' } },
repeatable: false,
repeatable: false,
},
},
{
{
@@ -60,7 +62,7 @@ const QUESTS = Object.freeze([
@@ -60,7 +62,7 @@ const QUESTS = Object.freeze([
title: 'Follow someone',
title: 'Follow someone',
why: 'The network is a list of people you chose to hear from.',
why: 'The network is a list of people you chose to hear from.',
steps: [{ event_type: 'network.follow.created', count: 1, label: 'Follow a channel or a person' }],
steps: [{ event_type: 'network.follow.created', count: 1, label: 'Follow a channel or a person' }],
- reward: { badge: { id: 'first-follow', name: 'First Follow' }, coins: 5 },
+ reward: { badge: { id: 'first-follow', name: 'First Follow' }, coins: 5, item: { issuer: 'service:live', alias: 'px_hearts', name: 'Hearts', kind: 'Particle effect', emoji: '💖' } },
repeatable: false,
repeatable: false,
},
},
{
{
@@ -69,7 +71,7 @@ const QUESTS = Object.freeze([
@@ -69,7 +71,7 @@ const QUESTS = Object.freeze([
title: 'Start a forum thread',
title: 'Start a forum thread',
why: 'Every thread on the forum started as somebody\'s question.',
why: 'Every thread on the forum started as somebody\'s question.',
steps: [{ event_type: 'community.thread.created', count: 1, label: 'Start a thread' }],
steps: [{ event_type: 'community.thread.created', count: 1, label: 'Start a thread' }],
- reward: { badge: { id: 'thread-starter', name: 'Thread Starter' }, coins: 10 },
+ reward: { badge: { id: 'thread-starter', name: 'Thread Starter' }, coins: 10, item: { issuer: 'service:live', alias: 'fx_ice', name: 'Ice Name', kind: 'Name effect', emoji: '❄️' } },
repeatable: false,
repeatable: false,
},
},
{
{
@@ -96,7 +98,7 @@ const QUESTS = Object.freeze([
@@ -96,7 +98,7 @@ const QUESTS = Object.freeze([
title: 'Say hello in chat',
title: 'Say hello in chat',
why: 'A public room is the fastest way to meet the people watching the same stream.',
why: 'A public room is the fastest way to meet the people watching the same stream.',
steps: [{ event_type: 'chat.room.message.created', count: 1, label: 'Send a message in a public room' }],
steps: [{ event_type: 'chat.room.message.created', count: 1, label: 'Send a message in a public room' }],
- reward: { badge: { id: 'said-hello', name: 'Said Hello' }, coins: 5 },
+ reward: { badge: { id: 'said-hello', name: 'Said Hello' }, coins: 5, item: { issuer: 'service:live', alias: 'px_sparkle', name: 'Sparkle', kind: 'Particle effect', emoji: '✨' } },
repeatable: false,
repeatable: false,
},
},
{
{
@@ -105,7 +107,7 @@ const QUESTS = Object.freeze([
@@ -105,7 +107,7 @@ const QUESTS = Object.freeze([
title: 'Improve a wiki page',
title: 'Improve a wiki page',
why: 'One corrected sentence is a page someone else reads correctly.',
why: 'One corrected sentence is a page someone else reads correctly.',
steps: [{ event_type: 'wiki.revision.created', count: 1, label: 'Save a revision' }],
steps: [{ event_type: 'wiki.revision.created', count: 1, label: 'Save a revision' }],
- reward: { badge: { id: 'wiki-editor', name: 'Wiki Editor' }, coins: 15 },
+ reward: { badge: { id: 'wiki-editor', name: 'Wiki Editor' }, coins: 15, item: { issuer: 'service:live', alias: 'fx_rainbow', name: 'Rainbow Name', kind: 'Name effect', emoji: '🌈' } },
repeatable: false,
repeatable: false,
},
},
{
{
new file mode 100644
new file mode 100644
@@ -0,0 +1,129 @@
@@ -0,0 +1,129 @@
+'use strict';
+
+/**
+ * Item rewards: granting a completion's item in OpenVibe.Inventory (plan T21).
+ *
+ * Some quests give one of Live's items (catalog.js reward.item: Live's alias, the name and emoji the pages show).
+ * Live is the item's issuer and names Quest a grantor of it (ADR-054 §3 amendment, Contracts 0.123.0), so Quest
+ * grants it itself: POST /api/v1/grants with origin `earned`, the completion id (qcp_…) as the idempotency key and
+ * the quest as the reason. A retry of a grant that did land answers the same instance, so nobody gets two. Nothing
+ * is bought, sold or traded, and an item is never money.
+ *
+ * Auth: this service's own client credentials (`quest`) through openvibe-contracts' cached token client, for
+ * audience openvibe.inventory and inventory.item.grant. Inventory's definition ids are read once by alias
+ * (GET /api/v1/definitions?issuer=service:live, public) and kept for an hour.
+ *
+ * Like OpenCoins, a grant runs after the completion commits: an Inventory outage never holds up a delivery or rolls
+ * back a completion. A failure is recorded (item_state 'failed' with a short reason) and the retry timer comes back
+ * to it; the timer also picks up completions from before this release (item_state NULL), so everyone who already
+ * finished a quest gets its item. QUEST_ITEMS=off turns all of it off.
+ */
+const { serviceAuth } = require('openvibe-contracts');
+const store = require('./store');
+const catalog = require('./catalog');
+
+const AUDIENCE = 'openvibe.inventory';
+const SCOPES = 'inventory.item.grant';
+const TIMEOUT_MS = 5000;
+const DEFS_TTL_MS = 60 * 60 * 1000;
+
+function createItems({ config, s, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() }) {
+ const enabled = config.items.mode === 'on';
+ const base = String(config.items.inventoryUrl || '').replace(/\/+$/, '');
+ const networkBase = String(config.networkInternalUrl || '').replace(/\/+$/, '');
+ const tokens = serviceAuth.createTokenClient({
+ tokenUrl: `${networkBase}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: AUDIENCE,
+ scope: SCOPES,
+ fetchImpl,
+ });
+ const rewarded = catalog.QUESTS.filter((q) => q.reward && q.reward.item).map((q) => q.id);
+ let defs = null; // { at, byAlias: Map(alias → itd_ id) }
+ let timer = null;
+ const stats = { granted: 0, failed: 0, attempts: 0, last_at: null };
+
+ async function call(method, path, { body, auth = false } = {}) {
+ const headers = { Accept: 'application/json', ...(auth ? await tokens.authHeaders() : {}) };
+ if (body) headers['Content-Type'] = 'application/json';
+ const res = await fetchImpl(`${base}/api/v1${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(TIMEOUT_MS) });
+ const json = await res.json().catch(() => null);
+ if (!res.ok) {
+ // Inventory's own problem code (inventory.not_issuer, inventory.not_published), never the token.
+ const err = new Error(`Inventory ${res.status}${json && json.code ? `: ${json.code}` : ''}`);
+ err.status = res.status;
+ throw err;
+ }
+ return json || {};
+ }
+
+ /** The itd_ id of one of an issuer's items, by its alias (Live's item id). */
+ async function definitionId(issuer, alias) {
+ if (!defs || now() - defs.at > DEFS_TTL_MS || !defs.byAlias.has(`${issuer}|${alias}`)) {
+ const out = await call('GET', `/definitions?issuer=${encodeURIComponent(issuer)}&limit=500`);
+ const byAlias = new Map();
+ for (const d of out.definitions || []) for (const a of d.aliases || []) byAlias.set(`${d.issuer}|${a}`, d.id);
+ defs = { at: now(), byAlias };
+ }
+ return defs.byAlias.get(`${issuer}|${alias}`) || null;
+ }
+
+ /** Grant one completion's item; → the instance id. Throws with a short, safe reason. */
+ async function grant(completion) {
+ const quest = catalog.byId(completion.quest_id);
+ const item = quest && quest.reward.item;
+ if (!item) throw new Error(`no item reward for ${completion.quest_id}`);
+ const id = await definitionId(item.issuer, item.alias);
+ if (!id) throw new Error(`OpenVibe.Inventory has no ${item.alias} from ${item.issuer}`);
+ const out = await call('POST', '/grants', {
+ auth: true,
+ body: { definition_id: id, subject: completion.subject, idempotency_key: completion.id, origin: 'earned', reason: `Quest: ${quest.title}`.slice(0, 200) },
+ });
+ return out.instance && out.instance.id;
+ }
+
+ /** Decide one completion's item now: grant it, or record the failure for the retry timer. Never throws. */
+ async function settle(completion) {
+ if (!enabled || !completion) return null;
+ const quest = catalog.byId(completion.quest_id);
+ if (!quest || !quest.reward.item) return null;
+ if (completion.item_state === 'granted') return completion;
+ stats.attempts++;
+ try {
+ const instance = await grant(completion);
+ stats.granted++; stats.last_at = new Date(now()).toISOString();
+ return store.setItem(s.db, completion.id, { state: 'granted', instance, error: null, at: new Date(now()).toISOString(), attempts: 1 });
+ } catch (err) {
+ stats.failed++;
+ const message = String((err && err.message) || err).slice(0, 200);
+ log.warn(`[Items] ${completion.id} (${completion.quest_id}) not granted: ${message}`);
+ return store.setItem(s.db, completion.id, { state: 'failed', error: message, attempts: 1 });
+ }
+ }
+
+ /** Come back to every completion whose item is still owed: a failure, or a completion from before items. */
+ async function retryPending({ limit = 50 } = {}) {
+ if (!enabled) return { tried: 0, granted: 0, failed: 0 };
+ const rows = await store.owedItems(s.db, rewarded, limit);
+ const out = { tried: rows.length, granted: 0, failed: 0 };
+ for (const row of rows) {
+ const after = await settle(row);
+ if (after && after.item_state === 'granted') out.granted++; else if (after) out.failed++;
+ }
+ if (rows.length) log.log(`[Items] retried ${rows.length}: ${out.granted} granted, ${out.failed} still failing`);
+ return out;
+ }
+
+ function start({ intervalMs = config.items.retryMs } = {}) {
+ if (!enabled || timer) return null;
+ timer = setInterval(() => { retryPending().catch((err) => log.warn('[Items] retry failed:', err.message)); }, intervalMs);
+ if (timer.unref) timer.unref();
+ return timer;
+ }
+ function stop() { if (timer) clearInterval(timer); timer = null; }
+
+ return { enabled, settle, retryPending, start, stop, grant, status: () => ({ enabled, ...stats }) };
+}
+
+module.exports = { createItems, AUDIENCE, SCOPES };
@@ -102,6 +102,21 @@ const creditedAll = async (db, day) => Number(await db.value(
@@ -102,6 +102,21 @@ const creditedAll = async (db, day) => Number(await db.value(
WHERE coins_state = 'credited' AND coins_at IS NOT NULL AND left(coins_at, 10) = $1::text`,
WHERE coins_state = 'credited' AND coins_at IS NOT NULL AND left(coins_at, 10) = $1::text`,
[day]));
[day]));
+/** Record what happened to a completion's item reward. */
+const setItem = (db, id, { state, instance = null, error = null, at = null, attempts = 0 }) => db.maybe(
+ `UPDATE quest_completions
+ SET item_state = $2, item_instance = COALESCE($3, item_instance), item_error = $4, item_at = $5,
+ item_attempts = item_attempts + $6
+ WHERE id = $1
+ RETURNING *`,
+ [id, state, instance, error, at, attempts]);
+
+/** Completions of the given quests whose item is still owed (never decided, or failed), oldest first. */
+const owedItems = (db, questIds, limit = 50) => (questIds.length ? db.many(
+ `SELECT * FROM quest_completions WHERE quest_id = ANY($1::text[]) AND (item_state IS NULL OR item_state = 'failed')
+ ORDER BY completed_at, id LIMIT $2`,
+ [questIds, limit]) : Promise.resolve([]));
+
/** How many people have a badge — the count a quest page can show without naming anyone. */
/** How many people have a badge — the count a quest page can show without naming anyone. */
const badgeHolders = async (db, badgeId) => Number(await db.value('SELECT count(*) FROM quest_badges WHERE badge_id = $1', [badgeId]));
const badgeHolders = async (db, badgeId) => Number(await db.value('SELECT count(*) FROM quest_badges WHERE badge_id = $1', [badgeId]));
@@ -111,4 +126,5 @@ module.exports = {
@@ -111,4 +126,5 @@ module.exports = {
completion, completionsFor, completionCount, complete,
completion, completionsFor, completionCount, complete,
awardBadge, badgesFor, badgesOf, badgeHolders,
awardBadge, badgesFor, badgesOf, badgeHolders,
setCoins, owedCoins, creditedTo, creditedAll,
setCoins, owedCoins, creditedTo, creditedAll,
+ setItem, owedItems,
};
};
@@ -9,15 +9,18 @@ const catalog = require('../quests/catalog');
@@ -9,15 +9,18 @@ const catalog = require('../quests/catalog');
const { html } = require('./html');
const { html } = require('./html');
/**
/**
- * What a quest pays: its badge (a text label) and its OpenCoins. When OpenCoins are off the reward says so
+ * What a quest pays: its badge (a text label), for some an item (OpenVibe.Inventory) and its OpenCoins. When OpenCoins are off the reward says so
* instead of promising points that do not move — the sentence the product uses everywhere.
* instead of promising points that do not move — the sentence the product uses everywhere.
*/
*/
function reward(quest, { coinsEnabled = false } = {}) {
function reward(quest, { coinsEnabled = false } = {}) {
const badge = quest.reward.badge.name;
const badge = quest.reward.badge.name;
const badgeChip = html`<span class="reward-badge">Badge: <strong>${badge}</strong></span>`;
const badgeChip = html`<span class="reward-badge">Badge: <strong>${badge}</strong></span>`;
- if (quest.reward.coins <= 0) return html`<span class="reward">${badgeChip}</span>`;
- if (!coinsEnabled) return html`<span class="reward">${badgeChip}<span class="reward-coins soon">OpenCoins rewards start soon</span></span>`;
- return html`<span class="reward">${badgeChip}<span class="reward-coins"><strong>${quest.reward.coins}</strong> OpenCoins</span></span>`;
+ // An item for the person's OpenVibe.Inventory (server/quests/items.js), worn in chat and on their profile.
+ const it = quest.reward.item;
+ const itemChip = it ? html`<span class="reward-item" title="${it.kind} for your OpenVibe inventory"><span aria-hidden="true">${it.emoji}</span> <strong>${it.name}</strong> <small>${it.kind.toLowerCase()}</small></span>` : '';
+ if (quest.reward.coins <= 0) return html`<span class="reward">${badgeChip}${itemChip}</span>`;
+ if (!coinsEnabled) return html`<span class="reward">${badgeChip}${itemChip}<span class="reward-coins soon">OpenCoins rewards start soon</span></span>`;
+ return html`<span class="reward">${badgeChip}${itemChip}<span class="reward-coins"><strong>${quest.reward.coins}</strong> OpenCoins</span></span>`;
}
}
/** A badge's colour, fixed by its quest id, so a badge looks the same wherever it is shown. */
/** A badge's colour, fixed by its quest id, so a badge looks the same wherever it is shown. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,132 @@
@@ -0,0 +1,132 @@
+'use strict';
+/**
+ * Item rewards (server/quests/items.js, plan T21): six quests also give one of Live's items, granted in a stand-in
+ * OpenVibe.Inventory with Quest's own token (audience openvibe.inventory, inventory.item.grant) after the completion
+ * commits: origin earned, the completion id as the idempotency key, the quest as the reason. A refusal is recorded
+ * and retried; a completion from before items is granted by the retry; a quest without an item asks nothing;
+ * QUEST_ITEMS=off asks nothing at all. The pages and the API name the item.
+ */
+const assert = require('assert');
+const http = require('http');
+const { ids } = require('openvibe-contracts');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const { boot, check, done } = require('./helpers/boot');
+
+const SECRET = 'quest-events-secret-0123456789abcdef';
+const SPARKLE = 'itd_01JZ00000000000000000001S1';
+const HEARTS = 'itd_01JZ00000000000000000001H1';
+const iso = () => new Date().toISOString();
+const chatMessage = (user_subject) => ({
+ event_id: ids.newId('event'), event_type: 'chat.room.message.created', version: 1, source: 'chat',
+ actor: { type: 'user', id: user_subject }, timestamp: iso(), subject: { type: 'thing', id: '1' },
+ payload: { message_id: 12, room: { id: 'room_1', slug: 'general', name: 'General' }, text: 'hello', created_at: iso(), user_subject, username: 'liv', display_name: 'Liv' },
+});
+const deliver = (t, event) => {
+ const raw = JSON.stringify({ event, seq: 1 });
+ return t.get('/internal/events', { method: 'POST', body: raw, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, SECRET) } });
+};
+const claims = (auth) => JSON.parse(Buffer.from(String(auth).replace(/^Bearer /, '').split('.')[1], 'base64url').toString('utf8'));
+
+function stubInventory() {
+ const st = { grants: [], down: false, instances: 0 };
+ const server = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ const json = (status, o) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
+ const u = new URL(req.url, 'http://x');
+ if (u.pathname === '/api/v1/definitions') return json(200, { definitions: [{ id: SPARKLE, issuer: 'service:live', aliases: ['px_sparkle'] }, { id: HEARTS, issuer: 'service:live', aliases: ['px_hearts'] }] });
+ if (u.pathname === '/api/v1/grants' && req.method === 'POST') {
+ if (st.down) return json(503, { code: 'unavailable' });
+ const body = JSON.parse(raw);
+ st.grants.push({ body, auth: req.headers.authorization });
+ const prior = st.grants.find((g) => g.body.idempotency_key === body.idempotency_key && g.instance);
+ const instance = prior ? prior.instance : `inv_01JZ00000000000000000000${String(++st.instances).padStart(2, '0')}`;
+ st.grants[st.grants.length - 1].instance = instance;
+ return json(prior ? 200 : 201, { instance: { id: instance, definition_id: body.definition_id, owner: body.subject, origin: body.origin, state: 'owned' }, created: !prior });
+ }
+ return json(404, { code: 'route.not_found' });
+ });
+ });
+ return new Promise((r) => server.listen(0, '127.0.0.1', () => r({ st, url: `http://127.0.0.1:${server.address().port}`, close: () => server.close() })));
+}
+
+(async () => {
+ const inv = await stubInventory();
+ try {
+ await check('saying hello in chat gives the Sparkle: earned, keyed by the completion, with Quest\'s token', async () => {
+ const t = await boot({ env: { QUEST_EVENTS_SECRET: SECRET, OV_INVENTORY_INTERNAL_URL: inv.url } });
+ try {
+ const u = t.network.addUser('liv');
+ const r = await deliver(t, chatMessage(u.subject));
+ assert.strictEqual(r.status, 200, r.text);
+ const c = (await t.ctx.s.db.many("SELECT * FROM quest_completions WHERE subject = $1 AND quest_id = 'say-hello-in-chat'", [u.subject]))[0];
+ assert.ok(c, 'the quest completed');
+ const g = inv.st.grants.find((x) => x.body.idempotency_key === c.id);
+ assert.deepStrictEqual(g.body, { definition_id: SPARKLE, subject: u.subject, idempotency_key: c.id, origin: 'earned', reason: 'Quest: Say hello in chat' });
+ const tok = claims(g.auth);
+ assert.deepStrictEqual([tok.sub, tok.aud, tok.cap], ['svc:quest', ['openvibe.inventory'], ['inventory.item.grant']]);
+ assert.deepStrictEqual([c.item_state, c.item_instance], ['granted', g.instance]);
+ // The person's page says it is in their inventory.
+ const me = await t.get('/me', { headers: { cookie: t.signIn(u) } });
+ assert.ok(me.text.includes('Sparkle') && me.text.includes('your inventory'), 'the /me page names the item');
+ } finally { await t.close(); }
+ });
+
+ await check('a refused grant is recorded and retried; a completion from before items is granted by the retry; no item, no call', async () => {
+ const t = await boot({ env: { QUEST_EVENTS_SECRET: SECRET, OV_INVENTORY_INTERNAL_URL: inv.url } });
+ try {
+ const u = t.network.addUser('ana');
+ inv.st.down = true;
+ await deliver(t, chatMessage(u.subject));
+ let c = (await t.ctx.s.db.many("SELECT * FROM quest_completions WHERE subject = $1 AND quest_id = 'say-hello-in-chat'", [u.subject]))[0];
+ assert.strictEqual(c.item_state, 'failed');
+ assert.match(c.item_error, /Inventory 503/);
+ inv.st.down = false;
+ // A completion recorded before this release: item_state NULL.
+ const old = 'qcp_01JZ0000000000000000000012';
+ await t.ctx.s.db.exec("INSERT INTO quest_completions (id, subject, quest_id, completed_at, coins, coins_state) VALUES ($1, $2, 'follow-someone', $3, 5, 'skipped')", [old, u.subject, iso()]);
+ const before = inv.st.grants.length;
+ const out = await t.ctx.items.retryPending();
+ assert.deepStrictEqual(out, { tried: 2, granted: 2, failed: 0 });
+ c = (await t.ctx.s.db.many("SELECT * FROM quest_completions WHERE subject = $1 AND quest_id = 'say-hello-in-chat'", [u.subject]))[0];
+ assert.strictEqual(c.item_state, 'granted');
+ const hearts = inv.st.grants.slice(before).find((g) => g.body.idempotency_key === old);
+ assert.strictEqual(hearts.body.definition_id, HEARTS, 'the earlier finisher gets their Hearts');
+ assert.deepStrictEqual(await t.ctx.items.retryPending(), { tried: 0, granted: 0, failed: 0 }, 'nothing owed now');
+ // A quest that gives no item is never asked about.
+ const none = 'qcp_01JZ0000000000000000000034';
+ await t.ctx.s.db.exec("INSERT INTO quest_completions (id, subject, quest_id, completed_at, coins, coins_state) VALUES ($1, $2, 'reply-in-a-thread', $3, 5, 'skipped')", [none, u.subject, iso()]);
+ assert.deepStrictEqual(await t.ctx.items.retryPending(), { tried: 0, granted: 0, failed: 0 });
+ } finally { await t.close(); }
+ });
+
+ await check('QUEST_ITEMS=off: nothing is granted, nothing retried, no timer', async () => {
+ const t = await boot({ env: { QUEST_EVENTS_SECRET: SECRET, OV_INVENTORY_INTERNAL_URL: inv.url, QUEST_ITEMS: 'off' } });
+ try {
+ const u = t.network.addUser('off');
+ const before = inv.st.grants.length;
+ await deliver(t, chatMessage(u.subject));
+ assert.strictEqual(inv.st.grants.length, before);
+ assert.deepStrictEqual(await t.ctx.items.retryPending(), { tried: 0, granted: 0, failed: 0 });
+ assert.strictEqual(t.ctx.items.start(), null);
+ } finally { await t.close(); }
+ });
+
+ await check('the quest pages and the API name the item', async () => {
+ const t = await boot({ env: { OV_INVENTORY_INTERNAL_URL: inv.url } });
+ try {
+ const page = await t.get('/quests/say-hello-in-chat');
+ assert.ok(page.text.includes('class="reward-item"') && page.text.includes('Sparkle'), 'the reward chip');
+ const api = (await t.get('/api/v1/quests')).json();
+ const q = (api.quests || api).find((x) => x.id === 'say-hello-in-chat');
+ assert.deepStrictEqual(q.reward.item, { issuer: 'service:live', alias: 'px_sparkle', name: 'Sparkle', kind: 'Particle effect' });
+ const none = (api.quests || api).find((x) => x.id === 'reply-in-a-thread');
+ assert.strictEqual(none.reward.item, undefined);
+ } finally { await t.close(); }
+ });
+ } finally {
+ inv.close();
+ }
+ done();
+})();
@@ -35,7 +35,7 @@ const { render } = require('../server/render/html');
@@ -35,7 +35,7 @@ const { render } = require('../server/render/html');
const goLive = body.quests.find((q) => q.id === 'go-live');
const goLive = body.quests.find((q) => q.id === 'go-live');
assert.strictEqual(goLive.title, 'Go live once');
assert.strictEqual(goLive.title, 'Go live once');
assert.deepStrictEqual(goLive.steps, [{ event_type: 'live.stream.started', count: 1, label: 'Start a stream' }]);
assert.deepStrictEqual(goLive.steps, [{ event_type: 'live.stream.started', count: 1, label: 'Start a stream' }]);
- assert.deepStrictEqual(goLive.reward, { badge: { id: 'first-broadcast', name: 'First Broadcast' }, coins: 10 });
+ assert.deepStrictEqual(goLive.reward, { badge: { id: 'first-broadcast', name: 'First Broadcast' }, coins: 10, item: { issuer: 'service:live', alias: 'hat_basic_cap', name: 'Basic Cap', kind: 'Hat' } });
assert.strictEqual(goLive.repeatable, false);
assert.strictEqual(goLive.repeatable, false);
assert.strictEqual(body.coins.enabled, false, 'OpenCoins are off by default');
assert.strictEqual(body.coins.enabled, false, 'OpenCoins are off by default');
assert.strictEqual(body.coins.daily_cap, 50);
assert.strictEqual(body.coins.daily_cap, 50);