openvibers/openvibe.quest Commits
Quest: Set up your channel, a quest per Live setup task (live.setup.task_completed) (#52)
Changes
@@@ -128,8 +128,11 @@ OpenVibe.Events delivers an event when a site really does something. Quest count| `wiki.revision.created` | OpenVibe.Wiki | the author (a service edit 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) |++| `live.setup.task_completed` | OpenVibe.Live | `payload.user`, the streamer who finished a task on Live's go-live setup list |--A quest completes when every step reaches its count. The completion, its badge and (when OpenCoins are on) its++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++`/series/<id>`. A quest completes when every step reaches its count. The completion, its badge and (when OpenCoins are on) itscoins are recorded once; a redelivery of the same event, or the whole request again, changes nothing.## Data sources and their terms@@@ -137,7 +140,7 @@ coins are recorded once; a redelivery of the same event, or the whole request ag| Source | Used for | Terms as applied ||---|---|---|| **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.115.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 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` |@@@ -75,6 +75,8 @@ p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 7.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-secondary, #96a7c2); } /* muted failed AA on the tinted chip */.reward-coins.soon { color: var(--text-muted, #7386a3); border-style: dashed; border-color: var(--border-light, #2c3d5c); }++.quest-series { border-style: dashed; }++.quest-series .medal { font-size: 1.15rem; }@media (prefers-reduced-motion: reduce) { .quest { transition: none; } }.steps { margin: .4rem 0; }.done { color: var(--success, #22c55e); font-weight: 700; }@@@ -16,6 +16,7 @@* wiki.revision.created a wiki page got a new revision ('improve a wiki page')* games.player.joined a character entered a world ('play a game')* tools.job.created a tool job was accepted ('use a tool')++ * live.setup.task_completed a streamer finished a go-live setup task (the 'set up your channel' series)** Which field carries the person, and what is ignored, is ./quests/apply.js.*@@@ -29,7 +29,8 @@ const questWire = (q) => ({site_name: (catalog.SITES[q.site] || {}).name || q.site,title: q.title,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, ...(s.match ? { match: { ...s.match } } : {}) })),++ ...(q.series ? { series: q.series } : {}),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,6 +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(Object.values(catalog.SERIES).map((x) => [`/series/${x.id}`, [x.title, `${x.why} On ${(catalog.SITES[x.site] || {}).name || x.site}.`]])),...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` : ''}.`]])),};@@@ -46,6 +47,7 @@ function homeJsonLd(config) {const publicPages = () => [{ path: '/', changefreq: 'weekly', priority: 1.0 },{ path: '/how-it-works', changefreq: 'monthly', priority: 0.6 },++ ...Object.values(catalog.SERIES).map((x) => ({ path: `/series/${x.id}`, changefreq: 'monthly', priority: 0.7 })),...catalog.all().map((q) => ({ path: `/quests/${q.id}`, changefreq: 'monthly', priority: 0.7 })),{ path: '/updates', changefreq: 'daily', priority: 0.5 },];@@@ -78,7 +78,7 @@ ${raw(showcase.features({}))}<section class="sc-sec" id="the-quests" aria-labelledby="the-quests-h"><h2 id="the-quests-h">The quests</h2><p class="sc-lede">Every quest below counts something a person really did on an OpenVibe site. Grouped by the site whose events it counts.</p>--${quests.list(catalog.grouped(), coinsUi)}++${quests.list(catalog.grouped({ fold: true }), coinsUi)}${quests.coinsNote(coinsUi)}</section><section class="sc-sec" id="how-progress-works" aria-labelledby="how-progress-works-h"><h2 id="how-progress-works-h">How progress works</h2><p class="sc-lede">Quest does not watch you and does not ask the sites who you are. The network's event backbone,@@@ -122,9 +122,9 @@ ${raw(showcase.cta({crumbs: [{ label: 'Quests', href: '/' }, { label: quest.title }],body: html`<h1>${quest.title}</h1><p class="lede">${quest.why}</p>--<p class="muted">On <a href="https://${quest.site}">${siteName(quest.site)}</a>.</p>++<p class="muted">On <a href="https://${quest.site}">${siteName(quest.site)}</a>${quest.series && catalog.SERIES[quest.series] ? html`, in <a href="/series/${quest.series}">${catalog.SERIES[quest.series].title}</a>` : ''}.</p><h2>What it counts</h2>--<ul class="steps">${quest.steps.map((step, i) => html`<li><strong>${step.label}</strong> — ${step.count}× <code>${step.event_type}</code>${state && (state.seen.get(i) || 0) >= step.count ? html` <span class="done">done</span>` : ''}</li>`)}</ul>++<ul class="steps">${quest.steps.map((step, i) => html`<li><strong>${step.label}</strong> — ${step.count}× <code>${step.event_type}</code>${Object.entries(step.match || {}).map(([k, v]) => html` with <code>${k}</code> <code>${String(v)}</code>`)}${state && (state.seen.get(i) || 0) >= step.count ? html` <span class="done">done</span>` : ''}</li>`)}</ul><h2>The reward</h2><p>${quests.reward(quest, coinsUi)}</p><p class="muted">${state && state.holders ? html`${state.holders} ${state.holders === 1 ? 'person has' : 'people have'} this badge.` : html`A badge is awarded once and kept.`}</p>@@@ -134,6 +134,31 @@ ${quests.coinsNote(coinsUi)}}));});++ // ── A series: its quests, and which of them the person has done ──++ r.get('/series/:id', async (req, res) => {++ const series = catalog.seriesById(req.params.id);++ if (!series) return page(req, res, {++ title: 'No such series',++ body: html`<h1>No such series</h1><p>There is no quest series at this address. <a href="/">The quest list</a> has every quest.</p>`,++ }, 404);++ const mine = signedIn(req) && SUBJECT_RE.test(req.viewer.subject);++ const done = new Set(mine ? (await store.completionsFor(s.db, req.viewer.subject)).map((c) => c.quest_id) : []);++ const finished = series.quests.filter((q) => done.has(q.id)).length;++ return page(req, res, {++ index: true, cache: mine ? null : PUBLIC_CACHE,++ title: series.title,++ description: series.why,++ crumbs: [{ label: 'Quests', href: '/' }, { label: series.title }],++ body: html`<h1>${series.title}</h1>++<p class="lede">${series.why}</p>++<p class="muted">On <a href="https://${series.site}">${siteName(series.site)}</a>. ${series.how} <a href="${series.href}">${series.hrefLabel}</a>.</p>++${mine ? quests.progress('Your progress', finished, series.quests.length) : ''}++<ul class="quest-list">${series.quests.map((q) => quests.card(q, { ...coinsUi, earned: done.has(q.id) }))}</ul>++${quests.coinsNote(coinsUi)}++<p><a href="/">All quests</a>${mine ? html` · <a href="/me">Your quest log</a>` : ''}</p>`,++ });++ });++// ── Your quest log ───────────────────────────────────────r.get('/me', async (req, res) => {if (!signedIn(req)) return page(req, res, {@@@ -11,7 +11,7 @@* choice; otherwise it is ignored. An event whose subject is missing, a guest (gst_…), or a service, app or mod* principal is ignored, outcome 'ignored:subject', and nothing is written.*-- * Then, for every quest that counts the event type:++ * Then, for every quest with a step that counts the event (its type, and the payload values a step's `match` names):* - one more on that step's count (capped at the step's target; a completed quest is left frozen);* - if every step is at its target, a completion — once per person, by the unique constraint — and its badge.*@@@ -53,6 +53,8 @@ function personOf(event) {return refUser(p.player && p.player.subject);case 'tools.job.created': // payload.owner (a user, or a service, app or mod principal)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);default:return null;}@@@ -67,7 +69,7 @@ function personOf(event) {async function apply(db, event, { at, newId }) {const subject = personOf(event);if (!subject) return { subject: null, outcome: 'ignored:subject', completed: [] };-- const quests = catalog.withEvent(event.event_type);++ const quests = catalog.forEvent(event);if (!quests.length) return { subject, outcome: 'ignored:type', completed: [] };const completed = [];@@@ -77,7 +79,7 @@ async function apply(db, event, { at, newId }) {const progress = new Map((await store.progressOf(db, subject, quest.id)).map((r) => [Number(r.step), Number(r.count)]));for (const [i, step] of quest.steps.entries()) {-- if (step.event_type !== event.event_type) continue;++ if (!catalog.stepCounts(step, event)) continue;progress.set(i, await store.bumpProgress(db, { subject, questId: quest.id, step: i, target: step.count, at }));}if (!quest.steps.every((s, i) => (progress.get(i) || 0) >= s.count)) continue;@@@ -15,7 +15,9 @@* site the domain whose events it counts (grouping and provenance)* 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++ * steps [{ event_type, count, label, match? }] — every step must reach its count; `match` ({ field: value })++ * counts only the events whose payload has those values (live.setup.task_completed: { task: 'emote' })++ * series optional: the SERIES it belongs to; the home page shows a series as one card, its page lists them* 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)@@@ -37,6 +39,22 @@ const SITES = Object.freeze({'openvibe.space': { name: 'OpenVibe.Space', what: 'forums in a Space' },});++/**++ * Series: a set of quests one site offers together. The home page shows a series as one card that opens its page++ * (/series/<id>), so a long set does not bury the rest of the catalog; the API and the quest log list every quest.++ */++const SERIES = Object.freeze({++ 'live-setup': {++ id: 'live-setup',++ site: 'openvibe.live',++ title: 'Set up your channel',++ why: 'OpenVibe.Live\'s go-live setup list: a quest for each task, and one for finishing it all.',++ how: 'Live tells Quest about a task the first time it sees it done: the next time you open OpenVibe.Live\'s home page or your setup list. A task done before this series existed counts too.',++ href: 'https://openvibe.live/',++ hrefLabel: 'Your setup list on OpenVibe.Live',++ },++});++const QUESTS = Object.freeze([{id: 'go-live',@@@ -149,6 +167,187 @@ const QUESTS = Object.freeze([reward: { badge: { id: 'settled-in', name: 'Settled In' }, coins: 20 },repeatable: false,},++ {++ id: 'live-setup-slot',++ site: 'openvibe.live',++ series: 'live-setup',++ title: 'Create your stream slot',++ why: 'Your show gets its own key, settings, VODs and restreams.',++ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'slot' }, count: 1, label: 'Create your stream slot on OpenVibe.Live' }],++ reward: { badge: { id: 'live-setup-slot-holder', name: 'Slot Holder' }, coins: 5 },++ repeatable: false,++ },++ {++ id: 'live-setup-method',++ site: 'openvibe.live',++ series: 'live-setup',++ title: 'Choose how you stream',++ why: 'From the browser, from OBS over RTMP, or from OBS over WHIP: picking one is half the setup.',++ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'method' }, count: 1, label: 'Choose how you stream on OpenVibe.Live' }],++ reward: { badge: { id: 'live-setup-method-picked', name: 'Method Picked' }, coins: 5 },++ repeatable: false,++ },++ {++ id: 'live-setup-restream',++ site: 'openvibe.live',++ series: 'live-setup',++ title: 'Mirror to another platform',++ why: 'One stream on Twitch, YouTube, Kick, RobotStreamer or any RTMP server at the same time.',++ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'restream' }, count: 1, label: 'Mirror to another platform on OpenVibe.Live' }],++ reward: { badge: { id: 'live-setup-restreamer', name: 'Restreamer' }, coins: 10 },++ repeatable: false,++ },++ {++ id: 'live-setup-profile',++ site: 'openvibe.live',++ series: 'live-setup',++ title: 'Add an avatar and a bio',++ why: 'The first thing people see on your channel and next to your name in chat.',++ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'profile' }, count: 1, label: 'Add an avatar and a bio on OpenVibe.Live' }],++ reward: { badge: { id: 'live-setup-profile-ready', name: 'Profile Ready' }, coins: 5 },++ repeatable: false,++ },++ {++ id: 'live-setup-offline',++ site: 'openvibe.live',++ series: 'live-setup',++ title: 'Make an offline screen',++ why: 'What visitors see while you are not live: an image, a video or your own page.',++ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'offline' }, count: 1, label: 'Make an offline screen on OpenVibe.Live' }],++ reward: { badge: { id: 'live-setup-offline-screen', name: 'Offline Screen' }, coins: 5 },++ repeatable: false,++ },++ {++ id: 'live-setup-emote',++ site: 'openvibe.live',++ series: 'live-setup',++ title: 'Upload a custom emote',++ why: 'Your chat, your inside jokes. Animated ones work too.',++ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'emote' }, count: 1, label: 'Upload a custom emote on OpenVibe.Live' }],++ reward: { badge: { id: 'live-setup-emote-maker', name: 'Emote Maker' }, coins: 5 },++ repeatable: false,++ },++ {++ id: 'live-setup-sound',++ site: 'openvibe.live',++ series: 'live-setup',++ title: 'Add a sound command',++ why: 'Viewers type !boom and your stream plays it.',++ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'sound' }, count: 1, label: 'Add a sound command on OpenVibe.Live' }],++ reward: { badge: { id: 'live-setup-sound-board', name: 'Sound Board' }, coins: 5 },++ repeatable: false,++ },++ {++ id: 'live-setup-goal',++ site: 'openvibe.live',++ series: 'live-setup',++ title: 'Set a donation goal',++ why: 'A visible target turns tips into a team effort.',++ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'goal' }, count: 1, label: 'Set a donation goal on OpenVibe.Live' }],++ reward: { badge: { id: 'live-setup-goal-setter', name: 'Goal Setter' }, coins: 5 },++ repeatable: false,++ },++ {++ id: 'live-setup-powerchat',++ site: 'openvibe.live',++ series: 'live-setup',++ title: 'Connect PowerChat',++ why: 'Card and crypto tips, with alerts on your stream.',++ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'powerchat' }, count: 1, label: 'Connect PowerChat on OpenVibe.Live' }],++ reward: { badge: { id: 'live-setup-tip-jar', name: 'Tip Jar' }, coins: 10 },++ repeatable: false,++ },++ {++ id: 'live-setup-panels',++ site: 'openvibe.live',++ series: 'live-setup',++ title: 'Fill in your About panels',++ why: 'Links, schedule and rules: what people read under the player.',++ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'panels' }, count: 1, label: 'Fill in your About panels on OpenVibe.Live' }],++ reward: { badge: { id: 'live-setup-panel-writer', name: 'Panel Writer' }, coins: 5 },++ repeatable: false,++ },++ {++ id: 'live-setup-share',++ site: 'openvibe.live',++ series: 'live-setup',++ title: 'Get your first follower',++ why: 'Share your channel link: a follower hears about it every time you go live.',++ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'share' }, count: 1, label: 'Get your first follower on OpenVibe.Live' }],++ reward: { badge: { id: 'live-setup-followed', name: 'Followed' }, coins: 10 },++ repeatable: false,++ },++ {++ id: 'live-setup-mods',++ site: 'openvibe.live',++ series: 'live-setup',++ title: 'Add a moderator',++ why: 'Someone you trust watching chat while you stream.',++ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'mods' }, count: 1, label: 'Add a moderator on OpenVibe.Live' }],++ reward: { badge: { id: 'live-setup-mod-squad', name: 'Mod Squad' }, coins: 5 },++ repeatable: false,++ },++ {++ id: 'live-setup-requests',++ site: 'openvibe.live',++ series: 'live-setup',++ title: 'Open media requests',++ why: 'Let viewers queue clips and tracks to play on your stream.',++ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'requests' }, count: 1, label: 'Open media requests on OpenVibe.Live' }],++ reward: { badge: { id: 'live-setup-request-line', name: 'Request Line' }, coins: 5 },++ repeatable: false,++ },++ {++ id: 'live-setup-moderation',++ site: 'openvibe.live',++ series: 'live-setup',++ title: 'Set your chat rules',++ why: 'Slow mode, followers-only and emote-only, tuned the way you want them.',++ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'moderation' }, count: 1, label: 'Set your chat rules on OpenVibe.Live' }],++ reward: { badge: { id: 'live-setup-rule-maker', name: 'Rule Maker' }, coins: 5 },++ repeatable: false,++ },++ {++ id: 'live-setup-robot',++ site: 'openvibe.live',++ series: 'live-setup',++ title: 'Connect a robot',++ why: 'Bind a robot from OpenVibe.Bot to your channel: its panel shows under your stream and chat can drive it.',++ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'robot' }, count: 1, label: 'Connect a robot on OpenVibe.Live' }],++ reward: { badge: { id: 'live-setup-robot-wrangler', name: 'Robot Wrangler' }, coins: 10 },++ repeatable: false,++ },++ {++ id: 'live-setup-aibot',++ site: 'openvibe.live',++ series: 'live-setup',++ title: 'Add an AI chat regular',++ why: 'Keeps an empty chat warm and answers the usual questions.',++ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'aibot' }, count: 1, label: 'Add an AI chat regular on OpenVibe.Live' }],++ reward: { badge: { id: 'live-setup-bot-host', name: 'Bot Host' }, coins: 5 },++ repeatable: false,++ },++ {++ id: 'live-setup-paste',++ site: 'openvibe.live',++ series: 'live-setup',++ title: 'Publish a paste',++ why: 'Code, configs or notes from your stream, each on a page of its own.',++ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'paste' }, count: 1, label: 'Publish a paste on OpenVibe.Live' }],++ reward: { badge: { id: 'live-setup-paste-author', name: 'Paste Author' }, coins: 5 },++ repeatable: false,++ },++ {++ id: 'live-setup-complete',++ site: 'openvibe.live',++ series: 'live-setup',++ title: 'Finish the whole go-live setup',++ why: 'Every task on the list done: the channel is ready for whoever walks in.',++ // Live sends each task once per person, so 18 of them is every task on the list.++ steps: [{ event_type: 'live.setup.task_completed', count: 18, label: 'Finish every task on the setup list' }],++ reward: { badge: { id: 'live-setup-complete', name: 'Fully Set Up' }, coins: 50 },++ repeatable: false,++ },]);const BY_ID = new Map(QUESTS.map((q) => [q.id, q]));@@@ -173,6 +372,7 @@ const EVENTS = Object.freeze([{ event_type: 'wiki.revision.created', site: 'openvibe.wiki', what: 'A wiki page got a new immutable revision; the payload names its author (a service edit 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: '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.' },]);// The events Quest actually counts: the consumer subscribes to exactly these, and a quest may not count an event@@@ -182,6 +382,8 @@ const EVENT_TYPES = Object.freeze(COUNTED.slice().sort());{const missing = QUESTS.flatMap((q) => q.steps.map((s) => s.event_type)).filter((t) => !COUNTED.includes(t));if (missing.length) throw new Error(`quests/catalog.js: a quest counts an event that is not in EVENTS: ${[...new Set(missing)].join(', ')}`);++ const orphans = QUESTS.filter((q) => q.series && !(SERIES[q.series] && SERIES[q.series].site === q.site)).map((q) => q.id);++ if (orphans.length) throw new Error(`quests/catalog.js: a quest names a series that is not in SERIES (or is on another site): ${orphans.join(', ')}`);}/** The sites, in the order they first appear in the catalog (so the pages read the same every time). */@@@ -191,17 +393,36 @@ const byId = (id) => BY_ID.get(String(id)) || null;const all = () => QUESTS;/** The quests that count this event type (a step may be shared: 'settle in' counts two sites). */const withEvent = (eventType) => QUESTS.filter((q) => q.steps.some((s) => s.event_type === eventType));--/** The catalog grouped by site, for the home page and the API: [{ site, name, what, quests }]. */--const grouped = () => SITE_ORDER.map((site) => ({-- site,-- name: (SITES[site] || {}).name || site,-- what: (SITES[site] || {}).what || '',-- quests: QUESTS.filter((q) => q.site === site),--}));++/** Whether a step counts this event: its type, and every value its `match` names in the payload. */++const stepCounts = (step, event) => step.event_type === event.event_type++ && Object.entries(step.match || {}).every(([k, v]) => ((event.payload || {})[k]) === v);++/** The quests with a step that counts this very event (live.setup.task_completed for 'emote' moves two, not eighteen). */++const forEvent = (event) => QUESTS.filter((q) => q.steps.some((s) => stepCounts(s, event)));++/**++ * The catalog grouped by site, for the home page and the API: [{ site, name, what, quests, series }]. With++ * { fold: true } (the home page) a series' quests leave `quests` and `series` lists the series instead, each with its++ * quests; otherwise `quests` has every quest and `series` is empty.++ */++const grouped = ({ fold = false } = {}) => SITE_ORDER.map((site) => {++ const here = QUESTS.filter((q) => q.site === site);++ const series = Object.values(SERIES).filter((x) => x.site === site)++ .map((x) => ({ ...x, quests: here.filter((q) => q.series === x.id) })).filter((x) => x.quests.length);++ return {++ site,++ name: (SITES[site] || {}).name || site,++ what: (SITES[site] || {}).what || '',++ quests: fold ? here.filter((q) => !q.series) : here,++ series: fold ? series : [],++ };++});++const seriesById = (id) => {++ const x = Object.prototype.hasOwnProperty.call(SERIES, String(id)) ? SERIES[String(id)] : null;++ return x ? { ...x, quests: QUESTS.filter((q) => q.series === x.id) } : null;++};/** The badge for a quest, by badge id (the public badge endpoint stores only the id). */const badgeById = (badgeId) => {for (const q of QUESTS) if (q.reward.badge.id === badgeId) return { ...q.reward.badge, quest_id: q.id };return null;};--module.exports = { QUESTS, SITES, EVENTS, EVENT_TYPES, SITE_ORDER, all, byId, withEvent, grouped, badgeById };++module.exports = { QUESTS, SITES, SERIES, EVENTS, EVENT_TYPES, SITE_ORDER, all, byId, withEvent, forEvent, stepCounts, grouped, seriesById, badgeById };@@@ -38,11 +38,11 @@ function medal(quest, { earned = false } = {}) {}/** One quest in a list: the badge it pays, what it is, why, the steps as a checklist, and the reward. */--function card(quest, { coinsEnabled = false } = {}) {++function card(quest, { coinsEnabled = false, earned = false } = {}) {return html`<li class="quest">-- ${medal(quest)}++ ${medal(quest, { earned })}<div class="quest-main">-- <a class="quest-title" href="/quests/${quest.id}">${quest.title}</a>++ <a class="quest-title" href="/quests/${quest.id}">${quest.title}</a>${earned ? html` <span class="done">done</span>` : ''}<p class="quest-why">${quest.why}</p><ul class="quest-steps">${quest.steps.map((s) => html`<li>${s.label}${s.count > 1 ? html` <span class="step-count">×${s.count}</span>` : ''}</li>`)}</ul><p class="quest-reward">${reward(quest, { coinsEnabled })}</p>@@@ -50,13 +50,28 @@ function card(quest, { coinsEnabled = false } = {}) {</li>`;}--/** The catalog grouped by the site whose events each quest counts. */++/** A series in a list (the home page): one card for all its quests, opening the series' page. */++function seriesCard(series, { coinsEnabled = false } = {}) {++ const coins = series.quests.reduce((n, q) => n + q.reward.coins, 0);++ const n = series.quests.length;++ return html`<li class="quest quest-series">++ <span class="medal" style="--h:${hueOf(series.id)}" aria-hidden="true">${n}</span>++ <div class="quest-main">++ <a class="quest-title" href="/series/${series.id}">${series.title}</a>++ <p class="quest-why">${series.why}</p>++ <p class="quest-reward"><span class="reward"><span class="reward-badge"><strong>${n}</strong> quests, a badge each</span>${coins > 0 ? (coinsEnabled ? html`<span class="reward-coins"><strong>${coins}</strong> OpenCoins in all</span>` : html`<span class="reward-coins soon">OpenCoins rewards start soon</span>`) : ''}</span></p>++ </div>++</li>`;++}++++/** The catalog grouped by the site whose events each quest counts (a folded catalog shows each series as one card). */function list(groups = catalog.grouped(), { coinsEnabled = false } = {}) {// The heading id is the domain with its dots replaced: an id with a dot is legal but awkward to target.const anchor = (site) => `site-${String(site).replace(/[^a-z0-9-]/gi, '-')}`;++ const count = (g) => g.quests.length + (g.series || []).reduce((n, x) => n + x.quests.length, 0);return html`${groups.map((g) => html`<section class="site-group" aria-labelledby="${anchor(g.site)}">-- <h3 id="${anchor(g.site)}"><a href="https://${g.site}">${String(g.name).replace(/^OpenVibe\./, '')}</a><span class="site-count">${g.quests.length} quest${g.quests.length === 1 ? '' : 's'}</span>${g.what ? html` <small>${g.what}</small>` : ''}</h3>-- <ul class="quest-list">${g.quests.map((q) => card(q, { coinsEnabled }))}</ul>++ <h3 id="${anchor(g.site)}"><a href="https://${g.site}">${String(g.name).replace(/^OpenVibe\./, '')}</a><span class="site-count">${count(g)} quest${count(g) === 1 ? '' : 's'}</span>${g.what ? html` <small>${g.what}</small>` : ''}</h3>++ <ul class="quest-list">${g.quests.map((q) => card(q, { coinsEnabled }))}${(g.series || []).map((x) => seriesCard(x, { coinsEnabled }))}</ul></section>`)}`;}@@@ -79,4 +94,4 @@ ${dailyCap} OpenCoins per person per day and ${globalDailyCap} for everyone togenever money — and never Vibes.</p>`;}--module.exports = { medal, hueOf, reward, card, list, progress, coinsNote };++module.exports = { medal, hueOf, reward, card, seriesCard, list, progress, coinsNote };@@@ -38,6 +38,10 @@ const chatMessage = (user_subject) => envelope('chat.room.message.created', {message_id: 12, room: { id: 'room_1', slug: 'general', name: 'General' }, text: 'hello', created_at: iso(),user_subject, username: 'liv', display_name: 'Liv',}, { source: 'chat', actor: user_subject ? { type: 'user', id: user_subject } : { type: 'service', id: 'chat' } });++/** live.setup.task_completed (Contracts 0.190.0): one task of Live's go-live setup list, done for the first time. */++const setupTask = (subject, task, done = 1) => envelope('live.setup.task_completed', {++ task, user: { type: 'user', id: subject }, title: `Task ${task}`, group: 'Stream', completed_at: iso(), done, total: 18,++}, { actor: { type: 'user', id: subject }, subject: { type: 'user', id: subject } });const followCreated = (follower) => envelope('network.follow.created', {follower, target_type: 'channel', target_id: 'usr_' + '0'.repeat(26), revision: 1,}, { source: 'network', actor: { type: 'user', id: follower } });@@@ -140,6 +144,37 @@ const progressOf = async (t, subject, questId) => (await rows(t, 'SELECT step, cassert.deepStrictEqual(await progressOf(t, u.subject, 'stream-three-times'), [3], 'progress is frozen at the target');});++ await check('a Live setup task completes its own quest (step match) and counts toward finishing the whole setup', async () => {++ const SETUP_TASKS = ['slot', 'method', 'restream', 'golive', 'profile', 'offline', 'emote', 'sound', 'goal', 'powerchat', 'panels', 'share', 'mods', 'requests', 'moderation', 'robot', 'aibot', 'paste'];++ const u = t.network.addUser('setter-upper');++ const emote = await deliver(t, setupTask(u.subject, 'emote'));++ assert.strictEqual(emote.json().outcome, 'completed');++ let done = await completions(t, u.subject);++ assert.deepStrictEqual(done.map((c) => c.quest_id), ['live-setup-emote'], 'only the emote quest, not the other sixteen');++ assert.deepStrictEqual(await progressOf(t, u.subject, 'live-setup-sound'), [], 'a step whose match differs is not counted');++ assert.deepStrictEqual(await progressOf(t, u.subject, 'live-setup-complete'), [1], 'the whole-setup quest counts every task');++ // "Go live for the first time" has no quest of its own (the go-live quest pays it) but counts toward the whole setup.++ for (const task of SETUP_TASKS.filter((x) => x !== 'emote')) await deliver(t, setupTask(u.subject, task));++ done = await completions(t, u.subject);++ assert.strictEqual(done.length, 18, '17 task quests and the whole setup');++ assert.ok(done.some((c) => c.quest_id === 'live-setup-complete'));++ assert.ok((await badges(t, u.subject)).some((b) => b.badge_id === 'live-setup-complete'));++ // A setup event without a person in the payload credits nobody, even with a user actor.++ const nobody = envelope('live.setup.task_completed', { task: 'slot', user: null }, { actor: { type: 'user', id: u.subject } });++ assert.strictEqual((await deliver(t, nobody)).json().outcome, 'ignored:subject');++ // The series page shows a signed-in streamer what they have done; the home page folds the series into one card.++ const page = await t.get('/series/live-setup', { as: u });++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /<progress max="18" value="18">/);++ assert.ok(page.text.includes('href="/quests/live-setup-emote"'));++ const home = await t.get('/');++ assert.ok(home.text.includes('href="/series/live-setup"'), 'the home page links the series');++ assert.ok(!home.text.includes('href="/quests/live-setup-emote"'), 'and does not list its quests one by one');++ assert.strictEqual((await t.get('/series/nope')).status, 404);++ const quest = await t.get('/quests/live-setup-emote');++ assert.ok(quest.text.includes('<code>task</code> <code>emote</code>'), 'the quest page says which task it counts');++ });++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);@@@ -27,7 +27,10 @@ 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 <= 12, `${body.quests.length} quests`);++ assert.ok(body.quests.length >= 8 && body.quests.length <= 32, `${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' } }]);assert.deepStrictEqual(body.sites.map((s) => s.site), catalog.SITE_ORDER, 'grouped in catalog order');assert.strictEqual(body.sites.reduce((n, s) => n + s.quests.length, 0), catalog.all().length, 'every quest is in exactly one group');const live = body.sites.find((s) => s.site === 'openvibe.live');
@@ -128,8 +128,11 @@ OpenVibe.Events delivers an event when a site really does something. Quest count
@@ -128,8 +128,11 @@ OpenVibe.Events delivers an event when a site really does something. Quest count
| `wiki.revision.created` | OpenVibe.Wiki | the author (a service edit is ignored) |
| `wiki.revision.created` | OpenVibe.Wiki | the author (a service edit is ignored) |
| `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 |
-A quest completes when every step reaches its count. The completion, its badge and (when OpenCoins are on) its
+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
+`/series/<id>`. A quest completes when every step reaches its count. The completion, its badge and (when OpenCoins are on) its
coins are recorded once; a redelivery of the same event, or the whole request again, changes nothing.
coins are recorded once; a redelivery of the same event, or the whole request again, changes nothing.
## Data sources and their terms
## Data sources and their terms
@@ -137,7 +140,7 @@ coins are recorded once; a redelivery of the same event, or the whole request ag
@@ -137,7 +140,7 @@ coins are recorded once; a redelivery of the same event, or the whole request ag
| Source | Used for | Terms as applied |
| Source | Used for | Terms as applied |
|---|---|---|
|---|---|---|
| **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.115.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) | 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` |
@@ -75,6 +75,8 @@ p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 7
@@ -75,6 +75,8 @@ p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 7
.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 { 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-secondary, #96a7c2); } /* muted failed AA on the tinted chip */
.reward-item small { color: var(--text-secondary, #96a7c2); } /* muted failed AA on the tinted chip */
.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); }
+.quest-series { border-style: dashed; }
+.quest-series .medal { font-size: 1.15rem; }
@media (prefers-reduced-motion: reduce) { .quest { transition: none; } }
@media (prefers-reduced-motion: reduce) { .quest { transition: none; } }
.steps { margin: .4rem 0; }
.steps { margin: .4rem 0; }
.done { color: var(--success, #22c55e); font-weight: 700; }
.done { color: var(--success, #22c55e); font-weight: 700; }
@@ -16,6 +16,7 @@
@@ -16,6 +16,7 @@
* wiki.revision.created a wiki page got a new revision ('improve a wiki page')
* wiki.revision.created a wiki page got a new revision ('improve a wiki page')
* games.player.joined a character entered a world ('play a game')
* games.player.joined a character entered a world ('play a game')
* tools.job.created a tool job was accepted ('use a tool')
* tools.job.created a tool job was accepted ('use a tool')
+ * live.setup.task_completed a streamer finished a go-live setup task (the 'set up your channel' series)
*
*
* Which field carries the person, and what is ignored, is ./quests/apply.js.
* Which field carries the person, and what is ignored, is ./quests/apply.js.
*
*
@@ -29,7 +29,8 @@ const questWire = (q) => ({
@@ -29,7 +29,8 @@ const questWire = (q) => ({
site_name: (catalog.SITES[q.site] || {}).name || q.site,
site_name: (catalog.SITES[q.site] || {}).name || q.site,
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, ...(s.match ? { match: { ...s.match } } : {}) })),
+ ...(q.series ? { series: q.series } : {}),
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 } } : {}) },
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,6 +23,7 @@ const PAGE_TEXT = {
@@ -23,6 +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(Object.values(catalog.SERIES).map((x) => [`/series/${x.id}`, [x.title, `${x.why} On ${(catalog.SITES[x.site] || {}).name || x.site}.`]])),
...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` : ''}.`]])),
...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` : ''}.`]])),
};
};
@@ -46,6 +47,7 @@ function homeJsonLd(config) {
@@ -46,6 +47,7 @@ function homeJsonLd(config) {
const publicPages = () => [
const publicPages = () => [
{ path: '/', changefreq: 'weekly', priority: 1.0 },
{ path: '/', changefreq: 'weekly', priority: 1.0 },
{ path: '/how-it-works', changefreq: 'monthly', priority: 0.6 },
{ path: '/how-it-works', changefreq: 'monthly', priority: 0.6 },
+ ...Object.values(catalog.SERIES).map((x) => ({ path: `/series/${x.id}`, changefreq: 'monthly', priority: 0.7 })),
...catalog.all().map((q) => ({ path: `/quests/${q.id}`, changefreq: 'monthly', priority: 0.7 })),
...catalog.all().map((q) => ({ path: `/quests/${q.id}`, changefreq: 'monthly', priority: 0.7 })),
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
];
];
@@ -78,7 +78,7 @@ ${raw(showcase.features({
@@ -78,7 +78,7 @@ ${raw(showcase.features({
}))}
}))}
<section class="sc-sec" id="the-quests" aria-labelledby="the-quests-h"><h2 id="the-quests-h">The quests</h2>
<section class="sc-sec" id="the-quests" aria-labelledby="the-quests-h"><h2 id="the-quests-h">The quests</h2>
<p class="sc-lede">Every quest below counts something a person really did on an OpenVibe site. Grouped by the site whose events it counts.</p>
<p class="sc-lede">Every quest below counts something a person really did on an OpenVibe site. Grouped by the site whose events it counts.</p>
-${quests.list(catalog.grouped(), coinsUi)}
+${quests.list(catalog.grouped({ fold: true }), coinsUi)}
${quests.coinsNote(coinsUi)}</section>
${quests.coinsNote(coinsUi)}</section>
<section class="sc-sec" id="how-progress-works" aria-labelledby="how-progress-works-h"><h2 id="how-progress-works-h">How progress works</h2>
<section class="sc-sec" id="how-progress-works" aria-labelledby="how-progress-works-h"><h2 id="how-progress-works-h">How progress works</h2>
<p class="sc-lede">Quest does not watch you and does not ask the sites who you are. The network's event backbone,
<p class="sc-lede">Quest does not watch you and does not ask the sites who you are. The network's event backbone,
@@ -122,9 +122,9 @@ ${raw(showcase.cta({
@@ -122,9 +122,9 @@ ${raw(showcase.cta({
crumbs: [{ label: 'Quests', href: '/' }, { label: quest.title }],
crumbs: [{ label: 'Quests', href: '/' }, { label: quest.title }],
body: html`<h1>${quest.title}</h1>
body: html`<h1>${quest.title}</h1>
<p class="lede">${quest.why}</p>
<p class="lede">${quest.why}</p>
-<p class="muted">On <a href="https://${quest.site}">${siteName(quest.site)}</a>.</p>
+<p class="muted">On <a href="https://${quest.site}">${siteName(quest.site)}</a>${quest.series && catalog.SERIES[quest.series] ? html`, in <a href="/series/${quest.series}">${catalog.SERIES[quest.series].title}</a>` : ''}.</p>
<h2>What it counts</h2>
<h2>What it counts</h2>
-<ul class="steps">${quest.steps.map((step, i) => html`<li><strong>${step.label}</strong> — ${step.count}× <code>${step.event_type}</code>${state && (state.seen.get(i) || 0) >= step.count ? html` <span class="done">done</span>` : ''}</li>`)}</ul>
+<ul class="steps">${quest.steps.map((step, i) => html`<li><strong>${step.label}</strong> — ${step.count}× <code>${step.event_type}</code>${Object.entries(step.match || {}).map(([k, v]) => html` with <code>${k}</code> <code>${String(v)}</code>`)}${state && (state.seen.get(i) || 0) >= step.count ? html` <span class="done">done</span>` : ''}</li>`)}</ul>
<h2>The reward</h2>
<h2>The reward</h2>
<p>${quests.reward(quest, coinsUi)}</p>
<p>${quests.reward(quest, coinsUi)}</p>
<p class="muted">${state && state.holders ? html`${state.holders} ${state.holders === 1 ? 'person has' : 'people have'} this badge.` : html`A badge is awarded once and kept.`}</p>
<p class="muted">${state && state.holders ? html`${state.holders} ${state.holders === 1 ? 'person has' : 'people have'} this badge.` : html`A badge is awarded once and kept.`}</p>
@@ -134,6 +134,31 @@ ${quests.coinsNote(coinsUi)}
@@ -134,6 +134,31 @@ ${quests.coinsNote(coinsUi)}
}));
}));
});
});
+ // ── A series: its quests, and which of them the person has done ──
+ r.get('/series/:id', async (req, res) => {
+ const series = catalog.seriesById(req.params.id);
+ if (!series) return page(req, res, {
+ title: 'No such series',
+ body: html`<h1>No such series</h1><p>There is no quest series at this address. <a href="/">The quest list</a> has every quest.</p>`,
+ }, 404);
+ const mine = signedIn(req) && SUBJECT_RE.test(req.viewer.subject);
+ const done = new Set(mine ? (await store.completionsFor(s.db, req.viewer.subject)).map((c) => c.quest_id) : []);
+ const finished = series.quests.filter((q) => done.has(q.id)).length;
+ return page(req, res, {
+ index: true, cache: mine ? null : PUBLIC_CACHE,
+ title: series.title,
+ description: series.why,
+ crumbs: [{ label: 'Quests', href: '/' }, { label: series.title }],
+ body: html`<h1>${series.title}</h1>
+<p class="lede">${series.why}</p>
+<p class="muted">On <a href="https://${series.site}">${siteName(series.site)}</a>. ${series.how} <a href="${series.href}">${series.hrefLabel}</a>.</p>
+${mine ? quests.progress('Your progress', finished, series.quests.length) : ''}
+<ul class="quest-list">${series.quests.map((q) => quests.card(q, { ...coinsUi, earned: done.has(q.id) }))}</ul>
+${quests.coinsNote(coinsUi)}
+<p><a href="/">All quests</a>${mine ? html` · <a href="/me">Your quest log</a>` : ''}</p>`,
+ });
+ });
+
// ── Your quest log ───────────────────────────────────────
// ── Your quest log ───────────────────────────────────────
r.get('/me', async (req, res) => {
r.get('/me', async (req, res) => {
if (!signedIn(req)) return page(req, res, {
if (!signedIn(req)) return page(req, res, {
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
* choice; otherwise it is ignored. An event whose subject is missing, a guest (gst_…), or a service, app or mod
* choice; otherwise it is ignored. An event whose subject is missing, a guest (gst_…), or a service, app or mod
* principal is ignored, outcome 'ignored:subject', and nothing is written.
* principal is ignored, outcome 'ignored:subject', and nothing is written.
*
*
- * Then, for every quest that counts the event type:
+ * Then, for every quest with a step that counts the event (its type, and the payload values a step's `match` names):
* - one more on that step's count (capped at the step's target; a completed quest is left frozen);
* - one more on that step's count (capped at the step's target; a completed quest is left frozen);
* - if every step is at its target, a completion — once per person, by the unique constraint — and its badge.
* - if every step is at its target, a completion — once per person, by the unique constraint — and its badge.
*
*
@@ -53,6 +53,8 @@ function personOf(event) {
@@ -53,6 +53,8 @@ function personOf(event) {
return refUser(p.player && p.player.subject);
return refUser(p.player && p.player.subject);
case 'tools.job.created': // payload.owner (a user, or a service, app or mod principal)
case 'tools.job.created': // payload.owner (a user, or a service, app or mod principal)
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)
+ return refUser(p.user);
default:
default:
return null;
return null;
}
}
@@ -67,7 +69,7 @@ function personOf(event) {
@@ -67,7 +69,7 @@ function personOf(event) {
async function apply(db, event, { at, newId }) {
async function apply(db, event, { at, newId }) {
const subject = personOf(event);
const subject = personOf(event);
if (!subject) return { subject: null, outcome: 'ignored:subject', completed: [] };
if (!subject) return { subject: null, outcome: 'ignored:subject', completed: [] };
- const quests = catalog.withEvent(event.event_type);
+ const quests = catalog.forEvent(event);
if (!quests.length) return { subject, outcome: 'ignored:type', completed: [] };
if (!quests.length) return { subject, outcome: 'ignored:type', completed: [] };
const completed = [];
const completed = [];
@@ -77,7 +79,7 @@ async function apply(db, event, { at, newId }) {
@@ -77,7 +79,7 @@ async function apply(db, event, { at, newId }) {
const progress = new Map((await store.progressOf(db, subject, quest.id)).map((r) => [Number(r.step), Number(r.count)]));
const progress = new Map((await store.progressOf(db, subject, quest.id)).map((r) => [Number(r.step), Number(r.count)]));
for (const [i, step] of quest.steps.entries()) {
for (const [i, step] of quest.steps.entries()) {
- if (step.event_type !== event.event_type) continue;
+ if (!catalog.stepCounts(step, event)) continue;
progress.set(i, await store.bumpProgress(db, { subject, questId: quest.id, step: i, target: step.count, at }));
progress.set(i, await store.bumpProgress(db, { subject, questId: quest.id, step: i, target: step.count, at }));
}
}
if (!quest.steps.every((s, i) => (progress.get(i) || 0) >= s.count)) continue;
if (!quest.steps.every((s, i) => (progress.get(i) || 0) >= s.count)) continue;
@@ -15,7 +15,9 @@
@@ -15,7 +15,9 @@
* site the domain whose events it counts (grouping and provenance)
* site the domain whose events it counts (grouping and provenance)
* 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, match? }] — every step must reach its count; `match` ({ field: value })
+ * counts only the events whose payload has those values (live.setup.task_completed: { task: 'emote' })
+ * series optional: the SERIES it belongs to; the home page shows a series as one card, its page lists them
* reward { badge: { id, name }, coins, item? } — a text badge label (no emoji), 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
* 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)
* Live's items that Live lets Quest give (ADR-054 §3 grantors; server/quests/items.js grants it)
@@ -37,6 +39,22 @@ const SITES = Object.freeze({
@@ -37,6 +39,22 @@ const SITES = Object.freeze({
'openvibe.space': { name: 'OpenVibe.Space', what: 'forums in a Space' },
'openvibe.space': { name: 'OpenVibe.Space', what: 'forums in a Space' },
});
});
+/**
+ * Series: a set of quests one site offers together. The home page shows a series as one card that opens its page
+ * (/series/<id>), so a long set does not bury the rest of the catalog; the API and the quest log list every quest.
+ */
+const SERIES = Object.freeze({
+ 'live-setup': {
+ id: 'live-setup',
+ site: 'openvibe.live',
+ title: 'Set up your channel',
+ why: 'OpenVibe.Live\'s go-live setup list: a quest for each task, and one for finishing it all.',
+ how: 'Live tells Quest about a task the first time it sees it done: the next time you open OpenVibe.Live\'s home page or your setup list. A task done before this series existed counts too.',
+ href: 'https://openvibe.live/',
+ hrefLabel: 'Your setup list on OpenVibe.Live',
+ },
+});
+
const QUESTS = Object.freeze([
const QUESTS = Object.freeze([
{
{
id: 'go-live',
id: 'go-live',
@@ -149,6 +167,187 @@ const QUESTS = Object.freeze([
@@ -149,6 +167,187 @@ const QUESTS = Object.freeze([
reward: { badge: { id: 'settled-in', name: 'Settled In' }, coins: 20 },
reward: { badge: { id: 'settled-in', name: 'Settled In' }, coins: 20 },
repeatable: false,
repeatable: false,
},
},
+ {
+ id: 'live-setup-slot',
+ site: 'openvibe.live',
+ series: 'live-setup',
+ title: 'Create your stream slot',
+ why: 'Your show gets its own key, settings, VODs and restreams.',
+ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'slot' }, count: 1, label: 'Create your stream slot on OpenVibe.Live' }],
+ reward: { badge: { id: 'live-setup-slot-holder', name: 'Slot Holder' }, coins: 5 },
+ repeatable: false,
+ },
+ {
+ id: 'live-setup-method',
+ site: 'openvibe.live',
+ series: 'live-setup',
+ title: 'Choose how you stream',
+ why: 'From the browser, from OBS over RTMP, or from OBS over WHIP: picking one is half the setup.',
+ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'method' }, count: 1, label: 'Choose how you stream on OpenVibe.Live' }],
+ reward: { badge: { id: 'live-setup-method-picked', name: 'Method Picked' }, coins: 5 },
+ repeatable: false,
+ },
+ {
+ id: 'live-setup-restream',
+ site: 'openvibe.live',
+ series: 'live-setup',
+ title: 'Mirror to another platform',
+ why: 'One stream on Twitch, YouTube, Kick, RobotStreamer or any RTMP server at the same time.',
+ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'restream' }, count: 1, label: 'Mirror to another platform on OpenVibe.Live' }],
+ reward: { badge: { id: 'live-setup-restreamer', name: 'Restreamer' }, coins: 10 },
+ repeatable: false,
+ },
+ {
+ id: 'live-setup-profile',
+ site: 'openvibe.live',
+ series: 'live-setup',
+ title: 'Add an avatar and a bio',
+ why: 'The first thing people see on your channel and next to your name in chat.',
+ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'profile' }, count: 1, label: 'Add an avatar and a bio on OpenVibe.Live' }],
+ reward: { badge: { id: 'live-setup-profile-ready', name: 'Profile Ready' }, coins: 5 },
+ repeatable: false,
+ },
+ {
+ id: 'live-setup-offline',
+ site: 'openvibe.live',
+ series: 'live-setup',
+ title: 'Make an offline screen',
+ why: 'What visitors see while you are not live: an image, a video or your own page.',
+ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'offline' }, count: 1, label: 'Make an offline screen on OpenVibe.Live' }],
+ reward: { badge: { id: 'live-setup-offline-screen', name: 'Offline Screen' }, coins: 5 },
+ repeatable: false,
+ },
+ {
+ id: 'live-setup-emote',
+ site: 'openvibe.live',
+ series: 'live-setup',
+ title: 'Upload a custom emote',
+ why: 'Your chat, your inside jokes. Animated ones work too.',
+ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'emote' }, count: 1, label: 'Upload a custom emote on OpenVibe.Live' }],
+ reward: { badge: { id: 'live-setup-emote-maker', name: 'Emote Maker' }, coins: 5 },
+ repeatable: false,
+ },
+ {
+ id: 'live-setup-sound',
+ site: 'openvibe.live',
+ series: 'live-setup',
+ title: 'Add a sound command',
+ why: 'Viewers type !boom and your stream plays it.',
+ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'sound' }, count: 1, label: 'Add a sound command on OpenVibe.Live' }],
+ reward: { badge: { id: 'live-setup-sound-board', name: 'Sound Board' }, coins: 5 },
+ repeatable: false,
+ },
+ {
+ id: 'live-setup-goal',
+ site: 'openvibe.live',
+ series: 'live-setup',
+ title: 'Set a donation goal',
+ why: 'A visible target turns tips into a team effort.',
+ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'goal' }, count: 1, label: 'Set a donation goal on OpenVibe.Live' }],
+ reward: { badge: { id: 'live-setup-goal-setter', name: 'Goal Setter' }, coins: 5 },
+ repeatable: false,
+ },
+ {
+ id: 'live-setup-powerchat',
+ site: 'openvibe.live',
+ series: 'live-setup',
+ title: 'Connect PowerChat',
+ why: 'Card and crypto tips, with alerts on your stream.',
+ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'powerchat' }, count: 1, label: 'Connect PowerChat on OpenVibe.Live' }],
+ reward: { badge: { id: 'live-setup-tip-jar', name: 'Tip Jar' }, coins: 10 },
+ repeatable: false,
+ },
+ {
+ id: 'live-setup-panels',
+ site: 'openvibe.live',
+ series: 'live-setup',
+ title: 'Fill in your About panels',
+ why: 'Links, schedule and rules: what people read under the player.',
+ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'panels' }, count: 1, label: 'Fill in your About panels on OpenVibe.Live' }],
+ reward: { badge: { id: 'live-setup-panel-writer', name: 'Panel Writer' }, coins: 5 },
+ repeatable: false,
+ },
+ {
+ id: 'live-setup-share',
+ site: 'openvibe.live',
+ series: 'live-setup',
+ title: 'Get your first follower',
+ why: 'Share your channel link: a follower hears about it every time you go live.',
+ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'share' }, count: 1, label: 'Get your first follower on OpenVibe.Live' }],
+ reward: { badge: { id: 'live-setup-followed', name: 'Followed' }, coins: 10 },
+ repeatable: false,
+ },
+ {
+ id: 'live-setup-mods',
+ site: 'openvibe.live',
+ series: 'live-setup',
+ title: 'Add a moderator',
+ why: 'Someone you trust watching chat while you stream.',
+ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'mods' }, count: 1, label: 'Add a moderator on OpenVibe.Live' }],
+ reward: { badge: { id: 'live-setup-mod-squad', name: 'Mod Squad' }, coins: 5 },
+ repeatable: false,
+ },
+ {
+ id: 'live-setup-requests',
+ site: 'openvibe.live',
+ series: 'live-setup',
+ title: 'Open media requests',
+ why: 'Let viewers queue clips and tracks to play on your stream.',
+ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'requests' }, count: 1, label: 'Open media requests on OpenVibe.Live' }],
+ reward: { badge: { id: 'live-setup-request-line', name: 'Request Line' }, coins: 5 },
+ repeatable: false,
+ },
+ {
+ id: 'live-setup-moderation',
+ site: 'openvibe.live',
+ series: 'live-setup',
+ title: 'Set your chat rules',
+ why: 'Slow mode, followers-only and emote-only, tuned the way you want them.',
+ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'moderation' }, count: 1, label: 'Set your chat rules on OpenVibe.Live' }],
+ reward: { badge: { id: 'live-setup-rule-maker', name: 'Rule Maker' }, coins: 5 },
+ repeatable: false,
+ },
+ {
+ id: 'live-setup-robot',
+ site: 'openvibe.live',
+ series: 'live-setup',
+ title: 'Connect a robot',
+ why: 'Bind a robot from OpenVibe.Bot to your channel: its panel shows under your stream and chat can drive it.',
+ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'robot' }, count: 1, label: 'Connect a robot on OpenVibe.Live' }],
+ reward: { badge: { id: 'live-setup-robot-wrangler', name: 'Robot Wrangler' }, coins: 10 },
+ repeatable: false,
+ },
+ {
+ id: 'live-setup-aibot',
+ site: 'openvibe.live',
+ series: 'live-setup',
+ title: 'Add an AI chat regular',
+ why: 'Keeps an empty chat warm and answers the usual questions.',
+ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'aibot' }, count: 1, label: 'Add an AI chat regular on OpenVibe.Live' }],
+ reward: { badge: { id: 'live-setup-bot-host', name: 'Bot Host' }, coins: 5 },
+ repeatable: false,
+ },
+ {
+ id: 'live-setup-paste',
+ site: 'openvibe.live',
+ series: 'live-setup',
+ title: 'Publish a paste',
+ why: 'Code, configs or notes from your stream, each on a page of its own.',
+ steps: [{ event_type: 'live.setup.task_completed', match: { task: 'paste' }, count: 1, label: 'Publish a paste on OpenVibe.Live' }],
+ reward: { badge: { id: 'live-setup-paste-author', name: 'Paste Author' }, coins: 5 },
+ repeatable: false,
+ },
+ {
+ id: 'live-setup-complete',
+ site: 'openvibe.live',
+ series: 'live-setup',
+ title: 'Finish the whole go-live setup',
+ why: 'Every task on the list done: the channel is ready for whoever walks in.',
+ // Live sends each task once per person, so 18 of them is every task on the list.
+ steps: [{ event_type: 'live.setup.task_completed', count: 18, label: 'Finish every task on the setup list' }],
+ reward: { badge: { id: 'live-setup-complete', name: 'Fully Set Up' }, coins: 50 },
+ repeatable: false,
+ },
]);
]);
const BY_ID = new Map(QUESTS.map((q) => [q.id, q]));
const BY_ID = new Map(QUESTS.map((q) => [q.id, q]));
@@ -173,6 +372,7 @@ const EVENTS = Object.freeze([
@@ -173,6 +372,7 @@ const EVENTS = Object.freeze([
{ event_type: 'wiki.revision.created', site: 'openvibe.wiki', what: 'A wiki page got a new immutable revision; the payload names its author (a service edit is ignored).' },
{ event_type: 'wiki.revision.created', site: 'openvibe.wiki', what: 'A wiki page got a new immutable revision; the payload names its author (a service edit 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: '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.' },
]);
]);
// 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
@@ -182,6 +382,8 @@ const EVENT_TYPES = Object.freeze(COUNTED.slice().sort());
@@ -182,6 +382,8 @@ const EVENT_TYPES = Object.freeze(COUNTED.slice().sort());
{
{
const missing = QUESTS.flatMap((q) => q.steps.map((s) => s.event_type)).filter((t) => !COUNTED.includes(t));
const missing = QUESTS.flatMap((q) => q.steps.map((s) => s.event_type)).filter((t) => !COUNTED.includes(t));
if (missing.length) throw new Error(`quests/catalog.js: a quest counts an event that is not in EVENTS: ${[...new Set(missing)].join(', ')}`);
if (missing.length) throw new Error(`quests/catalog.js: a quest counts an event that is not in EVENTS: ${[...new Set(missing)].join(', ')}`);
+ const orphans = QUESTS.filter((q) => q.series && !(SERIES[q.series] && SERIES[q.series].site === q.site)).map((q) => q.id);
+ if (orphans.length) throw new Error(`quests/catalog.js: a quest names a series that is not in SERIES (or is on another site): ${orphans.join(', ')}`);
}
}
/** The sites, in the order they first appear in the catalog (so the pages read the same every time). */
/** The sites, in the order they first appear in the catalog (so the pages read the same every time). */
@@ -191,17 +393,36 @@ const byId = (id) => BY_ID.get(String(id)) || null;
@@ -191,17 +393,36 @@ const byId = (id) => BY_ID.get(String(id)) || null;
const all = () => QUESTS;
const all = () => QUESTS;
/** The quests that count this event type (a step may be shared: 'settle in' counts two sites). */
/** The quests that count this event type (a step may be shared: 'settle in' counts two sites). */
const withEvent = (eventType) => QUESTS.filter((q) => q.steps.some((s) => s.event_type === eventType));
const withEvent = (eventType) => QUESTS.filter((q) => q.steps.some((s) => s.event_type === eventType));
-/** The catalog grouped by site, for the home page and the API: [{ site, name, what, quests }]. */
-const grouped = () => SITE_ORDER.map((site) => ({
- site,
- name: (SITES[site] || {}).name || site,
- what: (SITES[site] || {}).what || '',
- quests: QUESTS.filter((q) => q.site === site),
-}));
+/** Whether a step counts this event: its type, and every value its `match` names in the payload. */
+const stepCounts = (step, event) => step.event_type === event.event_type
+ && Object.entries(step.match || {}).every(([k, v]) => ((event.payload || {})[k]) === v);
+/** The quests with a step that counts this very event (live.setup.task_completed for 'emote' moves two, not eighteen). */
+const forEvent = (event) => QUESTS.filter((q) => q.steps.some((s) => stepCounts(s, event)));
+/**
+ * The catalog grouped by site, for the home page and the API: [{ site, name, what, quests, series }]. With
+ * { fold: true } (the home page) a series' quests leave `quests` and `series` lists the series instead, each with its
+ * quests; otherwise `quests` has every quest and `series` is empty.
+ */
+const grouped = ({ fold = false } = {}) => SITE_ORDER.map((site) => {
+ const here = QUESTS.filter((q) => q.site === site);
+ const series = Object.values(SERIES).filter((x) => x.site === site)
+ .map((x) => ({ ...x, quests: here.filter((q) => q.series === x.id) })).filter((x) => x.quests.length);
+ return {
+ site,
+ name: (SITES[site] || {}).name || site,
+ what: (SITES[site] || {}).what || '',
+ quests: fold ? here.filter((q) => !q.series) : here,
+ series: fold ? series : [],
+ };
+});
+const seriesById = (id) => {
+ const x = Object.prototype.hasOwnProperty.call(SERIES, String(id)) ? SERIES[String(id)] : null;
+ return x ? { ...x, quests: QUESTS.filter((q) => q.series === x.id) } : null;
+};
/** The badge for a quest, by badge id (the public badge endpoint stores only the id). */
/** The badge for a quest, by badge id (the public badge endpoint stores only the id). */
const badgeById = (badgeId) => {
const badgeById = (badgeId) => {
for (const q of QUESTS) if (q.reward.badge.id === badgeId) return { ...q.reward.badge, quest_id: q.id };
for (const q of QUESTS) if (q.reward.badge.id === badgeId) return { ...q.reward.badge, quest_id: q.id };
return null;
return null;
};
};
-module.exports = { QUESTS, SITES, EVENTS, EVENT_TYPES, SITE_ORDER, all, byId, withEvent, grouped, badgeById };
+module.exports = { QUESTS, SITES, SERIES, EVENTS, EVENT_TYPES, SITE_ORDER, all, byId, withEvent, forEvent, stepCounts, grouped, seriesById, badgeById };
@@ -38,11 +38,11 @@ function medal(quest, { earned = false } = {}) {
@@ -38,11 +38,11 @@ function medal(quest, { earned = false } = {}) {
}
}
/** One quest in a list: the badge it pays, what it is, why, the steps as a checklist, and the reward. */
/** One quest in a list: the badge it pays, what it is, why, the steps as a checklist, and the reward. */
-function card(quest, { coinsEnabled = false } = {}) {
+function card(quest, { coinsEnabled = false, earned = false } = {}) {
return html`<li class="quest">
return html`<li class="quest">
- ${medal(quest)}
+ ${medal(quest, { earned })}
<div class="quest-main">
<div class="quest-main">
- <a class="quest-title" href="/quests/${quest.id}">${quest.title}</a>
+ <a class="quest-title" href="/quests/${quest.id}">${quest.title}</a>${earned ? html` <span class="done">done</span>` : ''}
<p class="quest-why">${quest.why}</p>
<p class="quest-why">${quest.why}</p>
<ul class="quest-steps">${quest.steps.map((s) => html`<li>${s.label}${s.count > 1 ? html` <span class="step-count">×${s.count}</span>` : ''}</li>`)}</ul>
<ul class="quest-steps">${quest.steps.map((s) => html`<li>${s.label}${s.count > 1 ? html` <span class="step-count">×${s.count}</span>` : ''}</li>`)}</ul>
<p class="quest-reward">${reward(quest, { coinsEnabled })}</p>
<p class="quest-reward">${reward(quest, { coinsEnabled })}</p>
@@ -50,13 +50,28 @@ function card(quest, { coinsEnabled = false } = {}) {
@@ -50,13 +50,28 @@ function card(quest, { coinsEnabled = false } = {}) {
</li>`;
</li>`;
}
}
-/** The catalog grouped by the site whose events each quest counts. */
+/** A series in a list (the home page): one card for all its quests, opening the series' page. */
+function seriesCard(series, { coinsEnabled = false } = {}) {
+ const coins = series.quests.reduce((n, q) => n + q.reward.coins, 0);
+ const n = series.quests.length;
+ return html`<li class="quest quest-series">
+ <span class="medal" style="--h:${hueOf(series.id)}" aria-hidden="true">${n}</span>
+ <div class="quest-main">
+ <a class="quest-title" href="/series/${series.id}">${series.title}</a>
+ <p class="quest-why">${series.why}</p>
+ <p class="quest-reward"><span class="reward"><span class="reward-badge"><strong>${n}</strong> quests, a badge each</span>${coins > 0 ? (coinsEnabled ? html`<span class="reward-coins"><strong>${coins}</strong> OpenCoins in all</span>` : html`<span class="reward-coins soon">OpenCoins rewards start soon</span>`) : ''}</span></p>
+ </div>
+</li>`;
+}
+
+/** The catalog grouped by the site whose events each quest counts (a folded catalog shows each series as one card). */
function list(groups = catalog.grouped(), { coinsEnabled = false } = {}) {
function list(groups = catalog.grouped(), { coinsEnabled = false } = {}) {
// The heading id is the domain with its dots replaced: an id with a dot is legal but awkward to target.
// The heading id is the domain with its dots replaced: an id with a dot is legal but awkward to target.
const anchor = (site) => `site-${String(site).replace(/[^a-z0-9-]/gi, '-')}`;
const anchor = (site) => `site-${String(site).replace(/[^a-z0-9-]/gi, '-')}`;
+ const count = (g) => g.quests.length + (g.series || []).reduce((n, x) => n + x.quests.length, 0);
return html`${groups.map((g) => html`<section class="site-group" aria-labelledby="${anchor(g.site)}">
return html`${groups.map((g) => html`<section class="site-group" aria-labelledby="${anchor(g.site)}">
- <h3 id="${anchor(g.site)}"><a href="https://${g.site}">${String(g.name).replace(/^OpenVibe\./, '')}</a><span class="site-count">${g.quests.length} quest${g.quests.length === 1 ? '' : 's'}</span>${g.what ? html` <small>${g.what}</small>` : ''}</h3>
- <ul class="quest-list">${g.quests.map((q) => card(q, { coinsEnabled }))}</ul>
+ <h3 id="${anchor(g.site)}"><a href="https://${g.site}">${String(g.name).replace(/^OpenVibe\./, '')}</a><span class="site-count">${count(g)} quest${count(g) === 1 ? '' : 's'}</span>${g.what ? html` <small>${g.what}</small>` : ''}</h3>
+ <ul class="quest-list">${g.quests.map((q) => card(q, { coinsEnabled }))}${(g.series || []).map((x) => seriesCard(x, { coinsEnabled }))}</ul>
</section>`)}`;
</section>`)}`;
}
}
@@ -79,4 +94,4 @@ ${dailyCap} OpenCoins per person per day and ${globalDailyCap} for everyone toge
@@ -79,4 +94,4 @@ ${dailyCap} OpenCoins per person per day and ${globalDailyCap} for everyone toge
never money — and never Vibes.</p>`;
never money — and never Vibes.</p>`;
}
}
-module.exports = { medal, hueOf, reward, card, list, progress, coinsNote };
+module.exports = { medal, hueOf, reward, card, seriesCard, list, progress, coinsNote };
@@ -38,6 +38,10 @@ const chatMessage = (user_subject) => envelope('chat.room.message.created', {
@@ -38,6 +38,10 @@ const chatMessage = (user_subject) => envelope('chat.room.message.created', {
message_id: 12, room: { id: 'room_1', slug: 'general', name: 'General' }, text: 'hello', created_at: iso(),
message_id: 12, room: { id: 'room_1', slug: 'general', name: 'General' }, text: 'hello', created_at: iso(),
user_subject, username: 'liv', display_name: 'Liv',
user_subject, username: 'liv', display_name: 'Liv',
}, { source: 'chat', actor: user_subject ? { type: 'user', id: user_subject } : { type: 'service', id: 'chat' } });
}, { source: 'chat', actor: user_subject ? { type: 'user', id: user_subject } : { type: 'service', id: 'chat' } });
+/** live.setup.task_completed (Contracts 0.190.0): one task of Live's go-live setup list, done for the first time. */
+const setupTask = (subject, task, done = 1) => envelope('live.setup.task_completed', {
+ task, user: { type: 'user', id: subject }, title: `Task ${task}`, group: 'Stream', completed_at: iso(), done, total: 18,
+}, { actor: { type: 'user', id: subject }, subject: { type: 'user', id: subject } });
const followCreated = (follower) => envelope('network.follow.created', {
const followCreated = (follower) => envelope('network.follow.created', {
follower, target_type: 'channel', target_id: 'usr_' + '0'.repeat(26), revision: 1,
follower, target_type: 'channel', target_id: 'usr_' + '0'.repeat(26), revision: 1,
}, { source: 'network', actor: { type: 'user', id: follower } });
}, { source: 'network', actor: { type: 'user', id: follower } });
@@ -140,6 +144,37 @@ const progressOf = async (t, subject, questId) => (await rows(t, 'SELECT step, c
@@ -140,6 +144,37 @@ const progressOf = async (t, subject, questId) => (await rows(t, 'SELECT step, c
assert.deepStrictEqual(await progressOf(t, u.subject, 'stream-three-times'), [3], 'progress is frozen at the target');
assert.deepStrictEqual(await progressOf(t, u.subject, 'stream-three-times'), [3], 'progress is frozen at the target');
});
});
+ await check('a Live setup task completes its own quest (step match) and counts toward finishing the whole setup', async () => {
+ const SETUP_TASKS = ['slot', 'method', 'restream', 'golive', 'profile', 'offline', 'emote', 'sound', 'goal', 'powerchat', 'panels', 'share', 'mods', 'requests', 'moderation', 'robot', 'aibot', 'paste'];
+ const u = t.network.addUser('setter-upper');
+ const emote = await deliver(t, setupTask(u.subject, 'emote'));
+ assert.strictEqual(emote.json().outcome, 'completed');
+ let done = await completions(t, u.subject);
+ assert.deepStrictEqual(done.map((c) => c.quest_id), ['live-setup-emote'], 'only the emote quest, not the other sixteen');
+ assert.deepStrictEqual(await progressOf(t, u.subject, 'live-setup-sound'), [], 'a step whose match differs is not counted');
+ assert.deepStrictEqual(await progressOf(t, u.subject, 'live-setup-complete'), [1], 'the whole-setup quest counts every task');
+ // "Go live for the first time" has no quest of its own (the go-live quest pays it) but counts toward the whole setup.
+ for (const task of SETUP_TASKS.filter((x) => x !== 'emote')) await deliver(t, setupTask(u.subject, task));
+ done = await completions(t, u.subject);
+ assert.strictEqual(done.length, 18, '17 task quests and the whole setup');
+ assert.ok(done.some((c) => c.quest_id === 'live-setup-complete'));
+ assert.ok((await badges(t, u.subject)).some((b) => b.badge_id === 'live-setup-complete'));
+ // A setup event without a person in the payload credits nobody, even with a user actor.
+ const nobody = envelope('live.setup.task_completed', { task: 'slot', user: null }, { actor: { type: 'user', id: u.subject } });
+ assert.strictEqual((await deliver(t, nobody)).json().outcome, 'ignored:subject');
+ // The series page shows a signed-in streamer what they have done; the home page folds the series into one card.
+ const page = await t.get('/series/live-setup', { as: u });
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /<progress max="18" value="18">/);
+ assert.ok(page.text.includes('href="/quests/live-setup-emote"'));
+ const home = await t.get('/');
+ assert.ok(home.text.includes('href="/series/live-setup"'), 'the home page links the series');
+ assert.ok(!home.text.includes('href="/quests/live-setup-emote"'), 'and does not list its quests one by one');
+ assert.strictEqual((await t.get('/series/nope')).status, 404);
+ const quest = await t.get('/quests/live-setup-emote');
+ assert.ok(quest.text.includes('<code>task</code> <code>emote</code>'), 'the quest page says which task it counts');
+ });
+
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);
@@ -27,7 +27,10 @@ const { render } = require('../server/render/html');
@@ -27,7 +27,10 @@ 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 <= 12, `${body.quests.length} quests`);
+ assert.ok(body.quests.length >= 8 && body.quests.length <= 32, `${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' } }]);
assert.deepStrictEqual(body.sites.map((s) => s.site), catalog.SITE_ORDER, 'grouped in catalog order');
assert.deepStrictEqual(body.sites.map((s) => s.site), catalog.SITE_ORDER, 'grouped in catalog order');
assert.strictEqual(body.sites.reduce((n, s) => n + s.quests.length, 0), catalog.all().length, 'every quest is in exactly one group');
assert.strictEqual(body.sites.reduce((n, s) => n + s.quests.length, 0), catalog.all().length, 'every quest is in exactly one group');
const live = body.sites.find((s) => s.site === 'openvibe.live');
const live = body.sites.find((s) => s.site === 'openvibe.live');