openvibers/openvibe.games Commits
Games M5: Salvage Rush (a second game in the same process), achievements and leaderboards (#46)
Changes
@@@ -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, andextraction 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 usesan 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 sametransaction.- **Media.** Map-editor assets (textures, paint masks, glb models; the only@@@ -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",@@@ -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">nnew file mode 100644@@@ -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>@@@ -67,24 +67,37 @@ async function start(): Promise<void> {const uiRoot = document.getElementById('ui') as HTMLElementconst 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?: stringpacks?: 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[] | undefinedtry {// /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),++ )++ : nullhud.onQualityChange = (override) => {const quality = qualityForTier(resolveQualityTier(override, readDeviceSignals()))applyEngineQuality(engine, quality)@@@ -343,6 +365,7 @@ async function start(): Promise<void> {})let rejected = falseconnection.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) returnregisterMapAssets({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.@@@ -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 {nnew file mode 100644@@@ -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()@@@ -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 = rootthis.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' }nnew file mode 100644@@@ -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;++ }++}nnew file mode 100644@@@ -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)++ }++ }++}@@@ -37,6 +37,7 @@ export default defineConfig({home: 'index.html',main: 'play.html',editor: 'editor.html',++ leaderboard: 'leaderboard.html',},},outDir: 'dist',@@@ -22,7 +22,7 @@ export interface DbConfig {export interface ServerConfig {port: numberhost: 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: stringdb: 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:@@@ -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)@@@ -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 nullconst 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) === nullif (!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.worldconst 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,@@@ -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()}nnew file mode 100644@@@ -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()++ }++})nnew file mode 100644@@@ -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)++ }++}nnew file mode 100644@@@ -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([])++ })++})nnew file mode 100644@@@ -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,++ })),++ }))++}nnew file mode 100644@@@ -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)++ })++})nnew file mode 100644@@@ -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,++ }))++ }++}@@@ -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. */@@@ -189,9 +189,10 @@ export class SessionsSystem implements System {const world = ctx.world.content.worldconst 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,@@@ -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 undefinedconst { 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 = 0for (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 trueif (revocations.handle(req, res)) return trueif (readiness.handle(req, res)) return trueif (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) returnconst 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', {@@@ -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),}}nnew file mode 100644@@@ -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' })++ })++})nnew file mode 100644@@@ -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++}@@@ -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).mtimeMsif (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.nnew file mode 100644@@@ -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 })++ }++ })++})@@@ -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@@@ -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@@@ -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@@@ -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, playerinventories 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 packagerejects 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.nnew file mode 100644@@@ -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.@@@ -10,4 +10,4 @@ The Players tab lists room buttons when multiple rooms are enabled. Transfer isThe 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.@@@ -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"@@@ -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)@@@ -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. */nnew file mode 100644@@@ -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 },++ },++ ],++ },++}@@@ -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,@@@ -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 0return 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) ?? 0if (world.flatTerrain) return 0let mask = 1for (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 = mapOverridemapOverride = null++ suppressMapOverride = truefor (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 = falsereturn 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,nnew file mode 100644@@@ -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);@@@ -39,6 +39,8 @@ export interface PlayerDto {subjectId?: stringname: stringpos: [number, number, number]++ /** Join hint: this room or place has a saved position for the character. */++ positionInPlace?: booleanyaw: numberinventory: InventoryDto/** Total XP per skill id (levels are derived at runtime). */@@@ -49,6 +49,7 @@ interface WorldEntityRow {interface CharacterRow {id: string++ place_id: string | nullaccount_key: stringslot: numbersubject_id: string | null@@@ -268,6 +269,7 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto)if (!row) return nullconst player = rowToPlayer(row)++ player.positionInPlace = row.place_id === placeIdif (opts.instanceSlot !== undefined) {const position = await db.maybe<{pos_x: number@@@ -281,6 +283,7 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceStoif (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}`,++ )++ })},}@@@ -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})},@@@ -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@@@ -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',@@@ -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),@@@ -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| ServerRejectnnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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.
@@ -1,8 +1,11 @@
@@ -1,8 +1,11 @@
-# Scraplandia
+# OpenVibe.Games
## Purpose
## 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,
A persistent multiplayer physics sandbox for the browser: Source-style movement,
physgun-driven building from crafted physical objects, gathering, crafting, and
physgun-driven building from crafted physical objects, gathering, crafting, and
extraction risk — running against an authoritative dedicated server.
extraction risk — running against an authoritative dedicated server.
@@ -112,7 +115,8 @@ DATABASE_URL=postgres://… DATABASE_DIRECT_URL=postgres://… STATIC_DIR=apps/c
@@ -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`
`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
(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.
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
(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).
(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;
`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
@@ -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`
`games.skill.leveled`, `games.blueprint.unlocked`, `games.world.saved`
(at most one checkpoint per `WORLD_SAVED_EVENT_MINUTES`, plus shutdown) and
(at most one checkpoint per `WORLD_SAVED_EVENT_MINUTES`, plus shutdown) and
`games.mod.installed|enabled|disabled|grants_changed|revoked`. There is no
`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
Progression events come from what a save actually wrote, in the same
transaction.
transaction.
- **Media.** Map-editor assets (textures, paint masks, glb models; the only
- **Media.** Map-editor assets (textures, paint masks, glb models; the only
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"repository": "OpenVibers/OpenVibe.Games",
"repository": "OpenVibers/OpenVibe.Games",
"stage": "stable",
"stage": "stable",
"code": true,
"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",
"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,
"port": 8000,
"domain": "openvibe.games, play.openvibe.games",
"domain": "openvibe.games, play.openvibe.games",
@@ -26,6 +26,7 @@
@@ -26,6 +26,7 @@
"Source-style movement, physgun building, gathering, crafting, skills",
"Source-style movement, physgun building, gathering, crafting, skills",
"persistent world, characters and props in PostgreSQL (write-behind flusher; the tick never awaits I/O)",
"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",
"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",
"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",
"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",
"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",
@@ -268,6 +268,39 @@
@@ -268,6 +268,39 @@
.play-cta:hover {
.play-cta:hover {
transform: translateY(-3px) scale(1.02);
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 {
@keyframes pulse {
0%,
0%,
100% {
100% {
@@ -421,6 +454,24 @@
@@ -421,6 +454,24 @@
>
>
</div>
</div>
</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>
<section class="account">
<section class="account">
new file mode 100644
new file mode 100644
@@ -0,0 +1,249 @@
@@ -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>
@@ -67,24 +67,37 @@ async function start(): Promise<void> {
@@ -67,24 +67,37 @@ async function start(): Promise<void> {
const uiRoot = document.getElementById('ui') as HTMLElement
const uiRoot = document.getElementById('ui') as HTMLElement
const identity = getIdentity()
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.
// 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.
// A fresh client can join after a staff script install or reload changed the digest.
let content = createContent()
let content = createContent()
try {
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) {
if (response.ok) {
const data = (await response.json()) as {
const data = (await response.json()) as {
digest?: string
digest?: string
+ game?: string
packs?: Parameters<typeof createContent>[0]
packs?: Parameters<typeof createContent>[0]
}
}
if (Array.isArray(data.packs)) {
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
if (active.digest === data.digest) content = active
}
}
}
}
} catch {
} catch {
// The hello digest still refuses a mismatched join if content is unavailable.
// 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).
// 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
// The map as the runtime currently has it, so a reload can diff against
// it rather than rebuilding every category.
// it rather than rebuilding every category.
@@ -92,7 +105,7 @@ async function start(): Promise<void> {
@@ -92,7 +105,7 @@ async function start(): Promise<void> {
let mapLightsBoot: MapLight[] | undefined
let mapLightsBoot: MapLight[] | undefined
try {
try {
// /map.json is the v2 document, parsed and validated on the way in.
// /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) {
if (parsed.ok) {
registerMapAssets({
registerMapAssets({
textures: parsed.map.textures as MapTextureEntry[],
textures: parsed.map.textures as MapTextureEntry[],
@@ -196,7 +209,16 @@ async function start(): Promise<void> {
@@ -196,7 +209,16 @@ async function start(): Promise<void> {
const weaponSettings = new WeaponSettings()
const weaponSettings = new WeaponSettings()
registerPhysgunModule()
registerPhysgunModule()
registerRiggingModule()
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) => {
hud.onQualityChange = (override) => {
const quality = qualityForTier(resolveQualityTier(override, readDeviceSignals()))
const quality = qualityForTier(resolveQualityTier(override, readDeviceSignals()))
applyEngineQuality(engine, quality)
applyEngineQuality(engine, quality)
@@ -343,6 +365,7 @@ async function start(): Promise<void> {
@@ -343,6 +365,7 @@ async function start(): Promise<void> {
})
})
let rejected = false
let rejected = false
connection.onMessage = (msg) => {
connection.onMessage = (msg) => {
+ if (msg.t === 'salvage_round') roundUi?.update(msg)
if (msg.t === 'welcome' && state.myEntityId && msg.instanceId !== state.instanceId) {
if (msg.t === 'welcome' && state.myEntityId && msg.instanceId !== state.instanceId) {
player.onTransfer()
player.onTransfer()
}
}
@@ -358,7 +381,7 @@ async function start(): Promise<void> {
@@ -358,7 +381,7 @@ async function start(): Promise<void> {
if (msg.t === 'map_reload') {
if (msg.t === 'map_reload') {
void (async () => {
void (async () => {
try {
try {
- const parsed = parseMapFile(await (await fetch('/map.json')).json())
+ const parsed = parseMapFile(await (await fetch(`/map.json${placeQuery}`)).json())
if (!parsed.ok) return
if (!parsed.ok) return
registerMapAssets({
registerMapAssets({
textures: parsed.map.textures as MapTextureEntry[],
textures: parsed.map.textures as MapTextureEntry[],
@@ -482,6 +505,7 @@ async function start(): Promise<void> {
@@ -482,6 +505,7 @@ async function start(): Promise<void> {
content.digest,
content.digest,
slot,
slot,
sso,
sso,
+ selectedPlace,
)
)
} catch {
} catch {
// Refused or timed out (a page opened during a restart): wait for the server, then reload.
// Refused or timed out (a page opened during a restart): wait for the server, then reload.
@@ -25,8 +25,17 @@ export function helloForContent(
@@ -25,8 +25,17 @@ export function helloForContent(
appearance: Appearance,
appearance: Appearance,
slot: number,
slot: number,
contentDigest: string,
contentDigest: string,
+ place?: string,
): ClientHello {
): 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. */
/** 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 {
@@ -95,6 +104,7 @@ export class Connection {
slot = 0,
slot = 0,
/** Network access token; ignored if absent (the identity is the ticket). */
/** Network access token; ignored if absent (the identity is the ticket). */
networkToken?: string,
networkToken?: string,
+ place?: string,
): Promise<void> {
): Promise<void> {
const ticket = await fetchWsTicket({
const ticket = await fetchWsTicket({
...(networkToken ? { networkToken } : {}),
...(networkToken ? { networkToken } : {}),
@@ -138,7 +148,7 @@ export class Connection {
@@ -138,7 +148,7 @@ export class Connection {
}
}
ws.onclose = () => this.onClose?.()
ws.onclose = () => this.onClose?.()
// Identity was resolved at the upgrade; hello carries no identity field.
// 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 {
send(msg: ClientMessage): void {
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -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()
@@ -24,7 +24,14 @@ import {
@@ -24,7 +24,14 @@ import {
*/
*/
type MenuTab =
type MenuTab =
- 'inventory' | 'crafting' | 'equipment' | 'skills' | 'standing' | 'players' | 'settings'
+ | 'inventory'
+ | 'crafting'
+ | 'equipment'
+ | 'skills'
+ | 'achievements'
+ | 'standing'
+ | 'players'
+ | 'settings'
export class Hud {
export class Hud {
private root: HTMLElement
private root: HTMLElement
@@ -49,6 +56,7 @@ export class Hud {
@@ -49,6 +56,7 @@ export class Hud {
private readonly connection: Connection,
private readonly connection: Connection,
private readonly icons: IconFactory,
private readonly icons: IconFactory,
private readonly weaponSettings: WeaponSettings,
private readonly weaponSettings: WeaponSettings,
+ private readonly achievementIdentity?: { guest: string; bearer?: string },
) {
) {
this.root = root
this.root = root
this.build()
this.build()
@@ -536,6 +544,7 @@ export class Hud {
@@ -536,6 +544,7 @@ export class Hud {
['crafting', 'Crafting'],
['crafting', 'Crafting'],
['equipment', 'Equipment'],
['equipment', 'Equipment'],
['skills', 'Skills'],
['skills', 'Skills'],
+ ['achievements', 'Achievements'],
['players', 'Players'],
['players', 'Players'],
['settings', 'Settings'],
['settings', 'Settings'],
]
]
@@ -575,11 +584,44 @@ export class Hud {
@@ -575,11 +584,44 @@ export class Hud {
else if (this.activeTab === 'crafting') this.renderCrafting()
else if (this.activeTab === 'crafting') this.renderCrafting()
else if (this.activeTab === 'equipment') this.renderEquipment()
else if (this.activeTab === 'equipment') this.renderEquipment()
else if (this.activeTab === 'skills') this.renderSkills()
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 === 'standing') this.renderStanding()
else if (this.activeTab === 'settings') this.renderSettings()
else if (this.activeTab === 'settings') this.renderSettings()
else this.renderPlayers()
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). */
/** Settings: graphics quality (Auto follows the device; resolution and shadows apply at once). */
private renderSettings(): void {
private renderSettings(): void {
const tierLabel: Record<QualityTier, string> = { high: 'High', medium: 'Medium', low: 'Low' }
const tierLabel: Record<QualityTier, string> = { high: 'High', medium: 'Medium', low: 'Low' }
new file mode 100644
new file mode 100644
@@ -0,0 +1,126 @@
@@ -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;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -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)
+ }
+ }
+}
@@ -37,6 +37,7 @@ export default defineConfig({
@@ -37,6 +37,7 @@ export default defineConfig({
home: 'index.html',
home: 'index.html',
main: 'play.html',
main: 'play.html',
editor: 'editor.html',
editor: 'editor.html',
+ leaderboard: 'leaderboard.html',
},
},
},
},
outDir: 'dist',
outDir: 'dist',
@@ -22,7 +22,7 @@ export interface DbConfig {
@@ -22,7 +22,7 @@ export interface DbConfig {
export interface ServerConfig {
export interface ServerConfig {
port: number
port: number
host: string
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
placeId: string
db: DbConfig
db: DbConfig
/** Directory of built client assets to serve, or null for API/WS only. */
/** Directory of built client assets to serve, or null for API/WS only. */
@@ -157,7 +157,9 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
@@ -157,7 +157,9 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
valkeyPrefix: env.VALKEY_PREFIX ?? 'ov:games:',
valkeyPrefix: env.VALKEY_PREFIX ?? 'ov:games:',
},
},
staticDir: env.STATIC_DIR ?? null,
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),
trustProxy: parseProxyTrust(env.TRUST_PROXY),
/** openvibe.network auth endpoint; when set, editor tokens validate there. */
/** openvibe.network auth endpoint; when set, editor tokens validate there. */
networkAuthUrl:
networkAuthUrl:
@@ -31,6 +31,8 @@ import {
@@ -31,6 +31,8 @@ import {
import type { MessageHandler, System } from './systems/system.js'
import type { MessageHandler, System } from './systems/system.js'
import type { ServerMetrics } from '../observability/metrics.js'
import type { ServerMetrics } from '../observability/metrics.js'
import type { GameWorld } from './gameWorld.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 }
export type { GameConnection, GameIntegrations }
@@ -47,6 +49,10 @@ export class GameServer {
@@ -47,6 +49,10 @@ export class GameServer {
/** The systems in construction order: the handler table and the join/leave hooks both use it. */
/** The systems in construction order: the handler table and the join/leave hooks both use it. */
private readonly ordered: System[]
private readonly ordered: System[]
private readonly handlers: Map<string, MessageHandler<never>>
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(
constructor(
config: ServerConfig,
config: ServerConfig,
@@ -56,6 +62,12 @@ export class GameServer {
@@ -56,6 +62,12 @@ export class GameServer {
log: Logger,
log: Logger,
integrations: GameIntegrations = {},
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
// The world clock and weather survive restarts (a rainy dusk stays a rainy dusk); they are
// loaded asynchronously in load(), before the tick loop starts.
// loaded asynchronously in load(), before the tick loop starts.
this.ctx = new ServerContext(
this.ctx = new ServerContext(
@@ -135,6 +147,8 @@ export class GameServer {
@@ -135,6 +147,8 @@ export class GameServer {
ctx.lifecycle = {
ctx.lifecycle = {
joined: (session) => {
joined: (session) => {
for (const system of this.ordered) system.onJoin?.(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) => {
left: (session) => {
for (const system of this.ordered) system.onLeave?.(session)
for (const system of this.ordered) system.onLeave?.(session)
@@ -259,6 +273,22 @@ export class GameServer {
@@ -259,6 +273,22 @@ export class GameServer {
// 1. Movement from queued inputs (server-simulated, never client positions).
// 1. Movement from queued inputs (server-simulated, never client positions).
s.movement.tick(ctx, dt)
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.
// 2. Physgun: retry unlatched beams (sweep-to-grab), drive held bodies.
s.manipulation.tick(ctx, dt)
s.manipulation.tick(ctx, dt)
@@ -123,7 +123,7 @@ export class GameWorld {
@@ -123,7 +123,7 @@ export class GameWorld {
// Heightfield terrain — the SAME grid the client builds for prediction
// Heightfield terrain — the SAME grid the client builds for prediction
// and rendering (see @openvibe/content buildTerrainGrid).
// and rendering (see @openvibe/content buildTerrainGrid).
this.terrainBody = this.buildTerrainBody()
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
// 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.
// and an endless fall — the island's edge is the end of the world.
const b = world.groundHalfExtent + 0.5
const b = world.groundHalfExtent + 0.5
@@ -159,7 +159,7 @@ export class GameWorld {
@@ -159,7 +159,7 @@ export class GameWorld {
/** Live map save: bring map-authored static collision up to date. */
/** Live map save: bring map-authored static collision up to date. */
reconcileMapStatics(): void {
reconcileMapStatics(): void {
- this.mapStatics.reconcile(getMapOverride()?.statics ?? [])
+ this.mapStatics.reconcile(getMapOverride(this.content.world)?.statics ?? [])
}
}
private terrainBody: BodyId | null = null
private terrainBody: BodyId | null = null
@@ -168,7 +168,7 @@ export class GameWorld {
@@ -168,7 +168,7 @@ export class GameWorld {
/** Trimesh bodies for the map's extra terrain patches (world-space verts). */
/** Trimesh bodies for the map's extra terrain patches (world-space verts). */
private buildTerrainBody(): BodyId | null {
private buildTerrainBody(): BodyId | null {
- if (getMapOverride()) return null
+ if (getMapOverride(this.content.world)) return null
const grid = buildTerrainGrid(this.content.world)
const grid = buildTerrainGrid(this.content.world)
return this.physics.addBody({
return this.physics.addBody({
shape: { type: 'trimesh', positions: grid.positions, indices: grid.indices },
shape: { type: 'trimesh', positions: grid.positions, indices: grid.indices },
@@ -191,14 +191,14 @@ export class GameWorld {
@@ -191,14 +191,14 @@ export class GameWorld {
reconcileMapTerrain(): void {
reconcileMapTerrain(): void {
// The base world's procedural collider exists only when there is no map;
// The base world's procedural collider exists only when there is no map;
// whether one is loaded can change between saves.
// whether one is loaded can change between saves.
- const wantBase = getMapOverride() === null
+ const wantBase = getMapOverride(this.content.world) === null
if (!wantBase && this.terrainBody !== null) {
if (!wantBase && this.terrainBody !== null) {
this.physics.removeBody(this.terrainBody)
this.physics.removeBody(this.terrainBody)
this.terrainBody = null
this.terrainBody = null
} else if (wantBase && this.terrainBody === null) {
} else if (wantBase && this.terrainBody === null) {
this.terrainBody = this.buildTerrainBody()
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. */
/** Live-apply diagnostics and tests: what the map layers currently hold. */
@@ -646,7 +646,7 @@ export class GameWorld {
@@ -646,7 +646,7 @@ export class GameWorld {
private seedProps(_store: PersistenceStore): void {
private seedProps(_store: PersistenceStore): void {
const world = this.content.world
const world = this.content.world
- for (const prop of [...world.initialProps, ...(getMapOverride()?.props ?? [])]) {
+ for (const prop of [...world.initialProps, ...(getMapOverride(world)?.props ?? [])]) {
this.spawnProp({
this.spawnProp({
defId: prop.item,
defId: prop.item,
pos: vec3(
pos: vec3(
@@ -683,7 +683,7 @@ export class GameWorld {
@@ -683,7 +683,7 @@ export class GameWorld {
reconcileMapNodes(): void {
reconcileMapNodes(): void {
const world = this.content.world
const world = this.content.world
const plan = planMapProvenance(
const plan = planMapProvenance(
- getMapOverride()?.nodes ?? [],
+ getMapOverride(this.content.world)?.nodes ?? [],
[...this.entities.ofKind('resource')],
[...this.entities.ofKind('resource')],
{
{
spec: (n: MapNodeSpawn) => ({ id: n.id, defId: n.node, pos: n.pos }),
spec: (n: MapNodeSpawn) => ({ id: n.id, defId: n.node, pos: n.pos }),
@@ -734,7 +734,7 @@ export class GameWorld {
@@ -734,7 +734,7 @@ export class GameWorld {
* never lift one the world def declared.
* never lift one the world def declared.
*/
*/
reconcileMapZones(): void {
reconcileMapZones(): void {
- this.zones.setMapZones(getMapOverride()?.zones ?? [])
+ this.zones.setMapZones(getMapOverride(this.content.world)?.zones ?? [])
}
}
/**
/**
@@ -747,7 +747,7 @@ export class GameWorld {
@@ -747,7 +747,7 @@ export class GameWorld {
*/
*/
reconcileMapProps(): void {
reconcileMapProps(): void {
const plan = planMapProvenance(
const plan = planMapProvenance(
- getMapOverride()?.props ?? [],
+ getMapOverride(this.content.world)?.props ?? [],
[...this.entities.ofKind('prop')],
[...this.entities.ofKind('prop')],
{
{
spec: (p: MapPropSpawn) => ({ id: p.id, defId: p.item, pos: p.pos }),
spec: (p: MapPropSpawn) => ({ id: p.id, defId: p.item, pos: p.pos }),
@@ -798,7 +798,7 @@ export class GameWorld {
@@ -798,7 +798,7 @@ export class GameWorld {
remaining: nodeType.amount,
remaining: nodeType.amount,
})
})
}
}
- for (const node of getMapOverride()?.nodes ?? []) {
+ for (const node of getMapOverride(world)?.nodes ?? []) {
const nodeType = this.content.nodeTypeOrThrow(node.node)
const nodeType = this.content.nodeTypeOrThrow(node.node)
const entity = this.spawnResource({
const entity = this.spawnResource({
nodeTypeId: node.node,
nodeTypeId: node.node,
@@ -121,6 +121,11 @@ export class InstanceHub {
@@ -121,6 +121,11 @@ export class InstanceHub {
this.connections.delete(conn)
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 {
step(): void {
for (const room of this.rooms.values()) room.game.step()
for (const room of this.rooms.values()) room.game.step()
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,135 @@
@@ -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()
+ }
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -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)
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,146 @@
@@ -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([])
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,200 @@
@@ -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,
+ })),
+ }))
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -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)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -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,
+ }))
+ }
+}
@@ -61,6 +61,13 @@ export type ResolvedConnectionIdentity = TicketIdentity
@@ -61,6 +61,13 @@ export type ResolvedConnectionIdentity = TicketIdentity
* game runs exactly as before without them.
* game runs exactly as before without them.
*/
*/
export interface GameIntegrations {
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. */
/** Durable lifecycle/progression events through the outbox. */
events?: GameEventRecorder
events?: GameEventRecorder
/** Installed mods (content packs), reconciled every tick. */
/** Installed mods (content packs), reconciled every tick. */
@@ -189,9 +189,10 @@ export class SessionsSystem implements System {
@@ -189,9 +189,10 @@ export class SessionsSystem implements System {
const world = ctx.world.content.world
const world = ctx.world.content.world
const playerId = existing ? asPlayerId(existing.id) : newPlayerId()
const playerId = existing ? asPlayerId(existing.id) : newPlayerId()
const mapSpawn = worldSpawn(world)
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
const inventory = existing
? Inventory.fromDto(existing.inventory, ctx.world.content)
? Inventory.fromDto(existing.inventory, ctx.world.content)
: new Inventory(INVENTORY_SIZE, HOTBAR_SIZE, ctx.world.content)
: new Inventory(INVENTORY_SIZE, HOTBAR_SIZE, ctx.world.content)
@@ -225,7 +226,7 @@ export class SessionsSystem implements System {
@@ -225,7 +226,7 @@ export class SessionsSystem implements System {
rank,
rank,
name: msg.name,
name: msg.name,
spawn,
spawn,
- yaw: existing?.yaw ?? mapSpawn.yaw,
+ yaw: existing && existing.positionInPlace !== false ? existing.yaw : mapSpawn.yaw,
inventory,
inventory,
skills,
skills,
friends,
friends,
@@ -10,6 +10,7 @@ import {
@@ -10,6 +10,7 @@ import {
SCRAPLANDIA_PACK,
SCRAPLANDIA_PACK,
setMapOverride,
setMapOverride,
} from '@openvibe/content'
} from '@openvibe/content'
+import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'
import { InstanceRegistry, openPgStore, type InstanceLease } from '@openvibe/persistence'
import { InstanceRegistry, openPgStore, type InstanceLease } from '@openvibe/persistence'
import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'
import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'
import { createConsoleLogger } from '@openvibe/shared'
import { createConsoleLogger } from '@openvibe/shared'
@@ -18,6 +19,7 @@ import { openDb } from './db.js'
@@ -18,6 +19,7 @@ import { openDb } from './db.js'
import { GameServer } from './game/gameServer.js'
import { GameServer } from './game/gameServer.js'
import { GameWorld } from './game/gameWorld.js'
import { GameWorld } from './game/gameWorld.js'
import { InstanceHub } from './game/instanceHub.js'
import { InstanceHub } from './game/instanceHub.js'
+import { PlaceRouter } from './game/placeRouter.js'
import { attachEditorWs } from './net/editorWs.js'
import { attachEditorWs } from './net/editorWs.js'
import {
import {
closeSockets,
closeSockets,
@@ -63,6 +65,9 @@ import {
@@ -63,6 +65,9 @@ import {
} from './platform/revocationEvents.js'
} from './platform/revocationEvents.js'
import { createStaffAuthorizer } from './platform/staffAuth.js'
import { createStaffAuthorizer } from './platform/staffAuth.js'
import { createWsTicketStore } from './net/wsTicket.js'
import { createWsTicketStore } from './net/wsTicket.js'
+import { GameProgressWriter } from './game/progression.js'
+import type { RoundResult } from './game/salvageRush.js'
+import { handleGameRead } from './net/gameRoutes.js'
/**
/**
* Dedicated authoritative server entry point.
* Dedicated authoritative server entry point.
@@ -103,7 +108,11 @@ async function main(): Promise<void> {
@@ -103,7 +108,11 @@ async function main(): Promise<void> {
const packs = await loadDefinitionPacks(store.mods, (modId, reason) =>
const packs = await loadDefinitionPacks(store.mods, (modId, reason) =>
log.warn('mod definitions skipped', { mod: 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(
const builtInModIds = new Set(
(SCRAPLANDIA_PACK.mods ?? []).map((raw) => ScriptModSchema.parse(raw).id),
(SCRAPLANDIA_PACK.mods ?? []).map((raw) => ScriptModSchema.parse(raw).id),
)
)
@@ -120,7 +129,7 @@ async function main(): Promise<void> {
@@ -120,7 +129,7 @@ async function main(): Promise<void> {
// content.world.statics: that merge was permanent, so the map layer
// content.world.statics: that merge was permanent, so the map layer
// could never be replaced and a live save could not move or delete
// could never be replaced and a live save could not move or delete
// anything it had already added.
// anything it had already added.
- setMapOverride(compileMapFileV2(parsed.map))
+ setMapOverride(compileMapFileV2(parsed.map), content.world.id)
log.info('edited map loaded', {
log.info('edited map loaded', {
statics: parsed.map.statics.length,
statics: parsed.map.statics.length,
terrains: parsed.map.terrains.length,
terrains: parsed.map.terrains.length,
@@ -234,13 +243,18 @@ async function main(): Promise<void> {
@@ -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
// 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.
// 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 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
if (!config.scriptMods) return undefined
const { createQuickJsSandboxFactory } = await import('./mods/script/quickjs.js')
const { createQuickJsSandboxFactory } = await import('./mods/script/quickjs.js')
// Built-in pack code is reviewed with the place; installed packs use only the approved subset.
// Built-in pack code is reviewed with the place; installed packs use only the approved subset.
const grants: Record<string, string[]> = Object.fromEntries(
const grants: Record<string, string[]> = Object.fromEntries(
- content
+ roomContent
.allMods()
.allMods()
.filter((mod) => mod.granted === undefined)
.filter((mod) => mod.granted === undefined)
.map((mod) => [mod.id, mod.capabilities ?? ['players.read', 'world.emit']]),
.map((mod) => [mod.id, mod.capabilities ?? ['players.read', 'world.emit']]),
@@ -255,17 +269,17 @@ async function main(): Promise<void> {
@@ -255,17 +269,17 @@ async function main(): Promise<void> {
}
}
}
}
const host = new ScriptModHost(
const host = new ScriptModHost(
- content.allMods(),
+ roomContent.allMods(),
createQuickJsSandboxFactory(),
createQuickJsSandboxFactory(),
- scriptLog.child({ slot }),
+ scriptLog.child({ placeId, slot }),
undefined,
undefined,
{
{
- placeSeed: slot === 0 ? config.placeId : `${config.placeId}:${slot}`,
+ placeSeed: slot === 0 ? placeId : `${placeId}:${slot}`,
grants: grants as Record<string, ScriptCapability[]>,
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
return host
}
}
const scripts = await createScriptHost(0)
const scripts = await createScriptHost(0)
@@ -283,6 +297,16 @@ async function main(): Promise<void> {
@@ -283,6 +297,16 @@ async function main(): Promise<void> {
mods: modRuntime,
mods: modRuntime,
...(scripts ? { scripts } : {}),
...(scripts ? { scripts } : {}),
...(progressSummary ? { progressSummary } : {}),
...(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.
// Async boot: env, markets and the NPC population load before the tick loop starts.
@@ -300,7 +324,7 @@ async function main(): Promise<void> {
@@ -300,7 +324,7 @@ async function main(): Promise<void> {
dispose: async () => {
dispose: async () => {
await scripts?.flushWrites()
await scripts?.flushWrites()
scripts?.dispose()
scripts?.dispose()
- scriptHosts.delete(0)
+ scriptHosts.delete(`${config.placeId}:0`)
physics.dispose()
physics.dispose()
},
},
},
},
@@ -338,6 +362,16 @@ async function main(): Promise<void> {
@@ -338,6 +362,16 @@ async function main(): Promise<void> {
}),
}),
...(roomScripts ? { scripts: roomScripts } : {}),
...(roomScripts ? { scripts: roomScripts } : {}),
...(progressSummary ? { progressSummary } : {}),
...(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()
await roomGame.load()
@@ -349,14 +383,131 @@ async function main(): Promise<void> {
@@ -349,14 +383,131 @@ async function main(): Promise<void> {
dispose: async () => {
dispose: async () => {
await roomScripts?.flushWrites()
await roomScripts?.flushWrites()
roomScripts?.dispose()
roomScripts?.dispose()
- scriptHosts.delete(slot)
+ scriptHosts.delete(`${config.placeId}:${slot}`)
roomPhysics.dispose()
roomPhysics.dispose()
},
},
}
}
},
},
log.child({ system: 'instances' }),
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()
heartbeatTimer.unref()
const syncScriptView = async (view: ModView): Promise<void> => {
const syncScriptView = async (view: ModView): Promise<void> => {
@@ -366,15 +517,19 @@ async function main(): Promise<void> {
@@ -366,15 +517,19 @@ async function main(): Promise<void> {
const active = view.mod.status === 'enabled' && view.granted.has('games.script.run')
const active = view.mod.status === 'enabled' && view.granted.has('games.script.run')
for (const host of scriptHosts.values()) host.remove(script.id)
for (const host of scriptHosts.values()) host.remove(script.id)
if (!active) {
if (!active) {
- content.removeScriptMod(script.id)
+ for (const registryContent of contentByPlace.values())
+ registryContent.removeScriptMod(script.id)
continue
continue
}
}
const granted = (script.capabilities ?? (['players.read', 'world.emit'] as const)).filter(
const granted = (script.capabilities ?? (['players.read', 'world.emit'] as const)).filter(
(cap) => view.granted.has(scriptGrant(cap)),
(cap) => view.granted.has(scriptGrant(cap)),
)
)
const pinned = { ...script, granted }
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()) {
for (const host of scriptHosts.values()) {
const result = await host.add(pinned, granted)
const result = await host.add(pinned, granted)
if (!result.ok)
if (!result.ok)
@@ -384,7 +539,7 @@ async function main(): Promise<void> {
@@ -384,7 +539,7 @@ async function main(): Promise<void> {
})
})
}
}
}
}
- for (const room of hub.activeRooms)
+ for (const room of router.activeRooms)
room.game.announce(
room.game.announce(
'Script mod definitions changed. New connections must use the updated content set.',
'Script mod definitions changed. New connections must use the updated content set.',
)
)
@@ -398,12 +553,12 @@ async function main(): Promise<void> {
@@ -398,12 +553,12 @@ async function main(): Promise<void> {
closeSessions: async (subjects) => {
closeSessions: async (subjects) => {
let closed = 0
let closed = 0
for (const s of subjects)
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)
closed += room.game.revokeSubject(s, Number.MAX_SAFE_INTEGER)
// Let the closing sockets' character saves land before the rows are erased.
// Let the closing sockets' character saves land before the rows are erased.
if (closed) {
if (closed) {
await new Promise((r) => setTimeout(r, 2000))
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' }),
log: platformLog.child({ system: 'account-data' }),
@@ -416,7 +571,7 @@ async function main(): Promise<void> {
@@ -416,7 +571,7 @@ async function main(): Promise<void> {
db,
db,
secrets: config.platform.eventsSecrets,
secrets: config.platform.eventsSecrets,
onRevoked: (subject, validAfterMs) =>
onRevoked: (subject, validAfterMs) =>
- hub.activeRooms.reduce(
+ router.activeRooms.reduce(
(count, room) => count + room.game.revokeSubject(subject, validAfterMs),
(count, room) => count + room.game.revokeSubject(subject, validAfterMs),
0,
0,
),
),
@@ -467,7 +622,7 @@ async function main(): Promise<void> {
@@ -467,7 +622,7 @@ async function main(): Promise<void> {
const readiness = createReadiness({
const readiness = createReadiness({
pingDb: () => db.ready(),
pingDb: () => db.ready(),
metrics,
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
// 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> {
@@ -494,7 +649,7 @@ async function main(): Promise<void> {
// migrated and validated it, so there is nothing to re-parse here.
// migrated and validated it, so there is nothing to re-parse here.
(next, revision) => {
(next, revision) => {
try {
try {
- setMapOverride(compileMapFileV2(next))
+ setMapOverride(compileMapFileV2(next), content.world.id)
for (const room of hub.activeRooms) {
for (const room of hub.activeRooms) {
room.world.reconcileMapTerrain()
room.world.reconcileMapTerrain()
room.world.reconcileMapStatics()
room.world.reconcileMapStatics()
@@ -533,21 +688,35 @@ async function main(): Promise<void> {
@@ -533,21 +688,35 @@ async function main(): Promise<void> {
{
{
handle: (req, res) => {
handle: (req, res) => {
if (req.method === 'GET' && req.url?.split('?')[0] === '/api/v1/content') {
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)
const staticPacks = packs.map(({ mods: _mods, ...pack }) => pack)
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
res.end(
res.end(
JSON.stringify({
JSON.stringify({
- digest: content.digest,
+ digest: selectedContent.digest,
+ game: placeId,
packs: [
packs: [
...staticPacks,
...staticPacks,
{
{
- mods: content.allMods().filter((mod) => !builtInModIds.has(mod.id)),
+ mods: selectedContent.allMods().filter((mod) => !builtInModIds.has(mod.id)),
},
},
],
],
}),
}),
)
)
return true
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 (revocations.handle(req, res)) return true
if (readiness.handle(req, res)) return true
if (readiness.handle(req, res)) return true
if (serveRelease(req, res)) return true
if (serveRelease(req, res)) return true
@@ -642,9 +811,10 @@ async function main(): Promise<void> {
@@ -642,9 +811,10 @@ async function main(): Promise<void> {
}
}
try {
try {
const view = await mods.replace(id, request, actor)
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 candidate of prepared) candidate.commit()
- for (const room of hub.activeRooms)
+ for (const room of router.activeRooms)
room.game.announce(
room.game.announce(
`Script mod ${script.id} reloaded. New connections must use the updated content set.`,
`Script mod ${script.id} reloaded. New connections must use the updated content set.`,
)
)
@@ -669,11 +839,12 @@ async function main(): Promise<void> {
@@ -669,11 +839,12 @@ async function main(): Promise<void> {
config.indexnowKey,
config.indexnowKey,
log.child({ system: 'indexnow' }),
log.child({ system: 'indexnow' }),
),
),
+ config.placeId,
)
)
const drainer = httpDrainer(http)
const drainer = httpDrainer(http)
world.reconcileMapNodes()
world.reconcileMapNodes()
world.reconcileMapProps()
world.reconcileMapProps()
- const gameWss = attachWebSocket(http, hub, {
+ const gameWss = attachWebSocket(http, router, {
tickets: ticketStore,
tickets: ticketStore,
upgradeLimits: actorLimits,
upgradeLimits: actorLimits,
clientAddress,
clientAddress,
@@ -728,7 +899,7 @@ async function main(): Promise<void> {
@@ -728,7 +899,7 @@ async function main(): Promise<void> {
if (!running) return
if (!running) return
const now = performance.now()
const now = performance.now()
while (now >= nextTick) {
while (now >= nextTick) {
- hub.step()
+ router.step()
nextTick += tickMs
nextTick += tickMs
// If we fell far behind (debugger pause, EL stall), resync instead of
// If we fell far behind (debugger pause, EL stall), resync instead of
// spiraling through hundreds of catch-up ticks.
// spiraling through hundreds of catch-up ticks.
@@ -767,14 +938,14 @@ async function main(): Promise<void> {
@@ -767,14 +938,14 @@ async function main(): Promise<void> {
clearInterval(pruneTimer)
clearInterval(pruneTimer)
editors.stop()
editors.stop()
const mirrorDone = mirror?.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([
const [players, editorPeers] = await Promise.all([
closeSockets(gameWss, SOCKETS_MS),
closeSockets(gameWss, SOCKETS_MS),
closeSockets(editors.wss, SOCKETS_MS),
closeSockets(editors.wss, SOCKETS_MS),
])
])
// Mark every session dirty and queue the final checkpoint, then let the whole write-behind queue
// 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.
// (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()) {
for (const host of scriptHosts.values()) {
await host.flushWrites()
await host.flushWrites()
host.dispose()
host.dispose()
@@ -784,6 +955,7 @@ async function main(): Promise<void> {
@@ -784,6 +955,7 @@ async function main(): Promise<void> {
await within(1000, progressSummary?.settle())
await within(1000, progressSummary?.settle())
await within(2000, outbox?.stop())
await within(2000, outbox?.stop())
await mods.flushWrites()
await mods.flushWrites()
+ await progressWriter.stop()
await store.close()
await store.close()
await valkey?.close()
await valkey?.close()
log.info('stopped', {
log.info('stopped', {
@@ -48,6 +48,7 @@ export function createGamesActorLimits(
@@ -48,6 +48,7 @@ export function createGamesActorLimits(
// WebSocket upgrades, counted per address BEFORE the ticket is consumed: a
// 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).
// 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 upgrade = limiter('games.ws.upgrade', { minute: 90, hour: 1800 })
+ const publicRead = limiter('games.public.read', { minute: 60, hour: 1200 })
const run =
const run =
(mw: ReturnType<typeof limiter>) =>
(mw: ReturnType<typeof limiter>) =>
(req: IncomingMessage, res: ServerResponse, actor: string): Promise<boolean> =>
(req: IncomingMessage, res: ServerResponse, actor: string): Promise<boolean> =>
@@ -66,6 +67,7 @@ export function createGamesActorLimits(
@@ -66,6 +67,7 @@ export function createGamesActorLimits(
modWrite: run(modWrite),
modWrite: run(modWrite),
ticket: run(ticket),
ticket: run(ticket),
upgrade: run(upgrade),
upgrade: run(upgrade),
+ publicRead: run(publicRead),
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -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' })
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -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
+}
@@ -36,7 +36,7 @@ import { indexnowHandler, type SharedIndexNow } from './indexnow.js'
@@ -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
/** 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). */
* per request (a new build changes the mtime and rebuilds it once). */
let portalMemo: { path: string; mtimeMs: number; html: string } | null = null
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
const mtimeMs = statSync(filePath).mtimeMs
if (portalMemo && portalMemo.path === filePath && portalMemo.mtimeMs === mtimeMs)
if (portalMemo && portalMemo.path === filePath && portalMemo.mtimeMs === mtimeMs)
return portalMemo.html
return portalMemo.html
@@ -167,6 +167,7 @@ export function createHttpServer(
@@ -167,6 +167,7 @@ export function createHttpServer(
platform?: HttpPlatformHooks,
platform?: HttpPlatformHooks,
tickets?: WsTicketStore,
tickets?: WsTicketStore,
indexnow?: SharedIndexNow,
indexnow?: SharedIndexNow,
+ defaultPlaceId = 'scraplandia',
): Server {
): Server {
const root = staticDir ? resolve(staticDir) : null
const root = staticDir ? resolve(staticDir) : null
// Sessions the Network confirmed recently: lets a silent login on a signed-in
// Sessions the Network confirmed recently: lets a silent login on a signed-in
@@ -229,6 +230,12 @@ export function createHttpServer(
@@ -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
// 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.
// static fallback so they never resolve to a file or the landing page.
if (serveLegal(req, res)) return
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) {
if (url === '/map.json' && mapPath) {
// The canonical v2 document, with its revision as an ETag. This IS the
// The canonical v2 document, with its revision as an ETag. This IS the
// wire format.
// wire format.
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -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 })
+ }
+ })
+})
@@ -64,6 +64,7 @@ function openLimits() {
@@ -64,6 +64,7 @@ function openLimits() {
asset: () => Promise.resolve(true),
asset: () => Promise.resolve(true),
modWrite: () => Promise.resolve(true),
modWrite: () => Promise.resolve(true),
ticket: () => Promise.resolve(true),
ticket: () => Promise.resolve(true),
+ publicRead: () => Promise.resolve(true),
upgrade: (req: unknown, _res: unknown, actor: string) =>
upgrade: (req: unknown, _res: unknown, actor: string) =>
new Promise<boolean>((resolve) => {
new Promise<boolean>((resolve) => {
;(req as { ovActor?: string }).ovActor = actor
;(req as { ovActor?: string }).ovActor = actor
@@ -68,6 +68,15 @@ export function createAccountData(opts: {
@@ -68,6 +68,15 @@ export function createAccountData(opts: {
const files = [
const files = [
{ name: 'characters.json', content: data.characters },
{ name: 'characters.json', content: data.characters },
...(data.structures.length ? [{ name: 'structures.json', content: data.structures }] : []),
...(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 res = await opts.send(`/internal/account-exports/${id}/parts`, { subject, files })
const outcome = res.ok
const outcome = res.ok
@@ -12,9 +12,8 @@
@@ -12,9 +12,8 @@
* (common.moderation-action@1, ADR-022), for Network's moderation
* (common.moderation-action@1, ADR-022), for Network's moderation
* audit log
* 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
* Every event is written to Games' own `event_outbox` table inside the SAME
* SQLite transaction as the state it describes (progression events are
* SQLite transaction as the state it describes (progression events are
@@ -55,7 +55,8 @@ no mobile path, and pins that lag the platform.
@@ -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.
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
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.*`
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).
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
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.
rejects a manifest Games accepts, or the other way round.
@@ -124,7 +125,20 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegac
@@ -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.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.
- **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.
- **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.
- **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.
Each milestone is playable end to end and keeps the slice harness green.
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -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.
@@ -10,4 +10,4 @@ The Players tab lists room buttons when multiple rooms are enabled. Transfer is
@@ -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.
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.
@@ -4,7 +4,8 @@
@@ -4,7 +4,8 @@
"private": true,
"private": true,
"type": "module",
"type": "module",
"exports": {
"exports": {
- ".": "./src/index.ts"
+ ".": "./src/index.ts",
+ "./salvage-rush": "./src/packs/salvageRush.ts"
},
},
"scripts": {
"scripts": {
"build": "tsc --build"
"build": "tsc --build"
@@ -42,7 +42,10 @@ import { SKILLS } from './defs/skills.js'
@@ -42,7 +42,10 @@ import { SKILLS } from './defs/skills.js'
* a bad pack kills the boot instead of corrupting a world. The registry is
* 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.
* 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>(
const { defs, errors } = mergePackDefs<ContentDefs>(
{
{
items: ITEMS,
items: ITEMS,
@@ -67,7 +70,7 @@ export function createContent(packs: ContentPackV2[] = []): ContentRegistry {
@@ -67,7 +70,7 @@ export function createContent(packs: ContentPackV2[] = []): ContentRegistry {
zones: [],
zones: [],
},
},
},
},
- [SCRAPLANDIA_PACK, ...packs],
+ [builtIn, ...packs],
)
)
if (errors.length > 0) throw new Error(`content pack: ${errors.join('; ')}`)
if (errors.length > 0) throw new Error(`content pack: ${errors.join('; ')}`)
return new ContentRegistry(defs)
return new ContentRegistry(defs)
@@ -40,6 +40,26 @@ export interface ContentPackV2 {
@@ -40,6 +40,26 @@ export interface ContentPackV2 {
defs?: { [K in PackDefKind]?: unknown[] }
defs?: { [K in PackDefKind]?: unknown[] }
/** One authored map; the built-in Scraplandia pack supplies the default. */
/** One authored map; the built-in Scraplandia pack supplies the default. */
map?: WorldDef
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 }[]
announcements?: { text: string; everySeconds: number }[]
props?: { key: string; item: string; pos: [number, number, number]; yaw?: 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. */
/** `games-quickjs@1` server script mods (schema/scriptMod.ts); they are part of the def set. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -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 },
+ },
+ ],
+ },
+}
@@ -7,6 +7,14 @@ import type { ContentPackV2 } from '../pack.js'
@@ -7,6 +7,14 @@ import type { ContentPackV2 } from '../pack.js'
* SCRAP_CITY definition.
* SCRAP_CITY definition.
*/
*/
export const SCRAPLANDIA_PACK: ContentPackV2 = {
export const SCRAPLANDIA_PACK: ContentPackV2 = {
+ achievements: [
+ {
+ id: 'first-visit',
+ name: 'First Visit',
+ description: 'Join Scraplandia.',
+ criterion: { event: 'player_joined' },
+ },
+ ],
map: {
map: {
id: 'openvibeville_v2',
id: 'openvibeville_v2',
flatTerrain: true,
flatTerrain: true,
@@ -183,13 +183,21 @@ export interface MapOverride {
@@ -183,13 +183,21 @@ export interface MapOverride {
}
}
let mapOverride: MapOverride | null = null
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
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(
function bilinearGrid(
@@ -250,7 +258,8 @@ function sampleOverride(map: MapOverride, x: number, z: number): number | null {
@@ -250,7 +258,8 @@ function sampleOverride(map: MapOverride, x: number, z: number): number | null {
* own default instead of the sampler inventing ground.
* own default instead of the sampler inventing ground.
*/
*/
export function terrainHeightAt(world: WorldDef, x: number, z: number): number | null {
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
if (world.flatTerrain) return 0
return terrainHeight(world, x, z)
return terrainHeight(world, x, z)
}
}
@@ -262,7 +271,8 @@ export function terrainHeightAt(world: WorldDef, x: number, z: number): number |
@@ -262,7 +271,8 @@ export function terrainHeightAt(world: WorldDef, x: number, z: number): number |
* `terrainHeightAt` when "there is no ground here" is meaningful.
* `terrainHeightAt` when "there is no ground here" is meaningful.
*/
*/
export function terrainHeight(world: WorldDef, x: number, z: number): number {
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
if (world.flatTerrain) return 0
let mask = 1
let mask = 1
for (const pad of padsFor(world)) {
for (const pad of padsFor(world)) {
@@ -289,6 +299,7 @@ export function defaultHeights(world: WorldDef, sub: number): Float32Array {
@@ -289,6 +299,7 @@ export function defaultHeights(world: WorldDef, sub: number): Float32Array {
const heights = new Float32Array((sub + 1) * (sub + 1))
const heights = new Float32Array((sub + 1) * (sub + 1))
const saved = mapOverride
const saved = mapOverride
mapOverride = null
mapOverride = null
+ suppressMapOverride = true
for (let j = 0; j <= sub; j++) {
for (let j = 0; j <= sub; j++) {
for (let i = 0; i <= sub; i++) {
for (let i = 0; i <= sub; i++) {
heights[j * (sub + 1) + i] = terrainHeight(
heights[j * (sub + 1) + i] = terrainHeight(
@@ -299,6 +310,7 @@ export function defaultHeights(world: WorldDef, sub: number): Float32Array {
@@ -299,6 +310,7 @@ export function defaultHeights(world: WorldDef, sub: number): Float32Array {
}
}
}
}
mapOverride = saved
mapOverride = saved
+ suppressMapOverride = false
return heights
return heights
}
}
@@ -387,7 +399,7 @@ export function worldSpawn(world: WorldDef): {
@@ -387,7 +399,7 @@ export function worldSpawn(world: WorldDef): {
pos: [number, number, number]
pos: [number, number, number]
yaw: number
yaw: number
} {
} {
- const o = mapOverride
+ const o = getMapOverride(world)
return {
return {
pos: o?.spawn ?? [world.spawnPoint[0], world.spawnPoint[1], world.spawnPoint[2]],
pos: o?.spawn ?? [world.spawnPoint[0], world.spawnPoint[1], world.spawnPoint[2]],
yaw: o?.spawnYaw ?? world.spawnYaw,
yaw: o?.spawnYaw ?? world.spawnYaw,
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -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);
@@ -39,6 +39,8 @@ export interface PlayerDto {
@@ -39,6 +39,8 @@ export interface PlayerDto {
subjectId?: string
subjectId?: string
name: string
name: string
pos: [number, number, number]
pos: [number, number, number]
+ /** Join hint: this room or place has a saved position for the character. */
+ positionInPlace?: boolean
yaw: number
yaw: number
inventory: InventoryDto
inventory: InventoryDto
/** Total XP per skill id (levels are derived at runtime). */
/** Total XP per skill id (levels are derived at runtime). */
@@ -49,6 +49,7 @@ interface WorldEntityRow {
@@ -49,6 +49,7 @@ interface WorldEntityRow {
interface CharacterRow {
interface CharacterRow {
id: string
id: string
+ place_id: string | null
account_key: string
account_key: string
slot: number
slot: number
subject_id: string | null
subject_id: string | null
@@ -268,6 +269,7 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
@@ -268,6 +269,7 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
)
)
if (!row) return null
if (!row) return null
const player = rowToPlayer(row)
const player = rowToPlayer(row)
+ player.positionInPlace = row.place_id === placeId
if (opts.instanceSlot !== undefined) {
if (opts.instanceSlot !== undefined) {
const position = await db.maybe<{
const position = await db.maybe<{
pos_x: number
pos_x: number
@@ -281,6 +283,7 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
@@ -281,6 +283,7 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
if (position) {
if (position) {
player.pos = [position.pos_x, position.pos_y, position.pos_z]
player.pos = [position.pos_x, position.pos_y, position.pos_z]
player.yaw = position.yaw
player.yaw = position.yaw
+ player.positionInPlace = true
}
}
}
}
return player
return player
@@ -305,11 +308,14 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
@@ -305,11 +308,14 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
})
})
},
},
async resetAllPositions(pos: [number, number, number], yaw: number): Promise<void> {
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}`,
+ )
+ })
},
},
}
}
@@ -44,6 +44,33 @@ export function createIdentityRepository(db: Db): IdentityRepository {
@@ -44,6 +44,33 @@ export function createIdentityRepository(db: Db): IdentityRepository {
db.exec(
db.exec(
sql`UPDATE characters SET account_key = ${account}, subject_id = ${subject}, slot = ${slot} WHERE id = ${id}`,
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 = (
const recordAdoption = (
key: string,
key: string,
subject: string,
subject: string,
@@ -81,6 +108,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
@@ -81,6 +108,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
moved++
moved++
}
}
if (moved === 0) return { moved: 0, slot: null, full: true }
if (moved === 0) return { moved: 0, slot: null, full: true }
+ await moveProgress(key, subjectId)
await recordAdoption(key, subjectId, 'guest', moved, rows.length - moved, now)
await recordAdoption(key, subjectId, 'guest', moved, rows.length - moved, now)
return { moved, slot, full: false }
return { moved, slot, full: false }
})
})
@@ -115,6 +143,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
@@ -115,6 +143,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
taken.add(slot)
taken.add(slot)
moved++
moved++
}
}
+ await moveProgress(from, into)
await recordAdoption(key, into, 'merge', moved, kept, now)
await recordAdoption(key, into, 'merge', moved, kept, now)
return { moved, kept, already: false }
return { moved, kept, already: false }
})
})
@@ -130,9 +159,21 @@ export function createIdentityRepository(db: Db): IdentityRepository {
@@ -130,9 +159,21 @@ export function createIdentityRepository(db: Db): IdentityRepository {
sql`SELECT * FROM world_entities WHERE owner_id IN (${idList(ids)})`,
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 {
return {
characters: characters.map(withoutAccountKey),
characters: characters.map(withoutAccountKey),
structures,
structures,
+ gameResults,
+ gameBestScores,
+ gameAchievements,
}
}
},
},
@@ -154,6 +195,12 @@ export function createIdentityRepository(db: Db): IdentityRepository {
@@ -154,6 +195,12 @@ export function createIdentityRepository(db: Db): IdentityRepository {
out.identity_rows = await db.exec(
out.identity_rows = await db.exec(
sql`DELETE FROM identity_adoptions WHERE subject_id IN (${idList(subjects)})`,
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
return out
})
})
},
},
@@ -84,6 +84,9 @@ export interface IdentityRepository {
@@ -84,6 +84,9 @@ export interface IdentityRepository {
exportSubject(subject: string): Promise<{
exportSubject(subject: string): Promise<{
characters: Record<string, unknown>[]
characters: Record<string, unknown>[]
structures: 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
* Erase these subjects (a deleted account and the accounts merged into it; ADR-033), in one transaction: their
@@ -13,6 +13,9 @@ export { MIGRATIONS_DIR }
@@ -13,6 +13,9 @@ export { MIGRATIONS_DIR }
const TABLES = [
const TABLES = [
'places',
'places',
+ 'game_round_results',
+ 'game_best_scores',
+ 'game_achievements',
'game_instances',
'game_instances',
'character_instance_positions',
'character_instance_positions',
'world_meta',
'world_meta',
@@ -16,6 +16,8 @@ export const ClientHelloSchema = z.object({
@@ -16,6 +16,8 @@ export const ClientHelloSchema = z.object({
.string()
.string()
.regex(/^v[0-9]+-[0-9a-f]{8}$/)
.regex(/^v[0-9]+-[0-9a-f]{8}$/)
.optional(),
.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). */
/** Character slot under this account (MMO-style, 3 max). */
slot: z.number().int().min(0).max(2).default(0),
slot: z.number().int().min(0).max(2).default(0),
name: z.string().min(1).max(24),
name: z.string().min(1).max(24),
@@ -271,6 +271,14 @@ export interface ServerContainer {
@@ -271,6 +271,14 @@ export interface ServerContainer {
}
}
export type ServerMessage =
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
| ServerWelcome
| ServerTransferResult
| ServerTransferResult
| ServerReject
| ServerReject
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -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.