openvibers/openvibe.play Commits

Play's home tells game developers what they get: real figures, the most-earned achievements, the four services, an SDK sample, the limits and the questions people ask (with FAQPage data); the title names cloud saves (#25)

OP OpenVibers committed 22 days ago · 2d92c74908 · 5 files, +106 −15

Changes

ddiff --git a/public/css/app.css b/public/css/app.css
iindex 8d677a8..1e3f3ab 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -86,3 +86,15 @@ button.sc-btn.sc-primary { border: 0; }
.pl-tabs a, .pl-tabs span { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); text-decoration: none; font-size: 0.9rem; }
.pl-tabs span[aria-current] { background: var(--accent-strong, #1d4ed8); color: var(--on-accent-strong, #fff); border-color: transparent; font-weight: 600; }
.pl-pages { display: flex; gap: 14px; align-items: center; justify-content: center; margin: 18px 0; color: var(--text-secondary, #96a7c2); }
++
++/* The home: the figures beside the hero, the most-earned achievements, the questions. */
++.pl-stats { margin: 0; display: grid; gap: 10px; }
++.pl-stats div { border: 1px solid var(--border, #1f2d47); border-radius: 12px; padding: 12px 16px; background: var(--bg-card, #131c2e); }
++.pl-stats dt { font-size: 1.25rem; font-weight: 700; color: var(--text-primary, #e6edf7); }
++.pl-stats dd { margin: 2px 0 0; color: var(--text-secondary, #96a7c2); font-size: .92rem; }
++.pl-popular { margin: 36px 0; }
++.pl-faq { margin: 40px 0 8px; }
++.pl-faq details { border: 1px solid var(--border, #1f2d47); border-radius: 10px; padding: 4px 16px; margin: 8px 0; background: var(--bg-card, #131c2e); }
++.pl-faq details[open] { padding-bottom: 12px; }
++.pl-faq summary { cursor: pointer; font-weight: 600; padding: 8px 0; }
++.pl-faq p { margin: 4px 0 0; color: var(--text-secondary, #96a7c2); }
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex bf63a84..b1873c4 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -15,7 +15,7 @@ const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const SITE_NAME = 'OpenVibe.Play';
--const DESCRIPTION = 'OpenVibe.Play: achievements and leaderboards for every OpenVibe game.';
++const DESCRIPTION = 'Game services for OpenVibe games: achievements, weekly and all-time leaderboards and cloud saves, with guests welcome and an SDK for developers.';
const DISALLOW = ['/auth/', '/api/', '/me'];
const PAGE_TEXT = {
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 7ec358a..2e4843f 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -20,13 +20,26 @@ const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { html, raw } = require('../render/html');
const { send } = require('../render/layout');
--const { PlayError, PLAYER_RE } = require('../play/store');
++const { PlayError, PLAYER_RE, MAX_SLOTS, MAX_SAVE_BYTES, PER_PAGE } = require('../play/store');
const SITE_NAME = 'OpenVibe.Play';
const num = (n) => Number(n).toLocaleString('en-US');
const plural = (n, one, many) => `${num(n)} ${n === 1 ? one : many}`;
const pct = (part, whole) => (whole > 0 ? `${Math.round((part / whole) * 100)}%` : '—');
++/** The home's questions: shown on the page and given to search engines as FAQPage data (the same text). */
++const HOME_FAQ = [
++ { q: 'Do players need an account?', a: 'No. A guest plays at once and appears on the boards as a guest. When they sign in with OpenVibe, the game moves their guest achievements and scores to their account.' },
++ { q: 'What does a leaderboard show?', a: "Rank, name and score, never a player id. Each board keeps a player's best score or their total, this week (from Monday, UTC) and all time, and anyone can read it as public JSON." },
++ { q: 'How do cloud saves avoid overwriting each other?', a: `A player has up to ${MAX_SLOTS} save slots per game, ${MAX_SAVE_BYTES / 1024} KB each. Every write names the version it read; if another device saved in between, the write is refused with play.save_conflict, so the game can merge instead of losing progress.` },
++ { q: 'What happens when a game changes its achievements?', a: 'An achievement or board the game stops listing is retired, never deleted: what players earned stays on their profile.' },
++ { q: 'How does a game use Play?', a: "Its server calls Play's API (or the openvibe-sdk/play client) with an OpenVibe token granted Play's capabilities. The app that registers a title is its only writer; posting the same round twice changes nothing." },
++ { q: 'Where do I see my own achievements?', a: 'Sign in and open Your achievements: every game, every unlock and when it happened.' },
++];
++function homeFaqJsonLd() {
++ return { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: HOME_FAQ.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })) };
++}
++
function boardTable(b, { compact = false } = {}) {
if (!b.entries.length) return html`<p class="pl-empty">${b.period === 'week' ? 'Nobody has scored this week yet.' : 'Nobody has scored yet.'}</p>`;
return html`<table class="pl-board${compact ? ' pl-board-compact' : ''}"><caption class="pl-sr">${b.name}, ${b.period === 'week' ? 'this week' : 'all time'}</caption>
@@@ -55,22 +68,34 @@ function createPageRoutes(ctx) {
for (const t of titles) {
if (t.boards.length) leaders.set(t.id, await play.board(t.id, t.boards[0].id, { period: 'week' }));
}
++ // Real figures only: hidden achievements count toward unlocks but are never named on the home.
++ const shown = titles.flatMap((t) => t.achievements.filter((a) => !a.hidden).map((a) => ({ ...a, game: t })));
++ const unlocks = titles.reduce((n, t) => n + t.achievements.reduce((m, a) => m + (Number(a.unlocked_by) || 0), 0), 0);
++ const achievementCount = titles.reduce((n, t) => n + t.achievements.length, 0);
++ const popular = shown.filter((a) => a.unlocked_by > 0).sort((x, y) => y.unlocked_by - x.unlocked_by || x.name.localeCompare(y.name)).slice(0, 6);
++ const stats = [];
++ if (titles.length) stats.push([plural(titles.length, 'game', 'games'), 'use Play today']);
++ if (achievementCount) stats.push([plural(achievementCount, 'achievement', 'achievements'), 'to earn across them']);
++ if (unlocks) stats.push([plural(unlocks, 'unlock', 'unlocks'), 'earned by players so far']);
++ stats.push([`${MAX_SLOTS} save slots`, `per player per game, ${MAX_SAVE_BYTES / 1024} KB each`]);
const hero = showcase.hero({
eyebrow: `${SITE_NAME} · part of OpenVibe`,
-- title: 'Achievements and leaderboards',
-- accent: 'for every OpenVibe game.',
-- lede: 'The scores you post and the achievements you unlock in OpenVibe games, in one place: this week\'s leaders, the all-time boards, and what you have earned with your OpenVibe account.',
++ title: 'Achievements, leaderboards',
++ accent: 'and cloud saves for every game.',
++ lede: 'The game services behind OpenVibe games: achievements players keep, weekly and all-time leaderboards, and saves that follow them from device to device. Guests play at once; their progress moves to their OpenVibe account when they sign in.',
actions: me
-- ? [{ label: 'Your achievements', href: '/me', primary: true }, { label: 'Play on OpenVibe.Games', href: 'https://openvibe.games/' }]
-- : [{ label: 'Play on OpenVibe.Games', href: 'https://openvibe.games/', primary: true }, { label: 'Sign in to see yours', href: '/auth/login?next=%2Fme' }],
-- note: 'Guests appear on the boards too; sign in and a game moves your guest progress to your account.',
++ ? [{ label: 'Your achievements', href: '/me', primary: true }, { label: 'Read the API', href: '/docs' }, { label: 'Play on OpenVibe.Games', href: 'https://openvibe.games/' }]
++ : [{ label: 'Play on OpenVibe.Games', href: 'https://openvibe.games/', primary: true }, { label: 'Read the API', href: '/docs' }, { label: 'Sign in to see yours', href: '/auth/login?next=%2Fme' }],
++ note: 'Boards show a name and a score, never a player id.',
++ html: `<dl class="pl-stats">${stats.map(([dt, dd]) => `<div><dt>${dt}</dt><dd>${dd}</dd></div>`).join('')}</dl>`,
});
page(req, res, {
index: true, cache: me ? null : PUBLIC_CACHE,
-- jsonLd: homeJsonLd(config),
++ description: 'Game services for OpenVibe games: achievements, weekly and all-time leaderboards and cloud saves, with guests welcome and an SDK for developers.',
++ jsonLd: [...homeJsonLd(config), homeFaqJsonLd()],
styles: [showcase.STYLESHEET],
body: html`${raw(hero)}
--<section class="pl-titles" aria-labelledby="pl-titles-h"><h2 id="pl-titles-h">Games</h2>
++<section class="pl-titles" aria-labelledby="pl-titles-h"><h2 id="pl-titles-h">Games on Play</h2>
${titles.length ? html`<div class="pl-grid">${titles.map((t) => html`<article class="pl-card">
<h3><a href="/t/${t.id}">${t.name}</a></h3>
${t.summary ? html`<p class="pl-muted">${t.summary}</p>` : ''}
@@@ -78,6 +103,18 @@ ${t.summary ? html`<p class="pl-muted">${t.summary}</p>` : ''}
${leaders.has(t.id) ? html`<h4>This week: ${leaders.get(t.id).name}</h4>${boardTable({ ...leaders.get(t.id), entries: leaders.get(t.id).entries.slice(0, 5) }, { compact: true })}<p><a href="/t/${t.id}/b/${leaders.get(t.id).board}?period=week">The whole board</a></p>` : ''}
</article>`)}</div>` : html`<p class="pl-empty">No game has joined yet.</p>`}
</section>
++${popular.length ? html`<section class="pl-popular" aria-labelledby="pl-popular-h"><h2 id="pl-popular-h">Most earned</h2><p class="pl-muted">The achievements the most players have unlocked.</p>
++<ul class="pl-ach">${popular.map((a) => html`<li><span class="pl-ach-name">${a.name}</span>${a.description ? html`<span class="pl-ach-desc">${a.description}</span>` : ''}<span class="pl-ach-meta"><a href="/t/${a.game.id}">${a.game.name}</a> · ${plural(a.unlocked_by, 'player', 'players')}${a.game.players ? ` · ${pct(a.unlocked_by, a.game.players)}` : ''}</span></li>`)}</ul></section>` : ''}
++${raw(showcase.features({
++ title: 'What a game gets',
++ lede: 'Four services, one OpenVibe account for every player, and nothing to host yourself.',
++ items: [
++ { title: 'Achievements', text: 'Define them once; unlocks are idempotent, secret ones show only as "A secret achievement", and retired ones stay on every profile.' },
++ { title: 'Leaderboards', text: `Best score or total, this week and all time, ${PER_PAGE} to a page, readable by any site as public JSON.` },
++ { title: 'Cloud saves', text: `${MAX_SLOTS} slots per player per game, ${MAX_SAVE_BYTES / 1024} KB each, with a version check so two devices never overwrite each other.`, href: '/docs' },
++ { title: 'Guests and accounts', text: 'Guests play at once; when they sign in, their unlocks and scores move to their account, and merged OpenVibe accounts keep both histories.' },
++ ],
++ }))}
${raw(showcase.steps({
title: 'How a game uses Play',
items: [
@@@ -86,7 +123,46 @@ ${raw(showcase.steps({
{ title: 'Read the boards anywhere', text: 'Boards are public JSON for any page: a best score or a total, this week or all time.' },
{ title: 'Guests count', text: 'A guest plays at once; when they sign in, the game moves their progress to their account.', href: '/docs' },
],
-- }))}`,
++ }))}
++${raw(showcase.code({
++ id: 'sdk',
++ title: 'From your game server',
++ lede: 'The openvibe-sdk/play client, with a token for your app, or plain HTTPS: boards and titles need no token at all.',
++ samples: [
++ {
++ label: 'Node (openvibe-sdk)', lang: 'js',
++ code: `const { createClient } = require('openvibe-sdk/core');
++const { createPlayClient } = require('openvibe-sdk/play');
++
++const play = createPlayClient(createClient({ tokenProvider: tokens })); // your app's OpenVibe tokens
++await play.titles.define('gem-run', {
++ name: 'Gem Run', achievements: [{ id: 'first-gem', name: 'First Gem' }],
++ boards: [{ id: 'high-score', name: 'High score', kind: 'best' }],
++});
++await play.scores.post('gem-run', { board: 'high-score', ref: 'round-42', entries: [{ player: 'usr_…', name: 'kim', score: 1200 }] });
++await play.achievements.unlock('gem-run', { player: 'usr_…', achievements: ['first-gem'] }); // → the new ones
++await play.saves.put('gem-run', 'usr_…', 'main', { data: { level: 3 }, version: 0 });`,
++ },
++ {
++ label: 'Any language (HTTPS)', lang: 'bash',
++ code: `# A public board: rank, name and score, this week
++curl -s 'https://play.openvibe.network/api/v1/titles/scraplandia/boards/haul?period=week'`,
++ },
++ ],
++ }))}
++${raw(showcase.limits({
++ title: 'Limits',
++ lede: 'What one game and one player get today.',
++ columns: ['Today'],
++ rows: [
++ { label: 'Save slots per player, per game', values: [String(MAX_SLOTS)] },
++ { label: 'Size of one save', values: [`${MAX_SAVE_BYTES / 1024} KB`] },
++ { label: 'Scores in one post', values: ['100'] },
++ { label: 'Board entries per page', values: [String(PER_PAGE)] },
++ { label: 'Requests per caller', values: [`${num(config.limits.minute)} a minute, ${num(config.limits.hour)} an hour`] },
++ ],
++ }))}
++<section class="pl-faq" aria-labelledby="pl-faq-h"><h2 id="pl-faq-h">Questions</h2>${HOME_FAQ.map((f) => html`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</section>`,
});
});
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 640ed3b..de8a435 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -20,7 +20,7 @@ const { html, raw, esc } = require('./html');
const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Play';
--const TAGLINE = 'Game achievements and leaderboards';
++const TAGLINE = 'Achievements, leaderboards and cloud saves';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
// The product's own navigation: one entry per public page it serves.
const NAV = [
ddiff --git a/test/layout.test.js b/test/layout.test.js
iindex 037504c..5c6d23a 100644
---- a/test/layout.test.js
++++ b/test/layout.test.js
@@@ -38,11 +38,13 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
assert.strictEqual(r.status, 200);
const head = r.text.slice(0, r.text.indexOf('</head>'));
assert.strictEqual((r.text.match(/<title>/g) || []).length, 1, 'exactly one <title>');
-- assert.ok(head.includes('<title>OpenVibe.Play: Game achievements and leaderboards</title>'), 'home title');
++ assert.ok(head.includes('<title>OpenVibe.Play: Achievements, leaderboards and cloud saves</title>'), 'home title');
assert.ok(head.includes('<link rel="canonical" href="https://play.openvibe.network/">'), 'canonical');
assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);
-- assert.deepStrictEqual(ld, ['WebSite', 'WebPage'], 'the home JSON-LD');
++ assert.deepStrictEqual(ld, ['WebSite', 'WebPage', 'FAQPage'], 'the home JSON-LD: the site, the page and the questions it shows');
++ const faq = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])).find((j) => j['@type'] === 'FAQPage');
++ for (const q of faq.mainEntity) assert.ok(r.text.includes(`<summary>${q.name.replace(/'/g, '&#39;')}</summary>`) || r.text.includes(`<summary>${q.name}</summary>`), `the page shows "${q.name}"`);
for (const tag of ['<meta property="og:title"', 'data-ov-icon="play"', '/css/app.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {
assert.ok(head.includes(tag), `head has ${tag}`);
}
@@@ -61,7 +63,8 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
assert.strictEqual(r.status, 200, r.text.slice(0, 300));
assert.match(r.headers.get('content-type'), /^text\/html/);
assert.ok(r.text.includes('OpenVibe.Play'), 'the service name is on the page');
-- assert.ok(r.text.includes('Achievements and leaderboards'), 'what it is, on the page');
++ assert.ok(r.text.includes('Achievements, leaderboards'), 'what it is, on the page');
++ for (const id of ['pl-titles-h', 'sc-what-a-game-gets', 'sdk', 'sc-limits', 'pl-faq-h']) assert.ok(r.text.includes(`id="${id}"`), `the home has ${id}`);
assert.ok(r.text.includes('/shared/showcase.css'), 'the showcase stylesheet');
assert.ok(!r.text.includes('/home/') && !r.text.includes('/mnt/'), 'no local filesystem path on the page');
});