openvibers/openvibe.games Commits
Games: the result of a round, and the board a player stands on (#220)
Changes
@@@ -906,7 +906,7 @@<p class="hero-title">Open<b>Vibe.Games</b></p><h1 class="hero-h1">Free games that start in a browser tab</h1><p class="hero-sub">-- Build and survive with other players, race for scrap in 90-second rounds, grow a team of++ Build and survive with other players, race for scrap in timed rounds, grow a team ofheroes on your phone or join a world another player built. Nothing to install: play as aguest, or sign in to keep your character across OpenVibe.</p>@@@ -1137,12 +1137,12 @@<span class="badge live">● Live now</span><h3>Salvage Rush</h3><p>-- Collect scrap around a compact arena in 90-second on-foot rounds. The server scores each++ Collect scrap around a compact arena in timed on-foot rounds. The server scores eachpickup; rounds have a public leaderboard and three achievements that give items wornacross OpenVibe.</p><div class="tags">-- <span>timed rounds</span><span>on-foot</span><span>leaderboard</span>++ <!--ov:card-salvage-rush-round-tag--><span>on-foot</span><span>leaderboard</span></div><div class="cta-row"><a class="play-cta" href="/play?game=salvage-rush">▶ Play a round</a>@@@ -8,19 +8,23 @@name="description"content="OpenVibe.Games leaderboards: Salvage Rush best rounds and Scraplandia haul points, all time and this week. Scores come only from what the server played out."/>++ <!-- The reader's theme, before the first paint: the page's own light rules follow it. -->++ <script src="/shared/theme-loader.js"></script><style>++ /* The shared tokens (theme-loader.js): the page wears the reader's theme, dark or light. The++ values behind them are the page's own dark ones, so a reader without a theme sees the same. */:root {-- color-scheme: dark;-- --accent: #3b82f6;-- --accent-light: #60a5fa;++ color-scheme: var(--color-scheme, dark);++ --page-accent: var(--accent, #3b82f6);++ --page-accent-light: var(--accent-light, #60a5fa);--secondary: #22d3ee;-- --bg: #0a0f1c;-- --bg-raised: #101828;-- --bg-card: rgba(19, 28, 46, 0.88);-- --border: #1f2d47;-- --text: #e6edf7;-- --text-dim: #96a7c2;-- --text-faint: #7386a3;++ --bg: var(--bg-primary, #0a0f1c);++ --bg-raised: var(--bg-secondary, #101828);++ --bg-card: var(--bg-tertiary, rgba(19, 28, 46, 0.88));++ --page-border: var(--border, #1f2d47);++ --text: var(--text-primary, #e6edf7);++ --text-dim: var(--text-secondary, #96a7c2);++ --text-faint: var(--text-muted, #7386a3);}/* Light themes (theme-loader sets data-theme-mode): the cyan is text on tags and achievements, and #22d3ee on a lightpage read at 1.7:1; this deeper cyan reads at 5:1 (axe, 2026-10-22). */@@@ -42,7 +46,7 @@sans-serif;}a {-- color: var(--accent-light);++ color: var(--page-accent-light);}main {max-width: 760px;@@@ -54,6 +58,31 @@gap: 14px;font-size: 14px;}++ /* The viewer's own row: the same accent the page already uses, no colour of its own. */++ .you-line {++ margin: 10px 0 0;++ padding: 8px 12px;++ border: 1px solid var(--page-border);++ border-radius: 10px;++ background: var(--bg-card);++ color: var(--text);++ font-size: 14px;++ font-weight: 600;++ }++ tr.is-you {++ background: var(--bg-raised);++ }++ .you {++ margin-left: 8px;++ padding: 1px 7px;++ border: 1px solid var(--page-accent);++ border-radius: 999px;++ color: var(--page-accent-light);++ font-size: 11px;++ font-weight: 800;++ letter-spacing: 0.08em;++ text-transform: uppercase;++ }.eyebrow {margin: 28px 0 4px;font-size: 12px;@@@ -72,7 +101,7 @@color: var(--text-dim);}.board {-- border: 1px solid var(--border);++ border: 1px solid var(--page-border);border-radius: 18px;background: var(--bg-card);overflow: hidden;@@@ -81,12 +110,12 @@display: flex;gap: 6px;padding: 12px;-- border-bottom: 1px solid var(--border);++ border-bottom: 1px solid var(--page-border);}.tabs button {flex: 0 0 auto;padding: 8px 16px;-- border: 1px solid var(--border);++ border: 1px solid var(--page-border);border-radius: 999px;background: transparent;color: var(--text-dim);@@@ -95,13 +124,13 @@cursor: pointer;}.tabs button[aria-pressed='true'] {-- border-color: var(--accent);++ border-color: var(--page-accent);background: rgba(59, 130, 246, 0.18);color: var(--text);}.tabs button:focus-visible,.pager button:focus-visible {-- outline: 2px solid var(--accent-light);++ outline: 2px solid var(--page-accent-light);outline-offset: 2px;}table {@@@ -113,7 +142,7 @@td {padding: 12px 16px;text-align: left;-- border-bottom: 1px solid var(--border);++ border-bottom: 1px solid var(--page-border);}th {font-size: 12px;@@@ -139,7 +168,7 @@height: 30px;padding: 0 6px;border-radius: 999px;-- border: 1px solid var(--border);++ border: 1px solid var(--page-border);font-weight: 800;font-size: 14px;}@@@ -176,13 +205,13 @@justify-content: space-between;gap: 12px;padding: 12px 16px;-- border-top: 1px solid var(--border);++ border-top: 1px solid var(--page-border);color: var(--text-faint);font-size: 14px;}.pager button {padding: 7px 14px;-- border: 1px solid var(--border);++ border: 1px solid var(--page-border);border-radius: 10px;background: var(--bg-raised);color: var(--text);@@@ -198,7 +227,7 @@margin-top: 22px;padding: 13px 28px;border-radius: 12px;-- background: linear-gradient(120deg, var(--accent), #2563eb);++ background: linear-gradient(120deg, var(--page-accent), #2563eb);color: #fff;font-weight: 800;letter-spacing: 0.03em;@@@ -221,9 +250,9 @@<p class="eyebrow">LEADERBOARD</p><h1 id="title">Salvage Rush</h1><p class="lede" id="lede">-- Each player's best round score. Rounds last 90 seconds; every point comes from a pickup the-- server counted.++ Each player's best round score. Every point comes from a pickup the server counted.</p>++ <p class="you-line" id="you" hidden></p><section class="board" aria-labelledby="board-title"><h2 id="board-title" hidden>Best scores</h2><div class="tabs" role="group" aria-label="Game">@@@ -91,8 +91,8 @@board.</p><p>-- <strong>Salvage Rush</strong> is its quick cousin: collect scrap around a compact arena in 90-second rounds,-- scored by the server, with a public leaderboard.++ <strong>Salvage Rush</strong> is its quick cousin: collect scrap around a compact arena in timed++ on-foot rounds, scored by the server, with a public leaderboard.</p><p>Both run in your browser with no download, as a guest or with your OpenVibe account. They need JavaScript and@@@ -10,6 +10,7 @@ import {dayKey,} from '@openvibe/casual'import { enemyArt, icon } from '../art.js'++import { GAME_PROMISE } from '../../ui/gamePromise.js'import { button, duration, h, num, sheet, tick, toast } from '../dom.js'import type { App } from '../app.js'import { playBattle } from './battle.js'@@@ -316,4 +317,6 @@ function stageList(app: App): void {}),list,)++ // The one line every game page carries: what a player gets, and what they never have to do.++ s.body.append(h('p', { class: 'muted promise-line', text: GAME_PROMISE }))}@@@ -783,6 +783,7 @@ async function start(): Promise<void> {let rejected = falseconnection.onMessage = (msg) => {if (msg.t === 'salvage_round') roundUi?.update(msg)++ if (msg.t === 'salvage_result') roundUi?.showResult(msg)if (msg.t === 'surf_state') surfUi?.updateState(msg)if (msg.t === 'surf_finish') {surfUi?.showFinish(msg)@@@ -1,3 +1,5 @@++import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'++export {}interface Entry {@@@ -6,6 +8,13 @@ interface Entry {score: number}++/** The viewer's own best for the game on the page, and where it sits on the board we keep. */++interface Mine {++ best: number | null++ rank: number | null++ name: string | null++}++type Game = 'salvage-rush' | 'scraplandia'const gameTabs = Array.from(document.querySelectorAll<HTMLButtonElement>('.tabs button[data-game]'))@@@ -21,6 +30,7 @@ const title = document.getElementById('title') as HTMLElementconst lede = document.getElementById('lede') as HTMLElementconst scoreHeader = document.getElementById('score-header') as HTMLElementconst playLink = document.getElementById('play-link') as HTMLAnchorElement++const youLine = document.getElementById('you') as HTMLElementconst COPY: Record<Game,@@@ -35,7 +45,7 @@ const COPY: Record<> = {'salvage-rush': {title: 'Salvage Rush',-- lede: "Each player's best round score. Rounds last 90 seconds; every point comes from a pickup the server counted.",++ lede: `Each player's best round score. Rounds last ${SALVAGE_RUSH_PACK.game?.roundSeconds ?? 90} seconds; every point comes from a pickup the server counted.`,score: 'Best score',play: '/play?game=salvage-rush',playLabel: '▶ Play a round',@@@ -87,6 +97,31 @@ function applyGame(): void {document.title = `${copy.title} leaderboard · OpenVibe.Games`}++/**++ * The viewer's own best for this game, read from the rows we keep. The board itself comes from++ * OpenVibe.Play, so the row that is the viewer's is the one with their name and their best score.++ */++async function loadMine(): Promise<Mine | null> {++ try {++ const response = await fetch(`/api/v1/games/${game}/me`)++ if (!response.ok) return null++ const data = (await response.json()) as Mine++ return data++ } catch {++ return null++ }++}++++function markMine(mine: Mine | null): void {++ if (!mine || mine.best === null || mine.rank === null) {++ youLine.hidden = true++ youLine.textContent = ''++ return++ }++ youLine.hidden = false++ youLine.textContent = `Your best: ${mine.best.toLocaleString('en-US')} — rank ${mine.rank.toLocaleString('en-US')} on this board.`++}++async function load(): Promise<void> {for (const tab of periodTabs)tab.setAttribute('aria-pressed', String(tab.dataset.period === period))@@@ -100,12 +135,27 @@ async function load(): Promise<void> {const response = await fetch(`/api/v1/games/${game}/leaderboard?period=${period}&page=${page}`)if (!response.ok) throw new Error('unavailable')const data = (await response.json()) as { entries: Entry[]; pages: number }++ const mine = await loadMine()++ markMine(mine)for (const entry of data.entries) {const row = document.createElement('tr')if (entry.rank <= 3) row.className = `top-${entry.rank}`++ const isMine =++ mine !== null &&++ mine.best !== null &&++ entry.score === mine.best &&++ (mine.name === null || entry.name === mine.name)++ if (isMine) row.classList.add('is-you')++ const nameCell = cell(entry.name, 'name')++ if (isMine) {++ const tag = document.createElement('span')++ tag.className = 'you'++ tag.textContent = 'you'++ nameCell.append(tag)++ }row.append(cell(String(entry.rank), 'rank'),-- cell(entry.name, 'name'),++ nameCell,cell(entry.score.toLocaleString('en-US')),)entries.append(row)@@@ -1,4 +1,5 @@import { MAX_CHARACTERS, type Appearance } from '@openvibe/protocol'++import { GAME_PROMISE } from './gamePromise.js'/*** Character select: your characters in this game, and a "New character" button. There is no slot count to unlock@@@ -22,7 +23,9 @@ export function characterSelect(overlay.className = 'customize-overlay'const panel = document.createElement('div')panel.className = 'customize-panel'-- panel.innerHTML = '<h1>CHOOSE YOUR SCRAPPER</h1>'++ panel.innerHTML =++ '<h1>CHOOSE YOUR SCRAPPER</h1>' +++ '<p class="char-onesentence">Pick a scrapper and press Play — you are in the world in a moment.</p>'const pick = (slot: number, existing: CharacterInfo | null) => {overlay.remove()resolve({ slot, existing })@@@ -77,6 +80,11 @@ export function characterSelect(})panel.appendChild(btn)}++ // The one line every game page carries: what a player gets, and what they never have to do.++ const promise = document.createElement('p')++ promise.className = 'char-promise'++ promise.textContent = GAME_PROMISE++ panel.appendChild(promise)overlay.appendChild(panel)uiRoot.appendChild(overlay)})nnew file mode 100644@@@ -0,0 +1,8 @@++/**++ * The one line every game page carries: what a player gets, and what they never have to do. The words++ * are a promise about the games themselves, and the test beside them checks the code keeps it. It says++ * only what stays true: a paid store is planned for the casual games (plan T22), and the site counts++ * visits, so "nothing to buy" and "no tracking" are not promises it can keep (2026-10-31 review).++ */++export const GAME_PROMISE =++ 'Play as a guest, no sign-up. No ads inside the games.'nnew file mode 100644@@@ -0,0 +1,94 @@++/*++ * The result card of a finished round (the market outcome, 2026-10-30): the player's own score, their++ * rank and their best, and the button that shares the round's page.++ *++ * Every colour is a shared token (--bg-card, --text-primary, --accent, --border), so the card wears++ * the reader's theme: dark or light, with no colour of its own.++ */++.rush-result {++ position: fixed;++ z-index: 16;++ left: 50%;++ top: 50%;++ transform: translate(-50%, -50%);++ width: min(22rem, calc(100vw - 2rem));++ padding: 1rem 1.1rem 1.1rem;++ border: 1px solid var(--border);++ border-radius: 1rem;++ background: var(--bg-card);++ color: var(--text-primary);++ box-shadow: 0 18px 50px var(--shadow-lg, rgba(0, 0, 0, 0.45));++ font:++ 500 0.95rem/1.35 system-ui,++ sans-serif;++}++.rush-result-kicker {++ margin: 0;++ font-size: 0.72rem;++ font-weight: 800;++ letter-spacing: 0.14em;++ text-transform: uppercase;++ color: var(--accent);++}++.rush-result-score {++ margin: 0.15rem 0 0.6rem;++ font-size: 2.1rem;++ font-weight: 900;++ line-height: 1.1;++ font-variant-numeric: tabular-nums;++}++.rush-result-facts {++ display: grid;++ gap: 0.3rem;++ margin: 0 0 0.9rem;++}++.rush-result-facts > div {++ display: flex;++ align-items: baseline;++ justify-content: space-between;++ gap: 1rem;++ padding-top: 0.3rem;++ border-top: 1px solid var(--border);++}++.rush-result-facts dt {++ color: var(--text-muted);++ font-size: 0.82rem;++}++.rush-result-facts dd {++ margin: 0;++ font-weight: 800;++ font-variant-numeric: tabular-nums;++}++.rush-result-actions {++ display: flex;++ flex-wrap: wrap;++ gap: 0.5rem;++}++.rush-result-actions > * {++ flex: 1 1 8rem;++ min-height: 2.75rem;++ display: inline-flex;++ align-items: center;++ justify-content: center;++ padding: 0 0.9rem;++ border: 1px solid var(--border);++ border-radius: 0.7rem;++ background: var(--bg-secondary);++ color: var(--text-primary);++ font: inherit;++ font-weight: 700;++ text-align: center;++ text-decoration: none;++ cursor: pointer;++}++.rush-result-share {++ background: var(--accent-strong, var(--accent));++ border-color: transparent;++ color: var(--on-accent-strong, var(--on-accent, #fff));++}++.rush-result-board {++ background: transparent;++}++.rush-result-close {++ background: transparent;++}nnew file mode 100644@@@ -0,0 +1,47 @@++// @vitest-environment jsdom++import { describe, expect, it } from 'vitest'++import { buildResultCard, resultPageUrl, resultShareText } from './rushResult.js'++++const result = {++ t: 'salvage_result' as const,++ roundId: '0:1700000000000-12',++ score: 12,++ rank: 2,++ players: 7,++ best: 12,++}++++/**++ * The result card of a finished round: the player's own score, their rank and their best, and the button++ * that shares the round's page. The numbers come from the server's result, never from the client.++ */++describe('the result card of a round', () => {++ it('shows the score, the rank and the best', () => {++ const card = buildResultCard(result, 'https://openvibe.games')++ expect(card.textContent).toContain('12')++ expect(card.textContent).toContain('2 of 7')++ expect(card.textContent).toContain('Best')++ })++++ it('says plainly when there is no best on record', () => {++ const card = buildResultCard({ ...result, best: null }, 'https://openvibe.games')++ expect(card.textContent).toContain('no round on record yet')++ })++++ it('shares a page, not a screenshot', () => {++ expect(resultPageUrl('0:1700000000000-12', 'https://openvibe.games')).toBe(++ 'https://openvibe.games/result/0%3A1700000000000-12',++ )++ expect(resultShareText(result)).toBe(++ 'I scored 12 in Salvage Rush (rank 2 of 7). My best round: 12.',++ )++ })++++ it('carries the buttons a player needs', () => {++ const card = buildResultCard(result, 'https://openvibe.games')++ expect(card.querySelector('.rush-result-share')?.textContent).toBe('Share')++ expect(card.querySelector('.rush-result-board')?.getAttribute('href')).toBe(++ '/leaderboard.html?game=salvage-rush',++ )++ })++})nnew file mode 100644@@@ -0,0 +1,97 @@++/**++ * The result card of a finished Salvage Rush round (the market outcome, 2026-10-30): the player's own++ * score, their rank in that round and their own best, and one button that shares it. The numbers come++ * from the server's round result, never from the client.++ */++import type { ServerMessage } from '@openvibe/protocol'++import './rushResult.css'++++export type RoundResult = Extract<ServerMessage, { t: 'salvage_result' }>++++/** The address of the shareable page of one round. */++export function resultPageUrl(roundId: string, origin: string): string {++ return `${origin.replace(/\/+$/, '')}/result/${encodeURIComponent(roundId)}`++}++++/** The words a player sends: their score, their rank and their best. */++export function resultShareText(result: RoundResult): string {++ const score = new Intl.NumberFormat('en-US').format(result.score)++ const rank = new Intl.NumberFormat('en-US').format(result.rank)++ const players = new Intl.NumberFormat('en-US').format(result.players)++ const best = result.best === null ? null : new Intl.NumberFormat('en-US').format(result.best)++ return [++ `I scored ${score} in Salvage Rush (rank ${rank} of ${players}).`,++ best === null ? null : `My best round: ${best}.`,++ ]++ .filter(Boolean)++ .join(' ')++}++++/**++ * The card, as a DOM tree. The share button shares the round's page (the result screen), so the link++ * that travels is a page, not a screenshot.++ */++export function buildResultCard(result: RoundResult, origin: string): HTMLElement {++ const fmt = new Intl.NumberFormat('en-US')++ const url = resultPageUrl(result.roundId, origin)++ const card = document.createElement('div')++ card.className = 'rush-result'++ card.setAttribute('role', 'dialog')++ card.setAttribute('aria-label', 'Round result')++++ const kicker = document.createElement('p')++ kicker.className = 'rush-result-kicker'++ kicker.textContent = 'Round over'++++ const score = document.createElement('p')++ score.className = 'rush-result-score'++ const scoreValue = document.createElement('b')++ scoreValue.textContent = fmt.format(result.score)++ score.append(scoreValue, document.createTextNode(' scrap'))++++ const facts = document.createElement('dl')++ facts.className = 'rush-result-facts'++ const add = (label: string, value: string): void => {++ const row = document.createElement('div')++ const dt = document.createElement('dt')++ dt.textContent = label++ const dd = document.createElement('dd')++ dd.textContent = value++ row.append(dt, dd)++ facts.append(row)++ }++ add('Rank', `${fmt.format(result.rank)} of ${fmt.format(result.players)}`)++ add('Best', result.best === null ? 'no round on record yet' : fmt.format(result.best))++++ const actions = document.createElement('div')++ actions.className = 'rush-result-actions'++ const share = document.createElement('button')++ share.type = 'button'++ share.className = 'rush-result-share'++ share.textContent = 'Share'++ share.addEventListener('click', () => {++ const text = resultShareText(result)++ if (navigator.share) {++ navigator.share({ title: 'A Salvage Rush round', text, url }).catch(() => undefined)++ return++ }++ void navigator.clipboard?.writeText(`${text} ${url}`).catch(() => undefined)++ share.textContent = 'Copied'++ window.setTimeout(() => {++ share.textContent = 'Share'++ }, 1500)++ })++ const board = document.createElement('a')++ board.className = 'rush-result-board'++ board.href = '/leaderboard.html?game=salvage-rush'++ board.textContent = 'Full board'++ const close = document.createElement('button')++ close.type = 'button'++ close.className = 'rush-result-close'++ close.textContent = 'Next round'++ close.addEventListener('click', () => card.remove())++ actions.append(share, board, close)++++ card.append(kicker, score, facts, actions)++ return card++}@@@ -4,6 +4,7 @@ import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial.js'import { Color3 } from '@babylonjs/core/Maths/math.color.js'import type { Scene } from '@babylonjs/core/scene.js'import type { Mesh } from '@babylonjs/core/Meshes/mesh.js'++import { buildResultCard, type RoundResult } from './rushResult.js'import './salvageRushHud.css'type Round = Extract<ServerMessage, { t: 'salvage_round' }>@@@ -16,6 +17,7 @@ export class SalvageRushHud {private readonly scores: HTMLElementprivate timer: number | null = nullprivate pickups: Mesh[] = []++ private result: HTMLElement | null = nullconstructor(parent: HTMLElement,@@@ -40,6 +42,17 @@ export class SalvageRushHud {parent.append(this.root)}++ /**++ * The round this player finished: their score, their rank and their own best, with a button that++ * shares the round's page. The card sits over the game until it is closed.++ */++ showResult(result: RoundResult): void {++ this.result?.remove()++ const card = buildResultCard(result, window.location.origin)++ this.root.parentElement?.append(card)++ this.result = card++ }++update(round: Round): void {if (this.pickups.length !== round.pickups.length) {for (const mesh of this.pickups) mesh.dispose()@@@ -58,6 +71,11 @@ export class SalvageRushHud {)if (this.timer !== null) window.clearInterval(this.timer)const active = round.phase === 'active'++ if (active && this.result) {++ // A new round starts: the result card has made its point.++ this.result.remove()++ this.result = null++ }this.root.classList.toggle('is-active', active)this.phase.textContent = active ? 'Collect scrap' : 'Next round in'const refresh = () => {@@@ -1197,6 +1197,18 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {font-size: calc(12px * var(--ov-text-scale, 1));}++.char-onesentence {++ margin: 0 0 12px;++ color: var(--text-secondary);++ font-size: calc(14px * var(--ov-text-scale, 1));++ line-height: 1.45;++}++.char-promise {++ margin-top: 14px;++ color: var(--text-muted);++ font-size: calc(12px * var(--ov-text-scale, 1));++ line-height: 1.5;++}.char-upsell {margin-top: 14px;padding: 12px 14px;@@@ -57,6 +57,7 @@ export class GameServer {private lastRushBroadcast = 0private readonly roundFinished?: GameIntegrations['roundFinished']private readonly playerJoined?: GameIntegrations['playerJoined']++ private readonly integrations: GameIntegrationsconstructor(config: ServerConfig,@@@ -72,6 +73,7 @@ export class GameServer {: nullthis.roundFinished = integrations.roundFinishedthis.playerJoined = integrations.playerJoined++ this.integrations = integrations// The world clock and weather survive restarts (a rainy dusk stays a rainy dusk); they are// loaded asynchronously in load(), before the tick loop starts.this.ctx = new ServerContext(@@@ -295,6 +297,45 @@ export class GameServer {* One simulation tick at the room's own rate. This is the ONE place the order is written down;* the systems' internal cadence (1 Hz sweeps, every-N-tick snapshots) is their own.*/++ /**++ * Tell every player what their own round was: the score the server gave them, their rank in that++ * round, how many played it, and their own best round so far. The score and the rank come from the++ * round the server scored; the best is read from the rows we keep (null when they have none).++ */++ private async sendRoundResults(++ ctx: ServerContext,++ roundId: string,++ results: { key: string; name: string; score: number; rank: number }[],++ ): Promise<void> {++ const sessions = ctx.sessions.all()++ const bests = await Promise.all(++ sessions.map(async (session) => {++ const key = session.subjectId ?? session.token++ if (!this.integrations.bestScore) return null++ try {++ return await this.integrations.bestScore(key)++ } catch {++ return null++ }++ }),++ )++ sessions.forEach((session, index) => {++ const key = session.subjectId ?? session.token++ const result = results.find((r) => r.key === key)++ if (!result) return++ const own = bests[index] ?? null++ ctx.net.send(session, {++ t: 'salvage_result',++ roundId,++ score: result.score,++ rank: result.rank,++ players: results.length,++ // The round just finished counts towards the best, even when its row has not landed yet.++ best: own === null ? null : Math.max(own, result.score),++ })++ })++ }++step(): void {const ctx = this.ctxconst s = this.systems@@@ -320,7 +361,11 @@ export class GameServer {pos: [session.move.pos.x, session.move.pos.y, session.move.pos.z],})),)-- if (results) this.roundFinished?.(this.rush.view().roundId, results)++ if (results) {++ const roundId = this.rush.view().roundId++ this.roundFinished?.(roundId, results)++ void this.sendRoundResults(ctx, roundId, results)++ }if (results || now - this.lastRushBroadcast >= 1000) {ctx.net.broadcastAll({ t: 'salvage_round', ...this.rush.view() })this.lastRushBroadcast = now@@@ -102,6 +102,97 @@ export async function recordJoined(db: Db, key: string, now: number): Promise<vo}}++/** One row of a finished round, as the round's own board shows it. */++export interface RoundRow {++ /** The account the row belongs to (hashed, never the person's id). */++ accountKey: string++ playerName: string++ score: number++ rank: number++ finishedAt: number++}++++/**++ * The rows of one finished round, in rank order. These are the rows the server wrote when the round++ * ended, so the board a player sees is the round that really happened.++ */++export async function roundRows(++ db: Db,++ game: GameId,++ roundId: string,++): Promise<RoundRow[]> {++ const rows = await db.many<{++ account_key: string++ player_name: string++ score: number++ rank: number++ finished_at: number++ }>(sql`++ SELECT account_key, player_name, score, rank, finished_at++ FROM game_round_results++ WHERE game_id = ${game} AND round_id = ${roundId}++ ORDER BY rank ASC, score DESC, player_name ASC++ `)++ return rows.map((row) => ({++ accountKey: row.account_key,++ playerName: row.player_name,++ score: Number(row.score) || 0,++ rank: Number(row.rank) || 0,++ finishedAt: Number(row.finished_at) || 0,++ }))++}++++/**++ * The player's own best round for a game, from the row we keep. Null when they have no round on++ * record: not measured yet is said plainly rather than shown as a zero.++ */++export async function playerBestScore(++ db: Db,++ game: GameId,++ key: string,++): Promise<number | null> {++ const account = accountKey(key)++ const rows = await db.many<{ score: number }>(sql`++ SELECT score FROM game_best_scores WHERE game_id = ${game} AND account_key = ${account}++ `)++ const score = Number(rows[0]?.score)++ return Number.isFinite(score) ? score : null++}++++/** The player's own best row for a game: the score and the name the board shows. */++export async function playerBest(++ db: Db,++ game: GameId,++ key: string,++): Promise<{ score: number; name: string } | null> {++ const account = accountKey(key)++ const rows = await db.many<{ score: number; player_name: string }>(sql`++ SELECT score, player_name FROM game_best_scores++ WHERE game_id = ${game} AND account_key = ${account}++ `)++ const row = rows[0]++ if (!row) return null++ const score = Number(row.score)++ if (!Number.isFinite(score)) return null++ return { score, name: String(row.player_name ?? '') }++}++++/**++ * The place a score of this size sits on the board: one more than the number of players with a better++ * best. Counted from the rows we keep, so it is the rank of the same board the page shows.++ */++export async function playerBestRank(++ db: Db,++ game: GameId,++ score: number,++): Promise<number> {++ const rows = await db.many<{ better: number }>(sql`++ SELECT COUNT(*)::bigint AS better FROM game_best_scores++ WHERE game_id = ${game} AND score > ${score}++ `)++ return (Number(rows[0]?.better) || 0) + 1++}++/** The only writer for scores; called by the server's completed-round hook. */export async function recordRound(db: Db,@@@ -76,6 +76,11 @@ export interface GameIntegrations {roundId: string,results: { key: string; name: string; score: number; rank: number }[],) => void++ /**++ * The player's own best score for the game, read from the rows we keep. Null when they have no++ * score on record yet. Used to tell a player their own best after a round, never to rank anyone.++ */++ bestScore?: (key: string) => Promise<number | null>/** Called for a server-confirmed extraction haul (Scraplandia). Never accepts a client message. */haulExtracted?: (key: string, extractionId: string, name: string, score: number) => void/** A server-confirmed milestone past the first haul (Scraplandia): a contract, a harvest, a skill level. */@@@ -83,7 +83,8 @@ import {} from './platform/revocationEvents.js'import { createStaffAuthorizer } from './platform/staffAuth.js'import { createWsTicketStore } from './net/wsTicket.js'--import { GameProgressWriter, type Milestone } from './game/progression.js'++import { GameProgressWriter, playerBestScore, type Milestone } from './game/progression.js'++import { handleResultPage } from './net/resultPage.js'import type { RoundResult } from './game/salvageRush.js'import { handleGameRead } from './net/gameRoutes.js'import { createPlayBoards } from './platform/playBoards.js'@@@ -417,6 +418,7 @@ async function main(): Promise<void> {roundFinished: (roundId: string, results: RoundResult[]) => {progressWriter.submit(`0:${roundId}`, results)},++ bestScore: (key: string) => playerBestScore(db, 'salvage-rush', key),}: {}),...(primarySurf ? { surf: surfRecords } : {}),@@@ -490,6 +492,7 @@ async function main(): Promise<void> {roundFinished: (roundId: string, results: RoundResult[]) => {progressWriter.submit(`${slot}:${roundId}`, results)},++ bestScore: (key: string) => playerBestScore(db, 'salvage-rush', key),}: {}),...(primarySurf ? { surf: surfRecords } : {}),@@@ -591,6 +594,7 @@ async function main(): Promise<void> {: {roundFinished: (roundId: string, results: RoundResult[]) =>progressWriter.submit(`${slot}:${roundId}`, results),++ bestScore: (key: string) => playerBestScore(db, 'salvage-rush', key),}),},)@@@ -1100,6 +1104,16 @@ async function main(): Promise<void> {config.oauth,{handle: (req, res) => {++ // The result page of a Salvage Rush round (/result/<round>): a shareable page with the score,++ // the rank and the player's own best, read from the rows the server wrote for that round.++ if (++ handleResultPage(req, res, {++ db,++ networkAuthUrl: config.networkAuthUrl,++ site: gamesSiteUrl,++ })++ )++ return trueif (req.method === 'GET' && req.url?.split('?')[0] === '/api/v1/content') {const requestedWorld = new URL(req.url ?? '/', 'http://games.local').searchParams.get('world',nnew file mode 100644@@@ -0,0 +1,43 @@++import { describe, expect, it } from 'vitest'++import { readFileSync } from 'node:fs'++import { join } from 'node:path'++import { fileURLToPath } from 'node:url'++import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'++import { applyCardFacts, cardFacts } from './portalHome.js'++++const client = fileURLToPath(new URL('../../../client/', import.meta.url))++++/**++ * The home page's cards: the length of a round is the content pack's own number, so the card cannot++ * drift from the game it describes, and the number is never a sentence someone typed.++ */++describe('the game cards of the home page', () => {++ it('states the length of a Salvage Rush round from the pack', () => {++ const seconds = SALVAGE_RUSH_PACK.game?.roundSeconds++ expect(seconds).toBeGreaterThan(0)++ const facts = cardFacts()++ expect(facts['salvage-rush-round-tag']).toBe(`<span>${seconds}-second rounds</span>`)++ })++++ it('puts the fact where the card asks for it', () => {++ const html = applyCardFacts(++ '<div class="tags"><!--ov:card-salvage-rush-round-tag--><span>on-foot</span></div>',++ )++ expect(html).not.toContain('<!--ov:card-')++ expect(html).toContain(`<span>${SALVAGE_RUSH_PACK.game?.roundSeconds}-second rounds</span>`)++ expect(html).toContain('<span>on-foot</span>')++ })++++ it('writes no round length into the pages by hand', () => {++ // The length of a round is the pack's number; a sentence that carries one by hand would++ // drift from the game the day the pack changes.++ for (const file of ['index.html', 'play.html', 'leaderboard.html']) {++ const html = readFileSync(join(client, file), 'utf8')++ expect(html).not.toMatch(/\d+-second round/)++ }++ })++++ it('leaves a page without the marker alone', () => {++ expect(applyCardFacts('<p>nothing here</p>')).toBe('<p>nothing here</p>')++ })++})@@@ -1,10 +1,8 @@import type { IncomingMessage, ServerResponse } from 'node:http'import type { Db } from 'openvibe-sdk/db'--import { isGuestToken } from '../platform/accounts.js'--import { hashGuestKey } from './guestIdentity.js'--import { resolveNetworkUser } from './networkAuth.js'++import { viewerAccountKey } from './viewer.js'import type { GamesActorLimits } from './actorLimits.js'--import { isGameId, playerAchievements } from '../game/progression.js'++import { isGameId, playerAchievements, playerBest, playerBestRank } from '../game/progression.js'import { BoardUnavailable, type PlayBoards } from '../platform/playBoards.js'interface Options {@@@ -24,8 +22,9 @@ export function handleGameRead(if (req.method !== 'GET') return falseconst url = new URL(req.url ?? '/', 'http://games.local')const match = /^\/api\/v1\/games\/([^/]+)\/leaderboard$/.exec(url.pathname)++ const mine = /^\/api\/v1\/games\/([^/]+)\/me$/.exec(url.pathname)const achievements = url.pathname === '/api/v1/players/me/achievements'-- if (!match && !achievements) return false++ if (!match && !achievements && !mine) return falseconst send = (status: number, body: unknown): void => {res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })res.end(JSON.stringify(body))@@@ -34,6 +33,10 @@ export function handleGameRead(send(404, { error: 'unknown_game' })return true}++ if (mine && !isGameId(mine[1] ?? '')) {++ send(404, { error: 'unknown_game' })++ return true++ }void (async () => {if (!(await options.limits.publicRead(req, res, `ip:${options.address(req)}`))) returnif (match) {@@@ -64,20 +67,32 @@ export function handleGameRead(}return}-- const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1]-- const guest = req.headers['x-guest-token']-- let key: string | null = null-- if (bearer && options.networkAuthUrl) {-- const user = await resolveNetworkUser(options.networkAuthUrl, bearer)-- key = user?.subjectId ?? null-- } else if (typeof guest === 'string' && isGuestToken(guest)) {-- key = `guest:${hashGuestKey(guest)}`++ if (mine) {++ // The viewer's own best for that game, and where it sits on the board we keep.++ const game = mine[1] as 'scraplandia' | 'salvage-rush'++ const key = await viewerAccountKey(req, { networkAuthUrl: options.networkAuthUrl })++ if (!key) {++ send(200, { best: null, rank: null, name: null })++ return++ }++ const best = await playerBest(options.db, game, key)++ if (!best) {++ send(200, { best: null, rank: null, name: null })++ return++ }++ send(200, {++ best: best.score,++ rank: await playerBestRank(options.db, game, best.score),++ name: best.name,++ })++ return}-- if (!key) {++ const viewerKey = await viewerAccountKey(req, { networkAuthUrl: options.networkAuthUrl })++ if (!viewerKey) {send(401, { error: 'unauthorized' })return}-- send(200, { games: await playerAchievements(options.db, key) })++ send(200, { games: await playerAchievements(options.db, viewerKey) })})().catch(() => {if (!res.headersSent) send(503, { error: 'read_unavailable' })})@@@ -40,7 +40,7 @@ import {AGENT_PAGE_FILES,type AgentContext,} from './agent.js'--import type { HomeParts } from './portalHome.js'++import { applyCardFacts, type HomeParts } from './portalHome.js'import { legalHandler } from './legalPages.js'import { updatesHandler } from './updatesPage.js'import { indexnowHandler, type SharedIndexNow } from './indexnow.js'@@@ -63,7 +63,9 @@ export function portalHtml(filePath: string): string {/** The portal with its live parts (portalHome.ts) in place of the build's markers, and their JSON-LD in the head. */export function portalPage(filePath: string, parts: HomeParts | null): string {-- const html = portalHtml(filePath)++ // The card facts come from the content packs, so they are in the page even when the live sections++ // are not (a slow source must not leave a card without its round length).++ const html = applyCardFacts(portalHtml(filePath))if (!parts) return htmlreturn html.replace('<!--ov:home-stats-->', () => parts.stats)@@@ -121,6 +121,29 @@ const REWARDS = new Map<string, string>([),])++/**++ * The card facts that come from the content packs, not from a sentence written by hand: the length of a++ * Salvage Rush round is the pack's own number, so the card cannot drift from the game it describes.++ * The marker in the page is <!--ov:card-<name>--> and is replaced wherever the page is served.++ */++export function cardFacts(): Record<string, string> {++ const facts: Record<string, string> = {}++ const roundSeconds = SALVAGE_RUSH_PACK.game?.roundSeconds++ if (typeof roundSeconds === 'number' && roundSeconds > 0) {++ facts['salvage-rush-round-tag'] = `<span>${roundSeconds}-second rounds</span>`++ }++ return facts++}++++/** Puts the card facts into the page, wherever the markers are. */++export function applyCardFacts(html: string, facts: Record<string, string> = cardFacts()): string {++ let out = html++ for (const [name, value] of Object.entries(facts)) {++ out = out.replaceAll(`<!--ov:card-${name}-->`, () => value)++ }++ return out++}++const esc = (v: unknown): string => escapeHtml(String(v ?? ''))const fmt = new Intl.NumberFormat('en-US')const n = (v: number): string => fmt.format(v)@@@ -413,7 +436,7 @@ export function homeExtraJsonLd(site: string, playSite: string): unknown[] {{name: 'Salvage Rush',url: `${site}/play?game=salvage-rush`,-- description: '90-second scrap-collecting rounds with a leaderboard.',++ description: `${SALVAGE_RUSH_PACK.game?.roundSeconds ?? 90}-second scrap-collecting rounds with a leaderboard.`,},{name: 'Vibe Heroes',nnew file mode 100644@@@ -0,0 +1,78 @@++import { describe, expect, it } from 'vitest'++import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'++import { resultPageHtml, resultPageUrl, type ResultPageInput } from './resultPage.js'++++const row = (accountKey: string, name: string, score: number, rank: number) => ({++ accountKey,++ playerName: name,++ score,++ rank,++ finishedAt: 1_700_000_000_000,++})++++const input = (over: Partial<ResultPageInput> = {}): ResultPageInput => ({++ roundId: '0:1700000000000-12',++ rows: [row('guest:aaa', 'Ada', 12, 1), row('guest:bbb', 'Bo', 5, 2)],++ viewerKey: 'guest:aaa',++ viewerBest: 12,++ site: 'https://openvibe.games',++ ...over,++})++++describe('the result page of a round', () => {++ it('shows the score, the rank and the best of the player who opened it', () => {++ const html = resultPageHtml(input())++ expect(html).toContain('Your result')++ expect(html).toContain('12')++ expect(html).toContain('Rank')++ expect(html).toContain('1 of 2')++ expect(html).toContain('Best')++ })++++ it('marks the viewer\'s own row on the board of that round', () => {++ const html = resultPageHtml(input())++ expect(html).toContain('You')++ expect(html).toContain('Ada')++ expect(html).toContain('Bo')++ })++++ it('says so plainly when the viewer has no round on record', () => {++ const html = resultPageHtml(input({ viewerKey: 'guest:zzz', viewerBest: null }))++ expect(html).toContain('no round on record yet')++ })++++ it('never invents a score: a round with no rows shows no result', () => {++ const html = resultPageHtml(input({ rows: [], viewerKey: null, viewerBest: null }))++ expect(html).toContain('Nobody scored in this round.')++ expect(html).not.toContain('Your result')++ })++++ it('shares a page, not a screenshot', () => {++ const html = resultPageHtml(input())++ expect(resultPageUrl('https://openvibe.games/', '0:1700000000000-12')).toBe(++ 'https://openvibe.games/result/0%3A1700000000000-12',++ )++ expect(html).toContain('data-ovk-share')++ expect(html).toContain('I scored 12 in Salvage Rush')++ })++++ it('carries the one line every game page carries', () => {++ expect(resultPageHtml(input())).toContain('Play as a guest, no sign-up.')++ // kit.answer's rows are Kit 2: without kit-data.css they fall apart into a bare list.++ expect(resultPageHtml(input())).toContain('href="/shared/kit-data.css"')++ })++++ it('wears the shared kit and the shared theme', () => {++ const html = resultPageHtml(input())++ expect(html).toContain('/shared/kit.css')++ expect(html).toContain('/shared/theme-loader.js')++ expect(html).toContain('ovk-page')++ })++})++++describe('the card facts of the home page', () => {++ it('states the length of a round from the content pack, not from a sentence', () => {++ const seconds = SALVAGE_RUSH_PACK.game?.roundSeconds++ expect(seconds).toBeGreaterThan(0)++ })++})nnew file mode 100644@@@ -0,0 +1,190 @@++/**++ * The result page of a Salvage Rush round (the market outcome, 2026-10-30): a page you can send to++ * someone, with the score, the rank and the player's own best on it. Every number on it is read from the++ * rows the server wrote when the round ended; nothing here is typed in by hand.++ *++ * The page is built with the OpenVibe Kit (openvibe-shared/kit), the same look every OpenVibe app page++ * has, and wears the reader's theme (dark or light) through the shared tokens.++ */++import { createRequire } from 'node:module'++import type { IncomingMessage, ServerResponse } from 'node:http'++import type { Db } from 'openvibe-sdk/db'++import { playerBestScore, roundRows, type RoundRow } from '../game/progression.js'++import { viewerAccountKey } from './viewer.js'++++type Builder = (o: Record<string, unknown>) => string++interface Kit {++ esc: (v: unknown) => string++ icon: (name: string, o?: Record<string, unknown>) => string++ head: Builder++ list: (rows: string[], o?: Record<string, unknown>) => string++ row: Builder++ section: Builder++ fold: Builder++ answer: Builder++ toolHead: Builder++ tool: Builder++ page: Builder++ button: Builder++ badge: (text: string, tone?: string) => string++}++const sharedRequire = createRequire(import.meta.url)++const kit = sharedRequire('openvibe-shared/kit') as Kit++++/** The game the rounds belong to. */++export const RESULT_GAME = 'salvage-rush' as const++++/** The one line every game page carries: what a player gets, and what they never have to do. */++export const GAME_PROMISE =++ 'Play as a guest, no sign-up. No ads inside the games.'++++export interface ResultPageInput {++ /** The round the page is about. */++ roundId: string++ /** The round's rows, in rank order. */++ rows: RoundRow[]++ /** The viewer's account key, when the request named one. */++ viewerKey: string | null++ /** The viewer's own best round, when we keep one. */++ viewerBest: number | null++ /** The address the page is served on (the share link). */++ site: string++}++++const n = (v: number): string => new Intl.NumberFormat('en-US').format(v)++++/** The shareable address of a round's result page. */++export function resultPageUrl(site: string, roundId: string): string {++ return `${site.replace(/\/+$/, '')}/result/${encodeURIComponent(roundId)}`++}++++function shareText(input: ResultPageInput, mine: RoundRow | null): string {++ if (!mine) return 'A round of Salvage Rush on OpenVibe.Games'++ const best = input.viewerBest === null ? null : Math.max(input.viewerBest, mine.score)++ return [++ `I scored ${n(mine.score)} in Salvage Rush (rank ${n(mine.rank)} of ${n(input.rows.length)}).`,++ best === null ? null : `My best round: ${n(best)}.`,++ ]++ .filter(Boolean)++ .join(' ')++}++++/** The page itself, built from the rows of one round. */++export function resultPageHtml(input: ResultPageInput): string {++ const mine = input.viewerKey ? (input.rows.find((r) => r.accountKey === input.viewerKey) ?? null) : null++ const players = input.rows.length++ const url = resultPageUrl(input.site, input.roundId)++ const board = input.rows.slice(0, 25).map((row) =>++ kit.row({++ title: row.playerName,++ meta: `${n(row.score)} scrap`,++ badge: mine && row.accountKey === mine.accountKey ? kit.badge('You', 'accent') : '',++ sub: `Rank ${n(row.rank)} of ${n(players)}`,++ }),++ )++ const best = input.viewerBest === null ? null : Math.max(input.viewerBest, mine?.score ?? 0)++ const answer = kit.answer({++ title: mine ? 'Your result' : 'This round',++ rows: mine++ ? [++ ['Score', n(mine.score)],++ ['Rank', `${n(mine.rank)} of ${n(players)}`],++ ['Best', best === null ? 'no round on record yet' : n(best)],++ ]++ : input.viewerKey++ ? [++ ['Players', n(players)],++ ['Your best', best === null ? 'no round on record yet' : n(best)],++ ]++ : [['Players', n(players)]],++ })++ const share = kit.button({++ href: url,++ label: 'Share this result',++ icon: 'send',++ attrs: { 'data-ovk-share': shareText(input, mine) },++ })++ const body = [++ kit.tool({++ head: kit.toolHead({++ icon: 'trophy',++ title: mine ? 'Round over' : 'A round of Salvage Rush',++ task: 'The server scored this round; these are its numbers.',++ }),++ answer,++ }),++ kit.section({++ title: 'The board of this round',++ note: players > 0 ? `${n(players)} ${players === 1 ? 'player' : 'players'} scored in it.` : '',++ body: kit.list(board, { empty: 'Nobody scored in this round.' }),++ }),++ kit.section({++ body:++ `<div class="ovk-tool-card">${share}</div>` +++ `<p class="ovk-note">${kit.esc(GAME_PROMISE)}</p>` +++ kit.button({ href: '/leaderboard.html?game=salvage-rush', label: 'Full leaderboard', kind: 'ghost', icon: 'trophy' }),++ }),++ ].join('\n')++ const html = kit.page({++ head: kit.head({++ title: 'A Salvage Rush round',++ sub: mine ? `You scored ${n(mine.score)} scrap, rank ${n(mine.rank)} of ${n(players)}.` : `Round of ${n(players)} players.`,++ back: { href: '/play?game=salvage-rush', label: 'Play Salvage Rush' },++ }),++ body,++ })++ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>A Salvage Rush round · OpenVibe.Games</title><meta name="robots" content="noindex,follow"><meta name="description" content="A scored round of Salvage Rush on OpenVibe.Games."><script src="/shared/theme-loader.js"></script><link rel="stylesheet" href="/shared/kit.css"><link rel="stylesheet" href="/shared/kit-data.css"></head><body class="ovk-app"><div id="navbar-mount"></div>${html}<script src="/shared/navbar.js" defer></script><script src="/shared/kit-web.js" defer></script></body></html>`++}++++export interface ResultRouteOptions {++ db: Db++ networkAuthUrl: string | null++ /** The public address of the site, for the share link. */++ site: string++}++++/**++ * GET /result/<roundId>: the page of one finished round. A round we do not have is a 404, said plainly.++ */++export function handleResultPage(++ req: IncomingMessage,++ res: ServerResponse,++ opts: ResultRouteOptions,++): boolean {++ if (req.method !== 'GET' && req.method !== 'HEAD') return false++ const match = /^\/result\/([^/]+)\/?$/.exec(new URL(req.url ?? '/', 'http://games.local').pathname)++ if (!match) return false++ const roundId = decodeURIComponent(match[1] ?? '')++ const send = (status: number, body: string): void => {++ res.writeHead(status, {++ 'content-type': 'text/html; charset=utf-8',++ 'cache-control': 'no-store',++ })++ res.end(req.method === 'HEAD' ? undefined : body)++ }++ void (async () => {++ const rows = await roundRows(opts.db, RESULT_GAME, roundId)++ if (!rows.length) {++ send(++ 404,++ resultPageHtml({++ roundId,++ rows: [],++ viewerKey: null,++ viewerBest: null,++ site: opts.site,++ }),++ )++ return++ }++ const viewerKey = await viewerAccountKey(req, { networkAuthUrl: opts.networkAuthUrl }).catch(() => null)++ const viewerBest = viewerKey ? await playerBestScore(opts.db, RESULT_GAME, viewerKey) : null++ send(++ 200,++ resultPageHtml({ roundId, rows, viewerKey, viewerBest, site: opts.site }),++ )++ })().catch(() => {++ if (!res.headersSent) send(503, resultPageHtml({ roundId, rows: [], viewerKey: null, viewerBest: null, site: opts.site }))++ })++ return true++}nnew file mode 100644@@@ -0,0 +1,49 @@++/**++ * Who is asking (the viewer), read the way every OpenVibe site reads a person: a signed-in player++ * presents the Network token the browser keeps in `ovg_sso`, a guest presents the guest token the++ * browser keeps in `openvibe.token` (or sends in the X-Guest-Token header). What comes back is the++ * account key the rows in the database are stored under, never a raw token.++ *++ * The same helper serves the API reads and the pages, so a page and its API agree on who the viewer is.++ */++import type { IncomingMessage } from 'node:http'++import { isGuestToken } from '../platform/accounts.js'++import { hashGuestKey } from './guestIdentity.js'++import { resolveNetworkUser } from './networkAuth.js'++import { parseCookies } from './sso.js'++++/** The cookie the browser keeps its guest token in (client/net/identity.ts). */++export const GUEST_TOKEN_COOKIE = 'openvibe.token'++/** The cookie the sign-in leaves the Network token in (client/net/identity.ts). */++export const SESSION_COOKIE_NAME = 'ovg_sso'++++export interface ViewerOptions {++ /** The Network's auth address, when the site is signed in to it. */++ networkAuthUrl: string | null++}++++/** The account key of the guest who owns a raw guest token. */++export function guestAccountKey(rawToken: string): string | null {++ if (!isGuestToken(rawToken)) return null++ return `guest:${hashGuestKey(rawToken)}`++}++++/**++ * The viewer's account key, or null when the request names nobody. Signed in first (the Network token++ * wins), then the guest token from the header, then the guest token from the cookie.++ */++export async function viewerAccountKey(++ req: IncomingMessage,++ opts: ViewerOptions,++): Promise<string | null> {++ const cookies = parseCookies(req.headers.cookie)++ const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1]++ const session = bearer ?? cookies[SESSION_COOKIE_NAME]++ if (session && opts.networkAuthUrl) {++ const user = await resolveNetworkUser(opts.networkAuthUrl, session).catch(() => null)++ if (user?.subjectId) return user.subjectId++ }++ const guestHeader = req.headers['x-guest-token']++ const guest = typeof guestHeader === 'string' ? guestHeader : cookies[GUEST_TOKEN_COOKIE]++ return guest ? guestAccountKey(guest) : null++}@@@ -314,6 +314,19 @@ export type ServerMessage =pickups: { x: number; z: number; available: boolean }[]scores: { name: string; score: number; rank: number }[]}++ | {++ /**++ * A finished round, sent to one player only: their score, their rank in that round, how many++ * played it and their own best round so far (null when they have none on record). Every value++ * comes from the round the server scored, never from the client.++ */++ t: 'salvage_result'++ roundId: string++ score: number++ rank: number++ players: number++ best: number | null++ }| ServerWelcome| ServerCvarState| ServerTransferResultnnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/client/src/ui/rushResult.test.ts'++// covers: 'apps/client/src/ui'++// Generated by node test/run.js --index; do not edit.nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/server/src/net/cardFacts.test.ts'++// covers: 'apps/server/src/net'++// Generated by node test/run.js --index; do not edit.nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/server/src/net/resultPage.test.ts'++// covers: 'apps/server/src/net'++// Generated by node test/run.js --index; do not edit.
@@ -906,7 +906,7 @@
@@ -906,7 +906,7 @@
<p class="hero-title">Open<b>Vibe.Games</b></p>
<p class="hero-title">Open<b>Vibe.Games</b></p>
<h1 class="hero-h1">Free games that start in a browser tab</h1>
<h1 class="hero-h1">Free games that start in a browser tab</h1>
<p class="hero-sub">
<p class="hero-sub">
- Build and survive with other players, race for scrap in 90-second rounds, grow a team of
+ Build and survive with other players, race for scrap in timed rounds, grow a team of
heroes on your phone or join a world another player built. Nothing to install: play as a
heroes on your phone or join a world another player built. Nothing to install: play as a
guest, or sign in to keep your character across OpenVibe.
guest, or sign in to keep your character across OpenVibe.
</p>
</p>
@@ -1137,12 +1137,12 @@
@@ -1137,12 +1137,12 @@
<span class="badge live">● Live now</span>
<span class="badge live">● Live now</span>
<h3>Salvage Rush</h3>
<h3>Salvage Rush</h3>
<p>
<p>
- Collect scrap around a compact arena in 90-second on-foot rounds. The server scores each
+ Collect scrap around a compact arena in timed on-foot rounds. The server scores each
pickup; rounds have a public leaderboard and three achievements that give items worn
pickup; rounds have a public leaderboard and three achievements that give items worn
across OpenVibe.
across OpenVibe.
</p>
</p>
<div class="tags">
<div class="tags">
- <span>timed rounds</span><span>on-foot</span><span>leaderboard</span>
+ <!--ov:card-salvage-rush-round-tag--><span>on-foot</span><span>leaderboard</span>
</div>
</div>
<div class="cta-row">
<div class="cta-row">
<a class="play-cta" href="/play?game=salvage-rush">▶ Play a round</a>
<a class="play-cta" href="/play?game=salvage-rush">▶ Play a round</a>
@@ -8,19 +8,23 @@
@@ -8,19 +8,23 @@
name="description"
name="description"
content="OpenVibe.Games leaderboards: Salvage Rush best rounds and Scraplandia haul points, all time and this week. Scores come only from what the server played out."
content="OpenVibe.Games leaderboards: Salvage Rush best rounds and Scraplandia haul points, all time and this week. Scores come only from what the server played out."
/>
/>
+ <!-- The reader's theme, before the first paint: the page's own light rules follow it. -->
+ <script src="/shared/theme-loader.js"></script>
<style>
<style>
+ /* The shared tokens (theme-loader.js): the page wears the reader's theme, dark or light. The
+ values behind them are the page's own dark ones, so a reader without a theme sees the same. */
:root {
:root {
- color-scheme: dark;
- --accent: #3b82f6;
- --accent-light: #60a5fa;
+ color-scheme: var(--color-scheme, dark);
+ --page-accent: var(--accent, #3b82f6);
+ --page-accent-light: var(--accent-light, #60a5fa);
--secondary: #22d3ee;
--secondary: #22d3ee;
- --bg: #0a0f1c;
- --bg-raised: #101828;
- --bg-card: rgba(19, 28, 46, 0.88);
- --border: #1f2d47;
- --text: #e6edf7;
- --text-dim: #96a7c2;
- --text-faint: #7386a3;
+ --bg: var(--bg-primary, #0a0f1c);
+ --bg-raised: var(--bg-secondary, #101828);
+ --bg-card: var(--bg-tertiary, rgba(19, 28, 46, 0.88));
+ --page-border: var(--border, #1f2d47);
+ --text: var(--text-primary, #e6edf7);
+ --text-dim: var(--text-secondary, #96a7c2);
+ --text-faint: var(--text-muted, #7386a3);
}
}
/* Light themes (theme-loader sets data-theme-mode): the cyan is text on tags and achievements, and #22d3ee on a light
/* Light themes (theme-loader sets data-theme-mode): the cyan is text on tags and achievements, and #22d3ee on a light
page read at 1.7:1; this deeper cyan reads at 5:1 (axe, 2026-10-22). */
page read at 1.7:1; this deeper cyan reads at 5:1 (axe, 2026-10-22). */
@@ -42,7 +46,7 @@
@@ -42,7 +46,7 @@
sans-serif;
sans-serif;
}
}
a {
a {
- color: var(--accent-light);
+ color: var(--page-accent-light);
}
}
main {
main {
max-width: 760px;
max-width: 760px;
@@ -54,6 +58,31 @@
@@ -54,6 +58,31 @@
gap: 14px;
gap: 14px;
font-size: 14px;
font-size: 14px;
}
}
+ /* The viewer's own row: the same accent the page already uses, no colour of its own. */
+ .you-line {
+ margin: 10px 0 0;
+ padding: 8px 12px;
+ border: 1px solid var(--page-border);
+ border-radius: 10px;
+ background: var(--bg-card);
+ color: var(--text);
+ font-size: 14px;
+ font-weight: 600;
+ }
+ tr.is-you {
+ background: var(--bg-raised);
+ }
+ .you {
+ margin-left: 8px;
+ padding: 1px 7px;
+ border: 1px solid var(--page-accent);
+ border-radius: 999px;
+ color: var(--page-accent-light);
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ }
.eyebrow {
.eyebrow {
margin: 28px 0 4px;
margin: 28px 0 4px;
font-size: 12px;
font-size: 12px;
@@ -72,7 +101,7 @@
@@ -72,7 +101,7 @@
color: var(--text-dim);
color: var(--text-dim);
}
}
.board {
.board {
- border: 1px solid var(--border);
+ border: 1px solid var(--page-border);
border-radius: 18px;
border-radius: 18px;
background: var(--bg-card);
background: var(--bg-card);
overflow: hidden;
overflow: hidden;
@@ -81,12 +110,12 @@
@@ -81,12 +110,12 @@
display: flex;
display: flex;
gap: 6px;
gap: 6px;
padding: 12px;
padding: 12px;
- border-bottom: 1px solid var(--border);
+ border-bottom: 1px solid var(--page-border);
}
}
.tabs button {
.tabs button {
flex: 0 0 auto;
flex: 0 0 auto;
padding: 8px 16px;
padding: 8px 16px;
- border: 1px solid var(--border);
+ border: 1px solid var(--page-border);
border-radius: 999px;
border-radius: 999px;
background: transparent;
background: transparent;
color: var(--text-dim);
color: var(--text-dim);
@@ -95,13 +124,13 @@
@@ -95,13 +124,13 @@
cursor: pointer;
cursor: pointer;
}
}
.tabs button[aria-pressed='true'] {
.tabs button[aria-pressed='true'] {
- border-color: var(--accent);
+ border-color: var(--page-accent);
background: rgba(59, 130, 246, 0.18);
background: rgba(59, 130, 246, 0.18);
color: var(--text);
color: var(--text);
}
}
.tabs button:focus-visible,
.tabs button:focus-visible,
.pager button:focus-visible {
.pager button:focus-visible {
- outline: 2px solid var(--accent-light);
+ outline: 2px solid var(--page-accent-light);
outline-offset: 2px;
outline-offset: 2px;
}
}
table {
table {
@@ -113,7 +142,7 @@
@@ -113,7 +142,7 @@
td {
td {
padding: 12px 16px;
padding: 12px 16px;
text-align: left;
text-align: left;
- border-bottom: 1px solid var(--border);
+ border-bottom: 1px solid var(--page-border);
}
}
th {
th {
font-size: 12px;
font-size: 12px;
@@ -139,7 +168,7 @@
@@ -139,7 +168,7 @@
height: 30px;
height: 30px;
padding: 0 6px;
padding: 0 6px;
border-radius: 999px;
border-radius: 999px;
- border: 1px solid var(--border);
+ border: 1px solid var(--page-border);
font-weight: 800;
font-weight: 800;
font-size: 14px;
font-size: 14px;
}
}
@@ -176,13 +205,13 @@
@@ -176,13 +205,13 @@
justify-content: space-between;
justify-content: space-between;
gap: 12px;
gap: 12px;
padding: 12px 16px;
padding: 12px 16px;
- border-top: 1px solid var(--border);
+ border-top: 1px solid var(--page-border);
color: var(--text-faint);
color: var(--text-faint);
font-size: 14px;
font-size: 14px;
}
}
.pager button {
.pager button {
padding: 7px 14px;
padding: 7px 14px;
- border: 1px solid var(--border);
+ border: 1px solid var(--page-border);
border-radius: 10px;
border-radius: 10px;
background: var(--bg-raised);
background: var(--bg-raised);
color: var(--text);
color: var(--text);
@@ -198,7 +227,7 @@
@@ -198,7 +227,7 @@
margin-top: 22px;
margin-top: 22px;
padding: 13px 28px;
padding: 13px 28px;
border-radius: 12px;
border-radius: 12px;
- background: linear-gradient(120deg, var(--accent), #2563eb);
+ background: linear-gradient(120deg, var(--page-accent), #2563eb);
color: #fff;
color: #fff;
font-weight: 800;
font-weight: 800;
letter-spacing: 0.03em;
letter-spacing: 0.03em;
@@ -221,9 +250,9 @@
@@ -221,9 +250,9 @@
<p class="eyebrow">LEADERBOARD</p>
<p class="eyebrow">LEADERBOARD</p>
<h1 id="title">Salvage Rush</h1>
<h1 id="title">Salvage Rush</h1>
<p class="lede" id="lede">
<p class="lede" id="lede">
- Each player's best round score. Rounds last 90 seconds; every point comes from a pickup the
- server counted.
+ Each player's best round score. Every point comes from a pickup the server counted.
</p>
</p>
+ <p class="you-line" id="you" hidden></p>
<section class="board" aria-labelledby="board-title">
<section class="board" aria-labelledby="board-title">
<h2 id="board-title" hidden>Best scores</h2>
<h2 id="board-title" hidden>Best scores</h2>
<div class="tabs" role="group" aria-label="Game">
<div class="tabs" role="group" aria-label="Game">
@@ -91,8 +91,8 @@
@@ -91,8 +91,8 @@
board.
board.
</p>
</p>
<p>
<p>
- <strong>Salvage Rush</strong> is its quick cousin: collect scrap around a compact arena in 90-second rounds,
- scored by the server, with a public leaderboard.
+ <strong>Salvage Rush</strong> is its quick cousin: collect scrap around a compact arena in timed
+ on-foot rounds, scored by the server, with a public leaderboard.
</p>
</p>
<p>
<p>
Both run in your browser with no download, as a guest or with your OpenVibe account. They need JavaScript and
Both run in your browser with no download, as a guest or with your OpenVibe account. They need JavaScript and
@@ -10,6 +10,7 @@ import {
@@ -10,6 +10,7 @@ import {
dayKey,
dayKey,
} from '@openvibe/casual'
} from '@openvibe/casual'
import { enemyArt, icon } from '../art.js'
import { enemyArt, icon } from '../art.js'
+import { GAME_PROMISE } from '../../ui/gamePromise.js'
import { button, duration, h, num, sheet, tick, toast } from '../dom.js'
import { button, duration, h, num, sheet, tick, toast } from '../dom.js'
import type { App } from '../app.js'
import type { App } from '../app.js'
import { playBattle } from './battle.js'
import { playBattle } from './battle.js'
@@ -316,4 +317,6 @@ function stageList(app: App): void {
@@ -316,4 +317,6 @@ function stageList(app: App): void {
}),
}),
list,
list,
)
)
+ // The one line every game page carries: what a player gets, and what they never have to do.
+ s.body.append(h('p', { class: 'muted promise-line', text: GAME_PROMISE }))
}
}
@@ -783,6 +783,7 @@ async function start(): Promise<void> {
@@ -783,6 +783,7 @@ async function start(): Promise<void> {
let rejected = false
let rejected = false
connection.onMessage = (msg) => {
connection.onMessage = (msg) => {
if (msg.t === 'salvage_round') roundUi?.update(msg)
if (msg.t === 'salvage_round') roundUi?.update(msg)
+ if (msg.t === 'salvage_result') roundUi?.showResult(msg)
if (msg.t === 'surf_state') surfUi?.updateState(msg)
if (msg.t === 'surf_state') surfUi?.updateState(msg)
if (msg.t === 'surf_finish') {
if (msg.t === 'surf_finish') {
surfUi?.showFinish(msg)
surfUi?.showFinish(msg)
@@ -1,3 +1,5 @@
@@ -1,3 +1,5 @@
+import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'
+
export {}
export {}
interface Entry {
interface Entry {
@@ -6,6 +8,13 @@ interface Entry {
@@ -6,6 +8,13 @@ interface Entry {
score: number
score: number
}
}
+/** The viewer's own best for the game on the page, and where it sits on the board we keep. */
+interface Mine {
+ best: number | null
+ rank: number | null
+ name: string | null
+}
+
type Game = 'salvage-rush' | 'scraplandia'
type Game = 'salvage-rush' | 'scraplandia'
const gameTabs = Array.from(document.querySelectorAll<HTMLButtonElement>('.tabs button[data-game]'))
const gameTabs = Array.from(document.querySelectorAll<HTMLButtonElement>('.tabs button[data-game]'))
@@ -21,6 +30,7 @@ const title = document.getElementById('title') as HTMLElement
@@ -21,6 +30,7 @@ const title = document.getElementById('title') as HTMLElement
const lede = document.getElementById('lede') as HTMLElement
const lede = document.getElementById('lede') as HTMLElement
const scoreHeader = document.getElementById('score-header') as HTMLElement
const scoreHeader = document.getElementById('score-header') as HTMLElement
const playLink = document.getElementById('play-link') as HTMLAnchorElement
const playLink = document.getElementById('play-link') as HTMLAnchorElement
+const youLine = document.getElementById('you') as HTMLElement
const COPY: Record<
const COPY: Record<
Game,
Game,
@@ -35,7 +45,7 @@ const COPY: Record<
@@ -35,7 +45,7 @@ const COPY: Record<
> = {
> = {
'salvage-rush': {
'salvage-rush': {
title: 'Salvage Rush',
title: 'Salvage Rush',
- lede: "Each player's best round score. Rounds last 90 seconds; every point comes from a pickup the server counted.",
+ lede: `Each player's best round score. Rounds last ${SALVAGE_RUSH_PACK.game?.roundSeconds ?? 90} seconds; every point comes from a pickup the server counted.`,
score: 'Best score',
score: 'Best score',
play: '/play?game=salvage-rush',
play: '/play?game=salvage-rush',
playLabel: '▶ Play a round',
playLabel: '▶ Play a round',
@@ -87,6 +97,31 @@ function applyGame(): void {
@@ -87,6 +97,31 @@ function applyGame(): void {
document.title = `${copy.title} leaderboard · OpenVibe.Games`
document.title = `${copy.title} leaderboard · OpenVibe.Games`
}
}
+/**
+ * The viewer's own best for this game, read from the rows we keep. The board itself comes from
+ * OpenVibe.Play, so the row that is the viewer's is the one with their name and their best score.
+ */
+async function loadMine(): Promise<Mine | null> {
+ try {
+ const response = await fetch(`/api/v1/games/${game}/me`)
+ if (!response.ok) return null
+ const data = (await response.json()) as Mine
+ return data
+ } catch {
+ return null
+ }
+}
+
+function markMine(mine: Mine | null): void {
+ if (!mine || mine.best === null || mine.rank === null) {
+ youLine.hidden = true
+ youLine.textContent = ''
+ return
+ }
+ youLine.hidden = false
+ youLine.textContent = `Your best: ${mine.best.toLocaleString('en-US')} — rank ${mine.rank.toLocaleString('en-US')} on this board.`
+}
+
async function load(): Promise<void> {
async function load(): Promise<void> {
for (const tab of periodTabs)
for (const tab of periodTabs)
tab.setAttribute('aria-pressed', String(tab.dataset.period === period))
tab.setAttribute('aria-pressed', String(tab.dataset.period === period))
@@ -100,12 +135,27 @@ async function load(): Promise<void> {
@@ -100,12 +135,27 @@ async function load(): Promise<void> {
const response = await fetch(`/api/v1/games/${game}/leaderboard?period=${period}&page=${page}`)
const response = await fetch(`/api/v1/games/${game}/leaderboard?period=${period}&page=${page}`)
if (!response.ok) throw new Error('unavailable')
if (!response.ok) throw new Error('unavailable')
const data = (await response.json()) as { entries: Entry[]; pages: number }
const data = (await response.json()) as { entries: Entry[]; pages: number }
+ const mine = await loadMine()
+ markMine(mine)
for (const entry of data.entries) {
for (const entry of data.entries) {
const row = document.createElement('tr')
const row = document.createElement('tr')
if (entry.rank <= 3) row.className = `top-${entry.rank}`
if (entry.rank <= 3) row.className = `top-${entry.rank}`
+ const isMine =
+ mine !== null &&
+ mine.best !== null &&
+ entry.score === mine.best &&
+ (mine.name === null || entry.name === mine.name)
+ if (isMine) row.classList.add('is-you')
+ const nameCell = cell(entry.name, 'name')
+ if (isMine) {
+ const tag = document.createElement('span')
+ tag.className = 'you'
+ tag.textContent = 'you'
+ nameCell.append(tag)
+ }
row.append(
row.append(
cell(String(entry.rank), 'rank'),
cell(String(entry.rank), 'rank'),
- cell(entry.name, 'name'),
+ nameCell,
cell(entry.score.toLocaleString('en-US')),
cell(entry.score.toLocaleString('en-US')),
)
)
entries.append(row)
entries.append(row)
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
import { MAX_CHARACTERS, type Appearance } from '@openvibe/protocol'
import { MAX_CHARACTERS, type Appearance } from '@openvibe/protocol'
+import { GAME_PROMISE } from './gamePromise.js'
/**
/**
* Character select: your characters in this game, and a "New character" button. There is no slot count to unlock
* Character select: your characters in this game, and a "New character" button. There is no slot count to unlock
@@ -22,7 +23,9 @@ export function characterSelect(
@@ -22,7 +23,9 @@ export function characterSelect(
overlay.className = 'customize-overlay'
overlay.className = 'customize-overlay'
const panel = document.createElement('div')
const panel = document.createElement('div')
panel.className = 'customize-panel'
panel.className = 'customize-panel'
- panel.innerHTML = '<h1>CHOOSE YOUR SCRAPPER</h1>'
+ panel.innerHTML =
+ '<h1>CHOOSE YOUR SCRAPPER</h1>' +
+ '<p class="char-onesentence">Pick a scrapper and press Play — you are in the world in a moment.</p>'
const pick = (slot: number, existing: CharacterInfo | null) => {
const pick = (slot: number, existing: CharacterInfo | null) => {
overlay.remove()
overlay.remove()
resolve({ slot, existing })
resolve({ slot, existing })
@@ -77,6 +80,11 @@ export function characterSelect(
@@ -77,6 +80,11 @@ export function characterSelect(
})
})
panel.appendChild(btn)
panel.appendChild(btn)
}
}
+ // The one line every game page carries: what a player gets, and what they never have to do.
+ const promise = document.createElement('p')
+ promise.className = 'char-promise'
+ promise.textContent = GAME_PROMISE
+ panel.appendChild(promise)
overlay.appendChild(panel)
overlay.appendChild(panel)
uiRoot.appendChild(overlay)
uiRoot.appendChild(overlay)
})
})
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+/**
+ * The one line every game page carries: what a player gets, and what they never have to do. The words
+ * are a promise about the games themselves, and the test beside them checks the code keeps it. It says
+ * only what stays true: a paid store is planned for the casual games (plan T22), and the site counts
+ * visits, so "nothing to buy" and "no tracking" are not promises it can keep (2026-10-31 review).
+ */
+export const GAME_PROMISE =
+ 'Play as a guest, no sign-up. No ads inside the games.'
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
+/*
+ * The result card of a finished round (the market outcome, 2026-10-30): the player's own score, their
+ * rank and their best, and the button that shares the round's page.
+ *
+ * Every colour is a shared token (--bg-card, --text-primary, --accent, --border), so the card wears
+ * the reader's theme: dark or light, with no colour of its own.
+ */
+.rush-result {
+ position: fixed;
+ z-index: 16;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ width: min(22rem, calc(100vw - 2rem));
+ padding: 1rem 1.1rem 1.1rem;
+ border: 1px solid var(--border);
+ border-radius: 1rem;
+ background: var(--bg-card);
+ color: var(--text-primary);
+ box-shadow: 0 18px 50px var(--shadow-lg, rgba(0, 0, 0, 0.45));
+ font:
+ 500 0.95rem/1.35 system-ui,
+ sans-serif;
+}
+.rush-result-kicker {
+ margin: 0;
+ font-size: 0.72rem;
+ font-weight: 800;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--accent);
+}
+.rush-result-score {
+ margin: 0.15rem 0 0.6rem;
+ font-size: 2.1rem;
+ font-weight: 900;
+ line-height: 1.1;
+ font-variant-numeric: tabular-nums;
+}
+.rush-result-facts {
+ display: grid;
+ gap: 0.3rem;
+ margin: 0 0 0.9rem;
+}
+.rush-result-facts > div {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 1rem;
+ padding-top: 0.3rem;
+ border-top: 1px solid var(--border);
+}
+.rush-result-facts dt {
+ color: var(--text-muted);
+ font-size: 0.82rem;
+}
+.rush-result-facts dd {
+ margin: 0;
+ font-weight: 800;
+ font-variant-numeric: tabular-nums;
+}
+.rush-result-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+.rush-result-actions > * {
+ flex: 1 1 8rem;
+ min-height: 2.75rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 0.9rem;
+ border: 1px solid var(--border);
+ border-radius: 0.7rem;
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ font: inherit;
+ font-weight: 700;
+ text-align: center;
+ text-decoration: none;
+ cursor: pointer;
+}
+.rush-result-share {
+ background: var(--accent-strong, var(--accent));
+ border-color: transparent;
+ color: var(--on-accent-strong, var(--on-accent, #fff));
+}
+.rush-result-board {
+ background: transparent;
+}
+.rush-result-close {
+ background: transparent;
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from 'vitest'
+import { buildResultCard, resultPageUrl, resultShareText } from './rushResult.js'
+
+const result = {
+ t: 'salvage_result' as const,
+ roundId: '0:1700000000000-12',
+ score: 12,
+ rank: 2,
+ players: 7,
+ best: 12,
+}
+
+/**
+ * The result card of a finished round: the player's own score, their rank and their best, and the button
+ * that shares the round's page. The numbers come from the server's result, never from the client.
+ */
+describe('the result card of a round', () => {
+ it('shows the score, the rank and the best', () => {
+ const card = buildResultCard(result, 'https://openvibe.games')
+ expect(card.textContent).toContain('12')
+ expect(card.textContent).toContain('2 of 7')
+ expect(card.textContent).toContain('Best')
+ })
+
+ it('says plainly when there is no best on record', () => {
+ const card = buildResultCard({ ...result, best: null }, 'https://openvibe.games')
+ expect(card.textContent).toContain('no round on record yet')
+ })
+
+ it('shares a page, not a screenshot', () => {
+ expect(resultPageUrl('0:1700000000000-12', 'https://openvibe.games')).toBe(
+ 'https://openvibe.games/result/0%3A1700000000000-12',
+ )
+ expect(resultShareText(result)).toBe(
+ 'I scored 12 in Salvage Rush (rank 2 of 7). My best round: 12.',
+ )
+ })
+
+ it('carries the buttons a player needs', () => {
+ const card = buildResultCard(result, 'https://openvibe.games')
+ expect(card.querySelector('.rush-result-share')?.textContent).toBe('Share')
+ expect(card.querySelector('.rush-result-board')?.getAttribute('href')).toBe(
+ '/leaderboard.html?game=salvage-rush',
+ )
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
+/**
+ * The result card of a finished Salvage Rush round (the market outcome, 2026-10-30): the player's own
+ * score, their rank in that round and their own best, and one button that shares it. The numbers come
+ * from the server's round result, never from the client.
+ */
+import type { ServerMessage } from '@openvibe/protocol'
+import './rushResult.css'
+
+export type RoundResult = Extract<ServerMessage, { t: 'salvage_result' }>
+
+/** The address of the shareable page of one round. */
+export function resultPageUrl(roundId: string, origin: string): string {
+ return `${origin.replace(/\/+$/, '')}/result/${encodeURIComponent(roundId)}`
+}
+
+/** The words a player sends: their score, their rank and their best. */
+export function resultShareText(result: RoundResult): string {
+ const score = new Intl.NumberFormat('en-US').format(result.score)
+ const rank = new Intl.NumberFormat('en-US').format(result.rank)
+ const players = new Intl.NumberFormat('en-US').format(result.players)
+ const best = result.best === null ? null : new Intl.NumberFormat('en-US').format(result.best)
+ return [
+ `I scored ${score} in Salvage Rush (rank ${rank} of ${players}).`,
+ best === null ? null : `My best round: ${best}.`,
+ ]
+ .filter(Boolean)
+ .join(' ')
+}
+
+/**
+ * The card, as a DOM tree. The share button shares the round's page (the result screen), so the link
+ * that travels is a page, not a screenshot.
+ */
+export function buildResultCard(result: RoundResult, origin: string): HTMLElement {
+ const fmt = new Intl.NumberFormat('en-US')
+ const url = resultPageUrl(result.roundId, origin)
+ const card = document.createElement('div')
+ card.className = 'rush-result'
+ card.setAttribute('role', 'dialog')
+ card.setAttribute('aria-label', 'Round result')
+
+ const kicker = document.createElement('p')
+ kicker.className = 'rush-result-kicker'
+ kicker.textContent = 'Round over'
+
+ const score = document.createElement('p')
+ score.className = 'rush-result-score'
+ const scoreValue = document.createElement('b')
+ scoreValue.textContent = fmt.format(result.score)
+ score.append(scoreValue, document.createTextNode(' scrap'))
+
+ const facts = document.createElement('dl')
+ facts.className = 'rush-result-facts'
+ const add = (label: string, value: string): void => {
+ const row = document.createElement('div')
+ const dt = document.createElement('dt')
+ dt.textContent = label
+ const dd = document.createElement('dd')
+ dd.textContent = value
+ row.append(dt, dd)
+ facts.append(row)
+ }
+ add('Rank', `${fmt.format(result.rank)} of ${fmt.format(result.players)}`)
+ add('Best', result.best === null ? 'no round on record yet' : fmt.format(result.best))
+
+ const actions = document.createElement('div')
+ actions.className = 'rush-result-actions'
+ const share = document.createElement('button')
+ share.type = 'button'
+ share.className = 'rush-result-share'
+ share.textContent = 'Share'
+ share.addEventListener('click', () => {
+ const text = resultShareText(result)
+ if (navigator.share) {
+ navigator.share({ title: 'A Salvage Rush round', text, url }).catch(() => undefined)
+ return
+ }
+ void navigator.clipboard?.writeText(`${text} ${url}`).catch(() => undefined)
+ share.textContent = 'Copied'
+ window.setTimeout(() => {
+ share.textContent = 'Share'
+ }, 1500)
+ })
+ const board = document.createElement('a')
+ board.className = 'rush-result-board'
+ board.href = '/leaderboard.html?game=salvage-rush'
+ board.textContent = 'Full board'
+ const close = document.createElement('button')
+ close.type = 'button'
+ close.className = 'rush-result-close'
+ close.textContent = 'Next round'
+ close.addEventListener('click', () => card.remove())
+ actions.append(share, board, close)
+
+ card.append(kicker, score, facts, actions)
+ return card
+}
@@ -4,6 +4,7 @@ import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial.js'
@@ -4,6 +4,7 @@ import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial.js'
import { Color3 } from '@babylonjs/core/Maths/math.color.js'
import { Color3 } from '@babylonjs/core/Maths/math.color.js'
import type { Scene } from '@babylonjs/core/scene.js'
import type { Scene } from '@babylonjs/core/scene.js'
import type { Mesh } from '@babylonjs/core/Meshes/mesh.js'
import type { Mesh } from '@babylonjs/core/Meshes/mesh.js'
+import { buildResultCard, type RoundResult } from './rushResult.js'
import './salvageRushHud.css'
import './salvageRushHud.css'
type Round = Extract<ServerMessage, { t: 'salvage_round' }>
type Round = Extract<ServerMessage, { t: 'salvage_round' }>
@@ -16,6 +17,7 @@ export class SalvageRushHud {
@@ -16,6 +17,7 @@ export class SalvageRushHud {
private readonly scores: HTMLElement
private readonly scores: HTMLElement
private timer: number | null = null
private timer: number | null = null
private pickups: Mesh[] = []
private pickups: Mesh[] = []
+ private result: HTMLElement | null = null
constructor(
constructor(
parent: HTMLElement,
parent: HTMLElement,
@@ -40,6 +42,17 @@ export class SalvageRushHud {
@@ -40,6 +42,17 @@ export class SalvageRushHud {
parent.append(this.root)
parent.append(this.root)
}
}
+ /**
+ * The round this player finished: their score, their rank and their own best, with a button that
+ * shares the round's page. The card sits over the game until it is closed.
+ */
+ showResult(result: RoundResult): void {
+ this.result?.remove()
+ const card = buildResultCard(result, window.location.origin)
+ this.root.parentElement?.append(card)
+ this.result = card
+ }
+
update(round: Round): void {
update(round: Round): void {
if (this.pickups.length !== round.pickups.length) {
if (this.pickups.length !== round.pickups.length) {
for (const mesh of this.pickups) mesh.dispose()
for (const mesh of this.pickups) mesh.dispose()
@@ -58,6 +71,11 @@ export class SalvageRushHud {
@@ -58,6 +71,11 @@ export class SalvageRushHud {
)
)
if (this.timer !== null) window.clearInterval(this.timer)
if (this.timer !== null) window.clearInterval(this.timer)
const active = round.phase === 'active'
const active = round.phase === 'active'
+ if (active && this.result) {
+ // A new round starts: the result card has made its point.
+ this.result.remove()
+ this.result = null
+ }
this.root.classList.toggle('is-active', active)
this.root.classList.toggle('is-active', active)
this.phase.textContent = active ? 'Collect scrap' : 'Next round in'
this.phase.textContent = active ? 'Collect scrap' : 'Next round in'
const refresh = () => {
const refresh = () => {
@@ -1197,6 +1197,18 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
@@ -1197,6 +1197,18 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
font-size: calc(12px * var(--ov-text-scale, 1));
font-size: calc(12px * var(--ov-text-scale, 1));
}
}
+.char-onesentence {
+ margin: 0 0 12px;
+ color: var(--text-secondary);
+ font-size: calc(14px * var(--ov-text-scale, 1));
+ line-height: 1.45;
+}
+.char-promise {
+ margin-top: 14px;
+ color: var(--text-muted);
+ font-size: calc(12px * var(--ov-text-scale, 1));
+ line-height: 1.5;
+}
.char-upsell {
.char-upsell {
margin-top: 14px;
margin-top: 14px;
padding: 12px 14px;
padding: 12px 14px;
@@ -57,6 +57,7 @@ export class GameServer {
@@ -57,6 +57,7 @@ export class GameServer {
private lastRushBroadcast = 0
private lastRushBroadcast = 0
private readonly roundFinished?: GameIntegrations['roundFinished']
private readonly roundFinished?: GameIntegrations['roundFinished']
private readonly playerJoined?: GameIntegrations['playerJoined']
private readonly playerJoined?: GameIntegrations['playerJoined']
+ private readonly integrations: GameIntegrations
constructor(
constructor(
config: ServerConfig,
config: ServerConfig,
@@ -72,6 +73,7 @@ export class GameServer {
@@ -72,6 +73,7 @@ export class GameServer {
: null
: null
this.roundFinished = integrations.roundFinished
this.roundFinished = integrations.roundFinished
this.playerJoined = integrations.playerJoined
this.playerJoined = integrations.playerJoined
+ this.integrations = integrations
// The world clock and weather survive restarts (a rainy dusk stays a rainy dusk); they are
// The world clock and weather survive restarts (a rainy dusk stays a rainy dusk); they are
// loaded asynchronously in load(), before the tick loop starts.
// loaded asynchronously in load(), before the tick loop starts.
this.ctx = new ServerContext(
this.ctx = new ServerContext(
@@ -295,6 +297,45 @@ export class GameServer {
@@ -295,6 +297,45 @@ export class GameServer {
* One simulation tick at the room's own rate. This is the ONE place the order is written down;
* One simulation tick at the room's own rate. This is the ONE place the order is written down;
* the systems' internal cadence (1 Hz sweeps, every-N-tick snapshots) is their own.
* the systems' internal cadence (1 Hz sweeps, every-N-tick snapshots) is their own.
*/
*/
+ /**
+ * Tell every player what their own round was: the score the server gave them, their rank in that
+ * round, how many played it, and their own best round so far. The score and the rank come from the
+ * round the server scored; the best is read from the rows we keep (null when they have none).
+ */
+ private async sendRoundResults(
+ ctx: ServerContext,
+ roundId: string,
+ results: { key: string; name: string; score: number; rank: number }[],
+ ): Promise<void> {
+ const sessions = ctx.sessions.all()
+ const bests = await Promise.all(
+ sessions.map(async (session) => {
+ const key = session.subjectId ?? session.token
+ if (!this.integrations.bestScore) return null
+ try {
+ return await this.integrations.bestScore(key)
+ } catch {
+ return null
+ }
+ }),
+ )
+ sessions.forEach((session, index) => {
+ const key = session.subjectId ?? session.token
+ const result = results.find((r) => r.key === key)
+ if (!result) return
+ const own = bests[index] ?? null
+ ctx.net.send(session, {
+ t: 'salvage_result',
+ roundId,
+ score: result.score,
+ rank: result.rank,
+ players: results.length,
+ // The round just finished counts towards the best, even when its row has not landed yet.
+ best: own === null ? null : Math.max(own, result.score),
+ })
+ })
+ }
+
step(): void {
step(): void {
const ctx = this.ctx
const ctx = this.ctx
const s = this.systems
const s = this.systems
@@ -320,7 +361,11 @@ export class GameServer {
@@ -320,7 +361,11 @@ export class GameServer {
pos: [session.move.pos.x, session.move.pos.y, session.move.pos.z],
pos: [session.move.pos.x, session.move.pos.y, session.move.pos.z],
})),
})),
)
)
- if (results) this.roundFinished?.(this.rush.view().roundId, results)
+ if (results) {
+ const roundId = this.rush.view().roundId
+ this.roundFinished?.(roundId, results)
+ void this.sendRoundResults(ctx, roundId, results)
+ }
if (results || now - this.lastRushBroadcast >= 1000) {
if (results || now - this.lastRushBroadcast >= 1000) {
ctx.net.broadcastAll({ t: 'salvage_round', ...this.rush.view() })
ctx.net.broadcastAll({ t: 'salvage_round', ...this.rush.view() })
this.lastRushBroadcast = now
this.lastRushBroadcast = now
@@ -102,6 +102,97 @@ export async function recordJoined(db: Db, key: string, now: number): Promise<vo
@@ -102,6 +102,97 @@ export async function recordJoined(db: Db, key: string, now: number): Promise<vo
}
}
}
}
+/** One row of a finished round, as the round's own board shows it. */
+export interface RoundRow {
+ /** The account the row belongs to (hashed, never the person's id). */
+ accountKey: string
+ playerName: string
+ score: number
+ rank: number
+ finishedAt: number
+}
+
+/**
+ * The rows of one finished round, in rank order. These are the rows the server wrote when the round
+ * ended, so the board a player sees is the round that really happened.
+ */
+export async function roundRows(
+ db: Db,
+ game: GameId,
+ roundId: string,
+): Promise<RoundRow[]> {
+ const rows = await db.many<{
+ account_key: string
+ player_name: string
+ score: number
+ rank: number
+ finished_at: number
+ }>(sql`
+ SELECT account_key, player_name, score, rank, finished_at
+ FROM game_round_results
+ WHERE game_id = ${game} AND round_id = ${roundId}
+ ORDER BY rank ASC, score DESC, player_name ASC
+ `)
+ return rows.map((row) => ({
+ accountKey: row.account_key,
+ playerName: row.player_name,
+ score: Number(row.score) || 0,
+ rank: Number(row.rank) || 0,
+ finishedAt: Number(row.finished_at) || 0,
+ }))
+}
+
+/**
+ * The player's own best round for a game, from the row we keep. Null when they have no round on
+ * record: not measured yet is said plainly rather than shown as a zero.
+ */
+export async function playerBestScore(
+ db: Db,
+ game: GameId,
+ key: string,
+): Promise<number | null> {
+ const account = accountKey(key)
+ const rows = await db.many<{ score: number }>(sql`
+ SELECT score FROM game_best_scores WHERE game_id = ${game} AND account_key = ${account}
+ `)
+ const score = Number(rows[0]?.score)
+ return Number.isFinite(score) ? score : null
+}
+
+/** The player's own best row for a game: the score and the name the board shows. */
+export async function playerBest(
+ db: Db,
+ game: GameId,
+ key: string,
+): Promise<{ score: number; name: string } | null> {
+ const account = accountKey(key)
+ const rows = await db.many<{ score: number; player_name: string }>(sql`
+ SELECT score, player_name FROM game_best_scores
+ WHERE game_id = ${game} AND account_key = ${account}
+ `)
+ const row = rows[0]
+ if (!row) return null
+ const score = Number(row.score)
+ if (!Number.isFinite(score)) return null
+ return { score, name: String(row.player_name ?? '') }
+}
+
+/**
+ * The place a score of this size sits on the board: one more than the number of players with a better
+ * best. Counted from the rows we keep, so it is the rank of the same board the page shows.
+ */
+export async function playerBestRank(
+ db: Db,
+ game: GameId,
+ score: number,
+): Promise<number> {
+ const rows = await db.many<{ better: number }>(sql`
+ SELECT COUNT(*)::bigint AS better FROM game_best_scores
+ WHERE game_id = ${game} AND score > ${score}
+ `)
+ return (Number(rows[0]?.better) || 0) + 1
+}
+
/** The only writer for scores; called by the server's completed-round hook. */
/** The only writer for scores; called by the server's completed-round hook. */
export async function recordRound(
export async function recordRound(
db: Db,
db: Db,
@@ -76,6 +76,11 @@ export interface GameIntegrations {
@@ -76,6 +76,11 @@ export interface GameIntegrations {
roundId: string,
roundId: string,
results: { key: string; name: string; score: number; rank: number }[],
results: { key: string; name: string; score: number; rank: number }[],
) => void
) => void
+ /**
+ * The player's own best score for the game, read from the rows we keep. Null when they have no
+ * score on record yet. Used to tell a player their own best after a round, never to rank anyone.
+ */
+ bestScore?: (key: string) => Promise<number | null>
/** Called for a server-confirmed extraction haul (Scraplandia). Never accepts a client message. */
/** Called for a server-confirmed extraction haul (Scraplandia). Never accepts a client message. */
haulExtracted?: (key: string, extractionId: string, name: string, score: number) => void
haulExtracted?: (key: string, extractionId: string, name: string, score: number) => void
/** A server-confirmed milestone past the first haul (Scraplandia): a contract, a harvest, a skill level. */
/** A server-confirmed milestone past the first haul (Scraplandia): a contract, a harvest, a skill level. */
@@ -83,7 +83,8 @@ import {
@@ -83,7 +83,8 @@ import {
} from './platform/revocationEvents.js'
} from './platform/revocationEvents.js'
import { createStaffAuthorizer } from './platform/staffAuth.js'
import { createStaffAuthorizer } from './platform/staffAuth.js'
import { createWsTicketStore } from './net/wsTicket.js'
import { createWsTicketStore } from './net/wsTicket.js'
-import { GameProgressWriter, type Milestone } from './game/progression.js'
+import { GameProgressWriter, playerBestScore, type Milestone } from './game/progression.js'
+import { handleResultPage } from './net/resultPage.js'
import type { RoundResult } from './game/salvageRush.js'
import type { RoundResult } from './game/salvageRush.js'
import { handleGameRead } from './net/gameRoutes.js'
import { handleGameRead } from './net/gameRoutes.js'
import { createPlayBoards } from './platform/playBoards.js'
import { createPlayBoards } from './platform/playBoards.js'
@@ -417,6 +418,7 @@ async function main(): Promise<void> {
@@ -417,6 +418,7 @@ async function main(): Promise<void> {
roundFinished: (roundId: string, results: RoundResult[]) => {
roundFinished: (roundId: string, results: RoundResult[]) => {
progressWriter.submit(`0:${roundId}`, results)
progressWriter.submit(`0:${roundId}`, results)
},
},
+ bestScore: (key: string) => playerBestScore(db, 'salvage-rush', key),
}
}
: {}),
: {}),
...(primarySurf ? { surf: surfRecords } : {}),
...(primarySurf ? { surf: surfRecords } : {}),
@@ -490,6 +492,7 @@ async function main(): Promise<void> {
@@ -490,6 +492,7 @@ async function main(): Promise<void> {
roundFinished: (roundId: string, results: RoundResult[]) => {
roundFinished: (roundId: string, results: RoundResult[]) => {
progressWriter.submit(`${slot}:${roundId}`, results)
progressWriter.submit(`${slot}:${roundId}`, results)
},
},
+ bestScore: (key: string) => playerBestScore(db, 'salvage-rush', key),
}
}
: {}),
: {}),
...(primarySurf ? { surf: surfRecords } : {}),
...(primarySurf ? { surf: surfRecords } : {}),
@@ -591,6 +594,7 @@ async function main(): Promise<void> {
@@ -591,6 +594,7 @@ async function main(): Promise<void> {
: {
: {
roundFinished: (roundId: string, results: RoundResult[]) =>
roundFinished: (roundId: string, results: RoundResult[]) =>
progressWriter.submit(`${slot}:${roundId}`, results),
progressWriter.submit(`${slot}:${roundId}`, results),
+ bestScore: (key: string) => playerBestScore(db, 'salvage-rush', key),
}),
}),
},
},
)
)
@@ -1100,6 +1104,16 @@ async function main(): Promise<void> {
@@ -1100,6 +1104,16 @@ async function main(): Promise<void> {
config.oauth,
config.oauth,
{
{
handle: (req, res) => {
handle: (req, res) => {
+ // The result page of a Salvage Rush round (/result/<round>): a shareable page with the score,
+ // the rank and the player's own best, read from the rows the server wrote for that round.
+ if (
+ handleResultPage(req, res, {
+ db,
+ networkAuthUrl: config.networkAuthUrl,
+ site: gamesSiteUrl,
+ })
+ )
+ return true
if (req.method === 'GET' && req.url?.split('?')[0] === '/api/v1/content') {
if (req.method === 'GET' && req.url?.split('?')[0] === '/api/v1/content') {
const requestedWorld = new URL(req.url ?? '/', 'http://games.local').searchParams.get(
const requestedWorld = new URL(req.url ?? '/', 'http://games.local').searchParams.get(
'world',
'world',
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest'
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'
+import { applyCardFacts, cardFacts } from './portalHome.js'
+
+const client = fileURLToPath(new URL('../../../client/', import.meta.url))
+
+/**
+ * The home page's cards: the length of a round is the content pack's own number, so the card cannot
+ * drift from the game it describes, and the number is never a sentence someone typed.
+ */
+describe('the game cards of the home page', () => {
+ it('states the length of a Salvage Rush round from the pack', () => {
+ const seconds = SALVAGE_RUSH_PACK.game?.roundSeconds
+ expect(seconds).toBeGreaterThan(0)
+ const facts = cardFacts()
+ expect(facts['salvage-rush-round-tag']).toBe(`<span>${seconds}-second rounds</span>`)
+ })
+
+ it('puts the fact where the card asks for it', () => {
+ const html = applyCardFacts(
+ '<div class="tags"><!--ov:card-salvage-rush-round-tag--><span>on-foot</span></div>',
+ )
+ expect(html).not.toContain('<!--ov:card-')
+ expect(html).toContain(`<span>${SALVAGE_RUSH_PACK.game?.roundSeconds}-second rounds</span>`)
+ expect(html).toContain('<span>on-foot</span>')
+ })
+
+ it('writes no round length into the pages by hand', () => {
+ // The length of a round is the pack's number; a sentence that carries one by hand would
+ // drift from the game the day the pack changes.
+ for (const file of ['index.html', 'play.html', 'leaderboard.html']) {
+ const html = readFileSync(join(client, file), 'utf8')
+ expect(html).not.toMatch(/\d+-second round/)
+ }
+ })
+
+ it('leaves a page without the marker alone', () => {
+ expect(applyCardFacts('<p>nothing here</p>')).toBe('<p>nothing here</p>')
+ })
+})
@@ -1,10 +1,8 @@
@@ -1,10 +1,8 @@
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { Db } from 'openvibe-sdk/db'
import type { Db } from 'openvibe-sdk/db'
-import { isGuestToken } from '../platform/accounts.js'
-import { hashGuestKey } from './guestIdentity.js'
-import { resolveNetworkUser } from './networkAuth.js'
+import { viewerAccountKey } from './viewer.js'
import type { GamesActorLimits } from './actorLimits.js'
import type { GamesActorLimits } from './actorLimits.js'
-import { isGameId, playerAchievements } from '../game/progression.js'
+import { isGameId, playerAchievements, playerBest, playerBestRank } from '../game/progression.js'
import { BoardUnavailable, type PlayBoards } from '../platform/playBoards.js'
import { BoardUnavailable, type PlayBoards } from '../platform/playBoards.js'
interface Options {
interface Options {
@@ -24,8 +22,9 @@ export function handleGameRead(
@@ -24,8 +22,9 @@ export function handleGameRead(
if (req.method !== 'GET') return false
if (req.method !== 'GET') return false
const url = new URL(req.url ?? '/', 'http://games.local')
const url = new URL(req.url ?? '/', 'http://games.local')
const match = /^\/api\/v1\/games\/([^/]+)\/leaderboard$/.exec(url.pathname)
const match = /^\/api\/v1\/games\/([^/]+)\/leaderboard$/.exec(url.pathname)
+ const mine = /^\/api\/v1\/games\/([^/]+)\/me$/.exec(url.pathname)
const achievements = url.pathname === '/api/v1/players/me/achievements'
const achievements = url.pathname === '/api/v1/players/me/achievements'
- if (!match && !achievements) return false
+ if (!match && !achievements && !mine) return false
const send = (status: number, body: unknown): void => {
const send = (status: number, body: unknown): void => {
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
res.end(JSON.stringify(body))
res.end(JSON.stringify(body))
@@ -34,6 +33,10 @@ export function handleGameRead(
@@ -34,6 +33,10 @@ export function handleGameRead(
send(404, { error: 'unknown_game' })
send(404, { error: 'unknown_game' })
return true
return true
}
}
+ if (mine && !isGameId(mine[1] ?? '')) {
+ send(404, { error: 'unknown_game' })
+ return true
+ }
void (async () => {
void (async () => {
if (!(await options.limits.publicRead(req, res, `ip:${options.address(req)}`))) return
if (!(await options.limits.publicRead(req, res, `ip:${options.address(req)}`))) return
if (match) {
if (match) {
@@ -64,20 +67,32 @@ export function handleGameRead(
@@ -64,20 +67,32 @@ export function handleGameRead(
}
}
return
return
}
}
- const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1]
- const guest = req.headers['x-guest-token']
- let key: string | null = null
- if (bearer && options.networkAuthUrl) {
- const user = await resolveNetworkUser(options.networkAuthUrl, bearer)
- key = user?.subjectId ?? null
- } else if (typeof guest === 'string' && isGuestToken(guest)) {
- key = `guest:${hashGuestKey(guest)}`
+ if (mine) {
+ // The viewer's own best for that game, and where it sits on the board we keep.
+ const game = mine[1] as 'scraplandia' | 'salvage-rush'
+ const key = await viewerAccountKey(req, { networkAuthUrl: options.networkAuthUrl })
+ if (!key) {
+ send(200, { best: null, rank: null, name: null })
+ return
+ }
+ const best = await playerBest(options.db, game, key)
+ if (!best) {
+ send(200, { best: null, rank: null, name: null })
+ return
+ }
+ send(200, {
+ best: best.score,
+ rank: await playerBestRank(options.db, game, best.score),
+ name: best.name,
+ })
+ return
}
}
- if (!key) {
+ const viewerKey = await viewerAccountKey(req, { networkAuthUrl: options.networkAuthUrl })
+ if (!viewerKey) {
send(401, { error: 'unauthorized' })
send(401, { error: 'unauthorized' })
return
return
}
}
- send(200, { games: await playerAchievements(options.db, key) })
+ send(200, { games: await playerAchievements(options.db, viewerKey) })
})().catch(() => {
})().catch(() => {
if (!res.headersSent) send(503, { error: 'read_unavailable' })
if (!res.headersSent) send(503, { error: 'read_unavailable' })
})
})
@@ -40,7 +40,7 @@ import {
@@ -40,7 +40,7 @@ import {
AGENT_PAGE_FILES,
AGENT_PAGE_FILES,
type AgentContext,
type AgentContext,
} from './agent.js'
} from './agent.js'
-import type { HomeParts } from './portalHome.js'
+import { applyCardFacts, type HomeParts } from './portalHome.js'
import { legalHandler } from './legalPages.js'
import { legalHandler } from './legalPages.js'
import { updatesHandler } from './updatesPage.js'
import { updatesHandler } from './updatesPage.js'
import { indexnowHandler, type SharedIndexNow } from './indexnow.js'
import { indexnowHandler, type SharedIndexNow } from './indexnow.js'
@@ -63,7 +63,9 @@ export function portalHtml(filePath: string): string {
@@ -63,7 +63,9 @@ export function portalHtml(filePath: string): string {
/** The portal with its live parts (portalHome.ts) in place of the build's markers, and their JSON-LD in the head. */
/** The portal with its live parts (portalHome.ts) in place of the build's markers, and their JSON-LD in the head. */
export function portalPage(filePath: string, parts: HomeParts | null): string {
export function portalPage(filePath: string, parts: HomeParts | null): string {
- const html = portalHtml(filePath)
+ // The card facts come from the content packs, so they are in the page even when the live sections
+ // are not (a slow source must not leave a card without its round length).
+ const html = applyCardFacts(portalHtml(filePath))
if (!parts) return html
if (!parts) return html
return html
return html
.replace('<!--ov:home-stats-->', () => parts.stats)
.replace('<!--ov:home-stats-->', () => parts.stats)
@@ -121,6 +121,29 @@ const REWARDS = new Map<string, string>([
@@ -121,6 +121,29 @@ const REWARDS = new Map<string, string>([
),
),
])
])
+/**
+ * The card facts that come from the content packs, not from a sentence written by hand: the length of a
+ * Salvage Rush round is the pack's own number, so the card cannot drift from the game it describes.
+ * The marker in the page is <!--ov:card-<name>--> and is replaced wherever the page is served.
+ */
+export function cardFacts(): Record<string, string> {
+ const facts: Record<string, string> = {}
+ const roundSeconds = SALVAGE_RUSH_PACK.game?.roundSeconds
+ if (typeof roundSeconds === 'number' && roundSeconds > 0) {
+ facts['salvage-rush-round-tag'] = `<span>${roundSeconds}-second rounds</span>`
+ }
+ return facts
+}
+
+/** Puts the card facts into the page, wherever the markers are. */
+export function applyCardFacts(html: string, facts: Record<string, string> = cardFacts()): string {
+ let out = html
+ for (const [name, value] of Object.entries(facts)) {
+ out = out.replaceAll(`<!--ov:card-${name}-->`, () => value)
+ }
+ return out
+}
+
const esc = (v: unknown): string => escapeHtml(String(v ?? ''))
const esc = (v: unknown): string => escapeHtml(String(v ?? ''))
const fmt = new Intl.NumberFormat('en-US')
const fmt = new Intl.NumberFormat('en-US')
const n = (v: number): string => fmt.format(v)
const n = (v: number): string => fmt.format(v)
@@ -413,7 +436,7 @@ export function homeExtraJsonLd(site: string, playSite: string): unknown[] {
@@ -413,7 +436,7 @@ export function homeExtraJsonLd(site: string, playSite: string): unknown[] {
{
{
name: 'Salvage Rush',
name: 'Salvage Rush',
url: `${site}/play?game=salvage-rush`,
url: `${site}/play?game=salvage-rush`,
- description: '90-second scrap-collecting rounds with a leaderboard.',
+ description: `${SALVAGE_RUSH_PACK.game?.roundSeconds ?? 90}-second scrap-collecting rounds with a leaderboard.`,
},
},
{
{
name: 'Vibe Heroes',
name: 'Vibe Heroes',
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+import { describe, expect, it } from 'vitest'
+import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'
+import { resultPageHtml, resultPageUrl, type ResultPageInput } from './resultPage.js'
+
+const row = (accountKey: string, name: string, score: number, rank: number) => ({
+ accountKey,
+ playerName: name,
+ score,
+ rank,
+ finishedAt: 1_700_000_000_000,
+})
+
+const input = (over: Partial<ResultPageInput> = {}): ResultPageInput => ({
+ roundId: '0:1700000000000-12',
+ rows: [row('guest:aaa', 'Ada', 12, 1), row('guest:bbb', 'Bo', 5, 2)],
+ viewerKey: 'guest:aaa',
+ viewerBest: 12,
+ site: 'https://openvibe.games',
+ ...over,
+})
+
+describe('the result page of a round', () => {
+ it('shows the score, the rank and the best of the player who opened it', () => {
+ const html = resultPageHtml(input())
+ expect(html).toContain('Your result')
+ expect(html).toContain('12')
+ expect(html).toContain('Rank')
+ expect(html).toContain('1 of 2')
+ expect(html).toContain('Best')
+ })
+
+ it('marks the viewer\'s own row on the board of that round', () => {
+ const html = resultPageHtml(input())
+ expect(html).toContain('You')
+ expect(html).toContain('Ada')
+ expect(html).toContain('Bo')
+ })
+
+ it('says so plainly when the viewer has no round on record', () => {
+ const html = resultPageHtml(input({ viewerKey: 'guest:zzz', viewerBest: null }))
+ expect(html).toContain('no round on record yet')
+ })
+
+ it('never invents a score: a round with no rows shows no result', () => {
+ const html = resultPageHtml(input({ rows: [], viewerKey: null, viewerBest: null }))
+ expect(html).toContain('Nobody scored in this round.')
+ expect(html).not.toContain('Your result')
+ })
+
+ it('shares a page, not a screenshot', () => {
+ const html = resultPageHtml(input())
+ expect(resultPageUrl('https://openvibe.games/', '0:1700000000000-12')).toBe(
+ 'https://openvibe.games/result/0%3A1700000000000-12',
+ )
+ expect(html).toContain('data-ovk-share')
+ expect(html).toContain('I scored 12 in Salvage Rush')
+ })
+
+ it('carries the one line every game page carries', () => {
+ expect(resultPageHtml(input())).toContain('Play as a guest, no sign-up.')
+ // kit.answer's rows are Kit 2: without kit-data.css they fall apart into a bare list.
+ expect(resultPageHtml(input())).toContain('href="/shared/kit-data.css"')
+ })
+
+ it('wears the shared kit and the shared theme', () => {
+ const html = resultPageHtml(input())
+ expect(html).toContain('/shared/kit.css')
+ expect(html).toContain('/shared/theme-loader.js')
+ expect(html).toContain('ovk-page')
+ })
+})
+
+describe('the card facts of the home page', () => {
+ it('states the length of a round from the content pack, not from a sentence', () => {
+ const seconds = SALVAGE_RUSH_PACK.game?.roundSeconds
+ expect(seconds).toBeGreaterThan(0)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,190 @@
@@ -0,0 +1,190 @@
+/**
+ * The result page of a Salvage Rush round (the market outcome, 2026-10-30): a page you can send to
+ * someone, with the score, the rank and the player's own best on it. Every number on it is read from the
+ * rows the server wrote when the round ended; nothing here is typed in by hand.
+ *
+ * The page is built with the OpenVibe Kit (openvibe-shared/kit), the same look every OpenVibe app page
+ * has, and wears the reader's theme (dark or light) through the shared tokens.
+ */
+import { createRequire } from 'node:module'
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import type { Db } from 'openvibe-sdk/db'
+import { playerBestScore, roundRows, type RoundRow } from '../game/progression.js'
+import { viewerAccountKey } from './viewer.js'
+
+type Builder = (o: Record<string, unknown>) => string
+interface Kit {
+ esc: (v: unknown) => string
+ icon: (name: string, o?: Record<string, unknown>) => string
+ head: Builder
+ list: (rows: string[], o?: Record<string, unknown>) => string
+ row: Builder
+ section: Builder
+ fold: Builder
+ answer: Builder
+ toolHead: Builder
+ tool: Builder
+ page: Builder
+ button: Builder
+ badge: (text: string, tone?: string) => string
+}
+const sharedRequire = createRequire(import.meta.url)
+const kit = sharedRequire('openvibe-shared/kit') as Kit
+
+/** The game the rounds belong to. */
+export const RESULT_GAME = 'salvage-rush' as const
+
+/** The one line every game page carries: what a player gets, and what they never have to do. */
+export const GAME_PROMISE =
+ 'Play as a guest, no sign-up. No ads inside the games.'
+
+export interface ResultPageInput {
+ /** The round the page is about. */
+ roundId: string
+ /** The round's rows, in rank order. */
+ rows: RoundRow[]
+ /** The viewer's account key, when the request named one. */
+ viewerKey: string | null
+ /** The viewer's own best round, when we keep one. */
+ viewerBest: number | null
+ /** The address the page is served on (the share link). */
+ site: string
+}
+
+const n = (v: number): string => new Intl.NumberFormat('en-US').format(v)
+
+/** The shareable address of a round's result page. */
+export function resultPageUrl(site: string, roundId: string): string {
+ return `${site.replace(/\/+$/, '')}/result/${encodeURIComponent(roundId)}`
+}
+
+function shareText(input: ResultPageInput, mine: RoundRow | null): string {
+ if (!mine) return 'A round of Salvage Rush on OpenVibe.Games'
+ const best = input.viewerBest === null ? null : Math.max(input.viewerBest, mine.score)
+ return [
+ `I scored ${n(mine.score)} in Salvage Rush (rank ${n(mine.rank)} of ${n(input.rows.length)}).`,
+ best === null ? null : `My best round: ${n(best)}.`,
+ ]
+ .filter(Boolean)
+ .join(' ')
+}
+
+/** The page itself, built from the rows of one round. */
+export function resultPageHtml(input: ResultPageInput): string {
+ const mine = input.viewerKey ? (input.rows.find((r) => r.accountKey === input.viewerKey) ?? null) : null
+ const players = input.rows.length
+ const url = resultPageUrl(input.site, input.roundId)
+ const board = input.rows.slice(0, 25).map((row) =>
+ kit.row({
+ title: row.playerName,
+ meta: `${n(row.score)} scrap`,
+ badge: mine && row.accountKey === mine.accountKey ? kit.badge('You', 'accent') : '',
+ sub: `Rank ${n(row.rank)} of ${n(players)}`,
+ }),
+ )
+ const best = input.viewerBest === null ? null : Math.max(input.viewerBest, mine?.score ?? 0)
+ const answer = kit.answer({
+ title: mine ? 'Your result' : 'This round',
+ rows: mine
+ ? [
+ ['Score', n(mine.score)],
+ ['Rank', `${n(mine.rank)} of ${n(players)}`],
+ ['Best', best === null ? 'no round on record yet' : n(best)],
+ ]
+ : input.viewerKey
+ ? [
+ ['Players', n(players)],
+ ['Your best', best === null ? 'no round on record yet' : n(best)],
+ ]
+ : [['Players', n(players)]],
+ })
+ const share = kit.button({
+ href: url,
+ label: 'Share this result',
+ icon: 'send',
+ attrs: { 'data-ovk-share': shareText(input, mine) },
+ })
+ const body = [
+ kit.tool({
+ head: kit.toolHead({
+ icon: 'trophy',
+ title: mine ? 'Round over' : 'A round of Salvage Rush',
+ task: 'The server scored this round; these are its numbers.',
+ }),
+ answer,
+ }),
+ kit.section({
+ title: 'The board of this round',
+ note: players > 0 ? `${n(players)} ${players === 1 ? 'player' : 'players'} scored in it.` : '',
+ body: kit.list(board, { empty: 'Nobody scored in this round.' }),
+ }),
+ kit.section({
+ body:
+ `<div class="ovk-tool-card">${share}</div>` +
+ `<p class="ovk-note">${kit.esc(GAME_PROMISE)}</p>` +
+ kit.button({ href: '/leaderboard.html?game=salvage-rush', label: 'Full leaderboard', kind: 'ghost', icon: 'trophy' }),
+ }),
+ ].join('\n')
+ const html = kit.page({
+ head: kit.head({
+ title: 'A Salvage Rush round',
+ sub: mine ? `You scored ${n(mine.score)} scrap, rank ${n(mine.rank)} of ${n(players)}.` : `Round of ${n(players)} players.`,
+ back: { href: '/play?game=salvage-rush', label: 'Play Salvage Rush' },
+ }),
+ body,
+ })
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>A Salvage Rush round · OpenVibe.Games</title><meta name="robots" content="noindex,follow"><meta name="description" content="A scored round of Salvage Rush on OpenVibe.Games."><script src="/shared/theme-loader.js"></script><link rel="stylesheet" href="/shared/kit.css"><link rel="stylesheet" href="/shared/kit-data.css"></head><body class="ovk-app"><div id="navbar-mount"></div>${html}<script src="/shared/navbar.js" defer></script><script src="/shared/kit-web.js" defer></script></body></html>`
+}
+
+export interface ResultRouteOptions {
+ db: Db
+ networkAuthUrl: string | null
+ /** The public address of the site, for the share link. */
+ site: string
+}
+
+/**
+ * GET /result/<roundId>: the page of one finished round. A round we do not have is a 404, said plainly.
+ */
+export function handleResultPage(
+ req: IncomingMessage,
+ res: ServerResponse,
+ opts: ResultRouteOptions,
+): boolean {
+ if (req.method !== 'GET' && req.method !== 'HEAD') return false
+ const match = /^\/result\/([^/]+)\/?$/.exec(new URL(req.url ?? '/', 'http://games.local').pathname)
+ if (!match) return false
+ const roundId = decodeURIComponent(match[1] ?? '')
+ const send = (status: number, body: string): void => {
+ res.writeHead(status, {
+ 'content-type': 'text/html; charset=utf-8',
+ 'cache-control': 'no-store',
+ })
+ res.end(req.method === 'HEAD' ? undefined : body)
+ }
+ void (async () => {
+ const rows = await roundRows(opts.db, RESULT_GAME, roundId)
+ if (!rows.length) {
+ send(
+ 404,
+ resultPageHtml({
+ roundId,
+ rows: [],
+ viewerKey: null,
+ viewerBest: null,
+ site: opts.site,
+ }),
+ )
+ return
+ }
+ const viewerKey = await viewerAccountKey(req, { networkAuthUrl: opts.networkAuthUrl }).catch(() => null)
+ const viewerBest = viewerKey ? await playerBestScore(opts.db, RESULT_GAME, viewerKey) : null
+ send(
+ 200,
+ resultPageHtml({ roundId, rows, viewerKey, viewerBest, site: opts.site }),
+ )
+ })().catch(() => {
+ if (!res.headersSent) send(503, resultPageHtml({ roundId, rows: [], viewerKey: null, viewerBest: null, site: opts.site }))
+ })
+ return true
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+/**
+ * Who is asking (the viewer), read the way every OpenVibe site reads a person: a signed-in player
+ * presents the Network token the browser keeps in `ovg_sso`, a guest presents the guest token the
+ * browser keeps in `openvibe.token` (or sends in the X-Guest-Token header). What comes back is the
+ * account key the rows in the database are stored under, never a raw token.
+ *
+ * The same helper serves the API reads and the pages, so a page and its API agree on who the viewer is.
+ */
+import type { IncomingMessage } from 'node:http'
+import { isGuestToken } from '../platform/accounts.js'
+import { hashGuestKey } from './guestIdentity.js'
+import { resolveNetworkUser } from './networkAuth.js'
+import { parseCookies } from './sso.js'
+
+/** The cookie the browser keeps its guest token in (client/net/identity.ts). */
+export const GUEST_TOKEN_COOKIE = 'openvibe.token'
+/** The cookie the sign-in leaves the Network token in (client/net/identity.ts). */
+export const SESSION_COOKIE_NAME = 'ovg_sso'
+
+export interface ViewerOptions {
+ /** The Network's auth address, when the site is signed in to it. */
+ networkAuthUrl: string | null
+}
+
+/** The account key of the guest who owns a raw guest token. */
+export function guestAccountKey(rawToken: string): string | null {
+ if (!isGuestToken(rawToken)) return null
+ return `guest:${hashGuestKey(rawToken)}`
+}
+
+/**
+ * The viewer's account key, or null when the request names nobody. Signed in first (the Network token
+ * wins), then the guest token from the header, then the guest token from the cookie.
+ */
+export async function viewerAccountKey(
+ req: IncomingMessage,
+ opts: ViewerOptions,
+): Promise<string | null> {
+ const cookies = parseCookies(req.headers.cookie)
+ const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1]
+ const session = bearer ?? cookies[SESSION_COOKIE_NAME]
+ if (session && opts.networkAuthUrl) {
+ const user = await resolveNetworkUser(opts.networkAuthUrl, session).catch(() => null)
+ if (user?.subjectId) return user.subjectId
+ }
+ const guestHeader = req.headers['x-guest-token']
+ const guest = typeof guestHeader === 'string' ? guestHeader : cookies[GUEST_TOKEN_COOKIE]
+ return guest ? guestAccountKey(guest) : null
+}
@@ -314,6 +314,19 @@ export type ServerMessage =
@@ -314,6 +314,19 @@ export type ServerMessage =
pickups: { x: number; z: number; available: boolean }[]
pickups: { x: number; z: number; available: boolean }[]
scores: { name: string; score: number; rank: number }[]
scores: { name: string; score: number; rank: number }[]
}
}
+ | {
+ /**
+ * A finished round, sent to one player only: their score, their rank in that round, how many
+ * played it and their own best round so far (null when they have none on record). Every value
+ * comes from the round the server scored, never from the client.
+ */
+ t: 'salvage_result'
+ roundId: string
+ score: number
+ rank: number
+ players: number
+ best: number | null
+ }
| ServerWelcome
| ServerWelcome
| ServerCvarState
| ServerCvarState
| ServerTransferResult
| ServerTransferResult
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/client/src/ui/rushResult.test.ts'
+// covers: 'apps/client/src/ui'
+// Generated by node test/run.js --index; do not edit.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/server/src/net/cardFacts.test.ts'
+// covers: 'apps/server/src/net'
+// Generated by node test/run.js --index; do not edit.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/server/src/net/resultPage.test.ts'
+// covers: 'apps/server/src/net'
+// Generated by node test/run.js --index; do not edit.