openvibers/openvibe.games Commits

Steam section: top sellers, deals and new releases (#103)

OP OpenVibers committed 26 days ago · 08fe4e0a55 · 10 files, +530 −0

* Steam section (/steam, /api/steam; the owner's valve.openvibe.games): Steam's featured lists (top sellers, deals, new releases) from OpenVibe.Sources (category games), synced hourly into migration 0012 with the store's order and prices, each game linked to its store page; a card on the home

* test/ pointer for steam/steamCatalog.test.ts (node test/run.js --index)

* Steam page: top sellers numbered by the games shown (Steam's list repeats ids, so stored positions have gaps)

Changes

ddiff --git a/apps/client/index.html b/apps/client/index.html
iindex 753c107..578388a 100644
---- a/apps/client/index.html
++++ b/apps/client/index.html
@@@ -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">
ddiff --git a/apps/server/src/config.ts b/apps/server/src/config.ts
iindex 9265da9..a6ef365 100644
---- a/apps/server/src/config.ts
++++ b/apps/server/src/config.ts
@@@ -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 ?? '')
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 45c50fb..7331d44 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -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 true
if (serveWorlds(req, res)) return true
if (serveWorldPage(req, res)) return true
++ if (serveSteam(req, res)) return true
if (
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()
ddiff --git a/apps/server/src/net/httpServer.ts b/apps/server/src/net/httpServer.ts
iindex 2e1a1af..51f6af4 100644
---- a/apps/server/src/net/httpServer.ts
++++ b/apps/server/src/net/httpServer.ts
@@@ -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[] = [
ddiff --git a/apps/server/src/steam/steamCatalog.test.ts b/apps/server/src/steam/steamCatalog.test.ts
nnew file mode 100644
iindex 0000000..a18dcc5
---- /dev/null
++++ b/apps/server/src/steam/steamCatalog.test.ts
@@@ -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 &lt;New&gt; 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)
++ })
++})
ddiff --git a/apps/server/src/steam/steamCatalog.ts b/apps/server/src/steam/steamCatalog.ts
nnew file mode 100644
iindex 0000000..7c9b0d7
---- /dev/null
++++ b/apps/server/src/steam/steamCatalog.ts
@@@ -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>
ddiff --git a/apps/server/src/steam/steamPage.ts b/apps/server/src/steam/steamPage.ts
nnew file mode 100644
iindex 0000000..6580c0b
---- /dev/null
++++ b/apps/server/src/steam/steamPage.ts
@@@ -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
++ }
++}
ddiff --git a/packages/persistence/migrations/0012_steam_lists.sql b/packages/persistence/migrations/0012_steam_lists.sql
nnew file mode 100644
iindex 0000000..36cc832
---- /dev/null
++++ b/packages/persistence/migrations/0012_steam_lists.sql
@@@ -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)
++);
ddiff --git a/packages/persistence/src/testing.ts b/packages/persistence/src/testing.ts
iindex d59a0e0..634f3c1 100644
---- a/packages/persistence/src/testing.ts
++++ b/packages/persistence/src/testing.ts
@@@ -39,6 +39,7 @@ const TABLES = [
'account_data_events',
'event_outbox',
'token_revocations',
++ 'steam_list_entries',
].join(', ')
let shared: Db | null = null
ddiff --git a/test/apps__server__src__steam__steamCatalog.test.js b/test/apps__server__src__steam__steamCatalog.test.js
nnew file mode 100644
iindex 0000000..a6ab752
---- /dev/null
++++ b/test/apps__server__src__steam__steamCatalog.test.js
@@@ -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.