openvibers/openvibe.network Commits
Play is part of Games: stop listing it as its own site (#659)
Changes
@@@ -24,13 +24,21 @@ function manifestHost(service) {const m = contracts.services.get(service);try { return m && m.publicOrigin ? new URL(m.publicOrigin).hostname : null; } catch { return null; }}--const SITES = RAW_SITES.map((s) => {++// Play is the game-services API behind OpenVibe.Games, not a destination of its own: its pages moved to++// openvibe.games (achievements, leaderboards, developers) and the menu lists Games once. The service stays live and++// exposed in the registry (so a game can still call it); the frame, footer, home, llms.txt and JSON-LD drop the entry.++// The owner, 2026-11-08: "it should just be part of openvibe.games".++const HIDDEN_SITE = new Set(['play']);++const ALL_SITES = RAW_SITES.map((s) => {const e = exposureOf(s.service);const host = manifestHost(s.service) || s.host;if (!host) throw new Error(`site ${s.id}: no host (its manifest has no publicOrigin and the entry names none)`);return { ...s, host, status: e.state === 'live' && e.public_site === 'service' ? 'open' : 'soon', state: e.state };});--const byHost = new Map(SITES.map(s => [s.host, s]));++// The menu, footer, home and llms.txt list SITES; siteForHost still knows Play so a Play-host page (or a token++// consent screen) keeps its own name, without the site being offered as a destination.++const SITES = ALL_SITES.filter((s) => !HIDDEN_SITE.has(s.id));++const byHost = new Map(ALL_SITES.map(s => [s.host, s]));/** A site's full name: its own when it is a brand already (OpenRestream), otherwise OpenVibe.<name> (OpenVibe.Live).* "OpenVibe.OpenRestream" read wrong on the home page's cards (the owner, 2026-10-28). */@@@ -51,7 +59,7 @@ const GROUPS = [{ id: 'people', name: 'People & you', sites: ['chat', 'mail', 'community', 'tips', 'vip', 'help', 'network', 'inventory'] },// Actor is the engine behind every agent (Codes' Auto, Vibe, the Dock call it): a developer service, not a second// place to chat with an agent (plan L39, 2026-10-31).-- { id: 'build', name: 'For developers', sites: ['services', 'exchange', 'actor', 'run', 'zone', 'events', 'media', 'watch', 'play'] },++ { id: 'build', name: 'For developers', sites: ['services', 'exchange', 'actor', 'run', 'zone', 'events', 'media', 'watch'] },];// What each site is, in a few words, for the menus (the launcher's rows are two to a line on a phone: about 22// characters fit). The manifests' taglines stay the sites' own sentences everywhere else; a site missing here shows@@@ -67,7 +75,7 @@ const MENU_LINE = {search: 'Search everything', host: 'Compare hosting', report: 'Your numbers, honest', vision: 'Ask a picture',exchange: 'AI models, one account',network: 'Your account', inventory: 'Your items',-- services: 'APIs, SDK and keys', bench: 'Which AI is best', run: 'Run jobs anywhere', zone: 'S3 storage', events: 'Event streams', media: 'Media API', play: 'Game services',++ services: 'APIs, SDK and keys', bench: 'Which AI is best', run: 'Run jobs anywhere', zone: 'S3 storage', events: 'Event streams', media: 'Media API',};// A site's name in the menus, where the rows are short words (Pics, Download, VIP, Tips): the product keeps its full name@@@ -75,7 +75,7 @@ const INTENTS = [['pics', 'fa-image', 'Share pictures', 'Upload a picture and get links to share or embed it anywhere, in any size.', 'https://openvibe.pics/', 'picture pictures image images photo photos imgur upload host embed gif album albums alt text'],['media-hub', 'fa-cloud-arrow-up', 'Keep and share files', 'A 25 GB drive with share links and folder backups.', 'https://openvibe.download/', 'download files drive upload share link storage backup sync cloud send mediafire mega'],['zone', 'fa-box-archive', 'Store files in buckets', 'Private S3-compatible storage zones for your projects, with keys you scope and quotas you set.', 'https://openvibe.zone/', 'storage s3 bucket buckets object storage files backup backups rclone aws zone data upload'],-- ['play', 'fa-trophy', 'Climb the leaderboards', 'This week\'s leaders and the achievements you have unlocked in every OpenVibe game.', 'https://play.openvibe.network/', 'leaderboard leaderboards achievement achievements score scores ranking rank high score trophy games play'],++ ['play', 'fa-trophy', 'Climb the leaderboards', 'This week\'s leaders and the achievements you have unlocked in every OpenVibe game, now part of OpenVibe.Games.', 'https://openvibe.games/leaderboards', 'leaderboard leaderboards achievement achievements score scores ranking rank high score trophy games play'],['inventory', 'fa-shirt', 'Wear what you earn', 'One inventory for the items you earn across OpenVibe, worn on every site.', 'https://inventory.openvibe.network/', 'inventory items cosmetics name effect particles hat voice badge collect equip wear profile'],['watch', 'fa-bell', 'Get told when it changes', 'Watch a page, a feed or an API and hear when your value moves.', 'https://openvibe.watch/', 'watch monitor alert alerts notify change changes price drop feed rss tracker uptime'],['help', 'fa-life-ring', 'Get help', 'Every OpenVibe site\'s questions answered, and a person when they are not.', 'https://openvibe.help/', 'help support faq question questions ticket contact problem how'],@@@ -27,8 +27,8 @@ const esc = seo.esc;const SITE = 'https://openvibe.network';const INVENTORY_SITE = 'https://inventory.openvibe.network';const QUEST_SITE = 'https://openvibe.quest';--const PLAY_SITE = 'https://play.openvibe.network';--const RECENT_ACHIEVEMENTS = 6; // per game on the page; the rest is on the game's page on OpenVibe.Play++const GAMES_ACHIEVEMENTS = 'https://openvibe.games/achievements#game-';++const RECENT_ACHIEVEMENTS = 6; // per game on the page; the rest is on the game's row on OpenVibe.Gamesconst AUDIENCE = 'openvibe.inventory';const ITEMS_TTL_MS = 60_000;const KINDS_TTL_MS = 10 * 60_000;@@@ -128,7 +128,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'httpconst body = await r.json();const games = (Array.isArray(body.titles) ? body.titles : []).filter((t) => t && typeof t.title === 'string' && Array.isArray(t.achievements)).map((t) => ({title: t.title.slice(0, 64), name: String(t.name || t.title).slice(0, 80), count: t.achievements.length,-- url: `${PLAY_SITE}/t/${encodeURIComponent(t.title.slice(0, 64))}`,++ url: `${GAMES_ACHIEVEMENTS}${encodeURIComponent(t.title.slice(0, 64))}`,recent: t.achievements.slice(0, RECENT_ACHIEVEMENTS).filter((a) => a && typeof a.name === 'string').map((a) => ({ name: a.name.slice(0, 80), description: String(a.description || '').slice(0, 200), unlocked_at: typeof a.unlocked_at === 'string' ? a.unlocked_at : null })),}));value = { total: games.reduce((n, g) => n + g.count, 0), games };@@@ -291,7 +291,7 @@ function badgesHtml(inv) {}).join('')}</ul></section>`;}--/** Games: each game the person played with its achievements (newest first), Steam-style; links to the game on OpenVibe.Play. */++/** Games: each game the person played with its achievements (newest first), Steam-style; links to the game's row on OpenVibe.Games. */function gamesHtml(inv) {const a = inv.achievements;if (!a || !a.games.length) return '';@@@ -38,9 +38,10 @@ assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (thelet p = svc.payloadFor('openvibe.media');// Email (OpenVibe.Mail) joins the cold order after Chat once its manifest is in the pinned Contracts release; until then// the test still follows what the installed manifests say.--const COLD_ORDER = ['live', 'tools', 'space', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'search', 'run', 'play', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect'];++const COLD_ORDER = ['live', 'tools', 'space', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'search', 'run', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect'];if (HAS_MAIL) COLD_ORDER.splice(COLD_ORDER.indexOf('chat') + 1, 0, 'mail');assert.deepEqual(p.nav.filter(n => !n.of).map(n => n.id), COLD_ORDER, 'cold start follows the base order');++assert.ok(!p.nav.some(n => n.id === 'play'), 'Play is part of Games, not a menu entry of its own');// The launcher: every open site in one of the groups, and the products other services serve on their own domains// right after their site (Pics and Download after Video, Homes after Rent).assert.deepStrictEqual(p.groups.map(g => g.name), ['Streams & places', 'Games & laughs', 'News & answers', 'Make & build', 'Shop & swap', 'People & you', 'For developers'], 'the six neighbourhoods (plan 2026-10-28 §3.1), the building blocks last');@@@ -55,7 +56,7 @@ assert.deepStrictEqual(['actor', 'bot', 'run', 'zone', 'codes'].map((id) => p.na// Games together, items with the account, Alerts with the building blocks, files beside Video (each when its site is open).[['games', 'games'], ['connect', 'make'], ['quest', 'games'], ['inventory', 'people'], ['watch', 'build'], ['download', 'streams'], ['search', 'answers'], ['chat', 'people'], ['mail', 'people'], ['domains', 'make'], ['report', 'answers'], ['design', 'make'], ['today', 'answers'], ['surf', 'games'], ['meme', 'games'], ['vision', 'answers'], ['exchange', 'build'], ['fun', 'games'], ['cam', 'streams'], ['bench', 'answers'], ['casino', 'games'], ['tours', 'streams'], ['shoes', 'shop']].filter(([id]) => at(id) >= 0).forEach(([id, group]) => assert.strictEqual(p.nav[at(id)].group, group, `${id} is in ${group}`));--assert.strictEqual(p.nav.length, 54 + (HAS_MAIL ? 1 : 0)); // Connect since contracts 0.279.0, Tours and Shoes since contracts 0.265.0, Casino since contracts 0.264.0, Domains since contracts 0.226.0, Report since 0.227.0, Design since 0.228.0, Today since 0.230.0, Surf since 0.231.0, Meme since 0.234.0, Vision and Exchange since 0.235.0; AI is no longer a site of its own (its pages are Exchange now), Fun since 0.236.0, Cam since 0.237.0, Bench since 0.238.0; Email joins after Chat with the Contracts release that adds its manifest++assert.strictEqual(p.nav.length, 53 + (HAS_MAIL ? 1 : 0)); // Connect since contracts 0.279.0, Tours and Shoes since contracts 0.265.0, Casino since contracts 0.264.0, Domains since contracts 0.226.0, Report since 0.227.0, Design since 0.228.0, Today since 0.230.0, Surf since 0.231.0, Meme since 0.234.0, Vision and Exchange since 0.235.0; AI is no longer a site of its own (its pages are Exchange now), Fun since 0.236.0, Cam since 0.237.0, Bench since 0.238.0; Email joins after Chat with the Contracts release that adds its manifestassert.deepStrictEqual([p.nav[at('stream')].name, p.nav[at('pics')].name], ['Restream', 'Pics'], 'OpenRestream is "Restream" among the menu\'s short names (owner, 2026-10-28)');assert.strictEqual(p.nav.find(n => n.id === 'space').url, 'https://openvibe.space/', 'Space keeps its own home (the forum returned to Community, 0.118.0)');assert.strictEqual(p.nav.find(n => n.id === 'community').url, 'https://openvibe.community/', 'Community: the forum, pastes and Pulse');@@@ -31,6 +31,7 @@ const cases = [['how do I go live?', 'https://openvibe.live/broadcast'],['convert mp4 to mp3', 'tool search'],['merge pdf', 'tool search'],++ ['climb the leaderboards', 'https://openvibe.games/leaderboards'],['find me a cheap apartment in austin', 'https://openvibe.rent/'],['get help with my account', 'https://openvibe.help/'],['what is the weather in paris?', vibe('what is the weather in paris?')],@@@ -101,7 +101,7 @@ const INSTANCES = [assert.strictEqual(calls.find((c) => c.path.endsWith('/achievements')).auth, '', 'Play\'s read is public: no token');assert.ok(r.text.includes('id="pf-games"') && r.text.includes('Salvage <Rush>') && !r.text.includes('Salvage <Rush>'), 'games, escaped');assert.ok(/pf-game-n">2 achievements<[\s\S]*Big Haul[\s\S]*First Haul[\s\S]*Vibe Heroes[\s\S]*1 achievement</.test(r.text), 'each game with its achievements, newest first');-- assert.ok(r.text.includes('href="https://play.openvibe.network/t/salvage-rush"'), 'a game links to its page on Play');++ assert.ok(r.text.includes('href="https://openvibe.games/achievements#game-salvage-rush"'), 'a game links to its row on OpenVibe.Games');assert.ok(/<span class="pf-stat-k">Achievements<\/span><span class="pf-stat-v">3<\/span>/.test(r.text));assert.strictEqual(calls.find((c) => c.path.endsWith('/badges')).auth, 'Bearer tok:openvibe.quest:quest.badge.read', 'and for Quest');assert.ok(r.text.includes('id="pf-badges"') && /Thread <Starter>[\s\S]*First Follow/.test(r.text), 'badges, newest first, escaped');@@@ -142,8 +142,10 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], chassert.ok(/<tr id="svc-ai">[\s\S]*?>Up<\/span>/.test(html), 'AI serves its own public origin since contracts 0.110.0');// 3. The Frame: the nav lists only sites whose domain serves the service; the rest are soon with a reason.++ // Play stays live in the exposure overlay (the API serves) but is part of Games now, so it is not a nav site.++ assert.ok(!SITES.some(s => s.id === 'play'), 'Play is part of Games, not a menu entry');const open = SITES.filter(s => s.status === 'open').map(s => s.id);-- const EXPECTED_OPEN = ['live', 'tools', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'space', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'search', 'run', 'play', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect']; // by site position; Email joins after Chat once its manifest is pinned++ const EXPECTED_OPEN = ['live', 'tools', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'space', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'search', 'run', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect']; // by site position; Email joins after Chat once its manifest is pinnedif (HAS_MAIL) EXPECTED_OPEN.splice(EXPECTED_OPEN.indexOf('chat') + 1, 0, 'mail');assert.deepStrictEqual(open, EXPECTED_OPEN);for (const s of SITES) {
@@ -24,13 +24,21 @@ function manifestHost(service) {
@@ -24,13 +24,21 @@ function manifestHost(service) {
const m = contracts.services.get(service);
const m = contracts.services.get(service);
try { return m && m.publicOrigin ? new URL(m.publicOrigin).hostname : null; } catch { return null; }
try { return m && m.publicOrigin ? new URL(m.publicOrigin).hostname : null; } catch { return null; }
}
}
-const SITES = RAW_SITES.map((s) => {
+// Play is the game-services API behind OpenVibe.Games, not a destination of its own: its pages moved to
+// openvibe.games (achievements, leaderboards, developers) and the menu lists Games once. The service stays live and
+// exposed in the registry (so a game can still call it); the frame, footer, home, llms.txt and JSON-LD drop the entry.
+// The owner, 2026-11-08: "it should just be part of openvibe.games".
+const HIDDEN_SITE = new Set(['play']);
+const ALL_SITES = RAW_SITES.map((s) => {
const e = exposureOf(s.service);
const e = exposureOf(s.service);
const host = manifestHost(s.service) || s.host;
const host = manifestHost(s.service) || s.host;
if (!host) throw new Error(`site ${s.id}: no host (its manifest has no publicOrigin and the entry names none)`);
if (!host) throw new Error(`site ${s.id}: no host (its manifest has no publicOrigin and the entry names none)`);
return { ...s, host, status: e.state === 'live' && e.public_site === 'service' ? 'open' : 'soon', state: e.state };
return { ...s, host, status: e.state === 'live' && e.public_site === 'service' ? 'open' : 'soon', state: e.state };
});
});
-const byHost = new Map(SITES.map(s => [s.host, s]));
+// The menu, footer, home and llms.txt list SITES; siteForHost still knows Play so a Play-host page (or a token
+// consent screen) keeps its own name, without the site being offered as a destination.
+const SITES = ALL_SITES.filter((s) => !HIDDEN_SITE.has(s.id));
+const byHost = new Map(ALL_SITES.map(s => [s.host, s]));
/** A site's full name: its own when it is a brand already (OpenRestream), otherwise OpenVibe.<name> (OpenVibe.Live).
/** A site's full name: its own when it is a brand already (OpenRestream), otherwise OpenVibe.<name> (OpenVibe.Live).
* "OpenVibe.OpenRestream" read wrong on the home page's cards (the owner, 2026-10-28). */
* "OpenVibe.OpenRestream" read wrong on the home page's cards (the owner, 2026-10-28). */
@@ -51,7 +59,7 @@ const GROUPS = [
@@ -51,7 +59,7 @@ const GROUPS = [
{ id: 'people', name: 'People & you', sites: ['chat', 'mail', 'community', 'tips', 'vip', 'help', 'network', 'inventory'] },
{ id: 'people', name: 'People & you', sites: ['chat', 'mail', 'community', 'tips', 'vip', 'help', 'network', 'inventory'] },
// Actor is the engine behind every agent (Codes' Auto, Vibe, the Dock call it): a developer service, not a second
// Actor is the engine behind every agent (Codes' Auto, Vibe, the Dock call it): a developer service, not a second
// place to chat with an agent (plan L39, 2026-10-31).
// place to chat with an agent (plan L39, 2026-10-31).
- { id: 'build', name: 'For developers', sites: ['services', 'exchange', 'actor', 'run', 'zone', 'events', 'media', 'watch', 'play'] },
+ { id: 'build', name: 'For developers', sites: ['services', 'exchange', 'actor', 'run', 'zone', 'events', 'media', 'watch'] },
];
];
// What each site is, in a few words, for the menus (the launcher's rows are two to a line on a phone: about 22
// What each site is, in a few words, for the menus (the launcher's rows are two to a line on a phone: about 22
// characters fit). The manifests' taglines stay the sites' own sentences everywhere else; a site missing here shows
// characters fit). The manifests' taglines stay the sites' own sentences everywhere else; a site missing here shows
@@ -67,7 +75,7 @@ const MENU_LINE = {
@@ -67,7 +75,7 @@ const MENU_LINE = {
search: 'Search everything', host: 'Compare hosting', report: 'Your numbers, honest', vision: 'Ask a picture',
search: 'Search everything', host: 'Compare hosting', report: 'Your numbers, honest', vision: 'Ask a picture',
exchange: 'AI models, one account',
exchange: 'AI models, one account',
network: 'Your account', inventory: 'Your items',
network: 'Your account', inventory: 'Your items',
- services: 'APIs, SDK and keys', bench: 'Which AI is best', run: 'Run jobs anywhere', zone: 'S3 storage', events: 'Event streams', media: 'Media API', play: 'Game services',
+ services: 'APIs, SDK and keys', bench: 'Which AI is best', run: 'Run jobs anywhere', zone: 'S3 storage', events: 'Event streams', media: 'Media API',
};
};
// A site's name in the menus, where the rows are short words (Pics, Download, VIP, Tips): the product keeps its full name
// A site's name in the menus, where the rows are short words (Pics, Download, VIP, Tips): the product keeps its full name
@@ -75,7 +75,7 @@ const INTENTS = [
@@ -75,7 +75,7 @@ const INTENTS = [
['pics', 'fa-image', 'Share pictures', 'Upload a picture and get links to share or embed it anywhere, in any size.', 'https://openvibe.pics/', 'picture pictures image images photo photos imgur upload host embed gif album albums alt text'],
['pics', 'fa-image', 'Share pictures', 'Upload a picture and get links to share or embed it anywhere, in any size.', 'https://openvibe.pics/', 'picture pictures image images photo photos imgur upload host embed gif album albums alt text'],
['media-hub', 'fa-cloud-arrow-up', 'Keep and share files', 'A 25 GB drive with share links and folder backups.', 'https://openvibe.download/', 'download files drive upload share link storage backup sync cloud send mediafire mega'],
['media-hub', 'fa-cloud-arrow-up', 'Keep and share files', 'A 25 GB drive with share links and folder backups.', 'https://openvibe.download/', 'download files drive upload share link storage backup sync cloud send mediafire mega'],
['zone', 'fa-box-archive', 'Store files in buckets', 'Private S3-compatible storage zones for your projects, with keys you scope and quotas you set.', 'https://openvibe.zone/', 'storage s3 bucket buckets object storage files backup backups rclone aws zone data upload'],
['zone', 'fa-box-archive', 'Store files in buckets', 'Private S3-compatible storage zones for your projects, with keys you scope and quotas you set.', 'https://openvibe.zone/', 'storage s3 bucket buckets object storage files backup backups rclone aws zone data upload'],
- ['play', 'fa-trophy', 'Climb the leaderboards', 'This week\'s leaders and the achievements you have unlocked in every OpenVibe game.', 'https://play.openvibe.network/', 'leaderboard leaderboards achievement achievements score scores ranking rank high score trophy games play'],
+ ['play', 'fa-trophy', 'Climb the leaderboards', 'This week\'s leaders and the achievements you have unlocked in every OpenVibe game, now part of OpenVibe.Games.', 'https://openvibe.games/leaderboards', 'leaderboard leaderboards achievement achievements score scores ranking rank high score trophy games play'],
['inventory', 'fa-shirt', 'Wear what you earn', 'One inventory for the items you earn across OpenVibe, worn on every site.', 'https://inventory.openvibe.network/', 'inventory items cosmetics name effect particles hat voice badge collect equip wear profile'],
['inventory', 'fa-shirt', 'Wear what you earn', 'One inventory for the items you earn across OpenVibe, worn on every site.', 'https://inventory.openvibe.network/', 'inventory items cosmetics name effect particles hat voice badge collect equip wear profile'],
['watch', 'fa-bell', 'Get told when it changes', 'Watch a page, a feed or an API and hear when your value moves.', 'https://openvibe.watch/', 'watch monitor alert alerts notify change changes price drop feed rss tracker uptime'],
['watch', 'fa-bell', 'Get told when it changes', 'Watch a page, a feed or an API and hear when your value moves.', 'https://openvibe.watch/', 'watch monitor alert alerts notify change changes price drop feed rss tracker uptime'],
['help', 'fa-life-ring', 'Get help', 'Every OpenVibe site\'s questions answered, and a person when they are not.', 'https://openvibe.help/', 'help support faq question questions ticket contact problem how'],
['help', 'fa-life-ring', 'Get help', 'Every OpenVibe site\'s questions answered, and a person when they are not.', 'https://openvibe.help/', 'help support faq question questions ticket contact problem how'],
@@ -27,8 +27,8 @@ const esc = seo.esc;
@@ -27,8 +27,8 @@ const esc = seo.esc;
const SITE = 'https://openvibe.network';
const SITE = 'https://openvibe.network';
const INVENTORY_SITE = 'https://inventory.openvibe.network';
const INVENTORY_SITE = 'https://inventory.openvibe.network';
const QUEST_SITE = 'https://openvibe.quest';
const QUEST_SITE = 'https://openvibe.quest';
-const PLAY_SITE = 'https://play.openvibe.network';
-const RECENT_ACHIEVEMENTS = 6; // per game on the page; the rest is on the game's page on OpenVibe.Play
+const GAMES_ACHIEVEMENTS = 'https://openvibe.games/achievements#game-';
+const RECENT_ACHIEVEMENTS = 6; // per game on the page; the rest is on the game's row on OpenVibe.Games
const AUDIENCE = 'openvibe.inventory';
const AUDIENCE = 'openvibe.inventory';
const ITEMS_TTL_MS = 60_000;
const ITEMS_TTL_MS = 60_000;
const KINDS_TTL_MS = 10 * 60_000;
const KINDS_TTL_MS = 10 * 60_000;
@@ -128,7 +128,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
@@ -128,7 +128,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
const body = await r.json();
const body = await r.json();
const games = (Array.isArray(body.titles) ? body.titles : []).filter((t) => t && typeof t.title === 'string' && Array.isArray(t.achievements)).map((t) => ({
const games = (Array.isArray(body.titles) ? body.titles : []).filter((t) => t && typeof t.title === 'string' && Array.isArray(t.achievements)).map((t) => ({
title: t.title.slice(0, 64), name: String(t.name || t.title).slice(0, 80), count: t.achievements.length,
title: t.title.slice(0, 64), name: String(t.name || t.title).slice(0, 80), count: t.achievements.length,
- url: `${PLAY_SITE}/t/${encodeURIComponent(t.title.slice(0, 64))}`,
+ url: `${GAMES_ACHIEVEMENTS}${encodeURIComponent(t.title.slice(0, 64))}`,
recent: t.achievements.slice(0, RECENT_ACHIEVEMENTS).filter((a) => a && typeof a.name === 'string').map((a) => ({ name: a.name.slice(0, 80), description: String(a.description || '').slice(0, 200), unlocked_at: typeof a.unlocked_at === 'string' ? a.unlocked_at : null })),
recent: t.achievements.slice(0, RECENT_ACHIEVEMENTS).filter((a) => a && typeof a.name === 'string').map((a) => ({ name: a.name.slice(0, 80), description: String(a.description || '').slice(0, 200), unlocked_at: typeof a.unlocked_at === 'string' ? a.unlocked_at : null })),
}));
}));
value = { total: games.reduce((n, g) => n + g.count, 0), games };
value = { total: games.reduce((n, g) => n + g.count, 0), games };
@@ -291,7 +291,7 @@ function badgesHtml(inv) {
@@ -291,7 +291,7 @@ function badgesHtml(inv) {
}).join('')}</ul></section>`;
}).join('')}</ul></section>`;
}
}
-/** Games: each game the person played with its achievements (newest first), Steam-style; links to the game on OpenVibe.Play. */
+/** Games: each game the person played with its achievements (newest first), Steam-style; links to the game's row on OpenVibe.Games. */
function gamesHtml(inv) {
function gamesHtml(inv) {
const a = inv.achievements;
const a = inv.achievements;
if (!a || !a.games.length) return '';
if (!a || !a.games.length) return '';
@@ -38,9 +38,10 @@ assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (the
@@ -38,9 +38,10 @@ assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (the
let p = svc.payloadFor('openvibe.media');
let p = svc.payloadFor('openvibe.media');
// Email (OpenVibe.Mail) joins the cold order after Chat once its manifest is in the pinned Contracts release; until then
// Email (OpenVibe.Mail) joins the cold order after Chat once its manifest is in the pinned Contracts release; until then
// the test still follows what the installed manifests say.
// the test still follows what the installed manifests say.
-const COLD_ORDER = ['live', 'tools', 'space', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'search', 'run', 'play', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect'];
+const COLD_ORDER = ['live', 'tools', 'space', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'search', 'run', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect'];
if (HAS_MAIL) COLD_ORDER.splice(COLD_ORDER.indexOf('chat') + 1, 0, 'mail');
if (HAS_MAIL) COLD_ORDER.splice(COLD_ORDER.indexOf('chat') + 1, 0, 'mail');
assert.deepEqual(p.nav.filter(n => !n.of).map(n => n.id), COLD_ORDER, 'cold start follows the base order');
assert.deepEqual(p.nav.filter(n => !n.of).map(n => n.id), COLD_ORDER, 'cold start follows the base order');
+assert.ok(!p.nav.some(n => n.id === 'play'), 'Play is part of Games, not a menu entry of its own');
// The launcher: every open site in one of the groups, and the products other services serve on their own domains
// The launcher: every open site in one of the groups, and the products other services serve on their own domains
// right after their site (Pics and Download after Video, Homes after Rent).
// right after their site (Pics and Download after Video, Homes after Rent).
assert.deepStrictEqual(p.groups.map(g => g.name), ['Streams & places', 'Games & laughs', 'News & answers', 'Make & build', 'Shop & swap', 'People & you', 'For developers'], 'the six neighbourhoods (plan 2026-10-28 §3.1), the building blocks last');
assert.deepStrictEqual(p.groups.map(g => g.name), ['Streams & places', 'Games & laughs', 'News & answers', 'Make & build', 'Shop & swap', 'People & you', 'For developers'], 'the six neighbourhoods (plan 2026-10-28 §3.1), the building blocks last');
@@ -55,7 +56,7 @@ assert.deepStrictEqual(['actor', 'bot', 'run', 'zone', 'codes'].map((id) => p.na
@@ -55,7 +56,7 @@ assert.deepStrictEqual(['actor', 'bot', 'run', 'zone', 'codes'].map((id) => p.na
// Games together, items with the account, Alerts with the building blocks, files beside Video (each when its site is open).
// Games together, items with the account, Alerts with the building blocks, files beside Video (each when its site is open).
[['games', 'games'], ['connect', 'make'], ['quest', 'games'], ['inventory', 'people'], ['watch', 'build'], ['download', 'streams'], ['search', 'answers'], ['chat', 'people'], ['mail', 'people'], ['domains', 'make'], ['report', 'answers'], ['design', 'make'], ['today', 'answers'], ['surf', 'games'], ['meme', 'games'], ['vision', 'answers'], ['exchange', 'build'], ['fun', 'games'], ['cam', 'streams'], ['bench', 'answers'], ['casino', 'games'], ['tours', 'streams'], ['shoes', 'shop']]
[['games', 'games'], ['connect', 'make'], ['quest', 'games'], ['inventory', 'people'], ['watch', 'build'], ['download', 'streams'], ['search', 'answers'], ['chat', 'people'], ['mail', 'people'], ['domains', 'make'], ['report', 'answers'], ['design', 'make'], ['today', 'answers'], ['surf', 'games'], ['meme', 'games'], ['vision', 'answers'], ['exchange', 'build'], ['fun', 'games'], ['cam', 'streams'], ['bench', 'answers'], ['casino', 'games'], ['tours', 'streams'], ['shoes', 'shop']]
.filter(([id]) => at(id) >= 0).forEach(([id, group]) => assert.strictEqual(p.nav[at(id)].group, group, `${id} is in ${group}`));
.filter(([id]) => at(id) >= 0).forEach(([id, group]) => assert.strictEqual(p.nav[at(id)].group, group, `${id} is in ${group}`));
-assert.strictEqual(p.nav.length, 54 + (HAS_MAIL ? 1 : 0)); // Connect since contracts 0.279.0, Tours and Shoes since contracts 0.265.0, Casino since contracts 0.264.0, Domains since contracts 0.226.0, Report since 0.227.0, Design since 0.228.0, Today since 0.230.0, Surf since 0.231.0, Meme since 0.234.0, Vision and Exchange since 0.235.0; AI is no longer a site of its own (its pages are Exchange now), Fun since 0.236.0, Cam since 0.237.0, Bench since 0.238.0; Email joins after Chat with the Contracts release that adds its manifest
+assert.strictEqual(p.nav.length, 53 + (HAS_MAIL ? 1 : 0)); // Connect since contracts 0.279.0, Tours and Shoes since contracts 0.265.0, Casino since contracts 0.264.0, Domains since contracts 0.226.0, Report since 0.227.0, Design since 0.228.0, Today since 0.230.0, Surf since 0.231.0, Meme since 0.234.0, Vision and Exchange since 0.235.0; AI is no longer a site of its own (its pages are Exchange now), Fun since 0.236.0, Cam since 0.237.0, Bench since 0.238.0; Email joins after Chat with the Contracts release that adds its manifest
assert.deepStrictEqual([p.nav[at('stream')].name, p.nav[at('pics')].name], ['Restream', 'Pics'], 'OpenRestream is "Restream" among the menu\'s short names (owner, 2026-10-28)');
assert.deepStrictEqual([p.nav[at('stream')].name, p.nav[at('pics')].name], ['Restream', 'Pics'], 'OpenRestream is "Restream" among the menu\'s short names (owner, 2026-10-28)');
assert.strictEqual(p.nav.find(n => n.id === 'space').url, 'https://openvibe.space/', 'Space keeps its own home (the forum returned to Community, 0.118.0)');
assert.strictEqual(p.nav.find(n => n.id === 'space').url, 'https://openvibe.space/', 'Space keeps its own home (the forum returned to Community, 0.118.0)');
assert.strictEqual(p.nav.find(n => n.id === 'community').url, 'https://openvibe.community/', 'Community: the forum, pastes and Pulse');
assert.strictEqual(p.nav.find(n => n.id === 'community').url, 'https://openvibe.community/', 'Community: the forum, pastes and Pulse');
@@ -31,6 +31,7 @@ const cases = [
@@ -31,6 +31,7 @@ const cases = [
['how do I go live?', 'https://openvibe.live/broadcast'],
['how do I go live?', 'https://openvibe.live/broadcast'],
['convert mp4 to mp3', 'tool search'],
['convert mp4 to mp3', 'tool search'],
['merge pdf', 'tool search'],
['merge pdf', 'tool search'],
+ ['climb the leaderboards', 'https://openvibe.games/leaderboards'],
['find me a cheap apartment in austin', 'https://openvibe.rent/'],
['find me a cheap apartment in austin', 'https://openvibe.rent/'],
['get help with my account', 'https://openvibe.help/'],
['get help with my account', 'https://openvibe.help/'],
['what is the weather in paris?', vibe('what is the weather in paris?')],
['what is the weather in paris?', vibe('what is the weather in paris?')],
@@ -101,7 +101,7 @@ const INSTANCES = [
@@ -101,7 +101,7 @@ const INSTANCES = [
assert.strictEqual(calls.find((c) => c.path.endsWith('/achievements')).auth, '', 'Play\'s read is public: no token');
assert.strictEqual(calls.find((c) => c.path.endsWith('/achievements')).auth, '', 'Play\'s read is public: no token');
assert.ok(r.text.includes('id="pf-games"') && r.text.includes('Salvage <Rush>') && !r.text.includes('Salvage <Rush>'), 'games, escaped');
assert.ok(r.text.includes('id="pf-games"') && r.text.includes('Salvage <Rush>') && !r.text.includes('Salvage <Rush>'), 'games, escaped');
assert.ok(/pf-game-n">2 achievements<[\s\S]*Big Haul[\s\S]*First Haul[\s\S]*Vibe Heroes[\s\S]*1 achievement</.test(r.text), 'each game with its achievements, newest first');
assert.ok(/pf-game-n">2 achievements<[\s\S]*Big Haul[\s\S]*First Haul[\s\S]*Vibe Heroes[\s\S]*1 achievement</.test(r.text), 'each game with its achievements, newest first');
- assert.ok(r.text.includes('href="https://play.openvibe.network/t/salvage-rush"'), 'a game links to its page on Play');
+ assert.ok(r.text.includes('href="https://openvibe.games/achievements#game-salvage-rush"'), 'a game links to its row on OpenVibe.Games');
assert.ok(/<span class="pf-stat-k">Achievements<\/span><span class="pf-stat-v">3<\/span>/.test(r.text));
assert.ok(/<span class="pf-stat-k">Achievements<\/span><span class="pf-stat-v">3<\/span>/.test(r.text));
assert.strictEqual(calls.find((c) => c.path.endsWith('/badges')).auth, 'Bearer tok:openvibe.quest:quest.badge.read', 'and for Quest');
assert.strictEqual(calls.find((c) => c.path.endsWith('/badges')).auth, 'Bearer tok:openvibe.quest:quest.badge.read', 'and for Quest');
assert.ok(r.text.includes('id="pf-badges"') && /Thread <Starter>[\s\S]*First Follow/.test(r.text), 'badges, newest first, escaped');
assert.ok(r.text.includes('id="pf-badges"') && /Thread <Starter>[\s\S]*First Follow/.test(r.text), 'badges, newest first, escaped');
@@ -142,8 +142,10 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch
@@ -142,8 +142,10 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch
assert.ok(/<tr id="svc-ai">[\s\S]*?>Up<\/span>/.test(html), 'AI serves its own public origin since contracts 0.110.0');
assert.ok(/<tr id="svc-ai">[\s\S]*?>Up<\/span>/.test(html), 'AI serves its own public origin since contracts 0.110.0');
// 3. The Frame: the nav lists only sites whose domain serves the service; the rest are soon with a reason.
// 3. The Frame: the nav lists only sites whose domain serves the service; the rest are soon with a reason.
+ // Play stays live in the exposure overlay (the API serves) but is part of Games now, so it is not a nav site.
+ assert.ok(!SITES.some(s => s.id === 'play'), 'Play is part of Games, not a menu entry');
const open = SITES.filter(s => s.status === 'open').map(s => s.id);
const open = SITES.filter(s => s.status === 'open').map(s => s.id);
- const EXPECTED_OPEN = ['live', 'tools', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'space', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'search', 'run', 'play', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect']; // by site position; Email joins after Chat once its manifest is pinned
+ const EXPECTED_OPEN = ['live', 'tools', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'space', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'search', 'run', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect']; // by site position; Email joins after Chat once its manifest is pinned
if (HAS_MAIL) EXPECTED_OPEN.splice(EXPECTED_OPEN.indexOf('chat') + 1, 0, 'mail');
if (HAS_MAIL) EXPECTED_OPEN.splice(EXPECTED_OPEN.indexOf('chat') + 1, 0, 'mail');
assert.deepStrictEqual(open, EXPECTED_OPEN);
assert.deepStrictEqual(open, EXPECTED_OPEN);
for (const s of SITES) {
for (const s of SITES) {