openvibers/openvibe.quest Commits
Quest: quests on Blog, Deals, Run and Inventory (publish a post, share a deal, vote on ten, run a job, wear an item) (#53)
Changes
@@@ -87,7 +87,7 @@ own** and produces no events; no `/api/v1` route is guarded by one ([server/http| Piece | Where | What it does ||---|---|---|--| Catalog | [server/quests/catalog.js](server/quests/catalog.js) | 12 first-party quests in code, one per real thing a person can do, plus the whole list of events Quest listens to (and the one it does not). A quest may not count an event the catalog does not document: that fails at load |++| Catalog | [server/quests/catalog.js](server/quests/catalog.js) | 35 first-party quests in code (the 18 of the Live setup series among them), one per real thing a person can do, plus the whole list of events Quest listens to (and the one it does not). A quest may not count an event the catalog does not document: that fails at load || 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 |@@@ -129,6 +129,10 @@ OpenVibe.Events delivers an event when a site really does something. Quest count| `games.player.joined` | OpenVibe.Games | the player's account subject (a guest is ignored) || `tools.job.created` | OpenVibe.Tools | the owner (an anonymous session is ignored) || `live.setup.task_completed` | OpenVibe.Live | `payload.user`, the streamer who finished a task on Live's go-live setup list |++| `blog.post.published` | OpenVibe.Blog | the envelope actor (a schedule or a service publishing is nobody) |++| `deals.offer.created` / `deals.vote.changed` | OpenVibe.Deals | the envelope actor: the submitter or the voter (an imported deal's actor is `svc:deals`) |++| `run.job.succeeded` | OpenVibe.Run | `payload.requester` when it is a user (an app's or a service's job is nobody's) |++| `inventory.item.equipped` | OpenVibe.Inventory | `payload.owner` |A step can also name payload values to `match` (`{ task: 'emote' }`): the "Set up your channel" series is onequest per Live setup task, plus one that counts all 18 tasks. The home page shows a series as one card; its page is@@@ -142,7 +146,7 @@ coins are recorded once; a redelivery of the same event, or the whole request ag| **OpenVibe.Events** (`openvibe.events`) | the domain events that move the log, `POST /internal/events`, loopback only | Signed deliveries only: `X-OpenVibe-Signature-V2` (HMAC-SHA256 over `<timestamp>.<raw body>`), a 300-second replay window, `requireV2`, the secret from `QUEST_EVENTS_SECRET` (32+ characters, comma-separated for rotation). A delivery that carries a forwarding header is refused: nginx never proxies this route. Once-only application is the `openvibe-sdk` inbox, in the same transaction as the writes || **openvibe-contracts** v0.190.0 | the shape of every event and of the two Network calls | Every field read is the one the schema at `contracts/events/payloads/<type>.v1.json` defines; no field is inferred and nothing about a person is taken from a name or from the request || **OpenVibe.Network** (`openvibe.network`) | resolving a subject to its wallet id, and crediting OpenCoins | `GET /internal/identity/resolve` (capability `identity.subject.resolve`, `identity.resolve-result@1`) gives `network_user_id`; `POST /internal/coins/credit` (capability `network.coins.credit`, body `network.coins-change-request@1`, answer `network.coins-balance-result@1`) credits, with `app_id: quest` — a service token may only name its own app id. Both use this service's own client credentials over the internal URL; no token is logged |--| **OpenVibe sites** (Live, Community, Space, Chat, Wiki, Games, Tools, Network) | nothing is fetched from them | Quest makes no outbound request to any site, and no URL from a request ever decides one: the only host it calls is `OV_NETWORK_INTERNAL_URL` |++| **OpenVibe sites** (Live, Community, Space, Chat, Wiki, Games, Tools, Network, Blog, Deals, Run, Inventory) | nothing is fetched from them | Quest makes no outbound request to any site, and no URL from a request ever decides one: the only host it calls is `OV_NETWORK_INTERNAL_URL` |Rewards are badges and OpenCoins. Nothing here is money, and nothing here is Vibes.@@@ -55,6 +55,14 @@ function personOf(event) {return refUser(p.owner);case 'live.setup.task_completed': // payload.user (the streamer; the envelope subject and actor are the same person)return refUser(p.user);++ case 'blog.post.published': // the envelope actor: the person who published (a schedule or a service is not one)++ case 'deals.offer.created': // the envelope actor: the submitter (svc:deals for an imported deal)++ case 'deals.vote.changed': // the envelope actor: the voter++ return refUser(event.actor);++ case 'run.job.succeeded': // payload.requester (identity.subject-ref@1: a user, service, app or mod)++ return refUser(p.requester);++ case 'inventory.item.equipped': // payload.owner (usr_…)++ return isUser(p.owner) ? p.owner : null;default:return null;}@@@ -37,6 +37,10 @@ const SITES = Object.freeze({'openvibe.games': { name: 'OpenVibe.Games', what: 'the games platform' },'openvibe.tools': { name: 'OpenVibe.Tools', what: 'the tools' },'openvibe.space': { name: 'OpenVibe.Space', what: 'forums in a Space' },++ 'openvibe.blog': { name: 'OpenVibe.Blog', what: 'blogs' },++ 'openvibe.deals': { name: 'OpenVibe.Deals', what: 'deals the community shares and votes on' },++ 'openvibe.run': { name: 'OpenVibe.Run', what: 'jobs run on nodes' },++ 'inventory.openvibe.network': { name: 'OpenVibe.Inventory', what: 'the items you own across OpenVibe' },});/**@@@ -155,6 +159,51 @@ const QUESTS = Object.freeze([reward: { badge: { id: 'space-thread-starter', name: 'Space Thread Starter' }, coins: 10 },repeatable: false,},++ {++ id: 'publish-a-blog-post',++ site: 'openvibe.blog',++ title: 'Publish a blog post',++ why: 'Every member gets a blog on OpenVibe.Blog; a first post makes it yours.',++ steps: [{ event_type: 'blog.post.published', count: 1, label: 'Publish a post on your blog' }],++ reward: { badge: { id: 'first-post', name: 'First Post' }, coins: 15 },++ repeatable: false,++ },++ {++ id: 'share-a-deal',++ site: 'openvibe.deals',++ title: 'Share a deal',++ why: 'A deal is only worth something when somebody posts it with its price and where it was seen.',++ steps: [{ event_type: 'deals.offer.created', count: 1, label: 'Submit a deal' }],++ reward: { badge: { id: 'deal-spotter', name: 'Deal Spotter' }, coins: 10 },++ repeatable: false,++ },++ {++ id: 'vote-on-deals',++ site: 'openvibe.deals',++ title: 'Vote on ten deals',++ why: 'Votes decide what is hot on OpenVibe.Deals, one person, one vote.',++ steps: [{ event_type: 'deals.vote.changed', count: 10, label: 'Vote a deal up or down' }],++ reward: { badge: { id: 'deal-judge', name: 'Deal Judge' }, coins: 10 },++ repeatable: false,++ },++ {++ id: 'run-a-job',++ site: 'openvibe.run',++ title: 'Run a job on a node',++ why: 'OpenVibe.Run sends a job to a machine that can do it, yours or the network\'s.',++ steps: [{ event_type: 'run.job.succeeded', count: 1, label: 'Run a job that finishes' }],++ reward: { badge: { id: 'job-runner', name: 'Job Runner' }, coins: 15 },++ repeatable: false,++ },++ {++ id: 'wear-an-item',++ site: 'inventory.openvibe.network',++ title: 'Wear an item',++ why: 'Hats, name effects and particles you earn show on every OpenVibe site once you wear them.',++ steps: [{ event_type: 'inventory.item.equipped', count: 1, label: 'Equip an item from your inventory' }],++ reward: { badge: { id: 'dressed-up', name: 'Dressed Up' }, coins: 5 },++ repeatable: false,++ },{id: 'settle-in',site: 'openvibe.network',@@@ -373,6 +422,11 @@ const EVENTS = Object.freeze([{ event_type: 'games.player.joined', site: 'openvibe.games', what: 'A character entered a world; the payload names its account\'s user subject (a guest is ignored).' },{ event_type: 'tools.job.created', site: 'openvibe.tools', what: 'A tool job was accepted and queued; the payload names its owner (an anonymous session is ignored).' },{ event_type: 'live.setup.task_completed', site: 'openvibe.live', what: 'A streamer finished a task on Live\'s go-live setup list for the first time; the payload names the streamer and the task, never what they made.' },++ { event_type: 'blog.post.published', site: 'openvibe.blog', what: 'A post was published; the envelope actor is the person who published it (a scheduled or service publish is ignored).' },++ { event_type: 'deals.offer.created', site: 'openvibe.deals', what: 'A deal was submitted; the envelope actor is the person who posted it (deals imported from a source are ignored).' },++ { event_type: 'deals.vote.changed', site: 'openvibe.deals', what: 'A vote on a deal was cast or changed; the envelope actor is the voter.' },++ { event_type: 'run.job.succeeded', site: 'openvibe.run', what: 'A job finished on a node; the payload names its requester (a service or app requester is ignored).' },++ { event_type: 'inventory.item.equipped', site: 'inventory.openvibe.network', what: 'An item was equipped; the payload names its owner, never what else they own.' },]);// The events Quest actually counts: the consumer subscribes to exactly these, and a quest may not count an event@@@ -175,6 +175,27 @@ const progressOf = async (t, subject, questId) => (await rows(t, 'SELECT step, cassert.ok(quest.text.includes('<code>task</code> <code>emote</code>'), 'the quest page says which task it counts');});++ await check('the Blog, Deals, Run and Inventory quests: each event credits its person; a service never does', async () => {++ const done = async () => (await completions(t, sam.subject)).map((c) => c.quest_id);++ // A post published by a schedule or a service is nobody's.++ await deliver(t, envelope('blog.post.published', { canonical_url: 'https://openvibe.blog/@sam/hi' }, { source: 'blog', actor: { type: 'service', id: 'blog' } }));++ assert.ok(!(await done()).includes('publish-a-blog-post'));++ await deliver(t, envelope('blog.post.published', { canonical_url: 'https://openvibe.blog/@sam/hi' }, { source: 'blog', actor: { type: 'user', id: sam.subject } }));++ assert.ok((await done()).includes('publish-a-blog-post'));++ await deliver(t, envelope('deals.offer.created', { offer_id: 'off_2' }, { source: 'deals', actor: { type: 'user', id: sam.subject } }));++ assert.ok((await done()).includes('share-a-deal'));++ for (let i = 0; i < 9; i++) await deliver(t, envelope('deals.vote.changed', { offer_id: `off_${i}`, value: 1 }, { source: 'deals', actor: { type: 'user', id: sam.subject } }));++ assert.ok(!(await done()).includes('vote-on-deals'), 'nine votes are not ten');++ await deliver(t, envelope('deals.vote.changed', { offer_id: 'off_9', value: -1 }, { source: 'deals', actor: { type: 'user', id: sam.subject } }));++ assert.ok((await done()).includes('vote-on-deals'));++ await deliver(t, envelope('run.job.succeeded', { job_id: 'job_1', requester: { type: 'app', id: 'app_x' } }, { source: 'run' }));++ assert.ok(!(await done()).includes('run-a-job'), 'an app\'s job is nobody\'s');++ await deliver(t, envelope('run.job.succeeded', { job_id: 'job_2', requester: { type: 'user', id: sam.subject } }, { source: 'run' }));++ assert.ok((await done()).includes('run-a-job'));++ await deliver(t, envelope('inventory.item.equipped', { instance_id: 'i', definition_id: 'd', kind: 'hat', slot: 'hat', owner: sam.subject }, { source: 'inventory' }));++ assert.ok((await done()).includes('wear-an-item'));++ });++await check('an event that names nobody creditable is ignored: no subject, a guest, a service, an unknown type', async () => {const before = (await completions(t, sam.subject)).length;const guest = 'gst_' + '0'.repeat(26);@@@ -182,7 +203,7 @@ const progressOf = async (t, subject, questId) => (await rows(t, 'SELECT step, cchatMessage(null), // a private-ish/guest message: no user_subjectchatMessage(guest), // a guest id is not a usr_ subjectenvelope('chat.room.message.created', { message_id: 3, room: { id: 'r', slug: 'g', name: 'G' }, text: 'x', created_at: iso(), user_subject: null }, { source: 'chat' }),-- envelope('deals.vote.changed', { value: 1 }, { source: 'deals', actor: { type: 'user', id: sam.subject } }), // no person in the payload++ envelope('deals.offer.created', { offer_id: 'off_1' }, { source: 'deals', actor: { type: 'service', id: 'deals' } }), // an imported deal: a service, not a personenvelope('quest.something.happened', { user_subject: sam.subject }), // not an event Quest counts]) {const r = await deliver(t, event);@@@ -17,8 +17,9 @@ const BUDGETS = {// Raised 2026-10-08 with the product: the home page now carries the quest catalog (12 quests, grouped by// site, each with its reward) and the list of the events Quest counts. Measured 34.0 KB raw / 7.7 KB brotli.// Raised 2026-10-12 with openvibe-shared 4.1.3: the head reserves the navbar's 52px (120 bytes). Measured 37.6 KB raw.-- htmlRawKB: 41, // measured 37.6 (the home page: hero, features, the quests, the events, cta, JSON-LD)-- htmlBrotliKB: 8.5, // 7.7++ // Raised 2026-10-15: five more quests (Blog, Deals, Run, Inventory) and their events on the home page. Measured 43 KB raw.++ htmlRawKB: 47, // measured 43 (the home page: hero, features, the quests, the events, cta, JSON-LD)++ htmlBrotliKB: 9.8, // 9jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)jsRawKB: 264, // 239.8jsBrotliKB: 62.5, // 56.5@@@ -27,7 +27,7 @@ const { render } = require('../server/render/html');assert.match(r.headers.get('cache-control') || '', /no-store/);const body = r.json();assert.strictEqual(body.quests.length, catalog.all().length);-- assert.ok(body.quests.length >= 8 && body.quests.length <= 32, `${body.quests.length} quests`);++ assert.ok(body.quests.length >= 8 && body.quests.length <= 48, `${body.quests.length} quests`);const setup = body.quests.filter((q) => q.series === 'live-setup');assert.strictEqual(setup.length, 18, 'the Live setup series: a quest per task but go-live, and the whole setup');assert.deepStrictEqual(body.quests.find((q) => q.id === 'live-setup-emote').steps, [{ event_type: 'live.setup.task_completed', count: 1, label: 'Upload a custom emote on OpenVibe.Live', match: { task: 'emote' } }]);
@@ -87,7 +87,7 @@ own** and produces no events; no `/api/v1` route is guarded by one ([server/http
@@ -87,7 +87,7 @@ own** and produces no events; no `/api/v1` route is guarded by one ([server/http
| Piece | Where | What it does |
| Piece | Where | What it does |
|---|---|---|
|---|---|---|
-| Catalog | [server/quests/catalog.js](server/quests/catalog.js) | 12 first-party quests in code, one per real thing a person can do, plus the whole list of events Quest listens to (and the one it does not). A quest may not count an event the catalog does not document: that fails at load |
+| Catalog | [server/quests/catalog.js](server/quests/catalog.js) | 35 first-party quests in code (the 18 of the Live setup series among them), one per real thing a person can do, plus the whole list of events Quest listens to (and the one it does not). A quest may not count an event the catalog does not document: that fails at load |
| 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 |
@@ -129,6 +129,10 @@ OpenVibe.Events delivers an event when a site really does something. Quest count
@@ -129,6 +129,10 @@ OpenVibe.Events delivers an event when a site really does something. Quest count
| `games.player.joined` | OpenVibe.Games | the player's account subject (a guest is ignored) |
| `games.player.joined` | OpenVibe.Games | the player's account subject (a guest is ignored) |
| `tools.job.created` | OpenVibe.Tools | the owner (an anonymous session is ignored) |
| `tools.job.created` | OpenVibe.Tools | the owner (an anonymous session is ignored) |
| `live.setup.task_completed` | OpenVibe.Live | `payload.user`, the streamer who finished a task on Live's go-live setup list |
| `live.setup.task_completed` | OpenVibe.Live | `payload.user`, the streamer who finished a task on Live's go-live setup list |
+| `blog.post.published` | OpenVibe.Blog | the envelope actor (a schedule or a service publishing is nobody) |
+| `deals.offer.created` / `deals.vote.changed` | OpenVibe.Deals | the envelope actor: the submitter or the voter (an imported deal's actor is `svc:deals`) |
+| `run.job.succeeded` | OpenVibe.Run | `payload.requester` when it is a user (an app's or a service's job is nobody's) |
+| `inventory.item.equipped` | OpenVibe.Inventory | `payload.owner` |
A step can also name payload values to `match` (`{ task: 'emote' }`): the "Set up your channel" series is one
A step can also name payload values to `match` (`{ task: 'emote' }`): the "Set up your channel" series is one
quest per Live setup task, plus one that counts all 18 tasks. The home page shows a series as one card; its page is
quest per Live setup task, plus one that counts all 18 tasks. The home page shows a series as one card; its page is
@@ -142,7 +146,7 @@ coins are recorded once; a redelivery of the same event, or the whole request ag
@@ -142,7 +146,7 @@ coins are recorded once; a redelivery of the same event, or the whole request ag
| **OpenVibe.Events** (`openvibe.events`) | the domain events that move the log, `POST /internal/events`, loopback only | Signed deliveries only: `X-OpenVibe-Signature-V2` (HMAC-SHA256 over `<timestamp>.<raw body>`), a 300-second replay window, `requireV2`, the secret from `QUEST_EVENTS_SECRET` (32+ characters, comma-separated for rotation). A delivery that carries a forwarding header is refused: nginx never proxies this route. Once-only application is the `openvibe-sdk` inbox, in the same transaction as the writes |
| **OpenVibe.Events** (`openvibe.events`) | the domain events that move the log, `POST /internal/events`, loopback only | Signed deliveries only: `X-OpenVibe-Signature-V2` (HMAC-SHA256 over `<timestamp>.<raw body>`), a 300-second replay window, `requireV2`, the secret from `QUEST_EVENTS_SECRET` (32+ characters, comma-separated for rotation). A delivery that carries a forwarding header is refused: nginx never proxies this route. Once-only application is the `openvibe-sdk` inbox, in the same transaction as the writes |
| **openvibe-contracts** v0.190.0 | the shape of every event and of the two Network calls | Every field read is the one the schema at `contracts/events/payloads/<type>.v1.json` defines; no field is inferred and nothing about a person is taken from a name or from the request |
| **openvibe-contracts** v0.190.0 | the shape of every event and of the two Network calls | Every field read is the one the schema at `contracts/events/payloads/<type>.v1.json` defines; no field is inferred and nothing about a person is taken from a name or from the request |
| **OpenVibe.Network** (`openvibe.network`) | resolving a subject to its wallet id, and crediting OpenCoins | `GET /internal/identity/resolve` (capability `identity.subject.resolve`, `identity.resolve-result@1`) gives `network_user_id`; `POST /internal/coins/credit` (capability `network.coins.credit`, body `network.coins-change-request@1`, answer `network.coins-balance-result@1`) credits, with `app_id: quest` — a service token may only name its own app id. Both use this service's own client credentials over the internal URL; no token is logged |
| **OpenVibe.Network** (`openvibe.network`) | resolving a subject to its wallet id, and crediting OpenCoins | `GET /internal/identity/resolve` (capability `identity.subject.resolve`, `identity.resolve-result@1`) gives `network_user_id`; `POST /internal/coins/credit` (capability `network.coins.credit`, body `network.coins-change-request@1`, answer `network.coins-balance-result@1`) credits, with `app_id: quest` — a service token may only name its own app id. Both use this service's own client credentials over the internal URL; no token is logged |
-| **OpenVibe sites** (Live, Community, Space, Chat, Wiki, Games, Tools, Network) | nothing is fetched from them | Quest makes no outbound request to any site, and no URL from a request ever decides one: the only host it calls is `OV_NETWORK_INTERNAL_URL` |
+| **OpenVibe sites** (Live, Community, Space, Chat, Wiki, Games, Tools, Network, Blog, Deals, Run, Inventory) | nothing is fetched from them | Quest makes no outbound request to any site, and no URL from a request ever decides one: the only host it calls is `OV_NETWORK_INTERNAL_URL` |
Rewards are badges and OpenCoins. Nothing here is money, and nothing here is Vibes.
Rewards are badges and OpenCoins. Nothing here is money, and nothing here is Vibes.
@@ -55,6 +55,14 @@ function personOf(event) {
@@ -55,6 +55,14 @@ function personOf(event) {
return refUser(p.owner);
return refUser(p.owner);
case 'live.setup.task_completed': // payload.user (the streamer; the envelope subject and actor are the same person)
case 'live.setup.task_completed': // payload.user (the streamer; the envelope subject and actor are the same person)
return refUser(p.user);
return refUser(p.user);
+ case 'blog.post.published': // the envelope actor: the person who published (a schedule or a service is not one)
+ case 'deals.offer.created': // the envelope actor: the submitter (svc:deals for an imported deal)
+ case 'deals.vote.changed': // the envelope actor: the voter
+ return refUser(event.actor);
+ case 'run.job.succeeded': // payload.requester (identity.subject-ref@1: a user, service, app or mod)
+ return refUser(p.requester);
+ case 'inventory.item.equipped': // payload.owner (usr_…)
+ return isUser(p.owner) ? p.owner : null;
default:
default:
return null;
return null;
}
}
@@ -37,6 +37,10 @@ const SITES = Object.freeze({
@@ -37,6 +37,10 @@ const SITES = Object.freeze({
'openvibe.games': { name: 'OpenVibe.Games', what: 'the games platform' },
'openvibe.games': { name: 'OpenVibe.Games', what: 'the games platform' },
'openvibe.tools': { name: 'OpenVibe.Tools', what: 'the tools' },
'openvibe.tools': { name: 'OpenVibe.Tools', what: 'the tools' },
'openvibe.space': { name: 'OpenVibe.Space', what: 'forums in a Space' },
'openvibe.space': { name: 'OpenVibe.Space', what: 'forums in a Space' },
+ 'openvibe.blog': { name: 'OpenVibe.Blog', what: 'blogs' },
+ 'openvibe.deals': { name: 'OpenVibe.Deals', what: 'deals the community shares and votes on' },
+ 'openvibe.run': { name: 'OpenVibe.Run', what: 'jobs run on nodes' },
+ 'inventory.openvibe.network': { name: 'OpenVibe.Inventory', what: 'the items you own across OpenVibe' },
});
});
/**
/**
@@ -155,6 +159,51 @@ const QUESTS = Object.freeze([
@@ -155,6 +159,51 @@ const QUESTS = Object.freeze([
reward: { badge: { id: 'space-thread-starter', name: 'Space Thread Starter' }, coins: 10 },
reward: { badge: { id: 'space-thread-starter', name: 'Space Thread Starter' }, coins: 10 },
repeatable: false,
repeatable: false,
},
},
+ {
+ id: 'publish-a-blog-post',
+ site: 'openvibe.blog',
+ title: 'Publish a blog post',
+ why: 'Every member gets a blog on OpenVibe.Blog; a first post makes it yours.',
+ steps: [{ event_type: 'blog.post.published', count: 1, label: 'Publish a post on your blog' }],
+ reward: { badge: { id: 'first-post', name: 'First Post' }, coins: 15 },
+ repeatable: false,
+ },
+ {
+ id: 'share-a-deal',
+ site: 'openvibe.deals',
+ title: 'Share a deal',
+ why: 'A deal is only worth something when somebody posts it with its price and where it was seen.',
+ steps: [{ event_type: 'deals.offer.created', count: 1, label: 'Submit a deal' }],
+ reward: { badge: { id: 'deal-spotter', name: 'Deal Spotter' }, coins: 10 },
+ repeatable: false,
+ },
+ {
+ id: 'vote-on-deals',
+ site: 'openvibe.deals',
+ title: 'Vote on ten deals',
+ why: 'Votes decide what is hot on OpenVibe.Deals, one person, one vote.',
+ steps: [{ event_type: 'deals.vote.changed', count: 10, label: 'Vote a deal up or down' }],
+ reward: { badge: { id: 'deal-judge', name: 'Deal Judge' }, coins: 10 },
+ repeatable: false,
+ },
+ {
+ id: 'run-a-job',
+ site: 'openvibe.run',
+ title: 'Run a job on a node',
+ why: 'OpenVibe.Run sends a job to a machine that can do it, yours or the network\'s.',
+ steps: [{ event_type: 'run.job.succeeded', count: 1, label: 'Run a job that finishes' }],
+ reward: { badge: { id: 'job-runner', name: 'Job Runner' }, coins: 15 },
+ repeatable: false,
+ },
+ {
+ id: 'wear-an-item',
+ site: 'inventory.openvibe.network',
+ title: 'Wear an item',
+ why: 'Hats, name effects and particles you earn show on every OpenVibe site once you wear them.',
+ steps: [{ event_type: 'inventory.item.equipped', count: 1, label: 'Equip an item from your inventory' }],
+ reward: { badge: { id: 'dressed-up', name: 'Dressed Up' }, coins: 5 },
+ repeatable: false,
+ },
{
{
id: 'settle-in',
id: 'settle-in',
site: 'openvibe.network',
site: 'openvibe.network',
@@ -373,6 +422,11 @@ const EVENTS = Object.freeze([
@@ -373,6 +422,11 @@ const EVENTS = Object.freeze([
{ event_type: 'games.player.joined', site: 'openvibe.games', what: 'A character entered a world; the payload names its account\'s user subject (a guest is ignored).' },
{ event_type: 'games.player.joined', site: 'openvibe.games', what: 'A character entered a world; the payload names its account\'s user subject (a guest is ignored).' },
{ event_type: 'tools.job.created', site: 'openvibe.tools', what: 'A tool job was accepted and queued; the payload names its owner (an anonymous session is ignored).' },
{ event_type: 'tools.job.created', site: 'openvibe.tools', what: 'A tool job was accepted and queued; the payload names its owner (an anonymous session is ignored).' },
{ event_type: 'live.setup.task_completed', site: 'openvibe.live', what: 'A streamer finished a task on Live\'s go-live setup list for the first time; the payload names the streamer and the task, never what they made.' },
{ event_type: 'live.setup.task_completed', site: 'openvibe.live', what: 'A streamer finished a task on Live\'s go-live setup list for the first time; the payload names the streamer and the task, never what they made.' },
+ { event_type: 'blog.post.published', site: 'openvibe.blog', what: 'A post was published; the envelope actor is the person who published it (a scheduled or service publish is ignored).' },
+ { event_type: 'deals.offer.created', site: 'openvibe.deals', what: 'A deal was submitted; the envelope actor is the person who posted it (deals imported from a source are ignored).' },
+ { event_type: 'deals.vote.changed', site: 'openvibe.deals', what: 'A vote on a deal was cast or changed; the envelope actor is the voter.' },
+ { event_type: 'run.job.succeeded', site: 'openvibe.run', what: 'A job finished on a node; the payload names its requester (a service or app requester is ignored).' },
+ { event_type: 'inventory.item.equipped', site: 'inventory.openvibe.network', what: 'An item was equipped; the payload names its owner, never what else they own.' },
]);
]);
// The events Quest actually counts: the consumer subscribes to exactly these, and a quest may not count an event
// The events Quest actually counts: the consumer subscribes to exactly these, and a quest may not count an event
@@ -175,6 +175,27 @@ const progressOf = async (t, subject, questId) => (await rows(t, 'SELECT step, c
@@ -175,6 +175,27 @@ const progressOf = async (t, subject, questId) => (await rows(t, 'SELECT step, c
assert.ok(quest.text.includes('<code>task</code> <code>emote</code>'), 'the quest page says which task it counts');
assert.ok(quest.text.includes('<code>task</code> <code>emote</code>'), 'the quest page says which task it counts');
});
});
+ await check('the Blog, Deals, Run and Inventory quests: each event credits its person; a service never does', async () => {
+ const done = async () => (await completions(t, sam.subject)).map((c) => c.quest_id);
+ // A post published by a schedule or a service is nobody's.
+ await deliver(t, envelope('blog.post.published', { canonical_url: 'https://openvibe.blog/@sam/hi' }, { source: 'blog', actor: { type: 'service', id: 'blog' } }));
+ assert.ok(!(await done()).includes('publish-a-blog-post'));
+ await deliver(t, envelope('blog.post.published', { canonical_url: 'https://openvibe.blog/@sam/hi' }, { source: 'blog', actor: { type: 'user', id: sam.subject } }));
+ assert.ok((await done()).includes('publish-a-blog-post'));
+ await deliver(t, envelope('deals.offer.created', { offer_id: 'off_2' }, { source: 'deals', actor: { type: 'user', id: sam.subject } }));
+ assert.ok((await done()).includes('share-a-deal'));
+ for (let i = 0; i < 9; i++) await deliver(t, envelope('deals.vote.changed', { offer_id: `off_${i}`, value: 1 }, { source: 'deals', actor: { type: 'user', id: sam.subject } }));
+ assert.ok(!(await done()).includes('vote-on-deals'), 'nine votes are not ten');
+ await deliver(t, envelope('deals.vote.changed', { offer_id: 'off_9', value: -1 }, { source: 'deals', actor: { type: 'user', id: sam.subject } }));
+ assert.ok((await done()).includes('vote-on-deals'));
+ await deliver(t, envelope('run.job.succeeded', { job_id: 'job_1', requester: { type: 'app', id: 'app_x' } }, { source: 'run' }));
+ assert.ok(!(await done()).includes('run-a-job'), 'an app\'s job is nobody\'s');
+ await deliver(t, envelope('run.job.succeeded', { job_id: 'job_2', requester: { type: 'user', id: sam.subject } }, { source: 'run' }));
+ assert.ok((await done()).includes('run-a-job'));
+ await deliver(t, envelope('inventory.item.equipped', { instance_id: 'i', definition_id: 'd', kind: 'hat', slot: 'hat', owner: sam.subject }, { source: 'inventory' }));
+ assert.ok((await done()).includes('wear-an-item'));
+ });
+
await check('an event that names nobody creditable is ignored: no subject, a guest, a service, an unknown type', async () => {
await check('an event that names nobody creditable is ignored: no subject, a guest, a service, an unknown type', async () => {
const before = (await completions(t, sam.subject)).length;
const before = (await completions(t, sam.subject)).length;
const guest = 'gst_' + '0'.repeat(26);
const guest = 'gst_' + '0'.repeat(26);
@@ -182,7 +203,7 @@ const progressOf = async (t, subject, questId) => (await rows(t, 'SELECT step, c
@@ -182,7 +203,7 @@ const progressOf = async (t, subject, questId) => (await rows(t, 'SELECT step, c
chatMessage(null), // a private-ish/guest message: no user_subject
chatMessage(null), // a private-ish/guest message: no user_subject
chatMessage(guest), // a guest id is not a usr_ subject
chatMessage(guest), // a guest id is not a usr_ subject
envelope('chat.room.message.created', { message_id: 3, room: { id: 'r', slug: 'g', name: 'G' }, text: 'x', created_at: iso(), user_subject: null }, { source: 'chat' }),
envelope('chat.room.message.created', { message_id: 3, room: { id: 'r', slug: 'g', name: 'G' }, text: 'x', created_at: iso(), user_subject: null }, { source: 'chat' }),
- envelope('deals.vote.changed', { value: 1 }, { source: 'deals', actor: { type: 'user', id: sam.subject } }), // no person in the payload
+ envelope('deals.offer.created', { offer_id: 'off_1' }, { source: 'deals', actor: { type: 'service', id: 'deals' } }), // an imported deal: a service, not a person
envelope('quest.something.happened', { user_subject: sam.subject }), // not an event Quest counts
envelope('quest.something.happened', { user_subject: sam.subject }), // not an event Quest counts
]) {
]) {
const r = await deliver(t, event);
const r = await deliver(t, event);
@@ -17,8 +17,9 @@ const BUDGETS = {
@@ -17,8 +17,9 @@ const BUDGETS = {
// Raised 2026-10-08 with the product: the home page now carries the quest catalog (12 quests, grouped by
// Raised 2026-10-08 with the product: the home page now carries the quest catalog (12 quests, grouped by
// site, each with its reward) and the list of the events Quest counts. Measured 34.0 KB raw / 7.7 KB brotli.
// site, each with its reward) and the list of the events Quest counts. Measured 34.0 KB raw / 7.7 KB brotli.
// Raised 2026-10-12 with openvibe-shared 4.1.3: the head reserves the navbar's 52px (120 bytes). Measured 37.6 KB raw.
// Raised 2026-10-12 with openvibe-shared 4.1.3: the head reserves the navbar's 52px (120 bytes). Measured 37.6 KB raw.
- htmlRawKB: 41, // measured 37.6 (the home page: hero, features, the quests, the events, cta, JSON-LD)
- htmlBrotliKB: 8.5, // 7.7
+ // Raised 2026-10-15: five more quests (Blog, Deals, Run, Inventory) and their events on the home page. Measured 43 KB raw.
+ htmlRawKB: 47, // measured 43 (the home page: hero, features, the quests, the events, cta, JSON-LD)
+ htmlBrotliKB: 9.8, // 9
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 264, // 239.8
jsRawKB: 264, // 239.8
jsBrotliKB: 62.5, // 56.5
jsBrotliKB: 62.5, // 56.5
@@ -27,7 +27,7 @@ const { render } = require('../server/render/html');
@@ -27,7 +27,7 @@ const { render } = require('../server/render/html');
assert.match(r.headers.get('cache-control') || '', /no-store/);
assert.match(r.headers.get('cache-control') || '', /no-store/);
const body = r.json();
const body = r.json();
assert.strictEqual(body.quests.length, catalog.all().length);
assert.strictEqual(body.quests.length, catalog.all().length);
- assert.ok(body.quests.length >= 8 && body.quests.length <= 32, `${body.quests.length} quests`);
+ assert.ok(body.quests.length >= 8 && body.quests.length <= 48, `${body.quests.length} quests`);
const setup = body.quests.filter((q) => q.series === 'live-setup');
const setup = body.quests.filter((q) => q.series === 'live-setup');
assert.strictEqual(setup.length, 18, 'the Live setup series: a quest per task but go-live, and the whole setup');
assert.strictEqual(setup.length, 18, 'the Live setup series: a quest per task but go-live, and the whole setup');
assert.deepStrictEqual(body.quests.find((q) => q.id === 'live-setup-emote').steps, [{ event_type: 'live.setup.task_completed', count: 1, label: 'Upload a custom emote on OpenVibe.Live', match: { task: 'emote' } }]);
assert.deepStrictEqual(body.quests.find((q) => q.id === 'live-setup-emote').steps, [{ event_type: 'live.setup.task_completed', count: 1, label: 'Upload a custom emote on OpenVibe.Live', match: { task: 'emote' } }]);