openvibers/openvibe.games Commits

Games M5: Salvage Rush (a second game in the same process), achievements and leaderboards (#46)

OP OpenVibers committed 12 Oct 2026 · 98576a3a41 · 54 files, +2144 −80

Milestone M5 (first part): a second game on the same base, plus achievements and leaderboards inside Games (ADR-0007).

**Salvage Rush**
- **Content:** its own `games-content@2` pack and place: a compact arena map, with rules as data and a round system enabled only in its rooms.
- **Rounds:** 90 seconds of on-foot scrap collection, then an intermission. Scores per player per round are server-authoritative; clients only send intents.
- **One process for both places:** Scraplandia and Salvage Rush each get their own content, map, world rows, room hub and fenced instance leases (M4).
  - `hello.place` picks the place; a missing or unknown place falls back to `GAMES_PLACE_ID`, so old clients behave as before.
  - Transfers stay within a place.
  - No second process, portal rewrite or `GAMES_SALVAGE_RUSH_URL` is needed.
- **Client:** the HUD (phase, a large timer that turns amber in the last 10 seconds, ranked scores with the top three marked) is lazy-loaded, so it is not in the initial bundle (57 KB). It is compact on phones.

**Achievements and leaderboards**
- **Data:** migration 0004 (`game_round_results`, best scores, unlocks). Achievement definitions are data in the content pack, evaluated on the server from round results.
  - Guests are keyed like characters are; signed-in players by subject.
  - Guest adoption, subject merges, account export and erasure cover the new tables.
- **API:** `GET /api/v1/games/:game/leaderboard?period=all|week` (public, paginated, rate-limited like other public reads; the week starts Monday 00:00 UTC) and `GET /api/v1/players/me/achievements`, plus an in-game Achievements tab.
- **`/leaderboard.html`:** Salvage Rush's board with All time and This week tabs and top-three rank badges, in the front page's palette. Scraplandia has no scored mode yet, so it has no board.
- **Front page:** Salvage Rush is a full-width card like Scraplandia's, with "Play a round" and a Leaderboard link.
- **ADR-0007:** records that OpenVibe.Play (a separate service) is the later home for progression, seeded by this data model.

**Checks**
- `pnpm build`, `typecheck`, `lint`, Prettier on the changed files, `git diff --check`.
- The full suite, socket tests included (`node test/run.js`: 116 files passed).
- Bundle: initial play JS is 2.35 MB raw / 578.6 KB gzip against a 2.5 MB budget. The over-limit Babylon and Rapier chunks are the existing report-only ones.

Built with Codex (game, data model, single-process places); design pass on the HUD, leaderboard and card in review.

Changes

ddiff --git a/README.md b/README.md
iindex 0aa514c..d157f53 100644
---- a/README.md
++++ b/README.md
@@@ -1,8 +1,11 @@
--# Scraplandia
++# OpenVibe.Games
## Purpose
--OpenVibe.Games: Scraplandia, served at https://openvibe.games and https://play.openvibe.games.
++OpenVibe.Games hosts Scraplandia at https://openvibe.games and https://play.openvibe.games.
++The same Games process also hosts Salvage Rush, an on-foot timed collection game with its own
++place, world, rooms and round system. Play it at `/play?game=salvage-rush` on the same host.
++See [docs/games.md](docs/games.md) for both games and local progression.
A persistent multiplayer physics sandbox for the browser: Source-style movement,
physgun-driven building from crafted physical objects, gathering, crafting, and
extraction risk — running against an authoritative dedicated server.
@@@ -112,7 +115,8 @@ DATABASE_URL=postgres://… DATABASE_DIRECT_URL=postgres://… STATIC_DIR=apps/c
`DATABASE_URL` (pooled, through PgBouncer) serves; migrations run at boot on `DATABASE_DIRECT_URL`
(the owner role, a direct session). With neither set and `NODE_ENV` not `production`, the server uses
an embedded PGlite database under `DATABASE_DIR` (default `data/`) — development only, one process.
--`GAMES_PLACE_ID` (default `scraplandia`) is the place every world row belongs to; `VALKEY_URL`
++`GAMES_PLACE_ID` (default `scraplandia`) selects the default place for older clients and the
++edited map. Both built-in places have separate world rows and instance leases. `VALKEY_URL`
(optional) makes the per-actor limits and the WebSocket upgrade tickets shared across instances
(issue is `SET … PX … NX`, consume is `GETDEL`, so a ticket is single-use cluster-wide).
`GAMES_INSTANCES_PER_PLACE` (default `1`) enables several room simulations for a place in this process;
@@@ -173,7 +177,7 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjects
`games.skill.leveled`, `games.blueprint.unlocked`, `games.world.saved`
(at most one checkpoint per `WORLD_SAVED_EVENT_MINUTES`, plus shutdown) and
`games.mod.installed|enabled|disabled|grants_changed|revoked`. There is no
-- achievement system in Scraplandia, so there are no achievement events.
++ first-visit achievement is stored locally in Games; achievement events are not yet published.
Progression events come from what a save actually wrote, in the same
transaction.
- **Media.** Map-editor assets (textures, paint masks, glb models; the only
ddiff --git a/STATUS.json b/STATUS.json
iindex 72fd1da..c923a7e 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -2,7 +2,7 @@
"repository": "OpenVibers/OpenVibe.Games",
"stage": "stable",
"code": true,
-- "product": "Scraplandia",
++ "product": "Scraplandia and Salvage Rush",
"runtime": "node 22, TypeScript (tsx), pnpm workspace; Babylon.js (client) + deterministic Rapier physics; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in development); Valkey optional",
"port": 8000,
"domain": "openvibe.games, play.openvibe.games",
@@@ -26,6 +26,7 @@
"Source-style movement, physgun building, gathering, crafting, skills",
"persistent world, characters and props in PostgreSQL (write-behind flusher; the tick never awaits I/O)",
"M4 in working tree (not deployed): one-process rooms, PostgreSQL instance registry and fenced leases, capacity-based join, same-place transfer, per-room position, grid-cell interest and socketless load probe",
++ "M5 first part in working tree (not deployed): one Games process hosts Scraplandia and Salvage Rush in separate place and instance registries; server-scored rounds, per-game achievements and paginated all-time/week leaderboards in Games",
"map editor with assets mirrored to OpenVibe.Media",
"declarative mods (games-content@2: definitions merged into the content registry at boot, placements, announcements) with per-install grants and audit",
"server script mods (games-quickjs@1): isolated QuickJS sandboxes, per-binding grants, per-mod/place PostgreSQL storage, seeded randomness, multi-file imports, staff installs and reload, active definition delivery to new clients; off unless GAMES_SCRIPT_MODS=1",
ddiff --git a/apps/client/index.html b/apps/client/index.html
iindex 9b026e0..b880f37 100644
---- a/apps/client/index.html
++++ b/apps/client/index.html
@@@ -268,6 +268,39 @@
.play-cta:hover {
transform: translateY(-3px) scale(1.02);
}
++ /* A second game: full width like the featured card, with a shorter shot. */
++ .game-card.wide {
++ grid-column: 1 / -1;
++ display: grid;
++ grid-template-columns: minmax(220px, 4fr) minmax(260px, 5fr);
++ }
++ .game-card.wide .shot {
++ min-height: 200px;
++ }
++ @media (max-width: 760px) {
++ .game-card.wide {
++ grid-template-columns: 1fr;
++ }
++ }
++ .cta-row {
++ display: flex;
++ flex-wrap: wrap;
++ align-items: center;
++ gap: 10px 22px;
++ margin-top: 6px;
++ }
++ .cta-row .play-cta {
++ margin-top: 0;
++ }
++ .card-link {
++ color: var(--accent-light);
++ font-weight: 700;
++ text-decoration: none;
++ border-bottom: 1px solid rgba(96, 165, 250, 0.35);
++ }
++ .card-link:hover {
++ border-bottom-color: var(--accent-light);
++ }
@keyframes pulse {
0%,
100% {
@@@ -421,6 +454,24 @@
>
</div>
</div>
++ <div class="game-card wide">
++ <div class="shot" role="img" aria-label="Salvage Rush arena"><span>⚙️⏱️</span></div>
++ <div class="game-body">
++ <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
++ pickup; rounds have a public leaderboard and three achievements.
++ </p>
++ <div class="tags">
++ <span>timed rounds</span><span>on-foot</span><span>leaderboard</span>
++ </div>
++ <div class="cta-row">
++ <a class="play-cta" href="/play?game=salvage-rush">▶ PLAY A ROUND</a>
++ <a class="card-link" href="/leaderboard.html">Leaderboard →</a>
++ </div>
++ </div>
++ </div>
</section>
<section class="account">
ddiff --git a/apps/client/leaderboard.html b/apps/client/leaderboard.html
nnew file mode 100644
iindex 0000000..6bea2af
---- /dev/null
++++ b/apps/client/leaderboard.html
@@@ -0,0 +1,249 @@
++<!doctype html>
++<html lang="en">
++ <head>
++ <meta charset="UTF-8" />
++ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
++ <title>Salvage Rush leaderboard · OpenVibe.Games</title>
++ <meta
++ name="description"
++ content="The best Salvage Rush round scores on OpenVibe.Games, all time and this week. Scores come only from rounds the server played out."
++ />
++ <style>
++ :root {
++ color-scheme: dark;
++ --accent: #3b82f6;
++ --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;
++ }
++ * {
++ box-sizing: border-box;
++ }
++ body {
++ margin: 0;
++ min-height: 100vh;
++ color: var(--text);
++ background:
++ radial-gradient(900px 420px at 20% -10%, rgba(59, 130, 246, 0.18), transparent 70%),
++ radial-gradient(700px 360px at 90% 0%, rgba(34, 211, 238, 0.1), transparent 70%),
++ var(--bg);
++ font:
++ 16px/1.5 system-ui,
++ -apple-system,
++ sans-serif;
++ }
++ a {
++ color: var(--accent-light);
++ }
++ main {
++ max-width: 760px;
++ margin: 0 auto;
++ padding: 20px 16px 64px;
++ }
++ .crumbs {
++ display: flex;
++ gap: 14px;
++ font-size: 14px;
++ }
++ .eyebrow {
++ margin: 28px 0 4px;
++ font-size: 12px;
++ font-weight: 800;
++ letter-spacing: 0.14em;
++ color: var(--secondary);
++ }
++ h1 {
++ margin: 0 0 6px;
++ font-size: clamp(28px, 5vw, 40px);
++ font-weight: 900;
++ letter-spacing: 0.01em;
++ }
++ .lede {
++ margin: 0 0 18px;
++ color: var(--text-dim);
++ }
++ .board {
++ border: 1px solid var(--border);
++ border-radius: 18px;
++ background: var(--bg-card);
++ overflow: hidden;
++ }
++ .tabs {
++ display: flex;
++ gap: 6px;
++ padding: 12px;
++ border-bottom: 1px solid var(--border);
++ }
++ .tabs button {
++ flex: 0 0 auto;
++ padding: 8px 16px;
++ border: 1px solid var(--border);
++ border-radius: 999px;
++ background: transparent;
++ color: var(--text-dim);
++ font: inherit;
++ font-weight: 650;
++ cursor: pointer;
++ }
++ .tabs button[aria-pressed='true'] {
++ border-color: var(--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-offset: 2px;
++ }
++ table {
++ width: 100%;
++ border-collapse: collapse;
++ font-variant-numeric: tabular-nums;
++ }
++ th,
++ td {
++ padding: 12px 16px;
++ text-align: left;
++ border-bottom: 1px solid var(--border);
++ }
++ th {
++ font-size: 12px;
++ letter-spacing: 0.08em;
++ text-transform: uppercase;
++ color: var(--text-faint);
++ font-weight: 700;
++ }
++ th:last-child,
++ td:last-child {
++ text-align: right;
++ }
++ td:last-child {
++ font-weight: 800;
++ }
++ tbody tr:last-child td {
++ border-bottom: 0;
++ }
++ .rank {
++ display: inline-grid;
++ place-items: center;
++ min-width: 30px;
++ height: 30px;
++ padding: 0 6px;
++ border-radius: 999px;
++ border: 1px solid var(--border);
++ font-weight: 800;
++ font-size: 14px;
++ }
++ tr.top-1 .rank {
++ background: linear-gradient(135deg, #fde68a, #f59e0b);
++ color: #1a1405;
++ border: 0;
++ }
++ tr.top-2 .rank {
++ background: linear-gradient(135deg, #e5e7eb, #9ca3af);
++ color: #111827;
++ border: 0;
++ }
++ tr.top-3 .rank {
++ background: linear-gradient(135deg, #fdba74, #c2410c);
++ color: #1c0d04;
++ border: 0;
++ }
++ .name {
++ overflow-wrap: anywhere;
++ }
++ #status {
++ margin: 0;
++ padding: 28px 16px;
++ text-align: center;
++ color: var(--text-dim);
++ }
++ #status:empty {
++ display: none;
++ }
++ .pager {
++ display: flex;
++ align-items: center;
++ justify-content: space-between;
++ gap: 12px;
++ padding: 12px 16px;
++ border-top: 1px solid var(--border);
++ color: var(--text-faint);
++ font-size: 14px;
++ }
++ .pager button {
++ padding: 7px 14px;
++ border: 1px solid var(--border);
++ border-radius: 10px;
++ background: var(--bg-raised);
++ color: var(--text);
++ font: inherit;
++ cursor: pointer;
++ }
++ .pager button:disabled {
++ opacity: 0.45;
++ cursor: default;
++ }
++ .play {
++ display: inline-block;
++ margin-top: 22px;
++ padding: 13px 28px;
++ border-radius: 12px;
++ background: linear-gradient(120deg, var(--accent), #2563eb);
++ color: #fff;
++ font-weight: 800;
++ letter-spacing: 0.03em;
++ text-decoration: none;
++ }
++ .note {
++ margin-top: 14px;
++ font-size: 14px;
++ color: var(--text-faint);
++ }
++ </style>
++ <script type="module" src="/src/pages/leaderboard.ts"></script>
++ </head>
++ <body>
++ <main>
++ <nav class="crumbs" aria-label="Breadcrumb">
++ <a href="/">OpenVibe.Games</a><a href="/play?game=salvage-rush">Play Salvage Rush</a>
++ </nav>
++ <p class="eyebrow">LEADERBOARD</p>
++ <h1>Salvage Rush</h1>
++ <p class="lede">
++ Each player's best round score. Rounds last 90 seconds; every point comes from a pickup the
++ server counted.
++ </p>
++ <section class="board" aria-labelledby="board-title">
++ <h2 id="board-title" hidden>Best scores</h2>
++ <div class="tabs" role="group" aria-label="Period">
++ <button type="button" data-period="all" aria-pressed="true">All time</button>
++ <button type="button" data-period="week" aria-pressed="false">This week</button>
++ </div>
++ <table>
++ <thead>
++ <tr>
++ <th scope="col">Rank</th>
++ <th scope="col">Player</th>
++ <th scope="col">Best score</th>
++ </tr>
++ </thead>
++ <tbody id="entries"></tbody>
++ </table>
++ <p id="status" role="status"></p>
++ <div class="pager">
++ <button id="previous" type="button">Previous</button><span id="page"></span
++ ><button id="next" type="button">Next</button>
++ </div>
++ </section>
++ <a class="play" href="/play?game=salvage-rush">▶ Play a round</a>
++ <p class="note">"This week" starts on Monday, 00:00 UTC.</p>
++ </main>
++ </body>
++</html>
ddiff --git a/apps/client/src/main.ts b/apps/client/src/main.ts
iindex 73f5a17..1a2a164 100644
---- a/apps/client/src/main.ts
++++ b/apps/client/src/main.ts
@@@ -67,24 +67,37 @@ async function start(): Promise<void> {
const uiRoot = document.getElementById('ui') as HTMLElement
const identity = getIdentity()
++ const requestedGame = new URLSearchParams(location.search).get('game')
++ const selectedPlace =
++ requestedGame === 'salvage-rush' || requestedGame === 'scraplandia' ? requestedGame : undefined
++ const placeQuery = selectedPlace ? `?game=${selectedPlace}` : ''
// Fetch the active definition set before constructing game systems or sending hello.
// A fresh client can join after a staff script install or reload changed the digest.
let content = createContent()
try {
-- const response = await fetch('/api/v1/content', { cache: 'no-store' })
++ const response = await fetch(`/api/v1/content${placeQuery}`, { cache: 'no-store' })
if (response.ok) {
const data = (await response.json()) as {
digest?: string
++ game?: string
packs?: Parameters<typeof createContent>[0]
}
if (Array.isArray(data.packs)) {
-- const active = createContent(data.packs)
++ const builtIn =
++ data.game === 'salvage-rush'
++ ? (await import('@openvibe/content/salvage-rush')).SALVAGE_RUSH_PACK
++ : undefined
++ const active = createContent(data.packs, builtIn)
if (active.digest === data.digest) content = active
}
}
} catch {
// The hello digest still refuses a mismatched join if content is unavailable.
}
++ if (requestedGame === 'salvage-rush' && content.world.id !== 'salvage_rush_arena') {
++ uiRoot.textContent = 'Salvage Rush content is unavailable. Please retry shortly.'
++ return
++ }
// Edited map (must match the server's copy for prediction parity).
// The map as the runtime currently has it, so a reload can diff against
// it rather than rebuilding every category.
@@@ -92,7 +105,7 @@ async function start(): Promise<void> {
let mapLightsBoot: MapLight[] | undefined
try {
// /map.json is the v2 document, parsed and validated on the way in.
-- const parsed = parseMapFile(await (await fetch('/map.json')).json())
++ const parsed = parseMapFile(await (await fetch(`/map.json${placeQuery}`)).json())
if (parsed.ok) {
registerMapAssets({
textures: parsed.map.textures as MapTextureEntry[],
@@@ -196,7 +209,16 @@ async function start(): Promise<void> {
const weaponSettings = new WeaponSettings()
registerPhysgunModule()
registerRiggingModule()
-- const hud = new Hud(uiRoot, state, content, connection, icons, weaponSettings)
++ const hud = new Hud(uiRoot, state, content, connection, icons, weaponSettings, {
++ guest: identity.token,
++ ...(sso ? { bearer: sso } : {}),
++ })
++ const roundUi =
++ content.world.id === 'salvage_rush_arena'
++ ? await import('./ui/salvageRushHud.js').then(
++ ({ SalvageRushHud }) => new SalvageRushHud(uiRoot, scene),
++ )
++ : null
hud.onQualityChange = (override) => {
const quality = qualityForTier(resolveQualityTier(override, readDeviceSignals()))
applyEngineQuality(engine, quality)
@@@ -343,6 +365,7 @@ async function start(): Promise<void> {
})
let rejected = false
connection.onMessage = (msg) => {
++ if (msg.t === 'salvage_round') roundUi?.update(msg)
if (msg.t === 'welcome' && state.myEntityId && msg.instanceId !== state.instanceId) {
player.onTransfer()
}
@@@ -358,7 +381,7 @@ async function start(): Promise<void> {
if (msg.t === 'map_reload') {
void (async () => {
try {
-- const parsed = parseMapFile(await (await fetch('/map.json')).json())
++ const parsed = parseMapFile(await (await fetch(`/map.json${placeQuery}`)).json())
if (!parsed.ok) return
registerMapAssets({
textures: parsed.map.textures as MapTextureEntry[],
@@@ -482,6 +505,7 @@ async function start(): Promise<void> {
content.digest,
slot,
sso,
++ selectedPlace,
)
} catch {
// Refused or timed out (a page opened during a restart): wait for the server, then reload.
ddiff --git a/apps/client/src/net/connection.ts b/apps/client/src/net/connection.ts
iindex 1f45930..8f09a4f 100644
---- a/apps/client/src/net/connection.ts
++++ b/apps/client/src/net/connection.ts
@@@ -25,8 +25,17 @@ export function helloForContent(
appearance: Appearance,
slot: number,
contentDigest: string,
++ place?: string,
): ClientHello {
-- return { t: 'hello', v: PROTOCOL_VERSION, contentDigest, slot, name, appearance }
++ return {
++ t: 'hello',
++ v: PROTOCOL_VERSION,
++ contentDigest,
++ slot,
++ name,
++ appearance,
++ ...(place ? { place } : {}),
++ }
}
/** How long the first WebSocket connect may take before the page falls back to waiting for the server. */
@@@ -95,6 +104,7 @@ export class Connection {
slot = 0,
/** Network access token; ignored if absent (the identity is the ticket). */
networkToken?: string,
++ place?: string,
): Promise<void> {
const ticket = await fetchWsTicket({
...(networkToken ? { networkToken } : {}),
@@@ -138,7 +148,7 @@ export class Connection {
}
ws.onclose = () => this.onClose?.()
// Identity was resolved at the upgrade; hello carries no identity field.
-- this.send(helloForContent(name, appearance, slot, contentDigest))
++ this.send(helloForContent(name, appearance, slot, contentDigest, place))
}
send(msg: ClientMessage): void {
ddiff --git a/apps/client/src/pages/leaderboard.ts b/apps/client/src/pages/leaderboard.ts
nnew file mode 100644
iindex 0000000..c7071ff
---- /dev/null
++++ b/apps/client/src/pages/leaderboard.ts
@@@ -0,0 +1,82 @@
++export {}
++
++interface Entry {
++ rank: number
++ name: string
++ score: number
++}
++// Salvage Rush is the only game with scored rounds today; Scraplandia has no scored mode, so it has no board here.
++const GAME = 'salvage-rush'
++const tabs = Array.from(document.querySelectorAll<HTMLButtonElement>('.tabs button[data-period]'))
++const status = document.getElementById('status') as HTMLElement
++const entries = document.getElementById('entries') as HTMLElement
++const pageLabel = document.getElementById('page') as HTMLElement
++const previous = document.getElementById('previous') as HTMLButtonElement
++const next = document.getElementById('next') as HTMLButtonElement
++let period: 'all' | 'week' =
++ new URLSearchParams(location.search).get('period') === 'week' ? 'week' : 'all'
++let page = 1
++
++function cell(text: string, className?: string): HTMLTableCellElement {
++ const td = document.createElement('td')
++ if (className) {
++ const span = document.createElement('span')
++ span.className = className
++ span.textContent = text
++ td.append(span)
++ } else td.textContent = text
++ return td
++}
++
++async function load(): Promise<void> {
++ for (const tab of tabs) tab.setAttribute('aria-pressed', String(tab.dataset.period === period))
++ status.textContent = 'Loading…'
++ entries.replaceChildren()
++ previous.disabled = page === 1
++ next.disabled = true
++ pageLabel.textContent = `Page ${page}`
++ try {
++ 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[]; pageSize: number }
++ for (const entry of data.entries) {
++ const row = document.createElement('tr')
++ if (entry.rank <= 3) row.className = `top-${entry.rank}`
++ row.append(
++ cell(String(entry.rank), 'rank'),
++ cell(entry.name, 'name'),
++ cell(entry.score.toLocaleString('en-US')),
++ )
++ entries.append(row)
++ }
++ status.textContent = data.entries.length
++ ? ''
++ : period === 'week'
++ ? 'No rounds scored this week yet. Play one and take first place.'
++ : 'No rounds scored yet. Play one and take first place.'
++ next.disabled = data.entries.length < data.pageSize
++ } catch {
++ status.textContent = 'The leaderboard is unavailable right now. Try again in a minute.'
++ }
++}
++
++for (const tab of tabs) {
++ tab.onclick = () => {
++ const chosen = tab.dataset.period === 'week' ? 'week' : 'all'
++ if (chosen === period) return
++ period = chosen
++ page = 1
++ void load()
++ }
++}
++previous.onclick = () => {
++ if (page > 1) {
++ page--
++ void load()
++ }
++}
++next.onclick = () => {
++ page++
++ void load()
++}
++void load()
ddiff --git a/apps/client/src/ui/hud.ts b/apps/client/src/ui/hud.ts
iindex 261ceb5..db34a89 100644
---- a/apps/client/src/ui/hud.ts
++++ b/apps/client/src/ui/hud.ts
@@@ -24,7 +24,14 @@ import {
*/
type MenuTab =
-- 'inventory' | 'crafting' | 'equipment' | 'skills' | 'standing' | 'players' | 'settings'
++ | 'inventory'
++ | 'crafting'
++ | 'equipment'
++ | 'skills'
++ | 'achievements'
++ | 'standing'
++ | 'players'
++ | 'settings'
export class Hud {
private root: HTMLElement
@@@ -49,6 +56,7 @@ export class Hud {
private readonly connection: Connection,
private readonly icons: IconFactory,
private readonly weaponSettings: WeaponSettings,
++ private readonly achievementIdentity?: { guest: string; bearer?: string },
) {
this.root = root
this.build()
@@@ -536,6 +544,7 @@ export class Hud {
['crafting', 'Crafting'],
['equipment', 'Equipment'],
['skills', 'Skills'],
++ ['achievements', 'Achievements'],
['players', 'Players'],
['settings', 'Settings'],
]
@@@ -575,11 +584,44 @@ export class Hud {
else if (this.activeTab === 'crafting') this.renderCrafting()
else if (this.activeTab === 'equipment') this.renderEquipment()
else if (this.activeTab === 'skills') this.renderSkills()
++ else if (this.activeTab === 'achievements') void this.renderAchievements()
else if (this.activeTab === 'standing') this.renderStanding()
else if (this.activeTab === 'settings') this.renderSettings()
else this.renderPlayers()
}
++ private async renderAchievements(): Promise<void> {
++ const body = this.menuBodyEl
++ body.textContent = 'Loading achievements…'
++ if (!this.achievementIdentity) return
++ const headers = this.achievementIdentity.bearer
++ ? { authorization: `Bearer ${this.achievementIdentity.bearer}` }
++ : { 'x-guest-token': this.achievementIdentity.guest }
++ try {
++ const response = await fetch('/api/v1/players/me/achievements', { headers })
++ if (!response.ok) throw new Error('unavailable')
++ const data = (await response.json()) as {
++ games: {
++ game: string
++ achievements: { name: string; description: string; unlockedAt: number | null }[]
++ }[]
++ }
++ if (this.activeTab !== 'achievements') return
++ body.replaceChildren()
++ const gameId = this.content.world.id === 'salvage_rush_arena' ? 'salvage-rush' : 'scraplandia'
++ for (const achievement of data.games.find((game) => game.game === gameId)?.achievements ??
++ []) {
++ const line = document.createElement('p')
++ line.className = 'skill-row'
++ line.textContent = `${achievement.unlockedAt ? '✓' : '○'} ${achievement.name} — ${achievement.description}`
++ body.append(line)
++ }
++ if (!body.children.length) body.textContent = 'No achievements for this game yet.'
++ } catch {
++ if (this.activeTab === 'achievements') body.textContent = 'Achievements unavailable.'
++ }
++ }
++
/** Settings: graphics quality (Auto follows the device; resolution and shadows apply at once). */
private renderSettings(): void {
const tierLabel: Record<QualityTier, string> = { high: 'High', medium: 'Medium', low: 'Low' }
ddiff --git a/apps/client/src/ui/salvageRushHud.css b/apps/client/src/ui/salvageRushHud.css
nnew file mode 100644
iindex 0000000..6f49773
---- /dev/null
++++ b/apps/client/src/ui/salvageRushHud.css
@@@ -0,0 +1,126 @@
++.rush-hud {
++ position: fixed;
++ z-index: 12;
++ top: max(1rem, env(safe-area-inset-top));
++ right: max(1rem, env(safe-area-inset-right));
++ width: min(17rem, calc(100vw - 2rem));
++ max-height: 55vh;
++ overflow: auto;
++ padding: 0.75rem 0.9rem 0.8rem;
++ border: 1px solid #1f2d47;
++ border-radius: 0.9rem;
++ background: rgba(10, 15, 28, 0.88);
++ backdrop-filter: blur(6px);
++ box-shadow: 0 12px 34px rgba(0, 0, 0, 0.45);
++ color: #e6edf7;
++ font:
++ 500 0.88rem/1.35 system-ui,
++ sans-serif;
++}
++.rush-head {
++ display: flex;
++ align-items: center;
++ justify-content: space-between;
++ gap: 0.5rem;
++}
++.rush-head strong {
++ font-size: 0.72rem;
++ font-weight: 800;
++ letter-spacing: 0.14em;
++ color: #22d3ee;
++}
++.rush-phase {
++ font-size: 0.72rem;
++ font-weight: 700;
++ color: #96a7c2;
++}
++.rush-hud.is-active .rush-phase {
++ color: #6ee7a0;
++}
++.rush-time {
++ margin: 0.15rem 0 0.45rem;
++ font-size: 2rem;
++ font-weight: 900;
++ line-height: 1.1;
++ font-variant-numeric: tabular-nums;
++ letter-spacing: 0.02em;
++}
++.rush-hud.is-ending .rush-time {
++ color: #fbbf24;
++}
++.rush-scores {
++ margin: 0;
++ padding: 0;
++ list-style: none;
++ border-top: 1px solid #1f2d47;
++}
++.rush-scores li {
++ display: grid;
++ grid-template-columns: 1.6rem minmax(0, 1fr) auto;
++ align-items: center;
++ gap: 0.45rem;
++ padding: 0.32rem 0;
++ border-bottom: 1px solid rgba(31, 45, 71, 0.6);
++}
++.rush-scores li:last-child {
++ border-bottom: 0;
++}
++.rush-rank {
++ display: inline-grid;
++ place-items: center;
++ width: 1.45rem;
++ height: 1.45rem;
++ border-radius: 999px;
++ border: 1px solid #2c3d5c;
++ font-size: 0.72rem;
++ font-weight: 800;
++}
++.rush-top-1 .rush-rank {
++ background: #f59e0b;
++ border-color: #f59e0b;
++ color: #1a1405;
++}
++.rush-top-2 .rush-rank {
++ background: #cbd5e1;
++ border-color: #cbd5e1;
++ color: #111827;
++}
++.rush-top-3 .rush-rank {
++ background: #ea580c;
++ border-color: #ea580c;
++ color: #1c0d04;
++}
++.rush-name {
++ overflow: hidden;
++ text-overflow: ellipsis;
++ white-space: nowrap;
++}
++.rush-score {
++ font-weight: 800;
++ font-variant-numeric: tabular-nums;
++}
++.rush-scores li.rush-empty {
++ display: block;
++ color: #96a7c2;
++ font-size: 0.8rem;
++}
++@media (max-width: 600px) {
++ .rush-hud {
++ top: auto;
++ bottom: max(5rem, env(safe-area-inset-bottom));
++ right: 0.5rem;
++ width: min(12.5rem, 50vw);
++ max-height: 34vh;
++ padding: 0.55rem 0.65rem;
++ font-size: 0.74rem;
++ }
++ .rush-time {
++ font-size: 1.5rem;
++ }
++}
++@media (prefers-reduced-transparency: reduce) {
++ .rush-hud {
++ background: #0a0f1c;
++ backdrop-filter: none;
++ }
++}
ddiff --git a/apps/client/src/ui/salvageRushHud.ts b/apps/client/src/ui/salvageRushHud.ts
nnew file mode 100644
iindex 0000000..43f824e
---- /dev/null
++++ b/apps/client/src/ui/salvageRushHud.ts
@@@ -0,0 +1,97 @@
++import type { ServerMessage } from '@openvibe/protocol'
++import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder.js'
++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 './salvageRushHud.css'
++
++type Round = Extract<ServerMessage, { t: 'salvage_round' }>
++
++/** Loaded only in the arena, after the content handshake chooses its place. */
++export class SalvageRushHud {
++ private readonly root: HTMLElement
++ private readonly phase: HTMLElement
++ private readonly time: HTMLElement
++ private readonly scores: HTMLElement
++ private timer: number | null = null
++ private pickups: Mesh[] = []
++
++ constructor(
++ parent: HTMLElement,
++ private readonly scene: Scene,
++ ) {
++ this.root = document.createElement('aside')
++ this.root.className = 'rush-hud'
++ this.root.setAttribute('aria-label', 'Salvage Rush round')
++ const head = document.createElement('div')
++ head.className = 'rush-head'
++ const title = document.createElement('strong')
++ title.textContent = 'SALVAGE RUSH'
++ this.phase = document.createElement('span')
++ this.phase.className = 'rush-phase'
++ head.append(title, this.phase)
++ this.time = document.createElement('p')
++ this.time.className = 'rush-time'
++ this.time.setAttribute('aria-live', 'off')
++ this.scores = document.createElement('ol')
++ this.scores.className = 'rush-scores'
++ this.root.append(head, this.time, this.scores)
++ parent.append(this.root)
++ }
++
++ update(round: Round): void {
++ if (this.pickups.length !== round.pickups.length) {
++ for (const mesh of this.pickups) mesh.dispose()
++ this.pickups = round.pickups.map((point, i) => {
++ const mesh = MeshBuilder.CreateSphere(`rush-pickup-${i}`, { diameter: 0.8 }, this.scene)
++ mesh.position.set(point.x, 1.1, point.z)
++ const material = new StandardMaterial(`rush-pickup-material-${i}`, this.scene)
++ material.diffuseColor = new Color3(0.95, 0.7, 0.25)
++ material.emissiveColor = new Color3(0.5, 0.26, 0.04)
++ mesh.material = material
++ return mesh
++ })
++ }
++ round.pickups.forEach((point, i) =>
++ this.pickups[i]?.setEnabled(round.phase === 'active' && point.available),
++ )
++ if (this.timer !== null) window.clearInterval(this.timer)
++ const active = round.phase === 'active'
++ this.root.classList.toggle('is-active', active)
++ this.phase.textContent = active ? 'Collect scrap' : 'Next round in'
++ const refresh = () => {
++ const remaining = Math.max(0, Math.ceil((round.endsAt - Date.now()) / 1000))
++ const m = Math.floor(remaining / 60)
++ this.time.textContent = `${m}:${String(remaining % 60).padStart(2, '0')}`
++ this.root.classList.toggle('is-ending', active && remaining <= 10)
++ }
++ refresh()
++ this.timer = window.setInterval(refresh, 250)
++ this.scores.replaceChildren(
++ ...round.scores.map((entry) => {
++ const item = document.createElement('li')
++ if (entry.rank <= 3) item.className = `rush-top-${entry.rank}`
++ const rank = document.createElement('span')
++ rank.className = 'rush-rank'
++ rank.textContent = String(entry.rank)
++ const name = document.createElement('span')
++ name.className = 'rush-name'
++ name.textContent = entry.name
++ const score = document.createElement('span')
++ score.className = 'rush-score'
++ score.textContent = String(entry.score)
++ item.append(rank, name, score)
++ return item
++ }),
++ )
++ if (!round.scores.length) {
++ const empty = document.createElement('li')
++ empty.className = 'rush-empty'
++ empty.textContent = active
++ ? 'Grab the glowing scrap to score.'
++ : 'Scores appear when the round starts.'
++ this.scores.append(empty)
++ }
++ }
++}
ddiff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts
iindex 755e422..ca72bdd 100644
---- a/apps/client/vite.config.ts
++++ b/apps/client/vite.config.ts
@@@ -37,6 +37,7 @@ export default defineConfig({
home: 'index.html',
main: 'play.html',
editor: 'editor.html',
++ leaderboard: 'leaderboard.html',
},
},
outDir: 'dist',
ddiff --git a/apps/server/src/config.ts b/apps/server/src/config.ts
iindex e271d03..92f53e9 100644
---- a/apps/server/src/config.ts
++++ b/apps/server/src/config.ts
@@@ -22,7 +22,7 @@ export interface DbConfig {
export interface ServerConfig {
port: number
host: string
-- /** GAMES_PLACE_ID: the place every world row on this instance belongs to. */
++ /** GAMES_PLACE_ID: the default place for legacy hello messages and the editor map. */
placeId: string
db: DbConfig
/** Directory of built client assets to serve, or null for API/WS only. */
@@@ -157,7 +157,9 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
valkeyPrefix: env.VALKEY_PREFIX ?? 'ov:games:',
},
staticDir: env.STATIC_DIR ?? null,
-- mapPath: env.MAP_PATH ?? 'data/map.json',
++ mapPath:
++ env.MAP_PATH ??
++ (env.GAMES_PLACE_ID === 'salvage-rush' ? 'data/salvage-rush-map.json' : 'data/map.json'),
trustProxy: parseProxyTrust(env.TRUST_PROXY),
/** openvibe.network auth endpoint; when set, editor tokens validate there. */
networkAuthUrl:
ddiff --git a/apps/server/src/game/gameServer.ts b/apps/server/src/game/gameServer.ts
iindex 743d05f..d04ca76 100644
---- a/apps/server/src/game/gameServer.ts
++++ b/apps/server/src/game/gameServer.ts
@@@ -31,6 +31,8 @@ import {
import type { MessageHandler, System } from './systems/system.js'
import type { ServerMetrics } from '../observability/metrics.js'
import type { GameWorld } from './gameWorld.js'
++import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'
++import { SalvageRushRound } from './salvageRush.js'
export type { GameConnection, GameIntegrations }
@@@ -47,6 +49,10 @@ export class GameServer {
/** The systems in construction order: the handler table and the join/leave hooks both use it. */
private readonly ordered: System[]
private readonly handlers: Map<string, MessageHandler<never>>
++ private readonly rush: SalvageRushRound | null
++ private lastRushBroadcast = 0
++ private readonly roundFinished?: GameIntegrations['roundFinished']
++ private readonly playerJoined?: GameIntegrations['playerJoined']
constructor(
config: ServerConfig,
@@@ -56,6 +62,12 @@ export class GameServer {
log: Logger,
integrations: GameIntegrations = {},
) {
++ this.rush =
++ config.placeId === 'salvage-rush' && SALVAGE_RUSH_PACK.game
++ ? new SalvageRushRound(SALVAGE_RUSH_PACK.game, Date.now())
++ : null
++ this.roundFinished = integrations.roundFinished
++ this.playerJoined = integrations.playerJoined
// 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(
@@@ -135,6 +147,8 @@ export class GameServer {
ctx.lifecycle = {
joined: (session) => {
for (const system of this.ordered) system.onJoin?.(session)
++ this.playerJoined?.(session.subjectId ?? session.token)
++ if (this.rush) ctx.net.send(session, { t: 'salvage_round', ...this.rush.view() })
},
left: (session) => {
for (const system of this.ordered) system.onLeave?.(session)
@@@ -259,6 +273,22 @@ export class GameServer {
// 1. Movement from queued inputs (server-simulated, never client positions).
s.movement.tick(ctx, dt)
++ if (this.rush) {
++ const now = Date.now()
++ const results = this.rush.tick(
++ now,
++ ctx.sessions.all().map((session) => ({
++ key: session.subjectId ?? session.token,
++ name: session.name,
++ pos: [session.move.pos.x, session.move.pos.y, session.move.pos.z],
++ })),
++ )
++ if (results) this.roundFinished?.(this.rush.view().roundId, results)
++ if (results || now - this.lastRushBroadcast >= 1000) {
++ ctx.net.broadcastAll({ t: 'salvage_round', ...this.rush.view() })
++ this.lastRushBroadcast = now
++ }
++ }
// 2. Physgun: retry unlatched beams (sweep-to-grab), drive held bodies.
s.manipulation.tick(ctx, dt)
ddiff --git a/apps/server/src/game/gameWorld.ts b/apps/server/src/game/gameWorld.ts
iindex 0e13313..40b55f4 100644
---- a/apps/server/src/game/gameWorld.ts
++++ b/apps/server/src/game/gameWorld.ts
@@@ -123,7 +123,7 @@ export class GameWorld {
// Heightfield terrain — the SAME grid the client builds for prediction
// and rendering (see @openvibe/content buildTerrainGrid).
this.terrainBody = this.buildTerrainBody()
-- this.mapTerrain.reconcile(getMapOverride()?.terrains ?? [])
++ this.mapTerrain.reconcile(getMapOverride(this.content.world)?.terrains ?? [])
// Invisible boundary walls: past the terrain edge there is only ocean
// and an endless fall — the island's edge is the end of the world.
const b = world.groundHalfExtent + 0.5
@@@ -159,7 +159,7 @@ export class GameWorld {
/** Live map save: bring map-authored static collision up to date. */
reconcileMapStatics(): void {
-- this.mapStatics.reconcile(getMapOverride()?.statics ?? [])
++ this.mapStatics.reconcile(getMapOverride(this.content.world)?.statics ?? [])
}
private terrainBody: BodyId | null = null
@@@ -168,7 +168,7 @@ export class GameWorld {
/** Trimesh bodies for the map's extra terrain patches (world-space verts). */
private buildTerrainBody(): BodyId | null {
-- if (getMapOverride()) return null
++ if (getMapOverride(this.content.world)) return null
const grid = buildTerrainGrid(this.content.world)
return this.physics.addBody({
shape: { type: 'trimesh', positions: grid.positions, indices: grid.indices },
@@@ -191,14 +191,14 @@ export class GameWorld {
reconcileMapTerrain(): void {
// The base world's procedural collider exists only when there is no map;
// whether one is loaded can change between saves.
-- const wantBase = getMapOverride() === null
++ const wantBase = getMapOverride(this.content.world) === null
if (!wantBase && this.terrainBody !== null) {
this.physics.removeBody(this.terrainBody)
this.terrainBody = null
} else if (wantBase && this.terrainBody === null) {
this.terrainBody = this.buildTerrainBody()
}
-- this.mapTerrain.reconcile(getMapOverride()?.terrains ?? [])
++ this.mapTerrain.reconcile(getMapOverride(this.content.world)?.terrains ?? [])
}
/** Live-apply diagnostics and tests: what the map layers currently hold. */
@@@ -646,7 +646,7 @@ export class GameWorld {
private seedProps(_store: PersistenceStore): void {
const world = this.content.world
-- for (const prop of [...world.initialProps, ...(getMapOverride()?.props ?? [])]) {
++ for (const prop of [...world.initialProps, ...(getMapOverride(world)?.props ?? [])]) {
this.spawnProp({
defId: prop.item,
pos: vec3(
@@@ -683,7 +683,7 @@ export class GameWorld {
reconcileMapNodes(): void {
const world = this.content.world
const plan = planMapProvenance(
-- getMapOverride()?.nodes ?? [],
++ getMapOverride(this.content.world)?.nodes ?? [],
[...this.entities.ofKind('resource')],
{
spec: (n: MapNodeSpawn) => ({ id: n.id, defId: n.node, pos: n.pos }),
@@@ -734,7 +734,7 @@ export class GameWorld {
* never lift one the world def declared.
*/
reconcileMapZones(): void {
-- this.zones.setMapZones(getMapOverride()?.zones ?? [])
++ this.zones.setMapZones(getMapOverride(this.content.world)?.zones ?? [])
}
/**
@@@ -747,7 +747,7 @@ export class GameWorld {
*/
reconcileMapProps(): void {
const plan = planMapProvenance(
-- getMapOverride()?.props ?? [],
++ getMapOverride(this.content.world)?.props ?? [],
[...this.entities.ofKind('prop')],
{
spec: (p: MapPropSpawn) => ({ id: p.id, defId: p.item, pos: p.pos }),
@@@ -798,7 +798,7 @@ export class GameWorld {
remaining: nodeType.amount,
})
}
-- for (const node of getMapOverride()?.nodes ?? []) {
++ for (const node of getMapOverride(world)?.nodes ?? []) {
const nodeType = this.content.nodeTypeOrThrow(node.node)
const entity = this.spawnResource({
nodeTypeId: node.node,
ddiff --git a/apps/server/src/game/instanceHub.ts b/apps/server/src/game/instanceHub.ts
iindex 191c554..6c9b632 100644
---- a/apps/server/src/game/instanceHub.ts
++++ b/apps/server/src/game/instanceHub.ts
@@@ -121,6 +121,11 @@ export class InstanceHub {
this.connections.delete(conn)
}
++ /** Wait for an in-flight join before another place supersedes this character. */
++ async waitForConnection(conn: GameConnection): Promise<void> {
++ await this.pending.get(conn)
++ }
++
step(): void {
for (const room of this.rooms.values()) room.game.step()
}
ddiff --git a/apps/server/src/game/placeRouter.test.ts b/apps/server/src/game/placeRouter.test.ts
nnew file mode 100644
iindex 0000000..805059d
---- /dev/null
++++ b/apps/server/src/game/placeRouter.test.ts
@@@ -0,0 +1,135 @@
++import { createContent, SCRAPLANDIA_PACK } from '@openvibe/content'
++import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'
++import { InstanceRegistry, openPgStore, type InstanceLease } from '@openvibe/persistence'
++import { openTestDb } from '@openvibe/persistence/testing'
++import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'
++import { PROTOCOL_VERSION, defaultAppearance, type ServerMessage } from '@openvibe/protocol'
++import { createConsoleLogger } from '@openvibe/shared'
++import { expect, it } from 'vitest'
++import { loadConfig } from '../config.js'
++import { ServerMetrics } from '../observability/metrics.js'
++import { GameServer, type GameConnection } from './gameServer.js'
++import { GameWorld } from './gameWorld.js'
++import { InstanceHub } from './instanceHub.js'
++import { PlaceRouter } from './placeRouter.js'
++
++const log = createConsoleLogger({ app: 'place-router-test' }, 'error')
++
++async function until(check: () => boolean): Promise<void> {
++ for (let i = 0; i < 200; i++) {
++ if (check()) return
++ await new Promise((resolve) => setTimeout(resolve, 10))
++ }
++ throw new Error('join timed out')
++}
++
++it('one Games process hosts both places and routes hello to place-specific rooms and gameplay', async () => {
++ const db = await openTestDb()
++ const rapier = await loadRapier()
++ const config = loadConfig({ GAMES_PLACE_ID: 'scraplandia', GAMES_INSTANCES_PER_PLACE: '2' })
++ const contents = new Map([
++ ['scraplandia', createContent([], SCRAPLANDIA_PACK)],
++ ['salvage-rush', createContent([], SALVAGE_RUSH_PACK)],
++ ])
++ const hubs = new Map<string, InstanceHub>()
++ try {
++ for (const [placeId, content] of contents) {
++ const registry = new InstanceRegistry(db, placeId)
++ const bootstrapStore = openPgStore(db, { placeId, instanceSlot: 0 })
++ await bootstrapStore.ensurePlace()
++ const claimed = await registry.claim(0, 4, `holder-${placeId}`, Date.now(), 30_000)
++ if (!claimed) throw new Error('claim failed')
++ let lease: InstanceLease = claimed
++ const store = openPgStore(db, {
++ placeId,
++ instanceSlot: 0,
++ leaseGuard: () => registry.assertHeld(lease, Date.now()),
++ })
++ await store.ensurePlace()
++ const physics = createRapierWorld(rapier)
++ const world = new GameWorld(content, physics, log, config.interestCell)
++ await world.seedOrRestore(store)
++ const game = new GameServer({ ...config, placeId }, world, store, new ServerMetrics(), log)
++ await game.load()
++ const running = await registry.renew(lease, Date.now(), 30_000, 'running')
++ if (!running) throw new Error('renew failed')
++ lease = running
++ hubs.set(
++ placeId,
++ new InstanceHub(
++ registry,
++ { lease, game, world, dispose: () => physics.dispose() },
++ 2,
++ 4,
++ async () => {
++ throw new Error('unexpected room creation')
++ },
++ log,
++ ),
++ )
++ }
++ const router = new PlaceRouter(hubs, config.placeId)
++ const join = (place: string | undefined, key: string) => {
++ const inbox: ServerMessage[] = []
++ const closes: string[] = []
++ const conn: GameConnection = {
++ identity: { guestKeyHash: key.repeat(64) },
++ send: (raw) => inbox.push(JSON.parse(raw) as ServerMessage),
++ close: (_code, reason) => {
++ closes.push(reason)
++ router.onDisconnect(conn)
++ },
++ }
++ router.onMessage(conn, {
++ t: 'hello',
++ v: PROTOCOL_VERSION,
++ ...(place ? { place } : {}),
++ contentDigest: contents.get(router.resolve(place))?.digest,
++ slot: 0,
++ name: key,
++ appearance: defaultAppearance(),
++ })
++ return { conn, inbox, closes }
++ }
++ const scrap = join(undefined, 'a')
++ const rush = join('salvage-rush', 'b')
++ const fallback = join('unknown-place', 'c')
++ await until(() =>
++ [scrap, rush, fallback].every(({ inbox }) => inbox.some((m) => m.t === 'welcome')),
++ )
++ expect(scrap.inbox.find((m) => m.t === 'welcome')).toMatchObject({
++ instanceId: 'scraplandia:0',
++ })
++ expect(rush.inbox.find((m) => m.t === 'welcome')).toMatchObject({
++ instanceId: 'salvage-rush:0',
++ })
++ expect(fallback.inbox.find((m) => m.t === 'welcome')).toMatchObject({
++ instanceId: 'scraplandia:0',
++ })
++ expect(rush.inbox.some((m) => m.t === 'salvage_round')).toBe(true)
++ expect(scrap.inbox.some((m) => m.t === 'salvage_round')).toBe(false)
++ expect(fallback.inbox.some((m) => m.t === 'salvage_round')).toBe(false)
++ router.onMessage(scrap.conn, { t: 'transfer', instanceId: 'salvage-rush:0' })
++ await until(() => scrap.inbox.some((m) => m.t === 'transfer_result'))
++ expect(scrap.inbox.find((m) => m.t === 'transfer_result')).toMatchObject({
++ accepted: false,
++ reason: 'unknown_instance',
++ })
++ expect(router.onlineCount).toBe(3)
++ expect(router.activeRooms.map((room) => room.lease.id).sort()).toEqual([
++ 'salvage-rush:0',
++ 'scraplandia:0',
++ ])
++ expect(router.activeRooms.map((room) => room.world.content.world.id).sort()).toEqual([
++ 'openvibeville_v2',
++ 'salvage_rush_arena',
++ ])
++ const moved = join('salvage-rush', 'a')
++ await until(() => moved.inbox.some((m) => m.t === 'welcome'))
++ expect(scrap.closes).toContain('session_superseded')
++ expect(router.onlineCount).toBe(3)
++ await router.shutdown()
++ } finally {
++ await db.close()
++ }
++})
ddiff --git a/apps/server/src/game/placeRouter.ts b/apps/server/src/game/placeRouter.ts
nnew file mode 100644
iindex 0000000..c27be3c
---- /dev/null
++++ b/apps/server/src/game/placeRouter.ts
@@@ -0,0 +1,93 @@
++import type { ClientMessage } from '@openvibe/protocol'
++import type { GameConnection } from './systems/context.js'
++import type { InstanceHub, InstanceRoom } from './instanceHub.js'
++
++/** A single WebSocket endpoint over independent, place-fenced M4 room hubs. */
++export class PlaceRouter {
++ private readonly connections = new Map<
++ GameConnection,
++ { hub: InstanceHub; key: string | null; admitted: boolean }
++ >()
++ private readonly closed = new WeakSet<GameConnection>()
++ private admission: Promise<void> = Promise.resolve()
++
++ constructor(
++ private readonly hubs: ReadonlyMap<string, InstanceHub>,
++ private readonly defaultPlace: string,
++ ) {
++ if (!hubs.has(defaultPlace)) throw new Error(`default place ${defaultPlace} is not hosted`)
++ }
++
++ resolve(place: string | undefined): string {
++ return place && this.hubs.has(place) ? place : this.defaultPlace
++ }
++
++ onMessage(conn: GameConnection, msg: ClientMessage): void {
++ const active = this.connections.get(conn)
++ if (!active) {
++ if (msg.t !== 'hello') {
++ conn.close(4001, 'hello_first')
++ return
++ }
++ const hub = this.hubs.get(this.resolve(msg.place))
++ if (!hub) throw new Error('default hub missing')
++ const identity = conn.identity
++ const key = identity
++ ? `${'subject' in identity ? identity.subject : `guest:${identity.guestKeyHash}`}:${msg.slot}`
++ : null
++ this.connections.set(conn, { hub, key, admitted: false })
++ // A character is shared between places. Finish the old room's save before the new
++ // place restores that character, even when two hellos arrive in the same tick.
++ this.admission = this.admission
++ .then(async () => {
++ if (this.closed.has(conn)) return
++ if (key) {
++ for (const [other, prior] of this.connections) {
++ if (other === conn || prior.key !== key || !prior.admitted) continue
++ other.close(4004, 'session_superseded')
++ this.onDisconnect(other)
++ await Promise.all(prior.hub.activeRooms.map((room) => room.game.drain(5000)))
++ }
++ }
++ if (!this.closed.has(conn)) {
++ const current = this.connections.get(conn)
++ if (current) current.admitted = true
++ hub.onMessage(conn, msg)
++ await hub.waitForConnection(conn)
++ }
++ })
++ .catch(() => conn.close(1011, 'instance_unavailable'))
++ return
++ }
++ active.hub.onMessage(conn, msg)
++ }
++
++ onDisconnect(conn: GameConnection): void {
++ this.closed.add(conn)
++ this.connections.get(conn)?.hub.onDisconnect(conn)
++ this.connections.delete(conn)
++ }
++
++ get activeRooms(): readonly InstanceRoom[] {
++ return [...this.hubs.values()].flatMap((hub) => hub.activeRooms)
++ }
++
++ get onlineCount(): number {
++ return [...this.hubs.values()].reduce((total, hub) => total + hub.onlineCount, 0)
++ }
++
++ step(): void {
++ for (const hub of this.hubs.values()) hub.step()
++ }
++
++ async heartbeat(): Promise<void> {
++ await Promise.all([...this.hubs.values()].map((hub) => hub.heartbeat()))
++ }
++
++ async shutdown(): Promise<boolean> {
++ await this.admission
++ const drained = await Promise.all([...this.hubs.values()].map((hub) => hub.shutdown()))
++ this.connections.clear()
++ return drained.every(Boolean)
++ }
++}
ddiff --git a/apps/server/src/game/progression.test.ts b/apps/server/src/game/progression.test.ts
nnew file mode 100644
iindex 0000000..2ea94e9
---- /dev/null
++++ b/apps/server/src/game/progression.test.ts
@@@ -0,0 +1,146 @@
++import { describe, expect, it } from 'vitest'
++import { openTestDb, openTestStore } from '@openvibe/persistence/testing'
++import { accountKey } from '@openvibe/persistence'
++import {
++ earnedAchievements,
++ leaderboard,
++ playerAchievements,
++ recordJoined,
++ recordRound,
++} from './progression.js'
++
++describe('game progression', () => {
++ it('unlocks Scraplandia first visit once from a server join', async () => {
++ const db = await openTestDb()
++ const subject = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'
++ await recordJoined(db, subject, 100)
++ await recordJoined(db, subject, 200)
++ expect((await playerAchievements(db, subject))[0]?.achievements).toMatchObject([
++ { id: 'first-visit', unlockedAt: 100 },
++ ])
++ })
++
++ it('erases a signed-in subject from game progression with account data', async () => {
++ const store = await openTestStore()
++ const subject = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'
++ await recordJoined(store.db, subject, 100)
++ await recordRound(
++ store.db,
++ 'salvage-rush',
++ 'erase-round',
++ [{ key: subject, name: 'Member', score: 3, rank: 1 }],
++ 200,
++ )
++ const exported = await store.identity.exportSubject(subject)
++ expect(exported.gameResults).toHaveLength(1)
++ expect(exported.gameBestScores).toHaveLength(1)
++ expect(exported.gameAchievements).toHaveLength(3)
++ await store.identity.eraseSubjects([subject])
++ expect((await playerAchievements(store.db, subject))[0]?.achievements[0]?.unlockedAt).toBeNull()
++ expect((await leaderboard(store.db, 'salvage-rush', 'all', 1, 300)).entries).toEqual([])
++ })
++
++ it('moves guest scores and unlocks to the subject when its character is adopted', async () => {
++ const store = await openTestStore()
++ const guest = 'guest-test-key'
++ const subject = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'
++ await store.players.upsert({
++ id: 'guest-character',
++ token: guest,
++ name: 'Guest',
++ pos: [0, 1, 0],
++ yaw: 0,
++ inventory: { size: 24, hotbar: 6, slots: [] },
++ skills: {},
++ friends: [],
++ appearance: null,
++ charSlot: 0,
++ armor: null,
++ reputation: {},
++ unlocks: [],
++ activeJob: null,
++ stats: null,
++ updatedAt: 1,
++ })
++ await recordRound(
++ store.db,
++ 'salvage-rush',
++ 'guest-round',
++ [{ key: guest, name: 'Guest', score: 3, rank: 1 }],
++ 100,
++ )
++ expect((await store.identity.adoptGuestCharacter(guest, subject, 200)).moved).toBe(1)
++ expect((await leaderboard(store.db, 'salvage-rush', 'all', 1, 300)).entries[0]?.score).toBe(3)
++ expect(
++ (await playerAchievements(store.db, subject))[1]?.achievements.filter((a) => a.unlockedAt),
++ ).toHaveLength(2)
++ expect(
++ (await playerAchievements(store.db, guest))[1]?.achievements.filter((a) => a.unlockedAt),
++ ).toHaveLength(0)
++ })
++ it('unlocks criteria once and keys guests separately from subjects', async () => {
++ const db = await openTestDb()
++ const now = 2_000_000_000_000
++ const guest = 'guest-test-key'
++ const subject = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'
++ expect(
++ earnedAchievements('salvage-rush', { key: guest, name: 'Guest', score: 5, rank: 1 }),
++ ).toEqual(['first-haul', 'big-haul', 'arena-winner'])
++ await recordRound(
++ db,
++ 'salvage-rush',
++ 'r1',
++ [
++ { key: guest, name: 'Guest', score: 5, rank: 1 },
++ { key: subject, name: 'Member', score: 1, rank: 2 },
++ ],
++ now,
++ )
++ await recordRound(
++ db,
++ 'salvage-rush',
++ 'r1',
++ [{ key: guest, name: 'Guest', score: 5, rank: 1 }],
++ now,
++ )
++ const guestUnlocks = (await playerAchievements(db, guest))[1]?.achievements.filter(
++ (a) => a.unlockedAt,
++ )
++ const memberUnlocks = (await playerAchievements(db, subject))[1]?.achievements.filter(
++ (a) => a.unlockedAt,
++ )
++ expect(guestUnlocks?.map((a) => a.id)).toEqual(['first-haul', 'big-haul', 'arena-winner'])
++ expect(memberUnlocks?.map((a) => a.id)).toEqual(['first-haul'])
++ expect(accountKey(guest)).toMatch(/^guest:[0-9a-f]{64}$/)
++ expect(accountKey(subject)).toBe(subject)
++ })
++
++ it('orders personal bests and filters this week', async () => {
++ const db = await openTestDb()
++ const now = Date.UTC(2026, 9, 11, 12) // Sunday
++ await recordRound(
++ db,
++ 'salvage-rush',
++ 'old',
++ [{ key: 'a', name: 'A', score: 10, rank: 1 }],
++ Date.UTC(2026, 9, 4, 23, 59), // previous Sunday, within seven days but outside this week
++ )
++ await recordRound(
++ db,
++ 'salvage-rush',
++ 'new',
++ [
++ { key: 'a', name: 'A', score: 2, rank: 2 },
++ { key: 'b', name: 'B', score: 5, rank: 1 },
++ ],
++ now,
++ )
++ expect(
++ (await leaderboard(db, 'salvage-rush', 'all', 1, now)).entries.map((e) => e.score),
++ ).toEqual([10, 5])
++ expect(
++ (await leaderboard(db, 'salvage-rush', 'week', 1, now)).entries.map((e) => e.score),
++ ).toEqual([5, 2])
++ expect((await leaderboard(db, 'salvage-rush', 'all', 2, now)).entries).toEqual([])
++ })
++})
ddiff --git a/apps/server/src/game/progression.ts b/apps/server/src/game/progression.ts
nnew file mode 100644
iindex 0000000..f31304a
---- /dev/null
++++ b/apps/server/src/game/progression.ts
@@@ -0,0 +1,200 @@
++import { sql, type Db } from 'openvibe-sdk/db'
++import { accountKey } from '@openvibe/persistence'
++import { SCRAPLANDIA_PACK } from '@openvibe/content'
++import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'
++import type { RoundResult } from './salvageRush.js'
++
++export const GAMES = ['scraplandia', 'salvage-rush'] as const
++export type GameId = (typeof GAMES)[number]
++export function isGameId(value: string): value is GameId {
++ return GAMES.some((id) => id === value)
++}
++
++const definitions = {
++ scraplandia: SCRAPLANDIA_PACK.achievements ?? [],
++ 'salvage-rush': SALVAGE_RUSH_PACK.game?.achievements ?? [],
++}
++
++export function earnedAchievements(game: GameId, result: RoundResult): string[] {
++ if (game !== 'salvage-rush') return []
++ return definitions['salvage-rush']
++ .filter(
++ (def) =>
++ (def.criterion.minScore === undefined || result.score >= def.criterion.minScore) &&
++ (def.criterion.minRank === undefined || result.rank <= def.criterion.minRank),
++ )
++ .map((def) => def.id)
++}
++
++/** Server join event: once per account, regardless of how many characters or sessions join. */
++export async function recordJoined(db: Db, key: string, now: number): Promise<void> {
++ const account = accountKey(key)
++ for (const def of definitions.scraplandia) {
++ if (def.criterion.event !== 'player_joined') continue
++ await db.exec(sql`
++ INSERT INTO game_achievements (game_id, achievement_id, account_key, unlocked_at)
++ VALUES ('scraplandia', ${def.id}, ${account}, ${now})
++ ON CONFLICT (game_id, achievement_id, account_key) DO NOTHING
++ `)
++ }
++}
++
++/** The only writer for scores; called by the server's completed-round hook. */
++export async function recordRound(
++ db: Db,
++ game: GameId,
++ roundId: string,
++ results: readonly RoundResult[],
++ now: number,
++): Promise<void> {
++ await db.tx(async () => {
++ for (const result of results) {
++ const key = accountKey(result.key)
++ await db.exec(sql`
++ INSERT INTO game_round_results (game_id, round_id, account_key, player_name, score, rank, finished_at)
++ VALUES (${game}, ${roundId}, ${key}, ${result.name}, ${result.score}, ${result.rank}, ${now})
++ ON CONFLICT (game_id, round_id, account_key) DO NOTHING
++ `)
++ await db.exec(sql`
++ INSERT INTO game_best_scores (game_id, account_key, player_name, score, finished_at)
++ VALUES (${game}, ${key}, ${result.name}, ${result.score}, ${now})
++ ON CONFLICT (game_id, account_key) DO UPDATE SET
++ player_name = excluded.player_name, score = excluded.score, finished_at = excluded.finished_at
++ WHERE excluded.score > game_best_scores.score
++ `)
++ for (const achievement of earnedAchievements(game, result)) {
++ await db.exec(sql`
++ INSERT INTO game_achievements (game_id, achievement_id, account_key, unlocked_at)
++ VALUES (${game}, ${achievement}, ${key}, ${now})
++ ON CONFLICT (game_id, achievement_id, account_key) DO NOTHING
++ `)
++ }
++ }
++ })
++}
++
++/** Write-behind for completed rounds. Failed writes stay queued for retry and shutdown drain. */
++export class GameProgressWriter {
++ private readonly pending = new Map<string, { results: RoundResult[]; finishedAt: number }>()
++ private readonly joined = new Map<string, number>()
++ private inFlight: Promise<void> | null = null
++ private readonly timer: ReturnType<typeof setInterval>
++
++ constructor(
++ private readonly db: Db,
++ private readonly onError: (error: unknown) => void,
++ ) {
++ this.timer = setInterval(() => void this.flush().catch(onError), 2000)
++ this.timer.unref()
++ }
++
++ submit(roundId: string, results: RoundResult[]): void {
++ this.pending.set(roundId, { results, finishedAt: Date.now() })
++ void this.flush().catch(this.onError)
++ }
++
++ submitJoined(key: string): void {
++ if (!this.joined.has(key)) this.joined.set(key, Date.now())
++ void this.flush().catch(this.onError)
++ }
++
++ async flush(): Promise<void> {
++ if (this.inFlight) return this.inFlight
++ this.inFlight = (async () => {
++ while (this.pending.size > 0 || this.joined.size > 0) {
++ for (const [roundId, entry] of this.pending) {
++ await recordRound(this.db, 'salvage-rush', roundId, entry.results, entry.finishedAt)
++ this.pending.delete(roundId)
++ }
++ for (const [key, at] of this.joined) {
++ await recordJoined(this.db, key, at)
++ this.joined.delete(key)
++ }
++ }
++ })()
++ try {
++ await this.inFlight
++ } finally {
++ this.inFlight = null
++ }
++ }
++
++ async stop(): Promise<void> {
++ clearInterval(this.timer)
++ await this.flush()
++ }
++}
++
++interface BoardRow {
++ account_key: string
++ player_name: string
++ score: number
++ finished_at: number
++}
++export async function leaderboard(
++ db: Db,
++ game: GameId,
++ period: 'all' | 'week',
++ page: number,
++ now: number,
++) {
++ const date = new Date(now)
++ const weekStart = Date.UTC(
++ date.getUTCFullYear(),
++ date.getUTCMonth(),
++ date.getUTCDate() - ((date.getUTCDay() + 6) % 7),
++ )
++ const since = period === 'week' ? weekStart : 0
++ const limit = 20
++ const offset = (page - 1) * limit
++ const rows =
++ period === 'all'
++ ? await db.many<BoardRow>(sql`
++ SELECT account_key, player_name, score, finished_at FROM game_best_scores
++ WHERE game_id = ${game}
++ ORDER BY score DESC, finished_at ASC, account_key ASC
++ LIMIT ${limit} OFFSET ${offset}
++ `)
++ : await db.many<BoardRow>(sql`
++ SELECT account_key, player_name, score, finished_at FROM (
++ SELECT account_key, player_name, score, finished_at,
++ ROW_NUMBER() OVER (PARTITION BY account_key ORDER BY score DESC, finished_at ASC) AS own_rank
++ FROM game_round_results WHERE game_id = ${game} AND finished_at >= ${since}
++ ) best WHERE own_rank = 1
++ ORDER BY score DESC, finished_at ASC, account_key ASC
++ LIMIT ${limit} OFFSET ${offset}
++ `)
++ return {
++ game,
++ period,
++ page,
++ pageSize: limit,
++ entries: rows.map((row, i) => ({
++ rank: offset + i + 1,
++ name: row.player_name,
++ score: Number(row.score),
++ finishedAt: Number(row.finished_at),
++ })),
++ }
++}
++
++export async function playerAchievements(db: Db, token: string) {
++ const key = accountKey(token)
++ const unlocked = await db.many<{
++ game_id: string
++ achievement_id: string
++ unlocked_at: number
++ }>(sql`
++ SELECT game_id, achievement_id, unlocked_at FROM game_achievements
++ WHERE account_key = ${key} ORDER BY unlocked_at, game_id, achievement_id
++ `)
++ return GAMES.map((game) => ({
++ game,
++ achievements: definitions[game].map((def) => ({
++ ...def,
++ unlockedAt:
++ unlocked.find((row) => row.game_id === game && row.achievement_id === def.id)
++ ?.unlocked_at ?? null,
++ })),
++ }))
++}
ddiff --git a/apps/server/src/game/salvageRush.test.ts b/apps/server/src/game/salvageRush.test.ts
nnew file mode 100644
iindex 0000000..dfa6dad
---- /dev/null
++++ b/apps/server/src/game/salvageRush.test.ts
@@@ -0,0 +1,44 @@
++import { describe, expect, it } from 'vitest'
++import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'
++import { createContent } from '@openvibe/content'
++import { decodeClientMessage } from '@openvibe/protocol'
++import { SalvageRushRound } from './salvageRush.js'
++
++const rules = {
++ ...SALVAGE_RUSH_PACK.game!,
++ roundSeconds: 2,
++ intermissionSeconds: 1,
++ pickups: [
++ [0, 1, 0],
++ [8, 1, 0],
++ ] as [number, number, number][],
++}
++const player = (key: string, x: number) => ({ key, name: key, pos: [x, 1, 0] as const })
++
++describe('Salvage Rush server round', () => {
++ it('loads its own map without replacing the default Scraplandia map', () => {
++ expect(createContent().world.id).toBe('openvibeville_v2')
++ expect(createContent([], SALVAGE_RUSH_PACK).world.id).toBe('salvage_rush_arena')
++ })
++ it('starts, collects from simulated positions, ends and resets', () => {
++ const round = new SalvageRushRound(rules, 0)
++ expect(round.view().phase).toBe('intermission')
++ round.tick(1000, [player('a', 0), player('b', 8)])
++ expect(round.view().phase).toBe('active')
++ expect(round.view().scores.map((s) => s.score)).toEqual([1, 1])
++ expect(round.view().scores.map((s) => s.rank)).toEqual([1, 1])
++ const final = round.tick(3000, [player('a', 0)])
++ expect(final?.map((s) => s.rank)).toEqual([1, 1])
++ expect(round.view().phase).toBe('intermission')
++ round.tick(4000, [player('a', 20)])
++ expect(round.view().scores[0]?.score).toBe(0)
++ })
++
++ it('has no score path from a client intent', () => {
++ expect(decodeClientMessage(JSON.stringify({ t: 'score', points: 999 }))).toBeNull()
++ const round = new SalvageRushRound(rules, 0)
++ round.tick(1000, [player('a', 20)])
++ round.tick(1500, [player('a', 20)])
++ expect(round.view().scores[0]?.score).toBe(0)
++ })
++})
ddiff --git a/apps/server/src/game/salvageRush.ts b/apps/server/src/game/salvageRush.ts
nnew file mode 100644
iindex 0000000..0baed42
---- /dev/null
++++ b/apps/server/src/game/salvageRush.ts
@@@ -0,0 +1,102 @@
++import type { ContentPackV2 } from '@openvibe/content'
++
++export interface RoundPlayer {
++ key: string
++ name: string
++ pos: readonly [number, number, number]
++}
++export interface RoundResult {
++ key: string
++ name: string
++ score: number
++ rank: number
++}
++export interface RoundView {
++ roundId: string
++ phase: 'intermission' | 'active'
++ endsAt: number
++ pickups: { x: number; z: number; available: boolean }[]
++ scores: Pick<RoundResult, 'name' | 'score' | 'rank'>[]
++}
++
++/** Server-only arena rules. Positions come from movement simulation, never a score intent. */
++export class SalvageRushRound {
++ private phase: RoundView['phase'] = 'intermission'
++ private endsAt: number
++ private round = 0
++ private scores = new Map<string, { name: string; score: number }>()
++ private available: boolean[]
++ private respawnAt: number[]
++ private roundId = ''
++ constructor(
++ private readonly rules: NonNullable<ContentPackV2['game']>,
++ now: number,
++ ) {
++ this.endsAt = now + rules.intermissionSeconds * 1000
++ this.available = rules.pickups.map(() => true)
++ this.respawnAt = rules.pickups.map(() => 0)
++ }
++
++ tick(now: number, players: readonly RoundPlayer[]): RoundResult[] | null {
++ if (now >= this.endsAt) {
++ if (this.phase === 'active') {
++ const results = this.results()
++ this.phase = 'intermission'
++ this.endsAt = now + this.rules.intermissionSeconds * 1000
++ return results
++ }
++ this.round++
++ this.roundId = `${now}-${this.round}`
++ this.phase = 'active'
++ this.endsAt = now + this.rules.roundSeconds * 1000
++ this.scores = new Map(players.map((p) => [p.key, { name: p.name, score: 0 }]))
++ this.available.fill(true)
++ this.respawnAt.fill(0)
++ }
++ if (this.phase !== 'active') return null
++ for (let i = 0; i < this.rules.pickups.length; i++) {
++ if (!this.available[i] && now >= (this.respawnAt[i] ?? 0)) this.available[i] = true
++ if (!this.available[i]) continue
++ const point = this.rules.pickups[i]!
++ // Stable order gives a deterministic winner if two capsules overlap a pickup in one tick.
++ const collector = [...players]
++ .sort((a, b) => a.key.localeCompare(b.key))
++ .find((p) => {
++ const dx = p.pos[0] - point[0]
++ const dz = p.pos[2] - point[2]
++ return dx * dx + dz * dz <= 2.25 && Math.abs(p.pos[1] - point[1]) <= 2
++ })
++ if (!collector) continue
++ const score = this.scores.get(collector.key) ?? { name: collector.name, score: 0 }
++ score.score++
++ this.scores.set(collector.key, score)
++ this.available[i] = false
++ this.respawnAt[i] = now + 8_000
++ }
++ return null
++ }
++
++ view(): RoundView {
++ return {
++ roundId: this.roundId,
++ phase: this.phase,
++ endsAt: this.endsAt,
++ pickups: this.rules.pickups.map((p, i) => ({
++ x: p[0],
++ z: p[2],
++ available: this.available[i] ?? false,
++ })),
++ scores: this.results().map(({ name, score, rank }) => ({ name, score, rank })),
++ }
++ }
++
++ private results(): RoundResult[] {
++ const sorted = [...this.scores]
++ .map(([key, value]) => ({ key, ...value }))
++ .sort((a, b) => b.score - a.score || a.key.localeCompare(b.key))
++ return sorted.map((p) => ({
++ ...p,
++ rank: sorted.findIndex((v) => v.score === p.score) + 1,
++ }))
++ }
++}
ddiff --git a/apps/server/src/game/systems/context.ts b/apps/server/src/game/systems/context.ts
iindex 699843e..fb3bf8b 100644
---- a/apps/server/src/game/systems/context.ts
++++ b/apps/server/src/game/systems/context.ts
@@@ -61,6 +61,13 @@ export type ResolvedConnectionIdentity = TicketIdentity
* game runs exactly as before without them.
*/
export interface GameIntegrations {
++ /** A server-confirmed authenticated join, for place-scoped achievements. */
++ playerJoined?: (key: string) => void
++ /** Called only for completed server-simulated rounds. Never accepts a client message. */
++ roundFinished?: (
++ roundId: string,
++ results: { key: string; name: string; score: number; rank: number }[],
++ ) => void
/** Durable lifecycle/progression events through the outbox. */
events?: GameEventRecorder
/** Installed mods (content packs), reconciled every tick. */
ddiff --git a/apps/server/src/game/systems/sessions.ts b/apps/server/src/game/systems/sessions.ts
iindex 83bb9be..66d3a0e 100644
---- a/apps/server/src/game/systems/sessions.ts
++++ b/apps/server/src/game/systems/sessions.ts
@@@ -189,9 +189,10 @@ export class SessionsSystem implements System {
const world = ctx.world.content.world
const playerId = existing ? asPlayerId(existing.id) : newPlayerId()
const mapSpawn = worldSpawn(world)
-- const spawn = existing
-- ? vec3(existing.pos[0], existing.pos[1], existing.pos[2])
-- : vec3(mapSpawn.pos[0], mapSpawn.pos[1], mapSpawn.pos[2])
++ const spawn =
++ existing && existing.positionInPlace !== false
++ ? vec3(existing.pos[0], existing.pos[1], existing.pos[2])
++ : vec3(mapSpawn.pos[0], mapSpawn.pos[1], mapSpawn.pos[2])
const inventory = existing
? Inventory.fromDto(existing.inventory, ctx.world.content)
: new Inventory(INVENTORY_SIZE, HOTBAR_SIZE, ctx.world.content)
@@@ -225,7 +226,7 @@ export class SessionsSystem implements System {
rank,
name: msg.name,
spawn,
-- yaw: existing?.yaw ?? mapSpawn.yaw,
++ yaw: existing && existing.positionInPlace !== false ? existing.yaw : mapSpawn.yaw,
inventory,
skills,
friends,
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 4da4c5d..98483b2 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -10,6 +10,7 @@ import {
SCRAPLANDIA_PACK,
setMapOverride,
} from '@openvibe/content'
++import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'
import { InstanceRegistry, openPgStore, type InstanceLease } from '@openvibe/persistence'
import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'
import { createConsoleLogger } from '@openvibe/shared'
@@@ -18,6 +19,7 @@ import { openDb } from './db.js'
import { GameServer } from './game/gameServer.js'
import { GameWorld } from './game/gameWorld.js'
import { InstanceHub } from './game/instanceHub.js'
++import { PlaceRouter } from './game/placeRouter.js'
import { attachEditorWs } from './net/editorWs.js'
import {
closeSockets,
@@@ -63,6 +65,9 @@ import {
} from './platform/revocationEvents.js'
import { createStaffAuthorizer } from './platform/staffAuth.js'
import { createWsTicketStore } from './net/wsTicket.js'
++import { GameProgressWriter } from './game/progression.js'
++import type { RoundResult } from './game/salvageRush.js'
++import { handleGameRead } from './net/gameRoutes.js'
/**
* Dedicated authoritative server entry point.
@@@ -103,7 +108,11 @@ async function main(): Promise<void> {
const packs = await loadDefinitionPacks(store.mods, (modId, reason) =>
log.warn('mod definitions skipped', { mod: modId, reason }),
)
-- const content = createContent(packs)
++ const builtInPack = config.placeId === 'salvage-rush' ? SALVAGE_RUSH_PACK : SCRAPLANDIA_PACK
++ const content = createContent(packs, builtInPack)
++ const progressWriter = new GameProgressWriter(db, (error) =>
++ log.warn('game progression write failed', { error: String(error) }),
++ )
const builtInModIds = new Set(
(SCRAPLANDIA_PACK.mods ?? []).map((raw) => ScriptModSchema.parse(raw).id),
)
@@@ -120,7 +129,7 @@ async function main(): Promise<void> {
// content.world.statics: that merge was permanent, so the map layer
// could never be replaced and a live save could not move or delete
// anything it had already added.
-- setMapOverride(compileMapFileV2(parsed.map))
++ setMapOverride(compileMapFileV2(parsed.map), content.world.id)
log.info('edited map loaded', {
statics: parsed.map.statics.length,
terrains: parsed.map.terrains.length,
@@@ -234,13 +243,18 @@ async function main(): Promise<void> {
// games-quickjs@1 script mods run only where the place enables them; the engine (WASM) is not
// even loaded otherwise. They stay in the def set, so the handshake digest is the same either way.
const scriptLog = log.child({ system: 'script-mods' })
-- const scriptHosts = new Map<number, ScriptModHost>()
-- const createScriptHost = async (slot: number): Promise<ScriptModHost | undefined> => {
++ const scriptHosts = new Map<string, ScriptModHost>()
++ const createScriptHost = async (
++ slot: number,
++ placeId = config.placeId,
++ roomContent = content,
++ roomStore = store,
++ ): Promise<ScriptModHost | undefined> => {
if (!config.scriptMods) return undefined
const { createQuickJsSandboxFactory } = await import('./mods/script/quickjs.js')
// Built-in pack code is reviewed with the place; installed packs use only the approved subset.
const grants: Record<string, string[]> = Object.fromEntries(
-- content
++ roomContent
.allMods()
.filter((mod) => mod.granted === undefined)
.map((mod) => [mod.id, mod.capabilities ?? ['players.read', 'world.emit']]),
@@@ -255,17 +269,17 @@ async function main(): Promise<void> {
}
}
const host = new ScriptModHost(
-- content.allMods(),
++ roomContent.allMods(),
createQuickJsSandboxFactory(),
-- scriptLog.child({ slot }),
++ scriptLog.child({ placeId, slot }),
undefined,
{
-- placeSeed: slot === 0 ? config.placeId : `${config.placeId}:${slot}`,
++ placeSeed: slot === 0 ? placeId : `${placeId}:${slot}`,
grants: grants as Record<string, ScriptCapability[]>,
-- storage: new PgScriptStorage(store.mods, config.placeId, scriptLog),
++ storage: new PgScriptStorage(roomStore.mods, placeId, scriptLog),
},
)
-- scriptHosts.set(slot, host)
++ scriptHosts.set(`${placeId}:${slot}`, host)
return host
}
const scripts = await createScriptHost(0)
@@@ -283,6 +297,16 @@ async function main(): Promise<void> {
mods: modRuntime,
...(scripts ? { scripts } : {}),
...(progressSummary ? { progressSummary } : {}),
++ ...(config.placeId === 'scraplandia'
++ ? { playerJoined: (key: string) => progressWriter.submitJoined(key) }
++ : {}),
++ ...(config.placeId === 'salvage-rush'
++ ? {
++ roundFinished: (roundId: string, results: RoundResult[]) => {
++ progressWriter.submit(`0:${roundId}`, results)
++ },
++ }
++ : {}),
},
)
// Async boot: env, markets and the NPC population load before the tick loop starts.
@@@ -300,7 +324,7 @@ async function main(): Promise<void> {
dispose: async () => {
await scripts?.flushWrites()
scripts?.dispose()
-- scriptHosts.delete(0)
++ scriptHosts.delete(`${config.placeId}:0`)
physics.dispose()
},
},
@@@ -338,6 +362,16 @@ async function main(): Promise<void> {
}),
...(roomScripts ? { scripts: roomScripts } : {}),
...(progressSummary ? { progressSummary } : {}),
++ ...(config.placeId === 'scraplandia'
++ ? { playerJoined: (key: string) => progressWriter.submitJoined(key) }
++ : {}),
++ ...(config.placeId === 'salvage-rush'
++ ? {
++ roundFinished: (roundId: string, results: RoundResult[]) => {
++ progressWriter.submit(`${slot}:${roundId}`, results)
++ },
++ }
++ : {}),
},
)
await roomGame.load()
@@@ -349,14 +383,131 @@ async function main(): Promise<void> {
dispose: async () => {
await roomScripts?.flushWrites()
roomScripts?.dispose()
-- scriptHosts.delete(slot)
++ scriptHosts.delete(`${config.placeId}:${slot}`)
roomPhysics.dispose()
},
}
},
log.child({ system: 'instances' }),
)
-- const heartbeatTimer = setInterval(() => void hub.heartbeat(), 5_000)
++ const secondaryPlace = config.placeId === 'salvage-rush' ? 'scraplandia' : 'salvage-rush'
++ const secondaryContent = createContent(
++ packs,
++ secondaryPlace === 'salvage-rush' ? SALVAGE_RUSH_PACK : SCRAPLANDIA_PACK,
++ )
++ const secondaryRegistry = new InstanceRegistry(db, secondaryPlace)
++ let secondaryPrimaryLease: InstanceLease
++ const secondaryStore = openPgStore(db, {
++ placeId: secondaryPlace,
++ instanceSlot: 0,
++ leaseGuard: () => secondaryRegistry.assertHeld(secondaryPrimaryLease, Date.now()),
++ })
++ await secondaryStore.ensurePlace()
++ const secondaryClaim = await secondaryRegistry.claim(
++ 0,
++ config.maxPlayers,
++ crypto.randomUUID(),
++ Date.now(),
++ 60_000,
++ )
++ if (!secondaryClaim) throw new Error(`${secondaryPlace} primary instance slot is already leased`)
++ secondaryPrimaryLease = secondaryClaim
++ const secondaryRecorder = sink.enabled
++ ? new GameEventRecorder(
++ sink,
++ secondaryContent.world.id,
++ config.platform.worldSavedEventMinutes * 60_000,
++ )
++ : undefined
++ const createSecondaryRoom = async (slot: number, lease: InstanceLease) => {
++ const roomStore =
++ slot === 0
++ ? secondaryStore
++ : openPgStore(db, {
++ placeId: secondaryPlace,
++ instanceSlot: slot,
++ leaseGuard: () => secondaryRegistry.assertHeld(lease, Date.now()),
++ })
++ await roomStore.ensurePlace()
++ const roomPhysics = createRapierWorld(rapier)
++ const roomWorld = new GameWorld(
++ secondaryContent,
++ roomPhysics,
++ log.child({ system: 'world', place: secondaryPlace, slot }),
++ config.interestCell,
++ )
++ await roomWorld.seedOrRestore(roomStore)
++ roomWorld.reconcileMapNodes()
++ roomWorld.reconcileMapProps()
++ const roomScripts = await createScriptHost(slot, secondaryPlace, secondaryContent, roomStore)
++ const roomGame = new GameServer(
++ { ...config, placeId: secondaryPlace, instanceSlot: slot },
++ roomWorld,
++ roomStore,
++ metrics,
++ log.child({ system: 'game', place: secondaryPlace, slot }),
++ {
++ ...(secondaryRecorder ? { events: secondaryRecorder } : {}),
++ mods: new ModRuntime(
++ mods,
++ secondaryContent,
++ log.child({ system: 'mods', place: secondaryPlace, slot }),
++ {
++ reconcileEveryTicks: config.tickRate,
++ },
++ ),
++ ...(roomScripts ? { scripts: roomScripts } : {}),
++ ...(progressSummary ? { progressSummary } : {}),
++ ...(secondaryPlace === 'scraplandia'
++ ? { playerJoined: (key: string) => progressWriter.submitJoined(key) }
++ : {
++ roundFinished: (roundId: string, results: RoundResult[]) =>
++ progressWriter.submit(`${slot}:${roundId}`, results),
++ }),
++ },
++ )
++ await roomGame.load()
++ await roomScripts?.start()
++ return {
++ lease,
++ game: roomGame,
++ world: roomWorld,
++ dispose: async () => {
++ await roomScripts?.flushWrites()
++ roomScripts?.dispose()
++ scriptHosts.delete(`${secondaryPlace}:${slot}`)
++ roomPhysics.dispose()
++ },
++ }
++ }
++ const secondaryPrimary = await createSecondaryRoom(0, secondaryPrimaryLease)
++ const secondaryRunning = await secondaryRegistry.renew(
++ secondaryPrimaryLease,
++ Date.now(),
++ 15_000,
++ 'running',
++ )
++ if (!secondaryRunning) throw new Error(`${secondaryPlace} primary lease expired during boot`)
++ secondaryPrimary.lease = secondaryRunning
++ secondaryPrimaryLease = secondaryRunning
++ const secondaryHub = new InstanceHub(
++ secondaryRegistry,
++ secondaryPrimary,
++ config.instancesPerPlace,
++ config.maxPlayers,
++ createSecondaryRoom,
++ log.child({ system: 'instances', place: secondaryPlace }),
++ )
++ const hubs = new Map([
++ [config.placeId, hub],
++ [secondaryPlace, secondaryHub],
++ ])
++ const router = new PlaceRouter(hubs, config.placeId)
++ const contentByPlace = new Map([
++ [config.placeId, content],
++ [secondaryPlace, secondaryContent],
++ ])
++ const heartbeatTimer = setInterval(() => void router.heartbeat(), 5_000)
heartbeatTimer.unref()
const syncScriptView = async (view: ModView): Promise<void> => {
@@@ -366,15 +517,19 @@ async function main(): Promise<void> {
const active = view.mod.status === 'enabled' && view.granted.has('games.script.run')
for (const host of scriptHosts.values()) host.remove(script.id)
if (!active) {
-- content.removeScriptMod(script.id)
++ for (const registryContent of contentByPlace.values())
++ registryContent.removeScriptMod(script.id)
continue
}
const granted = (script.capabilities ?? (['players.read', 'world.emit'] as const)).filter(
(cap) => view.granted.has(scriptGrant(cap)),
)
const pinned = { ...script, granted }
-- if (content.allMods().some((mod) => mod.id === script.id)) content.replaceScriptMod(pinned)
-- else content.addScriptMod(pinned)
++ for (const registryContent of contentByPlace.values()) {
++ if (registryContent.allMods().some((mod) => mod.id === script.id))
++ registryContent.replaceScriptMod(pinned)
++ else registryContent.addScriptMod(pinned)
++ }
for (const host of scriptHosts.values()) {
const result = await host.add(pinned, granted)
if (!result.ok)
@@@ -384,7 +539,7 @@ async function main(): Promise<void> {
})
}
}
-- for (const room of hub.activeRooms)
++ for (const room of router.activeRooms)
room.game.announce(
'Script mod definitions changed. New connections must use the updated content set.',
)
@@@ -398,12 +553,12 @@ async function main(): Promise<void> {
closeSessions: async (subjects) => {
let closed = 0
for (const s of subjects)
-- for (const room of hub.activeRooms)
++ for (const room of router.activeRooms)
closed += room.game.revokeSubject(s, Number.MAX_SAFE_INTEGER)
// Let the closing sockets' character saves land before the rows are erased.
if (closed) {
await new Promise((r) => setTimeout(r, 2000))
-- await Promise.all(hub.activeRooms.map((room) => room.game.drain()))
++ await Promise.all(router.activeRooms.map((room) => room.game.drain()))
}
},
log: platformLog.child({ system: 'account-data' }),
@@@ -416,7 +571,7 @@ async function main(): Promise<void> {
db,
secrets: config.platform.eventsSecrets,
onRevoked: (subject, validAfterMs) =>
-- hub.activeRooms.reduce(
++ router.activeRooms.reduce(
(count, room) => count + room.game.revokeSubject(subject, validAfterMs),
0,
),
@@@ -467,7 +622,7 @@ async function main(): Promise<void> {
const readiness = createReadiness({
pingDb: () => db.ready(),
metrics,
-- online: () => hub.onlineCount,
++ online: () => router.onlineCount,
})
// Per-actor limits on editor saves, uploads and the mods API (roadmap WS-R task 4). With VALKEY_URL the
@@@ -494,7 +649,7 @@ async function main(): Promise<void> {
// migrated and validated it, so there is nothing to re-parse here.
(next, revision) => {
try {
-- setMapOverride(compileMapFileV2(next))
++ setMapOverride(compileMapFileV2(next), content.world.id)
for (const room of hub.activeRooms) {
room.world.reconcileMapTerrain()
room.world.reconcileMapStatics()
@@@ -533,21 +688,35 @@ async function main(): Promise<void> {
{
handle: (req, res) => {
if (req.method === 'GET' && req.url?.split('?')[0] === '/api/v1/content') {
++ const requested = new URL(req.url ?? '/', 'http://games.local').searchParams.get('game')
++ const placeId = router.resolve(requested ?? undefined)
++ const selectedContent = contentByPlace.get(placeId)
++ if (!selectedContent) throw new Error('place content missing')
const staticPacks = packs.map(({ mods: _mods, ...pack }) => pack)
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
res.end(
JSON.stringify({
-- digest: content.digest,
++ digest: selectedContent.digest,
++ game: placeId,
packs: [
...staticPacks,
{
-- mods: content.allMods().filter((mod) => !builtInModIds.has(mod.id)),
++ mods: selectedContent.allMods().filter((mod) => !builtInModIds.has(mod.id)),
},
],
}),
)
return true
}
++ if (
++ handleGameRead(req, res, {
++ db,
++ networkAuthUrl: config.networkAuthUrl,
++ limits: actorLimits,
++ address: (request) => resolveClientAddress(request, config.trustProxy),
++ })
++ )
++ return true
if (revocations.handle(req, res)) return true
if (readiness.handle(req, res)) return true
if (serveRelease(req, res)) return true
@@@ -642,9 +811,10 @@ async function main(): Promise<void> {
}
try {
const view = await mods.replace(id, request, actor)
-- content.replaceScriptMod(pinned)
++ for (const registryContent of contentByPlace.values())
++ registryContent.replaceScriptMod(pinned)
for (const candidate of prepared) candidate.commit()
-- for (const room of hub.activeRooms)
++ for (const room of router.activeRooms)
room.game.announce(
`Script mod ${script.id} reloaded. New connections must use the updated content set.`,
)
@@@ -669,11 +839,12 @@ async function main(): Promise<void> {
config.indexnowKey,
log.child({ system: 'indexnow' }),
),
++ config.placeId,
)
const drainer = httpDrainer(http)
world.reconcileMapNodes()
world.reconcileMapProps()
-- const gameWss = attachWebSocket(http, hub, {
++ const gameWss = attachWebSocket(http, router, {
tickets: ticketStore,
upgradeLimits: actorLimits,
clientAddress,
@@@ -728,7 +899,7 @@ async function main(): Promise<void> {
if (!running) return
const now = performance.now()
while (now >= nextTick) {
-- hub.step()
++ router.step()
nextTick += tickMs
// If we fell far behind (debugger pause, EL stall), resync instead of
// spiraling through hundreds of catch-up ticks.
@@@ -767,14 +938,14 @@ async function main(): Promise<void> {
clearInterval(pruneTimer)
editors.stop()
const mirrorDone = mirror?.stop()
-- for (const room of hub.activeRooms) room.game.announceRestart()
++ for (const room of router.activeRooms) room.game.announceRestart()
const [players, editorPeers] = await Promise.all([
closeSockets(gameWss, SOCKETS_MS),
closeSockets(editors.wss, SOCKETS_MS),
])
// Mark every session dirty and queue the final checkpoint, then let the whole write-behind queue
// (that checkpoint plus every disconnect save queued before it) drain.
-- const drained = await hub.shutdown()
++ const drained = await router.shutdown()
for (const host of scriptHosts.values()) {
await host.flushWrites()
host.dispose()
@@@ -784,6 +955,7 @@ async function main(): Promise<void> {
await within(1000, progressSummary?.settle())
await within(2000, outbox?.stop())
await mods.flushWrites()
++ await progressWriter.stop()
await store.close()
await valkey?.close()
log.info('stopped', {
ddiff --git a/apps/server/src/net/actorLimits.ts b/apps/server/src/net/actorLimits.ts
iindex bdf674b..5ea0f81 100644
---- a/apps/server/src/net/actorLimits.ts
++++ b/apps/server/src/net/actorLimits.ts
@@@ -48,6 +48,7 @@ export function createGamesActorLimits(
// WebSocket upgrades, counted per address BEFORE the ticket is consumed: a
// flood must not get to spend tickets or hold sockets (ADR-0007 decision 8).
const upgrade = limiter('games.ws.upgrade', { minute: 90, hour: 1800 })
++ const publicRead = limiter('games.public.read', { minute: 60, hour: 1200 })
const run =
(mw: ReturnType<typeof limiter>) =>
(req: IncomingMessage, res: ServerResponse, actor: string): Promise<boolean> =>
@@@ -66,6 +67,7 @@ export function createGamesActorLimits(
modWrite: run(modWrite),
ticket: run(ticket),
upgrade: run(upgrade),
++ publicRead: run(publicRead),
}
}
ddiff --git a/apps/server/src/net/gameRoutes.test.ts b/apps/server/src/net/gameRoutes.test.ts
nnew file mode 100644
iindex 0000000..56c7c4b
---- /dev/null
++++ b/apps/server/src/net/gameRoutes.test.ts
@@@ -0,0 +1,32 @@
++import { describe, expect, it } from 'vitest'
++import type { IncomingMessage, ServerResponse } from 'node:http'
++import { handleGameRead } from './gameRoutes.js'
++
++describe('game read routes', () => {
++ it('returns 404 for an unknown game before touching persistence', () => {
++ let status = 0
++ let body = ''
++ const req = {
++ method: 'GET',
++ url: '/api/v1/games/unknown/leaderboard?period=all',
++ } as IncomingMessage
++ const res = {
++ writeHead: (code: number) => {
++ status = code
++ },
++ end: (value: string) => {
++ body = value
++ },
++ } as ServerResponse
++ expect(
++ handleGameRead(req, res, {
++ db: null as never,
++ networkAuthUrl: null,
++ limits: null as never,
++ address: () => 'test',
++ }),
++ ).toBe(true)
++ expect(status).toBe(404)
++ expect(JSON.parse(body)).toEqual({ error: 'unknown_game' })
++ })
++})
ddiff --git a/apps/server/src/net/gameRoutes.ts b/apps/server/src/net/gameRoutes.ts
nnew file mode 100644
iindex 0000000..581bdf5
---- /dev/null
++++ b/apps/server/src/net/gameRoutes.ts
@@@ -0,0 +1,78 @@
++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 type { GamesActorLimits } from './actorLimits.js'
++import { isGameId, leaderboard, playerAchievements } from '../game/progression.js'
++
++interface Options {
++ db: Db
++ networkAuthUrl: string | null
++ limits: GamesActorLimits
++ address: (req: IncomingMessage) => string
++}
++
++export function handleGameRead(
++ req: IncomingMessage,
++ res: ServerResponse,
++ options: Options,
++): boolean {
++ 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 achievements = url.pathname === '/api/v1/players/me/achievements'
++ if (!match && !achievements) 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))
++ }
++ if (match && !isGameId(match[1] ?? '')) {
++ send(404, { error: 'unknown_game' })
++ return true
++ }
++ void (async () => {
++ if (!(await options.limits.publicRead(req, res, `ip:${options.address(req)}`))) return
++ if (match) {
++ const period = url.searchParams.get('period') ?? 'all'
++ const page = Number(url.searchParams.get('page') ?? '1')
++ if (
++ (period !== 'all' && period !== 'week') ||
++ !Number.isSafeInteger(page) ||
++ page < 1 ||
++ page > 10_000
++ ) {
++ send(400, { error: 'invalid_query' })
++ return
++ }
++ send(
++ 200,
++ await leaderboard(
++ options.db,
++ match[1] as 'scraplandia' | 'salvage-rush',
++ period,
++ page,
++ Date.now(),
++ ),
++ )
++ 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 (!key) {
++ send(401, { error: 'unauthorized' })
++ return
++ }
++ send(200, { games: await playerAchievements(options.db, key) })
++ })().catch(() => {
++ if (!res.headersSent) send(503, { error: 'read_unavailable' })
++ })
++ return true
++}
ddiff --git a/apps/server/src/net/httpServer.ts b/apps/server/src/net/httpServer.ts
iindex 044a7ed..bd3e6c2 100644
---- a/apps/server/src/net/httpServer.ts
++++ b/apps/server/src/net/httpServer.ts
@@@ -36,7 +36,7 @@ import { indexnowHandler, type SharedIndexNow } from './indexnow.js'
/** The portal page with its JSON-LD, built once per file version: the game server's event loop never reads the file
* per request (a new build changes the mtime and rebuilds it once). */
let portalMemo: { path: string; mtimeMs: number; html: string } | null = null
--function portalHtml(filePath: string): string {
++export function portalHtml(filePath: string): string {
const mtimeMs = statSync(filePath).mtimeMs
if (portalMemo && portalMemo.path === filePath && portalMemo.mtimeMs === mtimeMs)
return portalMemo.html
@@@ -167,6 +167,7 @@ export function createHttpServer(
platform?: HttpPlatformHooks,
tickets?: WsTicketStore,
indexnow?: SharedIndexNow,
++ defaultPlaceId = 'scraplandia',
): Server {
const root = staticDir ? resolve(staticDir) : null
// Sessions the Network confirmed recently: lets a silent login on a signed-in
@@@ -229,6 +230,12 @@ export function createHttpServer(
// The site's own legal pages (plan T11 decision 6): /terms, /privacy, /dmca. Before the SPA and the
// static fallback so they never resolve to a file or the landing page.
if (serveLegal(req, res)) return
++ const requestedMapPlace = new URL(req.url ?? '/', 'http://games.local').searchParams.get('game')
++ if (url === '/map.json' && requestedMapPlace && requestedMapPlace !== defaultPlaceId) {
++ res.writeHead(404, { 'content-type': 'application/json', 'cache-control': 'no-store' })
++ res.end('{"error":"no_editor_map"}')
++ return
++ }
if (url === '/map.json' && mapPath) {
// The canonical v2 document, with its revision as an ETag. This IS the
// wire format.
ddiff --git a/apps/server/src/net/portalGameLink.test.ts b/apps/server/src/net/portalGameLink.test.ts
nnew file mode 100644
iindex 0000000..73d73b8
---- /dev/null
++++ b/apps/server/src/net/portalGameLink.test.ts
@@@ -0,0 +1,22 @@
++import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
++import { tmpdir } from 'node:os'
++import { join } from 'node:path'
++import { describe, expect, it } from 'vitest'
++import { portalHtml } from './httpServer.js'
++
++describe('portal game card', () => {
++ it('keeps the locally hosted Salvage Rush route', () => {
++ const dir = mkdtempSync(join(tmpdir(), 'games-portal-'))
++ try {
++ const file = join(dir, 'index.html')
++ writeFileSync(
++ file,
++ '<html><head></head><body><a href="/play?game=salvage-rush">Rush</a></body></html>',
++ )
++ expect(portalHtml(file)).toContain('href="/play?game=salvage-rush"')
++ expect(portalHtml(file)).not.toContain('rush.example')
++ } finally {
++ rmSync(dir, { recursive: true, force: true })
++ }
++ })
++})
ddiff --git a/apps/server/src/net/wsTransport.test.ts b/apps/server/src/net/wsTransport.test.ts
iindex a2b9f30..8f6148e 100644
---- a/apps/server/src/net/wsTransport.test.ts
++++ b/apps/server/src/net/wsTransport.test.ts
@@@ -64,6 +64,7 @@ function openLimits() {
asset: () => Promise.resolve(true),
modWrite: () => Promise.resolve(true),
ticket: () => Promise.resolve(true),
++ publicRead: () => Promise.resolve(true),
upgrade: (req: unknown, _res: unknown, actor: string) =>
new Promise<boolean>((resolve) => {
;(req as { ovActor?: string }).ovActor = actor
ddiff --git a/apps/server/src/platform/accountData.ts b/apps/server/src/platform/accountData.ts
iindex ffa7503..8e98ced 100644
---- a/apps/server/src/platform/accountData.ts
++++ b/apps/server/src/platform/accountData.ts
@@@ -68,6 +68,15 @@ export function createAccountData(opts: {
const files = [
{ name: 'characters.json', content: data.characters },
...(data.structures.length ? [{ name: 'structures.json', content: data.structures }] : []),
++ ...(data.gameResults.length
++ ? [{ name: 'game-results.json', content: data.gameResults }]
++ : []),
++ ...(data.gameBestScores.length
++ ? [{ name: 'game-best-scores.json', content: data.gameBestScores }]
++ : []),
++ ...(data.gameAchievements.length
++ ? [{ name: 'game-achievements.json', content: data.gameAchievements }]
++ : []),
]
const res = await opts.send(`/internal/account-exports/${id}/parts`, { subject, files })
const outcome = res.ok
ddiff --git a/apps/server/src/platform/gameEvents.ts b/apps/server/src/platform/gameEvents.ts
iindex 7adc292..f8554f4 100644
---- a/apps/server/src/platform/gameEvents.ts
++++ b/apps/server/src/platform/gameEvents.ts
@@@ -12,9 +12,8 @@
* (common.moderation-action@1, ADR-022), for Network's moderation
* audit log
*
-- * Scraplandia has no achievement system, so there is no games.achievement.*
-- * event: skill levels and blueprint unlocks are the real progression
-- * milestones the game keeps.
++ * Games now stores per-game achievements locally. Achievement events are not yet
++ * published to OpenVibe.Events; Play will own that boundary later.
*
* Every event is written to Games' own `event_outbox` table inside the SAME
* SQLite transaction as the state it describes (progression events are
ddiff --git a/docs/adr/0007-rewrite-the-moddable-base.md b/docs/adr/0007-rewrite-the-moddable-base.md
iindex 6642ea8..831ac5f 100644
---- a/docs/adr/0007-rewrite-the-moddable-base.md
++++ b/docs/adr/0007-rewrite-the-moddable-base.md
@@@ -55,7 +55,8 @@ no mobile path, and pins that lag the platform.
sanity checks, signed asset manifests, replay telemetry: detection and revocation, no competitive-integrity claim.
10. **Platform game services live in a new service, OpenVibe.Play**: achievements, stats and leaderboards, player
inventories across games, presence and lobbies. Each game is a Play title with its own principal and `play.*`
-- grants scoped to that title. Games is the first consumer (M5); it never keeps its own copy.
++ grants scoped to that title. Games is the first consumer (M5). The M5 first part keeps a local seed schema
++ until Play exists, then moves those records and APIs to Play.
11. **Terrain stays heightfield patches** (the editor and `mapFileV2` keep them).
12. **Pin drift is a defect.** Contracts and SDK pins move to current; CI fails when the installed contracts package
rejects a manifest Games accepts, or the other way round.
@@@ -124,7 +125,20 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegac
- **M3.1 runtime, budgets, escape tests — done (docs/mods.md).** One QuickJS WASM instance per mod behind `sandbox.ts`; validated intents, CPU/memory/stack enforcement and escape tests. Off unless `GAMES_SCRIPT_MODS=1`.
- **M3.2 script capabilities and lifecycle — done.** Optional requested bindings on `games-quickjs@1`, approved grants pinned in the digest, per-mod/place PostgreSQL storage, deterministic random, contained ES modules, staff install/reload with audit and announcement, and a public validated pack snapshot fetched before browser bootstrap.
- **M4: instances and scale — done for one process.** Several isolated room simulations per place, a PostgreSQL registry and renewable fenced leases, validated same-process transfer with per-room positions and shared character inventory, configurable spatial cells/radius with spawn/despawn hysteresis, and a socketless join/move/transfer/leave load probe. The default is still one room; protocol v21 clients remain supported alongside v22 transfer. See `docs/instances.md`. Cross-process and multi-host routing, signed handoff tokens over Events, and region routing across hosts remain future work; PostgreSQL leases replace the originally proposed Valkey leases for this scope.
--- **M5: platform game services.** OpenVibe.Play with Games as first consumer; the second game on the same base.
++- **M5: second game and local progression — first part built.** Salvage Rush uses the base's movement, physics,
++ content registry, WebSocket intents, place and room instances. Its `games-content@2` built-in pack owns a small
++ primitive arena, pickup locations, round duration and achievement criteria. A server-only round module scores
++ proximity to pickups from simulated player positions. PostgreSQL stores completed results and unlocks; public
++ paginated leaderboards and a player's achievements are exposed by Games. Scraplandia's pack adds a first-visit
++ achievement from the server join event, while its leaderboard remains empty without a scored mode. This is a compact on-foot game because
++ movement and authoritative positions already exist, while vehicle racing would require checkpoint and vehicle
++ rules the current base does not have. One Games process now hosts both places with separate world rows,
++ instance registries, fenced leases and game systems. `hello.place` selects Salvage Rush on the same
++ WebSocket endpoint; missing or unknown places use `GAMES_PLACE_ID` as the default. Room transfer
++ stays within each place. Separate processes remain possible for scale.
++ **Still to build:** OpenVibe.Play as a separate service, Games as its first consumer, score/achievement migration
++ to Play. This local schema is the seed for Play's per-title results and unlocks;
++ the title-scoped principal/grant design in decision 10 remains the target.
- **M6: client floor.** Quality tiers, KTX2 and atlases, baked icons, bundle budget, editor E2E in CI.
Each milestone is playable end to end and keeps the slice harness green.
ddiff --git a/docs/games.md b/docs/games.md
nnew file mode 100644
iindex 0000000..93060a9
---- /dev/null
++++ b/docs/games.md
@@@ -0,0 +1,49 @@
++# Games on the base
++
++## Scraplandia
++
++The default place (`GAMES_PLACE_ID=scraplandia`) loads `SCRAPLANDIA_PACK`. Its persistent
++world, inventory, crafting, farming, PvP rules, and editor behavior stay as before. The
++pack defines a first-visit achievement unlocked by a server-confirmed join. Its leaderboard
++is public but empty until Scraplandia has a scored game event.
++
++## Salvage Rush
++
++The same Games server process also loads `SALVAGE_RUSH_PACK`: a flat 44-metre arena
++with primitive obstacles and eight virtual scrap pickup points. Players move on foot. After
++a 10-second intermission, a 90-second round begins. Crossing within 1.5 metres of an available
++pickup scores one point; it respawns after eight seconds. Ties share a rank. The server reads
++positions from its movement simulation; clients have no score intent. A room has its own round.
++The HUD shows phase, remaining time and scores. The Tab menu's Achievements panel reads the player's
++unlock list in both games. `/play?game=salvage-rush` fetches its own content and joins a Salvage
++Rush room on the same WebSocket endpoint. A missing or unknown place in `hello` joins the
++`GAMES_PLACE_ID` default. Each place has its own map, world rows, instance registry and room
++leases. Room transfers remain within their place. The editor map applies to the configured
++default place; Salvage Rush currently uses the map in its built-in content pack.
++
++Completed results are stored in `game_round_results`, keyed by game, round and account key.
++`game_best_scores` holds each player's all-time best; weekly bests come from the results table.
++`game_achievements` stores one unlock per game, criterion and player. Local guest keys are
++SHA-256 hashes as for characters; Network subjects remain canonical. No score is written
++until a server round ends. `GET /api/v1/games/:game/leaderboard?period=all|week&page=1`
++returns 20 personal bests per page, ordered by score, then earliest completion. `week`
++starts Monday 00:00 UTC. `GET /api/v1/players/me/achievements` accepts the same
++bearer or `X-Guest-Token` used by the ticket endpoint. Public reads have per-address limits.
++The read-only `/leaderboard.html` page shows Salvage Rush's best round scores, all time or this week (from Monday 00:00 UTC), 20 a page. Scraplandia has no scored mode, so it has no board yet.
++Network account exports include results, best scores and unlocks; account erasure removes them.
++Guest character adoption and Network subject merges move progression to the surviving subject.
++
++## Adding another game
++
++1. Create a `games-content@2` built-in pack with a validated map document and declarative
++ rules and achievements. Keep game-specific logic behind a place check.
++2. Add a server gameplay module that reads simulated state and emits completed results.
++ Never expose score writes as client intents.
++3. Add the place to server boot and content selection, with its own `InstanceRegistry`,
++ persistence store and gameplay module. Add a client HUD behind a dynamic import and a
++ local portal card.
++4. Add lifecycle, score integrity, criteria, persistence, join routing and API tests.
++ More Games processes may later be used for scale.
++
++OpenVibe.Play is the later home of these per-title results, achievements and leaderboards.
++The local tables and APIs are the seed for that extraction; Play is not yet running.
ddiff --git a/docs/instances.md b/docs/instances.md
iindex b2f18c4..dac7a7d 100644
---- a/docs/instances.md
++++ b/docs/instances.md
@@@ -10,4 +10,4 @@ The Players tab lists room buttons when multiple rooms are enabled. Transfer is
The socketless probe is `node --import tsx apps/server/scripts/instanceLoad.ts 4`; `--baseline` uses direct M3-style routing. It joins clients, drives movement for 90 ticks amid 400 spread entities per room, transfers one player out and back, and reports mean/p95 tick time and bytes per client. The two-client test uses conservative timing headroom because scheduler and WASM warmup vary; it keeps a tighter bandwidth bound.
--Scope: room routing and transfer work only within one server process. A second process or host cannot accept a handoff yet; region routing across hosts and cross-process transfer tokens remain future work. The room registry is shared in PostgreSQL, but this release does not direct a client to a remote process.
++Scope: one server process now hosts separate Scraplandia and Salvage Rush room hubs. Room routing and transfer stay within the joined place and this process. A second process or host cannot accept a handoff yet; region routing across hosts and cross-process transfer tokens remain future work. The room registry is shared in PostgreSQL, but this release does not direct a client to a remote process.
ddiff --git a/packages/content/package.json b/packages/content/package.json
iindex 7104256..9c12f89 100644
---- a/packages/content/package.json
++++ b/packages/content/package.json
@@@ -4,7 +4,8 @@
"private": true,
"type": "module",
"exports": {
-- ".": "./src/index.ts"
++ ".": "./src/index.ts",
++ "./salvage-rush": "./src/packs/salvageRush.ts"
},
"scripts": {
"build": "tsc --build"
ddiff --git a/packages/content/src/index.ts b/packages/content/src/index.ts
iindex 79dc2f1..c9ba8a3 100644
---- a/packages/content/src/index.ts
++++ b/packages/content/src/index.ts
@@@ -42,7 +42,10 @@ import { SKILLS } from './defs/skills.js'
* a bad pack kills the boot instead of corrupting a world. The registry is
* immutable: packs take effect when it is built, i.e. at the next start.
*/
--export function createContent(packs: ContentPackV2[] = []): ContentRegistry {
++export function createContent(
++ packs: ContentPackV2[] = [],
++ builtIn: ContentPackV2 = SCRAPLANDIA_PACK,
++): ContentRegistry {
const { defs, errors } = mergePackDefs<ContentDefs>(
{
items: ITEMS,
@@@ -67,7 +70,7 @@ export function createContent(packs: ContentPackV2[] = []): ContentRegistry {
zones: [],
},
},
-- [SCRAPLANDIA_PACK, ...packs],
++ [builtIn, ...packs],
)
if (errors.length > 0) throw new Error(`content pack: ${errors.join('; ')}`)
return new ContentRegistry(defs)
ddiff --git a/packages/content/src/pack.ts b/packages/content/src/pack.ts
iindex ae49f6a..06096d8 100644
---- a/packages/content/src/pack.ts
++++ b/packages/content/src/pack.ts
@@@ -40,6 +40,26 @@ export interface ContentPackV2 {
defs?: { [K in PackDefKind]?: unknown[] }
/** One authored map; the built-in Scraplandia pack supplies the default. */
map?: WorldDef
++ /** Place-scoped achievement definitions evaluated from server events. */
++ achievements?: {
++ id: string
++ name: string
++ description: string
++ criterion: { event: 'player_joined' }
++ }[]
++ /** Built-in game rules. Executable rule evaluation remains server-owned. */
++ game?: {
++ id: string
++ roundSeconds: number
++ intermissionSeconds: number
++ pickups: [number, number, number][]
++ achievements: {
++ id: string
++ name: string
++ description: string
++ criterion: { event: 'round_finished'; minScore?: number; minRank?: number }
++ }[]
++ }
announcements?: { text: string; everySeconds: number }[]
props?: { key: string; item: string; pos: [number, number, number]; yaw?: number }[]
/** `games-quickjs@1` server script mods (schema/scriptMod.ts); they are part of the def set. */
ddiff --git a/packages/content/src/packs/salvageRush.ts b/packages/content/src/packs/salvageRush.ts
nnew file mode 100644
iindex 0000000..1d63392
---- /dev/null
++++ b/packages/content/src/packs/salvageRush.ts
@@@ -0,0 +1,65 @@
++import type { ContentPackV2 } from '../pack.js'
++
++/** A compact on-foot collection arena. Pickups are virtual, server-owned points. */
++export const SALVAGE_RUSH_PACK: ContentPackV2 = {
++ map: {
++ id: 'salvage_rush_arena',
++ name: 'Salvage Rush Arena',
++ flatTerrain: true,
++ groundHalfExtent: 22,
++ spawnPoint: [0, 1.2, 0],
++ spawnYaw: 0,
++ statics: [
++ { shape: { type: 'box', size: [3, 1, 3] }, pos: [-7, 0.5, -7], yaw: 0, color: '#ef8d4a' },
++ { shape: { type: 'box', size: [3, 1, 3] }, pos: [7, 0.5, 7], yaw: 0, color: '#4ac9c2' },
++ { shape: { type: 'box', size: [4, 1, 2] }, pos: [-7, 0.5, 7], yaw: 0, color: '#f4cb65' },
++ { shape: { type: 'box', size: [4, 1, 2] }, pos: [7, 0.5, -7], yaw: 0, color: '#8e9ce9' },
++ ],
++ resourceNodes: [],
++ initialProps: [],
++ zones: [
++ {
++ id: 'arena',
++ name: 'Arena',
++ min: [-22, -2, -22],
++ max: [22, 10, 22],
++ rules: { pvp: false, build: false, physgun: false },
++ },
++ ],
++ },
++ game: {
++ id: 'salvage-rush',
++ roundSeconds: 90,
++ intermissionSeconds: 10,
++ pickups: [
++ [-12, 1, 0],
++ [12, 1, 0],
++ [0, 1, -12],
++ [0, 1, 12],
++ [-11, 1, -11],
++ [11, 1, 11],
++ [-11, 1, 11],
++ [11, 1, -11],
++ ],
++ achievements: [
++ {
++ id: 'first-haul',
++ name: 'First Haul',
++ description: 'Score in a Salvage Rush round.',
++ criterion: { event: 'round_finished', minScore: 1 },
++ },
++ {
++ id: 'big-haul',
++ name: 'Big Haul',
++ description: 'Score at least five points in one round.',
++ criterion: { event: 'round_finished', minScore: 5 },
++ },
++ {
++ id: 'arena-winner',
++ name: 'Arena Winner',
++ description: 'Finish a round in first place.',
++ criterion: { event: 'round_finished', minRank: 1 },
++ },
++ ],
++ },
++}
ddiff --git a/packages/content/src/packs/scraplandia.ts b/packages/content/src/packs/scraplandia.ts
iindex 3e679ef..3b62bce 100644
---- a/packages/content/src/packs/scraplandia.ts
++++ b/packages/content/src/packs/scraplandia.ts
@@@ -7,6 +7,14 @@ import type { ContentPackV2 } from '../pack.js'
* SCRAP_CITY definition.
*/
export const SCRAPLANDIA_PACK: ContentPackV2 = {
++ achievements: [
++ {
++ id: 'first-visit',
++ name: 'First Visit',
++ description: 'Join Scraplandia.',
++ criterion: { event: 'player_joined' },
++ },
++ ],
map: {
id: 'openvibeville_v2',
flatTerrain: true,
ddiff --git a/packages/content/src/terrain.ts b/packages/content/src/terrain.ts
iindex 8317fe8..69e8f79 100644
---- a/packages/content/src/terrain.ts
++++ b/packages/content/src/terrain.ts
@@@ -183,13 +183,21 @@ export interface MapOverride {
}
let mapOverride: MapOverride | null = null
++const placeMapOverrides = new Map<string, MapOverride>()
++let suppressMapOverride = false
--export function setMapOverride(map: MapOverride | null): void {
++export function setMapOverride(map: MapOverride | null, worldId?: string): void {
++ if (worldId) {
++ if (map) placeMapOverrides.set(worldId, map)
++ else placeMapOverrides.delete(worldId)
++ return
++ }
mapOverride = map
}
--export function getMapOverride(): MapOverride | null {
-- return mapOverride
++export function getMapOverride(world?: WorldDef): MapOverride | null {
++ if (suppressMapOverride) return null
++ return world ? (placeMapOverrides.get(world.id) ?? mapOverride) : mapOverride
}
function bilinearGrid(
@@@ -250,7 +258,8 @@ function sampleOverride(map: MapOverride, x: number, z: number): number | null {
* own default instead of the sampler inventing ground.
*/
export function terrainHeightAt(world: WorldDef, x: number, z: number): number | null {
-- if (mapOverride) return sampleOverride(mapOverride, x, z)
++ const override = getMapOverride(world)
++ if (override) return sampleOverride(override, x, z)
if (world.flatTerrain) return 0
return terrainHeight(world, x, z)
}
@@@ -262,7 +271,8 @@ export function terrainHeightAt(world: WorldDef, x: number, z: number): number |
* `terrainHeightAt` when "there is no ground here" is meaningful.
*/
export function terrainHeight(world: WorldDef, x: number, z: number): number {
-- if (mapOverride) return sampleOverride(mapOverride, x, z) ?? 0
++ const override = getMapOverride(world)
++ if (override) return sampleOverride(override, x, z) ?? 0
if (world.flatTerrain) return 0
let mask = 1
for (const pad of padsFor(world)) {
@@@ -289,6 +299,7 @@ export function defaultHeights(world: WorldDef, sub: number): Float32Array {
const heights = new Float32Array((sub + 1) * (sub + 1))
const saved = mapOverride
mapOverride = null
++ suppressMapOverride = true
for (let j = 0; j <= sub; j++) {
for (let i = 0; i <= sub; i++) {
heights[j * (sub + 1) + i] = terrainHeight(
@@@ -299,6 +310,7 @@ export function defaultHeights(world: WorldDef, sub: number): Float32Array {
}
}
mapOverride = saved
++ suppressMapOverride = false
return heights
}
@@@ -387,7 +399,7 @@ export function worldSpawn(world: WorldDef): {
pos: [number, number, number]
yaw: number
} {
-- const o = mapOverride
++ const o = getMapOverride(world)
return {
pos: o?.spawn ?? [world.spawnPoint[0], world.spawnPoint[1], world.spawnPoint[2]],
yaw: o?.spawnYaw ?? world.spawnYaw,
ddiff --git a/packages/persistence/migrations/0004_game_progress.sql b/packages/persistence/migrations/0004_game_progress.sql
nnew file mode 100644
iindex 0000000..836c72f
---- /dev/null
++++ b/packages/persistence/migrations/0004_game_progress.sql
@@@ -0,0 +1,32 @@
++-- phase: expand
++-- M5: round results are the sole score source. The best score is derived from them.
++CREATE TABLE game_round_results (
++ game_id text COLLATE "C" NOT NULL,
++ round_id text COLLATE "C" NOT NULL,
++ account_key text COLLATE "C" NOT NULL,
++ player_name text NOT NULL,
++ score integer NOT NULL CHECK (score >= 0),
++ rank integer NOT NULL CHECK (rank > 0),
++ finished_at bigint NOT NULL,
++ PRIMARY KEY (game_id, round_id, account_key)
++);
++CREATE INDEX game_round_results_board ON game_round_results (game_id, finished_at, score DESC);
++
++CREATE TABLE game_best_scores (
++ game_id text COLLATE "C" NOT NULL,
++ account_key text COLLATE "C" NOT NULL,
++ player_name text NOT NULL,
++ score integer NOT NULL CHECK (score >= 0),
++ finished_at bigint NOT NULL,
++ PRIMARY KEY (game_id, account_key)
++);
++CREATE INDEX game_best_scores_board ON game_best_scores (game_id, score DESC, finished_at ASC);
++
++CREATE TABLE game_achievements (
++ game_id text COLLATE "C" NOT NULL,
++ achievement_id text COLLATE "C" NOT NULL,
++ account_key text COLLATE "C" NOT NULL,
++ unlocked_at bigint NOT NULL,
++ PRIMARY KEY (game_id, achievement_id, account_key)
++);
++CREATE INDEX game_achievements_player ON game_achievements (account_key, game_id);
ddiff --git a/packages/persistence/src/dto.ts b/packages/persistence/src/dto.ts
iindex ee9dd39..cab2e2e 100644
---- a/packages/persistence/src/dto.ts
++++ b/packages/persistence/src/dto.ts
@@@ -39,6 +39,8 @@ export interface PlayerDto {
subjectId?: string
name: string
pos: [number, number, number]
++ /** Join hint: this room or place has a saved position for the character. */
++ positionInPlace?: boolean
yaw: number
inventory: InventoryDto
/** Total XP per skill id (levels are derived at runtime). */
ddiff --git a/packages/persistence/src/pg/pgStore.ts b/packages/persistence/src/pg/pgStore.ts
iindex 6143826..ff51f5e 100644
---- a/packages/persistence/src/pg/pgStore.ts
++++ b/packages/persistence/src/pg/pgStore.ts
@@@ -49,6 +49,7 @@ interface WorldEntityRow {
interface CharacterRow {
id: string
++ place_id: string | null
account_key: string
slot: number
subject_id: string | null
@@@ -268,6 +269,7 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
)
if (!row) return null
const player = rowToPlayer(row)
++ player.positionInPlace = row.place_id === placeId
if (opts.instanceSlot !== undefined) {
const position = await db.maybe<{
pos_x: number
@@@ -281,6 +283,7 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
if (position) {
player.pos = [position.pos_x, position.pos_y, position.pos_z]
player.yaw = position.yaw
++ player.positionInPlace = true
}
}
return player
@@@ -305,11 +308,14 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
})
},
async resetAllPositions(pos: [number, number, number], yaw: number): Promise<void> {
-- await writeGameState(() =>
-- db.exec(
-- sql`UPDATE characters SET pos_x = ${pos[0]}, pos_y = ${pos[1]}, pos_z = ${pos[2]}, yaw = ${yaw}`,
-- ),
-- )
++ await writeGameState(async () => {
++ await db.exec(
++ sql`UPDATE character_instance_positions SET pos_x = ${pos[0]}, pos_y = ${pos[1]}, pos_z = ${pos[2]}, yaw = ${yaw} WHERE instance_id = ${`${placeId}:${opts.instanceSlot ?? 0}`}`,
++ )
++ await db.exec(
++ sql`UPDATE characters SET pos_x = ${pos[0]}, pos_y = ${pos[1]}, pos_z = ${pos[2]}, yaw = ${yaw} WHERE place_id = ${placeId}`,
++ )
++ })
},
}
ddiff --git a/packages/persistence/src/pg/platformRepositories.ts b/packages/persistence/src/pg/platformRepositories.ts
iindex 202be1a..d331ead 100644
---- a/packages/persistence/src/pg/platformRepositories.ts
++++ b/packages/persistence/src/pg/platformRepositories.ts
@@@ -44,6 +44,33 @@ export function createIdentityRepository(db: Db): IdentityRepository {
db.exec(
sql`UPDATE characters SET account_key = ${account}, subject_id = ${subject}, slot = ${slot} WHERE id = ${id}`,
)
++ async function moveProgress(from: string, into: string): Promise<void> {
++ await db.exec(sql`
++ INSERT INTO game_round_results (game_id, round_id, account_key, player_name, score, rank, finished_at)
++ SELECT game_id, round_id, ${into}, player_name, score, rank, finished_at
++ FROM game_round_results WHERE account_key = ${from}
++ ON CONFLICT (game_id, round_id, account_key) DO NOTHING
++ `)
++ await db.exec(sql`DELETE FROM game_round_results WHERE account_key = ${from}`)
++ await db.exec(sql`
++ INSERT INTO game_best_scores (game_id, account_key, player_name, score, finished_at)
++ SELECT game_id, ${into}, player_name, score, finished_at
++ FROM game_best_scores WHERE account_key = ${from}
++ ON CONFLICT (game_id, account_key) DO UPDATE SET
++ player_name = excluded.player_name, score = excluded.score, finished_at = excluded.finished_at
++ WHERE excluded.score > game_best_scores.score OR
++ (excluded.score = game_best_scores.score AND excluded.finished_at < game_best_scores.finished_at)
++ `)
++ await db.exec(sql`DELETE FROM game_best_scores WHERE account_key = ${from}`)
++ await db.exec(sql`
++ INSERT INTO game_achievements (game_id, achievement_id, account_key, unlocked_at)
++ SELECT game_id, achievement_id, ${into}, unlocked_at
++ FROM game_achievements WHERE account_key = ${from}
++ ON CONFLICT (game_id, achievement_id, account_key) DO UPDATE SET
++ unlocked_at = LEAST(game_achievements.unlocked_at, excluded.unlocked_at)
++ `)
++ await db.exec(sql`DELETE FROM game_achievements WHERE account_key = ${from}`)
++ }
const recordAdoption = (
key: string,
subject: string,
@@@ -81,6 +108,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
moved++
}
if (moved === 0) return { moved: 0, slot: null, full: true }
++ await moveProgress(key, subjectId)
await recordAdoption(key, subjectId, 'guest', moved, rows.length - moved, now)
return { moved, slot, full: false }
})
@@@ -115,6 +143,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
taken.add(slot)
moved++
}
++ await moveProgress(from, into)
await recordAdoption(key, into, 'merge', moved, kept, now)
return { moved, kept, already: false }
})
@@@ -130,9 +159,21 @@ export function createIdentityRepository(db: Db): IdentityRepository {
sql`SELECT * FROM world_entities WHERE owner_id IN (${idList(ids)})`,
)
: []
++ const gameResults = await db.many<Record<string, unknown>>(
++ sql`SELECT game_id, round_id, player_name, score, rank, finished_at FROM game_round_results WHERE account_key = ${subject} ORDER BY finished_at`,
++ )
++ const gameBestScores = await db.many<Record<string, unknown>>(
++ sql`SELECT game_id, player_name, score, finished_at FROM game_best_scores WHERE account_key = ${subject} ORDER BY game_id`,
++ )
++ const gameAchievements = await db.many<Record<string, unknown>>(
++ sql`SELECT game_id, achievement_id, unlocked_at FROM game_achievements WHERE account_key = ${subject} ORDER BY game_id, achievement_id`,
++ )
return {
characters: characters.map(withoutAccountKey),
structures,
++ gameResults,
++ gameBestScores,
++ gameAchievements,
}
},
@@@ -154,6 +195,12 @@ export function createIdentityRepository(db: Db): IdentityRepository {
out.identity_rows = await db.exec(
sql`DELETE FROM identity_adoptions WHERE subject_id IN (${idList(subjects)})`,
)
++ // Game progression uses the same canonical account keys as characters.
++ await db.exec(sql`DELETE FROM game_achievements WHERE account_key IN (${idList(subjects)})`)
++ await db.exec(sql`DELETE FROM game_best_scores WHERE account_key IN (${idList(subjects)})`)
++ await db.exec(
++ sql`DELETE FROM game_round_results WHERE account_key IN (${idList(subjects)})`,
++ )
return out
})
},
ddiff --git a/packages/persistence/src/repositories.ts b/packages/persistence/src/repositories.ts
iindex f35ff9e..0a8adec 100644
---- a/packages/persistence/src/repositories.ts
++++ b/packages/persistence/src/repositories.ts
@@@ -84,6 +84,9 @@ export interface IdentityRepository {
exportSubject(subject: string): Promise<{
characters: Record<string, unknown>[]
structures: Record<string, unknown>[]
++ gameResults: Record<string, unknown>[]
++ gameBestScores: Record<string, unknown>[]
++ gameAchievements: Record<string, unknown>[]
}>
/**
* Erase these subjects (a deleted account and the accounts merged into it; ADR-033), in one transaction: their
ddiff --git a/packages/persistence/src/testing.ts b/packages/persistence/src/testing.ts
iindex 47aaae7..2bb6eef 100644
---- a/packages/persistence/src/testing.ts
++++ b/packages/persistence/src/testing.ts
@@@ -13,6 +13,9 @@ export { MIGRATIONS_DIR }
const TABLES = [
'places',
++ 'game_round_results',
++ 'game_best_scores',
++ 'game_achievements',
'game_instances',
'character_instance_positions',
'world_meta',
ddiff --git a/packages/protocol/src/messages/client.ts b/packages/protocol/src/messages/client.ts
iindex 03afa4d..5f4a2ad 100644
---- a/packages/protocol/src/messages/client.ts
++++ b/packages/protocol/src/messages/client.ts
@@@ -16,6 +16,8 @@ export const ClientHelloSchema = z.object({
.string()
.regex(/^v[0-9]+-[0-9a-f]{8}$/)
.optional(),
++ /** Requested place. Older clients omit it and join the configured default. */
++ place: z.string().min(1).max(64).optional(),
/** Character slot under this account (MMO-style, 3 max). */
slot: z.number().int().min(0).max(2).default(0),
name: z.string().min(1).max(24),
ddiff --git a/packages/protocol/src/messages/server.ts b/packages/protocol/src/messages/server.ts
iindex 847b018..cafdd8e 100644
---- a/packages/protocol/src/messages/server.ts
++++ b/packages/protocol/src/messages/server.ts
@@@ -271,6 +271,14 @@ export interface ServerContainer {
}
export type ServerMessage =
++ | {
++ t: 'salvage_round'
++ roundId: string
++ phase: 'intermission' | 'active'
++ endsAt: number
++ pickups: { x: number; z: number; available: boolean }[]
++ scores: { name: string; score: number; rank: number }[]
++ }
| ServerWelcome
| ServerTransferResult
| ServerReject
ddiff --git a/test/apps__server__src__game__placeRouter.test.js b/test/apps__server__src__game__placeRouter.test.js
nnew file mode 100644
iindex 0000000..20cbd81
---- /dev/null
++++ b/test/apps__server__src__game__placeRouter.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/server/src/game/placeRouter.test.ts'
++// covers: 'apps/server/src/game'
++// Generated by node test/run.js --index; do not edit.
ddiff --git a/test/apps__server__src__game__progression.test.js b/test/apps__server__src__game__progression.test.js
nnew file mode 100644
iindex 0000000..db2771e
---- /dev/null
++++ b/test/apps__server__src__game__progression.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/server/src/game/progression.test.ts'
++// covers: 'apps/server/src/game'
++// Generated by node test/run.js --index; do not edit.
ddiff --git a/test/apps__server__src__game__salvageRush.test.js b/test/apps__server__src__game__salvageRush.test.js
nnew file mode 100644
iindex 0000000..40a93b0
---- /dev/null
++++ b/test/apps__server__src__game__salvageRush.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/server/src/game/salvageRush.test.ts'
++// covers: 'apps/server/src/game'
++// Generated by node test/run.js --index; do not edit.
ddiff --git a/test/apps__server__src__net__gameRoutes.test.js b/test/apps__server__src__net__gameRoutes.test.js
nnew file mode 100644
iindex 0000000..501c43a
---- /dev/null
++++ b/test/apps__server__src__net__gameRoutes.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/server/src/net/gameRoutes.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__portalGameLink.test.js b/test/apps__server__src__net__portalGameLink.test.js
nnew file mode 100644
iindex 0000000..c77dcad
---- /dev/null
++++ b/test/apps__server__src__net__portalGameLink.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/server/src/net/portalGameLink.test.ts'
++// covers: 'apps/server/src/net'
++// Generated by node test/run.js --index; do not edit.