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)
Changes
@@@ -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); }@@@ -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 = {@@@ -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>`,});});@@@ -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 = [@@@ -38,11 +38,13 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#maiassert.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, ''')}</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="#maiassert.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');});
@@ -86,3 +86,15 @@ button.sc-btn.sc-primary { border: 0; }
@@ -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 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-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); }
.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); }
@@ -15,7 +15,7 @@ const cache = require('openvibe-shared/cache-policy');
@@ -15,7 +15,7 @@ const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const SITE_NAME = 'OpenVibe.Play';
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 DISALLOW = ['/auth/', '/api/', '/me'];
const PAGE_TEXT = {
const PAGE_TEXT = {
@@ -20,13 +20,26 @@ const { asyncRouter } = require('./router');
@@ -20,13 +20,26 @@ const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { html, raw } = require('../render/html');
const { html, raw } = require('../render/html');
const { send } = require('../render/layout');
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 SITE_NAME = 'OpenVibe.Play';
const num = (n) => Number(n).toLocaleString('en-US');
const num = (n) => Number(n).toLocaleString('en-US');
const plural = (n, one, many) => `${num(n)} ${n === 1 ? one : many}`;
const plural = (n, one, many) => `${num(n)} ${n === 1 ? one : many}`;
const pct = (part, whole) => (whole > 0 ? `${Math.round((part / whole) * 100)}%` : '—');
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 } = {}) {
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>`;
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>
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) {
@@ -55,22 +68,34 @@ function createPageRoutes(ctx) {
for (const t of titles) {
for (const t of titles) {
if (t.boards.length) leaders.set(t.id, await play.board(t.id, t.boards[0].id, { period: 'week' }));
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({
const hero = showcase.hero({
eyebrow: `${SITE_NAME} · part of OpenVibe`,
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
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, {
page(req, res, {
index: true, cache: me ? null : PUBLIC_CACHE,
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],
styles: [showcase.STYLESHEET],
body: html`${raw(hero)}
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">
${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>
<h3><a href="/t/${t.id}">${t.name}</a></h3>
${t.summary ? html`<p class="pl-muted">${t.summary}</p>` : ''}
${t.summary ? html`<p class="pl-muted">${t.summary}</p>` : ''}
@@ -78,6 +103,18 @@ ${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>` : ''}
${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>`}
</article>`)}</div>` : html`<p class="pl-empty">No game has joined yet.</p>`}
</section>
</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({
${raw(showcase.steps({
title: 'How a game uses Play',
title: 'How a game uses Play',
items: [
items: [
@@ -86,7 +123,46 @@ ${raw(showcase.steps({
@@ -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: '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' },
{ 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>`,
});
});
});
});
@@ -20,7 +20,7 @@ const { html, raw, esc } = require('./html');
@@ -20,7 +20,7 @@ const { html, raw, esc } = require('./html');
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Play';
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');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
// The product's own navigation: one entry per public page it serves.
// The product's own navigation: one entry per public page it serves.
const NAV = [
const NAV = [
@@ -38,11 +38,13 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
@@ -38,11 +38,13 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
const head = r.text.slice(0, r.text.indexOf('</head>'));
const head = r.text.slice(0, r.text.indexOf('</head>'));
assert.strictEqual((r.text.match(/<title>/g) || []).length, 1, 'exactly one <title>');
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('<link rel="canonical" href="https://play.openvibe.network/">'), 'canonical');
assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
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']);
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, ''')}</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"']) {
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}`);
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
@@ -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.strictEqual(r.status, 200, r.text.slice(0, 300));
assert.match(r.headers.get('content-type'), /^text\/html/);
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('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('/shared/showcase.css'), 'the showcase stylesheet');
assert.ok(!r.text.includes('/home/') && !r.text.includes('/mnt/'), 'no local filesystem path on the page');
assert.ok(!r.text.includes('/home/') && !r.text.includes('/mnt/'), 'no local filesystem path on the page');
});
});