openvibers/openvibe.quest Commits

Quest: Set up your channel, a quest per Live setup task (live.setup.task_completed) (#52)

OP OpenVibers committed 29 days ago · 677fcda738 · 11 files, +336 −26

Changes

ddiff --git a/README.md b/README.md
iindex 45efb25..20453ef 100644
---- a/README.md
++++ b/README.md
@@@ -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) its
coins 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` |
ddiff --git a/public/css/app.css b/public/css/app.css
iindex c430595..18cf5c1 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -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; }
ddiff --git a/server/events-consumer.js b/server/events-consumer.js
iindex 89ec94d..351a9e1 100644
---- a/server/events-consumer.js
++++ b/server/events-consumer.js
@@@ -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.
*
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 37782a7..145e94e 100644
---- a/server/http/api.js
++++ b/server/http/api.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}`,
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 1b25b9a..89f2314 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -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 },
];
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 77788c8..41577ce 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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, {
ddiff --git a/server/quests/apply.js b/server/quests/apply.js
iindex 55ef9ea..e265edf 100644
---- a/server/quests/apply.js
++++ b/server/quests/apply.js
@@@ -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;
ddiff --git a/server/quests/catalog.js b/server/quests/catalog.js
iindex e49af70..eaa1197 100644
---- a/server/quests/catalog.js
++++ b/server/quests/catalog.js
@@@ -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 };
ddiff --git a/server/render/quests.js b/server/render/quests.js
iindex 96e6d91..ed73cc7 100644
---- a/server/render/quests.js
++++ b/server/render/quests.js
@@@ -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 toge
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 };
ddiff --git a/test/events-consumer.test.js b/test/events-consumer.test.js
iindex a31ace0..aa04cde 100644
---- a/test/events-consumer.test.js
++++ b/test/events-consumer.test.js
@@@ -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, c
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 () => {
const before = (await completions(t, sam.subject)).length;
const guest = 'gst_' + '0'.repeat(26);
ddiff --git a/test/quests-api.test.js b/test/quests-api.test.js
iindex ca3acaf..f467df4 100644
---- a/test/quests-api.test.js
++++ b/test/quests-api.test.js
@@@ -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');