openvibers/openvibe.games Commits
Steam section: top sellers, deals and new releases (#103)
Changes
@@@ -791,6 +791,61 @@</div></div></div>++ <div class="game-card wide">++ <div++ class="shot"++ role="img"++ aria-label="On Steam right now: three game tiles ranked one to three, one marked minus fifty percent"++ >++ <svg class="shot-art" viewBox="0 0 400 260" preserveAspectRatio="xMidYMid meet" aria-hidden="true">++ <rect x="0" y="0" width="400" height="260" fill="#0b1324" />++ <g>++ <rect x="34" y="52" width="104" height="150" rx="12" fill="#16233f" stroke="#2b3d63" stroke-width="2" />++ <rect x="44" y="62" width="84" height="64" rx="7" fill="#1d4ed8" />++ <rect x="44" y="136" width="70" height="9" rx="4" fill="#cbd5e1" />++ <rect x="44" y="152" width="46" height="8" rx="4" fill="#64748b" />++ <circle cx="46" cy="64" r="14" fill="#0b1324" stroke="#f8fafc" stroke-width="2" />++ <text x="46" y="69" text-anchor="middle" font-size="14" font-weight="900" fill="#f8fafc" font-family="system-ui,sans-serif">1</text>++ <text x="44" y="186" font-size="16" font-weight="800" fill="#f8fafc" font-family="system-ui,sans-serif">$29.99</text>++ </g>++ <g>++ <rect x="148" y="40" width="104" height="170" rx="12" fill="#16233f" stroke="#22c55e" stroke-width="2.5" />++ <rect x="158" y="50" width="84" height="64" rx="7" fill="#7c3aed" />++ <rect x="158" y="124" width="70" height="9" rx="4" fill="#cbd5e1" />++ <rect x="158" y="140" width="52" height="8" rx="4" fill="#64748b" />++ <circle cx="160" cy="52" r="14" fill="#0b1324" stroke="#f8fafc" stroke-width="2" />++ <text x="160" y="57" text-anchor="middle" font-size="14" font-weight="900" fill="#f8fafc" font-family="system-ui,sans-serif">2</text>++ <rect x="158" y="164" width="46" height="22" rx="6" fill="#16a34a" />++ <text x="181" y="180" text-anchor="middle" font-size="13" font-weight="900" fill="#fff" font-family="system-ui,sans-serif">−50%</text>++ <text x="210" y="181" font-size="14" font-weight="800" fill="#f8fafc" font-family="system-ui,sans-serif">$7.49</text>++ <text x="158" y="200" font-size="11" fill="#7386a3" text-decoration="line-through" font-family="system-ui,sans-serif">$14.99</text>++ </g>++ <g>++ <rect x="262" y="52" width="104" height="150" rx="12" fill="#16233f" stroke="#2b3d63" stroke-width="2" />++ <rect x="272" y="62" width="84" height="64" rx="7" fill="#0e7490" />++ <rect x="272" y="136" width="70" height="9" rx="4" fill="#cbd5e1" />++ <rect x="272" y="152" width="40" height="8" rx="4" fill="#64748b" />++ <circle cx="274" cy="64" r="14" fill="#0b1324" stroke="#f8fafc" stroke-width="2" />++ <text x="274" y="69" text-anchor="middle" font-size="14" font-weight="900" fill="#f8fafc" font-family="system-ui,sans-serif">3</text>++ <text x="272" y="186" font-size="16" font-weight="800" fill="#f8fafc" font-family="system-ui,sans-serif">Free</text>++ </g>++ </svg>++ </div>++ <div class="game-body">++ <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 the++ day. Every game opens its store page.++ </p>++ <div class="tags">++ <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>++ </div>++ </div>++ </div></section><section class="account">@@@ -116,6 +116,11 @@ export interface PlatformConfig {* Needs the client secret too (ADR-0007 decision 10).*/playUrl: string | null++ /**++ * OpenVibe.Sources base (GAMES_SOURCES_URL, host-internal in production); null = no Steam lists. Needs the client++ * secret too (grant sources.item.read, category games).++ */++ sourcesUrl: string | null/** Media namespace (tenant app id) Games writes objects into. */mediaNamespace: string/** At most one `games.world.saved` checkpoint event per this many minutes. */@@@ -155,6 +160,7 @@ export function loadPlatformConfig(env: NodeJS.ProcessEnv): PlatformConfig {inventoryUrl: trimSlash(env.GAMES_INVENTORY_URL ?? 'https://inventory.openvibe.network'),items: env.GAMES_ITEMS !== 'off' && !!secret,playUrl: env.GAMES_PLAY_URL ? trimSlash(env.GAMES_PLAY_URL) : null,++ sourcesUrl: env.GAMES_SOURCES_URL ? trimSlash(env.GAMES_SOURCES_URL) : null,mediaNamespace: env.MEDIA_NAMESPACE ?? 'games',worldSavedEventMinutes: intEnv(env, 'WORLD_SAVED_EVENT_MINUTES', 15),eventsSecrets: (env.GAMES_EVENTS_SECRET ?? '')@@@ -88,6 +88,8 @@ import { CreatorWorldHost } from './worlds/worldHost.js'import { createWorldHubStarter } from './worlds/startWorldHub.js'import { createWorldRoutes } from './worlds/worldRoutes.js'import { createWorldPage } from './worlds/worldPage.js'++import { createSteamCatalog } from './steam/steamCatalog.js'++import { createSteamRoutes } from './steam/steamPage.js'import { createWorldSearchIndex } from './worlds/searchIndex.js'/**@@@ -773,6 +775,15 @@ async function main(): Promise<void> {worldSearch?.start()// GET /worlds/<id>: a published world's own page (the one Search links to).const serveWorldPage = createWorldPage({ store: creatorWorlds, siteUrl: gamesSiteUrl })++ // GET /steam and /api/steam: Steam's featured lists from OpenVibe.Sources (steam/steamCatalog.ts), synced hourly.++ const steamCatalog = createSteamCatalog({++ db,++ sourcesUrl: config.platform.sourcesUrl,++ tokens: platform?.tokens ?? null,++ log: log.child({ system: 'steam' }),++ })++ steamCatalog.start()++ const serveSteam = createSteamRoutes({ catalog: steamCatalog, siteUrl: gamesSiteUrl })// Creator worlds' JSON API (worlds/worldRoutes.ts). State changes run in the router's admission chain.const serveWorlds = createWorldRoutes({store: creatorWorlds,@@@ -909,6 +920,7 @@ async function main(): Promise<void> {if (serveCasual(req, res)) return trueif (serveWorlds(req, res)) return trueif (serveWorldPage(req, res)) return true++ if (serveSteam(req, res)) return trueif (handleGameRead(req, res, {db,@@@ -1139,6 +1151,7 @@ async function main(): Promise<void> {clearInterval(heartbeatTimer)clearInterval(worldSweepTimer)worldSearch?.stop()++ steamCatalog.stop()clearInterval(pruneTimer)editors.stop()const mirrorDone = mirror?.stop()@@@ -193,6 +193,7 @@ export function createHttpServer({ href: '/play/vibe-heroes', label: 'Play Vibe Heroes' },{ href: '/leaderboard.html', label: 'Leaderboard' },{ href: '/worlds', label: 'Creator worlds' },++ { href: '/steam', label: 'On Steam right now' },{ href: '/editor', label: 'Map editor' },]const playLinks: NotFoundLink[] = [nnew file mode 100644@@@ -0,0 +1,78 @@++import { openTestDb } from '@openvibe/persistence/testing'++import { describe, expect, it } from 'vitest'++import { createSteamCatalog, toGame, WINDOW_MS, type SourcesItem } from './steamCatalog.js'++import { steamPageHtml } from './steamPage.js'++++const NOW = Date.parse('2026-10-18T23:00:00Z')++const seen = (hoursAgo: number) => new Date(NOW - hoursAgo * 3600_000).toISOString()++const img = (id: number) => `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${id}/header.jpg`++const item = (id: number, name: string, fields: Record<string, unknown> = {}, hoursAgo = 1): SourcesItem => ({++ id: `itm_${id}`,++ identity: String(id),++ title: name,++ fields: { appid: id, position: 0, final_price: 2999, original_price: 2999, discount_percent: 0, currency: 'USD', header_image: img(id), windows: true, mac: false, linux: true, ...fields },++ provenance: { retrieved_at: seen(hoursAgo) },++})++++describe('Steam lists from OpenVibe.Sources', () => {++ it('a game is what the store showed, seen in the last 7 hours, with a Steam CDN image only', () => {++ const g = toGame(item(413150, 'Stardew Valley', { position: 2, final_price: 749, original_price: 1499, discount_percent: 50, discount_expiration: 1792800000 }), NOW)++ expect(g).toMatchObject({ appid: 413150, position: 2, name: 'Stardew Valley', final_price: 749, original_price: 1499, discount_percent: 50, currency: 'USD', windows: true, linux: true, mac: false, discount_ends_at: 1792800000000 })++ expect(toGame(item(1, 'Old', {}, WINDOW_MS / 3600_000 + 1), NOW)).toBeNull()++ expect(toGame({ ...item(2, 'Gone'), removed: { at: seen(0) } }, NOW)).toBeNull()++ expect(toGame(item(3, 'Elsewhere', { header_image: 'https://evil.example/x.jpg' }), NOW)?.header_image).toBeNull()++ })++++ it('sync replaces each list with what its source saw, in the store order; an empty read keeps the list', async () => {++ const db = await openTestDb()++ const bySource: Record<string, SourcesItem[]> = {++ 'steam-top-sellers': [item(4435490, 'Call of Duty', { position: 1, final_price: 6999, original_price: 6999 }), item(730, 'Counter-Strike 2', { position: 0, final_price: 0, original_price: 0 }), item(99, 'Was on the list', { position: 3 }, 30)],++ 'steam-specials': [item(413150, 'Stardew Valley', { final_price: 749, original_price: 1499, discount_percent: 50 })],++ 'steam-new-releases': [item(3527290, 'A <New> Game')],++ }++ const calls: string[] = []++ const fakeFetch = (async (url: string) => {++ calls.push(url)++ const source = new URL(url).searchParams.get('source') ?? ''++ return new Response(JSON.stringify({ items: bySource[source] ?? [], next_after: 1, more: false }), { status: 200 })++ }) as unknown as typeof fetch++ const catalog = createSteamCatalog({ db, sourcesUrl: 'http://sources.test', tokens: { getToken: async () => 'tok' }, fetch: fakeFetch, now: () => NOW })++ expect(await catalog.sync()).toEqual({ top: 2, sale: 1, new: 1 })++ expect(calls.every((u) => u.includes('category=games'))).toBe(true)++ const lists = await catalog.lists()++ expect(lists.top.map((g) => g.name)).toEqual(['Counter-Strike 2', 'Call of Duty'])++ expect(lists.sale[0]).toMatchObject({ appid: 413150, discount_percent: 50 })++ expect(lists.observed_at).toBe(NOW - 3600_000)++++ // Sources answers nothing for a list (down, or not fetched yet): the list stays as it was.++ bySource['steam-top-sellers'] = []++ await catalog.sync()++ expect((await catalog.lists()).top).toHaveLength(2)++ })++++ it('without a Sources URL or a token there is no sync and the lists are empty', async () => {++ const db = await openTestDb()++ const catalog = createSteamCatalog({ db, sourcesUrl: null, tokens: null })++ expect(catalog.enabled).toBe(false)++ expect(await catalog.sync()).toBeNull()++ expect((await catalog.lists()).top).toEqual([])++ })++++ it('the page ranks top sellers, shows a deal with its old price, escapes names and links every game to its store page', () => {++ const shell = { page: (o: Record<string, unknown>) => `${String(o.title)}\n${String(o.body)}` }++ const g = (appid: number, name: string, extra: Record<string, unknown> = {}) => ({ ...toGame(item(appid, name, extra), NOW)! })++ const html = steamPageHtml({ top: [g(730, 'Counter-Strike 2', { final_price: 0, original_price: 0 })], sale: [g(413150, 'Stardew Valley', { final_price: 749, original_price: 1499, discount_percent: 50 })], new: [g(3527290, 'A <New> Game')], observed_at: NOW - 3600_000 }, 'https://openvibe.games', shell, NOW)++ expect(html).toContain('Steam top sellers, deals and new releases')++ expect(html).toContain('<span class="rank" aria-label="Number 1">1</span>')++ // Steam's list repeats games (one id at positions 0 and 1): ranks count the games shown, 1, 2, 3 with no gaps.++ const gappy = steamPageHtml({ top: [g(1, 'First', { position: 0 }), g(2, 'Second', { position: 2 })], sale: [], new: [], observed_at: NOW }, 'https://openvibe.games', shell, NOW)++ expect(gappy).toContain('<span class="rank" aria-label="Number 2">2</span>')++ expect(gappy).not.toContain('aria-label="Number 3"')++ expect(html).toContain('<span>Free</span>')++ expect(html).toMatch(/<span class="off">−50%<\/span><span>\$7\.49<\/span><span class="was">\$14\.99<\/span>/)++ expect(html).toContain('A <New> Game')++ expect(html).toContain('href="https://store.steampowered.com/app/413150/"')++ expect(html).toContain('checked 1 hour ago')++ expect(html).not.toMatch(/coming soon|not yet/i)++ })++})nnew file mode 100644@@@ -0,0 +1,184 @@++import type { Logger } from '@openvibe/shared'++import type { Db } from 'openvibe-sdk/db'++++/**++ * The Steam section (the owner's valve.openvibe.games): Steam's featured lists, as OpenVibe.Sources reads them from++ * store.steampowered.com/api/featuredcategories every 3 hours (category games, contracts 0.205.0). One source per list;++ * every sync reads each source whole and replaces the list's rows (migration 0012) with the games its source saw in the++ * last WINDOW_MS, in the store's order. Sources only bumps an item's change position when the item changes (an unchanged++ * game re-seen only moves its retrieved_at), so a change cursor could not tell which games are still on a list.++ *++ * sync() read the three sources (the games principal's token for openvibe.sources, grant sources.item.read)++ * lists() { top, sale, new, observed_at }: what /steam shows++ */++export const STEAM_LISTS = Object.freeze({++ 'steam-top-sellers': 'top',++ 'steam-specials': 'sale',++ 'steam-new-releases': 'new',++} as const)++export type SteamListName = (typeof STEAM_LISTS)[keyof typeof STEAM_LISTS]++/** Two missed 3-hour fetches drop a game from its list; one does not. */++export const WINDOW_MS = 7 * 3600 * 1000++const PAGE = 500++const MAX_PAGES = 20++++export interface SteamGame {++ appid: number++ position: number++ name: string++ final_price: number | null++ original_price: number | null++ discount_percent: number++ currency: string | null++ header_image: string | null++ windows: boolean++ mac: boolean++ linux: boolean++ discount_ends_at: number | null++ observed_at: number++}++++export interface SourcesItem {++ id?: string++ identity?: string++ kind?: string++ title?: string | null++ removed?: unknown++ fields?: Record<string, unknown>++ provenance?: { retrieved_at?: string }++}++++const int = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? Math.trunc(v) : null)++const text = (v: unknown, max: number): string | null =>++ typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null++/** Only Steam's own image CDN: a header image is shown straight from it. */++const STEAM_IMAGE = /^https:\/\/(?:shared\.)?(?:akamai|cloudflare|fastly)\.steamstatic\.com\/[^\s"'<>]+$|^https:\/\/cdn\.(?:akamai|cloudflare)\.steamstatic\.com\/[^\s"'<>]+$/++++/** One Sources item → a list row, or null when it is not a usable game (or not seen recently). */++export function toGame(item: SourcesItem, now: number): SteamGame | null {++ if (!item || item.removed) return null++ const f = item.fields ?? {}++ const appid = int(f.appid) ?? (item.identity && /^\d{1,10}$/.test(item.identity) ? Number(item.identity) : null)++ const name = text(item.title, 200)++ const seen = Date.parse(item.provenance?.retrieved_at ?? '')++ if (!appid || appid <= 0 || !name || !Number.isFinite(seen) || now - seen > WINDOW_MS) return null++ const image = text(f.header_image, 500)++ const ends = int(f.discount_expiration)++ return {++ appid,++ position: int(f.position) ?? 999,++ name,++ final_price: int(f.final_price),++ original_price: int(f.original_price),++ discount_percent: Math.max(0, Math.min(100, int(f.discount_percent) ?? 0)),++ currency: text(f.currency, 3),++ header_image: image && STEAM_IMAGE.test(image) ? image : null,++ windows: f.windows === true,++ mac: f.mac === true,++ linux: f.linux === true,++ discount_ends_at: ends && ends > 0 ? ends * 1000 : null,++ observed_at: seen,++ }++}++++export interface SteamCatalogOptions {++ db: Db++ /** Base of OpenVibe.Sources (host-internal), or null: no sync, empty lists. */++ sourcesUrl: string | null++ tokens: { getToken(ctx?: { audience?: string }): Promise<string> } | null++ fetch?: typeof fetch++ now?: () => number++ log?: Pick<Logger, 'info' | 'warn'>++}++++export function createSteamCatalog(opts: SteamCatalogOptions) {++ const now = opts.now ?? Date.now++ const doFetch = opts.fetch ?? fetch++ const enabled = !!(opts.sourcesUrl && opts.tokens)++++ async function readSource(key: string): Promise<SourcesItem[]> {++ const token = await opts.tokens!.getToken({ audience: 'openvibe.sources' })++ const out: SourcesItem[] = []++ let after = 0++ for (let page = 0; page < MAX_PAGES; page++) {++ const url = `${opts.sourcesUrl}/api/v1/items?source=${encodeURIComponent(key)}&category=games&after=${after}&limit=${PAGE}`++ const res = await doFetch(url, { headers: { authorization: `Bearer ${token}`, accept: 'application/json' }, signal: AbortSignal.timeout(15_000) })++ if (!res.ok) throw new Error(`Sources answered ${res.status} for ${key}`)++ const body = (await res.json()) as { items?: SourcesItem[]; next_after?: number; more?: boolean }++ const items = Array.isArray(body.items) ? body.items : []++ out.push(...items)++ if (!body.more || typeof body.next_after !== 'number' || body.next_after <= after) break++ after = body.next_after++ }++ return out++ }++++ /** One list's rows, replaced whole in one transaction (an empty read leaves the list as it was: Sources may be down). */++ async function replace(list: SteamListName, games: SteamGame[]): Promise<void> {++ await opts.db.tx(async (t) => {++ await t.exec('DELETE FROM steam_list_entries WHERE list = $1', [list])++ for (const g of games) {++ await t.exec(++ `INSERT INTO steam_list_entries (list, appid, position, name, final_price, original_price, discount_percent, currency,++ header_image, windows, mac, linux, discount_ends_at, observed_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,++ [list, g.appid, g.position, g.name, g.final_price, g.original_price, g.discount_percent, g.currency, g.header_image, g.windows, g.mac, g.linux, g.discount_ends_at, g.observed_at],++ )++ }++ })++ }++++ async function sync(): Promise<Record<string, number> | null> {++ if (!enabled) return null++ const counts: Record<string, number> = {}++ for (const [key, list] of Object.entries(STEAM_LISTS)) {++ const at = now()++ const byApp = new Map<number, SteamGame>()++ for (const item of await readSource(key)) {++ const g = toGame(item, at)++ if (g && (!byApp.has(g.appid) || byApp.get(g.appid)!.observed_at < g.observed_at)) byApp.set(g.appid, g)++ }++ const games = [...byApp.values()].sort((a, b) => a.position - b.position || a.name.localeCompare(b.name))++ if (games.length) await replace(list, games)++ counts[list] = games.length++ }++ return counts++ }++++ async function lists(): Promise<{ top: SteamGame[]; sale: SteamGame[]; new: SteamGame[]; observed_at: number | null }> {++ const rows = await opts.db.many<SteamGame & { list: SteamListName }>(++ `SELECT list, appid, position, name, final_price, original_price, discount_percent, currency, header_image, windows, mac,++ linux, discount_ends_at::float8 AS discount_ends_at, observed_at::float8 AS observed_at++ FROM steam_list_entries ORDER BY list, position, name`,++ )++ const out = { top: [] as SteamGame[], sale: [] as SteamGame[], new: [] as SteamGame[], observed_at: null as number | null }++ for (const { list, ...g } of rows) {++ out[list]?.push(g)++ if (!out.observed_at || g.observed_at > out.observed_at) out.observed_at = g.observed_at++ }++ return out++ }++++ let timer: NodeJS.Timeout | null = null++ return {++ enabled,++ sync,++ lists,++ /** Sync now and every `everyMs` (unref'd); failures are logged and retried at the next tick. */++ start(everyMs = 3600_000): void {++ if (!enabled || timer) return++ const run = () =>++ void sync()++ .then((c) => opts.log?.info('steam lists synced', { top: c?.top ?? 0, sale: c?.sale ?? 0, new: c?.new ?? 0 }))++ .catch((err: unknown) => opts.log?.warn('steam lists sync failed', { error: String(err) }))++ run()++ timer = setInterval(run, everyMs)++ timer.unref()++ },++ stop(): void {++ if (timer) clearInterval(timer)++ timer = null++ },++ }++}++export type SteamCatalog = ReturnType<typeof createSteamCatalog>nnew file mode 100644@@@ -0,0 +1,168 @@++import type { IncomingMessage, ServerResponse } from 'node:http'++import { createRequire } from 'node:module'++import { escapeHtml } from '../net/notFound.js'++import type { SteamCatalog, SteamGame } from './steamCatalog.js'++++/**++ * GET /steam: Steam's featured lists (top sellers, deals, new releases) as the store showed them at the last read, each++ * game linked to its store page; GET /api/steam: the same as JSON, for anyone's code. Prices are Steam's (US dollars),++ * never converted; nothing is sold here and no endorsement by Valve is implied.++ */++interface Shell {++ page(o: Record<string, unknown>): string++}++++const SITE_NAME = 'OpenVibe.Games'++const NETWORK_URL = 'https://openvibe.network'++++const STYLE = `<style>++.steam{max-width:1180px;margin:0 auto;padding:28px 16px 64px}++.steam .eyebrow{margin:0 0 6px;font-size:12px;font-weight:800;letter-spacing:.14em;color:var(--secondary,#22d3ee)}++.steam h1{margin:0 0 8px;font-size:clamp(28px,5vw,40px);font-weight:900}++.steam .lede{margin:0 0 6px;max-width:68ch;color:var(--text-dim,#96a7c2)}++.steam .checked{margin:0 0 26px;font-size:14px;color:var(--text-faint,#7386a3)}++.steam section{margin:0 0 34px}++.steam h2{display:flex;align-items:baseline;gap:10px;margin:0 0 14px;font-size:22px;font-weight:850}++.steam h2 small{font-size:13px;font-weight:600;color:var(--text-faint,#7386a3)}++.steam .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(250px,100%),1fr));gap:14px;margin:0;padding:0;list-style:none}++.steam .game{position:relative;display:flex;flex-direction:column;border:1px solid var(--border,#1f2d47);border-radius:14px;++ overflow:hidden;background:var(--bg-card,#0f1729);transition:border-color .15s,transform .15s}++.steam .game:hover{border-color:var(--accent,#3b82f6);transform:translateY(-2px)}++.steam .game a{color:inherit;text-decoration:none}++.steam .game a::after{content:'';position:absolute;inset:0}++.steam .art{aspect-ratio:460/215;background:var(--bg-tertiary,#16213a);display:block;width:100%;object-fit:cover}++.steam .rank{position:absolute;top:8px;left:8px;min-width:30px;height:30px;padding:0 8px;border-radius:9px;display:grid;++ place-items:center;background:rgba(5,10,20,.82);font-weight:900;font-size:14px}++.steam .body{display:flex;flex-direction:column;gap:6px;padding:12px 13px 13px;flex:1}++.steam .name{margin:0;font-size:16px;font-weight:800;line-height:1.25;overflow-wrap:anywhere}++.steam .price{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:auto;font-weight:800}++.steam .off{padding:2px 7px;border-radius:7px;background:#16a34a;color:#fff;font-size:13px}++.steam .was{color:var(--text-faint,#7386a3);font-weight:600;text-decoration:line-through}++.steam .meta{display:flex;flex-wrap:wrap;gap:6px;font-size:12px;color:var(--text-faint,#7386a3)}++.steam .meta span{padding:1px 7px;border:1px solid var(--border,#1f2d47);border-radius:999px}++.steam .empty{padding:18px;border:1px dashed var(--border,#1f2d47);border-radius:14px;color:var(--text-dim,#96a7c2)}++.steam .about{margin-top:10px;font-size:14px;color:var(--text-faint,#7386a3);max-width:80ch}++.steam .about a{color:var(--accent-light,#93c5fd)}++</style>`++++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)++ return `${h} hour${h === 1 ? '' : 's'} ago`++}++++function endsIn(ms: number | null, now: number): string {++ if (!ms || ms <= now) return ''++ const days = Math.floor((ms - now) / 86400000)++ if (days >= 1) return `Ends in ${days} day${days === 1 ? '' : 's'}`++ const h = Math.max(1, Math.round((ms - now) / 3600000))++ return `Ends in ${h} hour${h === 1 ? '' : 's'}`++}++++function card(g: SteamGame, now: number, rank: number | null): string {++ const store = `https://store.steampowered.com/app/${g.appid}/`++ const price =++ g.discount_percent > 0 && g.original_price != null++ ? `<span class="off">−${g.discount_percent}%</span><span>${escapeHtml(money(g.final_price, g.currency))}</span><span class="was">${escapeHtml(money(g.original_price, g.currency))}</span>`++ : `<span>${escapeHtml(money(g.final_price, g.currency)) || 'See the store'}</span>`++ const platforms = [g.windows && 'Windows', g.mac && 'macOS', g.linux && 'Linux'].filter(Boolean) as string[]++ const ends = endsIn(g.discount_ends_at, now)++ return `<li class="game">++ ${g.header_image ? `<img class="art" src="${escapeHtml(g.header_image)}" alt="" loading="lazy" decoding="async" width="460" height="215" referrerpolicy="no-referrer">` : '<span class="art" aria-hidden="true"></span>'}++ ${rank ? `<span class="rank" aria-label="Number ${rank}">${rank}</span>` : ''}++ <div class="body">++ <h3 class="name"><a href="${store}" rel="noopener" target="_blank">${escapeHtml(g.name)}</a></h3>++ <p class="price">${price}</p>++ <p class="meta">${platforms.map((p) => `<span>${p}</span>`).join('')}${ends ? `<span>${escapeHtml(ends)}</span>` : ''}</p>++ </div>++</li>`++}++++function section(id: string, title: string, note: string, games: SteamGame[], now: number, ranked = false): string {++ return `<section aria-labelledby="${id}"><h2 id="${id}">${title} <small>${note}</small></h2>++${games.length ? `<ol class="grid">${games.map((g, i) => card(g, now, ranked ? i + 1 : null)).join('')}</ol>` : '<p class="empty">Steam\'s list could not be read just now.</p>'}++</section>`++}++++export function steamPageHtml(++ data: Awaited<ReturnType<SteamCatalog['lists']>>,++ siteUrl: string,++ shell: Shell,++ now = Date.now(),++): string {++ const links = [++ { label: 'Steam', href: '/steam' },++ { label: 'Creator worlds', href: '/worlds' },++ { label: 'Play Scraplandia', href: '/play' },++ ]++ const checked = data.observed_at ? `From the Steam store, checked ${ago(data.observed_at, now)}.` : ''++ return shell.page({++ name: SITE_NAME,++ service: 'games',++ lang: 'en',++ title: `Steam top sellers, deals and new releases · ${SITE_NAME}`,++ siteName: SITE_NAME,++ description: "What sells on Steam right now, what's on sale and what just came out, with Steam's prices and a link to every store page.",++ canonical: `${siteUrl}/steam`,++ robots: 'index, follow',++ head: `<link rel="icon" type="image/svg+xml" href="/favicon.svg" />\n${STYLE}`,++ navbar: { service: 'games', apiBase: NETWORK_URL, links, history: { type: 'page', title: 'Steam' } },++ footer: { service: 'games', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' },++ home: '/',++ navLinks: links,++ body: `<div id="navbar-mount"></div>++<main id="main" class="steam">++ <p class="eyebrow">STEAM</p>++ <h1>On Steam right now</h1>++ <p class="lede">Top sellers, deals and new releases as the Steam store lists them, with Steam's prices in US dollars. Every game opens its store page.</p>++ ${checked ? `<p class="checked">${escapeHtml(checked)}</p>` : ''}++ ${section('steam-top', 'Top sellers', "the store's order", data.top, now, true)}++ ${section('steam-sale', 'Deals', 'discounts Steam features', data.sale, now)}++ ${section('steam-new', 'New releases', 'just out', data.new, now)}++ <p class="about">Read from the Steam store's public storefront lists every few hours through OpenVibe.Sources++ and shown as they were at that time; prices change on Steam first. Game names and images belong to Valve and the games'++ publishers. OpenVibe sells nothing here and is not affiliated with Valve. The same lists as JSON: <a href="/api/steam">/api/steam</a>.</p>++</main>`,++ })++}++++export function createSteamRoutes(opts: { catalog: SteamCatalog; siteUrl: string; require?: NodeRequire }) {++ const shell = (opts.require ?? createRequire(import.meta.url))('openvibe-shared/shell') as Shell++ const siteUrl = opts.siteUrl.replace(/\/+$/, '')++ return (req: IncomingMessage, res: ServerResponse): boolean => {++ const path = (req.url ?? '/').split('?')[0] ?? '/'++ if (path !== '/steam' && path !== '/api/steam') return false++ if (req.method !== 'GET' && req.method !== 'HEAD') return false++ void (async () => {++ const data = await opts.catalog.lists()++ const json = path === '/api/steam'++ const body = json++ ? JSON.stringify({ source: 'store.steampowered.com (featured lists)', checked_at: data.observed_at ? new Date(data.observed_at).toISOString() : null, top_sellers: data.top, deals: data.sale, new_releases: data.new })++ : steamPageHtml(data, siteUrl, shell)++ res.writeHead(200, {++ 'content-type': json ? 'application/json; charset=utf-8' : 'text/html; charset=utf-8',++ 'cache-control': 'public, max-age=300',++ ...(json ? { 'access-control-allow-origin': '*' } : {}),++ 'content-length': String(Buffer.byteLength(body)),++ })++ res.end(req.method === 'HEAD' ? undefined : body)++ })().catch(() => {++ if (res.headersSent) return++ res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })++ res.end('steam lists unavailable')++ })++ return true++ }++}nnew file mode 100644@@@ -0,0 +1,21 @@++-- phase: expand++-- The Steam section (/steam): Steam's featured lists as OpenVibe.Sources last saw them (category games, contracts++-- 0.205.0; sources steam-top-sellers, steam-new-releases, steam-specials). Each sync replaces a list's rows with the++-- games its source saw in the last 7 hours, in the store's order; prices are in the currency's minor unit (cents).++CREATE TABLE steam_list_entries (++ list text COLLATE "C" NOT NULL, -- top | new | sale++ appid integer NOT NULL,++ position integer NOT NULL, -- 0-based, the store's order++ name text NOT NULL,++ final_price integer, -- null when the store states none++ original_price integer,++ discount_percent integer NOT NULL DEFAULT 0,++ currency text COLLATE "C",++ header_image text,++ windows boolean NOT NULL DEFAULT false,++ mac boolean NOT NULL DEFAULT false,++ linux boolean NOT NULL DEFAULT false,++ discount_ends_at bigint, -- epoch ms++ observed_at bigint NOT NULL, -- epoch ms: when Sources last saw it in the list++ PRIMARY KEY (list, appid)++);@@@ -39,6 +39,7 @@ const TABLES = ['account_data_events','event_outbox','token_revocations',++ 'steam_list_entries',].join(', ')let shared: Db | null = nullnnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/server/src/steam/steamCatalog.test.ts'++// covers: 'apps/server/src/steam'++// Generated by node test/run.js --index; do not edit.
@@ -791,6 +791,61 @@
@@ -791,6 +791,61 @@
</div>
</div>
</div>
</div>
</div>
</div>
+ <div class="game-card wide">
+ <div
+ class="shot"
+ role="img"
+ aria-label="On Steam right now: three game tiles ranked one to three, one marked minus fifty percent"
+ >
+ <svg class="shot-art" viewBox="0 0 400 260" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
+ <rect x="0" y="0" width="400" height="260" fill="#0b1324" />
+ <g>
+ <rect x="34" y="52" width="104" height="150" rx="12" fill="#16233f" stroke="#2b3d63" stroke-width="2" />
+ <rect x="44" y="62" width="84" height="64" rx="7" fill="#1d4ed8" />
+ <rect x="44" y="136" width="70" height="9" rx="4" fill="#cbd5e1" />
+ <rect x="44" y="152" width="46" height="8" rx="4" fill="#64748b" />
+ <circle cx="46" cy="64" r="14" fill="#0b1324" stroke="#f8fafc" stroke-width="2" />
+ <text x="46" y="69" text-anchor="middle" font-size="14" font-weight="900" fill="#f8fafc" font-family="system-ui,sans-serif">1</text>
+ <text x="44" y="186" font-size="16" font-weight="800" fill="#f8fafc" font-family="system-ui,sans-serif">$29.99</text>
+ </g>
+ <g>
+ <rect x="148" y="40" width="104" height="170" rx="12" fill="#16233f" stroke="#22c55e" stroke-width="2.5" />
+ <rect x="158" y="50" width="84" height="64" rx="7" fill="#7c3aed" />
+ <rect x="158" y="124" width="70" height="9" rx="4" fill="#cbd5e1" />
+ <rect x="158" y="140" width="52" height="8" rx="4" fill="#64748b" />
+ <circle cx="160" cy="52" r="14" fill="#0b1324" stroke="#f8fafc" stroke-width="2" />
+ <text x="160" y="57" text-anchor="middle" font-size="14" font-weight="900" fill="#f8fafc" font-family="system-ui,sans-serif">2</text>
+ <rect x="158" y="164" width="46" height="22" rx="6" fill="#16a34a" />
+ <text x="181" y="180" text-anchor="middle" font-size="13" font-weight="900" fill="#fff" font-family="system-ui,sans-serif">−50%</text>
+ <text x="210" y="181" font-size="14" font-weight="800" fill="#f8fafc" font-family="system-ui,sans-serif">$7.49</text>
+ <text x="158" y="200" font-size="11" fill="#7386a3" text-decoration="line-through" font-family="system-ui,sans-serif">$14.99</text>
+ </g>
+ <g>
+ <rect x="262" y="52" width="104" height="150" rx="12" fill="#16233f" stroke="#2b3d63" stroke-width="2" />
+ <rect x="272" y="62" width="84" height="64" rx="7" fill="#0e7490" />
+ <rect x="272" y="136" width="70" height="9" rx="4" fill="#cbd5e1" />
+ <rect x="272" y="152" width="40" height="8" rx="4" fill="#64748b" />
+ <circle cx="274" cy="64" r="14" fill="#0b1324" stroke="#f8fafc" stroke-width="2" />
+ <text x="274" y="69" text-anchor="middle" font-size="14" font-weight="900" fill="#f8fafc" font-family="system-ui,sans-serif">3</text>
+ <text x="272" y="186" font-size="16" font-weight="800" fill="#f8fafc" font-family="system-ui,sans-serif">Free</text>
+ </g>
+ </svg>
+ </div>
+ <div class="game-body">
+ <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 the
+ day. Every game opens its store page.
+ </p>
+ <div class="tags">
+ <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>
+ </div>
+ </div>
+ </div>
</section>
</section>
<section class="account">
<section class="account">
@@ -116,6 +116,11 @@ export interface PlatformConfig {
@@ -116,6 +116,11 @@ export interface PlatformConfig {
* Needs the client secret too (ADR-0007 decision 10).
* Needs the client secret too (ADR-0007 decision 10).
*/
*/
playUrl: string | null
playUrl: string | null
+ /**
+ * OpenVibe.Sources base (GAMES_SOURCES_URL, host-internal in production); null = no Steam lists. Needs the client
+ * secret too (grant sources.item.read, category games).
+ */
+ sourcesUrl: string | null
/** Media namespace (tenant app id) Games writes objects into. */
/** Media namespace (tenant app id) Games writes objects into. */
mediaNamespace: string
mediaNamespace: string
/** At most one `games.world.saved` checkpoint event per this many minutes. */
/** At most one `games.world.saved` checkpoint event per this many minutes. */
@@ -155,6 +160,7 @@ export function loadPlatformConfig(env: NodeJS.ProcessEnv): PlatformConfig {
@@ -155,6 +160,7 @@ export function loadPlatformConfig(env: NodeJS.ProcessEnv): PlatformConfig {
inventoryUrl: trimSlash(env.GAMES_INVENTORY_URL ?? 'https://inventory.openvibe.network'),
inventoryUrl: trimSlash(env.GAMES_INVENTORY_URL ?? 'https://inventory.openvibe.network'),
items: env.GAMES_ITEMS !== 'off' && !!secret,
items: env.GAMES_ITEMS !== 'off' && !!secret,
playUrl: env.GAMES_PLAY_URL ? trimSlash(env.GAMES_PLAY_URL) : null,
playUrl: env.GAMES_PLAY_URL ? trimSlash(env.GAMES_PLAY_URL) : null,
+ sourcesUrl: env.GAMES_SOURCES_URL ? trimSlash(env.GAMES_SOURCES_URL) : null,
mediaNamespace: env.MEDIA_NAMESPACE ?? 'games',
mediaNamespace: env.MEDIA_NAMESPACE ?? 'games',
worldSavedEventMinutes: intEnv(env, 'WORLD_SAVED_EVENT_MINUTES', 15),
worldSavedEventMinutes: intEnv(env, 'WORLD_SAVED_EVENT_MINUTES', 15),
eventsSecrets: (env.GAMES_EVENTS_SECRET ?? '')
eventsSecrets: (env.GAMES_EVENTS_SECRET ?? '')
@@ -88,6 +88,8 @@ import { CreatorWorldHost } from './worlds/worldHost.js'
@@ -88,6 +88,8 @@ import { CreatorWorldHost } from './worlds/worldHost.js'
import { createWorldHubStarter } from './worlds/startWorldHub.js'
import { createWorldHubStarter } from './worlds/startWorldHub.js'
import { createWorldRoutes } from './worlds/worldRoutes.js'
import { createWorldRoutes } from './worlds/worldRoutes.js'
import { createWorldPage } from './worlds/worldPage.js'
import { createWorldPage } from './worlds/worldPage.js'
+import { createSteamCatalog } from './steam/steamCatalog.js'
+import { createSteamRoutes } from './steam/steamPage.js'
import { createWorldSearchIndex } from './worlds/searchIndex.js'
import { createWorldSearchIndex } from './worlds/searchIndex.js'
/**
/**
@@ -773,6 +775,15 @@ async function main(): Promise<void> {
@@ -773,6 +775,15 @@ async function main(): Promise<void> {
worldSearch?.start()
worldSearch?.start()
// GET /worlds/<id>: a published world's own page (the one Search links to).
// GET /worlds/<id>: a published world's own page (the one Search links to).
const serveWorldPage = createWorldPage({ store: creatorWorlds, siteUrl: gamesSiteUrl })
const serveWorldPage = createWorldPage({ store: creatorWorlds, siteUrl: gamesSiteUrl })
+ // GET /steam and /api/steam: Steam's featured lists from OpenVibe.Sources (steam/steamCatalog.ts), synced hourly.
+ const steamCatalog = createSteamCatalog({
+ db,
+ sourcesUrl: config.platform.sourcesUrl,
+ tokens: platform?.tokens ?? null,
+ log: log.child({ system: 'steam' }),
+ })
+ steamCatalog.start()
+ const serveSteam = createSteamRoutes({ catalog: steamCatalog, siteUrl: gamesSiteUrl })
// Creator worlds' JSON API (worlds/worldRoutes.ts). State changes run in the router's admission chain.
// Creator worlds' JSON API (worlds/worldRoutes.ts). State changes run in the router's admission chain.
const serveWorlds = createWorldRoutes({
const serveWorlds = createWorldRoutes({
store: creatorWorlds,
store: creatorWorlds,
@@ -909,6 +920,7 @@ async function main(): Promise<void> {
@@ -909,6 +920,7 @@ async function main(): Promise<void> {
if (serveCasual(req, res)) return true
if (serveCasual(req, res)) return true
if (serveWorlds(req, res)) return true
if (serveWorlds(req, res)) return true
if (serveWorldPage(req, res)) return true
if (serveWorldPage(req, res)) return true
+ if (serveSteam(req, res)) return true
if (
if (
handleGameRead(req, res, {
handleGameRead(req, res, {
db,
db,
@@ -1139,6 +1151,7 @@ async function main(): Promise<void> {
@@ -1139,6 +1151,7 @@ async function main(): Promise<void> {
clearInterval(heartbeatTimer)
clearInterval(heartbeatTimer)
clearInterval(worldSweepTimer)
clearInterval(worldSweepTimer)
worldSearch?.stop()
worldSearch?.stop()
+ steamCatalog.stop()
clearInterval(pruneTimer)
clearInterval(pruneTimer)
editors.stop()
editors.stop()
const mirrorDone = mirror?.stop()
const mirrorDone = mirror?.stop()
@@ -193,6 +193,7 @@ export function createHttpServer(
@@ -193,6 +193,7 @@ export function createHttpServer(
{ href: '/play/vibe-heroes', label: 'Play Vibe Heroes' },
{ href: '/play/vibe-heroes', label: 'Play Vibe Heroes' },
{ href: '/leaderboard.html', label: 'Leaderboard' },
{ href: '/leaderboard.html', label: 'Leaderboard' },
{ href: '/worlds', label: 'Creator worlds' },
{ href: '/worlds', label: 'Creator worlds' },
+ { href: '/steam', label: 'On Steam right now' },
{ href: '/editor', label: 'Map editor' },
{ href: '/editor', label: 'Map editor' },
]
]
const playLinks: NotFoundLink[] = [
const playLinks: NotFoundLink[] = [
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+import { openTestDb } from '@openvibe/persistence/testing'
+import { describe, expect, it } from 'vitest'
+import { createSteamCatalog, toGame, WINDOW_MS, type SourcesItem } from './steamCatalog.js'
+import { steamPageHtml } from './steamPage.js'
+
+const NOW = Date.parse('2026-10-18T23:00:00Z')
+const seen = (hoursAgo: number) => new Date(NOW - hoursAgo * 3600_000).toISOString()
+const img = (id: number) => `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${id}/header.jpg`
+const item = (id: number, name: string, fields: Record<string, unknown> = {}, hoursAgo = 1): SourcesItem => ({
+ id: `itm_${id}`,
+ identity: String(id),
+ title: name,
+ fields: { appid: id, position: 0, final_price: 2999, original_price: 2999, discount_percent: 0, currency: 'USD', header_image: img(id), windows: true, mac: false, linux: true, ...fields },
+ provenance: { retrieved_at: seen(hoursAgo) },
+})
+
+describe('Steam lists from OpenVibe.Sources', () => {
+ it('a game is what the store showed, seen in the last 7 hours, with a Steam CDN image only', () => {
+ const g = toGame(item(413150, 'Stardew Valley', { position: 2, final_price: 749, original_price: 1499, discount_percent: 50, discount_expiration: 1792800000 }), NOW)
+ expect(g).toMatchObject({ appid: 413150, position: 2, name: 'Stardew Valley', final_price: 749, original_price: 1499, discount_percent: 50, currency: 'USD', windows: true, linux: true, mac: false, discount_ends_at: 1792800000000 })
+ expect(toGame(item(1, 'Old', {}, WINDOW_MS / 3600_000 + 1), NOW)).toBeNull()
+ expect(toGame({ ...item(2, 'Gone'), removed: { at: seen(0) } }, NOW)).toBeNull()
+ expect(toGame(item(3, 'Elsewhere', { header_image: 'https://evil.example/x.jpg' }), NOW)?.header_image).toBeNull()
+ })
+
+ it('sync replaces each list with what its source saw, in the store order; an empty read keeps the list', async () => {
+ const db = await openTestDb()
+ const bySource: Record<string, SourcesItem[]> = {
+ 'steam-top-sellers': [item(4435490, 'Call of Duty', { position: 1, final_price: 6999, original_price: 6999 }), item(730, 'Counter-Strike 2', { position: 0, final_price: 0, original_price: 0 }), item(99, 'Was on the list', { position: 3 }, 30)],
+ 'steam-specials': [item(413150, 'Stardew Valley', { final_price: 749, original_price: 1499, discount_percent: 50 })],
+ 'steam-new-releases': [item(3527290, 'A <New> Game')],
+ }
+ const calls: string[] = []
+ const fakeFetch = (async (url: string) => {
+ calls.push(url)
+ const source = new URL(url).searchParams.get('source') ?? ''
+ return new Response(JSON.stringify({ items: bySource[source] ?? [], next_after: 1, more: false }), { status: 200 })
+ }) as unknown as typeof fetch
+ const catalog = createSteamCatalog({ db, sourcesUrl: 'http://sources.test', tokens: { getToken: async () => 'tok' }, fetch: fakeFetch, now: () => NOW })
+ expect(await catalog.sync()).toEqual({ top: 2, sale: 1, new: 1 })
+ expect(calls.every((u) => u.includes('category=games'))).toBe(true)
+ const lists = await catalog.lists()
+ expect(lists.top.map((g) => g.name)).toEqual(['Counter-Strike 2', 'Call of Duty'])
+ expect(lists.sale[0]).toMatchObject({ appid: 413150, discount_percent: 50 })
+ expect(lists.observed_at).toBe(NOW - 3600_000)
+
+ // Sources answers nothing for a list (down, or not fetched yet): the list stays as it was.
+ bySource['steam-top-sellers'] = []
+ await catalog.sync()
+ expect((await catalog.lists()).top).toHaveLength(2)
+ })
+
+ it('without a Sources URL or a token there is no sync and the lists are empty', async () => {
+ const db = await openTestDb()
+ const catalog = createSteamCatalog({ db, sourcesUrl: null, tokens: null })
+ expect(catalog.enabled).toBe(false)
+ expect(await catalog.sync()).toBeNull()
+ expect((await catalog.lists()).top).toEqual([])
+ })
+
+ it('the page ranks top sellers, shows a deal with its old price, escapes names and links every game to its store page', () => {
+ const shell = { page: (o: Record<string, unknown>) => `${String(o.title)}\n${String(o.body)}` }
+ const g = (appid: number, name: string, extra: Record<string, unknown> = {}) => ({ ...toGame(item(appid, name, extra), NOW)! })
+ const html = steamPageHtml({ top: [g(730, 'Counter-Strike 2', { final_price: 0, original_price: 0 })], sale: [g(413150, 'Stardew Valley', { final_price: 749, original_price: 1499, discount_percent: 50 })], new: [g(3527290, 'A <New> Game')], observed_at: NOW - 3600_000 }, 'https://openvibe.games', shell, NOW)
+ expect(html).toContain('Steam top sellers, deals and new releases')
+ expect(html).toContain('<span class="rank" aria-label="Number 1">1</span>')
+ // Steam's list repeats games (one id at positions 0 and 1): ranks count the games shown, 1, 2, 3 with no gaps.
+ const gappy = steamPageHtml({ top: [g(1, 'First', { position: 0 }), g(2, 'Second', { position: 2 })], sale: [], new: [], observed_at: NOW }, 'https://openvibe.games', shell, NOW)
+ expect(gappy).toContain('<span class="rank" aria-label="Number 2">2</span>')
+ expect(gappy).not.toContain('aria-label="Number 3"')
+ expect(html).toContain('<span>Free</span>')
+ expect(html).toMatch(/<span class="off">−50%<\/span><span>\$7\.49<\/span><span class="was">\$14\.99<\/span>/)
+ expect(html).toContain('A <New> Game')
+ expect(html).toContain('href="https://store.steampowered.com/app/413150/"')
+ expect(html).toContain('checked 1 hour ago')
+ expect(html).not.toMatch(/coming soon|not yet/i)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,184 @@
@@ -0,0 +1,184 @@
+import type { Logger } from '@openvibe/shared'
+import type { Db } from 'openvibe-sdk/db'
+
+/**
+ * The Steam section (the owner's valve.openvibe.games): Steam's featured lists, as OpenVibe.Sources reads them from
+ * store.steampowered.com/api/featuredcategories every 3 hours (category games, contracts 0.205.0). One source per list;
+ * every sync reads each source whole and replaces the list's rows (migration 0012) with the games its source saw in the
+ * last WINDOW_MS, in the store's order. Sources only bumps an item's change position when the item changes (an unchanged
+ * game re-seen only moves its retrieved_at), so a change cursor could not tell which games are still on a list.
+ *
+ * sync() read the three sources (the games principal's token for openvibe.sources, grant sources.item.read)
+ * lists() { top, sale, new, observed_at }: what /steam shows
+ */
+export const STEAM_LISTS = Object.freeze({
+ 'steam-top-sellers': 'top',
+ 'steam-specials': 'sale',
+ 'steam-new-releases': 'new',
+} as const)
+export type SteamListName = (typeof STEAM_LISTS)[keyof typeof STEAM_LISTS]
+/** Two missed 3-hour fetches drop a game from its list; one does not. */
+export const WINDOW_MS = 7 * 3600 * 1000
+const PAGE = 500
+const MAX_PAGES = 20
+
+export interface SteamGame {
+ appid: number
+ position: number
+ name: string
+ final_price: number | null
+ original_price: number | null
+ discount_percent: number
+ currency: string | null
+ header_image: string | null
+ windows: boolean
+ mac: boolean
+ linux: boolean
+ discount_ends_at: number | null
+ observed_at: number
+}
+
+export interface SourcesItem {
+ id?: string
+ identity?: string
+ kind?: string
+ title?: string | null
+ removed?: unknown
+ fields?: Record<string, unknown>
+ provenance?: { retrieved_at?: string }
+}
+
+const int = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? Math.trunc(v) : null)
+const text = (v: unknown, max: number): string | null =>
+ typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null
+/** Only Steam's own image CDN: a header image is shown straight from it. */
+const STEAM_IMAGE = /^https:\/\/(?:shared\.)?(?:akamai|cloudflare|fastly)\.steamstatic\.com\/[^\s"'<>]+$|^https:\/\/cdn\.(?:akamai|cloudflare)\.steamstatic\.com\/[^\s"'<>]+$/
+
+/** One Sources item → a list row, or null when it is not a usable game (or not seen recently). */
+export function toGame(item: SourcesItem, now: number): SteamGame | null {
+ if (!item || item.removed) return null
+ const f = item.fields ?? {}
+ const appid = int(f.appid) ?? (item.identity && /^\d{1,10}$/.test(item.identity) ? Number(item.identity) : null)
+ const name = text(item.title, 200)
+ const seen = Date.parse(item.provenance?.retrieved_at ?? '')
+ if (!appid || appid <= 0 || !name || !Number.isFinite(seen) || now - seen > WINDOW_MS) return null
+ const image = text(f.header_image, 500)
+ const ends = int(f.discount_expiration)
+ return {
+ appid,
+ position: int(f.position) ?? 999,
+ name,
+ final_price: int(f.final_price),
+ original_price: int(f.original_price),
+ discount_percent: Math.max(0, Math.min(100, int(f.discount_percent) ?? 0)),
+ currency: text(f.currency, 3),
+ header_image: image && STEAM_IMAGE.test(image) ? image : null,
+ windows: f.windows === true,
+ mac: f.mac === true,
+ linux: f.linux === true,
+ discount_ends_at: ends && ends > 0 ? ends * 1000 : null,
+ observed_at: seen,
+ }
+}
+
+export interface SteamCatalogOptions {
+ db: Db
+ /** Base of OpenVibe.Sources (host-internal), or null: no sync, empty lists. */
+ sourcesUrl: string | null
+ tokens: { getToken(ctx?: { audience?: string }): Promise<string> } | null
+ fetch?: typeof fetch
+ now?: () => number
+ log?: Pick<Logger, 'info' | 'warn'>
+}
+
+export function createSteamCatalog(opts: SteamCatalogOptions) {
+ const now = opts.now ?? Date.now
+ const doFetch = opts.fetch ?? fetch
+ const enabled = !!(opts.sourcesUrl && opts.tokens)
+
+ async function readSource(key: string): Promise<SourcesItem[]> {
+ const token = await opts.tokens!.getToken({ audience: 'openvibe.sources' })
+ const out: SourcesItem[] = []
+ let after = 0
+ for (let page = 0; page < MAX_PAGES; page++) {
+ const url = `${opts.sourcesUrl}/api/v1/items?source=${encodeURIComponent(key)}&category=games&after=${after}&limit=${PAGE}`
+ const res = await doFetch(url, { headers: { authorization: `Bearer ${token}`, accept: 'application/json' }, signal: AbortSignal.timeout(15_000) })
+ if (!res.ok) throw new Error(`Sources answered ${res.status} for ${key}`)
+ const body = (await res.json()) as { items?: SourcesItem[]; next_after?: number; more?: boolean }
+ const items = Array.isArray(body.items) ? body.items : []
+ out.push(...items)
+ if (!body.more || typeof body.next_after !== 'number' || body.next_after <= after) break
+ after = body.next_after
+ }
+ return out
+ }
+
+ /** One list's rows, replaced whole in one transaction (an empty read leaves the list as it was: Sources may be down). */
+ async function replace(list: SteamListName, games: SteamGame[]): Promise<void> {
+ await opts.db.tx(async (t) => {
+ await t.exec('DELETE FROM steam_list_entries WHERE list = $1', [list])
+ for (const g of games) {
+ await t.exec(
+ `INSERT INTO steam_list_entries (list, appid, position, name, final_price, original_price, discount_percent, currency,
+ header_image, windows, mac, linux, discount_ends_at, observed_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
+ [list, g.appid, g.position, g.name, g.final_price, g.original_price, g.discount_percent, g.currency, g.header_image, g.windows, g.mac, g.linux, g.discount_ends_at, g.observed_at],
+ )
+ }
+ })
+ }
+
+ async function sync(): Promise<Record<string, number> | null> {
+ if (!enabled) return null
+ const counts: Record<string, number> = {}
+ for (const [key, list] of Object.entries(STEAM_LISTS)) {
+ const at = now()
+ const byApp = new Map<number, SteamGame>()
+ for (const item of await readSource(key)) {
+ const g = toGame(item, at)
+ if (g && (!byApp.has(g.appid) || byApp.get(g.appid)!.observed_at < g.observed_at)) byApp.set(g.appid, g)
+ }
+ const games = [...byApp.values()].sort((a, b) => a.position - b.position || a.name.localeCompare(b.name))
+ if (games.length) await replace(list, games)
+ counts[list] = games.length
+ }
+ return counts
+ }
+
+ async function lists(): Promise<{ top: SteamGame[]; sale: SteamGame[]; new: SteamGame[]; observed_at: number | null }> {
+ const rows = await opts.db.many<SteamGame & { list: SteamListName }>(
+ `SELECT list, appid, position, name, final_price, original_price, discount_percent, currency, header_image, windows, mac,
+ linux, discount_ends_at::float8 AS discount_ends_at, observed_at::float8 AS observed_at
+ FROM steam_list_entries ORDER BY list, position, name`,
+ )
+ const out = { top: [] as SteamGame[], sale: [] as SteamGame[], new: [] as SteamGame[], observed_at: null as number | null }
+ for (const { list, ...g } of rows) {
+ out[list]?.push(g)
+ if (!out.observed_at || g.observed_at > out.observed_at) out.observed_at = g.observed_at
+ }
+ return out
+ }
+
+ let timer: NodeJS.Timeout | null = null
+ return {
+ enabled,
+ sync,
+ lists,
+ /** Sync now and every `everyMs` (unref'd); failures are logged and retried at the next tick. */
+ start(everyMs = 3600_000): void {
+ if (!enabled || timer) return
+ const run = () =>
+ void sync()
+ .then((c) => opts.log?.info('steam lists synced', { top: c?.top ?? 0, sale: c?.sale ?? 0, new: c?.new ?? 0 }))
+ .catch((err: unknown) => opts.log?.warn('steam lists sync failed', { error: String(err) }))
+ run()
+ timer = setInterval(run, everyMs)
+ timer.unref()
+ },
+ stop(): void {
+ if (timer) clearInterval(timer)
+ timer = null
+ },
+ }
+}
+export type SteamCatalog = ReturnType<typeof createSteamCatalog>
new file mode 100644
new file mode 100644
@@ -0,0 +1,168 @@
@@ -0,0 +1,168 @@
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import { createRequire } from 'node:module'
+import { escapeHtml } from '../net/notFound.js'
+import type { SteamCatalog, SteamGame } from './steamCatalog.js'
+
+/**
+ * GET /steam: Steam's featured lists (top sellers, deals, new releases) as the store showed them at the last read, each
+ * game linked to its store page; GET /api/steam: the same as JSON, for anyone's code. Prices are Steam's (US dollars),
+ * never converted; nothing is sold here and no endorsement by Valve is implied.
+ */
+interface Shell {
+ page(o: Record<string, unknown>): string
+}
+
+const SITE_NAME = 'OpenVibe.Games'
+const NETWORK_URL = 'https://openvibe.network'
+
+const STYLE = `<style>
+.steam{max-width:1180px;margin:0 auto;padding:28px 16px 64px}
+.steam .eyebrow{margin:0 0 6px;font-size:12px;font-weight:800;letter-spacing:.14em;color:var(--secondary,#22d3ee)}
+.steam h1{margin:0 0 8px;font-size:clamp(28px,5vw,40px);font-weight:900}
+.steam .lede{margin:0 0 6px;max-width:68ch;color:var(--text-dim,#96a7c2)}
+.steam .checked{margin:0 0 26px;font-size:14px;color:var(--text-faint,#7386a3)}
+.steam section{margin:0 0 34px}
+.steam h2{display:flex;align-items:baseline;gap:10px;margin:0 0 14px;font-size:22px;font-weight:850}
+.steam h2 small{font-size:13px;font-weight:600;color:var(--text-faint,#7386a3)}
+.steam .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(250px,100%),1fr));gap:14px;margin:0;padding:0;list-style:none}
+.steam .game{position:relative;display:flex;flex-direction:column;border:1px solid var(--border,#1f2d47);border-radius:14px;
+ overflow:hidden;background:var(--bg-card,#0f1729);transition:border-color .15s,transform .15s}
+.steam .game:hover{border-color:var(--accent,#3b82f6);transform:translateY(-2px)}
+.steam .game a{color:inherit;text-decoration:none}
+.steam .game a::after{content:'';position:absolute;inset:0}
+.steam .art{aspect-ratio:460/215;background:var(--bg-tertiary,#16213a);display:block;width:100%;object-fit:cover}
+.steam .rank{position:absolute;top:8px;left:8px;min-width:30px;height:30px;padding:0 8px;border-radius:9px;display:grid;
+ place-items:center;background:rgba(5,10,20,.82);font-weight:900;font-size:14px}
+.steam .body{display:flex;flex-direction:column;gap:6px;padding:12px 13px 13px;flex:1}
+.steam .name{margin:0;font-size:16px;font-weight:800;line-height:1.25;overflow-wrap:anywhere}
+.steam .price{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:auto;font-weight:800}
+.steam .off{padding:2px 7px;border-radius:7px;background:#16a34a;color:#fff;font-size:13px}
+.steam .was{color:var(--text-faint,#7386a3);font-weight:600;text-decoration:line-through}
+.steam .meta{display:flex;flex-wrap:wrap;gap:6px;font-size:12px;color:var(--text-faint,#7386a3)}
+.steam .meta span{padding:1px 7px;border:1px solid var(--border,#1f2d47);border-radius:999px}
+.steam .empty{padding:18px;border:1px dashed var(--border,#1f2d47);border-radius:14px;color:var(--text-dim,#96a7c2)}
+.steam .about{margin-top:10px;font-size:14px;color:var(--text-faint,#7386a3);max-width:80ch}
+.steam .about a{color:var(--accent-light,#93c5fd)}
+</style>`
+
+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)
+ return `${h} hour${h === 1 ? '' : 's'} ago`
+}
+
+function endsIn(ms: number | null, now: number): string {
+ if (!ms || ms <= now) return ''
+ const days = Math.floor((ms - now) / 86400000)
+ if (days >= 1) return `Ends in ${days} day${days === 1 ? '' : 's'}`
+ const h = Math.max(1, Math.round((ms - now) / 3600000))
+ return `Ends in ${h} hour${h === 1 ? '' : 's'}`
+}
+
+function card(g: SteamGame, now: number, rank: number | null): string {
+ const store = `https://store.steampowered.com/app/${g.appid}/`
+ const price =
+ g.discount_percent > 0 && g.original_price != null
+ ? `<span class="off">−${g.discount_percent}%</span><span>${escapeHtml(money(g.final_price, g.currency))}</span><span class="was">${escapeHtml(money(g.original_price, g.currency))}</span>`
+ : `<span>${escapeHtml(money(g.final_price, g.currency)) || 'See the store'}</span>`
+ const platforms = [g.windows && 'Windows', g.mac && 'macOS', g.linux && 'Linux'].filter(Boolean) as string[]
+ const ends = endsIn(g.discount_ends_at, now)
+ return `<li class="game">
+ ${g.header_image ? `<img class="art" src="${escapeHtml(g.header_image)}" alt="" loading="lazy" decoding="async" width="460" height="215" referrerpolicy="no-referrer">` : '<span class="art" aria-hidden="true"></span>'}
+ ${rank ? `<span class="rank" aria-label="Number ${rank}">${rank}</span>` : ''}
+ <div class="body">
+ <h3 class="name"><a href="${store}" rel="noopener" target="_blank">${escapeHtml(g.name)}</a></h3>
+ <p class="price">${price}</p>
+ <p class="meta">${platforms.map((p) => `<span>${p}</span>`).join('')}${ends ? `<span>${escapeHtml(ends)}</span>` : ''}</p>
+ </div>
+</li>`
+}
+
+function section(id: string, title: string, note: string, games: SteamGame[], now: number, ranked = false): string {
+ return `<section aria-labelledby="${id}"><h2 id="${id}">${title} <small>${note}</small></h2>
+${games.length ? `<ol class="grid">${games.map((g, i) => card(g, now, ranked ? i + 1 : null)).join('')}</ol>` : '<p class="empty">Steam\'s list could not be read just now.</p>'}
+</section>`
+}
+
+export function steamPageHtml(
+ data: Awaited<ReturnType<SteamCatalog['lists']>>,
+ siteUrl: string,
+ shell: Shell,
+ now = Date.now(),
+): string {
+ const links = [
+ { label: 'Steam', href: '/steam' },
+ { label: 'Creator worlds', href: '/worlds' },
+ { label: 'Play Scraplandia', href: '/play' },
+ ]
+ const checked = data.observed_at ? `From the Steam store, checked ${ago(data.observed_at, now)}.` : ''
+ return shell.page({
+ name: SITE_NAME,
+ service: 'games',
+ lang: 'en',
+ title: `Steam top sellers, deals and new releases · ${SITE_NAME}`,
+ siteName: SITE_NAME,
+ description: "What sells on Steam right now, what's on sale and what just came out, with Steam's prices and a link to every store page.",
+ canonical: `${siteUrl}/steam`,
+ robots: 'index, follow',
+ head: `<link rel="icon" type="image/svg+xml" href="/favicon.svg" />\n${STYLE}`,
+ navbar: { service: 'games', apiBase: NETWORK_URL, links, history: { type: 'page', title: 'Steam' } },
+ footer: { service: 'games', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' },
+ home: '/',
+ navLinks: links,
+ body: `<div id="navbar-mount"></div>
+<main id="main" class="steam">
+ <p class="eyebrow">STEAM</p>
+ <h1>On Steam right now</h1>
+ <p class="lede">Top sellers, deals and new releases as the Steam store lists them, with Steam's prices in US dollars. Every game opens its store page.</p>
+ ${checked ? `<p class="checked">${escapeHtml(checked)}</p>` : ''}
+ ${section('steam-top', 'Top sellers', "the store's order", data.top, now, true)}
+ ${section('steam-sale', 'Deals', 'discounts Steam features', data.sale, now)}
+ ${section('steam-new', 'New releases', 'just out', data.new, now)}
+ <p class="about">Read from the Steam store's public storefront lists every few hours through OpenVibe.Sources
+ and shown as they were at that time; prices change on Steam first. Game names and images belong to Valve and the games'
+ publishers. OpenVibe sells nothing here and is not affiliated with Valve. The same lists as JSON: <a href="/api/steam">/api/steam</a>.</p>
+</main>`,
+ })
+}
+
+export function createSteamRoutes(opts: { catalog: SteamCatalog; siteUrl: string; require?: NodeRequire }) {
+ const shell = (opts.require ?? createRequire(import.meta.url))('openvibe-shared/shell') as Shell
+ const siteUrl = opts.siteUrl.replace(/\/+$/, '')
+ return (req: IncomingMessage, res: ServerResponse): boolean => {
+ const path = (req.url ?? '/').split('?')[0] ?? '/'
+ if (path !== '/steam' && path !== '/api/steam') return false
+ if (req.method !== 'GET' && req.method !== 'HEAD') return false
+ void (async () => {
+ const data = await opts.catalog.lists()
+ const json = path === '/api/steam'
+ const body = json
+ ? JSON.stringify({ source: 'store.steampowered.com (featured lists)', checked_at: data.observed_at ? new Date(data.observed_at).toISOString() : null, top_sellers: data.top, deals: data.sale, new_releases: data.new })
+ : steamPageHtml(data, siteUrl, shell)
+ res.writeHead(200, {
+ 'content-type': json ? 'application/json; charset=utf-8' : 'text/html; charset=utf-8',
+ 'cache-control': 'public, max-age=300',
+ ...(json ? { 'access-control-allow-origin': '*' } : {}),
+ 'content-length': String(Buffer.byteLength(body)),
+ })
+ res.end(req.method === 'HEAD' ? undefined : body)
+ })().catch(() => {
+ if (res.headersSent) return
+ res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
+ res.end('steam lists unavailable')
+ })
+ return true
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+-- phase: expand
+-- The Steam section (/steam): Steam's featured lists as OpenVibe.Sources last saw them (category games, contracts
+-- 0.205.0; sources steam-top-sellers, steam-new-releases, steam-specials). Each sync replaces a list's rows with the
+-- games its source saw in the last 7 hours, in the store's order; prices are in the currency's minor unit (cents).
+CREATE TABLE steam_list_entries (
+ list text COLLATE "C" NOT NULL, -- top | new | sale
+ appid integer NOT NULL,
+ position integer NOT NULL, -- 0-based, the store's order
+ name text NOT NULL,
+ final_price integer, -- null when the store states none
+ original_price integer,
+ discount_percent integer NOT NULL DEFAULT 0,
+ currency text COLLATE "C",
+ header_image text,
+ windows boolean NOT NULL DEFAULT false,
+ mac boolean NOT NULL DEFAULT false,
+ linux boolean NOT NULL DEFAULT false,
+ discount_ends_at bigint, -- epoch ms
+ observed_at bigint NOT NULL, -- epoch ms: when Sources last saw it in the list
+ PRIMARY KEY (list, appid)
+);
@@ -39,6 +39,7 @@ const TABLES = [
@@ -39,6 +39,7 @@ const TABLES = [
'account_data_events',
'account_data_events',
'event_outbox',
'event_outbox',
'token_revocations',
'token_revocations',
+ 'steam_list_entries',
].join(', ')
].join(', ')
let shared: Db | null = null
let shared: Db | null = null
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/server/src/steam/steamCatalog.test.ts'
+// covers: 'apps/server/src/steam'
+// Generated by node test/run.js --index; do not edit.