openvibers/openvibe.games Commits
The OpenVibe.Games home shows what is happening: players online, the top boards, achievements and who earned them, new and most-visited creator worlds, Steam's top sellers, deals and new releases, how it works and a FAQ, all rendered on the server (#134)
Changes
@@@ -6,10 +6,10 @@<link rel="icon" type="image/svg+xml" href="/favicon.svg" /><link rel="manifest" href="/manifest.webmanifest" /><link rel="apple-touch-icon" href="/app-icons/games-192.png" />-- <title>OpenVibe.Games — browser games on the OpenVibe network</title>++ <title>Free browser games, no download · OpenVibe.Games</title><metaname="description"-- content="Play Scraplandia, Salvage Rush and Vibe Heroes in your browser: survival-building together, 90-second scrap rounds and an idle hero collector. No download."++ content="Play Scraplandia, Salvage Rush and Vibe Heroes free in your browser, join worlds players built, and see Steam's top sellers, deals and new releases."/><!-- The OpenVibe Frame: the user's theme before first paint, then navbar + footer. --><!-- Deferred: a slow or unreachable openvibe.network never holds the first paint (the inline colours above cover it). -->@@@ -20,7 +20,7 @@<meta name="robots" content="index, follow" /><meta property="og:type" content="website" /><meta property="og:site_name" content="OpenVibe.Games" />-- <meta property="og:title" content="OpenVibe.Games — browser games on the OpenVibe network" />++ <meta property="og:title" content="Free browser games, no download · OpenVibe.Games" /><metaproperty="og:description"content="Play Scraplandia, Salvage Rush or Vibe Heroes in your browser, no download: survival-building in Scrap City, timed scrap-collecting rounds, or an idle hero collector for your phone. One OpenVibe account across the whole network."@@@ -30,10 +30,10 @@<meta property="og:image:width" content="1200" /><meta property="og:image:height" content="630" /><meta name="twitter:card" content="summary_large_image" />-- <meta name="twitter:title" content="OpenVibe.Games — browser games on the OpenVibe network" />++ <meta name="twitter:title" content="Free browser games, no download · OpenVibe.Games" /><metaname="twitter:description"-- content="Play Scraplandia — multiplayer survival-building in your browser, no download."++ content="Play Scraplandia, Salvage Rush and Vibe Heroes free in your browser, and see what's selling and on sale on Steam."/><meta name="twitter:image" content="https://openvibe.games/og-image.png" /><!-- JSON-LD (WebSite + the game as the site's primary type) is injected as this page is served,@@@ -99,7 +99,7 @@padding: clamp(48px, 9vh, 96px) 20px 30px;}.hero-title {-- font-size: clamp(42px, 8vw, 88px);++ font-size: clamp(42px, 7vw, 76px);font-weight: 900;letter-spacing: 0.01em;line-height: 1;@@@ -122,6 +122,65 @@color: transparent;filter: drop-shadow(0 6px 34px rgba(var(--accent-rgb, 59, 130, 246), 0.35));}++ .hero-h1 {++ margin: 18px auto 0;++ max-width: 22ch;++ font-size: clamp(24px, 3.6vw, 38px);++ font-weight: 850;++ line-height: 1.15;++ text-wrap: balance;++ animation: rise 0.9s 0.06s cubic-bezier(0.2, 0.8, 0.2, 1) both;++ }++ .hero-actions {++ display: flex;++ flex-wrap: wrap;++ justify-content: center;++ align-items: center;++ gap: 12px 24px;++ margin-top: 26px;++ }++ .hero-actions .play-cta {++ margin-top: 0;++ align-self: center;++ }++ .hero-link {++ color: var(--accent-light);++ font-weight: 700;++ text-decoration: none;++ border-bottom: 1px solid rgba(var(--accent-rgb, 59, 130, 246), 0.4);++ }++ .hero-link:hover {++ border-bottom-color: var(--accent-light);++ }++ /* The figures under the hero (server-rendered, net/portalHome.ts): real counts only. */++ .home-stats {++ list-style: none;++ padding: 0;++ margin: 30px auto 0;++ max-width: 980px;++ display: grid;++ grid-template-columns: repeat(auto-fit, minmax(min(128px, 100%), 1fr));++ gap: 10px;++ }++ .home-stats li {++ padding: 14px 12px;++ border: 1px solid var(--border);++ border-radius: 14px;++ background: var(--bg-card);++ display: flex;++ flex-direction: column;++ gap: 2px;++ }++ .home-stats b {++ font-size: 26px;++ font-weight: 900;++ color: var(--text);++ }++ .home-stats span {++ font-size: 13px;++ color: var(--text-dim);++ overflow-wrap: anywhere;++ }.hero-sub {margin: 18px auto 0;max-width: 640px;@@@ -225,6 +284,7 @@gap: 12px;}.badge {++ text-transform: uppercase;align-self: flex-start;font-size: 11px;font-weight: 800;@@@ -242,8 +302,13 @@letter-spacing: 0.02em;}.game-body h3 {-- font-size: 20px;-- font-weight: 800;++ font-size: 22px;++ font-weight: 850;++ letter-spacing: 0.01em;++ }++ .game-card.featured h3 {++ font-size: clamp(26px, 3.4vw, 38px);++ font-weight: 900;}.game-body p {color: var(--text-dim);@@@ -329,6 +394,288 @@box-shadow: 0 8px 48px rgba(var(--accent-rgb, 59, 130, 246), 0.7);}}++ /* ── live sections (net/portalHome.ts renders them on the server) ── */++ .home-sec {++ max-width: 1100px;++ margin: 0 auto 64px;++ padding: 0 20px;++ }++ .home-sec + .games {++ margin-top: -40px;++ }++ .home-sec h2 {++ font-size: clamp(24px, 3vw, 32px);++ font-weight: 900;++ margin-bottom: 8px;++ }++ .home-lede {++ color: var(--text-dim);++ line-height: 1.6;++ max-width: 70ch;++ margin-bottom: 20px;++ }++ .home-sub {++ font-size: 17px;++ font-weight: 800;++ margin: 22px 0 12px;++ color: var(--text-dim);++ }++ .home-more {++ margin-top: 18px;++ display: flex;++ flex-wrap: wrap;++ gap: 10px 24px;++ }++ .board-grid,++ .ach-grid,++ .world-grid,++ .steam-cols {++ display: grid;++ grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr));++ gap: 16px;++ list-style: none;++ padding: 0;++ }++ .board-card,++ .world-card,++ .steam-col,++ .ach {++ background: var(--bg-card);++ border: 1px solid var(--border);++ border-radius: 16px;++ padding: 18px;++ min-width: 0;++ }++ .board-card h3 {++ font-size: 18px;++ font-weight: 850;++ margin-bottom: 12px;++ }++ .board-card h3 small {++ display: block;++ font-size: 13px;++ font-weight: 600;++ color: var(--text-faint);++ margin-top: 2px;++ }++ .board-rows {++ list-style: none;++ padding: 0;++ margin-bottom: 14px;++ }++ .board-rows li {++ display: grid;++ grid-template-columns: 28px 1fr auto;++ align-items: center;++ gap: 10px;++ padding: 7px 0;++ border-top: 1px solid var(--border);++ }++ .board-rows li:first-child {++ border-top: 0;++ }++ .board-rows .rank {++ width: 26px;++ height: 26px;++ border-radius: 8px;++ display: grid;++ place-items: center;++ font-weight: 900;++ font-size: 13px;++ background: rgba(var(--accent-rgb, 59, 130, 246), 0.16);++ color: var(--text);++ }++ .board-rows .who {++ min-width: 0;++ overflow-wrap: anywhere;++ font-weight: 700;++ }++ .board-rows .score {++ color: var(--text-dim);++ font-variant-numeric: tabular-nums;++ white-space: nowrap;++ }++ .ach-grid {++ grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr));++ gap: 12px;++ }++ .ach {++ display: flex;++ flex-direction: column;++ gap: 4px;++ padding: 14px 16px;++ }++ .ach.earned {++ border-color: rgba(46, 204, 113, 0.45);++ }++ .ach-game {++ font-size: 11px;++ font-weight: 800;++ letter-spacing: 0.12em;++ text-transform: uppercase;++ color: var(--secondary);++ }++ .ach b {++ font-size: 16px;++ }++ .ach-how,++ .ach-reward {++ font-size: 14px;++ color: var(--text-dim);++ line-height: 1.45;++ }++ .ach-reward {++ color: var(--accent-light);++ }++ .ach-count {++ margin-top: auto;++ padding-top: 6px;++ font-size: 13px;++ font-weight: 700;++ color: var(--text-faint);++ }++ .ach.earned .ach-count {++ color: var(--success, #2ecc71);++ }++ .world-card h3 {++ font-size: 17px;++ overflow-wrap: anywhere;++ }++ .world-card h3 a {++ color: inherit;++ text-decoration: none;++ }++ .world-card h3 a:hover {++ color: var(--accent-light);++ }++ .world-card p {++ margin: 6px 0;++ color: var(--text-dim);++ font-size: 14px;++ line-height: 1.5;++ overflow-wrap: anywhere;++ }++ .world-meta {++ font-size: 13px;++ color: var(--text-faint);++ }++ .steam-col h3 {++ font-size: 17px;++ font-weight: 850;++ margin-bottom: 10px;++ }++ .steam-list {++ list-style: none;++ padding: 0;++ display: flex;++ flex-direction: column;++ gap: 8px;++ }++ .steam-list a {++ display: flex;++ gap: 12px;++ align-items: center;++ color: inherit;++ text-decoration: none;++ border-radius: 10px;++ padding: 4px;++ min-width: 0;++ }++ .steam-list a:hover,++ .steam-list a:focus-visible {++ background: rgba(var(--accent-rgb, 59, 130, 246), 0.1);++ }++ .steam-list img,++ .steam-art {++ width: 104px;++ height: 49px;++ flex: none;++ border-radius: 6px;++ object-fit: cover;++ background: var(--bg-raised);++ }++ .steam-text {++ display: flex;++ flex-direction: column;++ gap: 2px;++ min-width: 0;++ }++ .steam-text b {++ font-size: 14px;++ line-height: 1.3;++ overflow-wrap: anywhere;++ }++ .steam-price {++ font-size: 13px;++ color: var(--text-dim);++ }++ .steam-price s {++ color: var(--text-faint);++ }++ .steam-price .off {++ display: inline-block;++ padding: 0 6px;++ border-radius: 6px;++ background: #15803d;++ color: #fff;++ font-weight: 800;++ }++ .how-steps {++ list-style: none;++ padding: 0;++ counter-reset: how;++ display: grid;++ grid-template-columns: repeat(auto-fill, minmax(min(230px, 100%), 1fr));++ gap: 14px;++ }++ .how-steps li {++ counter-increment: how;++ display: flex;++ flex-direction: column;++ gap: 6px;++ padding: 18px;++ border: 1px solid var(--border);++ border-radius: 16px;++ background: var(--bg-card);++ }++ .how-steps li::before {++ content: counter(how);++ width: 30px;++ height: 30px;++ border-radius: 50%;++ display: grid;++ place-items: center;++ font-weight: 900;++ background: var(--accent-strong, #2563eb);++ color: var(--on-accent-strong, #fff);++ }++ .how-steps span {++ color: var(--text-dim);++ font-size: 14px;++ line-height: 1.55;++ }++ .faq {++ display: flex;++ flex-direction: column;++ gap: 10px;++ max-width: 820px;++ }++ .faq details {++ border: 1px solid var(--border);++ border-radius: 14px;++ background: var(--bg-card);++ padding: 0 18px;++ }++ .faq summary {++ cursor: pointer;++ padding: 15px 0;++ font-weight: 750;++ }++ .faq details p {++ padding: 0 0 16px;++ color: var(--text-dim);++ line-height: 1.6;++ }/* ── accounts ────────────────────────────────────────────────── */.account {max-width: 820px;@@@ -448,15 +795,29 @@</nav><header>-- <h1 class="hero-title">Open<b>Vibe.Games</b></h1>++ <p class="hero-title">Open<b>Vibe.Games</b></p>++ <h1 class="hero-h1">Free games that start in a browser tab</h1><p class="hero-sub">-- Multiplayer games that start in a browser tab. No installs, no launchers. Jump in as a-- guest, or sign in with your OpenVibe account to keep your character, your base and your name-- across the whole network.++ Build and survive with other players, race for scrap in 90-second rounds, grow a team of++ heroes on your phone or join a world another player built. Nothing to install: play as a++ guest, or sign in to keep your character across OpenVibe.</p>++ <div class="hero-actions">++ <a class="play-cta" href="https://play.openvibe.games" data-dev-href="/play"++ >▶ Play Scraplandia</a++ >++ <a class="hero-link" href="#play-now">See every game</a>++ </div>++ <!--ov:home-stats--></header>-- <section class="games">++ <section class="home-sec" aria-labelledby="play-now">++ <h2 id="play-now">Play now</h2>++ <p class="home-lede">++ Every game here runs in the browser, on a computer or a phone. Press play and you are in.++ </p>++ </section>++ <section class="games" aria-label="Games"><!-- ── Featured: Scraplandia ── --><div class="game-card featured"><div@@@ -559,8 +920,8 @@</svg></div><div class="game-body">-- <span class="badge live">● LIVE NOW</span>-- <h2>SCRAPLANDIA</h2>++ <span class="badge live">● Live now</span>++ <h3 class="game-name">Scraplandia</h3><p>Scrappy multiplayer survival-building in and around <b>Scrap City</b>. Physgun anything,weld it to anything else, cook at a burn barrel, chase supply drops in the wilds — and@@@ -574,7 +935,7 @@</div><div class="cta-row"><a class="play-cta" href="https://play.openvibe.games" data-dev-href="/play"-- >▶ PLAY — NO DOWNLOAD</a++ >▶ Play now, no download</a><a class="card-link" href="/leaderboard.html?game=scraplandia">Haul leaderboard →</a></div>@@@ -665,8 +1026,8 @@</svg></div><div class="game-body">-- <span class="badge live">● LIVE NOW</span>-- <h3>SALVAGE RUSH</h3>++ <span class="badge live">● Live now</span>++ <h3>Salvage Rush</h3><p>Collect scrap around a compact arena in 90-second on-foot rounds. The server scores eachpickup; rounds have a public leaderboard and three achievements that give items worn@@@ -676,7 +1037,7 @@<span>timed rounds</span><span>on-foot</span><span>leaderboard</span></div><div class="cta-row">-- <a class="play-cta" href="/play?game=salvage-rush">▶ PLAY A ROUND</a>++ <a class="play-cta" href="/play?game=salvage-rush">▶ Play a round</a><a class="card-link" href="/leaderboard.html?game=salvage-rush">Leaderboard →</a></div></div>@@@ -698,8 +1059,8 @@/></div><div class="game-body">-- <span class="badge live">● NEW</span>-- <h3>VIBE HEROES</h3>++ <span class="badge live">● New</span>++ <h3>Vibe Heroes</h3><p>An idle hero collector made for your phone. Your team battles on its own: fuse heroesfrom shards, star them up and push through 100 stages, even while you are away. Summons@@@ -709,7 +1070,7 @@<span>idle</span><span>hero collector</span><span>phone-first</span></div><div class="cta-row">-- <a class="play-cta" href="/play/vibe-heroes">▶ PLAY NOW</a>++ <a class="play-cta" href="/play/vibe-heroes">▶ Play now</a></div></div></div>@@@ -777,8 +1138,8 @@</svg></div><div class="game-body">-- <span class="badge live">● NEW</span>-- <h3>CREATOR WORLDS</h3>++ <span class="badge live">● New</span>++ <h3>Creator worlds</h3><p>Build your own world in the map editor: terrain, buildings, lights, zones and props.Publish it and anyone can join from their browser, or browse what other players made.@@@ -787,7 +1148,7 @@<span>map editor</span><span>publish</span><span>multiplayer</span></div><div class="cta-row">-- <a class="play-cta" href="/worlds">▶ BROWSE & BUILD</a>++ <a class="play-cta" href="/worlds">▶ Browse and build</a></div></div></div>@@@ -832,8 +1193,8 @@</svg></div><div class="game-body">-- <span class="badge live">● NEW</span>-- <h3>ON STEAM RIGHT NOW</h3>++ <span class="badge live">● New</span>++ <h3>On Steam right now</h3><p>What sells on Steam, what's on sale and what just came out, with Steam's own prices, refreshed through theday. Every game opens its store page.@@@ -842,12 +1203,14 @@<span>top sellers</span><span>deals</span><span>new releases</span></div><div class="cta-row">-- <a class="play-cta" href="/steam">▶ SEE THE LISTS</a>++ <a class="play-cta" href="/steam">▶ See the lists</a></div></div></div></section>++ <!--ov:home-sections-->++<section class="account"><div class="account-inner"><h2>One character as a guest. <b>Three</b> with an OpenVibe account.</h2>@@@ -115,6 +115,11 @@ export class PlaceRouter {return this.allHubs().flatMap((hub) => hub.activeRooms)}++ /** Players connected to each built-in place now (creator worlds count theirs in worldHost.online()). */++ onlineByPlace(): Map<string, number> {++ return new Map([...this.hubs].map(([id, hub]) => [id, hub.onlineCount]))++ }++get onlineCount(): number {return this.allHubs().reduce((total, hub) => total + hub.onlineCount, 0)}@@@ -82,7 +82,8 @@ import type { RoundResult } from './game/salvageRush.js'import { handleGameRead } from './net/gameRoutes.js'import { createPlayBoards } from './platform/playBoards.js'import { createCasualRoutes } from './casual/routes.js'--import { pruneCasualActions } from './casual/store.js'++import { casualLeaderboard, pruneCasualActions } from './casual/store.js'++import { createPortalHome, readPlayTitles } from './net/portalHome.js'import { CASUAL_GAMES, validatePack } from '@openvibe/casual'import { CreatorWorldHost } from './worlds/worldHost.js'import { createWorldHubStarter } from './worlds/startWorldHub.js'@@@ -819,6 +820,33 @@ async function main(): Promise<void> {events: { db, sink, siteUrl: gamesSiteUrl },...(worldSearch ? { search: worldSearch } : {}),})++ // The home page's live sections (net/portalHome.ts): players online, Play's boards and achievements, Vibe Heroes'++ // furthest teams, creator worlds and Steam's lists, built at most once a minute.++ const playTitlesUrl = config.platform.playUrl++ const homeSections = createPortalHome(++ {++ online: () => {++ const byPlace = router.onlineByPlace()++ let worlds = 0++ for (const count of worldHost.online().values()) worlds += count++ return {++ scraplandia: byPlace.get('scraplandia') ?? 0,++ salvageRush: byPlace.get('salvage-rush') ?? 0,++ worlds,++ }++ },++ titles: () => (playTitlesUrl ? readPlayTitles(playTitlesUrl) : Promise.resolve([])),++ board: async (game, period) => (await playBoards(game, period, 1, '127.0.0.1')).entries.slice(0, 5),++ heroes: () => casualLeaderboard(db, 'vibe-heroes', 5),++ worlds: () => creatorWorlds.listPublished(60, 0),++ steam: () => steamCatalog.lists(),++ },++ {++ site: gamesSiteUrl,++ playSite: config.oauth?.playUrl ?? 'https://play.openvibe.games',++ log: log.child({ system: 'home' }),++ },++ )// The casual games' JSON API (plan T22).const serveCasual = createCasualRoutes({db,@@@ -1053,6 +1081,7 @@ async function main(): Promise<void> {onAssetStored: (asset) => mirror?.enqueue(asset),limits: actorLimits,clientAddress,++ homeSections,},ticketStore,createGamesIndexNow(@@@ -209,6 +209,13 @@ function publicPageDocs(): PublicPageDoc[] {text: 'Creator worlds: players shape terrain and place buildings, lights, zones, resource nodes and props in the OpenVibe.Games map editor, then publish the world for anyone to join from the browser. A world is a sandbox: what players gather or build inside it stays in that world.',priority: 0.7,},++ {++ title: 'On Steam right now',++ url: `${SITE}/steam`,++ note: "Steam's top sellers, deals and new releases with Steam's prices, each linked to its store page",++ text: "Steam's featured lists as the store shows them: top sellers in the store's order, the deals Steam features and the new releases, with Steam's own US dollar prices and a link to every game's store page. Read from the Steam store's public storefront lists every few hours through OpenVibe.Sources.",++ priority: 0.6,++ },{title: 'What shipped',url: `${SITE}/updates`,@@@ -314,6 +321,11 @@ export function homeJsonLd(): unknown[] {]}++/** Any JSON-LD nodes as `<script>` tags, through the shared kit's escaping (the home's live parts use it). */++export function jsonLdTagsOf(nodes: unknown[]): string {++ return nodes.map((node) => seo.jsonLdTag(node)).join('\n')++}++/** The home page's JSON-LD as `<script>` tags, ready to inject before `</head>`. */export function homeJsonLdTags(): string {return homeJsonLd()@@@ -29,7 +29,8 @@ import { MAX_ASSET_BYTES, isContentAddressed, storeAsset } from './mapAssetStoreimport type { GamesActorLimits } from './actorLimits.js'import { resolveClientAddress } from './clientAddress.js'import { sendNotFound, type NotFoundLink } from './notFound.js'--import { discoveryHandler, homeJsonLdTags } from './discovery.js'++import { discoveryHandler, homeJsonLdTags, jsonLdTagsOf } from './discovery.js'++import type { HomeParts } from './portalHome.js'import { legalHandler } from './legalPages.js'import { updatesHandler } from './updatesPage.js'import { indexnowHandler, type SharedIndexNow } from './indexnow.js'@@@ -49,6 +50,16 @@ export function portalHtml(filePath: string): string {portalMemo = { path: filePath, mtimeMs, html }return html}++++/** The portal with its live parts (portalHome.ts) in place of the build's markers, and their JSON-LD in the head. */++export function portalPage(filePath: string, parts: HomeParts | null): string {++ const html = portalHtml(filePath)++ if (!parts) return html++ return html++ .replace('<!--ov:home-stats-->', () => parts.stats)++ .replace('<!--ov:home-sections-->', () => parts.sections)++ .replace('</head>', () => `${jsonLdTagsOf(parts.jsonLd)}\n </head>`)++}import { handleWsTicketRequest, type WsTicketStore } from './wsTicket.js'import { casualGameOf, casualPageHtml } from '../casual/page.js'import type { MapFileV2 } from '@openvibe/content'@@@ -151,6 +162,8 @@ export interface HttpPlatformHooks {limits?: GamesActorLimits/** Trusted-proxy-aware client address (./clientAddress.ts); the socket address when absent. */clientAddress?: (req: IncomingMessage) => string++ /** The home page's live sections (./portalHome.ts); the static portal alone when absent. */++ homeSections?: () => Promise<HomeParts>}/** The token endpoint's answer, for both the code and the jwt-bearer grant. */@@@ -818,11 +831,16 @@ export function createHttpServer(// The portal is a static build, so its JSON-LD is injected as the page is served: the same// openvibe-shared/seo builders every OpenVibe site uses, one response for every crawler.if (pageAlias === 'index.html' || url === '/index.html') {-- res.writeHead(200, {-- 'content-type': 'text/html; charset=utf-8',-- 'cache-control': 'no-cache',++ const live = platform?.homeSections++ void (live ? live().catch(() => null) : Promise.resolve(null)).then((parts) => {++ const body = portalPage(filePath, parts)++ res.writeHead(200, {++ 'content-type': 'text/html; charset=utf-8',++ 'cache-control': 'no-cache',++ 'content-length': String(Buffer.byteLength(body)),++ })++ res.end(req.method === 'HEAD' ? undefined : body)})-- res.end(portalHtml(filePath))return}const type = MIME[extname(filePath)] ?? 'application/octet-stream'nnew file mode 100644@@@ -0,0 +1,205 @@++import { mkdtempSync, writeFileSync } from 'node:fs'++import { tmpdir } from 'node:os'++import { join } from 'node:path'++import { describe, expect, it } from 'vitest'++import type { CreatorWorld } from '@openvibe/persistence'++import type { SteamGame } from '../steam/steamCatalog.js'++import { portalPage } from './httpServer.js'++import {++ HOME_FAQ,++ createPortalHome,++ readPlayTitles,++ renderHome,++ renderStats,++ type HomeData,++ type HomeSources,++} from './portalHome.js'++++const NOW = Date.parse('2026-10-22T12:00:00Z')++++const empty = (): HomeData => ({++ online: { scraplandia: 0, salvageRush: 0, worlds: 0 },++ titles: [],++ boards: { haulWeek: [], haulAll: [], rushWeek: [], rushAll: [] },++ heroes: [],++ worlds: [],++ steam: null,++})++++const game = (appid: number, name: string, extra: Partial<SteamGame> = {}): SteamGame => ({++ appid,++ position: 0,++ name,++ final_price: 1999,++ original_price: 1999,++ discount_percent: 0,++ currency: 'USD',++ header_image: `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${appid}/header.jpg`,++ windows: true,++ mac: false,++ linux: false,++ discount_ends_at: null,++ observed_at: NOW - 2 * 3600_000,++ ...extra,++})++++const world = (id: string, plays: number, publishedAt: number): CreatorWorld => ({++ id,++ ownerSubject: `usr_${id}`,++ ownerName: 'Builder <b>',++ title: `World ${id}`,++ summary: 'A floating island',++ status: 'published',++ revision: 'r1',++ plays,++ createdAt: publishedAt,++ updatedAt: publishedAt,++ publishedAt,++})++++describe('the home page live sections', () => {++ it('with no data, only the sections that need none: how it works and the questions', () => {++ const parts = renderHome(empty(), NOW)++ expect(parts.stats).toBe('')++ expect(parts.sections).toContain('id="home-how"')++ expect(parts.sections).toContain('id="home-faq"')++ for (const id of ['home-boards', 'home-achievements', 'home-worlds', 'home-steam'])++ expect(parts.sections).not.toContain(id)++ // The FAQPage carries exactly the questions the page shows.++ const faq = parts.jsonLd.find((n) => (n as { '@type': string })['@type'] === 'FAQPage') as {++ mainEntity: { name: string }[]++ }++ expect(faq.mainEntity.map((q) => q.name)).toEqual(HOME_FAQ.map((f) => f.q))++ for (const f of HOME_FAQ) expect(parts.sections).toContain(f.q.replace(/'/g, '''))++ })++++ it('shows the real counts, boards, achievements, worlds and Steam lists, escaped', () => {++ const d = empty()++ d.online = { scraplandia: 2, salvageRush: 1, worlds: 0 }++ d.titles = [++ {++ id: 'scraplandia',++ name: 'Scraplandia',++ players: 25,++ achievements: [++ {++ id: 'first-visit',++ name: 'First Visit',++ description: 'Join Scraplandia.',++ unlockedBy: 25,++ },++ { id: 'big-haul', name: 'Big Haul', description: 'Score 500.', unlockedBy: 0 },++ ],++ },++ ]++ d.boards.rushAll = [{ rank: 1, name: '<script>x</script>', score: 12 }]++ d.heroes = [{ rank: 1, name: 'Ana', stage: 7, power: 900 }]++ // Newest first (as the store lists them); the seventh is older but the most visited.++ d.worlds = [++ ...['a', 'b', 'c', 'd', 'e', 'f'].map((id, i) => world(id, 0, NOW - 3600_000 * (i + 1))),++ world('old', 40, NOW - 86400_000 * 3),++ ]++ d.steam = {++ top: [game(1, 'Top One')],++ sale: [game(2, 'Half Off', { discount_percent: 50, final_price: 999 })],++ new: [],++ observed_at: NOW - 2 * 3600_000,++ }++ const parts = renderHome(d, NOW)++ expect(parts.stats).toContain('<b>3</b><span>players online now</span>')++ expect(parts.stats).toContain('<b>25</b><span>players have played Scraplandia</span>')++ expect(parts.stats).toContain('<b>25</b><span>achievements earned</span>')++ expect(parts.sections).toContain('best round, all time')++ expect(parts.sections).toContain('<script>x</script>')++ expect(parts.sections).not.toContain('<script>x')++ expect(parts.sections).toContain('stage 7')++ expect(parts.sections).toContain('Earned by 25 players')++ expect(parts.sections).toContain('Nobody has earned it yet')++ expect(parts.sections).toContain('Gives the Golden Name')++ expect(parts.sections).toContain('href="/worlds/a"')++ expect(parts.sections).toContain('Builder <b>')++ expect(parts.sections).toContain('Most visited')++ expect(parts.sections).toContain('40 visits')++ expect(parts.stats).toContain('<b>7</b><span>creator worlds published</span>')++ expect(parts.sections).toContain('https://store.steampowered.com/app/2/')++ expect(parts.sections).toContain('−50%')++ expect(parts.sections).toContain('checked 2 hours ago')++ expect(parts.sections).not.toContain('home-steam-new')++ })++++ it('a stat with nothing to say is left out', () => {++ const d = empty()++ d.titles = [{ id: 'salvage-rush', name: 'Salvage Rush', players: 0, achievements: [] }]++ expect(renderStats(d)).toBe('')++ })++++ it('reads Play titles defensively', async () => {++ const fetch = (async () =>++ new Response(++ JSON.stringify({++ titles: [++ {++ id: 'scraplandia',++ name: 'Scraplandia',++ players: 3,++ achievements: [{ id: 'x', name: 'X', description: 'Do x', unlocked_by: 2 }],++ },++ { id: 7 },++ ],++ }),++ )) as unknown as typeof globalThis.fetch++ const titles = await readPlayTitles('http://play.test', fetch)++ expect(titles).toEqual([++ {++ id: 'scraplandia',++ name: 'Scraplandia',++ players: 3,++ achievements: [{ id: 'x', name: 'X', description: 'Do x', unlockedBy: 2 }],++ },++ ])++ })++++ it('a failing source drops its section, and a build is reused for a minute', async () => {++ let now = NOW++ let calls = 0++ const src: HomeSources = {++ online: () => ({ scraplandia: 1, salvageRush: 0, worlds: 0 }),++ titles: async () => {++ calls += 1++ throw new Error('play down')++ },++ board: async () => [],++ heroes: async () => [],++ worlds: async () => [],++ steam: async () => ({ top: [game(5, 'Five')], sale: [], new: [], observed_at: NOW }),++ }++ const warnings: string[] = []++ const home = createPortalHome(src, { now: () => now, log: { warn: (m) => warnings.push(m) } })++ const first = await home()++ expect(first.stats).toContain('player online now')++ expect(first.sections).toContain('id="home-steam"')++ expect(first.sections).not.toContain('home-achievements')++ now += 30_000++ await home()++ expect(calls).toBe(1)++ expect(warnings).toEqual(['home section unavailable'])++ })++++ it('the portal page swaps the markers and adds the JSON-LD', () => {++ const dir = mkdtempSync(join(tmpdir(), 'portal-'))++ const file = join(dir, 'index.html')++ writeFileSync(++ file,++ '<html><head><title>t</title></head><body><header><!--ov:home-stats--></header><!--ov:home-sections--></body></html>',++ )++ const html = portalPage(file, {++ stats: '<ul class="home-stats"></ul>',++ sections: '<section id="s">$&</section>',++ jsonLd: [{ '@type': 'FAQPage' }],++ })++ expect(html).toContain('<header><ul class="home-stats"></ul></header>')++ expect(html).toContain('<section id="s">$&</section>')++ expect(html).toContain('"@type":"FAQPage"')++ expect(html).not.toContain('<!--ov:home')++ })++})nnew file mode 100644@@@ -0,0 +1,573 @@++import { SCRAPLANDIA_PACK } from '@openvibe/content'++import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'++import type { CreatorWorld } from '@openvibe/persistence'++import type { Logger } from '@openvibe/shared'++import { escapeHtml } from './notFound.js'++import type { SteamGame } from '../steam/steamCatalog.js'++++/**++ * The home page's live sections, rendered on the server so crawlers and visitors without JavaScript read the same++ * page: who is playing now, the top boards (OpenVibe.Play) and Vibe Heroes' furthest teams, the achievements people++ * earn (Play's unlock counts), new and most-played creator worlds, Steam's lists (OpenVibe.Sources), how it works and++ * the questions people ask. Every number is a real row or a live count; a section with nothing to show is left out.++ *++ * The portal itself is a static Vite page: httpServer.ts swaps two markers in it for what this module builds,++ * <!--ov:home-stats--> the figures under the hero++ * <!--ov:home-sections--> the sections after the game cards++ * and adds the FAQ and game list JSON-LD. One build is kept for CACHE_MS and served while the next one is made, so a++ * slow Play or database never holds the page: the first request after boot waits at most FIRST_WAIT_MS.++ */++++const CACHE_MS = 60_000++const FIRST_WAIT_MS = 2_500++const PLAY_SITE = 'https://play.openvibe.network'++++export interface BoardRow {++ rank: number++ name: string++ score: number++}++++export interface PlayTitleSummary {++ id: string++ name: string++ players: number++ achievements: { id: string; name: string; description: string; unlockedBy: number }[]++}++++export interface SteamLists {++ top: SteamGame[]++ sale: SteamGame[]++ new: SteamGame[]++ observed_at: number | null++}++++export interface HomeData {++ /** Players connected now, per built-in place, plus everyone inside running creator worlds. */++ online: { scraplandia: number; salvageRush: number; worlds: number }++ titles: PlayTitleSummary[]++ boards: {++ haulWeek: BoardRow[]++ haulAll: BoardRow[]++ rushWeek: BoardRow[]++ rushAll: BoardRow[]++ }++ heroes: { rank: number; name: string; stage: number; power: number }[]++ worlds: CreatorWorld[]++ steam: SteamLists | null++}++++export interface HomeSources {++ online(): HomeData['online']++ /** Play's public titles read (players, achievements and how many unlocked each). */++ titles(): Promise<PlayTitleSummary[]>++ board(game: 'scraplandia' | 'salvage-rush', period: 'week' | 'all'): Promise<BoardRow[]>++ heroes(): Promise<HomeData['heroes']>++ worlds(): Promise<CreatorWorld[]>++ steam(): Promise<SteamLists>++}++++export interface HomeParts {++ stats: string++ sections: string++ jsonLd: unknown[]++}++++/** The questions the home answers, shown on the page and published as its FAQPage (the same pairs, one source). */++export const HOME_FAQ: readonly { q: string; a: string }[] = Object.freeze([++ {++ q: 'Do I need to download anything?',++ a: 'No. Every game here runs in a browser tab on a computer or a phone. Open the page and press play.',++ },++ {++ q: 'Are the games free?',++ a: 'Yes. Scraplandia, Salvage Rush, Vibe Heroes and every creator world are free to play in your browser.',++ },++ {++ q: 'Do I need an account?',++ a: 'No. You can play as a guest with one character tied to this browser. An OpenVibe account adds three character slots, keeps your progress across devices and puts your name on the leaderboards.',++ },++ {++ q: 'What do achievements give me?',++ a: 'Each one is recorded on OpenVibe.Play with your account, and some give an item you keep in your OpenVibe inventory, such as the Golden Name for a 500-point haul or the Cowboy Hat for a 2,000-point week in Scraplandia.',++ },++ {++ q: 'Can I build my own world?',++ a: 'Yes. Sign in, open Creator worlds and start a world in the map editor: terrain, buildings, lights, zones and props. Publish it and anyone can join from their browser.',++ },++ {++ q: 'Where do the Steam lists come from?',++ a: "From the Steam store's public top sellers, specials and new releases lists, read every few hours. Prices are Steam's own and every game links to its store page. OpenVibe sells nothing there.",++ },++])++++/** The item each achievement gives (the content packs' rewards, the same ones inventoryRewards.ts grants). */++const REWARDS = new Map<string, string>([++ ...(SCRAPLANDIA_PACK.achievements ?? []).flatMap((a) =>++ a.reward ? [[`scraplandia/${a.id}`, a.reward.name] as const] : [],++ ),++ ...(SALVAGE_RUSH_PACK.game?.achievements ?? []).flatMap((a) =>++ a.reward ? [[`salvage-rush/${a.id}`, a.reward.name] as const] : [],++ ),++])++++const esc = (v: unknown): string => escapeHtml(String(v ?? ''))++const fmt = new Intl.NumberFormat('en-US')++const n = (v: number): string => fmt.format(v)++const plural = (count: number, one: string, many: string): string =>++ `${n(count)} ${count === 1 ? one : many}`++++function money(cents: number | null, currency: string | null): string {++ if (cents == null) return ''++ if (cents === 0) return 'Free'++ try {++ return new Intl.NumberFormat('en-US', {++ style: 'currency',++ currency: currency || 'USD',++ }).format(cents / 100)++ } catch {++ return `${(cents / 100).toFixed(2)} ${currency ?? ''}`.trim()++ }++}++++function ago(ms: number, now: number): string {++ const min = Math.max(0, Math.round((now - ms) / 60000))++ if (min < 1) return 'just now'++ if (min < 60) return `${min} minute${min === 1 ? '' : 's'} ago`++ const h = Math.round(min / 60)++ if (h < 48) return `${h} hour${h === 1 ? '' : 's'} ago`++ return `${Math.round(h / 24)} days ago`++}++++/** The figures under the hero: only the ones that have something to say. */++export function renderStats(d: HomeData): string {++ const items: { value: number; label: string }[] = []++ const playing = d.online.scraplandia + d.online.salvageRush + d.online.worlds++ if (playing > 0)++ items.push({++ value: playing,++ label: playing === 1 ? 'player online now' : 'players online now',++ })++ for (const t of d.titles) {++ if (t.players > 0)++ items.push({++ value: t.players,++ label: `${t.players === 1 ? 'player has' : 'players have'} played ${t.name}`,++ })++ }++ const earned = d.titles.reduce(++ (sum, t) => sum + t.achievements.reduce((s, a) => s + a.unlockedBy, 0),++ 0,++ )++ if (earned > 0)++ items.push({++ value: earned,++ label: earned === 1 ? 'achievement earned' : 'achievements earned',++ })++ const published = d.worlds.length++ if (published > 0)++ items.push({++ value: published,++ label: published === 1 ? 'creator world published' : 'creator worlds published',++ })++ const deals = d.steam?.sale.length ?? 0++ if (deals > 0)++ items.push({ value: deals, label: deals === 1 ? 'Steam deal today' : 'Steam deals today' })++ if (!items.length) return ''++ return `<ul class="home-stats" aria-label="OpenVibe.Games in numbers">${items++ .slice(0, 5)++ .map((s) => `<li><b>${n(s.value)}</b><span>${esc(s.label)}</span></li>`)++ .join('')}</ul>`++}++++function boardCard(o: {++ title: string++ board: string++ rows: BoardRow[]++ unit: (score: number) => string++ href: string++ more: string++}): string {++ if (!o.rows.length) return ''++ return `<article class="board-card">++ <h3>${esc(o.title)} <small>${esc(o.board)}</small></h3>++ <ol class="board-rows">${o.rows++ .slice(0, 5)++ .map(++ (r) =>++ `<li><span class="rank" aria-hidden="true">${r.rank}</span><span class="who">${esc(r.name)}</span><span class="score">${esc(o.unit(r.score))}</span></li>`,++ )++ .join('')}</ol>++ <a class="card-link" href="${esc(o.href)}">${esc(o.more)}</a>++</article>`++}++++function boardsSection(d: HomeData): string {++ const haulWeek = d.boards.haulWeek.length > 0++ const rushWeek = d.boards.rushWeek.length > 0++ const cards = [++ boardCard({++ title: 'Scraplandia',++ board: haulWeek ? 'haul points this week' : 'haul points, all time',++ rows: haulWeek ? d.boards.haulWeek : d.boards.haulAll,++ unit: (s) => `${n(s)} pts`,++ href: '/leaderboard.html?game=scraplandia',++ more: 'Full haul board →',++ }),++ boardCard({++ title: 'Salvage Rush',++ board: rushWeek ? 'best round this week' : 'best round, all time',++ rows: rushWeek ? d.boards.rushWeek : d.boards.rushAll,++ unit: (s) => plural(s, 'scrap', 'scrap'),++ href: '/leaderboard.html?game=salvage-rush',++ more: 'Full leaderboard →',++ }),++ boardCard({++ title: 'Vibe Heroes',++ board: 'furthest teams',++ rows: d.heroes.map((h) => ({ rank: h.rank, name: h.name, score: h.stage })),++ unit: (s) => `stage ${n(s)}`,++ href: '/play/vibe-heroes',++ more: 'Build your team →',++ }),++ ].filter(Boolean)++ if (!cards.length) return ''++ return `<section class="home-sec" aria-labelledby="home-boards">++ <h2 id="home-boards">Top players</h2>++ <p class="home-lede">The servers score every round, so these boards are what really happened. Signed-in players get their name on them.</p>++ <div class="board-grid">${cards.join('')}</div>++</section>`++}++++function achievementsSection(d: HomeData): string {++ const all = d.titles.flatMap((t) => t.achievements.map((a) => ({ ...a, title: t })))++ if (!all.length) return ''++ all.sort((a, b) => b.unlockedBy - a.unlockedBy || a.title.name.localeCompare(b.title.name))++ return `<section class="home-sec" aria-labelledby="home-achievements">++ <h2 id="home-achievements">Achievements to earn</h2>++ <p class="home-lede">Every achievement is kept on OpenVibe.Play with the number of players who earned it. Some give an item for your OpenVibe inventory.</p>++ <ul class="ach-grid">${all++ .slice(0, 8)++ .map(++ (a) => `<li class="ach${a.unlockedBy > 0 ? ' earned' : ''}">++ <span class="ach-game">${esc(a.title.name)}</span>++ <b>${esc(a.name)}</b>++ <span class="ach-how">${esc(a.description)}</span>++ ${REWARDS.has(`${a.title.id}/${a.id}`) ? `<span class="ach-reward">Gives the ${esc(REWARDS.get(`${a.title.id}/${a.id}`))}</span>` : ''}++ <span class="ach-count">${a.unlockedBy > 0 ? `Earned by ${plural(a.unlockedBy, 'player', 'players')}` : 'Nobody has earned it yet'}</span>++ </li>`,++ )++ .join('')}</ul>++ <p class="home-more">${d.titles++ .map(++ (t) =>++ `<a class="card-link" href="${PLAY_SITE}/t/${encodeURIComponent(t.id)}">${esc(t.name)} on OpenVibe.Play →</a>`,++ )++ .join(' ')}</p>++</section>`++}++++function worldCard(w: CreatorWorld, now: number): string {++ const meta = [++ `by ${w.ownerName}`,++ w.plays > 0 ? plural(w.plays, 'visit', 'visits') : 'no visits yet',++ ]++ if (w.publishedAt) meta.push(`published ${ago(w.publishedAt, now)}`)++ return `<li class="world-card">++ <h3><a href="/worlds/${encodeURIComponent(w.id)}">${esc(w.title)}</a></h3>++ ${w.summary ? `<p>${esc(w.summary)}</p>` : ''}++ <span class="world-meta">${esc(meta.join(' · '))}</span>++</li>`++}++++function worldsSection(d: HomeData, now: number): string {++ if (!d.worlds.length) return ''++ const newest = d.worlds.slice(0, 6)++ const shown = new Set(newest.map((w) => w.id))++ const popular = [...d.worlds]++ .filter((w) => w.plays > 0 && !shown.has(w.id))++ .sort((a, b) => b.plays - a.plays)++ .slice(0, 6)++ return `<section class="home-sec" aria-labelledby="home-worlds">++ <h2 id="home-worlds">Creator worlds</h2>++ <p class="home-lede">Worlds players built in the map editor and published. Each one opens in your browser.</p>++ <h3 class="home-sub">Newest</h3>++ <ul class="world-grid">${newest.map((w) => worldCard(w, now)).join('')}</ul>++ ${popular.length ? `<h3 class="home-sub">Most visited</h3><ul class="world-grid">${popular.map((w) => worldCard(w, now)).join('')}</ul>` : ''}++ <p class="home-more"><a class="card-link" href="/worlds">Every creator world →</a></p>++</section>`++}++++function steamRow(g: SteamGame): string {++ const price =++ g.discount_percent > 0 && g.original_price != null++ ? `<span class="off">−${g.discount_percent}%</span> ${esc(money(g.final_price, g.currency))} <s>${esc(money(g.original_price, g.currency))}</s>`++ : esc(money(g.final_price, g.currency))++ return `<li><a href="https://store.steampowered.com/app/${g.appid}/" rel="noopener" target="_blank">${++ g.header_image++ ? `<img src="${esc(g.header_image)}" alt="" width="120" height="56" loading="lazy" decoding="async" referrerpolicy="no-referrer">`++ : '<span class="steam-art" aria-hidden="true"></span>'++ }<span class="steam-text"><b>${esc(g.name)}</b>${price ? `<span class="steam-price">${price}</span>` : ''}</span></a></li>`++}++++function steamSection(d: HomeData, now: number): string {++ const s = d.steam++ if (!s) return ''++ const deals = [...s.sale].sort((a, b) => b.discount_percent - a.discount_percent)++ const cols = [++ { id: 'home-steam-top', title: 'Top sellers', games: s.top },++ { id: 'home-steam-deals', title: 'Biggest deals', games: deals },++ { id: 'home-steam-new', title: 'New releases', games: s.new },++ ].filter((c) => c.games.length)++ if (!cols.length) return ''++ return `<section class="home-sec" aria-labelledby="home-steam">++ <h2 id="home-steam">On Steam right now</h2>++ <p class="home-lede">What sells, what's discounted and what just came out on Steam, with Steam's prices in US dollars${++ s.observed_at ? `, checked ${esc(ago(s.observed_at, now))}` : ''++ }. Each game opens its store page.</p>++ <div class="steam-cols">${cols++ .map(++ (c) => `<div class="steam-col">++ <h3 id="${c.id}">${c.title}</h3>++ <ol class="steam-list" aria-labelledby="${c.id}">${c.games.slice(0, 5).map(steamRow).join('')}</ol>++ </div>`,++ )++ .join('')}</div>++ <p class="home-more"><a class="card-link" href="/steam">All of Steam's lists →</a></p>++</section>`++}++++const HOW: readonly { title: string; text: string }[] = [++ {++ title: 'Open a game',++ text: 'Pick one above and press play. It loads in the tab, on a computer or a phone, with nothing to install.',++ },++ {++ title: 'Play as a guest',++ text: 'You get one character tied to this browser right away. No form, no email.',++ },++ {++ title: 'Sign in to keep it',++ text: 'An OpenVibe account adds three character slots, saves progress across devices and puts your name on the boards.',++ },++ {++ title: 'Earn and build',++ text: 'Achievements give items worn across OpenVibe, and the map editor lets you publish a world of your own.',++ },++]++++function howSection(): string {++ return `<section class="home-sec" aria-labelledby="home-how">++ <h2 id="home-how">How it works</h2>++ <ol class="how-steps">${HOW.map((s) => `<li><b>${esc(s.title)}</b><span>${esc(s.text)}</span></li>`).join('')}</ol>++</section>`++}++++function faqSection(): string {++ return `<section class="home-sec" aria-labelledby="home-faq">++ <h2 id="home-faq">Questions</h2>++ <div class="faq">${HOME_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</div>++</section>`++}++++/** The home page's JSON-LD beyond the site and game nodes discovery.ts already writes: the games and the FAQ. */++export function homeExtraJsonLd(site: string, playSite: string): unknown[] {++ const games = [++ {++ name: 'Scraplandia',++ url: `${playSite}/`,++ description: 'Multiplayer survival-building in the browser.',++ },++ {++ name: 'Salvage Rush',++ url: `${site}/play?game=salvage-rush`,++ description: '90-second scrap-collecting rounds with a leaderboard.',++ },++ {++ name: 'Vibe Heroes',++ url: `${site}/play/vibe-heroes`,++ description: 'An idle hero collector made for the phone.',++ },++ {++ name: 'Creator worlds',++ url: `${site}/worlds`,++ description: 'Worlds players built in the map editor and published.',++ },++ {++ name: 'On Steam right now',++ url: `${site}/steam`,++ description: "Steam's top sellers, deals and new releases.",++ },++ ]++ return [++ {++ '@context': 'https://schema.org',++ '@type': 'ItemList',++ name: 'Games on OpenVibe.Games',++ url: `${site}/`,++ numberOfItems: games.length,++ itemListElement: games.map((g, i) => ({++ '@type': 'ListItem',++ position: i + 1,++ name: g.name,++ url: g.url,++ description: g.description,++ })),++ },++ {++ '@context': 'https://schema.org',++ '@type': 'FAQPage',++ mainEntity: HOME_FAQ.map((f) => ({++ '@type': 'Question',++ name: f.q,++ acceptedAnswer: { '@type': 'Answer', text: f.a },++ })),++ },++ ]++}++++export function renderHome(++ d: HomeData,++ now: number,++ site = 'https://openvibe.games',++ playSite = 'https://play.openvibe.games',++): HomeParts {++ return {++ stats: renderStats(d),++ sections: [++ boardsSection(d),++ achievementsSection(d),++ worldsSection(d, now),++ steamSection(d, now),++ howSection(),++ faqSection(),++ ]++ .filter(Boolean)++ .join('\n'),++ jsonLd: homeExtraJsonLd(site, playSite),++ }++}++++const EMPTY: HomeData = {++ online: { scraplandia: 0, salvageRush: 0, worlds: 0 },++ titles: [],++ boards: { haulWeek: [], haulAll: [], rushWeek: [], rushAll: [] },++ heroes: [],++ worlds: [],++ steam: null,++}++++/** Reads every source at once; a source that fails or is slow leaves its section out, never the page. */++export async function gatherHome(src: HomeSources, log?: Pick<Logger, 'warn'>): Promise<HomeData> {++ const soft = <T>(name: string, p: Promise<T>, fallback: T): Promise<T> =>++ p.catch((err: unknown) => {++ log?.warn('home section unavailable', {++ section: name,++ error: err instanceof Error ? err.message : String(err),++ })++ return fallback++ })++ let online = EMPTY.online++ try {++ online = src.online()++ } catch {++ /* counts are optional */++ }++ const [titles, haulWeek, haulAll, rushWeek, rushAll, heroes, worlds, steam] = await Promise.all([++ soft('titles', src.titles(), [] as PlayTitleSummary[]),++ soft('haul-week', src.board('scraplandia', 'week'), [] as BoardRow[]),++ soft('haul-all', src.board('scraplandia', 'all'), [] as BoardRow[]),++ soft('rush-week', src.board('salvage-rush', 'week'), [] as BoardRow[]),++ soft('rush-all', src.board('salvage-rush', 'all'), [] as BoardRow[]),++ soft('heroes', src.heroes(), [] as HomeData['heroes']),++ soft('worlds', src.worlds(), [] as CreatorWorld[]),++ soft('steam', src.steam(), null as SteamLists | null),++ ])++ return { online, titles, boards: { haulWeek, haulAll, rushWeek, rushAll }, heroes, worlds, steam }++}++++/** Play's public titles read, trimmed to what the home shows. */++export async function readPlayTitles(++ playUrl: string,++ doFetch: typeof fetch = fetch,++): Promise<PlayTitleSummary[]> {++ const res = await doFetch(`${playUrl}/api/v1/titles`, {++ headers: { accept: 'application/json' },++ signal: AbortSignal.timeout(5_000),++ })++ if (!res.ok) throw new Error(`play ${res.status}`)++ const body = (await res.json()) as {++ titles?: {++ id?: unknown++ name?: unknown++ players?: unknown++ achievements?: {++ id?: unknown++ name?: unknown++ description?: unknown++ unlocked_by?: unknown++ }[]++ }[]++ }++ return (body.titles ?? [])++ .filter((t) => typeof t.id === 'string' && typeof t.name === 'string')++ .map((t) => ({++ id: String(t.id),++ name: String(t.name),++ players: Math.max(0, Number(t.players) || 0),++ achievements: (t.achievements ?? [])++ .filter((a) => typeof a.id === 'string' && typeof a.name === 'string')++ .map((a) => ({++ id: String(a.id),++ name: String(a.name),++ description: String(a.description ?? ''),++ unlockedBy: Math.max(0, Number(a.unlocked_by) || 0),++ })),++ }))++}++++/** The cached builder httpServer.ts asks for on every home request. */++export function createPortalHome(++ src: HomeSources,++ opts: { site?: string; playSite?: string; log?: Pick<Logger, 'warn'>; now?: () => number } = {},++): () => Promise<HomeParts> {++ const now = opts.now ?? Date.now++ let cached: { at: number; parts: HomeParts } | null = null++ let building: Promise<HomeParts> | null = null++ // A source that is down is said once every ten minutes, not on every rebuild.++ const warned = new Map<string, number>()++ const log: Pick<Logger, 'warn'> | undefined = opts.log && {++ warn: (msg, meta) => {++ const key = String((meta as { section?: unknown } | undefined)?.section ?? msg)++ if (now() - (warned.get(key) ?? -Infinity) < 600_000) return++ warned.set(key, now())++ opts.log!.warn(msg, meta)++ },++ }++ const build = (): Promise<HomeParts> => {++ building ??= gatherHome(src, log)++ .then((d) => {++ const parts = renderHome(d, now(), opts.site, opts.playSite)++ cached = { at: now(), parts }++ return parts++ })++ .finally(() => {++ building = null++ })++ return building++ }++ return async () => {++ if (cached && now() - cached.at < CACHE_MS) return cached.parts++ const next = build()++ if (cached) {++ next.catch(() => undefined)++ return cached.parts++ }++ // Nothing built yet and the sources are slow: the sections that need no data (how it works, the questions).++ const bare = (): HomeParts => renderHome(EMPTY, now(), opts.site, opts.playSite)++ return Promise.race([++ next,++ new Promise<HomeParts>((resolve) => setTimeout(() => resolve(bare()), FIRST_WAIT_MS).unref()),++ ]).catch(bare)++ }++}nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/server/src/net/portalHome.test.ts'++// covers: 'apps/server/src/net'++// Generated by node test/run.js --index; do not edit.
@@ -6,10 +6,10 @@
@@ -6,10 +6,10 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/app-icons/games-192.png" />
<link rel="apple-touch-icon" href="/app-icons/games-192.png" />
- <title>OpenVibe.Games — browser games on the OpenVibe network</title>
+ <title>Free browser games, no download · OpenVibe.Games</title>
<meta
<meta
name="description"
name="description"
- content="Play Scraplandia, Salvage Rush and Vibe Heroes in your browser: survival-building together, 90-second scrap rounds and an idle hero collector. No download."
+ content="Play Scraplandia, Salvage Rush and Vibe Heroes free in your browser, join worlds players built, and see Steam's top sellers, deals and new releases."
/>
/>
<!-- The OpenVibe Frame: the user's theme before first paint, then navbar + footer. -->
<!-- The OpenVibe Frame: the user's theme before first paint, then navbar + footer. -->
<!-- Deferred: a slow or unreachable openvibe.network never holds the first paint (the inline colours above cover it). -->
<!-- Deferred: a slow or unreachable openvibe.network never holds the first paint (the inline colours above cover it). -->
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
<meta name="robots" content="index, follow" />
<meta name="robots" content="index, follow" />
<meta property="og:type" content="website" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="OpenVibe.Games" />
<meta property="og:site_name" content="OpenVibe.Games" />
- <meta property="og:title" content="OpenVibe.Games — browser games on the OpenVibe network" />
+ <meta property="og:title" content="Free browser games, no download · OpenVibe.Games" />
<meta
<meta
property="og:description"
property="og:description"
content="Play Scraplandia, Salvage Rush or Vibe Heroes in your browser, no download: survival-building in Scrap City, timed scrap-collecting rounds, or an idle hero collector for your phone. One OpenVibe account across the whole network."
content="Play Scraplandia, Salvage Rush or Vibe Heroes in your browser, no download: survival-building in Scrap City, timed scrap-collecting rounds, or an idle hero collector for your phone. One OpenVibe account across the whole network."
@@ -30,10 +30,10 @@
@@ -30,10 +30,10 @@
<meta property="og:image:width" content="1200" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:card" content="summary_large_image" />
- <meta name="twitter:title" content="OpenVibe.Games — browser games on the OpenVibe network" />
+ <meta name="twitter:title" content="Free browser games, no download · OpenVibe.Games" />
<meta
<meta
name="twitter:description"
name="twitter:description"
- content="Play Scraplandia — multiplayer survival-building in your browser, no download."
+ content="Play Scraplandia, Salvage Rush and Vibe Heroes free in your browser, and see what's selling and on sale on Steam."
/>
/>
<meta name="twitter:image" content="https://openvibe.games/og-image.png" />
<meta name="twitter:image" content="https://openvibe.games/og-image.png" />
<!-- JSON-LD (WebSite + the game as the site's primary type) is injected as this page is served,
<!-- JSON-LD (WebSite + the game as the site's primary type) is injected as this page is served,
@@ -99,7 +99,7 @@
@@ -99,7 +99,7 @@
padding: clamp(48px, 9vh, 96px) 20px 30px;
padding: clamp(48px, 9vh, 96px) 20px 30px;
}
}
.hero-title {
.hero-title {
- font-size: clamp(42px, 8vw, 88px);
+ font-size: clamp(42px, 7vw, 76px);
font-weight: 900;
font-weight: 900;
letter-spacing: 0.01em;
letter-spacing: 0.01em;
line-height: 1;
line-height: 1;
@@ -122,6 +122,65 @@
@@ -122,6 +122,65 @@
color: transparent;
color: transparent;
filter: drop-shadow(0 6px 34px rgba(var(--accent-rgb, 59, 130, 246), 0.35));
filter: drop-shadow(0 6px 34px rgba(var(--accent-rgb, 59, 130, 246), 0.35));
}
}
+ .hero-h1 {
+ margin: 18px auto 0;
+ max-width: 22ch;
+ font-size: clamp(24px, 3.6vw, 38px);
+ font-weight: 850;
+ line-height: 1.15;
+ text-wrap: balance;
+ animation: rise 0.9s 0.06s cubic-bezier(0.2, 0.8, 0.2, 1) both;
+ }
+ .hero-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ align-items: center;
+ gap: 12px 24px;
+ margin-top: 26px;
+ }
+ .hero-actions .play-cta {
+ margin-top: 0;
+ align-self: center;
+ }
+ .hero-link {
+ color: var(--accent-light);
+ font-weight: 700;
+ text-decoration: none;
+ border-bottom: 1px solid rgba(var(--accent-rgb, 59, 130, 246), 0.4);
+ }
+ .hero-link:hover {
+ border-bottom-color: var(--accent-light);
+ }
+ /* The figures under the hero (server-rendered, net/portalHome.ts): real counts only. */
+ .home-stats {
+ list-style: none;
+ padding: 0;
+ margin: 30px auto 0;
+ max-width: 980px;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(128px, 100%), 1fr));
+ gap: 10px;
+ }
+ .home-stats li {
+ padding: 14px 12px;
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ background: var(--bg-card);
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ }
+ .home-stats b {
+ font-size: 26px;
+ font-weight: 900;
+ color: var(--text);
+ }
+ .home-stats span {
+ font-size: 13px;
+ color: var(--text-dim);
+ overflow-wrap: anywhere;
+ }
.hero-sub {
.hero-sub {
margin: 18px auto 0;
margin: 18px auto 0;
max-width: 640px;
max-width: 640px;
@@ -225,6 +284,7 @@
@@ -225,6 +284,7 @@
gap: 12px;
gap: 12px;
}
}
.badge {
.badge {
+ text-transform: uppercase;
align-self: flex-start;
align-self: flex-start;
font-size: 11px;
font-size: 11px;
font-weight: 800;
font-weight: 800;
@@ -242,8 +302,13 @@
@@ -242,8 +302,13 @@
letter-spacing: 0.02em;
letter-spacing: 0.02em;
}
}
.game-body h3 {
.game-body h3 {
- font-size: 20px;
- font-weight: 800;
+ font-size: 22px;
+ font-weight: 850;
+ letter-spacing: 0.01em;
+ }
+ .game-card.featured h3 {
+ font-size: clamp(26px, 3.4vw, 38px);
+ font-weight: 900;
}
}
.game-body p {
.game-body p {
color: var(--text-dim);
color: var(--text-dim);
@@ -329,6 +394,288 @@
@@ -329,6 +394,288 @@
box-shadow: 0 8px 48px rgba(var(--accent-rgb, 59, 130, 246), 0.7);
box-shadow: 0 8px 48px rgba(var(--accent-rgb, 59, 130, 246), 0.7);
}
}
}
}
+ /* ── live sections (net/portalHome.ts renders them on the server) ── */
+ .home-sec {
+ max-width: 1100px;
+ margin: 0 auto 64px;
+ padding: 0 20px;
+ }
+ .home-sec + .games {
+ margin-top: -40px;
+ }
+ .home-sec h2 {
+ font-size: clamp(24px, 3vw, 32px);
+ font-weight: 900;
+ margin-bottom: 8px;
+ }
+ .home-lede {
+ color: var(--text-dim);
+ line-height: 1.6;
+ max-width: 70ch;
+ margin-bottom: 20px;
+ }
+ .home-sub {
+ font-size: 17px;
+ font-weight: 800;
+ margin: 22px 0 12px;
+ color: var(--text-dim);
+ }
+ .home-more {
+ margin-top: 18px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px 24px;
+ }
+ .board-grid,
+ .ach-grid,
+ .world-grid,
+ .steam-cols {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr));
+ gap: 16px;
+ list-style: none;
+ padding: 0;
+ }
+ .board-card,
+ .world-card,
+ .steam-col,
+ .ach {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ padding: 18px;
+ min-width: 0;
+ }
+ .board-card h3 {
+ font-size: 18px;
+ font-weight: 850;
+ margin-bottom: 12px;
+ }
+ .board-card h3 small {
+ display: block;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-faint);
+ margin-top: 2px;
+ }
+ .board-rows {
+ list-style: none;
+ padding: 0;
+ margin-bottom: 14px;
+ }
+ .board-rows li {
+ display: grid;
+ grid-template-columns: 28px 1fr auto;
+ align-items: center;
+ gap: 10px;
+ padding: 7px 0;
+ border-top: 1px solid var(--border);
+ }
+ .board-rows li:first-child {
+ border-top: 0;
+ }
+ .board-rows .rank {
+ width: 26px;
+ height: 26px;
+ border-radius: 8px;
+ display: grid;
+ place-items: center;
+ font-weight: 900;
+ font-size: 13px;
+ background: rgba(var(--accent-rgb, 59, 130, 246), 0.16);
+ color: var(--text);
+ }
+ .board-rows .who {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ font-weight: 700;
+ }
+ .board-rows .score {
+ color: var(--text-dim);
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+ }
+ .ach-grid {
+ grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr));
+ gap: 12px;
+ }
+ .ach {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 14px 16px;
+ }
+ .ach.earned {
+ border-color: rgba(46, 204, 113, 0.45);
+ }
+ .ach-game {
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--secondary);
+ }
+ .ach b {
+ font-size: 16px;
+ }
+ .ach-how,
+ .ach-reward {
+ font-size: 14px;
+ color: var(--text-dim);
+ line-height: 1.45;
+ }
+ .ach-reward {
+ color: var(--accent-light);
+ }
+ .ach-count {
+ margin-top: auto;
+ padding-top: 6px;
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--text-faint);
+ }
+ .ach.earned .ach-count {
+ color: var(--success, #2ecc71);
+ }
+ .world-card h3 {
+ font-size: 17px;
+ overflow-wrap: anywhere;
+ }
+ .world-card h3 a {
+ color: inherit;
+ text-decoration: none;
+ }
+ .world-card h3 a:hover {
+ color: var(--accent-light);
+ }
+ .world-card p {
+ margin: 6px 0;
+ color: var(--text-dim);
+ font-size: 14px;
+ line-height: 1.5;
+ overflow-wrap: anywhere;
+ }
+ .world-meta {
+ font-size: 13px;
+ color: var(--text-faint);
+ }
+ .steam-col h3 {
+ font-size: 17px;
+ font-weight: 850;
+ margin-bottom: 10px;
+ }
+ .steam-list {
+ list-style: none;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+ .steam-list a {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ color: inherit;
+ text-decoration: none;
+ border-radius: 10px;
+ padding: 4px;
+ min-width: 0;
+ }
+ .steam-list a:hover,
+ .steam-list a:focus-visible {
+ background: rgba(var(--accent-rgb, 59, 130, 246), 0.1);
+ }
+ .steam-list img,
+ .steam-art {
+ width: 104px;
+ height: 49px;
+ flex: none;
+ border-radius: 6px;
+ object-fit: cover;
+ background: var(--bg-raised);
+ }
+ .steam-text {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+ }
+ .steam-text b {
+ font-size: 14px;
+ line-height: 1.3;
+ overflow-wrap: anywhere;
+ }
+ .steam-price {
+ font-size: 13px;
+ color: var(--text-dim);
+ }
+ .steam-price s {
+ color: var(--text-faint);
+ }
+ .steam-price .off {
+ display: inline-block;
+ padding: 0 6px;
+ border-radius: 6px;
+ background: #15803d;
+ color: #fff;
+ font-weight: 800;
+ }
+ .how-steps {
+ list-style: none;
+ padding: 0;
+ counter-reset: how;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(min(230px, 100%), 1fr));
+ gap: 14px;
+ }
+ .how-steps li {
+ counter-increment: how;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 18px;
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ background: var(--bg-card);
+ }
+ .how-steps li::before {
+ content: counter(how);
+ width: 30px;
+ height: 30px;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ font-weight: 900;
+ background: var(--accent-strong, #2563eb);
+ color: var(--on-accent-strong, #fff);
+ }
+ .how-steps span {
+ color: var(--text-dim);
+ font-size: 14px;
+ line-height: 1.55;
+ }
+ .faq {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ max-width: 820px;
+ }
+ .faq details {
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ background: var(--bg-card);
+ padding: 0 18px;
+ }
+ .faq summary {
+ cursor: pointer;
+ padding: 15px 0;
+ font-weight: 750;
+ }
+ .faq details p {
+ padding: 0 0 16px;
+ color: var(--text-dim);
+ line-height: 1.6;
+ }
/* ── accounts ────────────────────────────────────────────────── */
/* ── accounts ────────────────────────────────────────────────── */
.account {
.account {
max-width: 820px;
max-width: 820px;
@@ -448,15 +795,29 @@
@@ -448,15 +795,29 @@
</nav>
</nav>
<header>
<header>
- <h1 class="hero-title">Open<b>Vibe.Games</b></h1>
+ <p class="hero-title">Open<b>Vibe.Games</b></p>
+ <h1 class="hero-h1">Free games that start in a browser tab</h1>
<p class="hero-sub">
<p class="hero-sub">
- Multiplayer games that start in a browser tab. No installs, no launchers. Jump in as a
- guest, or sign in with your OpenVibe account to keep your character, your base and your name
- across the whole network.
+ Build and survive with other players, race for scrap in 90-second rounds, grow a team of
+ heroes on your phone or join a world another player built. Nothing to install: play as a
+ guest, or sign in to keep your character across OpenVibe.
</p>
</p>
+ <div class="hero-actions">
+ <a class="play-cta" href="https://play.openvibe.games" data-dev-href="/play"
+ >▶ Play Scraplandia</a
+ >
+ <a class="hero-link" href="#play-now">See every game</a>
+ </div>
+ <!--ov:home-stats-->
</header>
</header>
- <section class="games">
+ <section class="home-sec" aria-labelledby="play-now">
+ <h2 id="play-now">Play now</h2>
+ <p class="home-lede">
+ Every game here runs in the browser, on a computer or a phone. Press play and you are in.
+ </p>
+ </section>
+ <section class="games" aria-label="Games">
<!-- ── Featured: Scraplandia ── -->
<!-- ── Featured: Scraplandia ── -->
<div class="game-card featured">
<div class="game-card featured">
<div
<div
@@ -559,8 +920,8 @@
@@ -559,8 +920,8 @@
</svg>
</svg>
</div>
</div>
<div class="game-body">
<div class="game-body">
- <span class="badge live">● LIVE NOW</span>
- <h2>SCRAPLANDIA</h2>
+ <span class="badge live">● Live now</span>
+ <h3 class="game-name">Scraplandia</h3>
<p>
<p>
Scrappy multiplayer survival-building in and around <b>Scrap City</b>. Physgun anything,
Scrappy multiplayer survival-building in and around <b>Scrap City</b>. Physgun anything,
weld it to anything else, cook at a burn barrel, chase supply drops in the wilds — and
weld it to anything else, cook at a burn barrel, chase supply drops in the wilds — and
@@ -574,7 +935,7 @@
@@ -574,7 +935,7 @@
</div>
</div>
<div class="cta-row">
<div class="cta-row">
<a class="play-cta" href="https://play.openvibe.games" data-dev-href="/play"
<a class="play-cta" href="https://play.openvibe.games" data-dev-href="/play"
- >▶ PLAY — NO DOWNLOAD</a
+ >▶ Play now, no download</a
>
>
<a class="card-link" href="/leaderboard.html?game=scraplandia">Haul leaderboard →</a>
<a class="card-link" href="/leaderboard.html?game=scraplandia">Haul leaderboard →</a>
</div>
</div>
@@ -665,8 +1026,8 @@
@@ -665,8 +1026,8 @@
</svg>
</svg>
</div>
</div>
<div class="game-body">
<div class="game-body">
- <span class="badge live">● LIVE NOW</span>
- <h3>SALVAGE RUSH</h3>
+ <span class="badge live">● Live now</span>
+ <h3>Salvage Rush</h3>
<p>
<p>
Collect scrap around a compact arena in 90-second on-foot rounds. The server scores each
Collect scrap around a compact arena in 90-second on-foot rounds. The server scores each
pickup; rounds have a public leaderboard and three achievements that give items worn
pickup; rounds have a public leaderboard and three achievements that give items worn
@@ -676,7 +1037,7 @@
@@ -676,7 +1037,7 @@
<span>timed rounds</span><span>on-foot</span><span>leaderboard</span>
<span>timed rounds</span><span>on-foot</span><span>leaderboard</span>
</div>
</div>
<div class="cta-row">
<div class="cta-row">
- <a class="play-cta" href="/play?game=salvage-rush">▶ PLAY A ROUND</a>
+ <a class="play-cta" href="/play?game=salvage-rush">▶ Play a round</a>
<a class="card-link" href="/leaderboard.html?game=salvage-rush">Leaderboard →</a>
<a class="card-link" href="/leaderboard.html?game=salvage-rush">Leaderboard →</a>
</div>
</div>
</div>
</div>
@@ -698,8 +1059,8 @@
@@ -698,8 +1059,8 @@
/>
/>
</div>
</div>
<div class="game-body">
<div class="game-body">
- <span class="badge live">● NEW</span>
- <h3>VIBE HEROES</h3>
+ <span class="badge live">● New</span>
+ <h3>Vibe Heroes</h3>
<p>
<p>
An idle hero collector made for your phone. Your team battles on its own: fuse heroes
An idle hero collector made for your phone. Your team battles on its own: fuse heroes
from shards, star them up and push through 100 stages, even while you are away. Summons
from shards, star them up and push through 100 stages, even while you are away. Summons
@@ -709,7 +1070,7 @@
@@ -709,7 +1070,7 @@
<span>idle</span><span>hero collector</span><span>phone-first</span>
<span>idle</span><span>hero collector</span><span>phone-first</span>
</div>
</div>
<div class="cta-row">
<div class="cta-row">
- <a class="play-cta" href="/play/vibe-heroes">▶ PLAY NOW</a>
+ <a class="play-cta" href="/play/vibe-heroes">▶ Play now</a>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -777,8 +1138,8 @@
@@ -777,8 +1138,8 @@
</svg>
</svg>
</div>
</div>
<div class="game-body">
<div class="game-body">
- <span class="badge live">● NEW</span>
- <h3>CREATOR WORLDS</h3>
+ <span class="badge live">● New</span>
+ <h3>Creator worlds</h3>
<p>
<p>
Build your own world in the map editor: terrain, buildings, lights, zones and props.
Build your own world in the map editor: terrain, buildings, lights, zones and props.
Publish it and anyone can join from their browser, or browse what other players made.
Publish it and anyone can join from their browser, or browse what other players made.
@@ -787,7 +1148,7 @@
@@ -787,7 +1148,7 @@
<span>map editor</span><span>publish</span><span>multiplayer</span>
<span>map editor</span><span>publish</span><span>multiplayer</span>
</div>
</div>
<div class="cta-row">
<div class="cta-row">
- <a class="play-cta" href="/worlds">▶ BROWSE & BUILD</a>
+ <a class="play-cta" href="/worlds">▶ Browse and build</a>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -832,8 +1193,8 @@
@@ -832,8 +1193,8 @@
</svg>
</svg>
</div>
</div>
<div class="game-body">
<div class="game-body">
- <span class="badge live">● NEW</span>
- <h3>ON STEAM RIGHT NOW</h3>
+ <span class="badge live">● New</span>
+ <h3>On Steam right now</h3>
<p>
<p>
What sells on Steam, what's on sale and what just came out, with Steam's own prices, refreshed through the
What sells on Steam, what's on sale and what just came out, with Steam's own prices, refreshed through the
day. Every game opens its store page.
day. Every game opens its store page.
@@ -842,12 +1203,14 @@
@@ -842,12 +1203,14 @@
<span>top sellers</span><span>deals</span><span>new releases</span>
<span>top sellers</span><span>deals</span><span>new releases</span>
</div>
</div>
<div class="cta-row">
<div class="cta-row">
- <a class="play-cta" href="/steam">▶ SEE THE LISTS</a>
+ <a class="play-cta" href="/steam">▶ See the lists</a>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</section>
+ <!--ov:home-sections-->
+
<section class="account">
<section class="account">
<div class="account-inner">
<div class="account-inner">
<h2>One character as a guest. <b>Three</b> with an OpenVibe account.</h2>
<h2>One character as a guest. <b>Three</b> with an OpenVibe account.</h2>
@@ -115,6 +115,11 @@ export class PlaceRouter {
@@ -115,6 +115,11 @@ export class PlaceRouter {
return this.allHubs().flatMap((hub) => hub.activeRooms)
return this.allHubs().flatMap((hub) => hub.activeRooms)
}
}
+ /** Players connected to each built-in place now (creator worlds count theirs in worldHost.online()). */
+ onlineByPlace(): Map<string, number> {
+ return new Map([...this.hubs].map(([id, hub]) => [id, hub.onlineCount]))
+ }
+
get onlineCount(): number {
get onlineCount(): number {
return this.allHubs().reduce((total, hub) => total + hub.onlineCount, 0)
return this.allHubs().reduce((total, hub) => total + hub.onlineCount, 0)
}
}
@@ -82,7 +82,8 @@ import type { RoundResult } from './game/salvageRush.js'
@@ -82,7 +82,8 @@ import type { RoundResult } from './game/salvageRush.js'
import { handleGameRead } from './net/gameRoutes.js'
import { handleGameRead } from './net/gameRoutes.js'
import { createPlayBoards } from './platform/playBoards.js'
import { createPlayBoards } from './platform/playBoards.js'
import { createCasualRoutes } from './casual/routes.js'
import { createCasualRoutes } from './casual/routes.js'
-import { pruneCasualActions } from './casual/store.js'
+import { casualLeaderboard, pruneCasualActions } from './casual/store.js'
+import { createPortalHome, readPlayTitles } from './net/portalHome.js'
import { CASUAL_GAMES, validatePack } from '@openvibe/casual'
import { CASUAL_GAMES, validatePack } from '@openvibe/casual'
import { CreatorWorldHost } from './worlds/worldHost.js'
import { CreatorWorldHost } from './worlds/worldHost.js'
import { createWorldHubStarter } from './worlds/startWorldHub.js'
import { createWorldHubStarter } from './worlds/startWorldHub.js'
@@ -819,6 +820,33 @@ async function main(): Promise<void> {
@@ -819,6 +820,33 @@ async function main(): Promise<void> {
events: { db, sink, siteUrl: gamesSiteUrl },
events: { db, sink, siteUrl: gamesSiteUrl },
...(worldSearch ? { search: worldSearch } : {}),
...(worldSearch ? { search: worldSearch } : {}),
})
})
+ // The home page's live sections (net/portalHome.ts): players online, Play's boards and achievements, Vibe Heroes'
+ // furthest teams, creator worlds and Steam's lists, built at most once a minute.
+ const playTitlesUrl = config.platform.playUrl
+ const homeSections = createPortalHome(
+ {
+ online: () => {
+ const byPlace = router.onlineByPlace()
+ let worlds = 0
+ for (const count of worldHost.online().values()) worlds += count
+ return {
+ scraplandia: byPlace.get('scraplandia') ?? 0,
+ salvageRush: byPlace.get('salvage-rush') ?? 0,
+ worlds,
+ }
+ },
+ titles: () => (playTitlesUrl ? readPlayTitles(playTitlesUrl) : Promise.resolve([])),
+ board: async (game, period) => (await playBoards(game, period, 1, '127.0.0.1')).entries.slice(0, 5),
+ heroes: () => casualLeaderboard(db, 'vibe-heroes', 5),
+ worlds: () => creatorWorlds.listPublished(60, 0),
+ steam: () => steamCatalog.lists(),
+ },
+ {
+ site: gamesSiteUrl,
+ playSite: config.oauth?.playUrl ?? 'https://play.openvibe.games',
+ log: log.child({ system: 'home' }),
+ },
+ )
// The casual games' JSON API (plan T22).
// The casual games' JSON API (plan T22).
const serveCasual = createCasualRoutes({
const serveCasual = createCasualRoutes({
db,
db,
@@ -1053,6 +1081,7 @@ async function main(): Promise<void> {
@@ -1053,6 +1081,7 @@ async function main(): Promise<void> {
onAssetStored: (asset) => mirror?.enqueue(asset),
onAssetStored: (asset) => mirror?.enqueue(asset),
limits: actorLimits,
limits: actorLimits,
clientAddress,
clientAddress,
+ homeSections,
},
},
ticketStore,
ticketStore,
createGamesIndexNow(
createGamesIndexNow(
@@ -209,6 +209,13 @@ function publicPageDocs(): PublicPageDoc[] {
@@ -209,6 +209,13 @@ function publicPageDocs(): PublicPageDoc[] {
text: 'Creator worlds: players shape terrain and place buildings, lights, zones, resource nodes and props in the OpenVibe.Games map editor, then publish the world for anyone to join from the browser. A world is a sandbox: what players gather or build inside it stays in that world.',
text: 'Creator worlds: players shape terrain and place buildings, lights, zones, resource nodes and props in the OpenVibe.Games map editor, then publish the world for anyone to join from the browser. A world is a sandbox: what players gather or build inside it stays in that world.',
priority: 0.7,
priority: 0.7,
},
},
+ {
+ title: 'On Steam right now',
+ url: `${SITE}/steam`,
+ note: "Steam's top sellers, deals and new releases with Steam's prices, each linked to its store page",
+ text: "Steam's featured lists as the store shows them: top sellers in the store's order, the deals Steam features and the new releases, with Steam's own US dollar prices and a link to every game's store page. Read from the Steam store's public storefront lists every few hours through OpenVibe.Sources.",
+ priority: 0.6,
+ },
{
{
title: 'What shipped',
title: 'What shipped',
url: `${SITE}/updates`,
url: `${SITE}/updates`,
@@ -314,6 +321,11 @@ export function homeJsonLd(): unknown[] {
@@ -314,6 +321,11 @@ export function homeJsonLd(): unknown[] {
]
]
}
}
+/** Any JSON-LD nodes as `<script>` tags, through the shared kit's escaping (the home's live parts use it). */
+export function jsonLdTagsOf(nodes: unknown[]): string {
+ return nodes.map((node) => seo.jsonLdTag(node)).join('\n')
+}
+
/** The home page's JSON-LD as `<script>` tags, ready to inject before `</head>`. */
/** The home page's JSON-LD as `<script>` tags, ready to inject before `</head>`. */
export function homeJsonLdTags(): string {
export function homeJsonLdTags(): string {
return homeJsonLd()
return homeJsonLd()
@@ -29,7 +29,8 @@ import { MAX_ASSET_BYTES, isContentAddressed, storeAsset } from './mapAssetStore
@@ -29,7 +29,8 @@ import { MAX_ASSET_BYTES, isContentAddressed, storeAsset } from './mapAssetStore
import type { GamesActorLimits } from './actorLimits.js'
import type { GamesActorLimits } from './actorLimits.js'
import { resolveClientAddress } from './clientAddress.js'
import { resolveClientAddress } from './clientAddress.js'
import { sendNotFound, type NotFoundLink } from './notFound.js'
import { sendNotFound, type NotFoundLink } from './notFound.js'
-import { discoveryHandler, homeJsonLdTags } from './discovery.js'
+import { discoveryHandler, homeJsonLdTags, jsonLdTagsOf } from './discovery.js'
+import type { HomeParts } from './portalHome.js'
import { legalHandler } from './legalPages.js'
import { legalHandler } from './legalPages.js'
import { updatesHandler } from './updatesPage.js'
import { updatesHandler } from './updatesPage.js'
import { indexnowHandler, type SharedIndexNow } from './indexnow.js'
import { indexnowHandler, type SharedIndexNow } from './indexnow.js'
@@ -49,6 +50,16 @@ export function portalHtml(filePath: string): string {
@@ -49,6 +50,16 @@ export function portalHtml(filePath: string): string {
portalMemo = { path: filePath, mtimeMs, html }
portalMemo = { path: filePath, mtimeMs, html }
return html
return html
}
}
+
+/** The portal with its live parts (portalHome.ts) in place of the build's markers, and their JSON-LD in the head. */
+export function portalPage(filePath: string, parts: HomeParts | null): string {
+ const html = portalHtml(filePath)
+ if (!parts) return html
+ return html
+ .replace('<!--ov:home-stats-->', () => parts.stats)
+ .replace('<!--ov:home-sections-->', () => parts.sections)
+ .replace('</head>', () => `${jsonLdTagsOf(parts.jsonLd)}\n </head>`)
+}
import { handleWsTicketRequest, type WsTicketStore } from './wsTicket.js'
import { handleWsTicketRequest, type WsTicketStore } from './wsTicket.js'
import { casualGameOf, casualPageHtml } from '../casual/page.js'
import { casualGameOf, casualPageHtml } from '../casual/page.js'
import type { MapFileV2 } from '@openvibe/content'
import type { MapFileV2 } from '@openvibe/content'
@@ -151,6 +162,8 @@ export interface HttpPlatformHooks {
@@ -151,6 +162,8 @@ export interface HttpPlatformHooks {
limits?: GamesActorLimits
limits?: GamesActorLimits
/** Trusted-proxy-aware client address (./clientAddress.ts); the socket address when absent. */
/** Trusted-proxy-aware client address (./clientAddress.ts); the socket address when absent. */
clientAddress?: (req: IncomingMessage) => string
clientAddress?: (req: IncomingMessage) => string
+ /** The home page's live sections (./portalHome.ts); the static portal alone when absent. */
+ homeSections?: () => Promise<HomeParts>
}
}
/** The token endpoint's answer, for both the code and the jwt-bearer grant. */
/** The token endpoint's answer, for both the code and the jwt-bearer grant. */
@@ -818,11 +831,16 @@ export function createHttpServer(
@@ -818,11 +831,16 @@ export function createHttpServer(
// The portal is a static build, so its JSON-LD is injected as the page is served: the same
// The portal is a static build, so its JSON-LD is injected as the page is served: the same
// openvibe-shared/seo builders every OpenVibe site uses, one response for every crawler.
// openvibe-shared/seo builders every OpenVibe site uses, one response for every crawler.
if (pageAlias === 'index.html' || url === '/index.html') {
if (pageAlias === 'index.html' || url === '/index.html') {
- res.writeHead(200, {
- 'content-type': 'text/html; charset=utf-8',
- 'cache-control': 'no-cache',
+ const live = platform?.homeSections
+ void (live ? live().catch(() => null) : Promise.resolve(null)).then((parts) => {
+ const body = portalPage(filePath, parts)
+ res.writeHead(200, {
+ 'content-type': 'text/html; charset=utf-8',
+ 'cache-control': 'no-cache',
+ 'content-length': String(Buffer.byteLength(body)),
+ })
+ res.end(req.method === 'HEAD' ? undefined : body)
})
})
- res.end(portalHtml(filePath))
return
return
}
}
const type = MIME[extname(filePath)] ?? 'application/octet-stream'
const type = MIME[extname(filePath)] ?? 'application/octet-stream'
new file mode 100644
new file mode 100644
@@ -0,0 +1,205 @@
@@ -0,0 +1,205 @@
+import { mkdtempSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import type { CreatorWorld } from '@openvibe/persistence'
+import type { SteamGame } from '../steam/steamCatalog.js'
+import { portalPage } from './httpServer.js'
+import {
+ HOME_FAQ,
+ createPortalHome,
+ readPlayTitles,
+ renderHome,
+ renderStats,
+ type HomeData,
+ type HomeSources,
+} from './portalHome.js'
+
+const NOW = Date.parse('2026-10-22T12:00:00Z')
+
+const empty = (): HomeData => ({
+ online: { scraplandia: 0, salvageRush: 0, worlds: 0 },
+ titles: [],
+ boards: { haulWeek: [], haulAll: [], rushWeek: [], rushAll: [] },
+ heroes: [],
+ worlds: [],
+ steam: null,
+})
+
+const game = (appid: number, name: string, extra: Partial<SteamGame> = {}): SteamGame => ({
+ appid,
+ position: 0,
+ name,
+ final_price: 1999,
+ original_price: 1999,
+ discount_percent: 0,
+ currency: 'USD',
+ header_image: `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${appid}/header.jpg`,
+ windows: true,
+ mac: false,
+ linux: false,
+ discount_ends_at: null,
+ observed_at: NOW - 2 * 3600_000,
+ ...extra,
+})
+
+const world = (id: string, plays: number, publishedAt: number): CreatorWorld => ({
+ id,
+ ownerSubject: `usr_${id}`,
+ ownerName: 'Builder <b>',
+ title: `World ${id}`,
+ summary: 'A floating island',
+ status: 'published',
+ revision: 'r1',
+ plays,
+ createdAt: publishedAt,
+ updatedAt: publishedAt,
+ publishedAt,
+})
+
+describe('the home page live sections', () => {
+ it('with no data, only the sections that need none: how it works and the questions', () => {
+ const parts = renderHome(empty(), NOW)
+ expect(parts.stats).toBe('')
+ expect(parts.sections).toContain('id="home-how"')
+ expect(parts.sections).toContain('id="home-faq"')
+ for (const id of ['home-boards', 'home-achievements', 'home-worlds', 'home-steam'])
+ expect(parts.sections).not.toContain(id)
+ // The FAQPage carries exactly the questions the page shows.
+ const faq = parts.jsonLd.find((n) => (n as { '@type': string })['@type'] === 'FAQPage') as {
+ mainEntity: { name: string }[]
+ }
+ expect(faq.mainEntity.map((q) => q.name)).toEqual(HOME_FAQ.map((f) => f.q))
+ for (const f of HOME_FAQ) expect(parts.sections).toContain(f.q.replace(/'/g, '''))
+ })
+
+ it('shows the real counts, boards, achievements, worlds and Steam lists, escaped', () => {
+ const d = empty()
+ d.online = { scraplandia: 2, salvageRush: 1, worlds: 0 }
+ d.titles = [
+ {
+ id: 'scraplandia',
+ name: 'Scraplandia',
+ players: 25,
+ achievements: [
+ {
+ id: 'first-visit',
+ name: 'First Visit',
+ description: 'Join Scraplandia.',
+ unlockedBy: 25,
+ },
+ { id: 'big-haul', name: 'Big Haul', description: 'Score 500.', unlockedBy: 0 },
+ ],
+ },
+ ]
+ d.boards.rushAll = [{ rank: 1, name: '<script>x</script>', score: 12 }]
+ d.heroes = [{ rank: 1, name: 'Ana', stage: 7, power: 900 }]
+ // Newest first (as the store lists them); the seventh is older but the most visited.
+ d.worlds = [
+ ...['a', 'b', 'c', 'd', 'e', 'f'].map((id, i) => world(id, 0, NOW - 3600_000 * (i + 1))),
+ world('old', 40, NOW - 86400_000 * 3),
+ ]
+ d.steam = {
+ top: [game(1, 'Top One')],
+ sale: [game(2, 'Half Off', { discount_percent: 50, final_price: 999 })],
+ new: [],
+ observed_at: NOW - 2 * 3600_000,
+ }
+ const parts = renderHome(d, NOW)
+ expect(parts.stats).toContain('<b>3</b><span>players online now</span>')
+ expect(parts.stats).toContain('<b>25</b><span>players have played Scraplandia</span>')
+ expect(parts.stats).toContain('<b>25</b><span>achievements earned</span>')
+ expect(parts.sections).toContain('best round, all time')
+ expect(parts.sections).toContain('<script>x</script>')
+ expect(parts.sections).not.toContain('<script>x')
+ expect(parts.sections).toContain('stage 7')
+ expect(parts.sections).toContain('Earned by 25 players')
+ expect(parts.sections).toContain('Nobody has earned it yet')
+ expect(parts.sections).toContain('Gives the Golden Name')
+ expect(parts.sections).toContain('href="/worlds/a"')
+ expect(parts.sections).toContain('Builder <b>')
+ expect(parts.sections).toContain('Most visited')
+ expect(parts.sections).toContain('40 visits')
+ expect(parts.stats).toContain('<b>7</b><span>creator worlds published</span>')
+ expect(parts.sections).toContain('https://store.steampowered.com/app/2/')
+ expect(parts.sections).toContain('−50%')
+ expect(parts.sections).toContain('checked 2 hours ago')
+ expect(parts.sections).not.toContain('home-steam-new')
+ })
+
+ it('a stat with nothing to say is left out', () => {
+ const d = empty()
+ d.titles = [{ id: 'salvage-rush', name: 'Salvage Rush', players: 0, achievements: [] }]
+ expect(renderStats(d)).toBe('')
+ })
+
+ it('reads Play titles defensively', async () => {
+ const fetch = (async () =>
+ new Response(
+ JSON.stringify({
+ titles: [
+ {
+ id: 'scraplandia',
+ name: 'Scraplandia',
+ players: 3,
+ achievements: [{ id: 'x', name: 'X', description: 'Do x', unlocked_by: 2 }],
+ },
+ { id: 7 },
+ ],
+ }),
+ )) as unknown as typeof globalThis.fetch
+ const titles = await readPlayTitles('http://play.test', fetch)
+ expect(titles).toEqual([
+ {
+ id: 'scraplandia',
+ name: 'Scraplandia',
+ players: 3,
+ achievements: [{ id: 'x', name: 'X', description: 'Do x', unlockedBy: 2 }],
+ },
+ ])
+ })
+
+ it('a failing source drops its section, and a build is reused for a minute', async () => {
+ let now = NOW
+ let calls = 0
+ const src: HomeSources = {
+ online: () => ({ scraplandia: 1, salvageRush: 0, worlds: 0 }),
+ titles: async () => {
+ calls += 1
+ throw new Error('play down')
+ },
+ board: async () => [],
+ heroes: async () => [],
+ worlds: async () => [],
+ steam: async () => ({ top: [game(5, 'Five')], sale: [], new: [], observed_at: NOW }),
+ }
+ const warnings: string[] = []
+ const home = createPortalHome(src, { now: () => now, log: { warn: (m) => warnings.push(m) } })
+ const first = await home()
+ expect(first.stats).toContain('player online now')
+ expect(first.sections).toContain('id="home-steam"')
+ expect(first.sections).not.toContain('home-achievements')
+ now += 30_000
+ await home()
+ expect(calls).toBe(1)
+ expect(warnings).toEqual(['home section unavailable'])
+ })
+
+ it('the portal page swaps the markers and adds the JSON-LD', () => {
+ const dir = mkdtempSync(join(tmpdir(), 'portal-'))
+ const file = join(dir, 'index.html')
+ writeFileSync(
+ file,
+ '<html><head><title>t</title></head><body><header><!--ov:home-stats--></header><!--ov:home-sections--></body></html>',
+ )
+ const html = portalPage(file, {
+ stats: '<ul class="home-stats"></ul>',
+ sections: '<section id="s">$&</section>',
+ jsonLd: [{ '@type': 'FAQPage' }],
+ })
+ expect(html).toContain('<header><ul class="home-stats"></ul></header>')
+ expect(html).toContain('<section id="s">$&</section>')
+ expect(html).toContain('"@type":"FAQPage"')
+ expect(html).not.toContain('<!--ov:home')
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,573 @@
@@ -0,0 +1,573 @@
+import { SCRAPLANDIA_PACK } from '@openvibe/content'
+import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'
+import type { CreatorWorld } from '@openvibe/persistence'
+import type { Logger } from '@openvibe/shared'
+import { escapeHtml } from './notFound.js'
+import type { SteamGame } from '../steam/steamCatalog.js'
+
+/**
+ * The home page's live sections, rendered on the server so crawlers and visitors without JavaScript read the same
+ * page: who is playing now, the top boards (OpenVibe.Play) and Vibe Heroes' furthest teams, the achievements people
+ * earn (Play's unlock counts), new and most-played creator worlds, Steam's lists (OpenVibe.Sources), how it works and
+ * the questions people ask. Every number is a real row or a live count; a section with nothing to show is left out.
+ *
+ * The portal itself is a static Vite page: httpServer.ts swaps two markers in it for what this module builds,
+ * <!--ov:home-stats--> the figures under the hero
+ * <!--ov:home-sections--> the sections after the game cards
+ * and adds the FAQ and game list JSON-LD. One build is kept for CACHE_MS and served while the next one is made, so a
+ * slow Play or database never holds the page: the first request after boot waits at most FIRST_WAIT_MS.
+ */
+
+const CACHE_MS = 60_000
+const FIRST_WAIT_MS = 2_500
+const PLAY_SITE = 'https://play.openvibe.network'
+
+export interface BoardRow {
+ rank: number
+ name: string
+ score: number
+}
+
+export interface PlayTitleSummary {
+ id: string
+ name: string
+ players: number
+ achievements: { id: string; name: string; description: string; unlockedBy: number }[]
+}
+
+export interface SteamLists {
+ top: SteamGame[]
+ sale: SteamGame[]
+ new: SteamGame[]
+ observed_at: number | null
+}
+
+export interface HomeData {
+ /** Players connected now, per built-in place, plus everyone inside running creator worlds. */
+ online: { scraplandia: number; salvageRush: number; worlds: number }
+ titles: PlayTitleSummary[]
+ boards: {
+ haulWeek: BoardRow[]
+ haulAll: BoardRow[]
+ rushWeek: BoardRow[]
+ rushAll: BoardRow[]
+ }
+ heroes: { rank: number; name: string; stage: number; power: number }[]
+ worlds: CreatorWorld[]
+ steam: SteamLists | null
+}
+
+export interface HomeSources {
+ online(): HomeData['online']
+ /** Play's public titles read (players, achievements and how many unlocked each). */
+ titles(): Promise<PlayTitleSummary[]>
+ board(game: 'scraplandia' | 'salvage-rush', period: 'week' | 'all'): Promise<BoardRow[]>
+ heroes(): Promise<HomeData['heroes']>
+ worlds(): Promise<CreatorWorld[]>
+ steam(): Promise<SteamLists>
+}
+
+export interface HomeParts {
+ stats: string
+ sections: string
+ jsonLd: unknown[]
+}
+
+/** The questions the home answers, shown on the page and published as its FAQPage (the same pairs, one source). */
+export const HOME_FAQ: readonly { q: string; a: string }[] = Object.freeze([
+ {
+ q: 'Do I need to download anything?',
+ a: 'No. Every game here runs in a browser tab on a computer or a phone. Open the page and press play.',
+ },
+ {
+ q: 'Are the games free?',
+ a: 'Yes. Scraplandia, Salvage Rush, Vibe Heroes and every creator world are free to play in your browser.',
+ },
+ {
+ q: 'Do I need an account?',
+ a: 'No. You can play as a guest with one character tied to this browser. An OpenVibe account adds three character slots, keeps your progress across devices and puts your name on the leaderboards.',
+ },
+ {
+ q: 'What do achievements give me?',
+ a: 'Each one is recorded on OpenVibe.Play with your account, and some give an item you keep in your OpenVibe inventory, such as the Golden Name for a 500-point haul or the Cowboy Hat for a 2,000-point week in Scraplandia.',
+ },
+ {
+ q: 'Can I build my own world?',
+ a: 'Yes. Sign in, open Creator worlds and start a world in the map editor: terrain, buildings, lights, zones and props. Publish it and anyone can join from their browser.',
+ },
+ {
+ q: 'Where do the Steam lists come from?',
+ a: "From the Steam store's public top sellers, specials and new releases lists, read every few hours. Prices are Steam's own and every game links to its store page. OpenVibe sells nothing there.",
+ },
+])
+
+/** The item each achievement gives (the content packs' rewards, the same ones inventoryRewards.ts grants). */
+const REWARDS = new Map<string, string>([
+ ...(SCRAPLANDIA_PACK.achievements ?? []).flatMap((a) =>
+ a.reward ? [[`scraplandia/${a.id}`, a.reward.name] as const] : [],
+ ),
+ ...(SALVAGE_RUSH_PACK.game?.achievements ?? []).flatMap((a) =>
+ a.reward ? [[`salvage-rush/${a.id}`, a.reward.name] as const] : [],
+ ),
+])
+
+const esc = (v: unknown): string => escapeHtml(String(v ?? ''))
+const fmt = new Intl.NumberFormat('en-US')
+const n = (v: number): string => fmt.format(v)
+const plural = (count: number, one: string, many: string): string =>
+ `${n(count)} ${count === 1 ? one : many}`
+
+function money(cents: number | null, currency: string | null): string {
+ if (cents == null) return ''
+ if (cents === 0) return 'Free'
+ try {
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: currency || 'USD',
+ }).format(cents / 100)
+ } catch {
+ return `${(cents / 100).toFixed(2)} ${currency ?? ''}`.trim()
+ }
+}
+
+function ago(ms: number, now: number): string {
+ const min = Math.max(0, Math.round((now - ms) / 60000))
+ if (min < 1) return 'just now'
+ if (min < 60) return `${min} minute${min === 1 ? '' : 's'} ago`
+ const h = Math.round(min / 60)
+ if (h < 48) return `${h} hour${h === 1 ? '' : 's'} ago`
+ return `${Math.round(h / 24)} days ago`
+}
+
+/** The figures under the hero: only the ones that have something to say. */
+export function renderStats(d: HomeData): string {
+ const items: { value: number; label: string }[] = []
+ const playing = d.online.scraplandia + d.online.salvageRush + d.online.worlds
+ if (playing > 0)
+ items.push({
+ value: playing,
+ label: playing === 1 ? 'player online now' : 'players online now',
+ })
+ for (const t of d.titles) {
+ if (t.players > 0)
+ items.push({
+ value: t.players,
+ label: `${t.players === 1 ? 'player has' : 'players have'} played ${t.name}`,
+ })
+ }
+ const earned = d.titles.reduce(
+ (sum, t) => sum + t.achievements.reduce((s, a) => s + a.unlockedBy, 0),
+ 0,
+ )
+ if (earned > 0)
+ items.push({
+ value: earned,
+ label: earned === 1 ? 'achievement earned' : 'achievements earned',
+ })
+ const published = d.worlds.length
+ if (published > 0)
+ items.push({
+ value: published,
+ label: published === 1 ? 'creator world published' : 'creator worlds published',
+ })
+ const deals = d.steam?.sale.length ?? 0
+ if (deals > 0)
+ items.push({ value: deals, label: deals === 1 ? 'Steam deal today' : 'Steam deals today' })
+ if (!items.length) return ''
+ return `<ul class="home-stats" aria-label="OpenVibe.Games in numbers">${items
+ .slice(0, 5)
+ .map((s) => `<li><b>${n(s.value)}</b><span>${esc(s.label)}</span></li>`)
+ .join('')}</ul>`
+}
+
+function boardCard(o: {
+ title: string
+ board: string
+ rows: BoardRow[]
+ unit: (score: number) => string
+ href: string
+ more: string
+}): string {
+ if (!o.rows.length) return ''
+ return `<article class="board-card">
+ <h3>${esc(o.title)} <small>${esc(o.board)}</small></h3>
+ <ol class="board-rows">${o.rows
+ .slice(0, 5)
+ .map(
+ (r) =>
+ `<li><span class="rank" aria-hidden="true">${r.rank}</span><span class="who">${esc(r.name)}</span><span class="score">${esc(o.unit(r.score))}</span></li>`,
+ )
+ .join('')}</ol>
+ <a class="card-link" href="${esc(o.href)}">${esc(o.more)}</a>
+</article>`
+}
+
+function boardsSection(d: HomeData): string {
+ const haulWeek = d.boards.haulWeek.length > 0
+ const rushWeek = d.boards.rushWeek.length > 0
+ const cards = [
+ boardCard({
+ title: 'Scraplandia',
+ board: haulWeek ? 'haul points this week' : 'haul points, all time',
+ rows: haulWeek ? d.boards.haulWeek : d.boards.haulAll,
+ unit: (s) => `${n(s)} pts`,
+ href: '/leaderboard.html?game=scraplandia',
+ more: 'Full haul board →',
+ }),
+ boardCard({
+ title: 'Salvage Rush',
+ board: rushWeek ? 'best round this week' : 'best round, all time',
+ rows: rushWeek ? d.boards.rushWeek : d.boards.rushAll,
+ unit: (s) => plural(s, 'scrap', 'scrap'),
+ href: '/leaderboard.html?game=salvage-rush',
+ more: 'Full leaderboard →',
+ }),
+ boardCard({
+ title: 'Vibe Heroes',
+ board: 'furthest teams',
+ rows: d.heroes.map((h) => ({ rank: h.rank, name: h.name, score: h.stage })),
+ unit: (s) => `stage ${n(s)}`,
+ href: '/play/vibe-heroes',
+ more: 'Build your team →',
+ }),
+ ].filter(Boolean)
+ if (!cards.length) return ''
+ return `<section class="home-sec" aria-labelledby="home-boards">
+ <h2 id="home-boards">Top players</h2>
+ <p class="home-lede">The servers score every round, so these boards are what really happened. Signed-in players get their name on them.</p>
+ <div class="board-grid">${cards.join('')}</div>
+</section>`
+}
+
+function achievementsSection(d: HomeData): string {
+ const all = d.titles.flatMap((t) => t.achievements.map((a) => ({ ...a, title: t })))
+ if (!all.length) return ''
+ all.sort((a, b) => b.unlockedBy - a.unlockedBy || a.title.name.localeCompare(b.title.name))
+ return `<section class="home-sec" aria-labelledby="home-achievements">
+ <h2 id="home-achievements">Achievements to earn</h2>
+ <p class="home-lede">Every achievement is kept on OpenVibe.Play with the number of players who earned it. Some give an item for your OpenVibe inventory.</p>
+ <ul class="ach-grid">${all
+ .slice(0, 8)
+ .map(
+ (a) => `<li class="ach${a.unlockedBy > 0 ? ' earned' : ''}">
+ <span class="ach-game">${esc(a.title.name)}</span>
+ <b>${esc(a.name)}</b>
+ <span class="ach-how">${esc(a.description)}</span>
+ ${REWARDS.has(`${a.title.id}/${a.id}`) ? `<span class="ach-reward">Gives the ${esc(REWARDS.get(`${a.title.id}/${a.id}`))}</span>` : ''}
+ <span class="ach-count">${a.unlockedBy > 0 ? `Earned by ${plural(a.unlockedBy, 'player', 'players')}` : 'Nobody has earned it yet'}</span>
+ </li>`,
+ )
+ .join('')}</ul>
+ <p class="home-more">${d.titles
+ .map(
+ (t) =>
+ `<a class="card-link" href="${PLAY_SITE}/t/${encodeURIComponent(t.id)}">${esc(t.name)} on OpenVibe.Play →</a>`,
+ )
+ .join(' ')}</p>
+</section>`
+}
+
+function worldCard(w: CreatorWorld, now: number): string {
+ const meta = [
+ `by ${w.ownerName}`,
+ w.plays > 0 ? plural(w.plays, 'visit', 'visits') : 'no visits yet',
+ ]
+ if (w.publishedAt) meta.push(`published ${ago(w.publishedAt, now)}`)
+ return `<li class="world-card">
+ <h3><a href="/worlds/${encodeURIComponent(w.id)}">${esc(w.title)}</a></h3>
+ ${w.summary ? `<p>${esc(w.summary)}</p>` : ''}
+ <span class="world-meta">${esc(meta.join(' · '))}</span>
+</li>`
+}
+
+function worldsSection(d: HomeData, now: number): string {
+ if (!d.worlds.length) return ''
+ const newest = d.worlds.slice(0, 6)
+ const shown = new Set(newest.map((w) => w.id))
+ const popular = [...d.worlds]
+ .filter((w) => w.plays > 0 && !shown.has(w.id))
+ .sort((a, b) => b.plays - a.plays)
+ .slice(0, 6)
+ return `<section class="home-sec" aria-labelledby="home-worlds">
+ <h2 id="home-worlds">Creator worlds</h2>
+ <p class="home-lede">Worlds players built in the map editor and published. Each one opens in your browser.</p>
+ <h3 class="home-sub">Newest</h3>
+ <ul class="world-grid">${newest.map((w) => worldCard(w, now)).join('')}</ul>
+ ${popular.length ? `<h3 class="home-sub">Most visited</h3><ul class="world-grid">${popular.map((w) => worldCard(w, now)).join('')}</ul>` : ''}
+ <p class="home-more"><a class="card-link" href="/worlds">Every creator world →</a></p>
+</section>`
+}
+
+function steamRow(g: SteamGame): string {
+ const price =
+ g.discount_percent > 0 && g.original_price != null
+ ? `<span class="off">−${g.discount_percent}%</span> ${esc(money(g.final_price, g.currency))} <s>${esc(money(g.original_price, g.currency))}</s>`
+ : esc(money(g.final_price, g.currency))
+ return `<li><a href="https://store.steampowered.com/app/${g.appid}/" rel="noopener" target="_blank">${
+ g.header_image
+ ? `<img src="${esc(g.header_image)}" alt="" width="120" height="56" loading="lazy" decoding="async" referrerpolicy="no-referrer">`
+ : '<span class="steam-art" aria-hidden="true"></span>'
+ }<span class="steam-text"><b>${esc(g.name)}</b>${price ? `<span class="steam-price">${price}</span>` : ''}</span></a></li>`
+}
+
+function steamSection(d: HomeData, now: number): string {
+ const s = d.steam
+ if (!s) return ''
+ const deals = [...s.sale].sort((a, b) => b.discount_percent - a.discount_percent)
+ const cols = [
+ { id: 'home-steam-top', title: 'Top sellers', games: s.top },
+ { id: 'home-steam-deals', title: 'Biggest deals', games: deals },
+ { id: 'home-steam-new', title: 'New releases', games: s.new },
+ ].filter((c) => c.games.length)
+ if (!cols.length) return ''
+ return `<section class="home-sec" aria-labelledby="home-steam">
+ <h2 id="home-steam">On Steam right now</h2>
+ <p class="home-lede">What sells, what's discounted and what just came out on Steam, with Steam's prices in US dollars${
+ s.observed_at ? `, checked ${esc(ago(s.observed_at, now))}` : ''
+ }. Each game opens its store page.</p>
+ <div class="steam-cols">${cols
+ .map(
+ (c) => `<div class="steam-col">
+ <h3 id="${c.id}">${c.title}</h3>
+ <ol class="steam-list" aria-labelledby="${c.id}">${c.games.slice(0, 5).map(steamRow).join('')}</ol>
+ </div>`,
+ )
+ .join('')}</div>
+ <p class="home-more"><a class="card-link" href="/steam">All of Steam's lists →</a></p>
+</section>`
+}
+
+const HOW: readonly { title: string; text: string }[] = [
+ {
+ title: 'Open a game',
+ text: 'Pick one above and press play. It loads in the tab, on a computer or a phone, with nothing to install.',
+ },
+ {
+ title: 'Play as a guest',
+ text: 'You get one character tied to this browser right away. No form, no email.',
+ },
+ {
+ title: 'Sign in to keep it',
+ text: 'An OpenVibe account adds three character slots, saves progress across devices and puts your name on the boards.',
+ },
+ {
+ title: 'Earn and build',
+ text: 'Achievements give items worn across OpenVibe, and the map editor lets you publish a world of your own.',
+ },
+]
+
+function howSection(): string {
+ return `<section class="home-sec" aria-labelledby="home-how">
+ <h2 id="home-how">How it works</h2>
+ <ol class="how-steps">${HOW.map((s) => `<li><b>${esc(s.title)}</b><span>${esc(s.text)}</span></li>`).join('')}</ol>
+</section>`
+}
+
+function faqSection(): string {
+ return `<section class="home-sec" aria-labelledby="home-faq">
+ <h2 id="home-faq">Questions</h2>
+ <div class="faq">${HOME_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</div>
+</section>`
+}
+
+/** The home page's JSON-LD beyond the site and game nodes discovery.ts already writes: the games and the FAQ. */
+export function homeExtraJsonLd(site: string, playSite: string): unknown[] {
+ const games = [
+ {
+ name: 'Scraplandia',
+ url: `${playSite}/`,
+ description: 'Multiplayer survival-building in the browser.',
+ },
+ {
+ name: 'Salvage Rush',
+ url: `${site}/play?game=salvage-rush`,
+ description: '90-second scrap-collecting rounds with a leaderboard.',
+ },
+ {
+ name: 'Vibe Heroes',
+ url: `${site}/play/vibe-heroes`,
+ description: 'An idle hero collector made for the phone.',
+ },
+ {
+ name: 'Creator worlds',
+ url: `${site}/worlds`,
+ description: 'Worlds players built in the map editor and published.',
+ },
+ {
+ name: 'On Steam right now',
+ url: `${site}/steam`,
+ description: "Steam's top sellers, deals and new releases.",
+ },
+ ]
+ return [
+ {
+ '@context': 'https://schema.org',
+ '@type': 'ItemList',
+ name: 'Games on OpenVibe.Games',
+ url: `${site}/`,
+ numberOfItems: games.length,
+ itemListElement: games.map((g, i) => ({
+ '@type': 'ListItem',
+ position: i + 1,
+ name: g.name,
+ url: g.url,
+ description: g.description,
+ })),
+ },
+ {
+ '@context': 'https://schema.org',
+ '@type': 'FAQPage',
+ mainEntity: HOME_FAQ.map((f) => ({
+ '@type': 'Question',
+ name: f.q,
+ acceptedAnswer: { '@type': 'Answer', text: f.a },
+ })),
+ },
+ ]
+}
+
+export function renderHome(
+ d: HomeData,
+ now: number,
+ site = 'https://openvibe.games',
+ playSite = 'https://play.openvibe.games',
+): HomeParts {
+ return {
+ stats: renderStats(d),
+ sections: [
+ boardsSection(d),
+ achievementsSection(d),
+ worldsSection(d, now),
+ steamSection(d, now),
+ howSection(),
+ faqSection(),
+ ]
+ .filter(Boolean)
+ .join('\n'),
+ jsonLd: homeExtraJsonLd(site, playSite),
+ }
+}
+
+const EMPTY: HomeData = {
+ online: { scraplandia: 0, salvageRush: 0, worlds: 0 },
+ titles: [],
+ boards: { haulWeek: [], haulAll: [], rushWeek: [], rushAll: [] },
+ heroes: [],
+ worlds: [],
+ steam: null,
+}
+
+/** Reads every source at once; a source that fails or is slow leaves its section out, never the page. */
+export async function gatherHome(src: HomeSources, log?: Pick<Logger, 'warn'>): Promise<HomeData> {
+ const soft = <T>(name: string, p: Promise<T>, fallback: T): Promise<T> =>
+ p.catch((err: unknown) => {
+ log?.warn('home section unavailable', {
+ section: name,
+ error: err instanceof Error ? err.message : String(err),
+ })
+ return fallback
+ })
+ let online = EMPTY.online
+ try {
+ online = src.online()
+ } catch {
+ /* counts are optional */
+ }
+ const [titles, haulWeek, haulAll, rushWeek, rushAll, heroes, worlds, steam] = await Promise.all([
+ soft('titles', src.titles(), [] as PlayTitleSummary[]),
+ soft('haul-week', src.board('scraplandia', 'week'), [] as BoardRow[]),
+ soft('haul-all', src.board('scraplandia', 'all'), [] as BoardRow[]),
+ soft('rush-week', src.board('salvage-rush', 'week'), [] as BoardRow[]),
+ soft('rush-all', src.board('salvage-rush', 'all'), [] as BoardRow[]),
+ soft('heroes', src.heroes(), [] as HomeData['heroes']),
+ soft('worlds', src.worlds(), [] as CreatorWorld[]),
+ soft('steam', src.steam(), null as SteamLists | null),
+ ])
+ return { online, titles, boards: { haulWeek, haulAll, rushWeek, rushAll }, heroes, worlds, steam }
+}
+
+/** Play's public titles read, trimmed to what the home shows. */
+export async function readPlayTitles(
+ playUrl: string,
+ doFetch: typeof fetch = fetch,
+): Promise<PlayTitleSummary[]> {
+ const res = await doFetch(`${playUrl}/api/v1/titles`, {
+ headers: { accept: 'application/json' },
+ signal: AbortSignal.timeout(5_000),
+ })
+ if (!res.ok) throw new Error(`play ${res.status}`)
+ const body = (await res.json()) as {
+ titles?: {
+ id?: unknown
+ name?: unknown
+ players?: unknown
+ achievements?: {
+ id?: unknown
+ name?: unknown
+ description?: unknown
+ unlocked_by?: unknown
+ }[]
+ }[]
+ }
+ return (body.titles ?? [])
+ .filter((t) => typeof t.id === 'string' && typeof t.name === 'string')
+ .map((t) => ({
+ id: String(t.id),
+ name: String(t.name),
+ players: Math.max(0, Number(t.players) || 0),
+ achievements: (t.achievements ?? [])
+ .filter((a) => typeof a.id === 'string' && typeof a.name === 'string')
+ .map((a) => ({
+ id: String(a.id),
+ name: String(a.name),
+ description: String(a.description ?? ''),
+ unlockedBy: Math.max(0, Number(a.unlocked_by) || 0),
+ })),
+ }))
+}
+
+/** The cached builder httpServer.ts asks for on every home request. */
+export function createPortalHome(
+ src: HomeSources,
+ opts: { site?: string; playSite?: string; log?: Pick<Logger, 'warn'>; now?: () => number } = {},
+): () => Promise<HomeParts> {
+ const now = opts.now ?? Date.now
+ let cached: { at: number; parts: HomeParts } | null = null
+ let building: Promise<HomeParts> | null = null
+ // A source that is down is said once every ten minutes, not on every rebuild.
+ const warned = new Map<string, number>()
+ const log: Pick<Logger, 'warn'> | undefined = opts.log && {
+ warn: (msg, meta) => {
+ const key = String((meta as { section?: unknown } | undefined)?.section ?? msg)
+ if (now() - (warned.get(key) ?? -Infinity) < 600_000) return
+ warned.set(key, now())
+ opts.log!.warn(msg, meta)
+ },
+ }
+ const build = (): Promise<HomeParts> => {
+ building ??= gatherHome(src, log)
+ .then((d) => {
+ const parts = renderHome(d, now(), opts.site, opts.playSite)
+ cached = { at: now(), parts }
+ return parts
+ })
+ .finally(() => {
+ building = null
+ })
+ return building
+ }
+ return async () => {
+ if (cached && now() - cached.at < CACHE_MS) return cached.parts
+ const next = build()
+ if (cached) {
+ next.catch(() => undefined)
+ return cached.parts
+ }
+ // Nothing built yet and the sources are slow: the sections that need no data (how it works, the questions).
+ const bare = (): HomeParts => renderHome(EMPTY, now(), opts.site, opts.playSite)
+ return Promise.race([
+ next,
+ new Promise<HomeParts>((resolve) => setTimeout(() => resolve(bare()), FIRST_WAIT_MS).unref()),
+ ]).catch(bare)
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/server/src/net/portalHome.test.ts'
+// covers: 'apps/server/src/net'
+// Generated by node test/run.js --index; do not edit.