openvibers/openvibe.quest Commits

Quest experience awards and the Network levels page (#262)

OP OpenVibers committed 3 days ago · 5bcfc6807c · 9 files, +118 −6

* Award quest experience once and show the shared level board

* Merge current main and retain the released levels SDK

* Merge personal daily quests and move experience migration to 0012

Changes

ddiff --git a/migrations/0012_experience_rewards.sql b/migrations/0012_experience_rewards.sql
nnew file mode 100644
iindex 0000000..d15c8b7
---- /dev/null
++++ b/migrations/0012_experience_rewards.sql
@@@ -0,0 +1,5 @@
++-- phase: expand
++-- Completion is the durable outbox: retries reuse its existing id.
++ALTER TABLE quest_completions ADD COLUMN IF NOT EXISTS experience_state text NOT NULL DEFAULT 'pending'
++ CHECK (experience_state IN ('pending', 'awarded', 'skipped'));
++CREATE INDEX IF NOT EXISTS quest_completions_experience_pending ON quest_completions(completed_at) WHERE experience_state = 'pending';
ddiff --git a/package-lock.json b/package-lock.json
iindex 66e28df..d3fc5fa 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.136.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.186.0",
"pg": "^8.23.0"
},
@@@ -1181,9 +1181,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.125.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
-- "integrity": "sha512-hGYmiaYLk/QrZC7w0opFAlEQi9fgDct0qcwc4cVBNBzCPm8HSRZlibYBe5x+NVshM3kqgQ6LZihqvFj1LP51bg==",
++ "version": "0.136.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.136.0",
++ "integrity": "sha512-iPi6gijL8mE961KMgIxh4wFey2GlJMOhmnQQnqPFr1qEY6YGE/3hzLY7w1JQUUSFZrR6/f822CQzPpPIFiMB7g==",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex a412150..1c4042d 100644
---- a/package.json
++++ b/package.json
@@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.136.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.186.0",
"pg": "^8.23.0"
},
ddiff --git a/server/app.js b/server/app.js
iindex cc4c4e1..14e7499 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -60,6 +60,7 @@ async function createApp(opts = {}) {
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const principal = createPrincipal({ config, keys });
const coins = createCoins({ config, s, fetchImpl, log });
++ const levels = require('./quests/levels').createLevels({ config, s, fetchImpl });
const items = createItems({ config, s, fetchImpl, log });
// Account export and deletion (ADR-033, ./identity/account-data.js): the three tables that hold a person's rows.
// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;
@@@ -77,7 +78,7 @@ async function createApp(opts = {}) {
const eventsConsumer = createEventsConsumer({ db: s.db, s, secrets: config.events.secrets, coins, items, accountData, accountSend, appQuests, daily, now: s.now, log });
// The quests in OpenVibe.Search (./search-index.js), swept on its own timer once server/index.js starts it.
const searchIndex = opts.searchIndex || createSearchIndex({ config, s, log });
-- const ctx = { config, s, keys, sso, principal, coins, items, daily, glyphs, eventsConsumer, accountData, accountSend, searchIndex, appQuests, log };
++ const ctx = { config, s, keys, sso, principal, levels, coins, items, daily, glyphs, eventsConsumer, accountData, accountSend, searchIndex, appQuests, log };
const app = express();
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 8ebdbf9..bdf3fa8 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -51,6 +51,19 @@ function questDescription(quest) {
function createPageRoutes(ctx) {
const { config, s, coins } = ctx;
const r = asyncRouter();
++ r.get('/levels', async (req, res) => {
++ res.set('Cache-Control', req.viewer?.kind === 'user' ? 'private, no-store' : 'public, max-age=30');
++ const client = ctx.levels.readClient(req.viewer?.token);
++ let board, mine;
++ try {
++ [board, mine] = await Promise.all([client.board(), req.viewer?.username ? client.get(req.viewer.username) : null]);
++ } catch { return page(req, res, { title: 'Levels', body: html`<h1>Levels</h1><p>Experience is temporarily unavailable. Try again shortly.</p>` }, 503); }
++ const renderBoard = (label, data) => html`<section><h2>${label}</h2><ol>${data.top.map(e => html`<li value="${e.rank}"><a href="https://openvibe.network/@${encodeURIComponent(e.username)}">${e.username}</a> — Level ${e.level} · ${e.xp} experience</li>`)}</ol>${data.me ? html`<p>Your place: ${data.me.rank}</p><ol>${data.around.map(e => html`<li value="${e.rank}">${e.username} — Level ${e.level} · ${e.xp} experience</li>`)}</ol>` : ''}</section>`;
++ return page(req, res, { title: 'Levels', styles: KIT_STYLES, body: html`<h1>Levels</h1>
++ ${mine ? html`<section><h2>Your level: ${mine.level}</h2><p>${mine.xp} experience · ${mine.next_at - mine.xp} to the next level</p><progress aria-label="Experience to next level" value="${mine.into}" max="${mine.span}"></progress><h3>Recent experience</h3><ul>${mine.recent.map(e => html`<li>${e.name} · ${e.amount} · <time datetime="${e.at}">${e.at.slice(0, 10)}</time></li>`)}</ul></section>` : html`<p><a href="/me">Sign in to see your level</a></p>`}
++ ${renderBoard('All time', board.all_time)}${renderBoard('This week', board.week)}
++ <section><h2>How experience is earned</h2><p>Earned OpenCoins add the same amount of experience. Finishing a platform quest adds 25 extra experience. Experience is never bought or spent.</p><p><a href="https://openvibe.network/my#profile">Choose whether to appear on the leaderboard</a>. Your profile still shows your level.</p></section>` });
++ });
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
// The pages with a Markdown twin (openvibe-shared/agent-layer): the public ones only.
const TWIN_PATH_RE = /^\/(?:how-it-works|build-a-step-type|updates|quests|quests\/[^/]+|series\/[^/]+|apps\/quests\/[^/]+)?$/;
ddiff --git a/server/index.js b/server/index.js
iindex 364edea..9eecc62 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -47,6 +47,10 @@ async function start() {
const secret = config.events.secrets[0] || '';
const subscriptions = require('./events-consumer').startSubscriptions({ config, port: config.port, secret });
const timers = [];
++ const levelsTimer = setInterval(() => ctx.levels.retryPending().catch(() => {}), 30000);
++ levelsTimer.unref();
++ timers.push(levelsTimer);
++ ctx.levels.retryPending().catch(() => {});
const coinsTimer = ctx.coins.start();
if (coinsTimer) timers.push(coinsTimer);
// Item rewards owed (a failed grant, or a completion from before items) are granted by the same kind of timer.
ddiff --git a/server/quests/levels.js b/server/quests/levels.js
nnew file mode 100644
iindex 0000000..3443af2
---- /dev/null
++++ b/server/quests/levels.js
@@@ -0,0 +1,40 @@
++'use strict';
++const { serviceAuth } = require('openvibe-contracts');
++const { createClient } = require('openvibe-sdk/core');
++const { createLevelsClient } = require('openvibe-sdk/levels');
++const catalog = require('./catalog');
++const EXTRA_XP = 25;
++function createLevels({ config, s, fetchImpl = globalThis.fetch }) {
++ const tokens = serviceAuth.createTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret, audience: 'openvibe.network', scope: 'network.levels.award', fetchImpl });
++ const awards = createLevelsClient(createClient({ baseUrls: { network: config.networkInternalUrl }, getToken: () => tokens.getToken(), fetch: fetchImpl, retries: 0, timeoutMs: 5000 }));
++ let running = false;
++ async function settle(row) {
++ // Developer-defined badge quests cannot mint platform experience from arbitrary app events.
++ if (!catalog.byId(row.quest_id) && !/^daily-\d{4}-\d{2}-\d{2}$/.test(row.quest_id)) {
++ await s.db.exec("UPDATE quest_completions SET experience_state = 'skipped' WHERE id = $1", [row.id]); return false;
++ }
++ try { await awards.award({ subject: row.subject, amount: EXTRA_XP, reason: 'quest.completed', idempotency_key: row.id }); }
++ catch (e) {
++ if (e.status === 404 && e.code === 'levels.person_not_found') {
++ await s.db.exec("UPDATE quest_completions SET experience_state = 'skipped' WHERE id = $1", [row.id]); return false;
++ }
++ if (e.status !== 409 || e.code !== 'levels.duplicate') throw e;
++ }
++ await s.db.exec("UPDATE quest_completions SET experience_state = 'awarded' WHERE id = $1", [row.id]);
++ return true;
++ }
++ async function retryPending() {
++ if (running) return { awarded: 0 };
++ running = true;
++ let awarded = 0;
++ try {
++ const rows = await s.db.many("SELECT id, subject, quest_id FROM quest_completions WHERE experience_state = 'pending' ORDER BY completed_at LIMIT 50");
++ for (const row of rows) { try { if (await settle(row)) awarded++; } catch { /* durable pending row retries next tick; never log a credential */ } }
++ } finally { running = false; }
++ return { awarded };
++ }
++ function readClient(token) { return createLevelsClient(createClient({ baseUrls: { network: config.networkInternalUrl }, token: token || undefined, fetch: fetchImpl, retries: 0, timeoutMs: 5000 })); }
++ return { settle, retryPending, readClient };
++}
++module.exports = { createLevels, EXTRA_XP };
ddiff --git a/test/levels-page.test.js b/test/levels-page.test.js
nnew file mode 100644
iindex 0000000..415204a
---- /dev/null
++++ b/test/levels-page.test.js
@@@ -0,0 +1,25 @@
++'use strict';
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++(async () => {
++ const t = await boot();
++ const user = t.network.addUser('levels_page');
++ const entry = { subject: user.subject, username: 'levels_page', xp: 125, level: 2, rank: 1 };
++ t.ctx.levels.readClient = () => ({
++ board: async () => ({ all_time: { top: [entry], me: entry, around: [entry] }, week: { top: [entry], me: entry, around: [entry] } }),
++ get: async () => ({ level: 2, xp: 125, into: 25, span: 200, next_at: 300, rank: 1, recent: [{ name: 'Finished a quest', amount: 25, at: '2026-11-09T12:00:00Z' }] }),
++ });
++ try {
++ await check('public levels page shows boards and sign-in; signed-in page shows progress and named history', async () => {
++ let r = await t.get('/levels'); assert.equal(r.status, 200); assert(r.text.includes('All time')); assert(r.text.includes('Sign in to see your level'));
++ r = await t.get('/levels', { as: user }); assert.equal(r.status, 200);
++ assert(r.text.includes('Your level: 2')); assert(r.text.includes('Finished a quest')); assert(r.text.includes('value="25" max="200"'));
++ assert.equal(r.headers.get('cache-control'), 'private, no-store');
++ });
++ await check('Network outage gives a recoverable page', async () => {
++ t.ctx.levels.readClient = () => ({ board: async () => { throw new Error('down'); } });
++ const r = await t.get('/levels'); assert.equal(r.status, 503); assert(r.text.includes('temporarily unavailable'));
++ });
++ } finally { await t.close(); }
++ done();
++})().catch(() => process.exit(1));
ddiff --git a/test/levels.test.js b/test/levels.test.js
nnew file mode 100644
iindex 0000000..10c516c
---- /dev/null
++++ b/test/levels.test.js
@@@ -0,0 +1,24 @@
++'use strict';
++const assert = require('assert');
++const { createLevels } = require('../server/quests/levels');
++(async () => {
++ const state = new Map(); const sent = new Map(); let down = false;
++ const row = { id: 'qcp_probe', subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1', quest_id: 'follow-someone' };
++ const db = {
++ exec: async (_sql, [id]) => state.set(id, 'done'),
++ many: async () => state.has(row.id) ? [] : [row],
++ };
++ const fetchImpl = async (url, options) => {
++ if (String(url).endsWith('/oauth/token')) return new Response(JSON.stringify({ access_token: 'test-service-token', expires_in: 300 }), { status: 200 });
++ if (down) return new Response('{}', { status: 503 });
++ const body = JSON.parse(options.body); const duplicate = sent.has(body.idempotency_key);
++ if (!duplicate) sent.set(body.idempotency_key, body.amount);
++ return new Response(JSON.stringify(duplicate ? { code: 'levels.duplicate' } : { level: 1 }), { status: duplicate ? 409 : 201, headers: { 'content-type': 'application/json' } });
++ };
++ const levels = createLevels({ config: { networkInternalUrl: 'https://network.example', oauth: { clientId: 'quest', clientSecret: 'test-only' } }, s: { db }, fetchImpl });
++ down = true; await levels.retryPending(); assert(!state.has(row.id), 'outage leaves durable pending completion');
++ down = false; await levels.retryPending(); assert.equal(sent.get(row.id), 25); assert(state.has(row.id));
++ state.clear(); await levels.retryPending(); assert.equal(sent.size, 1, 'crash replay changes XP once'); assert(state.has(row.id), '409 settles the retry');
++ await levels.settle({ ...row, id: 'app-row', quest_id: 'app-custom' }); assert.equal(sent.size, 1, 'app events do not mint platform XP');
++ console.log('levels: completed quest awards 25 once; outages retry; duplicate refusal settles; app quests excluded');
++})().catch(e => { console.error(e); process.exitCode = 1; });