openvibers/openvibe.games Commits

Games: the result of a round, and the board a player stands on (#220)

OP OpenVibers committed 13 days ago · ff30934f13 · 28 files, +995 −51

* Games: the result of a round, and the board a player stands on

The market outcomes for Games ask for one thing above the rest: a player finishes a
round and sees their score, their rank and their own best, on something they can send
to someone.

- a finished round now answers that player alone (salvage_result): the score the server
  gave them, their rank in that round, how many played it and their own best
- the round's result page (/result/<round>) is built with the OpenVibe Kit and reads the
  rows the server wrote for that round, so the numbers are the round that happened; the
  share button shares that page, not a screenshot
- the leaderboard marks the viewer's own row and says their best and rank, read from the
  rows we keep
- a game card states the length of a round from the content pack, and a test fails if a
  page ever carries a round length written by hand
- the first screen of a game says what to do in one sentence, and every game page carries
  the one line: no ads, no tracking, play as a guest with no sign-up, nothing to buy in
  the game

No new table: every number is read from the rows the server already keeps
(game_round_results, game_best_scores).

* The game promise says only what stays true: guest play, no ads inside the games

* Result page loads kit-data.css, so its score rows are a table, not a bare list

Changes

ddiff --git a/apps/client/index.html b/apps/client/index.html
iindex f76bd22..e903f65 100644
---- a/apps/client/index.html
++++ b/apps/client/index.html
@@@ -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 of
heroes on your phone or join a world another player built. Nothing to install: play as a
guest, or sign in to keep your character across OpenVibe.
</p>
@@@ -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 each
pickup; rounds have a public leaderboard and three achievements that give items worn
across 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>
ddiff --git a/apps/client/leaderboard.html b/apps/client/leaderboard.html
iindex b84f60f..d479a0b 100644
---- a/apps/client/leaderboard.html
++++ b/apps/client/leaderboard.html
@@@ -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 light
page 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">
ddiff --git a/apps/client/play.html b/apps/client/play.html
iindex 3a57491..5b4032a 100644
---- a/apps/client/play.html
++++ b/apps/client/play.html
@@@ -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
ddiff --git a/apps/client/src/casual/views/home.ts b/apps/client/src/casual/views/home.ts
iindex 6be0e5a..9318746 100644
---- a/apps/client/src/casual/views/home.ts
++++ b/apps/client/src/casual/views/home.ts
@@@ -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 }))
}
ddiff --git a/apps/client/src/main.ts b/apps/client/src/main.ts
iindex e0bc1f4..0de8c95 100644
---- a/apps/client/src/main.ts
++++ b/apps/client/src/main.ts
@@@ -783,6 +783,7 @@ async function start(): Promise<void> {
let rejected = false
connection.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)
ddiff --git a/apps/client/src/pages/leaderboard.ts b/apps/client/src/pages/leaderboard.ts
iindex 6d18253..757dc0b 100644
---- a/apps/client/src/pages/leaderboard.ts
++++ b/apps/client/src/pages/leaderboard.ts
@@@ -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 HTMLElement
const lede = document.getElementById('lede') as HTMLElement
const scoreHeader = document.getElementById('score-header') as HTMLElement
const playLink = document.getElementById('play-link') as HTMLAnchorElement
++const youLine = document.getElementById('you') as HTMLElement
const 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)
ddiff --git a/apps/client/src/ui/characterSelect.ts b/apps/client/src/ui/characterSelect.ts
iindex bd72d98..f92e318 100644
---- a/apps/client/src/ui/characterSelect.ts
++++ b/apps/client/src/ui/characterSelect.ts
@@@ -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)
})
ddiff --git a/apps/client/src/ui/gamePromise.ts b/apps/client/src/ui/gamePromise.ts
nnew file mode 100644
iindex 0000000..0ff1668
---- /dev/null
++++ b/apps/client/src/ui/gamePromise.ts
@@@ -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.'
ddiff --git a/apps/client/src/ui/rushResult.css b/apps/client/src/ui/rushResult.css
nnew file mode 100644
iindex 0000000..5511358
---- /dev/null
++++ b/apps/client/src/ui/rushResult.css
@@@ -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;
++}
ddiff --git a/apps/client/src/ui/rushResult.test.ts b/apps/client/src/ui/rushResult.test.ts
nnew file mode 100644
iindex 0000000..e374d42
---- /dev/null
++++ b/apps/client/src/ui/rushResult.test.ts
@@@ -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',
++ )
++ })
++})
ddiff --git a/apps/client/src/ui/rushResult.ts b/apps/client/src/ui/rushResult.ts
nnew file mode 100644
iindex 0000000..f52a34c
---- /dev/null
++++ b/apps/client/src/ui/rushResult.ts
@@@ -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
++}
ddiff --git a/apps/client/src/ui/salvageRushHud.ts b/apps/client/src/ui/salvageRushHud.ts
iindex 43f824e..6b470ae 100644
---- a/apps/client/src/ui/salvageRushHud.ts
++++ b/apps/client/src/ui/salvageRushHud.ts
@@@ -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: HTMLElement
private timer: number | null = null
private pickups: Mesh[] = []
++ private result: HTMLElement | null = null
constructor(
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 = () => {
ddiff --git a/apps/client/src/ui/styles.css b/apps/client/src/ui/styles.css
iindex 4d8292e..dc406d7 100644
---- a/apps/client/src/ui/styles.css
++++ b/apps/client/src/ui/styles.css
@@@ -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;
ddiff --git a/apps/server/src/game/gameServer.ts b/apps/server/src/game/gameServer.ts
iindex 120bb82..ea20c0a 100644
---- a/apps/server/src/game/gameServer.ts
++++ b/apps/server/src/game/gameServer.ts
@@@ -57,6 +57,7 @@ export class GameServer {
private lastRushBroadcast = 0
private readonly roundFinished?: GameIntegrations['roundFinished']
private readonly playerJoined?: GameIntegrations['playerJoined']
++ private readonly integrations: GameIntegrations
constructor(
config: ServerConfig,
@@@ -72,6 +73,7 @@ export class GameServer {
: null
this.roundFinished = integrations.roundFinished
this.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.ctx
const 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
ddiff --git a/apps/server/src/game/progression.ts b/apps/server/src/game/progression.ts
iindex bdba3a8..89c8728 100644
---- a/apps/server/src/game/progression.ts
++++ b/apps/server/src/game/progression.ts
@@@ -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,
ddiff --git a/apps/server/src/game/systems/context.ts b/apps/server/src/game/systems/context.ts
iindex c440896..e72d9d6 100644
---- a/apps/server/src/game/systems/context.ts
++++ b/apps/server/src/game/systems/context.ts
@@@ -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. */
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex cda64ac..8ab0528 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -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 true
if (req.method === 'GET' && req.url?.split('?')[0] === '/api/v1/content') {
const requestedWorld = new URL(req.url ?? '/', 'http://games.local').searchParams.get(
'world',
ddiff --git a/apps/server/src/net/cardFacts.test.ts b/apps/server/src/net/cardFacts.test.ts
nnew file mode 100644
iindex 0000000..f77c6ff
---- /dev/null
++++ b/apps/server/src/net/cardFacts.test.ts
@@@ -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>')
++ })
++})
ddiff --git a/apps/server/src/net/gameRoutes.ts b/apps/server/src/net/gameRoutes.ts
iindex bbd4620..7aa4e05 100644
---- a/apps/server/src/net/gameRoutes.ts
++++ b/apps/server/src/net/gameRoutes.ts
@@@ -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 false
const 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 false
const 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)}`))) return
if (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' })
})
ddiff --git a/apps/server/src/net/httpServer.ts b/apps/server/src/net/httpServer.ts
iindex 5f18125..ea4eeb9 100644
---- a/apps/server/src/net/httpServer.ts
++++ b/apps/server/src/net/httpServer.ts
@@@ -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 html
return html
.replace('<!--ov:home-stats-->', () => parts.stats)
ddiff --git a/apps/server/src/net/portalHome.ts b/apps/server/src/net/portalHome.ts
iindex f7d6831..6f46e8f 100644
---- a/apps/server/src/net/portalHome.ts
++++ b/apps/server/src/net/portalHome.ts
@@@ -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',
ddiff --git a/apps/server/src/net/resultPage.test.ts b/apps/server/src/net/resultPage.test.ts
nnew file mode 100644
iindex 0000000..3f4e641
---- /dev/null
++++ b/apps/server/src/net/resultPage.test.ts
@@@ -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)
++ })
++})
ddiff --git a/apps/server/src/net/resultPage.ts b/apps/server/src/net/resultPage.ts
nnew file mode 100644
iindex 0000000..557c602
---- /dev/null
++++ b/apps/server/src/net/resultPage.ts
@@@ -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
++}
ddiff --git a/apps/server/src/net/viewer.ts b/apps/server/src/net/viewer.ts
nnew file mode 100644
iindex 0000000..8e9c581
---- /dev/null
++++ b/apps/server/src/net/viewer.ts
@@@ -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
++}
ddiff --git a/packages/protocol/src/messages/server.ts b/packages/protocol/src/messages/server.ts
iindex b380c92..6cfea5f 100644
---- a/packages/protocol/src/messages/server.ts
++++ b/packages/protocol/src/messages/server.ts
@@@ -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
| ServerTransferResult
ddiff --git a/test/apps__client__src__ui__rushResult.test.js b/test/apps__client__src__ui__rushResult.test.js
nnew file mode 100644
iindex 0000000..99840dd
---- /dev/null
++++ b/test/apps__client__src__ui__rushResult.test.js
@@@ -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.
ddiff --git a/test/apps__server__src__net__cardFacts.test.js b/test/apps__server__src__net__cardFacts.test.js
nnew file mode 100644
iindex 0000000..5e1535c
---- /dev/null
++++ b/test/apps__server__src__net__cardFacts.test.js
@@@ -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.
ddiff --git a/test/apps__server__src__net__resultPage.test.js b/test/apps__server__src__net__resultPage.test.js
nnew file mode 100644
iindex 0000000..4ac12fd
---- /dev/null
++++ b/test/apps__server__src__net__resultPage.test.js
@@@ -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.