openvibers/openvibe.play Commits
Expose public person achievement feed (#215)
Changes
@@@ -162,7 +162,7 @@ async function createApp(opts = {}) {// Public reads from any site: the titles, their leaderboards and a person's achievements answer every origin,// without credentials (a game's own page draws its board with them; any site shows a profile's showcase). Everything else keeps the same-site resource policy and no CORS. A preflight// is answered here.-- const PUBLIC_READ = /^\/(?:ping|titles(?:\/[^/]+(?:\/boards\/[^/]+)?)?|people\/[^/]+\/achievements)$/;++ const PUBLIC_READ = /^\/(?:ping|titles(?:\/[^/]+(?:\/boards\/[^/]+)?)?|people\/[^/]+\/achievements|achievements\/feed)$/;app.use('/api/v1', (req, res, next) => {if (!PUBLIC_READ.test(req.path) || !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();res.setHeader('Access-Control-Allow-Origin', '*');@@@ -9,6 +9,7 @@* GET /titles/:title/boards/:board?period=&page= public a leaderboard: rank, name, score (never a player id)* GET /people/:subject/achievements public a person's unlocks across every title (their profile:* OpenVibe.Network's /@name and any site's showcase)++ * GET /achievements/feed public recent person unlocks, cursor paginated* GET /me/achievements a person their own unlocks across titles* PUT /titles/:title play.title.manage register or update a title the caller manages* DELETE /titles/:title play.title.manage delete a sandbox title the caller manages (204)@@@ -65,6 +66,27 @@ function createApi(ctx) {return res.json(await play.board(req.params.title, req.params.board, { period: String(req.query.period || 'all'), page }));}));++ r.get('/achievements/feed', reads, wrap(async (req, res) => {++ const limit = req.query.limit === undefined ? 50 : Number(req.query.limit);++ if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new PlayError(400, 'play.bad_limit', 'limit is 1 to 100');++ let before = null;++ if (req.query.cursor !== undefined) {++ const encoded = String(req.query.cursor);++ try {++ if (!/^[A-Za-z0-9_-]{1,500}$/.test(encoded)) throw new Error('bad cursor');++ const raw = Buffer.from(encoded, 'base64url');++ if (raw.toString('base64url') !== encoded) throw new Error('bad cursor');++ before = JSON.parse(raw.toString('utf8'));++ if (!Array.isArray(before) || before.length !== 4 || !Number.isSafeInteger(before[0]) || before[0] < 0++ || !/^[a-z0-9-]{2,40}$/.test(before[1]) || !/^[a-z0-9_-]{1,64}$/.test(before[2])++ || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(before[3])) throw new Error('bad cursor');++ } catch { throw new PlayError(400, 'play.bad_cursor', 'invalid feed cursor'); }++ }++ const out = await play.achievementFeed({ before, limit });++ res.set('Cache-Control', 'public, max-age=60');++ return res.json({ items: out.items, next: out.next ? Buffer.from(JSON.stringify(out.next)).toString('base64url') : null });++ }));++// A person's unlocks, for their public profile (Steam-style: what you earned shows wherever you are). Only people// (usr_…): a guest's rows are a game's own until it adopts them (404). Someone with no unlocks yet gets an empty list,// the same answer as a subject Play has never seen, so the read never says whether an account exists.@@@ -300,6 +300,21 @@ function createPlay({ s }) {return [...by.values()];}++ /** Public person unlocks across every production title, newest first. Guest and sandbox rows stay private. */++ async function achievementFeed({ before = null, limit = 50 } = {}) {++ const rows = await db.many(`SELECT u.player, u.title_id, t.name AS title_name, u.achievement_id, a.name, a.description, u.unlocked_at++ FROM play_unlocks u JOIN play_titles t ON t.id = u.title_id++ JOIN play_achievements a ON a.title_id = u.title_id AND a.id = u.achievement_id++ WHERE u.player ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$' AND t.id NOT LIKE 'sandbox-%'++ AND ($1::bigint IS NULL OR (u.unlocked_at, u.title_id, u.achievement_id, u.player) < ($1::bigint, $2::text, $3::text, $4::text))++ ORDER BY u.unlocked_at DESC, u.title_id DESC, u.achievement_id DESC, u.player DESC LIMIT $5`,++ [before ? before[0] : null, before ? before[1] : null, before ? before[2] : null, before ? before[3] : null, limit + 1]);++ const next = rows.length > limit ? rows[limit - 1] : null;++ return { items: rows.slice(0, limit).map((r) => ({ player: r.player, title: r.title_id, title_name: r.title_name,++ id: r.achievement_id, name: r.name, description: r.description, unlocked_at: new Date(Number(r.unlocked_at)).toISOString() })),++ next: next ? [Number(next.unlocked_at), next.title_id, next.achievement_id, next.player] : null };++ }++// ── cloud saves ──const slotOf = (v) => { if (!SLOT_RE.test(String(v || ''))) fail(400, 'play.bad_slot', 'a slot is 1 to 32 of a-z 0-9 _ - (starting with a letter or digit)'); return String(v); };const saveView = (r, withData) => ({ slot: r.slot, version: Number(r.version), size: Number(r.size), created_at: new Date(Number(r.created_at)).toISOString(), updated_at: new Date(Number(r.updated_at)).toISOString(), ...(withData ? { data: r.data } : {}) });@@@ -353,7 +368,7 @@ function createPlay({ s }) {return (await db.exec('DELETE FROM play_saves WHERE title_id = $1 AND player = $2 AND slot = $3', [titleId, player(person), slotOf(slot)])) > 0;}-- return { define, removeSandbox, recordScores, unlock, adopt, board, title, titles, playerAchievements, ownTitle,++ return { define, removeSandbox, recordScores, unlock, adopt, board, title, titles, playerAchievements, achievementFeed, ownTitle,putSave, getSave, listSaves, deleteSave, mySaves, deleteMySave };}@@@ -142,6 +142,23 @@ const DAY = 86_400_000;assert.strictEqual((await t.get('/api/v1/people/not-a-subject/achievements')).status, 404);});++ await check('the public achievement feed pages across people and excludes guests', async () => {++ const guest = 'guest:feedguest123';++ await t.get('/api/v1/titles/salvage-rush/unlocks', { ...games, json: { player: guest, achievements: ['first-haul'] } });++ const first = await t.get('/api/v1/achievements/feed?limit=1', { headers: { Origin: 'https://openvibe.community' } });++ assert.strictEqual(first.status, 200, first.text);++ assert.strictEqual(first.headers.get('access-control-allow-origin'), '*');++ assert.strictEqual(first.json().items.length, 1);++ assert.ok(first.json().items[0].player.startsWith('usr_'));++ const second = await t.get(`/api/v1/achievements/feed?limit=1&cursor=${encodeURIComponent(first.json().next)}`);++ assert.strictEqual(second.status, 200, second.text);++ assert.notDeepStrictEqual(second.json().items[0], first.json().items[0]);++ const full = (await t.get('/api/v1/achievements/feed?limit=100')).json();++ assert.ok(full.items.length >= 3);++ assert.ok(full.items.every((i) => i.player.startsWith('usr_') && i.title === 'salvage-rush'));++ assert.strictEqual((await t.get('/api/v1/achievements/feed?cursor=bad')).json().code, 'play.bad_cursor');++ });++await check('the player pages moved to OpenVibe.Games; llms.txt says where and keeps the API links', async () => {for (const [from, to] of [['/', 'https://openvibe.games/achievements'],
@@ -162,7 +162,7 @@ async function createApp(opts = {}) {
@@ -162,7 +162,7 @@ async function createApp(opts = {}) {
// Public reads from any site: the titles, their leaderboards and a person's achievements answer every origin,
// Public reads from any site: the titles, their leaderboards and a person's achievements answer every origin,
// without credentials (a game's own page draws its board with them; any site shows a profile's showcase). Everything else keeps the same-site resource policy and no CORS. A preflight
// without credentials (a game's own page draws its board with them; any site shows a profile's showcase). Everything else keeps the same-site resource policy and no CORS. A preflight
// is answered here.
// is answered here.
- const PUBLIC_READ = /^\/(?:ping|titles(?:\/[^/]+(?:\/boards\/[^/]+)?)?|people\/[^/]+\/achievements)$/;
+ const PUBLIC_READ = /^\/(?:ping|titles(?:\/[^/]+(?:\/boards\/[^/]+)?)?|people\/[^/]+\/achievements|achievements\/feed)$/;
app.use('/api/v1', (req, res, next) => {
app.use('/api/v1', (req, res, next) => {
if (!PUBLIC_READ.test(req.path) || !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
if (!PUBLIC_READ.test(req.path) || !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Origin', '*');
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
* GET /titles/:title/boards/:board?period=&page= public a leaderboard: rank, name, score (never a player id)
* GET /titles/:title/boards/:board?period=&page= public a leaderboard: rank, name, score (never a player id)
* GET /people/:subject/achievements public a person's unlocks across every title (their profile:
* GET /people/:subject/achievements public a person's unlocks across every title (their profile:
* OpenVibe.Network's /@name and any site's showcase)
* OpenVibe.Network's /@name and any site's showcase)
+ * GET /achievements/feed public recent person unlocks, cursor paginated
* GET /me/achievements a person their own unlocks across titles
* GET /me/achievements a person their own unlocks across titles
* PUT /titles/:title play.title.manage register or update a title the caller manages
* PUT /titles/:title play.title.manage register or update a title the caller manages
* DELETE /titles/:title play.title.manage delete a sandbox title the caller manages (204)
* DELETE /titles/:title play.title.manage delete a sandbox title the caller manages (204)
@@ -65,6 +66,27 @@ function createApi(ctx) {
@@ -65,6 +66,27 @@ function createApi(ctx) {
return res.json(await play.board(req.params.title, req.params.board, { period: String(req.query.period || 'all'), page }));
return res.json(await play.board(req.params.title, req.params.board, { period: String(req.query.period || 'all'), page }));
}));
}));
+ r.get('/achievements/feed', reads, wrap(async (req, res) => {
+ const limit = req.query.limit === undefined ? 50 : Number(req.query.limit);
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new PlayError(400, 'play.bad_limit', 'limit is 1 to 100');
+ let before = null;
+ if (req.query.cursor !== undefined) {
+ const encoded = String(req.query.cursor);
+ try {
+ if (!/^[A-Za-z0-9_-]{1,500}$/.test(encoded)) throw new Error('bad cursor');
+ const raw = Buffer.from(encoded, 'base64url');
+ if (raw.toString('base64url') !== encoded) throw new Error('bad cursor');
+ before = JSON.parse(raw.toString('utf8'));
+ if (!Array.isArray(before) || before.length !== 4 || !Number.isSafeInteger(before[0]) || before[0] < 0
+ || !/^[a-z0-9-]{2,40}$/.test(before[1]) || !/^[a-z0-9_-]{1,64}$/.test(before[2])
+ || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(before[3])) throw new Error('bad cursor');
+ } catch { throw new PlayError(400, 'play.bad_cursor', 'invalid feed cursor'); }
+ }
+ const out = await play.achievementFeed({ before, limit });
+ res.set('Cache-Control', 'public, max-age=60');
+ return res.json({ items: out.items, next: out.next ? Buffer.from(JSON.stringify(out.next)).toString('base64url') : null });
+ }));
+
// A person's unlocks, for their public profile (Steam-style: what you earned shows wherever you are). Only people
// A person's unlocks, for their public profile (Steam-style: what you earned shows wherever you are). Only people
// (usr_…): a guest's rows are a game's own until it adopts them (404). Someone with no unlocks yet gets an empty list,
// (usr_…): a guest's rows are a game's own until it adopts them (404). Someone with no unlocks yet gets an empty list,
// the same answer as a subject Play has never seen, so the read never says whether an account exists.
// the same answer as a subject Play has never seen, so the read never says whether an account exists.
@@ -300,6 +300,21 @@ function createPlay({ s }) {
@@ -300,6 +300,21 @@ function createPlay({ s }) {
return [...by.values()];
return [...by.values()];
}
}
+ /** Public person unlocks across every production title, newest first. Guest and sandbox rows stay private. */
+ async function achievementFeed({ before = null, limit = 50 } = {}) {
+ const rows = await db.many(`SELECT u.player, u.title_id, t.name AS title_name, u.achievement_id, a.name, a.description, u.unlocked_at
+ FROM play_unlocks u JOIN play_titles t ON t.id = u.title_id
+ JOIN play_achievements a ON a.title_id = u.title_id AND a.id = u.achievement_id
+ WHERE u.player ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$' AND t.id NOT LIKE 'sandbox-%'
+ AND ($1::bigint IS NULL OR (u.unlocked_at, u.title_id, u.achievement_id, u.player) < ($1::bigint, $2::text, $3::text, $4::text))
+ ORDER BY u.unlocked_at DESC, u.title_id DESC, u.achievement_id DESC, u.player DESC LIMIT $5`,
+ [before ? before[0] : null, before ? before[1] : null, before ? before[2] : null, before ? before[3] : null, limit + 1]);
+ const next = rows.length > limit ? rows[limit - 1] : null;
+ return { items: rows.slice(0, limit).map((r) => ({ player: r.player, title: r.title_id, title_name: r.title_name,
+ id: r.achievement_id, name: r.name, description: r.description, unlocked_at: new Date(Number(r.unlocked_at)).toISOString() })),
+ next: next ? [Number(next.unlocked_at), next.title_id, next.achievement_id, next.player] : null };
+ }
+
// ── cloud saves ──
// ── cloud saves ──
const slotOf = (v) => { if (!SLOT_RE.test(String(v || ''))) fail(400, 'play.bad_slot', 'a slot is 1 to 32 of a-z 0-9 _ - (starting with a letter or digit)'); return String(v); };
const slotOf = (v) => { if (!SLOT_RE.test(String(v || ''))) fail(400, 'play.bad_slot', 'a slot is 1 to 32 of a-z 0-9 _ - (starting with a letter or digit)'); return String(v); };
const saveView = (r, withData) => ({ slot: r.slot, version: Number(r.version), size: Number(r.size), created_at: new Date(Number(r.created_at)).toISOString(), updated_at: new Date(Number(r.updated_at)).toISOString(), ...(withData ? { data: r.data } : {}) });
const saveView = (r, withData) => ({ slot: r.slot, version: Number(r.version), size: Number(r.size), created_at: new Date(Number(r.created_at)).toISOString(), updated_at: new Date(Number(r.updated_at)).toISOString(), ...(withData ? { data: r.data } : {}) });
@@ -353,7 +368,7 @@ function createPlay({ s }) {
@@ -353,7 +368,7 @@ function createPlay({ s }) {
return (await db.exec('DELETE FROM play_saves WHERE title_id = $1 AND player = $2 AND slot = $3', [titleId, player(person), slotOf(slot)])) > 0;
return (await db.exec('DELETE FROM play_saves WHERE title_id = $1 AND player = $2 AND slot = $3', [titleId, player(person), slotOf(slot)])) > 0;
}
}
- return { define, removeSandbox, recordScores, unlock, adopt, board, title, titles, playerAchievements, ownTitle,
+ return { define, removeSandbox, recordScores, unlock, adopt, board, title, titles, playerAchievements, achievementFeed, ownTitle,
putSave, getSave, listSaves, deleteSave, mySaves, deleteMySave };
putSave, getSave, listSaves, deleteSave, mySaves, deleteMySave };
}
}
@@ -142,6 +142,23 @@ const DAY = 86_400_000;
@@ -142,6 +142,23 @@ const DAY = 86_400_000;
assert.strictEqual((await t.get('/api/v1/people/not-a-subject/achievements')).status, 404);
assert.strictEqual((await t.get('/api/v1/people/not-a-subject/achievements')).status, 404);
});
});
+ await check('the public achievement feed pages across people and excludes guests', async () => {
+ const guest = 'guest:feedguest123';
+ await t.get('/api/v1/titles/salvage-rush/unlocks', { ...games, json: { player: guest, achievements: ['first-haul'] } });
+ const first = await t.get('/api/v1/achievements/feed?limit=1', { headers: { Origin: 'https://openvibe.community' } });
+ assert.strictEqual(first.status, 200, first.text);
+ assert.strictEqual(first.headers.get('access-control-allow-origin'), '*');
+ assert.strictEqual(first.json().items.length, 1);
+ assert.ok(first.json().items[0].player.startsWith('usr_'));
+ const second = await t.get(`/api/v1/achievements/feed?limit=1&cursor=${encodeURIComponent(first.json().next)}`);
+ assert.strictEqual(second.status, 200, second.text);
+ assert.notDeepStrictEqual(second.json().items[0], first.json().items[0]);
+ const full = (await t.get('/api/v1/achievements/feed?limit=100')).json();
+ assert.ok(full.items.length >= 3);
+ assert.ok(full.items.every((i) => i.player.startsWith('usr_') && i.title === 'salvage-rush'));
+ assert.strictEqual((await t.get('/api/v1/achievements/feed?cursor=bad')).json().code, 'play.bad_cursor');
+ });
+
await check('the player pages moved to OpenVibe.Games; llms.txt says where and keeps the API links', async () => {
await check('the player pages moved to OpenVibe.Games; llms.txt says where and keeps the API links', async () => {
for (const [from, to] of [
for (const [from, to] of [
['/', 'https://openvibe.games/achievements'],
['/', 'https://openvibe.games/achievements'],