openvibers/openvibe.quest Commits

Catalogue Live setup tasks with exact event matching (#277)

OP OpenVibers committed 2 days ago · ba891df0cb · 5 files, +58 −15

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 22004ac..280948a 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,7 @@
## Unreleased
++- Catalogue Live's seventeen individually guided setup tasks, and count only the matching task event for each step.
- Keep personalized AI wording off until the person records an explicit choice and language; choose only guided step kinds for that person.
- Give guided picture, thread and calendar steps opaque per-person links and an authenticated step reader for the host page.
- Let a channel owner save a quest made from guided catalogue steps through a default-off API.
ddiff --git a/migrations/0015_daily_event_detail.sql b/migrations/0015_daily_event_detail.sql
nnew file mode 100644
iindex 0000000..59cee1a
---- /dev/null
++++ b/migrations/0015_daily_event_detail.sql
@@@ -0,0 +1,3 @@
++-- phase: expand
++-- Match a Live setup event to its one task without storing any other payload data.
++ALTER TABLE daily_events ADD COLUMN event_detail text;
ddiff --git a/server/quests/daily.js b/server/quests/daily.js
iindex 4fd5543..4a3a3ab 100644
---- a/server/quests/daily.js
++++ b/server/quests/daily.js
@@@ -6,6 +6,14 @@ const { randomBytes } = require('node:crypto');
const catalog = require('./catalog');
const { personOf } = require('./apply');
const store = require('./store');
++const SETUP_DAILY = new Set(['slot', 'method', 'profile', 'offline', 'emote', 'panels', 'share', 'moderation', 'paste']);
++const SETUP_TYPES = catalog.QUESTS.flatMap((quest) => quest.steps
++ .filter((step) => step.event_type === 'live.setup.task_completed' && step.match?.task)
++ .map((step) => Object.freeze({ id: `setup-${step.match.task}`, event_type: step.event_type,
++ site: 'openvibe.live', sentence: quest.title, href: 'https://openvibe.live/?guide=setup-hub',
++ weight: 3, hook: `live.setup.${step.match.task}`, help: quest.why, match_task: step.match.task,
++ daily_eligible: SETUP_DAILY.has(step.match.task), count: 1,
++ verification: 'the matching setup task event for this person', status: 'approved', source: 'quest' })));
const BASE_TYPES = Object.freeze([
['go-live', 'live.stream.started', 'openvibe.live', 'Go live for a stream', 'https://openvibe.live/broadcast', 3],
@@@ -25,7 +33,7 @@ const BASE_TYPES = Object.freeze([
].map(([id, event_type, site, sentence, href, weight, hook, help]) => Object.freeze({
id, event_type, site, sentence, href, weight, hook, help, count: 1, verification: 'event subject, count within the person\'s local day',
status: 'approved', source: 'quest',
--})));
++})).concat(SETUP_TYPES));
const KNOWN_TOPICS = new Set(catalog.EVENT_TYPES);
for (const t of BASE_TYPES) if (!KNOWN_TOPICS.has(t.event_type)) throw new Error(`Unknown daily event topic: ${t.event_type}`);
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
@@@ -65,6 +73,7 @@ function rotation(day, types, yesterday) {
function wireType(t) {
return { id: t.id, event_type: t.event_type, site: t.site, sentence: t.sentence, href: t.href,
...(t.hook ? { hook: t.hook, help: t.help } : {}),
++ ...(t.match_task ? { match_task: t.match_task, daily_eligible: t.daily_eligible } : {}),
weight: Number(t.weight), count: 1, verification: 'event subject, count within the person\'s local day',
status: t.status, source: t.source || t.project_id };
}
@@@ -124,7 +133,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
if (!DAY_RE.test(day) || !Number.isFinite(Date.parse(day + 'T00:00:00Z'))) return null;
const existing = await read(day);
if (existing) return existing;
-- const types = await allTypes();
++ const types = (await allTypes()).filter((step) => !step.match_task);
const priorDay = utcDay(Date.parse(day + 'T00:00:00Z') - 86400000);
const yesterday = await read(priorDay);
let chosen = null, title = '', story = '', source = 'rotation';
@@@ -156,12 +165,16 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
const cached = await readPersonal(subject, day);
if (cached) return cached;
const preference = await aiPreference(subject);
-- const types = (await allTypes()).filter((step) => step.hook && step.help);
++ const types = (await allTypes()).filter((step) => step.hook && step.help && step.daily_eligible !== false);
++ const completedSetup = await db.many(`SELECT quest_id FROM quest_progress
++ WHERE subject=$1 AND quest_id LIKE 'live-setup-%' AND count > 0`, [subject]);
++ const completedIds = new Set(completedSetup.map((row) => row.quest_id.replace(/^live-setup-/, 'setup-')));
const since = new Date(s.now() - 30 * 86400000).toISOString();
-- const history = await db.many('SELECT DISTINCT event_type FROM daily_events WHERE subject=$1 AND occurred_at >= $2', [subject, since]);
-- const done = new Set(history.map((x) => x.event_type));
-- const fresh = types.filter((x) => !done.has(x.event_type));
-- const candidates = fresh.length >= 3 ? fresh : types;
++ const history = await db.many('SELECT DISTINCT event_type,event_detail FROM daily_events WHERE subject=$1 AND occurred_at >= $2', [subject, since]);
++ const done = new Set(history.map((x) => `${x.event_type}:${x.event_detail || ''}`));
++ const available = types.filter((step) => !completedIds.has(step.id));
++ const fresh = available.filter((step) => !done.has(`${step.event_type}:${step.match_task || ''}`));
++ const candidates = fresh.length >= 3 ? fresh : available;
let chosen = null, title = '', story = '', source = 'rotation', lines = null;
if (preference.ai_text_enabled) try {
const ai = await generateAi(day, candidates, null, preference.language);
@@@ -174,7 +187,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
} catch (err) { log.warn('[Daily quest] Personal AI unavailable:', err.message); }
if (!chosen) {
try { chosen = rotation(day, candidates, null); }
-- catch { chosen = rotation(day, types, null); }
++ catch { chosen = rotation(day, available, null); }
title = 'Make your way around OpenVibe';
story = 'Three small things, across the network, to make today yours.';
}
@@@ -199,7 +212,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
}
async function putChannelQuest(subject, input) {
const title = typeof input?.title === 'string' ? input.title.trim() : '';
-- const types = (await allTypes()).filter((step) => step.hook && step.help);
++ const types = (await allTypes()).filter((step) => step.hook && step.help && !step.match_task);
const steps = validateSelection(input?.steps, types, null);
if (!title || title.length > 80 || !steps) return null;
await db.exec(`INSERT INTO channel_daily_quests(subject,title,steps_json,updated_at) VALUES($1,$2,$3,$4)
@@@ -240,8 +253,9 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
if (!subject || !KNOWN_TOPICS.has(event.event_type)) return null;
const occurred = new Date(event.timestamp);
if (!Number.isFinite(occurred.getTime()) || Math.abs(s.now() - occurred.getTime()) > 3 * 86400000) return null;
-- await tx.exec(`INSERT INTO daily_events (event_id,subject,event_type,occurred_at) VALUES ($1,$2,$3,$4)
-- ON CONFLICT (event_id) DO NOTHING`, [event.event_id, subject, event.event_type, occurred.toISOString()]);
++ const detail = event.event_type === 'live.setup.task_completed' && typeof event.payload?.task === 'string' ? event.payload.task : null;
++ await tx.exec(`INSERT INTO daily_events (event_id,subject,event_type,occurred_at,event_detail) VALUES ($1,$2,$3,$4,$5)
++ ON CONFLICT (event_id) DO NOTHING`, [event.event_id, subject, event.event_type, occurred.toISOString(), detail]);
return subject;
}
// Read-only: unlike progress(), a card must never ensure quests or settle rewards.
@@@ -263,12 +277,14 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
const q = selectedQuest || await ensure(day);
if (!q) return null;
const start = new Date(day + 'T00:00:00Z').getTime();
-- const events = await db.many(`SELECT event_type,occurred_at FROM daily_events
++ const events = await db.many(`SELECT event_type,occurred_at,event_detail FROM daily_events
WHERE subject=$1 AND occurred_at >= $2 AND occurred_at < $3`,
[subject, new Date(start - 86400000).toISOString(), new Date(start + 2 * 86400000).toISOString()]);
const local = events.filter((e) => localDay(e.occurred_at, zone) === day);
-- const steps = q.steps.map((step) => ({ ...step, progress: Math.min(step.count, local.filter((e) => e.event_type === step.event_type).length),
-- done: local.some((e) => e.event_type === step.event_type) }));
++ const counts = (step) => local.filter((event) => event.event_type === step.event_type
++ && (!step.match_task || event.event_detail === step.match_task)).length;
++ const steps = q.steps.map((step) => ({ ...step, progress: Math.min(step.count, counts(step)),
++ done: counts(step) >= step.count }));
const done = steps.every((step) => step.done);
if (done && savedZone) {
const at = s.iso();
ddiff --git a/test/daily.test.js b/test/daily.test.js
iindex 61890c2..67ba3ce 100644
---- a/test/daily.test.js
++++ b/test/daily.test.js
@@@ -11,6 +11,17 @@ const { ids, serviceAuth } = require('openvibe-contracts');
}) });
const user = t.network.addUser('daily');
try {
++ await check('catalogue carries the Live setup controls and at least thirty kinds', async () => {
++ const kinds = await t.ctx.daily.allTypes();
++ assert.ok(kinds.length >= 30);
++ const setup = kinds.filter((step) => step.id.startsWith('setup-'));
++ assert.strictEqual(setup.length, 17);
++ for (const step of setup) {
++ assert.strictEqual(step.hook, `live.setup.${step.match_task}`);
++ assert.strictEqual(new URL(step.href).hostname, 'openvibe.live');
++ assert.ok(step.help);
++ }
++ });
await check('today reader is private and permits network-site bearer requests', async () => {
assert.strictEqual((await t.get('/api/v1/quests/today')).status, 401);
const preflight = await t.get('/api/v1/quests/today', { method: 'OPTIONS', headers: { origin: 'https://openvibe.pics', 'access-control-request-method': 'GET' } });
@@@ -24,6 +35,7 @@ const { ids, serviceAuth } = require('openvibe-contracts');
assert.strictEqual(validateSelection(['thread', 'reply', 'comment'], BASE_TYPES, null), null);
const today = await t.ctx.daily.ensure();
assert.deepStrictEqual(today.steps.map((x) => x.id), ['go-live', 'chat', 'wiki']);
++ assert.ok(today.steps.every((step) => !step.match_task), 'the public quest does not assign streamer-only setup tasks to everyone');
assert.strictEqual(today.source, 'ai');
const same = await t.ctx.daily.ensure();
assert.deepStrictEqual(same, today);
@@@ -161,6 +173,16 @@ const { ids, serviceAuth } = require('openvibe-contracts');
assert.strictEqual(publicQuest.json().title, request.title);
} finally { await g.close(); }
});
++ await check('one setup task event cannot finish a different task', async () => {
++ const task = BASE_TYPES.find((step) => step.id === 'setup-slot');
++ const chosen = { date: '2026-11-08', title: 'Set up', story: '', source: 'rotation', steps: [task] };
++ const base = { event_type: 'live.setup.task_completed', timestamp: '2026-11-08T18:00:00Z',
++ payload: { user: { type: 'user', id: user.subject } } };
++ await t.ctx.daily.recordEvent(t.ctx.s.db, { ...base, event_id: 'evt_setup_method', payload: { ...base.payload, task: 'method' } });
++ assert.strictEqual((await t.ctx.daily.progress(user.subject, chosen.date, chosen)).steps[0].done, false);
++ await t.ctx.daily.recordEvent(t.ctx.s.db, { ...base, event_id: 'evt_setup_slot', payload: { ...base.payload, task: 'slot' } });
++ assert.strictEqual((await t.ctx.daily.progress(user.subject, chosen.date, chosen)).steps[0].done, true);
++ });
} finally { await t.close(); }
done();
})().catch((err) => { process.stderr.write(String(err.stack || err) + '\n'); process.exit(1); });
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex ccaca41..cca0c26 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -21,7 +21,8 @@ const BUDGETS = {
// Raised 2026-10-22 with the rich home: figures under the hero, the most-earned badges, each card's "earned by",
// the badges nobody holds yet, the sites Quest follows, a FAQ and the quests as ItemList + FAQPage JSON-LD.
// Measured 54.3 KB raw / 10.6 KB brotli on a fresh database.
-- htmlRawKB: 89.5, // 89.5 openvibe-shared v4.202.0 (2026-11-11: measured 89.5, raised by pin-bump from 87); 87: measured 86.5 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 83.2 with the daily quest as a card of three rows and its glyphs drawn inline (2026-11-10); before: 82.6 with the daily quest hero (2026-11-09); 81.3 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 80.6 with 2026-11-01: the hero orbit (15 sites, inline icons) (2026-11-01); 58.6 the home a taste and a door (2026-10-29): rails of kit cards and folds instead of every quest card and the sites list; 67.5 the hero's and the numbers' icons (inline ring icons, 2026-10-28); 61.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; measured 54.3 (the home page: hero, figures, badges, features, the quests, sites, FAQ, cta, JSON-LD)
++ // Shared 4.205.0 on main measures 89.9 KB raw; 89.5 KB came from Shared 4.202.0.
++ htmlRawKB: 90, // 89.5 openvibe-shared v4.202.0 (2026-11-11: measured 89.5, raised by pin-bump from 87); 87: measured 86.5 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 83.2 with the daily quest as a card of three rows and its glyphs drawn inline (2026-11-10); before: 82.6 with the daily quest hero (2026-11-09); 81.3 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 80.6 with 2026-11-01: the hero orbit (15 sites, inline icons) (2026-11-01); 58.6 the home a taste and a door (2026-10-29): rails of kit cards and folds instead of every quest card and the sites list; 67.5 the hero's and the numbers' icons (inline ring icons, 2026-10-28); 61.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; measured 54.3 (the home page: hero, figures, badges, features, the quests, sites, FAQ, cta, JSON-LD)
htmlBrotliKB: 17.5, // 17.5 openvibe-shared v4.202.0 (2026-11-11: measured 17.5, raised by pin-bump from 17); 17: measured 16.9 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 16.8: measured 16.3 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 394.5, // 394.5: measured 394.1 with openvibe-shared 4.197.0 (2026-11-10: the menu's row of places, the You tab's row, the Sign in mark, the tooltip); 393: measured 392.5 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 376.7: measured 368.7 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09