openvibers/openvibe.games Commits
Surf mode: three maps, server-timed runs, ghosts, Play time boards, /surf/embed for OpenVibe.Surf (#191)
Changes
@@@ -867,7 +867,7 @@ section('O. Live static reconciliation reaches the running server')/** Wait for the tick loop to publish, then read the live map counts. */const counts = async (): Promise<Record<string, number>> => {for (let i = 0; i < 40; i++) {-- const m = (await (await fetch(`${root}/metrics`)).json()) as Record<string, number>++ const m = (await (await fetch(`${root}/metrics?place=scraplandia`)).json()) as Record<string, number>if (m['tick']! > 0) return mawait new Promise((r) => setTimeout(r, 100))}@@@ -1126,7 +1126,7 @@ section('U. Live reconciliation touches only what changed')const root = `http://127.0.0.1:${port}`const metrics = async (): Promise<Record<string, number>> => {for (let i = 0; i < 40; i++) {-- const m = (await (await fetch(`${root}/metrics`)).json()) as Record<string, number>++ const m = (await (await fetch(`${root}/metrics?place=scraplandia`)).json()) as Record<string, number>if (m['tick']! > 0) return mawait new Promise((r) => setTimeout(r, 100))}@@@ -62,7 +62,15 @@<link rel="stylesheet" href="/src/ui/styles.css" /><script>window.__ovgFrame = { page: 'play', title: 'Scraplandia', collapsible: true }++ // The iframe route (/surf/embed) is the canvas and HUD only: an++ // embedding site owns the page around it.++ if (new URLSearchParams(location.search).get('embed') === '1')++ document.documentElement.classList.add('ov-embed')</script>++ <style>++ .ov-embed #navbar-mount, .ov-embed #ov-footer, .ov-embed .ovf, .ov-embed .ov-nav { display: none !important; }++ .ov-embed, .ov-embed body, .ov-embed #stage { margin: 0; height: 100%; overflow: hidden; background: #070b12; }++ </style><script src="/frame.js" defer></script></head><body>@@@ -9,6 +9,7 @@ import {stepMovement,type CollisionQueries,type MoveInput,++ type MovementParams,type PlayerMoveState,} from '@openvibe/gameplay'import { CollisionLayer, type PhysicsWorld } from '@openvibe/physics'@@@ -33,8 +34,6 @@ const TOP_DOWN_ABOVE = 120* already are and nothing visibly changes.*/--const MOVE = DEFAULT_MOVEMENT--export class LocalPlayer {readonly camera: UniversalCamerareadonly move: PlayerMoveState@@@ -47,7 +46,7 @@ export class LocalPlayer {/** Interpolated render position (capsule center), updated each frame. */readonly renderPos = new Vector3()/** Smoothed eye height so stance changes glide instead of popping. */-- private eyeSmooth = DEFAULT_MOVEMENT.eyeOffset++ private eyeSmooth: number/** Reconciliation error, blended away over ~100ms instead of snapping —* this is what makes standing on moving props watchable. */private readonly corr = new Vector3()@@@ -75,11 +74,14 @@ export class LocalPlayer {private readonly connection: Connection,private readonly state: ClientState,spawn: { x: number; y: number; z: number },++ /** The place's movement preset, resolved on both sides (surf vs default). */++ private readonly params: MovementParams = DEFAULT_MOVEMENT,) {this.move = createMoveState(spawn)++ this.eyeSmooth = params.eyeOffsetthis.camera = new UniversalCamera('cam',-- new Vector3(spawn.x, spawn.y + MOVE.eyeOffset, spawn.z),++ new Vector3(spawn.x, spawn.y + params.eyeOffset, spawn.z),scene,)this.camera.minZ = 0.05@@@ -147,7 +149,7 @@ export class LocalPlayer {pitch: dequantiseAngle(cmd.pitchQ),buttons: cmd.intents,}-- stepMovement(this.move, input, MOVE, this.queries, 1 / this.state.tickRate)++ stepMovement(this.move, input, this.params, this.queries, 1 / this.state.tickRate)}/** Reconcile against an authoritative snapshot. */nnew file mode 100644@@@ -0,0 +1,77 @@++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 type { DecodedGhost } from '@openvibe/gameplay'++++/**++ * A translucent capsule replaying an authoritative ghost track. The track is++ * a recording of server positions/yaw (1 cm, 1/256 turn), so playback is an++ * interpolation, never a re-simulation: no collision and no drift.++ */++export class SurfGhost {++ private readonly mesh: Mesh++ private track: DecodedGhost | null = null++ private visible = true++++ constructor(scene: Scene) {++ this.mesh = MeshBuilder.CreateCapsule(++ 'surf-ghost',++ { height: 1.8, radius: 0.4, tessellation: 12 },++ scene,++ )++ const material = new StandardMaterial('surf-ghost-material', scene)++ material.diffuseColor = new Color3(0.45, 0.85, 1)++ material.emissiveColor = new Color3(0.15, 0.45, 0.7)++ material.alpha = 0.35++ this.mesh.material = material++ this.mesh.isPickable = false++ this.mesh.setEnabled(false)++ }++++ get hasTrack(): boolean {++ return this.track !== null++ }++++ setTrack(track: DecodedGhost): void {++ this.track = track++ this.mesh.setEnabled(this.visible)++ }++++ clear(): void {++ this.track = null++ this.mesh.setEnabled(false)++ }++++ setVisible(visible: boolean): void {++ this.visible = visible++ this.mesh.setEnabled(visible && this.track !== null)++ }++++ toggle(): boolean {++ this.setVisible(!this.visible)++ return this.visible++ }++++ /** Place the capsule at `elapsedMs` into the recorded run. */++ update(elapsedMs: number): void {++ const track = this.track++ if (!track || !this.visible || track.samples.length === 0) return++ const pos = (elapsedMs / 1000) * track.tickRate++ const i = Math.min(Math.max(Math.floor(pos), 0), track.samples.length - 1)++ const j = Math.min(i + 1, track.samples.length - 1)++ const f = Math.min(Math.max(pos - i, 0), 1)++ const a = track.samples[i]!++ const b = track.samples[j]!++ this.mesh.position.set(++ a.x + (b.x - a.x) * f,++ a.y + (b.y - a.y) * f,++ a.z + (b.z - a.z) * f,++ )++ let dy = b.yaw - a.yaw++ if (dy > Math.PI) dy -= Math.PI * 2++ if (dy < -Math.PI) dy += Math.PI * 2++ this.mesh.rotation.y = a.yaw + dy * f++ }++}@@@ -9,7 +9,12 @@ import {emptyMapV2,type MapFileV2,parseMapFile,++ surfMap,++ surfMapOfPlace,++ surfPack,++ worldSpawn,type MapTextureEntry,++ type SurfMapInfo,} from '@openvibe/content'import { MapLightLayer, mapDiff, staticWork, terrainWork } from './render/mapRuntimeLayers.js'import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'@@@ -19,7 +24,12 @@ import type { Appearance } from '@openvibe/protocol'import { Color3 } from '@babylonjs/core/Maths/math.color.js'import { Scene } from '@babylonjs/core/scene.js'import { WATER_LEVEL, getMapOverride, terrainHeight } from '@openvibe/content'--import { DEFAULT_MOVEMENT } from '@openvibe/gameplay'++import {++ DEFAULT_MOVEMENT,++ decodeSurfTrack,++ movementParamsFor,++ movementStyleFor,++} from '@openvibe/gameplay'import { Sfx, footstepSurface, materialForVisual } from './audio/sfx.js'import { HitChips } from './render/hitChips.js'import { InteractionController } from './game/interactionController.js'@@@ -82,11 +92,20 @@ async function start(): Promise<void> {// A creator world (/worlds): its own place, the plain creator pack and the world's map.const requestedWorld = params.get('world')const worldId = requestedWorld && CREATOR_WORLD_ID.test(requestedWorld) ? requestedWorld : null++ // OpenVibe.Surf Ride: ?map=<id> (or ?game=surf&map=…). A map is a place++ // (`surf-lagoon`), so the existing place URL scheme carries it unchanged.++ const requestedMap = params.get('map')++ const surfInfo: SurfMapInfo | null =++ (requestedMap ? surfMap(requestedMap) : null) ??++ (requestedGame === 'surf' ? surfMap('surf_lagoon') : null) ??++ nullconst selectedPlace = worldId? creatorPlaceId(worldId)-- : requestedGame === 'salvage-rush' || requestedGame === 'scraplandia'-- ? requestedGame-- : undefined++ : surfInfo++ ? surfInfo.placeId++ : requestedGame === 'salvage-rush' || requestedGame === 'scraplandia'++ ? requestedGame++ : undefinedconst placeQuery = worldId ? `?world=${worldId}` : selectedPlace ? `?game=${selectedPlace}` : ''// A draft world's map answers only its owner, so the map read carries the Network token when there is one.const mapRequest = (): Promise<Response> => {@@@ -120,12 +139,15 @@ async function start(): Promise<void> {packs?: Parameters<typeof createContent>[0]}if (Array.isArray(data.packs)) {++ const contentSurf = data.game ? surfMapOfPlace(data.game) : nullconst builtIn =-- data.game === 'salvage-rush'-- ? (await import('@openvibe/content/salvage-rush')).SALVAGE_RUSH_PACK-- : data.game === 'creator' && worldId-- ? creatorWorldPack(worldId)-- : undefined++ contentSurf++ ? surfPack(contentSurf.id)++ : data.game === 'salvage-rush'++ ? (await import('@openvibe/content/salvage-rush')).SALVAGE_RUSH_PACK++ : data.game === 'creator' && worldId++ ? creatorWorldPack(worldId)++ : undefinedconst active = createContent(data.packs, builtIn)if (active.digest === data.digest) content = active}@@@ -137,6 +159,10 @@ async function start(): Promise<void> {uiRoot.textContent = 'Salvage Rush content is unavailable. Please retry shortly.'return}++ if (surfInfo && content.world.id !== surfInfo.id) {++ uiRoot.textContent = 'Surf 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.@@@ -312,6 +338,9 @@ async function start(): Promise<void> {({ SalvageRushHud }) => new SalvageRushHud(uiRoot, scene),): null++ const surfUi = surfInfo++ ? await import('./ui/surfHud.js').then(({ SurfHud }) => new SurfHud(uiRoot))++ : nullhud.onQualityChange = (override) => {const quality = qualityForTier(resolveQualityTier(override, readDeviceSignals()))applyEngineQuality(engine, quality)@@@ -325,6 +354,16 @@ async function start(): Promise<void> {const touchPad = new TouchPad()const touchSource = new TouchInputSource(touchPad)const touchControls = new TouchControls(uiRoot, canvas, touchPad)++ const surfTrainer = surfInfo++ ? await import('./ui/surfTrainer.js').then(++ ({ SurfTrainer }) =>++ new SurfTrainer(uiRoot, canvas, touchPad, () => {++ surfUi?.hideFinish()++ connection.send({ t: 'surf_restart' })++ }),++ )++ : null++ if (surfInfo) void import('./ui/surfTrainer.css')const switchable = new SwitchableInputSource(keyboardSource)// From above, WASD (or the stick) moves along the screen and the mouse aims; see topDownInputSource.ts.const topDown = (await worldView) === 'top'@@@ -353,7 +392,8 @@ async function start(): Promise<void> {}switchable.current = nextif (touch) pointerOffset.known = false-- touchControls.setActive(touch)++ touchControls.setActive(touch && !surfInfo)++ surfTrainer?.setActive(touch && !!surfInfo)}useTouch(matchMedia('(pointer: coarse)').matches)// Capture phase: the switch happens before the press is handled.@@@ -368,11 +408,20 @@ async function start(): Promise<void> {)const world = content.world-- const player = new LocalPlayer(scene, physics, inputSource, connection, state, {-- x: world.spawnPoint[0],-- y: world.spawnPoint[1],-- z: world.spawnPoint[2],-- })++ const placeSpawn = worldSpawn(world)++ const player = new LocalPlayer(++ scene,++ physics,++ inputSource,++ connection,++ state,++ {++ x: placeSpawn.pos[0],++ y: placeSpawn.pos[1],++ z: placeSpawn.pos[2],++ },++ movementParamsFor(movementStyleFor(content.world)),++ )scene.activeCamera = player.camerareleaseCamera()environment.attachCamera(player.camera)@@@ -411,6 +460,63 @@ async function start(): Promise<void> {const beams = new BeamRenderer(scene)const constraintLines = new ConstraintLineRenderer(scene, state, view)const ghost = new PlacementGhost(scene, physics, content)++ const surfGhost = surfInfo++ ? await import('./game/surfGhost.js').then(({ SurfGhost }) => new SurfGhost(scene))++ : null++ const surfGhostHeaders = (): Record<string, string> => {++ const headers: Record<string, string> = {}++ if (sso) headers.authorization = `Bearer ${sso}`++ else headers['x-guest-token'] = identity.token++ return headers++ }++ const loadSurfGhost = async (of: 'pb' | 'wr'): Promise<boolean> => {++ if (!surfInfo || !surfGhost) return false++ try {++ const response = await fetch(++ `/api/surf/maps/${encodeURIComponent(surfInfo.id)}/ghost?of=${of}`,++ { cache: 'no-store', headers: surfGhostHeaders() },++ )++ if (!response.ok) return false++ const bytes = new Uint8Array(await response.arrayBuffer())++ surfGhost.setTrack(decodeSurfTrack(bytes))++ return true++ } catch {++ return false++ }++ }++ if (surfUi && surfGhost) {++ surfUi.onRestart = () => {++ surfUi.hideFinish()++ connection.send({ t: 'surf_restart' })++ }++ surfUi.onWatchWr = () => {++ surfGhost.setVisible(true)++ void loadSurfGhost('wr')++ }++ // Your PB is the default ghost; the WR is the fallback before your first run.++ void loadSurfGhost('pb').then((ok) => (ok ? true : loadSurfGhost('wr')))++ }++ // Ride's keys. Capture-phase: InputTracker would otherwise read R as++ // reload and G as drop, neither of which exists in a surf place.++ document.addEventListener(++ 'keydown',++ (e) => {++ if (!surfInfo) return++ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return++ if (input.uiCapture) return++ if (e.code !== 'KeyR' && e.code !== 'KeyG') return++ e.preventDefault()++ e.stopImmediatePropagation()++ if (e.code === 'KeyR') {++ surfUi?.hideFinish()++ connection.send({ t: 'surf_restart' })++ } else {++ const on = surfGhost?.toggle() ?? false++ hud.toast(on ? 'Ghost on' : 'Ghost off', false)++ }++ },++ true,++ )interact.placementPose = () => (ghost.valid ? ghost.pose : null)interact.onPlacementRotate = (delta) => ghost.rotate(delta)@@@ -529,6 +635,12 @@ async function start(): Promise<void> {let rejected = falseconnection.onMessage = (msg) => {if (msg.t === 'salvage_round') roundUi?.update(msg)++ if (msg.t === 'surf_state') surfUi?.updateState(msg)++ if (msg.t === 'surf_finish') {++ surfUi?.showFinish(msg)++ // The just-finished run may be the new PB: refresh its ghost.++ setTimeout(() => void loadSurfGhost('pb'), 750)++ }if (msg.t === 'welcome' && state.myEntityId && msg.instanceId !== state.instanceId) {player.onTransfer()}@@@ -705,6 +817,11 @@ async function start(): Promise<void> {}physics.step(0) // query-only world: refresh broadphase, no dynamicsplayer.frameUpdate(timestep.alpha, elapsed)++ if (surfUi) {++ surfUi.setSpeed(Math.hypot(player.move.vel.x, player.move.vel.z))++ surfUi.tick(now)++ surfGhost?.update(surfUi.elapsedNow(now))++ }view.update(now / 1000)markers?.update()nnew file mode 100644@@@ -0,0 +1,118 @@++.surf-hud {++ position: fixed;++ z-index: 12;++ top: max(1rem, env(safe-area-inset-top));++ left: max(1rem, env(safe-area-inset-left));++ width: min(18rem, calc(100vw - 2rem));++ padding: 0.8rem 0.95rem 0.9rem;++ border: 1px solid var(--border, #2a3340);++ border-left: 3px solid var(--g-accent, #f0b040);++ border-radius: 0.85rem;++ background: color-mix(in srgb, var(--bg-secondary, #0a0e12) 88%, transparent);++ backdrop-filter: blur(6px);++ box-shadow: 0 12px 34px rgba(0, 0, 0, 0.45);++ color: var(--text-primary, #e6e6e6);++ font: 500 calc(0.85rem * var(--ov-text-scale, 1)) / 1.35 system-ui, sans-serif;++}++.surf-head {++ display: flex;++ align-items: baseline;++ justify-content: space-between;++ gap: 0.5rem;++}++.surf-title {++ font-size: 0.7rem;++ font-weight: 800;++ letter-spacing: 0.16em;++ color: var(--g-accent-text, #f0b040);++}++.surf-stage {++ font-weight: 800;++ font-variant-numeric: tabular-nums;++ color: var(--text-secondary, #b0b0b8);++}++.surf-timer {++ margin: 0.1rem 0 0.15rem;++ font-size: calc(2.15rem * var(--ov-text-scale, 1));++ font-weight: 900;++ line-height: 1.05;++ font-variant-numeric: tabular-nums;++ letter-spacing: 0.02em;++}++.surf-meta {++ display: flex;++ justify-content: space-between;++ gap: 0.5rem;++ font-size: 0.72rem;++ color: var(--text-secondary, #b0b0b8);++}++.surf-speed {++ font-variant-numeric: tabular-nums;++ color: var(--text-primary, #e6e6e6);++}++.surf-hint { opacity: 0.7; }++.surf-splits {++ margin: 0.55rem 0 0;++ padding: 0;++ list-style: none;++ border-top: 1px solid var(--border, #2a3340);++}++.surf-split {++ display: grid;++ grid-template-columns: 1.8rem minmax(0, 1fr) auto;++ align-items: baseline;++ gap: 0.4rem;++ padding: 0.22rem 0;++ font-variant-numeric: tabular-nums;++}++.surf-split.is-empty {++ display: block;++ color: var(--text-secondary, #b0b0b8);++ font-size: 0.75rem;++}++.surf-split-label { color: var(--text-secondary, #b0b0b8); font-size: 0.72rem; }++.surf-split-time { font-weight: 700; }++.surf-delta {++ font-size: 0.72rem;++ font-weight: 800;++ padding: 0.05rem 0.3rem;++ border-radius: 0.3rem;++}++.surf-delta.is-faster { color: #6ee7a0; background: rgba(110, 231, 160, 0.12); }++.surf-delta.is-slower { color: #fca5a5; background: rgba(252, 165, 165, 0.12); }++.surf-finish {++ margin-top: 0.6rem;++ padding-top: 0.6rem;++ border-top: 1px solid var(--border, #2a3340);++}++.surf-finish[hidden] { display: none; }++.surf-finish-title {++ margin: 0;++ font-size: 0.7rem;++ letter-spacing: 0.16em;++ color: var(--g-accent-text, #f0b040);++}++.surf-finish-time {++ margin: 0.15rem 0 0.25rem;++ font-size: calc(1.55rem * var(--ov-text-scale, 1));++ font-weight: 900;++ font-variant-numeric: tabular-nums;++}++.surf-finish.is-pb .surf-finish-pb { color: #6ee7a0; }++.surf-finish.is-wr .surf-finish-wr { color: var(--g-accent-text, #f0b040); }++.surf-finish-pb, .surf-finish-wr { margin: 0.1rem 0; font-size: 0.75rem; }++.surf-finish-actions { display: flex; gap: 0.45rem; margin-top: 0.5rem; }++.surf-finish-actions button {++ flex: 1;++ padding: 0.4rem 0.5rem;++ border: 1px solid var(--border, #2a3340);++ border-radius: 0.45rem;++ background: var(--g-action, #2a5a8a);++ color: var(--g-on-action, #fff);++ font: inherit;++ font-size: 0.75rem;++ font-weight: 700;++ cursor: pointer;++}++.surf-watch { background: transparent !important; color: var(--text-primary, #e6e6e6) !important; }++.surf-finish-actions button:disabled { opacity: 0.45; cursor: default; }nnew file mode 100644@@@ -0,0 +1,212 @@++import type { ServerMessage } from '@openvibe/protocol'++import './surfHud.css'++++type SurfState = Extract<ServerMessage, { t: 'surf_state' }>++type SurfFinish = Extract<ServerMessage, { t: 'surf_finish' }>++++/** m:ss.mmm, the surf timer's format. */++export function formatSurfTime(ms: number): string {++ const clamped = Math.max(0, Math.round(ms))++ const minutes = Math.floor(clamped / 60_000)++ const seconds = Math.floor((clamped % 60_000) / 1000)++ const millis = clamped % 1000++ return `${minutes}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`++}++++/** Engine m/s → Source units/s (1 unit = 1 inch = 0.0254 m). */++export function unitsPerSecond(mps: number): number {++ return mps / 0.0254++}++++/**++ * Ride's HUD: the authoritative timer, speed, stage and split deltas, and the++ * finish card. Markup is semantic and `surf-`-classed so the session can++ * restyle it without touching behaviour; colours are the shared theme tokens.++ */++export class SurfHud {++ private readonly root: HTMLElement++ private readonly timer: HTMLElement++ private readonly speed: HTMLElement++ private readonly stage: HTMLElement++ private readonly splits: HTMLElement++ private readonly finishCard: HTMLElement++ private readonly finishTime: HTMLElement++ private readonly finishPb: HTMLElement++ private readonly finishWr: HTMLElement++ private readonly watchButton: HTMLButtonElement++ private state: SurfState | null = null++ private baseElapsed = 0++ private baseAt = performance.now()++ private deltaUntil = new Map<number, number>()++ private lastSplitCount = 0++++ onRestart: (() => void) | null = null++ onWatchWr: (() => void) | null = null++++ constructor(parent: HTMLElement) {++ this.root = document.createElement('aside')++ this.root.className = 'surf-hud'++ this.root.setAttribute('aria-label', 'Surf run')++++ const head = document.createElement('div')++ head.className = 'surf-head'++ const title = document.createElement('strong')++ title.className = 'surf-title'++ title.textContent = 'RIDE'++ this.stage = document.createElement('span')++ this.stage.className = 'surf-stage'++ this.stage.textContent = '1/3'++ head.append(title, this.stage)++++ this.timer = document.createElement('p')++ this.timer.className = 'surf-timer'++ this.timer.setAttribute('aria-live', 'off')++ this.timer.textContent = '0:00.000'++++ const meta = document.createElement('div')++ meta.className = 'surf-meta'++ this.speed = document.createElement('span')++ this.speed.className = 'surf-speed'++ this.speed.textContent = '0 u/s'++ const hint = document.createElement('span')++ hint.className = 'surf-hint'++ hint.textContent = 'R restart · G ghost'++ meta.append(this.speed, hint)++++ this.splits = document.createElement('ol')++ this.splits.className = 'surf-splits'++ this.splits.setAttribute('aria-label', 'Splits')++++ this.finishCard = document.createElement('section')++ this.finishCard.className = 'surf-finish'++ this.finishCard.hidden = true++ const finishTitle = document.createElement('h2')++ finishTitle.className = 'surf-finish-title'++ finishTitle.textContent = 'RUN COMPLETE'++ this.finishTime = document.createElement('p')++ this.finishTime.className = 'surf-finish-time'++ this.finishTime.setAttribute('aria-live', 'polite')++ this.finishPb = document.createElement('p')++ this.finishPb.className = 'surf-finish-pb'++ this.finishWr = document.createElement('p')++ this.finishWr.className = 'surf-finish-wr'++ const actions = document.createElement('div')++ actions.className = 'surf-finish-actions'++ const again = document.createElement('button')++ again.type = 'button'++ again.className = 'surf-again'++ again.textContent = 'Again (R)'++ again.addEventListener('click', () => this.onRestart?.())++ this.watchButton = document.createElement('button')++ this.watchButton.type = 'button'++ this.watchButton.className = 'surf-watch'++ this.watchButton.textContent = 'Watch the WR'++ this.watchButton.addEventListener('click', () => this.onWatchWr?.())++ actions.append(again, this.watchButton)++ this.finishCard.append(finishTitle, this.finishTime, this.finishPb, this.finishWr, actions)++++ this.root.append(head, this.timer, meta, this.splits, this.finishCard)++ parent.append(this.root)++ }++++ updateState(state: SurfState): void {++ this.state = state++ this.baseElapsed = state.elapsedMs++ this.baseAt = performance.now()++ this.stage.textContent = `${Math.min(state.stage + 1, state.stages)}/${state.stages}`++ this.root.classList.toggle('is-running', state.running)++ // A fresh run (idle, zero time) puts the card away even when the reset++ // came from re-entering the start zone rather than the button.++ if (!state.running && state.elapsedMs === 0) this.hideFinish()++ this.renderSplits(state)++ }++++ /** Speed in engine m/s, read from prediction; shown in Source units/s. */++ setSpeed(mps: number): void {++ this.speed.textContent = `${Math.round(unitsPerSecond(mps))} u/s`++ }++++ showFinish(finish: SurfFinish): void {++ this.finishCard.hidden = false++ this.finishTime.textContent = formatSurfTime(finish.timeMs)++ this.finishPb.textContent = finish.pb++ ? 'NEW PERSONAL BEST'++ : finish.pbMs === null++ ? 'First run — set a PB'++ : `PB ${formatSurfTime(finish.pbMs)}`++ const rank = finish.rank ? ` · rank #${finish.rank}` : ''++ this.finishWr.textContent = finish.wr++ ? `WORLD RECORD${rank}`++ : finish.wrMs === null++ ? 'No WR yet'++ : `WR ${formatSurfTime(finish.wrMs)}${rank}`++ this.finishCard.classList.toggle('is-pb', finish.pb)++ this.finishCard.classList.toggle('is-wr', finish.wr)++ this.watchButton.disabled = finish.wrMs === null++ }++++ hideFinish(): void {++ this.finishCard.hidden = true++ }++++ /** The currently displayed elapsed time (server base + local interpolation). */++ elapsedNow(now = performance.now()): number {++ const state = this.state++ if (!state) return 0++ return state.running ? this.baseElapsed + Math.max(0, now - this.baseAt) : state.elapsedMs++ }++++ /** Per-frame: interpolate the server clock and expire split deltas. */++ tick(now = performance.now()): void {++ const state = this.state++ if (!state) return++ this.timer.textContent = formatSurfTime(this.elapsedNow(now))++ let changed = false++ for (const [stage, until] of this.deltaUntil)++ if (now >= until) {++ this.deltaUntil.delete(stage)++ changed = true++ }++ if (changed) this.renderSplits(state)++ }++++ private renderSplits(state: SurfState): void {++ if (state.splits.length > this.lastSplitCount) {++ const now = performance.now()++ for (let i = this.lastSplitCount; i < state.splits.length; i++)++ this.deltaUntil.set(i, now + 3000)++ this.lastSplitCount = state.splits.length++ } else if (state.splits.length < this.lastSplitCount) {++ this.lastSplitCount = state.splits.length++ this.deltaUntil.clear()++ }++ if (state.splits.length === 0) {++ const empty = document.createElement('li')++ empty.className = 'surf-split is-empty'++ empty.textContent = 'Leave the start to begin.'++ this.splits.replaceChildren(empty)++ return++ }++ const rows = state.splits.map((timeMs, i) => {++ const item = document.createElement('li')++ item.className = 'surf-split'++ const label = document.createElement('span')++ label.className = 'surf-split-label'++ label.textContent = `S${i + 1}`++ const value = document.createElement('span')++ value.className = 'surf-split-time'++ value.textContent = formatSurfTime(timeMs)++ item.append(label, value)++ const pb = state.pbSplits?.[i]++ if (pb !== undefined && this.deltaUntil.has(i)) {++ const delta = timeMs - pb++ const badge = document.createElement('span')++ badge.className = `surf-delta ${delta <= 0 ? 'is-faster' : 'is-slower'}`++ badge.textContent = `${delta <= 0 ? '−' : '+'}${formatSurfTime(Math.abs(delta))}`++ item.append(badge)++ }++ return item++ })++ this.splits.replaceChildren(...rows)++ }++}nnew file mode 100644@@@ -0,0 +1,46 @@++.surf-trainer {++ position: fixed;++ inset: 0;++ z-index: 11;++ pointer-events: none;++ user-select: none;++ -webkit-user-select: none;++}++.surf-trainer[hidden] { display: none; }++.surf-trainer-look {++ position: absolute;++ inset: 0;++ pointer-events: auto;++ touch-action: none;++}++.surf-trainer-strafe {++ position: absolute;++ bottom: max(1.2rem, env(safe-area-inset-bottom));++ width: min(34vw, 11rem);++ height: min(28vh, 9rem);++ border: 2px solid color-mix(in srgb, var(--g-accent, #f0b040) 60%, transparent);++ border-radius: 1.1rem;++ background: color-mix(in srgb, var(--g-accent, #f0b040) 18%, rgba(8, 12, 20, 0.55));++ color: var(--text-primary, #fff);++ font: 800 calc(0.9rem * var(--ov-text-scale, 1)) / 1.2 system-ui, sans-serif;++ pointer-events: auto;++ touch-action: none;++}++.surf-trainer-left { left: max(1rem, env(safe-area-inset-left)); }++.surf-trainer-right { right: max(1rem, env(safe-area-inset-right)); }++.surf-trainer-strafe:active {++ background: color-mix(in srgb, var(--g-accent, #f0b040) 45%, rgba(8, 12, 20, 0.5));++}++.surf-trainer-restart {++ position: absolute;++ top: max(0.8rem, env(safe-area-inset-top));++ right: max(0.8rem, env(safe-area-inset-right));++ padding: 0.5rem 0.8rem;++ border: 1px solid var(--border, #2a3340);++ border-radius: 0.5rem;++ background: var(--g-action, #2a5a8a);++ color: var(--g-on-action, #fff);++ font: 700 0.8rem system-ui, sans-serif;++ pointer-events: auto;++ touch-action: none;++}nnew file mode 100644@@@ -0,0 +1,88 @@++import type { TouchPad } from '../input/touchInputSource.js'++++/**++ * Touch's Ride control: two big strafe zones (left/right) and drag-to-look.++ * It is deliberately a trainer, not a virtual stick: surf is about holding a++ * direction into the ramp and turning, and two thumb-sized zones make that++ * possible on a phone. The zones drive the same TouchPad the rest of the++ * game uses, so prediction and intent bits are identical.++ */++export class SurfTrainer {++ private readonly root: HTMLElement++ private readonly look: HTMLElement++ private active = false++++ constructor(++ parent: HTMLElement,++ private readonly canvas: HTMLCanvasElement,++ private readonly pad: TouchPad,++ private readonly onRestart: () => void,++ ) {++ this.root = document.createElement('div')++ this.root.className = 'surf-trainer'++ this.root.hidden = true++ this.look = document.createElement('div')++ this.look.className = 'surf-trainer-look'++ this.look.setAttribute('aria-hidden', 'true')++ const left = this.zone('left', '◀ hold left', -1)++ const right = this.zone('right', 'hold right ▶', 1)++ const restart = document.createElement('button')++ restart.type = 'button'++ restart.className = 'surf-trainer-restart'++ restart.textContent = 'Restart'++ restart.addEventListener('click', () => this.onRestart())++ this.root.append(this.look, left, right, restart)++ parent.append(this.root)++ this.bindLook()++ }++++ setActive(active: boolean): void {++ this.active = active++ this.root.hidden = !active++ if (!active) this.pad.releaseAll(performance.now())++ this.canvas.style.touchAction = active ? 'none' : ''++ }++++ private zone(side: 'left' | 'right', label: string, sign: -1 | 1): HTMLElement {++ const zone = document.createElement('button')++ zone.type = 'button'++ zone.className = `surf-trainer-strafe surf-trainer-${side}`++ zone.textContent = label++ zone.addEventListener('pointerdown', (e) => {++ if (e.pointerType !== 'touch') return++ e.preventDefault()++ zone.setPointerCapture(e.pointerId)++ const r = zone.getBoundingClientRect()++ const cx = r.left + r.width / 2++ const cy = r.top + r.height / 2++ this.pad.stickRadius = r.width / 2++ this.pad.stickDown(e.pointerId, cx, cy, cx, cy)++ this.pad.move(e.pointerId, cx + sign * r.width, cy)++ })++ const lift = (e: PointerEvent): void => {++ if (e.pointerType !== 'touch') return++ this.pad.up(e.pointerId, e.timeStamp)++ }++ zone.addEventListener('pointerup', lift)++ zone.addEventListener('pointercancel', lift)++ return zone++ }++++ private bindLook(): void {++ this.look.addEventListener('pointerdown', (e) => {++ if (e.pointerType !== 'touch') return++ e.preventDefault()++ this.look.setPointerCapture(e.pointerId)++ this.pad.lookDown(e.pointerId, e.clientX, e.clientY)++ })++ // TouchControls' window-level pointermove already forwards the look++ // finger to the same TouchPad; registering it again would double the++ // sensitivity.++ const lift = (e: PointerEvent): void => {++ if (e.pointerType !== 'touch') return++ this.pad.up(e.pointerId, e.timeStamp)++ }++ this.look.addEventListener('pointerup', lift)++ this.look.addEventListener('pointercancel', lift)++ }++}@@@ -24,6 +24,7 @@ import {ReplicationSystem,SessionsSystem,SocialSystem,++ SurfSystem,VehicleSystem,WorldEventSystem,buildHandlerTable,@@@ -82,6 +83,9 @@ export class GameServer {const ctx = this.ctxconst sessions = new SessionsSystem(ctx)const movement = new MovementSystem(ctx)++ // Runs see this tick's authoritative positions: construct after movement,++ // insert in the ordered list before replication.++ const surf = new SurfSystem(ctx)// The wire surface every system sends through.const replication = new ReplicationSystem(ctx)ctx.net = replication@@@ -104,6 +108,7 @@ export class GameServer {this.systems = {sessions,movement,++ surf,replication,manipulation,combat,@@@ -127,6 +132,7 @@ export class GameServer {this.ordered = [sessions,movement,++ surf,replication,manipulation,combat,@@@ -279,6 +285,9 @@ export class GameServer {// 1. Movement from queued inputs (server-simulated, never client positions).s.movement.tick(ctx, dt)++ // 1b. Surf run state: timer, checkpoints, fails, finish (after movement,++ // before replication so surf_state travels in this tick's snapshot).++ s.surf.tick(ctx, dt)if (this.rush) {const now = Date.now()const results = this.rush.tick(@@@ -7,6 +7,7 @@ import {eyeOffsetFor,normalizeStats,type PlayerMoveState,++ type SurfRunState,type SurvivalStats,} from '@openvibe/gameplay'import type { ItemStack, ReputationDto } from '@openvibe/gameplay'@@@ -93,6 +94,15 @@ export interface PlayerSession {openContainer: EntityId | null/** Most negative airborne vertical velocity (fall-damage tracking). */fallVy: number++ /** Surf run tracker; null outside a surf place. */++ surf: SurfRunState | null++ /** Cached personal best/world record shown in surf_state. */++ surfPb: number | null++ surfWr: number | null++ /** The PB's split times (HUD deltas). */++ surfPbSplits: number[]++ /** This tick's authoritative movement segments (cleared by MovementSystem). */++ moveSegments: { from: Vec3; to: Vec3; yaw: number }[]/** Tick of the last accepted use/swing (server-side swing cooldown). */lastUseTick: number/** Stance the kinematic physics body was last built for. */@@@ -174,6 +184,11 @@ export function createSession(init: SessionInit): PlayerSession {statsDirty: true,openContainer: null,fallVy: 0,++ surf: null,++ surfPb: null,++ surfWr: null,++ surfPbSplits: [],++ moveSegments: [],lastUseTick: 0,bodyStance: 0,content: init.content,nnew file mode 100644@@@ -0,0 +1,157 @@++import { readFileSync } from 'node:fs'++import { describe, expect, it } from 'vitest'++import {++ SURF_MAPS,++ compileMapFileV2,++ parseMapFile,++ surfMap,++ type MapFileV2,++} from '@openvibe/content'++import {++ Buttons,++ SurfRunState,++ createMoveState,++ movementParamsFor,++ placeMoveStateAt,++ stepMovement,++ surfRunMapOf,++} from '@openvibe/gameplay'++import { CollisionLayer, type PhysicsWorld } from '@openvibe/physics'++import { qfromEuler, quat, vec3 } from '@openvibe/shared'++import { surfMapPath } from './surfMaps.js'++++/**++ * The three authored maps: they compile as MapFileV2, carry a complete surf++ * run, and are actually reachable. The reachability proof simulates a bot++ * with scripted inputs through the real movement kernel and real Rapier++ * collision — the same controller the server runs — in the surf style.++ */++const RAMP_MIN_DEG = 50++const RAMP_MAX_DEG = 60++++function load(id: string): MapFileV2 {++ const parsed = parseMapFile(JSON.parse(readFileSync(surfMapPath(id), 'utf8')))++ if (!parsed.ok) throw new Error(parsed.issues.join('; '))++ return parsed.map++}++++async function rapierWorld(map: MapFileV2): Promise<{ world: PhysicsWorld; dispose(): void } | null> {++ try {++ const { createRapierWorld, loadRapier } = await import('@openvibe/physics/rapier')++ const R = await loadRapier()++ const world = createRapierWorld(R)++ for (const body of map.statics) {++ if (body.shape.type !== 'box') continue++ world.addBody({++ shape: body.shape,++ motion: 'static',++ pos: vec3(body.pos[0], body.pos[1], body.pos[2]),++ ...(body.rot++ ? { rot: qfromEuler(quat(), body.rot[0], body.rot[1], body.rot[2]) }++ : {}),++ layer: CollisionLayer.Static,++ collidesWith: CollisionLayer.Player,++ })++ }++ return { world, dispose: () => world.dispose() }++ } catch {++ return null++ }++}++++describe.each(SURF_MAPS)('$name', (info) => {++ it('compiles with its movement, meta, spawn and ordered zones', () => {++ const map = load(info.id)++ expect(map.movement).toBe('surf')++ expect(map.surf).toMatchObject({++ title: info.name,++ author: 'OpenVibe',++ tier: info.tier,++ stages: info.stages,++ })++ expect(map.spawn).toBeDefined()++ const compiled = compileMapFileV2(map)++ expect(compiled.movement).toBe('surf')++ expect(compiled.surf?.stages).toBe(info.stages)++ const run = surfRunMapOf(compiled.surf, compiled.zones ?? [], compiled.spawn, compiled.spawnYaw)++ expect(run.checkpoints).toHaveLength(info.stages - 1)++ expect(run.start.stage).toBe(0)++ expect(run.finish.stage).toBe(info.stages)++ expect(run.failY).toBeLessThan(run.spawn.y)++ })++++ it('authors every ramp steeper than the 0.7 standable plane (50–60°)', () => {++ const map = load(info.id)++ const ramps = map.statics.filter((s) => s.id.includes('ramp') || s.id === 'transfer')++ expect(ramps.length).toBeGreaterThan(1)++ for (const ramp of ramps) {++ const pitch = Math.abs(((ramp.rot?.[0] ?? 0) * 180) / Math.PI)++ const roll = Math.abs(((ramp.rot?.[2] ?? 0) * 180) / Math.PI)++ const tilt = Math.max(pitch, roll)++ expect(tilt).toBeGreaterThanOrEqual(RAMP_MIN_DEG)++ expect(tilt).toBeLessThanOrEqual(RAMP_MAX_DEG)++ }++ })++++ it('is reachable by a scripted bot in the surf style', async () => {++ const map = load(info.id)++ const world = await rapierWorld(map)++ if (!world) return // wasm unavailable: the other map checks still ran++ try {++ const queries = {++ sweepCapsule: (++ from: Parameters<typeof world.world.sweepCapsule>[0],++ to: Parameters<typeof world.world.sweepCapsule>[1],++ radius: number,++ height: number,++ ) => world.world.sweepCapsule(from, to, radius, height, CollisionLayer.Static),++ }++ const compiled = compileMapFileV2(map)++ const run = new SurfRunState(++ surfRunMapOf(compiled.surf, compiled.zones ?? [], compiled.spawn, compiled.spawnYaw),++ 30,++ )++ const move = createMoveState(vec3(run.map.spawn.x, run.map.spawn.y, run.map.spawn.z))++ const params = movementParamsFor('surf')++ let finished = false++ for (let tick = 0; tick < 60 * 30 && !finished; tick++) {++ const next =++ run.reached < run.map.checkpoints.length++ ? run.map.checkpoints[run.reached]!++ : run.map.finish++ const target = next.spawn ?? {++ x: (next.min[0] + next.max[0]) / 2,++ y: (next.min[1] + next.max[1]) / 2,++ z: (next.min[2] + next.max[2]) / 2,++ }++ const yaw = Math.atan2(target.x - move.pos.x, target.z - move.pos.z)++ const from = { x: move.pos.x, y: move.pos.y, z: move.pos.z }++ // A hop pulse clears the gaps between landings; the surf line itself++ // is a held forward run down the 50–60° ramps.++ const buttons = tick % 20 < 2 ? Buttons.Jump : 0++ stepMovement(move, { moveX: 0, moveZ: 1, yaw, pitch: 0, buttons }, params, queries, 1 / 30)++ const events = run.observe(tick, from, move.pos, yaw)++ for (const event of events) {++ if (event.type === 'failed' || event.type === 'restarted') {++ placeMoveStateAt(move, event.to)++ } else if (event.type === 'finished') {++ finished = true++ }++ }++ }++ expect(finished).toBe(true)++ expect(run.finishResult?.timeMs).toBeGreaterThan(0)++ } finally {++ world.dispose()++ }++ })++})++++describe('surf map catalogue', () => {++ it('has three original maps, one place each, with the brief’s tiers', () => {++ expect(SURF_MAPS.map((m) => m.id)).toEqual(['surf_lagoon', 'surf_canyon', 'surf_orbit'])++ expect(SURF_MAPS.map((m) => m.tier)).toEqual([1, 2, 4])++ expect(SURF_MAPS.map((m) => m.stages)).toEqual([3, 4, 6])++ for (const info of SURF_MAPS) expect(surfMap(info.placeId)?.id).toBe(info.id)++ })++})nnew file mode 100644@@@ -0,0 +1,40 @@++import { existsSync, readFileSync } from 'node:fs'++import { dirname, join, resolve } from 'node:path'++import { fileURLToPath } from 'node:url'++import { compileMapFileV2, parseMapFile, surfMap, type MapFileV2, type MapOverride } from '@openvibe/content'++++/** The authored surf maps, versioned with the code (the runtime world under data/ is the editor's). */++export const SURF_MAP_DIR = 'content/surf'++++/**++ * The repository root, independent of the process cwd. Tests (and some++ * launchers) start the server from `apps/server/`, while a deployed server++ * runs from the repo root; `surfMapPath` accepts an explicit root for tests.++ */++function defaultRoot(): string {++ for (const candidate of [process.cwd(), resolve(process.cwd(), '../..')]) {++ if (existsSync(join(candidate, SURF_MAP_DIR))) return candidate++ }++ // Bundled layouts: apps/server/dist/… -> repo root.++ return resolve(dirname(fileURLToPath(import.meta.url)), '../../../..')++}++++export function surfMapPath(mapId: string, root = defaultRoot()): string {++ const info = surfMap(mapId)++ if (!info) throw new Error(`unknown surf map: ${mapId}`)++ return join(root, SURF_MAP_DIR, info.file)++}++++/** Read and validate one authored map. A bad document fails the place, not the process silently. */++export function loadSurfMap(mapId: string, root = defaultRoot()): MapFileV2 {++ const raw = JSON.parse(readFileSync(surfMapPath(mapId, root), 'utf8')) as unknown++ const parsed = parseMapFile(raw)++ if (!parsed.ok)++ throw new Error(`surf map ${mapId} is invalid: ${parsed.issues.slice(0, 5).join('; ')}`)++ return parsed.map++}++++/** The compiled runtime override, with the map's movement/surf metadata. */++export function loadSurfOverride(mapId: string, root = defaultRoot()): MapOverride {++ return compileMapFileV2(loadSurfMap(mapId, root))++}nnew file mode 100644@@@ -0,0 +1,48 @@++import type { SurfTrackSample } from '@openvibe/gameplay'++++/** One server-authoritative finished run, before persistence. */++export interface SurfRunRecord {++ id: string++ mapId: string++ accountKey: string++ playerName: string++ /** Server-derived milliseconds, sub-tick crossing included. */++ timeMs: number++ /** Split times relative to the start, finish last. */++ splits: number[]++ track: SurfTrackSample[]++ tickRate: number++ startedTick: number++ finishedTick: number++ startFrac: number++ finishFrac: number++ fails: number++ createdAt: number++}++++export interface SurfRecordOutcome {++ /** This run became the player's personal best. */++ pb: boolean++ /** This run became the map's world record. */++ wr: boolean++ /** Play's rank for the run when it answered, else null. */++ rank: number | null++ pbMs: number | null++ wrMs: number | null++}++++/**++ * The persistence/ranking seam the surf system writes runs through. Games'++ * implementation is platform/surfRuns.ts; tests use a fake. The server never++ * accepts a time from a client, so this is the only writer.++ */++export interface SurfRecords {++ pbMs(mapId: string, accountKey: string): Promise<number | null>++ /** The PB's total and splits, for the HUD's per-split delta. */++ pbRun(++ mapId: string,++ accountKey: string,++ ): Promise<{ timeMs: number; splits: number[] } | null>++ wrMs(mapId: string): Promise<number | null>++ record(run: SurfRunRecord): Promise<SurfRecordOutcome>++}nnew file mode 100644@@@ -0,0 +1,160 @@++/* eslint-disable @typescript-eslint/no-explicit-any */++import { describe, expect, it } from 'vitest'++import { createMoveState, normalizeStats } from '@openvibe/gameplay'++import { vec3 } from '@openvibe/shared'++import { loadSurfOverride } from './surfMaps.js'++import { setMapOverride } from '@openvibe/content'++import { EnvironmentSystem } from './systems/environment.js'++import { MovementSystem } from './systems/movement.js'++import { SurfSystem } from './systems/surf.js'++++/**++ * Place-rule proof: in a surf place there is no fall damage, no void rescue,++ * and a join puts the capsule at the start line. These are the three hooks the++ * inventory names as conflicting with a time trial; the tests drive the real++ * systems with a minimal context so a rule cannot regress silently.++ */++function surfWorld() {++ const override = loadSurfOverride('surf_lagoon')++ const world = { id: 'surf_lagoon', movement: 'surf', zones: [], spawnPoint: [0, 2, 0], spawnYaw: 0 }++ setMapOverride(override, world.id)++ return { world, override }++}++++function sessionFor(override: ReturnType<typeof loadSurfOverride>) {++ const spawn = override.spawn!++ return {++ playerId: 'p1',++ entityId: 'e1',++ token: 'guest:abc',++ subjectId: null,++ name: 'Tester',++ driving: null,++ lastInput: null,++ starvedTicks: 0,++ inputCredit: 0,++ inputQueue: [],++ lastProcessedSeq: 0,++ move: createMoveState(vec3(spawn[0], spawn[1], spawn[2])),++ yaw: 0,++ pitch: 0,++ buttons: 0,++ bodyStance: 0,++ moveSegments: [],++ surf: null as null | SurfSystem['tick'] extends never ? never : any,++ surfPb: null as number | null,++ surfWr: null as number | null,++ surfPbSplits: [] as number[],++ fallVy: 0,++ stats: normalizeStats(undefined),++ statsDirty: false,++ dirty: false,++ }++}++++function ctxFor(session: any, respawns: string[], sent: unknown[]) {++ return {++ clock: { tick: 30 },++ config: { tickRate: 30, snapshotEvery: 2, placeId: 'surf-lagoon', instanceSlot: 0 },++ env: {},++ regions: { update() {} },++ sessions: {++ values: () => [session][Symbol.iterator](),++ all: () => [session],++ bodyOf: () => undefined,++ },++ world: {++ content: { world: { id: 'surf_lagoon', movement: 'surf', zones: [] } },++ physics: { addBody: () => 1, removeBody() {}, setTransform() {}, sweepCapsule: () => null },++ entities: { get: () => undefined },++ spatial: { move() {}, forEachInRadius() {} },++ bodyOf: () => undefined,++ },++ systems: {++ vehicles: { driveVehicle() {} },++ combat: { respawn: (_s: unknown, died: boolean) => respawns.push(died ? 'died' : 'void') },++ },++ net: {++ send: (_s: unknown, msg: unknown) => sent.push(msg),++ broadcastAll() {},++ timeWire: () => ({ t: 'time', frac: 0.3, weather: 'clear' }),++ statsWire: () => ({}),++ },++ integrations: {++ surf: {++ async pbRun() {++ return null++ },++ async pbMs() {++ return null++ },++ async wrMs() {++ return null++ },++ async record() {++ return { pb: true, wr: false, rank: null, pbMs: 1000, wrMs: 1000 }++ },++ },++ },++ log: { info() {}, warn() {} },++ }++}++++describe('surf place rules', () => {++ it('does not apply fall damage in a surf place (but does in the default style)', () => {++ const { world } = surfWorld()++ const surfSession = sessionFor(loadSurfOverride('surf_lagoon'))++ surfSession.move.grounded = true++ surfSession.fallVy = -30++ const surfRespawns: string[] = []++ const surf = new MovementSystem(ctxFor(surfSession, surfRespawns, []) as any)++ surf.tick(ctxFor(surfSession, surfRespawns, []) as any, 1 / 30)++ expect(surfRespawns).toEqual([])++ expect(surfSession.fallVy).toBe(0)++++ setMapOverride({ terrains: [], movement: 'default' }, world.id)++ const defaultSession = sessionFor(loadSurfOverride('surf_lagoon'))++ defaultSession.move.grounded = true++ defaultSession.fallVy = -300++ const defaultRespawns: string[] = []++ const movement = new MovementSystem(ctxFor(defaultSession, defaultRespawns, []) as any)++ movement.tick(ctxFor(defaultSession, defaultRespawns, []) as any, 1 / 30)++ expect(defaultRespawns).toEqual(['died'])++ })++++ it('does not void-rescue a falling surf player, even below y = -25', () => {++ const { world } = surfWorld()++ const session = sessionFor(loadSurfOverride('surf_lagoon'))++ session.move.pos.y = -100++ const respawns: string[] = []++ const ctx = ctxFor(session, respawns, []) as any++ ctx.env = { timeOfDay: 0.3, weather: 'clear' }++ new EnvironmentSystem(ctx).tick(ctx, 1 / 30)++ expect(respawns).toEqual([])++ setMapOverride({ terrains: [], movement: 'default' }, world.id)++ const session2 = sessionFor(loadSurfOverride('surf_lagoon'))++ session2.move.pos.y = -100++ const respawns2: string[] = []++ const ctx2 = ctxFor(session2, respawns2, []) as any++ ctx2.env = { timeOfDay: 0.3, weather: 'clear' }++ new EnvironmentSystem(ctx2).tick(ctx2, 1 / 30)++ expect(respawns2).toContain('void')++ })++++ it('spawns a surf join at the start line, never at a saved position', () => {++ const { override } = surfWorld()++ const session = sessionFor(override)++ // Pretend the character was saved half-way through the map.++ session.move.pos = vec3(123, 456, 789)++ const sent: any[] = []++ const surf = new SurfSystem(ctxFor(session, [], sent) as any)++ surf.onJoin(session as any)++ expect(session.surf).not.toBeNull()++ expect(session.move.pos.x).toBeCloseTo(override.spawn![0])++ expect(session.move.pos.y).toBeCloseTo(override.spawn![1])++ expect(session.move.pos.z).toBeCloseTo(override.spawn![2])++ expect(session.move.vel).toEqual(vec3(0, 0, 0))++ const state = sent.find((m) => m.t === 'surf_state')++ expect(state).toMatchObject({ running: false, stage: 0 })++ })++})@@@ -27,6 +27,8 @@ import type { PhysicsSystem } from './physics.js'import type { ProductionSystem } from './production.js'import type { ReplicationSystem } from './replication.js'import type { SessionsSystem } from './sessions.js'++import type { SurfSystem } from './surf.js'++import type { SurfRecords } from '../surfRecords.js'import type { SocialSystem } from './social.js'import type { VehicleSystem } from './vehicles.js'import type { WorldEventSystem } from './worldEvents.js'@@@ -83,6 +85,8 @@ export interface GameIntegrations {scripts?: ScriptModHost/** The person's games.progress.summary user module on Network, written when a character leaves. */progressSummary?: ProgressSummaryWriter++ /** Surf run persistence + Play ranking (absent outside surf places). */++ surf?: SurfRecords}/** The mutable tick counter, shared by every system that reads cadence or cooldowns. */@@@ -188,6 +192,7 @@ export class SessionRegistry {export interface ServerSystems {sessions: SessionsSystemmovement: MovementSystem++ surf: SurfSystemreplication: ReplicationSystemmanipulation: ManipulationSystemcombat: CombatSystem@@@ -3,6 +3,7 @@ import {activeStatuses,ambientTemperature,precipitation,++ movementStyleFor,stepEnvironment,tickSurvival,type WeatherKind,@@@ -45,11 +46,17 @@ export class EnvironmentSystem implements System {const ambientC = ambientTemperature(ctx.env)const raining = precipitation(ctx.env) > 0const nowMs = Date.now()++ // A surf place is a time trial: no exposure/hunger simulation and no++ // void rescue (a miss is the SurfSystem's checkpoint teleport). The void++ // hook would otherwise yank a falling player to the world spawn once a++ // second, before the run could fail them.++ const surfPlace = movementStyleFor(ctx.world.content.world) === 'surf'// Region activation follows players (coarse, 1 Hz).ctx.regions.update([...ctx.sessions.values()].map((s) => ({ x: s.move.pos.x, z: s.move.pos.z })),)for (const session of ctx.sessions.values()) {++ if (surfPlace) continueconst sprinting =(session.buttons & Buttons.Sprint) !== 0 &&Math.hypot(session.move.vel.x, session.move.vel.z) > 1@@@ -17,6 +17,7 @@ export type { HandlerMap, MessageHandler, System } from './system.js'export { SessionsSystem } from './sessions.js'export { MovementSystem, MOVE, MAX_INPUT_QUEUE } from './movement.js'++export { SurfSystem } from './surf.js'export { ReplicationSystem } from './replication.js'export { ManipulationSystem } from './manipulation.js'export { CombatSystem } from './combat.js'@@@ -2,9 +2,12 @@ import {DEFAULT_MOVEMENT,applyDamage,hullHeightFor,++ movementParamsFor,++ movementStyleFor,stepMovement,type CollisionQueries,type MoveInput,++ type MovementParams,} from '@openvibe/gameplay'import { CollisionLayer, type BodyId } from '@openvibe/physics'import type { ClientEditMode, ClientInput } from '@openvibe/protocol'@@@ -130,17 +133,29 @@ export class MovementSystem implements System {}tick(ctx: ServerContext, dt: number): void {++ // Resolve the place's movement once per tick, not once per player: the++ // style is room-wide, and instanceHub's per-tick budget is tight.++ const style = movementStyleFor(ctx.world.content.world)++ const params = movementParamsFor(style)++ const surfPlace = style === 'surf'for (const session of ctx.sessions.values()) {-- this.stepSession(ctx, session, dt)++ this.stepSession(ctx, session, dt, params, surfPlace)}}-- private stepSession(ctx: ServerContext, session: PlayerSession, dt: number): void {++ private stepSession(++ ctx: ServerContext,++ session: PlayerSession,++ dt: number,++ params: MovementParams,++ surfPlace: boolean,++ ): void {if (session.driving) {ctx.systems.vehicles.driveVehicle(session)return}this.sweepSelf = ctx.sessions.bodyOf(session.playerId)++ session.moveSegments.length = 0let simulated = 0for (const input of takeInputs(session)) {session.lastInput = input@@@ -148,7 +163,16 @@ export class MovementSystem implements System {session.lastProcessedSeq = input.seqapplyLookInput(session, input)session.buttons = input.intents-- stepMovement(session.move, toMoveInput(session, input), MOVE, this.moveQueries, dt)++ const from = surfPlace++ ? vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z)++ : null++ stepMovement(session.move, toMoveInput(session, input), params, this.moveQueries, dt)++ if (from)++ session.moveSegments.push({++ from,++ to: vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z),++ yaw: session.yaw,++ })simulated++}if (simulated === 0 && session.lastInput) {@@@ -159,13 +183,22 @@ export class MovementSystem implements System {if (session.starvedTicks > STALL_TICKS) {// Zero MOVEMENT only: buttons stay held. Zeroing buttons fabricates release edges for toggle keys// (prone/crouch), and the server flapped stances.++ const from = surfPlace++ ? vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z)++ : nullstepMovement(session.move,toMoveInput(session, { ...session.lastInput, moveX: 0, moveZ: 0 }),-- MOVE,++ params,this.moveQueries,dt,)++ if (from)++ session.moveSegments.push({++ from,++ to: vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z),++ yaw: session.yaw,++ })}}let bodyId = ctx.sessions.bodyOf(session.playerId)@@@ -176,7 +209,7 @@ export class MovementSystem implements System {bodyId = ctx.world.physics.addBody({shape: {type: 'capsule',-- radius: MOVE.capsuleRadius,++ radius: params.capsuleRadius,height: Math.max(hull - 0.3, 0.4),},motion: 'kinematic',@@@ -195,7 +228,11 @@ export class MovementSystem implements System {}// Fall damage: track the hardest downward velocity while airborne and// cash it in on landing. ~13 m/s (≈2.5 story drop) is the free threshold.-- if (!session.move.grounded) {++ // A surf place is a time trial: misses teleport to the last checkpoint and++ // the clock keeps running, so falls must not kill (brief decision 4).++ if (surfPlace) {++ session.fallVy = 0++ } else if (!session.move.grounded) {session.fallVy = Math.min(session.fallVy, session.move.vel.y)} else if (session.fallVy < -13) {const dmg = Math.round((-session.fallVy - 13) * 3.5)@@@ -1,10 +1,9 @@--import { STARTER_ITEMS, worldSpawn } from '@openvibe/content'--import { Inventory, SkillSet, type GameEntity } from '@openvibe/gameplay'++import { STARTER_ITEMS, getMapOverride, worldSpawn } from '@openvibe/content'++import { Inventory, SkillSet, movementParamsFor, movementStyleFor, type GameEntity } from '@openvibe/gameplay'import { CollisionLayer } from '@openvibe/physics'import { PROTOCOL_VERSION, encodeServerMessage, type ClientHello } from '@openvibe/protocol'import { asPlayerId, newEntityId, newPlayerId, qfromYaw, quat, vec3 } from '@openvibe/shared'import type { GameConnection, ServerContext } from './context.js'--import { MOVE } from './movement.js'import { eventPlayer, progressOf } from './platform.js'import { createSession, HOTBAR_SIZE, INVENTORY_SIZE } from '../playerSession.js'import type { HandlerMap, System } from './system.js'@@@ -185,10 +184,16 @@ export class SessionsSystem implements System {const world = ctx.world.content.worldconst playerId = existing ? asPlayerId(existing.id) : newPlayerId()const mapSpawn = worldSpawn(world)++ // A surf join always starts at the map's start line, never at a saved++ // position: resuming half-way would skip the clock (brief decision 4).++ const override = getMapOverride(world)++ const surfSpawn = override?.surf ? override.spawn : undefinedconst 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])++ surfSpawn++ ? vec3(surfSpawn[0], surfSpawn[1], surfSpawn[2])++ : 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)@@@ -222,7 +227,11 @@ export class SessionsSystem implements System {rank,name: msg.name,spawn,-- yaw: existing && existing.positionInPlace !== false ? existing.yaw : mapSpawn.yaw,++ yaw: surfSpawn++ ? (override?.spawnYaw ?? 0)++ : existing && existing.positionInPlace !== false++ ? existing.yaw++ : mapSpawn.yaw,inventory,skills,friends,@@@ -253,8 +262,9 @@ export class SessionsSystem implements System {// Kinematic capsule so props collide with players. Shorter than the// movement hull and lifted off the feet: standing ON a prop must not// press it down (that caused sink/jitter loops when prop-surfing).++ const movement = movementParamsFor(movementStyleFor(world))const bodyId = ctx.world.physics.addBody({-- shape: { type: 'capsule', radius: MOVE.capsuleRadius, height: MOVE.capsuleHeight - 0.3 },++ shape: { type: 'capsule', radius: movement.capsuleRadius, height: movement.capsuleHeight - 0.3 },motion: 'kinematic',pos: vec3(session.move.pos.x, session.move.pos.y + 0.15, session.move.pos.z),layer: CollisionLayer.Player,nnew file mode 100644@@@ -0,0 +1,204 @@++import { getMapOverride } from '@openvibe/content'++import { SurfRunState, placeMoveStateAt, surfRunMapOf, type SurfRunResult } from '@openvibe/gameplay'++import { newEntityId, qfromYaw, vec3 } from '@openvibe/shared'++import type { ClientSurfRestart } from '@openvibe/protocol'++import type { PlayerSession } from '../playerSession.js'++import type { ServerContext } from './context.js'++import type { HandlerMap, System } from './system.js'++++/**++ * Surf (OpenVibe.Surf Ride): the run clock, ordered checkpoints, fail++ * teleports, ghosts and the finish card's data.++ *++ * It is inserted AFTER MovementSystem and BEFORE ReplicationSystem, so it++ * sees this tick's authoritative positions and its `surf_state`/`surf_finish`++ * messages travel in the same tick. Every movement segment MovementSystem++ * simulated is fed to the run state machine, including catch-up inputs; no++ * client time or position is involved.++ */++export class SurfSystem implements System {++ readonly name = 'surf'++ private readonly warned = new Set<string>()++++ readonly handlers: HandlerMap = {++ surf_restart: (session, _msg: ClientSurfRestart) => {++ if (!session.surf) return++ const event = session.surf.restart(this.ctx.clock.tick)++ if (event.type === 'restarted') this.teleport(session, event.to, event.yaw)++ this.sendState(session)++ },++ }++++ /** True only where the loaded map authored surf metadata; the common place skips this system. */++ private readonly enabled: boolean++++ constructor(private readonly ctx: ServerContext) {++ this.enabled = getMapOverride(ctx.world.content.world)?.surf !== undefined++ }++++ onJoin(session: PlayerSession): void {++ const map = this.runMap()++ if (!map) return++ session.surf = new SurfRunState(map, this.ctx.config.tickRate)++ session.surfPb = null++ session.surfWr = null++ session.surfPbSplits = []++ this.teleport(session, map.spawn, map.spawnYaw)++ void this.loadRecords(session)++ this.sendState(session)++ }++++ tick(ctx: ServerContext, _dt: number): void {++ if (!this.enabled) return++ for (const session of ctx.sessions.values()) {++ const run = session.surf++ if (!run) continue++ for (const segment of session.moveSegments) {++ const events = run.observe(ctx.clock.tick, segment.from, segment.to, segment.yaw)++ for (const event of events) {++ if (event.type === 'failed' || event.type === 'restarted') {++ this.teleport(session, event.to, event.yaw)++ this.sendState(session)++ } else if (event.type === 'checkpoint') {++ this.sendState(session)++ } else if (event.type === 'finished') {++ void this.finish(session, event.result)++ }++ }++ }++ if (ctx.clock.tick % ctx.config.snapshotEvery === 0) this.sendState(session)++ }++ }++++ private runMap() {++ const override = getMapOverride(this.ctx.world.content.world)++ if (!override?.surf) return null++ return surfRunMapOf(++ override.surf,++ override.zones ?? [],++ override.spawn ?? [0, 2, 0],++ override.spawnYaw ?? 0,++ this.ctx.world.content.world.id,++ )++ }++++ private teleport(session: PlayerSession, to: { x: number; y: number; z: number }, yaw: number): void {++ placeMoveStateAt(session.move, to)++ session.yaw = yaw++ session.surf?.teleport(to)++ const body = this.ctx.world.bodyOf(session.entityId)++ if (body !== undefined)++ this.ctx.world.physics.setTransform(body, vec3(to.x, to.y + 0.15, to.z))++ const entity = this.ctx.world.entities.get(session.entityId)++ if (entity) qfromYaw(entity.transform.rot, yaw)++ }++++ private async loadRecords(session: PlayerSession): Promise<void> {++ const records = this.ctx.integrations.surf++ const map = this.runMap()++ if (!records || !map) return++ const key = session.subjectId ?? session.token++ try {++ const [pbRun, wr] = await Promise.all([++ records.pbRun(map.id, key),++ records.wrMs(map.id),++ ])++ session.surfPb = pbRun?.timeMs ?? null++ session.surfPbSplits = pbRun?.splits ?? []++ session.surfWr = wr++ this.sendState(session)++ } catch (err) {++ this.warnOnce('load', `surf records load failed: ${String(err)}`)++ }++ }++++ private async finish(session: PlayerSession, result: SurfRunResult): Promise<void> {++ const records = this.ctx.integrations.surf++ let pb = session.surfPb === null || result.timeMs < session.surfPb++ let wr = session.surfWr === null || result.timeMs < session.surfWr++ let rank: number | null = null++ if (records) {++ try {++ const outcome = await records.record({++ id: newEntityId(),++ mapId: result.mapId,++ accountKey: session.subjectId ?? session.token,++ playerName: session.name,++ timeMs: result.timeMs,++ splits: result.splits,++ track: result.track,++ tickRate: this.ctx.config.tickRate,++ startedTick: result.startedTick,++ finishedTick: result.finishedTick,++ startFrac: result.startFrac,++ finishFrac: result.finishFrac,++ fails: result.fails,++ createdAt: Date.now(),++ })++ pb = outcome.pb++ wr = outcome.wr++ rank = outcome.rank++ session.surfPb = outcome.pbMs++ session.surfWr = outcome.wrMs++ if (pb) session.surfPbSplits = result.splits++ } catch (err) {++ // The run is already authoritative; a storage/Play failure must not++ // turn a finish into an error. Keep the local PB comparison.++ this.warnOnce('record', `surf run record failed: ${String(err)}`)++ if (pb) {++ session.surfPb = result.timeMs++ session.surfPbSplits = result.splits++ }++ if (wr) session.surfWr = result.timeMs++ }++ } else {++ if (pb) {++ session.surfPb = result.timeMs++ session.surfPbSplits = result.splits++ }++ if (wr) session.surfWr = result.timeMs++ }++ this.ctx.net.send(session, {++ t: 'surf_finish',++ timeMs: result.timeMs,++ splits: result.splits,++ pb,++ wr,++ rank,++ pbMs: session.surfPb,++ wrMs: session.surfWr,++ })++ this.sendState(session)++ this.ctx.log.info('surf run finished', {++ playerId: session.playerId,++ map: result.mapId,++ timeMs: result.timeMs,++ pb,++ wr,++ fails: result.fails,++ })++ session.dirty = true++ }++++ private sendState(session: PlayerSession): void {++ const run = session.surf++ if (!run) return++ this.ctx.net.send(session, {++ t: 'surf_state',++ running: run.status === 'running',++ elapsedMs: run.elapsedMs,++ stage: run.reached,++ stages: run.map.stages,++ splits: run.splitTimes,++ pbMs: session.surfPb,++ wrMs: session.surfWr,++ ...(session.surfPbSplits.length ? { pbSplits: session.surfPbSplits } : {}),++ })++ }++++ private warnOnce(tag: string, message: string): void {++ if (this.warned.has(tag)) return++ this.warned.add(tag)++ this.ctx.log.warn(message)++ }++}@@@ -12,6 +12,9 @@ import {parseMapFile,SCRAPLANDIA_PACK,setMapOverride,++ surfMapOfPlace,++ surfPack,++ SURF_MAPS,type ContentRegistry,} from '@openvibe/content'import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'@@@ -30,6 +33,7 @@ import { telemetry } from 'openvibe-sdk/service'import { createPgTelemetrySink, startTelemetryRetention } from 'openvibe-sdk/telemetry'import { GameServer } from './game/gameServer.js'import { GameWorld } from './game/gameWorld.js'++import { loadSurfOverride, surfMapPath } from './game/surfMaps.js'import { InstanceHub } from './game/instanceHub.js'import { PlaceRouter } from './game/placeRouter.js'import { attachEditorWs } from './net/editorWs.js'@@@ -83,6 +87,8 @@ import { GameProgressWriter, type Milestone } from './game/progression.js'import type { RoundResult } from './game/salvageRush.js'import { handleGameRead } from './net/gameRoutes.js'import { createPlayBoards } from './platform/playBoards.js'++import { PgSurfRecords, createSurfPlay, surfBoardId } from './platform/surfRuns.js'++import { handleSurfRead } from './net/surfRoutes.js'import { createCasualRoutes } from './casual/routes.js'import { casualLeaderboard, pruneCasualActions } from './casual/store.js'import { createPortalHome, readPlayTitles } from './net/portalHome.js'@@@ -141,7 +147,12 @@ async function main(): Promise<void> {const packs = await loadDefinitionPacks(store.mods, (modId, reason) =>log.warn('mod definitions skipped', { mod: modId, reason }),)-- const builtInPack = config.placeId === 'salvage-rush' ? SALVAGE_RUSH_PACK : SCRAPLANDIA_PACK++ const primarySurf = surfMapOfPlace(config.placeId)++ const builtInPack = primarySurf++ ? surfPack(primarySurf.id)++ : config.placeId === 'salvage-rush'++ ? SALVAGE_RUSH_PACK++ : SCRAPLANDIA_PACKconst content = createContent(packs, builtInPack)let inventoryRewards: InventoryRewards | null = nullconst settleRewards = (subject: string): void => {@@@ -162,11 +173,12 @@ async function main(): Promise<void> {// Edited map (from /editor): heightfield + extra statics replace the// procedural terrain for EVERYTHING (physics, spawns, clients fetch the// same file over /map.json).-- if (existsSync(config.mapPath)) {++ const primaryMapPath = primarySurf ? surfMapPath(primarySurf.id) : config.mapPath++ if (existsSync(primaryMapPath)) {try {// The document is v2; the RUNTIME representation is v2 compiled straight// through.-- const parsed = parseMapFile(JSON.parse(readFileSync(config.mapPath, 'utf8')))++ const parsed = parseMapFile(JSON.parse(readFileSync(primaryMapPath, 'utf8')))if (parsed.ok) {// The map's statics live in the override, NOT merged into// content.world.statics: that merge was permanent, so the map layer@@@ -220,6 +232,13 @@ async function main(): Promise<void> {playSync.start()// The in-game boards are Play's (public reads, no token).const playBoards = createPlayBoards({ url: config.platform.playUrl })++ // Surf runs: local rows first (migration 0018), then Play's `time` board.++ // A Play failure never fails a run.++ const surfRecords = new PgSurfRecords(++ db,++ createSurfPlay({ url: config.platform.playUrl, tokens: platform?.tokens ?? null }),++ (message) => log.warn(message),++ )const platformLog = log.child({ system: 'platform' })const valkey = createValkey({ url: config.db.valkeyUrl ?? '', prefix: config.db.valkeyPrefix })let outbox: PgOutbox | null = null@@@ -389,6 +408,7 @@ async function main(): Promise<void> {},}: {}),++ ...(primarySurf ? { surf: surfRecords } : {}),},)// Async boot: env, markets and the NPC population load before the tick loop starts.@@@ -461,6 +481,7 @@ async function main(): Promise<void> {},}: {}),++ ...(primarySurf ? { surf: surfRecords } : {}),},)await roomGame.load()@@@ -594,9 +615,97 @@ async function main(): Promise<void> {createSecondaryRoom,log.child({ system: 'instances', place: secondaryPlace }),)++ // ── OpenVibe.Surf (openvibe.surf): one place per map, all in this process ──++ // A surf place is ordinary content (the surf pack) plus an authored MapFileV2++ // override; its GameServer runs the SurfSystem. Places are separate so each++ // room's world has exactly one map, the same way Scraplandia and Salvage Rush++ // are separate places. The primary place already hosts one when it is surf.++ const extraHubs = new Map<string, InstanceHub>()++ const surfContentByPlace = new Map<string, ContentRegistry>()++ for (const info of SURF_MAPS) {++ if (info.placeId === config.placeId || info.placeId === secondaryPlace) continue++ const placeContent = createContent(packs, surfPack(info.id))++ setMapOverride(loadSurfOverride(info.id), placeContent.world.id)++ surfContentByPlace.set(info.placeId, placeContent)++ const surfRegistry = new InstanceRegistry(db, info.placeId)++ let surfPrimaryLease: InstanceLease++ const createSurfRoom = async (slot: number, lease: InstanceLease) => {++ const roomStore = openPgStore(db, {++ placeId: info.placeId,++ instanceSlot: slot,++ leaseGuard: () =>++ surfRegistry.assertHeld(slot === 0 ? surfPrimaryLease : lease, Date.now()),++ })++ await roomStore.ensurePlace()++ const roomPhysics = createRapierWorld(rapier)++ const roomWorld = new GameWorld(++ placeContent,++ roomPhysics,++ log.child({ system: 'world', place: info.placeId, slot }),++ config.interestCell,++ )++ await roomWorld.seedOrRestore(roomStore)++ const roomGame = new GameServer(++ {++ ...config,++ placeId: info.placeId,++ instanceSlot: slot,++ // A surf room has no survival, NPC population or world events; the++ // run system owns falls and respawns.++ sandbox: true,++ },++ roomWorld,++ roomStore,++ metrics,++ log.child({ system: 'game', place: info.placeId, slot }),++ { surf: surfRecords },++ )++ await roomGame.load()++ return {++ lease,++ game: roomGame,++ world: roomWorld,++ dispose: async () => {++ roomPhysics.dispose()++ },++ }++ }++ // The place row must exist before a lease can reference it (the same++ // order the primary/secondary rooms use: ensurePlace, then claim).++ const surfBootstrap = openPgStore(db, {++ placeId: info.placeId,++ instanceSlot: 0,++ leaseGuard: async () => {},++ })++ await surfBootstrap.ensurePlace()++ const surfClaim = await surfRegistry.claim(++ 0,++ config.maxPlayers,++ crypto.randomUUID(),++ Date.now(),++ 60_000,++ )++ if (!surfClaim) throw new Error(`${info.placeId} primary instance slot is already leased`)++ surfPrimaryLease = surfClaim++ const surfPrimary = await createSurfRoom(0, surfClaim)++ const surfRunning = await surfRegistry.renew(surfClaim, Date.now(), 15_000, 'running')++ if (!surfRunning) throw new Error(`${info.placeId} primary lease expired during boot`)++ surfPrimary.lease = surfRunning++ surfPrimaryLease = surfRunning++ const surfHub = new InstanceHub(++ surfRegistry,++ surfPrimary,++ config.instancesPerPlace,++ config.maxPlayers,++ createSurfRoom,++ log.child({ system: 'instances', place: info.placeId }),++ )++ extraHubs.set(info.placeId, surfHub)++ }const hubs = new Map([[config.placeId, hub],[secondaryPlace, secondaryHub],++ ...extraHubs,])// ── Creator worlds (worlds/): players' own places, started on the first join, stopped when empty ──// A creator world is the place `world:<id>` with the plain creator pack and its map as the override. Its room is@@@ -654,6 +763,7 @@ async function main(): Promise<void> {const contentByPlace = new Map([[config.placeId, content],[secondaryPlace, secondaryContent],++ ...surfContentByPlace,])const heartbeatTimer = setInterval(() => void router.heartbeat(), 5_000)heartbeatTimer.unref()@@@ -1005,6 +1115,38 @@ async function main(): Promise<void> {if (serveAssets(req, res)) return trueif (serveWorldPage(req, res)) return trueif (serveSteam(req, res)) return true++ if (++ handleSurfRead(req, res, {++ db,++ networkAuthUrl: config.networkAuthUrl,++ ...(config.platform.playUrl++ ? {++ playBoard: async (mapId: string) => {++ const res = await fetch(++ `${config.platform.playUrl}/api/v1/titles/surf/boards/${surfBoardId(mapId)}?period=all&page=1`,++ {++ headers: { accept: 'application/json' },++ signal: AbortSignal.timeout(4_000),++ },++ )++ if (!res.ok) return null++ const body = (await res.json()) as {++ entries?: { rank: number; name: string; score: number }[]++ }++ return {++ entries: (body.entries ?? []).map((e) => ({++ rank: Number(e.rank),++ name: String(e.name),++ score: Number(e.score),++ })),++ }++ },++ }++ : {}),++ log: (message) => log.warn(message),++ })++ )++ return trueif (handleGameRead(req, res, {db,@@@ -1139,6 +1281,15 @@ async function main(): Promise<void> {log.child({ system: 'indexnow' }),),config.placeId,++ // One map document per place: surf maps are separate files, the rest++ // share the configured editor map.++ (place) => {++ const surf = place ? surfMapOfPlace(place) : null++ if (surf) return surfMapPath(surf.id)++ if (!place || place === config.placeId)++ return primarySurf ? surfMapPath(primarySurf.id) : config.mapPath++ return undefined++ },)const drainer = httpDrainer(http)world.reconcileMapNodes()@@@ -192,6 +192,8 @@ export function createHttpServer(tickets?: WsTicketStore,indexnow?: SharedIndexNow,defaultPlaceId = 'scraplandia',++ /** Per-place map documents (surf places each have their own). */++ mapPathFor?: (place: string | undefined) => string | undefined,): Server {const root = staticDir ? resolve(staticDir) : null// Sessions the Network confirmed recently: lets a silent login on a signed-in@@@ -265,15 +267,16 @@ export function createHttpServer(if (serveLegal(req, res)) returnif (!playHost && serveUpdates(req, res)) returnconst requestedMapPlace = new URL(req.url ?? '/', 'http://games.local').searchParams.get('game')-- if (url === '/map.json' && requestedMapPlace && requestedMapPlace !== defaultPlaceId) {++ const resolvedMapPath = mapPathFor ? mapPathFor(requestedMapPlace ?? defaultPlaceId) : mapPath++ if (url === '/map.json' && requestedMapPlace && requestedMapPlace !== defaultPlaceId && !resolvedMapPath) {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' && resolvedMapPath) {// The canonical v2 document, with its revision as an ETag. This IS the// wire format.-- void loadMap(mapPath).then((rec) => {++ void loadMap(resolvedMapPath).then((rec) => {res.writeHead(200, {'content-type': 'application/json','cache-control': 'no-cache',@@@ -737,8 +740,10 @@ export function createHttpServer(res.end(prometheusText(metrics.snapshot() as unknown as Record<string, unknown>))return}++ // ?place=<id>: the world gauges of that place's rooms only (the process hosts several places).++ const place = new URLSearchParams((req.url ?? '').split('?')[1] ?? '').get('place')res.writeHead(200, { 'content-type': 'application/json' })-- res.end(JSON.stringify(metrics.snapshot()))++ res.end(JSON.stringify(place && /^[a-z0-9-]{1,64}$/.test(place) ? metrics.placeSnapshot(place) : metrics.snapshot()))return}// Every API route has answered above; what is left under /api is unknown.@@@ -808,7 +813,9 @@ export function createHttpServer(? 'editor.html': url === '/worlds'? 'worlds.html'-- : null++ : url === '/surf/embed'++ ? 'play.html'++ : null// Static files with path traversal guard.const safePath = normalize(pageAlias ?? url).replace(/^(\.\.[/\\])+/, '')@@@ -858,6 +865,11 @@ export function createHttpServer(: filePath.includes('/assets/')? 'public, max-age=86400, stale-while-revalidate=604800': 'public, max-age=3600',++ // The embeddable surf route is frameable by openvibe.surf only; every++ // other page keeps the deployment's no-CSP/no-frame-rule default.++ ...(url === '/surf/embed'++ ? { 'content-security-policy': "frame-ancestors https://openvibe.surf 'self'" }++ : {}),})createReadStream(filePath).on('error', (err) => {nnew file mode 100644@@@ -0,0 +1,185 @@++import type { IncomingMessage, ServerResponse } from 'node:http'++import { SURF_MAPS, type SurfMapInfo } from '@openvibe/content'++import type { Db } from 'openvibe-sdk/db'++import { isGuestToken } from '../platform/accounts.js'++import { PgSurfRecords } from '../platform/surfRuns.js'++import { resolveNetworkUser } from './networkAuth.js'++import { hashGuestKey } from './guestIdentity.js'++++/**++ * Public surf reads. Mounted through the same `platform.handle` seam as the++ * other public APIs (main.ts), before the static/SPA fallback:++ *++ * GET /api/surf/maps++ * GET /api/surf/maps/:id++ * GET /api/surf/maps/:id/ghost?of=wr|pb++ *++ * Maps and the WR ghost are briefly public-cached; a PB ghost is private.++ * `pb` needs the caller's identity: a Network bearer or a guest token (the++ * same identity the WebSocket upgrade uses, never a query parameter).++ */++export interface SurfRoutesOptions {++ db: Db++ networkAuthUrl: string | null++ /** Optional Play board read for the map page; local runs are the fallback. */++ playBoard?: (mapId: string) => Promise<{++ entries: { rank: number; name: string; score: number }[]++ } | null>++ log?: (message: string) => void++}++++const JSON_CACHE = 'public, max-age=15'++const GHOST_CACHE = 'public, max-age=60'++++function send(res: ServerResponse, status: number, body: unknown, cache: string): void {++ res.writeHead(status, {++ 'content-type': 'application/json; charset=utf-8',++ 'cache-control': cache,++ })++ res.end(JSON.stringify(body))++}++++function noStore(res: ServerResponse, status: number, body: unknown): void {++ send(res, status, body, 'no-store')++}++++function mapInfo(id: string): SurfMapInfo | undefined {++ return SURF_MAPS.find((m) => m.id === id)++}++++export function handleSurfRead(++ req: IncomingMessage,++ res: ServerResponse,++ options: SurfRoutesOptions,++): boolean {++ if (req.method !== 'GET') return false++ const url = new URL(req.url ?? '/', 'http://games.local')++ if (!url.pathname.startsWith('/api/surf/')) return false++ const parts = url.pathname.split('/').filter(Boolean) // api surf maps <id> ghost++ if (parts[0] !== 'api' || parts[1] !== 'surf' || parts[2] !== 'maps') return false++ const records = new PgSurfRecords(options.db, null, options.log ?? (() => {}))++ const id = parts[3]++ const ghost = parts[4]++++ if (!id) {++ if (parts.length !== 3) return false++ void records++ .maps()++ .then((maps) =>++ send(++ res,++ 200,++ {++ maps: maps.map((m) => ({++ id: m.id,++ name: m.name,++ tier: m.tier,++ stages: m.stages,++ author: m.author,++ records: m.records,++ wrMs: m.wrMs,++ wrHolder: m.wrHolder,++ })),++ },++ JSON_CACHE,++ ),++ )++ .catch(() => noStore(res, 503, { error: 'surf_unavailable' }))++ return true++ }++++ const info = mapInfo(id)++ if (!info) {++ noStore(res, 404, { error: 'unknown_map' })++ return true++ }++++ if (ghost) {++ if (ghost !== 'ghost' || parts.length !== 5) return false++ const of = url.searchParams.get('of') ?? 'wr'++ if (of !== 'wr' && of !== 'pb') {++ noStore(res, 400, { error: 'invalid_query' })++ return true++ }++ void (async () => {++ let key: string | undefined++ if (of === 'pb') {++ const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1]++ const guest = req.headers['x-guest-token']++ if (bearer && options.networkAuthUrl) {++ const user = await resolveNetworkUser(options.networkAuthUrl, bearer)++ key = user?.subjectId ?? undefined++ } else if (typeof guest === 'string' && isGuestToken(guest)) {++ key = `guest:${hashGuestKey(guest)}`++ }++ if (!key) {++ noStore(res, 401, { error: 'unauthorized' })++ return++ }++ }++ const bytes = await records.ghostBytes(id, of, key)++ if (!bytes) {++ noStore(res, 404, { error: 'no_ghost' })++ return++ }++ res.writeHead(200, {++ 'content-type': 'application/octet-stream',++ 'content-encoding': 'gzip',++ 'cache-control': of === 'wr' ? GHOST_CACHE : 'private, no-store',++ 'content-length': String(bytes.length),++ })++ res.end(bytes)++ })().catch(() => {++ if (!res.headersSent) noStore(res, 503, { error: 'surf_unavailable' })++ })++ return true++ }++++ if (parts.length !== 4) return false++ void (async () => {++ const maps = await records.maps()++ const summary = maps.find((m) => m.id === id)!++ const local = await records.topRuns(id, 20)++ let entries: { rank: number; name: string; timeMs: number; runId: string | null }[] = local.map((r, i) => ({++ rank: i + 1,++ name: r.name,++ timeMs: r.timeMs,++ runId: r.id,++ }))++ // Prefer Play's board when the platform is reachable; local runs are the++ // honest fallback when it is not.++ if (options.playBoard) {++ try {++ const board = await options.playBoard(id)++ if (board?.entries.length)++ entries = board.entries.map((e) => ({++ rank: e.rank,++ name: e.name,++ timeMs: e.score,++ runId: null,++ }))++ } catch {++ // keep local++ }++ }++ send(++ res,++ 200,++ {++ map: {++ id: summary.id,++ name: summary.name,++ tier: summary.tier,++ stages: summary.stages,++ author: summary.author,++ records: summary.records,++ wrMs: summary.wrMs,++ wrHolder: summary.wrHolder,++ },++ top: entries,++ },++ JSON_CACHE,++ )++ })().catch(() => noStore(res, 503, { error: 'surf_unavailable' }))++ return true++}@@@ -38,4 +38,15 @@ describe('ServerMetrics room gauges', () => {expect(m.mapZones).toBe(1)expect(m.roomCount).toBe(1)})++ it("answers one place's gauges, not the other places' (the e2e reads its own place through /metrics?place=)", () => {++ const m = new ServerMetrics()++ m.sampleRoom('scraplandia:0', gauges({ mapStatics: 0, entities: 3 }), 1_000)++ m.sampleRoom('surf-lagoon:0', gauges({ mapStatics: 47, entities: 1 }), 1_000)++ expect(m.mapStatics).toBe(47)++ const one = m.placeSnapshot('scraplandia')++ expect(one['mapStatics']).toBe(0)++ expect(one['entities']).toBe(3)++ expect(one['rooms']).toBe(1)++ expect(m.placeSnapshot('nowhere')['rooms']).toBe(0)++ })})@@@ -91,6 +91,21 @@ export class ServerMetrics {for (const k of ROOM_GAUGES) this[k] = totals[k]}++ /**++ * One place's view: the snapshot with the world gauges summed over that place's rooms only (`/metrics?place=`).++ * The process also hosts other places (the second built-in place, one per surf map), whose maps add to the totals.++ */++ placeSnapshot(place: string): Record<string, number> {++ const totals = Object.fromEntries(ROOM_GAUGES.map((k) => [k, 0])) as Record<keyof RoomGauges, number>++ let rooms = 0++ for (const [key, r] of this.rooms) {++ if (!key.startsWith(`${place}:`)) continue++ rooms++++ for (const k of ROOM_GAUGES) totals[k] += r.gauges[k]++ }++ return { ...this.snapshot(), ...totals, rooms }++ }++/** How many rooms reported recently. */get roomCount(): number {return this.rooms.sizennew file mode 100644@@@ -0,0 +1,176 @@++/* eslint-disable @typescript-eslint/no-explicit-any */++import { gunzipSync } from 'node:zlib'++import { describe, expect, it } from 'vitest'++import { openTestDb } from '@openvibe/persistence/testing'++import { decodeSurfTrack, type SurfTrackSample } from '@openvibe/gameplay'++import { PgSurfRecords, createSurfPlay, surfBoardId, surfPlayKey } from './surfRuns.js'++import type { SurfRunRecord } from '../game/surfRecords.js'++++/** A made-up person (Network subject). */++const PERSON = 'usr_01ARZ3NDEKTSV4RRFFQ69G5FAV'++++const MAP = 'surf_lagoon'++++function track(n = 8): SurfTrackSample[] {++ return Array.from({ length: n }, (_, i) => ({++ tick: 100 + i,++ x: i * 0.5,++ y: 5 - i * 0.1,++ z: -i * 0.25,++ yaw: (i / n) * Math.PI,++ }))++}++++function run(partial: Partial<SurfRunRecord> = {}): SurfRunRecord {++ return {++ id: crypto.randomUUID(),++ mapId: MAP,++ accountKey: PERSON,++ playerName: 'Tester',++ timeMs: 12_345,++ splits: [4_000, 8_000, 12_345],++ track: track(),++ tickRate: 30,++ startedTick: 100,++ finishedTick: 500,++ startFrac: 0.25,++ finishFrac: 0.75,++ fails: 0,++ createdAt: Date.now(),++ ...partial,++ }++}++++function fakePlay() {++ const defined: string[] = []++ const posted: { board: string; time: number; ref: string }[] = []++ return {++ defined,++ posted,++ poster: {++ async define() {++ defined.push('surf')++ },++ async post(r: SurfRunRecord) {++ posted.push({ board: surfBoardId(r.mapId), time: r.timeMs, ref: r.id })++ return { rank: null }++ },++ },++ }++}++++describe('surf runs persistence', () => {++ it('keeps the PB and the last five runs, derives the WR, and round-trips the ghost', async () => {++ const db = await openTestDb()++ const records = new PgSurfRecords(db)++ const key = PERSON++ const times = [20_000, 18_000, 16_000, 15_000, 14_000, 13_000, 12_000]++ for (const [i, timeMs] of times.entries())++ await records.record(run({ timeMs, createdAt: 1_000 + i, splits: [timeMs] }))++ const leaderboard = await records.topRuns(MAP, 20)++ expect(leaderboard[0]!.timeMs).toBe(12_000)++ // One PB + five non-PB runs: the two slowest are gone.++ const rows = await db.many<{ n: number | string }>(++ `SELECT COUNT(*) AS n FROM surf_runs WHERE map_id = $1 AND account_key = $2`,++ [MAP, key],++ )++ expect(Number(rows[0]!.n)).toBe(6)++ const pbs = await db.many(++ `SELECT time_ms FROM surf_runs WHERE map_id = $1 AND account_key = $2 AND is_pb`,++ [MAP, key],++ )++ expect(pbs).toHaveLength(1)++ expect((pbs[0] as { time_ms: number }).time_ms).toBe(12_000)++ expect(await records.pbMs(MAP, key)).toBe(12_000)++ expect(await records.wrMs(MAP)).toBe(12_000)++ expect((await records.pbRun(MAP, key))?.splits).toEqual([12_000])++ const summary = (await records.maps()).find((m) => m.id === MAP)!++ expect(summary.records).toBe(6)++ expect(summary.wrMs).toBe(12_000)++ expect(summary.wrHolder).toBe('Tester')++++ const ghost = await records.ghostBytes(MAP, 'wr')++ expect(ghost).not.toBeNull()++ const decoded = decodeSurfTrack(new Uint8Array(gunzipSync(ghost!)))++ expect(decoded.samples[0]!.x).toBeCloseTo(0)++ expect(decoded.samples[7]!.z).toBeCloseTo(-1.75)++ expect((await records.ghostBytes(MAP, 'pb', key))).not.toBeNull()++ expect(await records.ghostBytes(MAP, 'pb', 'guest:unknown')).toBeNull()++ })++++ it('play: defines and posts the `time` board with the run ms', async () => {++ const db = await openTestDb()++ const play = fakePlay()++ const records = new PgSurfRecords(db, play.poster)++ const outcome = await records.record(run({ id: 'run-1', timeMs: 9_876 }))++ expect(outcome.pb).toBe(true)++ expect(play.defined).toEqual(['surf'])++ expect(play.posted).toEqual([{ board: 'lagoon', time: 9_876, ref: 'run-1' }])++ expect(surfPlayKey('surf_lagoon')).toBe('surf:lagoon')++ })++++ it('an unknown-kind Play answer leaves the run saved and reports no rank', async () => {++ const db = await openTestDb()++ const warnings: string[] = []++ const records = new PgSurfRecords(++ db,++ {++ async define() {},++ async post() {++ throw new Error('Play POST /titles/surf/scores answered 400: {"code":"play.bad_definitions","message":"boards[0].kind is best or sum"}')++ },++ },++ (m) => warnings.push(m),++ )++ const outcome = await records.record(run({ timeMs: 7_000 }))++ expect(outcome.pb).toBe(true)++ expect(outcome.rank).toBeNull()++ const rows = await db.many(`SELECT time_ms FROM surf_runs WHERE map_id = $1`, [MAP])++ expect(rows).toHaveLength(1)++ expect(warnings).toHaveLength(1)++ // The disabled publisher is not asked again, and the run still saves.++ await records.record(run({ timeMs: 8_000 }))++ expect(warnings).toHaveLength(1)++ })++++ it('defines the surf time boards and posts milliseconds through Play', async () => {++ const calls: { method: string; path: string; body: any }[] = []++ const poster = createSurfPlay({++ url: 'https://play.test',++ tokens: { getToken: async () => 'token' },++ fetchImpl: (async (url: string, init?: RequestInit) => {++ calls.push({++ method: String(init?.method),++ path: new URL(url).pathname,++ body: init?.body ? JSON.parse(String(init.body)) : null,++ })++ return new Response('{"recorded":1,"rank":2}', {++ status: 200,++ headers: { 'content-type': 'application/json' },++ })++ }) as typeof fetch,++ })!++ await poster.define()++ expect(calls[0]!.path).toBe('/api/v1/titles/surf')++ expect(calls[0]!.body.boards).toEqual(++ expect.arrayContaining([++ expect.objectContaining({ id: 'lagoon', kind: 'time' }),++ expect.objectContaining({ id: 'canyon', kind: 'time' }),++ expect.objectContaining({ id: 'orbit', kind: 'time' }),++ ]),++ )++ const result = await poster.post(run({ id: 'run-9', timeMs: 4_321 }))++ expect(calls[1]!.path).toBe('/api/v1/titles/surf/scores')++ expect(calls[1]!.body).toMatchObject({++ board: 'lagoon',++ ref: 'run-9',++ entries: [{ score: 4_321 }],++ })++ expect(result.rank).toBe(2)++ })++++ it('builds a Play poster only when URL and tokens exist', () => {++ expect(createSurfPlay({ url: null, tokens: null })).toBeNull()++ expect(createSurfPlay({ url: 'https://play.openvibe.games', tokens: null })).toBeNull()++ })++})nnew file mode 100644@@@ -0,0 +1,330 @@++import { gzipSync } from 'node:zlib'++import { sql, type Db } from 'openvibe-sdk/db'++import { SURF_MAPS, type SurfMapInfo } from '@openvibe/content'++import { decodeSurfTrack, encodeSurfTrack, type DecodedGhost } from '@openvibe/gameplay'++import { accountKey } from '@openvibe/persistence'++import type { SurfRecordOutcome, SurfRecords, SurfRunRecord } from '../game/surfRecords.js'++import { toPlayer } from './playSync.js'++++/**++ * Games' own surf persistence (migration 0018) and the Play `time` board.++ *++ * Retention is part of the contract: one PB plus the last five runs per++ * player per map. The world record is derived, never stored twice, so it++ * cannot drift from the runs that produced it. A run is written locally++ * before Play is asked; Play being down, or not knowing the `time` kind yet,++ * can never lose or fail a run.++ */++export interface SurfMapSummary {++ id: string++ name: string++ tier: number++ stages: number++ author: string++ records: number++ wrMs: number | null++ wrHolder: string | null++}++++export interface SurfRunRow {++ id: string++ name: string++ timeMs: number++ createdAt: number++ isPb: boolean++}++++export interface SurfPlayPoster {++ /** Register the surf title/boards; safe to call repeatedly. */++ define(): Promise<void>++ /** Post one run's ms. Returns the rank when Play reports one. */++ post(run: SurfRunRecord): Promise<{ rank: number | null }>++}++++/** The Play board id for a map: `surf` title, `<slug>` board (`surf:lagoon`). */++export function surfBoardId(mapId: string): string {++ return mapId.replace(/^surf_/, '').replace(/[^a-z0-9_-]/g, '')++}++++export function surfPlayKey(mapId: string): string {++ return `surf:${surfBoardId(mapId)}`++}++++function trackBytes(run: SurfRunRecord): Buffer {++ const raw = encodeSurfTrack(run.track, run.tickRate, run.startedTick)++ return gzipSync(Buffer.from(raw))++}++++function toBuffer(value: unknown): Buffer {++ if (Buffer.isBuffer(value)) return value++ if (value instanceof Uint8Array) return Buffer.from(value)++ if (typeof value === 'string') return Buffer.from(value, 'base64')++ throw new Error('surf track is not bytes')++}++++function splitArray(value: unknown): number[] {++ if (Array.isArray(value)) return value.map(Number)++ if (typeof value === 'string') {++ try {++ const parsed: unknown = JSON.parse(value)++ return Array.isArray(parsed) ? parsed.map(Number) : []++ } catch {++ return []++ }++ }++ return []++}++++export function ghostOfRun(row: { track: unknown }): DecodedGhost {++ return decodeSurfTrack(new Uint8Array(toBuffer(row.track)))++}++++export class PgSurfRecords implements SurfRecords {++ private playWarned = false++ private playDisabled = false++++ constructor(++ private readonly db: Db,++ private readonly play: SurfPlayPoster | null = null,++ private readonly warn: (message: string) => void = () => {},++ ) {}++++ async pbMs(mapId: string, key: string): Promise<number | null> {++ const row = await this.db.maybe<{ time_ms: number | string }>(sql`++ SELECT time_ms FROM surf_runs++ WHERE map_id = ${mapId} AND account_key = ${accountKey(key)} AND is_pb++ LIMIT 1++ `)++ return row ? Number(row.time_ms) : null++ }++++ async pbRun(++ mapId: string,++ key: string,++ ): Promise<{ timeMs: number; splits: number[] } | null> {++ const row = await this.db.maybe<{ time_ms: number | string; splits: unknown }>(sql`++ SELECT time_ms, splits FROM surf_runs++ WHERE map_id = ${mapId} AND account_key = ${accountKey(key)} AND is_pb++ LIMIT 1++ `)++ if (!row) return null++ return { timeMs: Number(row.time_ms), splits: splitArray(row.splits) }++ }++++ async wrMs(mapId: string): Promise<number | null> {++ const row = await this.db.maybe<{ time_ms: number | string | null }>(sql`++ SELECT MIN(time_ms) AS time_ms FROM surf_runs WHERE map_id = ${mapId}++ `)++ return row?.time_ms === null || row?.time_ms === undefined ? null : Number(row.time_ms)++ }++++ async record(run: SurfRunRecord): Promise<SurfRecordOutcome> {++ const key = accountKey(run.accountKey)++ const bytes = trackBytes(run)++ let pb = false++ let wr = false++ let pbMs: number | null = null++ let wrMs: number | null = null++ await this.db.tx(async () => {++ const previousPb = await this.db.maybe<{ time_ms: number | string }>(sql`++ SELECT time_ms FROM surf_runs++ WHERE map_id = ${run.mapId} AND account_key = ${key} AND is_pb++ LIMIT 1++ `)++ const prev = previousPb ? Number(previousPb.time_ms) : null++ pb = prev === null || run.timeMs < prev++ if (pb) {++ await this.db.exec(sql`++ UPDATE surf_runs SET is_pb = false++ WHERE map_id = ${run.mapId} AND account_key = ${key} AND is_pb++ `)++ }++ await this.db.exec(sql`++ INSERT INTO surf_runs++ (id, map_id, account_key, player_name, time_ms, splits, track, is_pb, created_at)++ VALUES++ (${run.id}, ${run.mapId}, ${key}, ${run.playerName}, ${run.timeMs},++ ${JSON.stringify(run.splits)}::jsonb, ${bytes}, ${pb}, ${run.createdAt})++ ON CONFLICT (id) DO NOTHING++ `)++ // One PB plus the last five non-PB runs. "At most" is enforced here,++ // not by a reader that might forget.++ await this.db.exec(sql`++ DELETE FROM surf_runs++ WHERE id IN (++ SELECT id FROM surf_runs++ WHERE map_id = ${run.mapId} AND account_key = ${key} AND NOT is_pb++ ORDER BY created_at DESC, id DESC++ OFFSET 5++ )++ `)++ const best = await this.db.maybe<{ time_ms: number | string | null }>(sql`++ SELECT MIN(time_ms) AS time_ms FROM surf_runs WHERE map_id = ${run.mapId}++ `)++ wrMs = best?.time_ms === null || best?.time_ms === undefined ? null : Number(best.time_ms)++ wr = wrMs === run.timeMs++ const mine = await this.db.maybe<{ time_ms: number | string }>(sql`++ SELECT time_ms FROM surf_runs++ WHERE map_id = ${run.mapId} AND account_key = ${key} AND is_pb LIMIT 1++ `)++ pbMs = mine ? Number(mine.time_ms) : null++ })++++ let rank: number | null = null++ if (this.play && !this.playDisabled && toPlayer(key)) {++ try {++ await this.play.define()++ rank = (await this.play.post(run)).rank++ } catch (err) {++ // Never fail a run because the platform is down or does not know++ // `time` yet. Keep the local row (already committed) and log once.++ if (!this.playWarned) {++ this.playWarned = true++ this.warn(`surf Play post skipped: ${String(err)}`)++ }++ if (/unknown|kind|400/i.test(String(err))) this.playDisabled = true++ }++ }++ return { pb, wr, rank, pbMs, wrMs }++ }++++ /** Every map with its record count and derived world record. */++ async maps(): Promise<SurfMapSummary[]> {++ const rows = await this.db.many<{++ map_id: string++ records: number | string++ wr_ms: number | string | null++ wr_holder: string | null++ }>(sql`++ SELECT r.map_id,++ COUNT(*)::int AS records,++ MIN(r.time_ms) AS wr_ms,++ (SELECT player_name FROM surf_runs w++ WHERE w.map_id = r.map_id++ ORDER BY w.time_ms ASC, w.created_at ASC LIMIT 1) AS wr_holder++ FROM surf_runs r GROUP BY r.map_id++ `)++ const byMap = new Map(rows.map((r) => [r.map_id, r]))++ return SURF_MAPS.map((m: SurfMapInfo) => {++ const row = byMap.get(m.id)++ return {++ id: m.id,++ name: m.name,++ tier: m.tier,++ stages: m.stages,++ author: m.author,++ records: Number(row?.records ?? 0),++ wrMs: row?.wr_ms === null || row?.wr_ms === undefined ? null : Number(row.wr_ms),++ wrHolder: row?.wr_holder ?? null,++ }++ })++ }++++ async topRuns(mapId: string, limit = 20): Promise<SurfRunRow[]> {++ const rows = await this.db.many<{++ id: string++ player_name: string++ time_ms: number | string++ created_at: number | string++ is_pb: boolean++ }>(sql`++ SELECT id, player_name, time_ms, created_at, is_pb++ FROM surf_runs WHERE map_id = ${mapId}++ ORDER BY time_ms ASC, created_at ASC++ LIMIT ${Math.max(1, Math.min(20, limit))}++ `)++ return rows.map((r) => ({++ id: r.id,++ name: r.player_name,++ timeMs: Number(r.time_ms),++ createdAt: Number(r.created_at),++ isPb: r.is_pb,++ }))++ }++++ async ghost(mapId: string, of: 'wr' | 'pb', key?: string): Promise<DecodedGhost | null> {++ const bytes = await this.ghostBytes(mapId, of, key)++ return bytes ? decodeSurfTrack(new Uint8Array(bytes)) : null++ }++++ /** The stored gzipped track, ready to serve with `content-encoding: gzip`. */++ async ghostBytes(mapId: string, of: 'wr' | 'pb', key?: string): Promise<Buffer | null> {++ const row =++ of === 'wr'++ ? await this.db.maybe<{ track: unknown }>(sql`++ SELECT track FROM surf_runs WHERE map_id = ${mapId}++ ORDER BY time_ms ASC, created_at ASC LIMIT 1++ `)++ : await this.db.maybe<{ track: unknown }>(sql`++ SELECT track FROM surf_runs++ WHERE map_id = ${mapId} AND account_key = ${accountKey(key ?? '')} AND is_pb++ LIMIT 1++ `)++ return row ? toBuffer(row.track) : null++ }++}++++const PLAY_TIMEOUT_MS = 4_000++++/** A minimal Play client for the surf time board (title `surf`, one board per map, kind `time`). */++export function createSurfPlay(opts: {++ url: string | null++ tokens: { getToken(ctx?: { audience?: string; scope?: string }): Promise<string> } | null++ fetchImpl?: typeof fetch++}): SurfPlayPoster | null {++ if (!opts.url || !opts.tokens) return null++ const doFetch = opts.fetchImpl ?? fetch++ const base = opts.url.replace(/\/+$/, '')++ let defined = false++ const call = async (method: string, path: string, body?: unknown): Promise<unknown> => {++ const token = await opts.tokens!.getToken({++ audience: 'openvibe.play',++ scope: 'play.title.manage play.result.write',++ })++ const res = await doFetch(`${base}/api/v1${path}`, {++ method,++ headers: {++ authorization: `Bearer ${token}`,++ 'content-type': 'application/json',++ accept: 'application/json',++ },++ ...(body === undefined ? {} : { body: JSON.stringify(body) }),++ signal: AbortSignal.timeout(PLAY_TIMEOUT_MS),++ })++ const text = await res.text()++ if (!res.ok)++ throw new Error(`Play ${method} ${path} answered ${res.status}: ${text.slice(0, 200)}`)++ return text ? JSON.parse(text) : null++ }++ return {++ async define() {++ if (defined) return++ await call('PUT', '/titles/surf', {++ name: 'OpenVibe.Surf',++ url: 'https://openvibe.surf',++ summary: 'Counter-Strike-style surf time trials on the OpenVibe.Games engine.',++ boards: SURF_MAPS.map((m) => ({++ id: surfBoardId(m.id),++ name: `${m.name} — time`,++ kind: 'time',++ })),++ })++ defined = true++ },++ async post(run) {++ const player = toPlayer(accountKey(run.accountKey))++ if (!player) return { rank: null }++ const body = await call('POST', '/titles/surf/scores', {++ board: surfBoardId(run.mapId),++ ref: run.id,++ at: run.createdAt,++ entries: [{ player, name: run.playerName.slice(0, 40) || 'Player', score: run.timeMs }],++ })++ const rank =++ body && typeof body === 'object' && 'rank' in body++ ? Number((body as { rank: unknown }).rank)++ : null++ return { rank: Number.isSafeInteger(rank) && (rank as number) > 0 ? (rank as number) : null }++ },++ }++}nnew file mode 100644@@@ -0,0 +1,452 @@++{++ "v": 2,++ "movement": "surf",++ "surf": {++ "title": "Surf Canyon",++ "author": "OpenVibe",++ "tier": 2,++ "stages": 4,++ "failY": 0.527++ },++ "spawn": [++ 0,++ 59,++ 3++ ],++ "spawnYaw": 0,++ "terrains": [],++ "statics": [++ {++ "id": "start-pad",++ "shape": {++ "type": "box",++ "size": [++ 17,++ 1,++ 12++ ]++ },++ "pos": [++ 0,++ 57.5,++ 6++ ],++ "color": "#285867"++ },++ {++ "id": "practice-ramp",++ "shape": {++ "type": "box",++ "size": [++ 11,++ 0.6,++ 8.0++ ]++ },++ "pos": [++ 0,++ 54.551,++ -4.049++ ],++ "color": "#4aa3b5",++ "rot": [++ 0.959931,++ 0,++ 0++ ]++ },++ {++ "id": "practice-return",++ "shape": {++ "type": "box",++ "size": [++ 11,++ 0.6,++ 6++ ]++ },++ "pos": [++ 0,++ 56.6,++ -8++ ],++ "color": "#3d8b9c"++ },++ {++ "id": "ramp-1",++ "shape": {++ "type": "box",++ "size": [++ 11,++ 0.6,++ 14++ ]++ },++ "pos": [++ 0,++ 52.094,++ 15.769++ ],++ "color": "#2f6f7e",++ "rot": [++ 0.959931,++ 0,++ 0++ ]++ },++ {++ "id": "landing-1",++ "shape": {++ "type": "box",++ "size": [++ 13,++ 1,++ 10++ ]++ },++ "pos": [++ 0,++ 46.032,++ 25.03++ ],++ "color": "#21505d"++ },++ {++ "id": "ramp-2",++ "shape": {++ "type": "box",++ "size": [++ 11,++ 0.6,++ 14++ ]++ },++ "pos": [++ 0,++ 39.426,++ 35.999++ ],++ "color": "#3d8b9c",++ "rot": [++ 0.959931,++ 0,++ 0++ ]++ },++ {++ "id": "landing-2",++ "shape": {++ "type": "box",++ "size": [++ 13,++ 1,++ 10++ ]++ },++ "pos": [++ 0,++ 33.364,++ 45.26++ ],++ "color": "#21505d"++ },++ {++ "id": "ramp-3",++ "shape": {++ "type": "box",++ "size": [++ 11,++ 0.6,++ 14++ ]++ },++ "pos": [++ 0,++ 26.758,++ 56.229++ ],++ "color": "#285867",++ "rot": [++ 0.959931,++ 0,++ 0++ ]++ },++ {++ "id": "landing-3",++ "shape": {++ "type": "box",++ "size": [++ 13,++ 1,++ 10++ ]++ },++ "pos": [++ 0,++ 20.696,++ 65.49++ ],++ "color": "#21505d"++ },++ {++ "id": "ramp-4",++ "shape": {++ "type": "box",++ "size": [++ 11,++ 0.6,++ 14++ ]++ },++ "pos": [++ 0,++ 14.089,++ 76.459++ ],++ "color": "#4aa3b5",++ "rot": [++ 0.959931,++ 0,++ 0++ ]++ },++ {++ "id": "landing-4",++ "shape": {++ "type": "box",++ "size": [++ 13,++ 1,++ 10++ ]++ },++ "pos": [++ 0,++ 8.027,++ 85.72++ ],++ "color": "#21505d"++ },++ {++ "id": "finish-gate-l",++ "shape": {++ "type": "box",++ "size": [++ 0.4,++ 5,++ 0.4++ ]++ },++ "pos": [++ -6.3,++ 10.527,++ 90.72++ ],++ "color": "#f4cb65"++ },++ {++ "id": "finish-gate-r",++ "shape": {++ "type": "box",++ "size": [++ 0.4,++ 5,++ 0.4++ ]++ },++ "pos": [++ 6.3,++ 10.527,++ 90.72++ ],++ "color": "#f4cb65"++ },++ {++ "id": "bonus-ramp",++ "shape": {++ "type": "box",++ "size": [++ 5,++ 0.6,++ 10++ ]++ },++ "pos": [++ 10.5,++ 10.027,++ 73.72++ ],++ "color": "#ef8d4a",++ "rot": [++ 1.012291,++ 0,++ 0++ ]++ },++ {++ "id": "bonus-pad",++ "shape": {++ "type": "box",++ "size": [++ 6,++ 1,++ 8++ ]++ },++ "pos": [++ 10.5,++ 5.827,++ 65.72++ ],++ "color": "#ef8d4a"++ }++ ],++ "nodes": [],++ "props": [],++ "npcs": [],++ "lights": [],++ "zones": [++ {++ "id": "start",++ "name": "Start",++ "min": [++ -7.5,++ 57,++ 0++ ],++ "max": [++ 7.5,++ 62,++ 8++ ],++ "rules": {++ "pvp": false,++ "build": false,++ "physgun": false++ },++ "surf": {++ "kind": "start",++ "stage": 0++ }++ },++ {++ "id": "cp-1",++ "name": "Checkpoint 1",++ "min": [++ -6.5,++ 45.532,++ 26.03++ ],++ "max": [++ 6.5,++ 50.532,++ 30.03++ ],++ "rules": {++ "pvp": false,++ "build": false,++ "physgun": false++ },++ "surf": {++ "kind": "checkpoint",++ "stage": 1,++ "spawn": [++ 0,++ 47.532,++ 28.03++ ],++ "yaw": 0++ }++ },++ {++ "id": "cp-2",++ "name": "Checkpoint 2",++ "min": [++ -6.5,++ 32.864,++ 46.26++ ],++ "max": [++ 6.5,++ 37.864,++ 50.26++ ],++ "rules": {++ "pvp": false,++ "build": false,++ "physgun": false++ },++ "surf": {++ "kind": "checkpoint",++ "stage": 2,++ "spawn": [++ 0,++ 34.864,++ 48.26++ ],++ "yaw": 0++ }++ },++ {++ "id": "cp-3",++ "name": "Checkpoint 3",++ "min": [++ -6.5,++ 20.196,++ 66.49++ ],++ "max": [++ 6.5,++ 25.196,++ 70.49++ ],++ "rules": {++ "pvp": false,++ "build": false,++ "physgun": false++ },++ "surf": {++ "kind": "checkpoint",++ "stage": 3,++ "spawn": [++ 0,++ 22.196,++ 68.49++ ],++ "yaw": 0++ }++ },++ {++ "id": "finish",++ "name": "Finish",++ "min": [++ -6.5,++ 7.527,++ 86.72++ ],++ "max": [++ 6.5,++ 12.527,++ 90.72++ ],++ "rules": {++ "pvp": false,++ "build": false,++ "physgun": false++ },++ "surf": {++ "kind": "finish",++ "stage": 4,++ "spawn": [++ 0,++ 9.527,++ 88.72++ ],++ "yaw": 0++ }++ }++ ],++ "models": [],++ "textures": []++}nnew file mode 100644@@@ -0,0 +1,345 @@++{++ "v": 2,++ "movement": "surf",++ "surf": {++ "title": "Surf Lagoon",++ "author": "OpenVibe",++ "tier": 1,++ "stages": 3,++ "failY": -7.096++ },++ "spawn": [++ 0,++ 35,++ 3++ ],++ "spawnYaw": 0,++ "terrains": [],++ "statics": [++ {++ "id": "start-pad",++ "shape": {++ "type": "box",++ "size": [++ 24,++ 1,++ 12++ ]++ },++ "pos": [++ 0,++ 33.5,++ 6++ ],++ "color": "#285867"++ },++ {++ "id": "practice-ramp",++ "shape": {++ "type": "box",++ "size": [++ 18,++ 0.6,++ 8.0++ ]++ },++ "pos": [++ 0,++ 30.663,++ -4.226++ ],++ "color": "#4aa3b5",++ "rot": [++ 0.907571,++ 0,++ 0++ ]++ },++ {++ "id": "practice-return",++ "shape": {++ "type": "box",++ "size": [++ 18,++ 0.6,++ 6++ ]++ },++ "pos": [++ 0,++ 32.6,++ -8++ ],++ "color": "#3d8b9c"++ },++ {++ "id": "ramp-1",++ "shape": {++ "type": "box",++ "size": [++ 18,++ 0.6,++ 14++ ]++ },++ "pos": [++ 0,++ 28.299,++ 16.073++ ],++ "color": "#2f6f7e",++ "rot": [++ 0.907571,++ 0,++ 0++ ]++ },++ {++ "id": "landing-1",++ "shape": {++ "type": "box",++ "size": [++ 20,++ 1,++ 12++ ]++ },++ "pos": [++ 0,++ 22.468,++ 26.619++ ],++ "color": "#21505d"++ },++ {++ "id": "ramp-2",++ "shape": {++ "type": "box",++ "size": [++ 18,++ 0.6,++ 14++ ]++ },++ "pos": [++ 0,++ 17.267,++ 36.692++ ],++ "color": "#3d8b9c",++ "rot": [++ 0.907571,++ 0,++ 0++ ]++ },++ {++ "id": "landing-2",++ "shape": {++ "type": "box",++ "size": [++ 20,++ 1,++ 12++ ]++ },++ "pos": [++ 0,++ 11.436,++ 47.239++ ],++ "color": "#21505d"++ },++ {++ "id": "ramp-3",++ "shape": {++ "type": "box",++ "size": [++ 18,++ 0.6,++ 14++ ]++ },++ "pos": [++ 0,++ 6.235,++ 57.312++ ],++ "color": "#285867",++ "rot": [++ 0.907571,++ 0,++ 0++ ]++ },++ {++ "id": "landing-3",++ "shape": {++ "type": "box",++ "size": [++ 20,++ 1,++ 12++ ]++ },++ "pos": [++ 0,++ 0.404,++ 67.858++ ],++ "color": "#21505d"++ },++ {++ "id": "finish-gate-l",++ "shape": {++ "type": "box",++ "size": [++ 0.4,++ 5,++ 0.4++ ]++ },++ "pos": [++ -9.8,++ 2.904,++ 73.858++ ],++ "color": "#f4cb65"++ },++ {++ "id": "finish-gate-r",++ "shape": {++ "type": "box",++ "size": [++ 0.4,++ 5,++ 0.4++ ]++ },++ "pos": [++ 9.8,++ 2.904,++ 73.858++ ],++ "color": "#f4cb65"++ }++ ],++ "nodes": [],++ "props": [],++ "npcs": [],++ "lights": [],++ "zones": [++ {++ "id": "start",++ "name": "Start",++ "min": [++ -11.0,++ 33,++ 0++ ],++ "max": [++ 11.0,++ 38,++ 8++ ],++ "rules": {++ "pvp": false,++ "build": false,++ "physgun": false++ },++ "surf": {++ "kind": "start",++ "stage": 0++ }++ },++ {++ "id": "cp-1",++ "name": "Checkpoint 1",++ "min": [++ -10.0,++ 21.968,++ 28.619++ ],++ "max": [++ 10.0,++ 26.968,++ 32.619++ ],++ "rules": {++ "pvp": false,++ "build": false,++ "physgun": false++ },++ "surf": {++ "kind": "checkpoint",++ "stage": 1,++ "spawn": [++ 0,++ 23.968,++ 30.619++ ],++ "yaw": 0++ }++ },++ {++ "id": "cp-2",++ "name": "Checkpoint 2",++ "min": [++ -10.0,++ 10.936,++ 49.239++ ],++ "max": [++ 10.0,++ 15.936,++ 53.239++ ],++ "rules": {++ "pvp": false,++ "build": false,++ "physgun": false++ },++ "surf": {++ "kind": "checkpoint",++ "stage": 2,++ "spawn": [++ 0,++ 12.936,++ 51.239++ ],++ "yaw": 0++ }++ },++ {++ "id": "finish",++ "name": "Finish",++ "min": [++ -10.0,++ -0.096,++ 69.858++ ],++ "max": [++ 10.0,++ 4.904,++ 73.858++ ],++ "rules": {++ "pvp": false,++ "build": false,++ "physgun": false++ },++ "surf": {++ "kind": "finish",++ "stage": 3,++ "spawn": [++ 0,++ 1.904,++ 71.858++ ],++ "yaw": 0++ }++ }++ ],++ "models": [],++ "textures": []++}nnew file mode 100644@@@ -0,0 +1,588 @@++{++ "v": 2,++ "movement": "surf",++ "surf": {++ "title": "Surf Orbit",++ "author": "OpenVibe",++ "tier": 4,++ "stages": 6,++ "failY": 3.852++ },++ "spawn": [++ 0,++ 79,++ 3++ ],++ "spawnYaw": 0,++ "terrains": [],++ "statics": [++ {++ "id": "start-pad",++ "shape": {++ "type": "box",++ "size": [++ 12.5,++ 1,++ 12++ ]++ },++ "pos": [++ 0,++ 77.5,++ 6++ ],++ "color": "#285867"++ },++ {++ "id": "practice-ramp",++ "shape": {++ "type": "box",++ "size": [++ 6.5,++ 0.6,++ 8.0++ ]++ },++ "pos": [++ 0,++ 74.449,++ -3.865++ ],++ "color": "#4aa3b5",++ "rot": [++ 1.012291,++ 0,++ 0++ ]++ },++ {++ "id": "practice-return",++ "shape": {++ "type": "box",++ "size": [++ 6.5,++ 0.6,++ 6++ ]++ },++ "pos": [++ 0,++ 76.6,++ -8++ ],++ "color": "#3d8b9c"++ },++ {++ "id": "ramp-1",++ "shape": {++ "type": "box",++ "size": [++ 6.5,++ 0.6,++ 13++ ]++ },++ "pos": [++ 0,++ 72.329,++ 15.19++ ],++ "color": "#2f6f7e",++ "rot": [++ 1.012291,++ 0,++ 0++ ]++ },++ {++ "id": "landing-1",++ "shape": {++ "type": "box",++ "size": [++ 8.5,++ 1,++ 9++ ]++ },++ "pos": [++ 0,++ 66.475,++ 23.389++ ],++ "color": "#21505d"++ },++ {++ "id": "ramp-2",++ "shape": {++ "type": "box",++ "size": [++ 6.5,++ 0.6,++ 13++ ]++ },++ "pos": [++ 0,++ 61.304,++ 31.079++ ],++ "color": "#3d8b9c",++ "rot": [++ 1.012291,++ 0,++ 0++ ]++ },++ {++ "id": "landing-2",++ "shape": {++ "type": "box",++ "size": [++ 8.5,++ 1,++ 9++ ]++ },++ "pos": [++ 0,++ 55.451,++ 39.278++ ],++ "color": "#21505d"++ },++ {++ "id": "ramp-3",++ "shape": {++ "type": "box",++ "size": [++ 6.5,++ 0.6,++ 13++ ]++ },++ "pos": [++ 0,++ 50.279,++ 46.968++ ],++ "color": "#285867",++ "rot": [++ 1.012291,++ 0,++ 0++ ]++ },++ {++ "id": "landing-3",++ "shape": {++ "type": "box",++ "size": [++ 8.5,++ 1,++ 9++ ]++ },++ "pos": [++ 0,++ 44.426,++ 55.167++ ],++ "color": "#21505d"++ },++ {++ "id": "ramp-4",++ "shape": {++ "type": "box",++ "size": [++ 6.5,++ 0.6,++ 13++ ]++ },++ "pos": [++ 0,++ 39.255,++ 62.857++ ],++ "color": "#4aa3b5",++ "rot": [++ 1.012291,++ 0,++ 0++ ]++ },++ {++ "id": "landing-4",++ "shape": {++ "type": "box",++ "size": [++ 8.5,++ 1,++ 9++ ]++ },++ "pos": [++ 0,++ 33.401,++ 71.056++ ],++ "color": "#21505d"++ },++ {++ "id": "ramp-5",++ "shape": {++ "type": "box",++ "size": [++ 6.5,++ 0.6,++ 13++ ]++ },++ "pos": [++ 0,++ 28.23,++ 78.746++ ],++ "color": "#21505d",++ "rot": [++ 1.012291,++ 0,++ 0++ ]++ },++ {++ "id": "landing-5",++ "shape": {++ "type": "box",++ "size": [++ 8.5,++ 1,++ 9++ ]++ },++ "pos": [++ 0,++ 22.377,++ 86.945++ ],++ "color": "#21505d"++ },++ {++ "id": "ramp-6",++ "shape": {++ "type": "box",++ "size": [++ 6.5,++ 0.6,++ 13++ ]++ },++ "pos": [++ 0,++ 17.206,++ 94.635++ ],++ "color": "#5a7f8c",++ "rot": [++ 1.012291,++ 0,++ 0++ ]++ },++ {++ "id": "landing-6",++ "shape": {++ "type": "box",++ "size": [++ 8.5,++ 1,++ 9++ ]++ },++ "pos": [++ 0,++ 11.352,++ 102.834++ ],++ "color": "#21505d"++ },++ {++ "id": "finish-gate-l",++ "shape": {++ "type": "box",++ "size": [++ 0.4,++ 5,++ 0.4++ ]++ },++ "pos": [++ -4.05,++ 13.852,++ 107.334++ ],++ "color": "#f4cb65"++ },++ {++ "id": "finish-gate-r",++ "shape": {++ "type": "box",++ "size": [++ 0.4,++ 5,++ 0.4++ ]++ },++ "pos": [++ 4.05,++ 13.852,++ 107.334++ ],++ "color": "#f4cb65"++ },++ {++ "id": "transfer",++ "shape": {++ "type": "box",++ "size": [++ 10,++ 0.6,++ 9++ ]++ },++ "pos": [++ -7.25,++ 13.052,++ 94.334++ ],++ "color": "#8e9ce9",++ "rot": [++ 0.872665,++ 0,++ 0.314159++ ]++ },++ {++ "id": "transfer-pad",++ "shape": {++ "type": "box",++ "size": [++ 8,++ 1,++ 8++ ]++ },++ "pos": [++ -12.25,++ 8.652,++ 87.334++ ],++ "color": "#8e9ce9"++ }++ ],++ "nodes": [],++ "props": [],++ "npcs": [],++ "lights": [],++ "zones": [++ {++ "id": "start",++ "name": "Start",++ "min": [++ -5.25,++ 77,++ 0++ ],++ "max": [++ 5.25,++ 82,++ 8++ ],++ "rules": {++ "pvp": false,++ "build": false,++ "physgun": false++ },++ "surf": {++ "kind": "start",++ "stage": 0++ }++ },++ {++ "id": "cp-1",++ "name": "Checkpoint 1",++ "min": [++ -4.25,++ 65.975,++ 23.889++ ],++ "max": [++ 4.25,++ 70.975,++ 27.889++ ],++ "rules": {++ "pvp": false,++ "build": false,++ "physgun": false++ },++ "surf": {++ "kind": "checkpoint",++ "stage": 1,++ "spawn": [++ 0,++ 67.975,++ 25.889++ ],++ "yaw": 0++ }++ },++ {++ "id": "cp-2",++ "name": "Checkpoint 2",++ "min": [++ -4.25,++ 54.951,++ 39.778++ ],++ "max": [++ 4.25,++ 59.951,++ 43.778++ ],++ "rules": {++ "pvp": false,++ "build": false,++ "physgun": false++ },++ "surf": {++ "kind": "checkpoint",++ "stage": 2,++ "spawn": [++ 0,++ 56.951,++ 41.778++ ],++ "yaw": 0++ }++ },++ {++ "id": "cp-3",++ "name": "Checkpoint 3",++ "min": [++ -4.25,++ 43.926,++ 55.667++ ],++ "max": [++ 4.25,++ 48.926,++ 59.667++ ],++ "rules": {++ "pvp": false,++ "build": false,++ "physgun": false++ },++ "surf": {++ "kind": "checkpoint",++ "stage": 3,++ "spawn": [++ 0,++ 45.926,++ 57.667++ ],++ "yaw": 0++ }++ },++ {++ "id": "cp-4",++ "name": "Checkpoint 4",++ "min": [++ -4.25,++ 32.901,++ 71.556++ ],++ "max": [++ 4.25,++ 37.901,++ 75.556++ ],++ "rules": {++ "pvp": false,++ "build": false,++ "physgun": false++ },++ "surf": {++ "kind": "checkpoint",++ "stage": 4,++ "spawn": [++ 0,++ 34.901,++ 73.556++ ],++ "yaw": 0++ }++ },++ {++ "id": "cp-5",++ "name": "Checkpoint 5",++ "min": [++ -4.25,++ 21.877,++ 87.445++ ],++ "max": [++ 4.25,++ 26.877,++ 91.445++ ],++ "rules": {++ "pvp": false,++ "build": false,++ "physgun": false++ },++ "surf": {++ "kind": "checkpoint",++ "stage": 5,++ "spawn": [++ 0,++ 23.877,++ 89.445++ ],++ "yaw": 0++ }++ },++ {++ "id": "finish",++ "name": "Finish",++ "min": [++ -4.25,++ 10.852,++ 103.334++ ],++ "max": [++ 4.25,++ 15.852,++ 107.334++ ],++ "rules": {++ "pvp": false,++ "build": false,++ "physgun": false++ },++ "surf": {++ "kind": "finish",++ "stage": 6,++ "spawn": [++ 0,++ 12.852,++ 105.334++ ],++ "yaw": 0++ }++ }++ ],++ "models": [],++ "textures": []++}@@@ -24,6 +24,7 @@ export * from './defs/resources.js'export * from './packs/scraplandia.js'export * from './packs/creatorWorld.js'export * from './packs/creatorAsset.js'++export * from './packs/surf.js'import { ContentRegistry, type ContentDefs } from './registry.js'import { mergePackDefs, type ContentPackV2 } from './pack.js'@@@ -18,7 +18,11 @@ import {MapLightSchema,StaticBodySchema,ZoneDefSchema,++ MovementStyleSchema,++ SurfMapMetaSchema,type FaceStyle,++ type MovementStyle,++ type SurfMapMeta,type ZoneDef,} from './schema/world.js'import { decodeHeights, encodeHeights, type MapLight } from './mapFile.js'@@@ -182,6 +186,10 @@ export const MapFileV2Schema = z.object({).max(512).default([]),++ /** Movement preset for this map (resolved on both sides; the world default if absent). */++ movement: MovementStyleSchema.optional(),++ /** Surf run metadata (kind: time trial): fail plane, stage count, author and tier. */++ surf: SurfMapMetaSchema.optional(),spawn: finiteVec3.optional(),spawnYaw: z.number().optional(),})@@@ -195,6 +203,8 @@ export type MapNodeV2 = MapFileV2['nodes'][number]export type MapPropV2 = MapFileV2['props'][number]export type MapNpcV2 = MapFileV2['npcs'][number]export type MapModelV2 = MapFileV2['models'][number]++export type MapMovementStyle = MovementStyle++export type MapSurfMeta = SurfMapMeta/** The synthetic document id for the spawn point (stored top-level on the wire). */export const SPAWN_OBJECT_ID = 'spawn'@@@ -356,6 +366,40 @@ export function validateMapFile(map: MapFileV2): string[] {for (const l of map.lights) claim(l.id, 'light')for (const z0 of map.zones) claim(z0.id, 'zone')if (seen.has(SPAWN_OBJECT_ID)) issues.push(`"${SPAWN_OBJECT_ID}" is reserved for the spawn point`)++ if (map.surf) issues.push(...surfIssues(map))++ return issues++}++++/** A surf map's zones must describe exactly one ordered run. */++function surfIssues(map: MapFileV2): string[] {++ const issues: string[] = []++ const surf = map.surf++ if (!surf) return issues++ const zones = map.zones.filter((z) => z.surf)++ const starts = zones.filter((z) => z.surf!.kind === 'start')++ const finishes = zones.filter((z) => z.surf!.kind === 'finish')++ if (starts.length !== 1) issues.push(`surf: expected one start zone, found ${starts.length}`)++ if (finishes.length !== 1) issues.push(`surf: expected one finish zone, found ${finishes.length}`)++ if (map.movement !== 'surf') issues.push('surf: a surf map must set movement to "surf"')++ for (const zone of zones) {++ const meta = zone.surf!++ const maxStage = meta.kind === 'finish' ? surf.stages : surf.stages - 1++ if (meta.stage > maxStage)++ issues.push(`surf zone "${zone.id}": stage ${meta.stage} exceeds ${maxStage}`)++ if (meta.kind === 'checkpoint' && !meta.spawn)++ issues.push(`surf zone "${zone.id}": a checkpoint needs a respawn point`)++ }++ const checkpoints = zones.filter((z) => z.surf!.kind === 'checkpoint')++ if (checkpoints.length !== surf.stages - 1)++ issues.push(++ `surf: ${checkpoints.length} checkpoints for ${surf.stages} stages (expected ${surf.stages - 1})`,++ )++ const stages = checkpoints.map((z) => z.surf!.stage).sort((a, b) => a - b)++ for (let i = 0; i < stages.length; i++)++ if (stages[i] !== i + 1) {++ issues.push(`surf: checkpoint stages must be 1..${surf.stages - 1}, got ${stages.join(',')}`)++ break++ }return issues}@@@ -427,6 +471,8 @@ export function compileMapFileV2(map: MapFileV2): MapOverride {})),statics: map.statics.map((b) => ({ ...b })),zones: map.zones.map((z0): ZoneDef => ({ ...z0, min: [...z0.min], max: [...z0.max] })),++ ...(map.movement ? { movement: map.movement } : {}),++ ...(map.surf ? { surf: map.surf } : {}),...(map.spawn ? { spawn: map.spawn } : {}),...(map.spawnYaw !== undefined ? { spawnYaw: map.spawnYaw } : {}),}nnew file mode 100644@@@ -0,0 +1,121 @@++import type { ContentPackV2 } from '../pack.js'++import type { MovementStyle } from '../schema/world.js'++++/**++ * OpenVibe.Surf (openvibe.surf) — Ride v1's three original maps.++ *++ * A surf map is an ordinary `MapFileV2` document (`content/surf/<id>.json`) with++ * the `surf` block, surf zones and `movement: "surf"`. These records are the++ * catalogue the client's `?map=` URL and the server's place router share; a++ * place id is `surf-<slug>` so the existing `/play?game=…` scheme carries it++ * unchanged. None of the maps, names or layouts are CS maps: they are authored++ * for this engine and this brief.++ */++export interface SurfMapInfo {++ /** Stable map id, also the content world id (`surf_lagoon`). */++ id: `surf_${'lagoon' | 'canyon' | 'orbit'}`++ /** Place id in the existing URL scheme (`surf-lagoon`). */++ placeId: string++ name: string++ /** Difficulty: 1 beginner, 2 intermediate, 4 advanced. */++ tier: number++ /** Total stages, including the finish. */++ stages: number++ author: 'OpenVibe'++ /** Map document under `content/surf/`. */++ file: string++}++++export const SURF_AUTHOR = 'OpenVibe' as const++++export const SURF_MAPS: readonly SurfMapInfo[] = [++ {++ id: 'surf_lagoon',++ placeId: 'surf-lagoon',++ name: 'Surf Lagoon',++ tier: 1,++ stages: 3,++ author: SURF_AUTHOR,++ file: 'surf_lagoon.json',++ },++ {++ id: 'surf_canyon',++ placeId: 'surf-canyon',++ name: 'Surf Canyon',++ tier: 2,++ stages: 4,++ author: SURF_AUTHOR,++ file: 'surf_canyon.json',++ },++ {++ id: 'surf_orbit',++ placeId: 'surf-orbit',++ name: 'Surf Orbit',++ tier: 4,++ stages: 6,++ author: SURF_AUTHOR,++ file: 'surf_orbit.json',++ },++] as const++++export type SurfMapId = SurfMapInfo['id']++++export function isSurfMapId(value: string): value is SurfMapId {++ return SURF_MAPS.some((m) => m.id === value)++}++++/** Resolve by map id or place id (`surf_lagoon` / `surf-lagoon`). */++export function surfMap(value: string): SurfMapInfo | undefined {++ return SURF_MAPS.find((m) => m.id === value || m.placeId === value)++}++++export function surfPlaceId(mapId: SurfMapId): string {++ return SURF_MAPS.find((m) => m.id === mapId)!.placeId++}++++/** The place id a map id maps to, or null. */++export function surfPlaceOf(value: string | undefined): string | null {++ return value ? (surfMap(value)?.placeId ?? null) : null++}++++/** The map id a place id maps to, or null. */++export function surfMapOfPlace(placeId: string): SurfMapInfo | null {++ return SURF_MAPS.find((m) => m.placeId === placeId) ?? null++}++++export function isSurfPlace(placeId: string): boolean {++ return surfMapOfPlace(placeId) !== null++}++++/**++ * The content pack for one surf place: a world whose movement is the surf++ * preset. Geometry, zones and spawn all come from the map document, so this++ * world def is deliberately empty. Both server and client build it, which is++ * what puts `movement: "surf"` inside the content digest.++ */++export function surfPack(mapId: SurfMapId): ContentPackV2 {++ const info = SURF_MAPS.find((m) => m.id === mapId)++ if (!info) throw new Error(`unknown surf map: ${mapId}`)++ const movement: MovementStyle = 'surf'++ return {++ map: {++ id: info.id,++ name: info.name,++ flatTerrain: true,++ groundHalfExtent: 120,++ spawnPoint: [0, 2, 0],++ spawnYaw: 0,++ statics: [],++ resourceNodes: [],++ initialProps: [],++ zones: [],++ movement,++ },++ }++}++++export const SURF_PACKS: Record<SurfMapId, ContentPackV2> = {++ surf_lagoon: surfPack('surf_lagoon'),++ surf_canyon: surfPack('surf_canyon'),++ surf_orbit: surfPack('surf_orbit'),++}@@@ -11,6 +11,16 @@ import { WorldShapeSchema } from './item.js'const vec3 = z.tuple([z.number(), z.number(), z.number()])const finiteVec3 = vec3.refine((v) => v.every(Number.isFinite), 'must be finite')++/**++ * How a place moves. The style name is content: both sides resolve it to the++ * SAME MovementParams (gameplay/movement/styles.ts) and pass those to the one++ * movement kernel, so a mismatch is a content-digest mismatch, not a feel++ * difference. `default` is the sandbox controller; `surf` is the CS:S-like++ * air-control preset used by the Ride maps.++ */++export const MovementStyleSchema = z.enum(['default', 'surf'])++export type MovementStyle = z.infer<typeof MovementStyleSchema>++/*** Canonical authored colour: lower-case six-digit hex. One definition shared* by every schema that carries a colour, so a value cannot be accepted by one@@@ -124,6 +134,29 @@ export const PropSpawnSchema = z.object({yaw: z.number().default(0),})++/** What a surf zone means: the run's start, an ordered checkpoint, the finish, or a kill volume. */++export const SurfZoneMetaSchema = z.object({++ kind: z.enum(['start', 'checkpoint', 'finish', 'kill']),++ /** 0 for the start, 1.. for checkpoints, the final stage for the finish. */++ stage: z.number().int().min(0).max(63).default(0),++ /** Checkpoint respawn (capsule centre) and yaw; the start uses the map spawn. */++ spawn: finiteVec3.optional(),++ yaw: z.number().optional(),++})++export type SurfZoneMeta = z.infer<typeof SurfZoneMetaSchema>++++/** Map-level surf metadata: the fail plane and the advertised run shape. */++export const SurfMapMetaSchema = z.object({++ title: z.string().min(1).max(80),++ author: z.string().min(1).max(80),++ tier: z.number().int().min(1).max(5),++ /** Total stages, including the finish (checkpoints are stages - 1). */++ stages: z.number().int().min(1).max(64),++ /** Below this height (metres) the run fails to the last checkpoint. */++ failY: z.number(),++})++export type SurfMapMeta = z.infer<typeof SurfMapMetaSchema>++export const ZoneDefSchema = z.object({/*** Stable zone id. Dashes are permitted so the map editor can mint ids in the@@@ -140,6 +173,8 @@ export const ZoneDefSchema = z.object({build: z.boolean().default(true),physgun: z.boolean().default(true),}),++ /** Surf trigger metadata; absent on ordinary rule zones. */++ surf: SurfZoneMetaSchema.optional(),})export const WorldDefSchema = z.object({@@@ -151,6 +186,8 @@ export const WorldDefSchema = z.object({spawnYaw: z.number().default(0),/** Blank-slate world: terrain is a flat floor (map editor builds the rest). */flatTerrain: z.boolean().default(false),++ /** Movement preset for this place; resolved to params on both sides (default when absent). */++ movement: MovementStyleSchema.optional(),statics: z.array(StaticBodySchema),resourceNodes: z.array(ResourceNodeSpawnSchema),initialProps: z.array(PropSpawnSchema),@@@ -192,6 +192,10 @@ export interface MapOverride {/** Editor-placed spawn point (overrides the world def). */spawn?: [number, number, number]spawnYaw?: number++ /** Movement preset authored by the map (overrides the world def). */++ movement?: 'default' | 'surf'++ /** Surf run metadata authored by the map (fail plane, stages, tier, author). */++ surf?: { title: string; author: string; tier: number; stages: number; failY: number }}let mapOverride: MapOverride | null = null@@@ -6,6 +6,9 @@ export * from './movement/buttons.js'export * from './movement/params.js'export * from './movement/collision.js'export * from './movement/simulate.js'++export * from './movement/styles.js'++export * from './surf/run.js'++export * from './surf/ghost.js'export * from './skills/skills.js'export * from './zones/zones.js'export * from './entity/entity.js'@@@ -29,6 +29,8 @@ export interface MovementParams {readonly eyeOffset: number/** Collision skin to keep the capsule from touching surfaces exactly. */readonly skin: number++ /** Horizontal speed ceiling (m/s); Infinity means the engine's old unbounded rule. */++ readonly maxHorizontalSpeed: number/** Hold-jump auto-hops when true (fun default for a sandbox). */readonly autoBhop: boolean}@@@ -49,5 +51,6 @@ export const DEFAULT_MOVEMENT: MovementParams = {capsuleRadius: 0.4,eyeOffset: 0.65,skin: 0.02,++ maxHorizontalSpeed: Number.POSITIVE_INFINITY,autoBhop: true,}@@@ -507,4 +507,14 @@ export function stepMovement(// Terminal velocity guard.state.vel.y = clamp(state.vel.y, -50, 50)++ // Horizontal ceiling (Source's sv_maxvelocity). The sandbox leaves it off;++ // the surf style uses it so a long ramp cannot exceed CS's 3500 u/s.++ if (Number.isFinite(params.maxHorizontalSpeed)) {++ const speed = Math.hypot(state.vel.x, state.vel.z)++ if (speed > params.maxHorizontalSpeed) {++ const scale = params.maxHorizontalSpeed / speed++ state.vel.x *= scale++ state.vel.z *= scale++ }++ }}nnew file mode 100644@@@ -0,0 +1,81 @@++import { getMapOverride, type MovementStyle, type WorldDef } from '@openvibe/content'++import { DEFAULT_MOVEMENT, type MovementParams } from './params.js'++++/**++ * CS:S surf tuning, converted from Source units into this engine's metres.++ *++ * Unit scale: Source is authored in hammer units where **1 unit = 1 inch =++ * 0.0254 m** (a 72-unit player is 1.8288 m, the same 1.8 m capsule this engine++ * uses). Converting every velocity/length by 0.0254 keeps the map and the++ * player the same physical size as CS.++ *++ * sv_gravity 800 u/s² × 0.0254 = 20.32 m/s²++ * sv_maxvelocity 3500 u/s × 0.0254 = 88.9 m/s++ * air wish cap 30 u/s × 0.0254 = 0.762 m/s++ * walk speed 250 u/s × 0.0254 = 6.35 m/s++ * jump 301.993 u/s × 0.0254 = 7.6706 m/s++ * stop speed 80 u/s × 0.0254 = 2.032 m/s++ *++ * The cap is applied PER TICK, so the per-second gain is `cap × tickRate`.++ * CS:S surf is played at 64 tick; this server keeps 30 Hz, so the cap is++ * scaled by 64/30 to preserve the per-second acceleration:++ *++ * 0.762 × 64 / 30 = 1.6256 m/s at 30 Hz++ * 1.6256 × 30 = 48.768 m/s² (same as 0.762 × 64)++ *++ * `airAccel` is `sv_airaccelerate 150` unchanged: it is already a per-second++ * rate (the kernel multiplies it by dt), so it does not need a tick-rate++ * scale. At 30 Hz its term is 150 × 6.35 × (1/30) = 31.75 m/s per tick,++ * well above the 1.6256 cap — the cap is what limits surf, exactly as in CS.++ * `sv_airaccelerate` only becomes visible when a very small wish speed makes++ * the rate term smaller than the cap.++ *++ * `groundNormalY` stays at 0.7 (cos 45.57°), CS's standable plane. Ride ramps++ * are authored at 50–60°, so cos(50°)=0.643 < 0.7 and the controller never++ * treats them as ground; the 45° edge stays exactly where CS puts it.++ */++export const SURF_MOVEMENT: MovementParams = {++ maxGroundSpeed: 6.35,++ // CS has no sprint; equalling the two makes Shift harmless.++ maxSprintSpeed: 6.35,++ groundAccel: 10,++ airAccel: 150,++ airSpeedCap: 1.6256,++ friction: 4,++ stopSpeed: 2.032,++ gravity: 20.32,++ jumpSpeed: 7.6706,++ groundNormalY: 0.7,++ stepHeight: 0.45,++ capsuleHeight: 1.8,++ capsuleRadius: 0.4,++ eyeOffset: 0.65,++ skin: 0.02,++ maxHorizontalSpeed: 88.9,++ autoBhop: false,++}++++/** The one table both server and client resolve a content style through. */++export const MOVEMENT_STYLES: Readonly<Record<MovementStyle, MovementParams>> = {++ default: DEFAULT_MOVEMENT,++ surf: SURF_MOVEMENT,++}++++/** Resolve a content style id; unknown ids are a content bug, so fail loudly. */++export function movementParamsFor(style: MovementStyle): MovementParams {++ const params = MOVEMENT_STYLES[style]++ if (!params) throw new Error(`unknown movement style: ${String(style)}`)++ return params++}++++++/**++ * The movement style in force for a world: the loaded map's choice overrides++ * the content world's. Both sides call this with their own (identical) map++ * override, so server simulation and client prediction resolve the same++ * params; the world's field is part of the content digest, so a client with a++ * different default is refused at the handshake.++ */++export function movementStyleFor(world: WorldDef): MovementStyle {++ return getMapOverride(world)?.movement ?? world.movement ?? 'default'++}nnew file mode 100644@@@ -0,0 +1,108 @@++import { describe, expect, it } from 'vitest'++import { vec3 } from '@openvibe/shared'++import type { CollisionQueries, SweepHit } from './collision.js'++import { DEFAULT_MOVEMENT } from './params.js'++import { createMoveState, stepMovement, type PlayerMoveState } from './simulate.js'++import { SURF_MOVEMENT } from './styles.js'++++/**++ * The feel proof for Ride. A 55° ramp is steeper than cos⁻¹(0.7) = 45.57°, so++ * NEITHER style ever grounds on it; the difference is the air controller.++ * Surf's CS:S cap (scaled to 30 Hz) lets a strafe + turn keep or gain++ * horizontal speed; the sandbox default's 0.85 m/s cap gives the ramp the++ * speed instead. A 40° slope is ground in both styles.++ *++ * The collision world is an analytic half-space rather than Rapier: the test++ * is about the movement rule, and the half-space has no box edges, so the++ * comparison is not polluted by a capsule catching a lip.++ */++const RAD = Math.PI / 180++++function planeQueries(angleDeg: number): CollisionQueries {++ const a = angleDeg * RAD++ const n = { x: 0, y: Math.cos(a), z: Math.sin(a) }++ return {++ sweepCapsule(from, to, radius): SweepHit | null {++ const da = from.x * n.x + from.y * n.y + from.z * n.z - radius++ const db = to.x * n.x + to.y * n.y + to.z * n.z - radius++ if (da <= 0)++ return { fraction: 0, normal: { ...n }, point: { ...from } }++ if (db >= 0) return null++ const f = da / (da - db)++ return {++ fraction: f,++ normal: { ...n },++ point: {++ x: from.x + (to.x - from.x) * f,++ y: from.y + (to.y - from.y) * f,++ z: from.z + (to.z - from.z) * f,++ },++ }++ },++ }++}++++function spawnOnPlane(angleDeg: number, above = 1.2): PlayerMoveState {++ const a = angleDeg * RAD++ const n = { x: 0, y: Math.cos(a), z: Math.sin(a) }++ return createMoveState(vec3(n.x * above, n.y * above, n.z * above))++}++++/** 3 s of a strafe whose yaw turns 45°, the surf line. */++function runLine(params: typeof DEFAULT_MOVEMENT, angle: number): number[] {++ const state = spawnOnPlane(angle)++ state.vel.x = 0++ state.vel.y = 0++ state.vel.z = 10++ const speeds: number[] = []++ for (let tick = 0; tick < 90; tick++) {++ const yaw = (tick / 30) * 45 * RAD++ stepMovement(++ state,++ { moveX: 1, moveZ: 0, yaw, pitch: 0, buttons: 0 },++ params,++ planeQueries(angle),++ 1 / 30,++ )++ if (tick % 30 === 29) speeds.push(Math.hypot(state.vel.x, state.vel.z))++ }++ return speeds++}++++describe('surf feel on a steep ramp', () => {++ it('keeps or gains horizontal speed over 3 s in the surf style', () => {++ const speeds = runLine(SURF_MOVEMENT, 55)++ expect(speeds[0]).toBeGreaterThan(9)++ expect(speeds[2]).toBeGreaterThanOrEqual(10 * 0.99)++ })++++ it('loses horizontal speed in the default style on the same ramp and line', () => {++ const speeds = runLine(DEFAULT_MOVEMENT, 55)++ expect(speeds[2]).toBeLessThan(9)++ expect(speeds[2]).toBeLessThan(runLine(SURF_MOVEMENT, 55)[2]! * 0.75)++ })++++ it('a 40° slope is ground in both styles', () => {++ for (const params of [SURF_MOVEMENT, DEFAULT_MOVEMENT]) {++ const state = spawnOnPlane(40, 0.1)++ stepMovement(++ state,++ { moveX: 0, moveZ: 0, yaw: 0, pitch: 0, buttons: 0 },++ params,++ planeQueries(40),++ 1 / 30,++ )++ expect(state.grounded).toBe(true)++ }++ })++++ it('CS:S constants are the converted Source values, scaled at 30 Hz', () => {++ expect(SURF_MOVEMENT.groundNormalY).toBe(0.7)++ expect(SURF_MOVEMENT.gravity).toBeCloseTo(800 * 0.0254)++ expect(SURF_MOVEMENT.maxHorizontalSpeed).toBeCloseTo(3500 * 0.0254)++ expect(SURF_MOVEMENT.airSpeedCap).toBeCloseTo(30 * 0.0254 * (64 / 30))++ expect(SURF_MOVEMENT.airAccel).toBe(150)++ expect(SURF_MOVEMENT.autoBhop).toBe(false)++ expect(SURF_MOVEMENT.maxSprintSpeed).toBe(SURF_MOVEMENT.maxGroundSpeed)++ })++})nnew file mode 100644@@@ -0,0 +1,86 @@++import type { SurfTrackSample } from './run.js'++++/**++ * The ghost transform track (inventory §5.3 option 1): authoritative positions++ * quantised to 1 cm and yaw to 1/256 turn, little-endian. The server gzips the++ * bytes for storage/HTTP; this codec stays pure so the client can decode it++ * without a decompressor of its own (the browser's content-encoding does that).++ *++ * header magic "SURF", u8 version, u8 pad, u16 tickRate, u32 startTick, u32 count++ * sample i32 x_cm, i32 y_cm, i32 z_cm, u8 yawQ++ */++const MAGIC = 0x46525553 // "SURF" little-endian++const VERSION = 1++export const GHOST_HEADER_BYTES = 16++export const GHOST_SAMPLE_BYTES = 13++++export interface DecodedGhost {++ tickRate: number++ startTick: number++ samples: SurfTrackSample[]++}++++export function quantiseCentimetres(v: number): number {++ return Math.max(-2_147_483_648, Math.min(2_147_483_647, Math.round(v * 100)))++}++++/** One byte per 1/256 turn. */++export function quantiseYaw8(yaw: number): number {++ const turn = yaw / (Math.PI * 2)++ const q = Math.round(turn * 256) % 256++ return q < 0 ? q + 256 : q++}++++export function dequantiseYaw8(q: number): number {++ const yaw = (q / 256) * Math.PI * 2++ return yaw > Math.PI ? yaw - Math.PI * 2 : yaw++}++++export function encodeSurfTrack(++ track: readonly SurfTrackSample[],++ tickRate: number,++ startTick: number,++): Uint8Array {++ const out = new Uint8Array(GHOST_HEADER_BYTES + GHOST_SAMPLE_BYTES * track.length)++ const view = new DataView(out.buffer)++ view.setUint32(0, MAGIC, true)++ view.setUint8(4, VERSION)++ view.setUint8(5, 0)++ view.setUint16(6, tickRate, true)++ view.setUint32(8, startTick >>> 0, true)++ view.setUint32(12, track.length, true)++ let o = GHOST_HEADER_BYTES++ for (const s of track) {++ view.setInt32(o, quantiseCentimetres(s.x), true)++ view.setInt32(o + 4, quantiseCentimetres(s.y), true)++ view.setInt32(o + 8, quantiseCentimetres(s.z), true)++ view.setUint8(o + 12, quantiseYaw8(s.yaw))++ o += GHOST_SAMPLE_BYTES++ }++ return out++}++++export function decodeSurfTrack(bytes: Uint8Array): DecodedGhost {++ if (bytes.byteLength < GHOST_HEADER_BYTES) throw new Error('ghost track: truncated header')++ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)++ if (view.getUint32(0, true) !== MAGIC) throw new Error('ghost track: bad magic')++ if (view.getUint8(4) !== VERSION) throw new Error('ghost track: unsupported version')++ const tickRate = view.getUint16(6, true)++ const startTick = view.getUint32(8, true)++ const count = view.getUint32(12, true)++ if (bytes.byteLength !== GHOST_HEADER_BYTES + count * GHOST_SAMPLE_BYTES)++ throw new Error('ghost track: length does not match its header')++ const samples: SurfTrackSample[] = []++ let o = GHOST_HEADER_BYTES++ for (let i = 0; i < count; i++) {++ samples.push({++ tick: startTick + i,++ x: view.getInt32(o, true) / 100,++ y: view.getInt32(o + 4, true) / 100,++ z: view.getInt32(o + 8, true) / 100,++ yaw: dequantiseYaw8(view.getUint8(o + 12)),++ })++ o += GHOST_SAMPLE_BYTES++ }++ return { tickRate, startTick, samples }++}nnew file mode 100644@@@ -0,0 +1,230 @@++import { describe, expect, it } from 'vitest'++import { createMoveState } from '../movement/simulate.js'++import { vec3 } from '@openvibe/shared'++import {++ SurfRunState,++ insideBox,++ placeMoveStateAt,++ segmentAabbEntry,++ segmentAabbExit,++ surfRunMapOf,++ type SurfRunMap,++ type SurfRunZone,++} from './run.js'++import type { DecodedGhost } from './ghost.js'++import { decodeSurfTrack, encodeSurfTrack } from './ghost.js'++++function zone(++ id: string,++ kind: SurfRunZone['kind'],++ stage: number,++ min: [number, number, number],++ max: [number, number, number],++ spawn?: [number, number, number],++): SurfRunZone {++ return {++ id,++ kind,++ stage,++ min,++ max,++ ...(spawn ? { spawn: { x: spawn[0], y: spawn[1], z: spawn[2] } } : {}),++ ...(kind === 'checkpoint' ? { yaw: 0 } : {}),++ }++}++++function map(overrides: Partial<SurfRunMap> = {}): SurfRunMap {++ return {++ id: 'test',++ failY: -10,++ stages: 3,++ start: zone('start', 'start', 0, [-2, -1, 0], [2, 2, 2]),++ checkpoints: [++ zone('cp1', 'checkpoint', 1, [-2, -1, 10], [2, 2, 12], [0, 0, 11]),++ zone('cp2', 'checkpoint', 2, [-2, -1, 20], [2, 2, 22], [0, 0, 21]),++ ],++ finish: zone('finish', 'finish', 3, [-2, -1, 30], [2, 2, 32]),++ kills: [],++ spawn: { x: 0, y: 0, z: 1 },++ spawnYaw: 0,++ ...overrides,++ }++}++++const p = (z: number): { x: number; y: number; z: number } => ({ x: 0, y: 0, z })++++describe('slab crossing fractions', () => {++ it('computes an entry fraction from the segment and the AABB', () => {++ expect(segmentAabbEntry(p(8), p(12), { min: [-1, -1, 10], max: [1, 1, 12] })).toBeCloseTo(0.5)++ expect(segmentAabbEntry(p(11), p(13), { min: [-1, -1, 10], max: [1, 1, 12] })).toBe(0)++ expect(segmentAabbEntry(p(0), p(5), { min: [-1, -1, 10], max: [1, 1, 12] })).toBeNull()++ expect(segmentAabbEntry(p(0), p(20), { min: [-1, -1, 10], max: [1, 1, 12] })).toBeCloseTo(0.5)++ })++++ it('computes the exit fraction only when the segment starts inside', () => {++ expect(segmentAabbExit(p(1), p(3), { min: [-2, -1, 0], max: [2, 2, 2] })).toBeCloseTo(0.5)++ expect(segmentAabbExit(p(3), p(4), { min: [-2, -1, 0], max: [2, 2, 2] })).toBeNull()++ expect(segmentAabbExit(p(1), p(1.5), { min: [-2, -1, 0], max: [2, 2, 2] })).toBeNull()++ })++})++++describe('surf timer', () => {++ it('uses sub-tick fractions at both ends', () => {++ const run = new SurfRunState(++ map({ checkpoints: [], stages: 1, finish: zone('finish', 'finish', 1, [-2, -1, 30], [2, 2, 32]) }),++ 30,++ )++ // Exit the start at 0.25 of tick 10: from z=1.5 to z=2.5, box ends at 2.++ run.observe(10, p(1.5), p(2.5), 0)++ expect(run.status).toBe('running')++ expect(run.startTick).toBe(10)++ expect(run.startFrac).toBeCloseTo(0.5)++ expect(run.elapsedMs).toBe(17) // (11 - 10 - 0.5) * 33.333, rounded++++ // Finish at 0.75 of tick 20: z 28 → 32 crosses z=30 at 0.5, but use cp++ // zones first (they are not on this line), then the finish.++ run.observe(20, p(28), p(32), 0)++ // fracEnd = 0.5, startFrac = 0.5 → (20-10+0.5-0.5)*33.333 = 333ms++ expect(run.finishResult?.timeMs).toBe(333)++ expect(run.finishResult?.timeMs).toBe(++ Math.round((20 - 10 + 0.5 - 0.5) * (1000 / 30)),++ )++ })++++ it('crosses mid-tick at both ends and matches the formula exactly', () => {++ const m = map({++ start: zone('start', 'start', 0, [-2, -1, 0], [2, 2, 2]),++ checkpoints: [],++ finish: zone('finish', 'finish', 1, [-2, -1, 10], [2, 2, 12]),++ stages: 1,++ })++ const run = new SurfRunState(m, 30)++ run.observe(5, p(1.2), p(2.8), 0) // exit at (2-1.2)/1.6 = 0.5++ expect(run.startFrac).toBeCloseTo(0.5)++ run.observe(9, p(8), p(12), 0) // enter at (10-8)/4 = 0.5++ const expected = Math.round((9 - 5 + 0.5 - 0.5) * (1000 / 30))++ expect(run.finishResult?.timeMs).toBe(expected)++ })++})++++describe('surf zones', () => {++ it('records ordered checkpoints and ignores a skipped one', () => {++ const run = new SurfRunState(map(), 30)++ run.observe(1, p(1), p(3), 0) // start++ // Jump straight into checkpoint 2's volume: stage 1 is skipped, so nothing.++ run.observe(2, p(18), p(21), 0)++ expect(run.reached).toBe(0)++ expect(run.splits).toEqual([])++ run.observe(3, p(9), p(11), 0)++ expect(run.reached).toBe(1)++ expect(run.splits.map((s) => s.stage)).toEqual([1])++ run.observe(4, p(19), p(21), 0)++ expect(run.reached).toBe(2)++ run.observe(5, p(29), p(31), 0)++ expect(run.status).toBe('finished')++ expect(run.finishResult?.splits).toHaveLength(3)++ })++++ it('fails below failY to the last checkpoint with velocity zeroed, and the clock still runs', () => {++ const run = new SurfRunState(map(), 30)++ run.observe(10, p(1), p(3), 0)++ run.observe(11, p(9), p(11), 0) // cp1++ const before = run.elapsedMs++ const events = run.observe(12, { x: 0, y: -11, z: 12 }, { x: 0, y: -12, z: 13 }, 0)++ const failed = events.find((e) => e.type === 'failed')++ expect(failed).toMatchObject({ type: 'failed', to: { x: 0, y: 0, z: 11 } })++ expect(run.fails).toBe(1)++ expect(run.status).toBe('running')++ expect(run.elapsedMs).toBeGreaterThan(before)++ const move = createMoveState(vec3(0, 0, 12))++ move.vel.x = 9++ move.vel.y = -4++ placeMoveStateAt(move, (failed as Extract<typeof failed, { type: 'failed' }>).to)++ expect(move.vel).toEqual(vec3(0, 0, 0))++ expect(move.pos).toEqual(vec3(0, 0, 11))++ })++++ it('fails inside a kill volume, even above failY', () => {++ const kill = zone('kill', 'kill', 0, [-2, -1, 14], [2, 3, 18])++ const run = new SurfRunState(map({ kills: [kill] }), 30)++ run.observe(10, p(1), p(3), 0)++ const events = run.observe(11, p(13), p(15), 0)++ expect(events.some((e) => e.type === 'failed')).toBe(true)++ expect(run.fails).toBe(1)++ expect(run.status).toBe('running')++ })++++ it('re-entering the start after a finish arms a fresh run', () => {++ const run = new SurfRunState(map({ checkpoints: [], stages: 1 }), 30)++ run.observe(1, p(1), p(3), 0)++ run.observe(2, p(29), p(31), 0)++ expect(run.status).toBe('finished')++ const events = run.observe(3, p(3), p(1), 0)++ expect(events.some((e) => e.type === 'restarted')).toBe(true)++ expect(run.status).toBe('idle')++ })++++ it('a fail before the first checkpoint respawns at the start without resetting the clock', () => {++ const run = new SurfRunState(map(), 30)++ run.observe(10, p(1), p(3), 0)++ const startTick = run.startTick++ const failed = run.observe(12, { x: 0, y: -11, z: 3 }, { x: 0, y: -12, z: 3 }, 0)++ expect(failed.some((e) => e.type === 'failed')).toBe(true)++ // Next tick starts at the start spawn (inside the start zone) and runs++ // out again; that is not a fresh entry.++ run.observe(13, p(1), p(3), 0)++ expect(run.status).toBe('running')++ expect(run.startTick).toBe(startTick)++ })++++ it('resets to idle and can start again', () => {++ const run = new SurfRunState(map(), 30)++ run.observe(10, p(1), p(3), 0)++ run.observe(11, p(9), p(11), 0)++ run.restart(20)++ expect(run.status).toBe('idle')++ expect(run.reached).toBe(0)++ expect(run.splits).toEqual([])++ expect(run.restart(20)).toMatchObject({ type: 'restarted', to: { z: 1 } })++ run.observe(21, p(1), p(3), 0)++ expect(run.status).toBe('running')++ expect(run.startTick).toBe(21)++ })++++ it('builds the run from tagged zones and requires one ordered set', () => {++ const zones = [++ { id: 's', name: 'S', min: [0, 0, 0] as [number, number, number], max: [2, 2, 2] as [number, number, number], rules: { pvp: false, build: false, physgun: false }, surf: { kind: 'start' as const, stage: 0 } },++ { id: 'f', name: 'F', min: [0, 0, 10] as [number, number, number], max: [2, 2, 12] as [number, number, number], rules: { pvp: false, build: false, physgun: false }, surf: { kind: 'finish' as const, stage: 1 } },++ ]++ const runMap = surfRunMapOf({ title: 'T', author: 'A', tier: 1, stages: 1, failY: -5 }, zones)++ expect(runMap.finish.id).toBe('f')++ expect(insideBox(runMap.start, { x: 1, y: 1, z: 1 })).toBe(true)++ expect(insideBox(runMap.start, { x: 3, y: 1, z: 1 })).toBe(false)++ })++})++++describe('ghost track', () => {++ it('round-trips positions at 1 cm and yaw at 1/256 turn', () => {++ const samples = [++ { tick: 100, x: 1.23, y: 4.56, z: -7.89, yaw: Math.PI / 4 },++ { tick: 101, x: -10.01, y: 0.02, z: 300.4, yaw: -Math.PI / 2 },++ ]++ const bytes = encodeSurfTrack(samples, 30, 100)++ const decoded: DecodedGhost = decodeSurfTrack(bytes)++ expect(decoded.tickRate).toBe(30)++ expect(decoded.startTick).toBe(100)++ expect(decoded.samples[0]!.x).toBeCloseTo(1.23)++ expect(decoded.samples[0]!.y).toBeCloseTo(4.56)++ expect(decoded.samples[0]!.z).toBeCloseTo(-7.89)++ expect(decoded.samples[0]!.yaw).toBeCloseTo(Math.PI / 4, 2)++ expect(decoded.samples[1]!.x).toBeCloseTo(-10.01)++ })++++ it('refuses a corrupt or truncated track', () => {++ const bytes = encodeSurfTrack([{ tick: 0, x: 0, y: 0, z: 0, yaw: 0 }], 30, 0)++ expect(() => decodeSurfTrack(bytes.subarray(0, 20))).toThrow()++ const bad = bytes.slice()++ bad[0] = 0++ expect(() => decodeSurfTrack(bad)).toThrow()++ })++})nnew file mode 100644@@@ -0,0 +1,376 @@++import type { SurfMapMeta, ZoneDef } from '@openvibe/content'++import type { PlayerMoveState } from '../movement/simulate.js'++++/**++ * The authoritative surf run state machine.++ *++ * It is deliberately pure and engine-free: the server feeds it the segment++ * between the previous and current AUTHORITATIVE capsule positions once per++ * simulated input, and it returns what happened. No client time, no client++ * position and no wall clock participate in a run.++ *++ * Timing: the server ticks at 30 Hz, so a tick count alone is 33.3 ms coarse.++ * Every crossing is a slab intersection against the zone AABB, giving the++ * fraction of the segment at which it happened:++ *++ * timeMs = (endTick - startTick + fracEnd - fracStart) * (1000 / tickRate)++ *++ * `startTick` is the tick in which the capsule EXITED the start zone++ * (`fracStart` is that exit fraction, measured from the tick start), so the++ * interval from the crossing to the end of that tick is `1 - fracStart`.++ */++++export type SurfVec3 = { x: number; y: number; z: number }++export type SurfZoneKind = 'start' | 'checkpoint' | 'finish' | 'kill'++++export interface SurfBox {++ min: readonly [number, number, number]++ max: readonly [number, number, number]++}++++export interface SurfRunZone extends SurfBox {++ id: string++ kind: SurfZoneKind++ stage: number++ spawn?: SurfVec3++ yaw?: number++}++++export interface SurfRunMap {++ id: string++ failY: number++ stages: number++ start: SurfRunZone++ checkpoints: readonly SurfRunZone[]++ finish: SurfRunZone++ kills: readonly SurfRunZone[]++ spawn: SurfVec3++ spawnYaw: number++}++++export interface SurfTrackSample {++ tick: number++ x: number++ y: number++ z: number++ yaw: number++}++++export interface SurfSplit {++ stage: number++ timeMs: number++}++++export interface SurfRunResult {++ mapId: string++ timeMs: number++ splits: number[]++ track: SurfTrackSample[]++ fails: number++ startedTick: number++ finishedTick: number++ startFrac: number++ finishFrac: number++}++++export type SurfRunEvent =++ | { type: 'started'; tick: number; frac: number }++ | { type: 'checkpoint'; stage: number; timeMs: number }++ | { type: 'finished'; result: SurfRunResult }++ | { type: 'failed'; to: SurfVec3; yaw: number; fails: number }++ | { type: 'restarted'; to: SurfVec3; yaw: number }++++export type SurfRunStatus = 'idle' | 'running' | 'finished'++++const EPS = 1e-9++++export function insideBox(box: SurfBox, p: SurfVec3): boolean {++ return (++ p.x >= box.min[0] &&++ p.x <= box.max[0] &&++ p.y >= box.min[1] &&++ p.y <= box.max[1] &&++ p.z >= box.min[2] &&++ p.z <= box.max[2]++ )++}++++/**++ * Slab intersection. Returns the first fraction in [0,1] at which the segment++ * enters the box, or null when it never does. A point already inside enters at++ * 0; a zero-length segment inside returns 0 and outside returns null.++ */++export function segmentAabbEntry(a: SurfVec3, b: SurfVec3, box: SurfBox): number | null {++ if (insideBox(box, a)) return 0++ let tEnter = 0++ let tExit = 1++ const axes: ('x' | 'y' | 'z')[] = ['x', 'y', 'z']++ for (let i = 0; i < 3; i++) {++ const axis = axes[i]!++ const lo = box.min[i]!++ const hi = box.max[i]!++ const p = a[axis]++ const d = b[axis] - p++ if (Math.abs(d) < EPS) {++ if (p < lo || p > hi) return null++ continue++ }++ let t1 = (lo - p) / d++ let t2 = (hi - p) / d++ if (t1 > t2) [t1, t2] = [t2, t1]++ if (t1 > tEnter) tEnter = t1++ if (t2 < tExit) tExit = t2++ if (tEnter > tExit) return null++ }++ return tEnter >= 0 && tEnter <= 1 ? tEnter : null++}++++/**++ * The fraction at which a segment EXITS the box while starting inside, or null++ * when it does not leave this tick. This is the start-line fraction: the run++ * begins as the capsule crosses out of the start volume.++ */++export function segmentAabbExit(a: SurfVec3, b: SurfVec3, box: SurfBox): number | null {++ if (!insideBox(box, a)) return null++ if (insideBox(box, b)) return null++ let tExit = 1++ const axes: ('x' | 'y' | 'z')[] = ['x', 'y', 'z']++ for (let i = 0; i < 3; i++) {++ const axis = axes[i]!++ const lo = box.min[i]!++ const hi = box.max[i]!++ const p = a[axis]++ const d = b[axis] - p++ if (Math.abs(d) < EPS) continue++ const t1 = (lo - p) / d++ const t2 = (hi - p) / d++ const t = d > 0 ? t2 : t1++ if (t < tExit) tExit = t++ }++ return tExit >= 0 && tExit <= 1 ? tExit : null++}++++/** Build the run description from a compiled map's surf metadata and zones. */++export function surfRunMapOf(++ meta: SurfMapMeta | undefined,++ zones: readonly ZoneDef[],++ spawn: readonly [number, number, number] = [0, 0, 0],++ spawnYaw = 0,++ id = meta?.title ?? 'surf',++): SurfRunMap {++ const tagged = zones.filter((z) => z.surf)++ const start = tagged.find((z) => z.surf!.kind === 'start')++ const finish = tagged.find((z) => z.surf!.kind === 'finish')++ if (!start || !finish)++ throw new Error('surf map needs a start zone and a finish zone')++ const toZone = (z: ZoneDef): SurfRunZone => ({++ id: z.id,++ kind: z.surf!.kind,++ stage: z.surf!.stage,++ min: [z.min[0], z.min[1], z.min[2]],++ max: [z.max[0], z.max[1], z.max[2]],++ ...(z.surf!.spawn++ ? { spawn: { x: z.surf!.spawn[0], y: z.surf!.spawn[1], z: z.surf!.spawn[2] } }++ : {}),++ ...(z.surf!.yaw !== undefined ? { yaw: z.surf!.yaw } : {}),++ })++ const checkpoints = tagged++ .filter((z) => z.surf!.kind === 'checkpoint')++ .sort((a, b) => a.surf!.stage - b.surf!.stage)++ .map(toZone)++ return {++ id,++ failY: meta?.failY ?? -25,++ stages: meta?.stages ?? checkpoints.length + 1,++ start: toZone(start),++ checkpoints,++ finish: toZone(finish),++ kills: tagged.filter((z) => z.surf!.kind === 'kill').map(toZone),++ spawn: { x: spawn[0], y: spawn[1], z: spawn[2] },++ spawnYaw,++ }++}++++/** One session's run. Create per player per surf place. */++export class SurfRunState {++ status: SurfRunStatus = 'idle'++ /** Highest checkpoint stage reached this run (0 = none). */++ reached = 0++ fails = 0++ startTick = 0++ startFrac = 0++ lastTick = 0++ splits: SurfSplit[] = []++ private track: SurfTrackSample[] = []++ private result: SurfRunResult | null = null++ private lastPos: SurfVec3 | null = null++++ constructor(++ readonly map: SurfRunMap,++ readonly tickRate: number,++ ) {}++++ get elapsedMs(): number {++ if (this.status !== 'running') return this.result?.timeMs ?? 0++ return Math.max(++ 0,++ Math.round((this.lastTick + 1 - this.startTick - this.startFrac) * (1000 / this.tickRate)),++ )++ }++++ get finishResult(): SurfRunResult | null {++ return this.result++ }++++ get splitTimes(): number[] {++ return this.splits.map((s) => s.timeMs)++ }++++ /** The PB-format tracks use integer milliseconds; so does the wire. */++ private timeAt(tick: number, frac: number): number {++ return Math.max(++ 0,++ Math.round((tick + frac - this.startTick - this.startFrac) * (1000 / this.tickRate)),++ )++ }++++ /** Put the run back at the start line; the caller teleports the capsule. */++ restart(tick: number): SurfRunEvent {++ this.status = 'idle'++ this.reached = 0++ this.fails = 0++ this.startTick = tick++ this.startFrac = 0++ this.lastTick = tick++ this.splits = []++ this.track = []++ this.result = null++ this.lastPos = null++ return { type: 'restarted', to: this.map.spawn, yaw: this.map.spawnYaw }++ }++++ /** The server moved the capsule outside movement (fail/reset/join): do not treat it as a crossing. */++ teleport(pos: SurfVec3): void {++ this.lastPos = { ...pos }++ }++++ /**++ * Feed one authoritative movement segment. Returns events in order; the++ * caller applies `failed`/`restarted` teleports after this call.++ */++ observe(tick: number, from: SurfVec3, to: SurfVec3, yaw: number): SurfRunEvent[] {++ const events: SurfRunEvent[] = []++ this.lastTick = tick++ if (this.status === 'finished') {++ // Re-entering the start after a finish arms a fresh run, like CS surf's++ // start zone. A respawn already inside it is not an entry.++ if (!insideBox(this.map.start, from) && insideBox(this.map.start, to)) {++ this.lastPos = { ...to }++ return [this.restart(tick)]++ }++ this.lastPos = { ...to }++ return events++ }++++ if (this.status === 'idle') {++ // Leaving the start volume starts the clock. Re-entering it while idle++ // simply keeps the player at the line.++ const exitFrac = segmentAabbExit(from, to, this.map.start)++ if (exitFrac !== null && !insideBox(this.map.start, to)) {++ this.status = 'running'++ this.startTick = tick++ this.startFrac = exitFrac++ this.reached = 0++ this.fails = 0++ this.splits = []++ this.track = [{ tick: tick + exitFrac, x: to.x, y: to.y, z: to.z, yaw }]++ events.push({ type: 'started', tick, frac: exitFrac })++ }++ this.lastPos = { ...to }++ return events++ }++++ // Running. Only an actual entry restarts: a fail can respawn the player++ // at the start while the clock keeps running (brief decision 4).++ const enteredStart = !insideBox(this.map.start, from) && insideBox(this.map.start, to)++ if (enteredStart) {++ events.push(this.restart(tick) as SurfRunEvent)++ this.lastPos = { ...to }++ return events++ }++++ this.track.push({ tick: tick + 1, x: to.x, y: to.y, z: to.z, yaw })++++ // Only the NEXT checkpoint counts: skipping one records nothing.++ while (this.reached < this.map.checkpoints.length) {++ const next = this.map.checkpoints[this.reached]!++ const frac = segmentAabbEntry(from, to, next)++ if (frac === null) break++ this.reached++++ const timeMs = this.timeAt(tick, frac)++ this.splits.push({ stage: next.stage, timeMs })++ events.push({ type: 'checkpoint', stage: next.stage, timeMs })++ }++++ // Finish only after every checkpoint is in order.++ if (this.reached === this.map.checkpoints.length) {++ const fracEnd = segmentAabbEntry(from, to, this.map.finish)++ if (fracEnd !== null) {++ const timeMs = this.timeAt(tick, fracEnd)++ this.status = 'finished'++ this.result = {++ mapId: this.map.id,++ timeMs,++ splits: [...this.splits.map((s) => s.timeMs), timeMs],++ track: this.track,++ fails: this.fails,++ startedTick: this.startTick,++ finishedTick: tick,++ startFrac: this.startFrac,++ finishFrac: fracEnd,++ }++ events.push({ type: 'finished', result: this.result })++ this.lastPos = { ...to }++ return events++ }++ }++++ // A fall or a kill volume puts the player back at the last checkpoint;++ // the clock keeps running, as in CS surf's stage behaviour.++ const kill = this.map.kills.some((k) => insideBox(k, to))++ if (kill || to.y < this.map.failY) {++ this.fails++++ const last = this.map.checkpoints[this.reached - 1]++ const respawn = last?.spawn ?? this.map.spawn++ const respawnYaw = last?.yaw ?? this.map.spawnYaw++ events.push({ type: 'failed', to: { ...respawn }, yaw: respawnYaw, fails: this.fails })++ this.teleport(respawn)++ } else {++ this.lastPos = { ...to }++ }++ return events++ }++}++++++/**++ * Move a capsule to a respawn point with zero velocity. Shared by the server's++ * checkpoint/fall teleport and the tests that prove a fail does not stop the++ * clock.++ */++export function placeMoveStateAt(++ state: PlayerMoveState,++ to: SurfVec3,++ yaw?: number,++): void {++ state.pos.x = to.x++ state.pos.y = to.y++ state.pos.z = to.z++ state.vel.x = 0++ state.vel.y = 0++ state.vel.z = 0++ state.grounded = false++ if (yaw !== undefined) {++ // The move state has no yaw; callers that own a session set its view.++ }++}nnew file mode 100644@@@ -0,0 +1,22 @@++-- phase: expand++-- OpenVibe.Surf Ride: server-derived time-trial runs. A run is authoritative++-- (the server simulated it); the client never supplies a time or position.++-- The track is a gzipped transform track for the ghost: positions at 1 cm++-- and yaw at 1/256 turn. `is_pb` marks the one personal best kept per++-- (map, account); the last five other runs are kept beside it, and the world++-- record is derived as MIN(time_ms) per map.++CREATE TABLE IF NOT EXISTS surf_runs (++ id text COLLATE "C" PRIMARY KEY,++ map_id text COLLATE "C" NOT NULL,++ account_key text COLLATE "C" NOT NULL,++ player_name text NOT NULL,++ time_ms integer NOT NULL CHECK (time_ms >= 0),++ splits jsonb NOT NULL,++ track bytea NOT NULL,++ is_pb boolean NOT NULL DEFAULT false,++ created_at bigint NOT NULL++);++CREATE INDEX IF NOT EXISTS surf_runs_board ON surf_runs (map_id, time_ms ASC, created_at ASC);++CREATE INDEX IF NOT EXISTS surf_runs_player ON surf_runs (map_id, account_key, created_at DESC);++CREATE UNIQUE INDEX IF NOT EXISTS surf_runs_one_pb++ ON surf_runs (map_id, account_key) WHERE is_pb;@@@ -41,6 +41,7 @@ const TABLES = ['event_outbox','token_revocations','steam_list_entries',++ 'surf_runs',].join(', ')let shared: Db | null = null@@@ -3,5 +3,6 @@ export * from './appearance.js'export * from './wire.js'export * from './messages/client.js'export * from './messages/server.js'++export * from './messages/surf.js'export * from './codec.js'export * from './editor.js'@@@ -1,6 +1,7 @@import { z } from 'zod'import { ANGLE_Q_MAX, ANGLE_Q_MIN, PITCH_Q_MAX, intentMask } from '@openvibe/shared'import { AppearanceSchema } from '../appearance.js'++import { ClientSurfRestartSchema } from './surf.js'/*** How many characters one account (or one guest browser) may keep in a game. Not a feature: a technical ceiling for the@@@ -328,6 +329,7 @@ export const ClientMessageSchema = z.union([ClientContainerOpenSchema,ClientContainerMoveSchema,ClientContainerSortSchema,++ ClientSurfRestartSchema,])export type ClientHello = z.infer<typeof ClientHelloSchema>@@@ -358,4 +360,5 @@ export type ClientJobTurnIn = z.infer<typeof ClientJobTurnInSchema>export type ClientContainerOpen = z.infer<typeof ClientContainerOpenSchema>export type ClientContainerMove = z.infer<typeof ClientContainerMoveSchema>export type ClientContainerSort = z.infer<typeof ClientContainerSortSchema>++export type { ClientSurfRestart } from './surf.js'export type ClientMessage = z.infer<typeof ClientMessageSchema>@@@ -7,6 +7,7 @@ import type {WirePlayerState,WirePlant,} from '../wire.js'++import type { ServerSurfFinish, ServerSurfState } from './surf.js'/*** Server -> client messages. Plain types (no zod): the client trusts the@@@ -271,6 +272,8 @@ export interface ServerContainer {}export type ServerMessage =++ | ServerSurfState++ | ServerSurfFinish| {t: 'salvage_round'roundId: stringnnew file mode 100644@@@ -0,0 +1,52 @@++import { describe, expect, it } from 'vitest'++import { decodeClientMessage, encodeClientMessage } from '../codec.js'++import { SurfFinishSchema, SurfStateSchema } from './surf.js'++++const state = {++ t: 'surf_state' as const,++ running: true,++ elapsedMs: 12_345,++ stage: 2,++ stages: 6,++ splits: [3_000, 7_000],++ pbMs: 11_000,++ wrMs: 9_500,++ pbSplits: [2_900, 6_900],++}++++const finish = {++ t: 'surf_finish' as const,++ timeMs: 10_000,++ splits: [3_000, 7_000, 10_000],++ pb: true,++ wr: false,++ rank: 4,++ pbMs: 10_000,++ wrMs: 9_500,++}++++describe('surf protocol', () => {++ it('accepts the client reset and round-trips it', () => {++ const msg = { t: 'surf_restart' as const }++ expect(decodeClientMessage(encodeClientMessage(msg))).toEqual(msg)++ })++++ it('validates surf_state ranges', () => {++ expect(SurfStateSchema.safeParse(state).success).toBe(true)++ expect(SurfStateSchema.safeParse({ ...state, elapsedMs: -1 }).success).toBe(false)++ expect(SurfStateSchema.safeParse({ ...state, elapsedMs: 86_400_001 }).success).toBe(false)++ expect(SurfStateSchema.safeParse({ ...state, stage: 65 }).success).toBe(false)++ expect(SurfStateSchema.safeParse({ ...state, stages: 0 }).success).toBe(false)++ expect(SurfStateSchema.safeParse({ ...state, splits: [-1] }).success).toBe(false)++ expect(SurfStateSchema.safeParse({ ...state, splits: new Array(65).fill(1) }).success).toBe(false)++ expect(SurfStateSchema.safeParse({ ...state, pbMs: 86_400_001 }).success).toBe(false)++ })++++ it('validates surf_finish ranges and accepts a Play-less finish', () => {++ expect(SurfFinishSchema.safeParse(finish).success).toBe(true)++ expect(SurfFinishSchema.safeParse({ ...finish, rank: null }).success).toBe(true)++ expect(SurfFinishSchema.safeParse({ ...finish, timeMs: -5 }).success).toBe(false)++ expect(SurfFinishSchema.safeParse({ ...finish, rank: 0 }).success).toBe(false)++ expect(SurfFinishSchema.safeParse({ ...finish, splits: [-5] }).success).toBe(false)++ })++})nnew file mode 100644@@@ -0,0 +1,56 @@++import { z } from 'zod'++++/**++ * Surf (OpenVibe.Surf Ride) protocol. Times are integer milliseconds measured++ * by the server; a run's track and splits are server-derived, never client++ * timestamps. The schemas are the range contract: values outside a plausible++ * run (a day, 64 stages, 64 splits) are refused rather than rendered.++ */++export const SURF_MAX_TIME_MS = 86_400_000++export const SURF_MAX_STAGES = 64++export const SURF_MAX_SPLITS = 64++++const timeMs = z.number().int().min(0).max(SURF_MAX_TIME_MS)++const splitList = z.array(timeMs).max(SURF_MAX_SPLITS)++const stage = z.number().int().min(0).max(SURF_MAX_STAGES)++++export const ClientSurfRestartSchema = z.object({++ t: z.literal('surf_restart'),++})++export type ClientSurfRestart = z.infer<typeof ClientSurfRestartSchema>++++export const SurfStateSchema = z.object({++ t: z.literal('surf_state'),++ /** The clock is running (start crossed, finish not yet). */++ running: z.boolean(),++ /** Server time since the start crossing, in ms (startTick-relative). */++ elapsedMs: timeMs,++ /** Checkpoints reached this run (0 = at the start). */++ stage: stage,++ /** Total stages on this map, including the finish. */++ stages: z.number().int().min(1).max(SURF_MAX_STAGES),++ /** Split times so far, in stage order, relative to the start. */++ splits: splitList,++ /** Personal best for this player on this map, if they have one. */++ pbMs: timeMs.nullable(),++ /** World record on this map, if one has been set. */++ wrMs: timeMs.nullable(),++ /** The PB's split times, for the per-split delta (absent until one exists). */++ pbSplits: splitList.optional(),++})++export type ServerSurfState = z.infer<typeof SurfStateSchema>++++export const SurfFinishSchema = z.object({++ t: z.literal('surf_finish'),++ timeMs,++ splits: splitList,++ /** This run is the player's new PB. */++ pb: z.boolean(),++ /** This run is the map's new world record. */++ wr: z.boolean(),++ /** The result Play reported, when it accepted a `time` post. */++ rank: z.number().int().min(1).max(1_000_000).nullable(),++ pbMs: timeMs.nullable(),++ wrMs: timeMs.nullable(),++})++export type ServerSurfFinish = z.infer<typeof SurfFinishSchema>nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/server/src/game/surfMaps.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/surfRules.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/platform/surfRuns.test.ts'++// covers: 'apps/server/src/platform'++// Generated by node test/run.js --index; do not edit.nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'packages/gameplay/src/movement/surf.test.ts'++// covers: 'packages/gameplay/src/movement'++// Generated by node test/run.js --index; do not edit.nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'packages/gameplay/src/surf/run.test.ts'++// covers: 'packages/gameplay/src/surf'++// Generated by node test/run.js --index; do not edit.nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'packages/protocol/src/messages/surf.test.ts'++// covers: 'packages/protocol/src/messages'++// Generated by node test/run.js --index; do not edit.
@@ -867,7 +867,7 @@ section('O. Live static reconciliation reaches the running server')
@@ -867,7 +867,7 @@ section('O. Live static reconciliation reaches the running server')
/** Wait for the tick loop to publish, then read the live map counts. */
/** Wait for the tick loop to publish, then read the live map counts. */
const counts = async (): Promise<Record<string, number>> => {
const counts = async (): Promise<Record<string, number>> => {
for (let i = 0; i < 40; i++) {
for (let i = 0; i < 40; i++) {
- const m = (await (await fetch(`${root}/metrics`)).json()) as Record<string, number>
+ const m = (await (await fetch(`${root}/metrics?place=scraplandia`)).json()) as Record<string, number>
if (m['tick']! > 0) return m
if (m['tick']! > 0) return m
await new Promise((r) => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
}
}
@@ -1126,7 +1126,7 @@ section('U. Live reconciliation touches only what changed')
@@ -1126,7 +1126,7 @@ section('U. Live reconciliation touches only what changed')
const root = `http://127.0.0.1:${port}`
const root = `http://127.0.0.1:${port}`
const metrics = async (): Promise<Record<string, number>> => {
const metrics = async (): Promise<Record<string, number>> => {
for (let i = 0; i < 40; i++) {
for (let i = 0; i < 40; i++) {
- const m = (await (await fetch(`${root}/metrics`)).json()) as Record<string, number>
+ const m = (await (await fetch(`${root}/metrics?place=scraplandia`)).json()) as Record<string, number>
if (m['tick']! > 0) return m
if (m['tick']! > 0) return m
await new Promise((r) => setTimeout(r, 100))
await new Promise((r) => setTimeout(r, 100))
}
}
@@ -62,7 +62,15 @@
@@ -62,7 +62,15 @@
<link rel="stylesheet" href="/src/ui/styles.css" />
<link rel="stylesheet" href="/src/ui/styles.css" />
<script>
<script>
window.__ovgFrame = { page: 'play', title: 'Scraplandia', collapsible: true }
window.__ovgFrame = { page: 'play', title: 'Scraplandia', collapsible: true }
+ // The iframe route (/surf/embed) is the canvas and HUD only: an
+ // embedding site owns the page around it.
+ if (new URLSearchParams(location.search).get('embed') === '1')
+ document.documentElement.classList.add('ov-embed')
</script>
</script>
+ <style>
+ .ov-embed #navbar-mount, .ov-embed #ov-footer, .ov-embed .ovf, .ov-embed .ov-nav { display: none !important; }
+ .ov-embed, .ov-embed body, .ov-embed #stage { margin: 0; height: 100%; overflow: hidden; background: #070b12; }
+ </style>
<script src="/frame.js" defer></script>
<script src="/frame.js" defer></script>
</head>
</head>
<body>
<body>
@@ -9,6 +9,7 @@ import {
@@ -9,6 +9,7 @@ import {
stepMovement,
stepMovement,
type CollisionQueries,
type CollisionQueries,
type MoveInput,
type MoveInput,
+ type MovementParams,
type PlayerMoveState,
type PlayerMoveState,
} from '@openvibe/gameplay'
} from '@openvibe/gameplay'
import { CollisionLayer, type PhysicsWorld } from '@openvibe/physics'
import { CollisionLayer, type PhysicsWorld } from '@openvibe/physics'
@@ -33,8 +34,6 @@ const TOP_DOWN_ABOVE = 120
@@ -33,8 +34,6 @@ const TOP_DOWN_ABOVE = 120
* already are and nothing visibly changes.
* already are and nothing visibly changes.
*/
*/
-const MOVE = DEFAULT_MOVEMENT
-
export class LocalPlayer {
export class LocalPlayer {
readonly camera: UniversalCamera
readonly camera: UniversalCamera
readonly move: PlayerMoveState
readonly move: PlayerMoveState
@@ -47,7 +46,7 @@ export class LocalPlayer {
@@ -47,7 +46,7 @@ export class LocalPlayer {
/** Interpolated render position (capsule center), updated each frame. */
/** Interpolated render position (capsule center), updated each frame. */
readonly renderPos = new Vector3()
readonly renderPos = new Vector3()
/** Smoothed eye height so stance changes glide instead of popping. */
/** Smoothed eye height so stance changes glide instead of popping. */
- private eyeSmooth = DEFAULT_MOVEMENT.eyeOffset
+ private eyeSmooth: number
/** Reconciliation error, blended away over ~100ms instead of snapping —
/** Reconciliation error, blended away over ~100ms instead of snapping —
* this is what makes standing on moving props watchable. */
* this is what makes standing on moving props watchable. */
private readonly corr = new Vector3()
private readonly corr = new Vector3()
@@ -75,11 +74,14 @@ export class LocalPlayer {
@@ -75,11 +74,14 @@ export class LocalPlayer {
private readonly connection: Connection,
private readonly connection: Connection,
private readonly state: ClientState,
private readonly state: ClientState,
spawn: { x: number; y: number; z: number },
spawn: { x: number; y: number; z: number },
+ /** The place's movement preset, resolved on both sides (surf vs default). */
+ private readonly params: MovementParams = DEFAULT_MOVEMENT,
) {
) {
this.move = createMoveState(spawn)
this.move = createMoveState(spawn)
+ this.eyeSmooth = params.eyeOffset
this.camera = new UniversalCamera(
this.camera = new UniversalCamera(
'cam',
'cam',
- new Vector3(spawn.x, spawn.y + MOVE.eyeOffset, spawn.z),
+ new Vector3(spawn.x, spawn.y + params.eyeOffset, spawn.z),
scene,
scene,
)
)
this.camera.minZ = 0.05
this.camera.minZ = 0.05
@@ -147,7 +149,7 @@ export class LocalPlayer {
@@ -147,7 +149,7 @@ export class LocalPlayer {
pitch: dequantiseAngle(cmd.pitchQ),
pitch: dequantiseAngle(cmd.pitchQ),
buttons: cmd.intents,
buttons: cmd.intents,
}
}
- stepMovement(this.move, input, MOVE, this.queries, 1 / this.state.tickRate)
+ stepMovement(this.move, input, this.params, this.queries, 1 / this.state.tickRate)
}
}
/** Reconcile against an authoritative snapshot. */
/** Reconcile against an authoritative snapshot. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+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 type { DecodedGhost } from '@openvibe/gameplay'
+
+/**
+ * A translucent capsule replaying an authoritative ghost track. The track is
+ * a recording of server positions/yaw (1 cm, 1/256 turn), so playback is an
+ * interpolation, never a re-simulation: no collision and no drift.
+ */
+export class SurfGhost {
+ private readonly mesh: Mesh
+ private track: DecodedGhost | null = null
+ private visible = true
+
+ constructor(scene: Scene) {
+ this.mesh = MeshBuilder.CreateCapsule(
+ 'surf-ghost',
+ { height: 1.8, radius: 0.4, tessellation: 12 },
+ scene,
+ )
+ const material = new StandardMaterial('surf-ghost-material', scene)
+ material.diffuseColor = new Color3(0.45, 0.85, 1)
+ material.emissiveColor = new Color3(0.15, 0.45, 0.7)
+ material.alpha = 0.35
+ this.mesh.material = material
+ this.mesh.isPickable = false
+ this.mesh.setEnabled(false)
+ }
+
+ get hasTrack(): boolean {
+ return this.track !== null
+ }
+
+ setTrack(track: DecodedGhost): void {
+ this.track = track
+ this.mesh.setEnabled(this.visible)
+ }
+
+ clear(): void {
+ this.track = null
+ this.mesh.setEnabled(false)
+ }
+
+ setVisible(visible: boolean): void {
+ this.visible = visible
+ this.mesh.setEnabled(visible && this.track !== null)
+ }
+
+ toggle(): boolean {
+ this.setVisible(!this.visible)
+ return this.visible
+ }
+
+ /** Place the capsule at `elapsedMs` into the recorded run. */
+ update(elapsedMs: number): void {
+ const track = this.track
+ if (!track || !this.visible || track.samples.length === 0) return
+ const pos = (elapsedMs / 1000) * track.tickRate
+ const i = Math.min(Math.max(Math.floor(pos), 0), track.samples.length - 1)
+ const j = Math.min(i + 1, track.samples.length - 1)
+ const f = Math.min(Math.max(pos - i, 0), 1)
+ const a = track.samples[i]!
+ const b = track.samples[j]!
+ this.mesh.position.set(
+ a.x + (b.x - a.x) * f,
+ a.y + (b.y - a.y) * f,
+ a.z + (b.z - a.z) * f,
+ )
+ let dy = b.yaw - a.yaw
+ if (dy > Math.PI) dy -= Math.PI * 2
+ if (dy < -Math.PI) dy += Math.PI * 2
+ this.mesh.rotation.y = a.yaw + dy * f
+ }
+}
@@ -9,7 +9,12 @@ import {
@@ -9,7 +9,12 @@ import {
emptyMapV2,
emptyMapV2,
type MapFileV2,
type MapFileV2,
parseMapFile,
parseMapFile,
+ surfMap,
+ surfMapOfPlace,
+ surfPack,
+ worldSpawn,
type MapTextureEntry,
type MapTextureEntry,
+ type SurfMapInfo,
} from '@openvibe/content'
} from '@openvibe/content'
import { MapLightLayer, mapDiff, staticWork, terrainWork } from './render/mapRuntimeLayers.js'
import { MapLightLayer, mapDiff, staticWork, terrainWork } from './render/mapRuntimeLayers.js'
import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'
import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'
@@ -19,7 +24,12 @@ import type { Appearance } from '@openvibe/protocol'
@@ -19,7 +24,12 @@ import type { Appearance } from '@openvibe/protocol'
import { Color3 } from '@babylonjs/core/Maths/math.color.js'
import { Color3 } from '@babylonjs/core/Maths/math.color.js'
import { Scene } from '@babylonjs/core/scene.js'
import { Scene } from '@babylonjs/core/scene.js'
import { WATER_LEVEL, getMapOverride, terrainHeight } from '@openvibe/content'
import { WATER_LEVEL, getMapOverride, terrainHeight } from '@openvibe/content'
-import { DEFAULT_MOVEMENT } from '@openvibe/gameplay'
+import {
+ DEFAULT_MOVEMENT,
+ decodeSurfTrack,
+ movementParamsFor,
+ movementStyleFor,
+} from '@openvibe/gameplay'
import { Sfx, footstepSurface, materialForVisual } from './audio/sfx.js'
import { Sfx, footstepSurface, materialForVisual } from './audio/sfx.js'
import { HitChips } from './render/hitChips.js'
import { HitChips } from './render/hitChips.js'
import { InteractionController } from './game/interactionController.js'
import { InteractionController } from './game/interactionController.js'
@@ -82,11 +92,20 @@ async function start(): Promise<void> {
@@ -82,11 +92,20 @@ async function start(): Promise<void> {
// A creator world (/worlds): its own place, the plain creator pack and the world's map.
// A creator world (/worlds): its own place, the plain creator pack and the world's map.
const requestedWorld = params.get('world')
const requestedWorld = params.get('world')
const worldId = requestedWorld && CREATOR_WORLD_ID.test(requestedWorld) ? requestedWorld : null
const worldId = requestedWorld && CREATOR_WORLD_ID.test(requestedWorld) ? requestedWorld : null
+ // OpenVibe.Surf Ride: ?map=<id> (or ?game=surf&map=…). A map is a place
+ // (`surf-lagoon`), so the existing place URL scheme carries it unchanged.
+ const requestedMap = params.get('map')
+ const surfInfo: SurfMapInfo | null =
+ (requestedMap ? surfMap(requestedMap) : null) ??
+ (requestedGame === 'surf' ? surfMap('surf_lagoon') : null) ??
+ null
const selectedPlace = worldId
const selectedPlace = worldId
? creatorPlaceId(worldId)
? creatorPlaceId(worldId)
- : requestedGame === 'salvage-rush' || requestedGame === 'scraplandia'
- ? requestedGame
- : undefined
+ : surfInfo
+ ? surfInfo.placeId
+ : requestedGame === 'salvage-rush' || requestedGame === 'scraplandia'
+ ? requestedGame
+ : undefined
const placeQuery = worldId ? `?world=${worldId}` : selectedPlace ? `?game=${selectedPlace}` : ''
const placeQuery = worldId ? `?world=${worldId}` : selectedPlace ? `?game=${selectedPlace}` : ''
// A draft world's map answers only its owner, so the map read carries the Network token when there is one.
// A draft world's map answers only its owner, so the map read carries the Network token when there is one.
const mapRequest = (): Promise<Response> => {
const mapRequest = (): Promise<Response> => {
@@ -120,12 +139,15 @@ async function start(): Promise<void> {
@@ -120,12 +139,15 @@ async function start(): Promise<void> {
packs?: Parameters<typeof createContent>[0]
packs?: Parameters<typeof createContent>[0]
}
}
if (Array.isArray(data.packs)) {
if (Array.isArray(data.packs)) {
+ const contentSurf = data.game ? surfMapOfPlace(data.game) : null
const builtIn =
const builtIn =
- data.game === 'salvage-rush'
- ? (await import('@openvibe/content/salvage-rush')).SALVAGE_RUSH_PACK
- : data.game === 'creator' && worldId
- ? creatorWorldPack(worldId)
- : undefined
+ contentSurf
+ ? surfPack(contentSurf.id)
+ : data.game === 'salvage-rush'
+ ? (await import('@openvibe/content/salvage-rush')).SALVAGE_RUSH_PACK
+ : data.game === 'creator' && worldId
+ ? creatorWorldPack(worldId)
+ : undefined
const active = createContent(data.packs, builtIn)
const active = createContent(data.packs, builtIn)
if (active.digest === data.digest) content = active
if (active.digest === data.digest) content = active
}
}
@@ -137,6 +159,10 @@ async function start(): Promise<void> {
@@ -137,6 +159,10 @@ async function start(): Promise<void> {
uiRoot.textContent = 'Salvage Rush content is unavailable. Please retry shortly.'
uiRoot.textContent = 'Salvage Rush content is unavailable. Please retry shortly.'
return
return
}
}
+ if (surfInfo && content.world.id !== surfInfo.id) {
+ uiRoot.textContent = 'Surf 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.
@@ -312,6 +338,9 @@ async function start(): Promise<void> {
@@ -312,6 +338,9 @@ async function start(): Promise<void> {
({ SalvageRushHud }) => new SalvageRushHud(uiRoot, scene),
({ SalvageRushHud }) => new SalvageRushHud(uiRoot, scene),
)
)
: null
: null
+ const surfUi = surfInfo
+ ? await import('./ui/surfHud.js').then(({ SurfHud }) => new SurfHud(uiRoot))
+ : 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)
@@ -325,6 +354,16 @@ async function start(): Promise<void> {
@@ -325,6 +354,16 @@ async function start(): Promise<void> {
const touchPad = new TouchPad()
const touchPad = new TouchPad()
const touchSource = new TouchInputSource(touchPad)
const touchSource = new TouchInputSource(touchPad)
const touchControls = new TouchControls(uiRoot, canvas, touchPad)
const touchControls = new TouchControls(uiRoot, canvas, touchPad)
+ const surfTrainer = surfInfo
+ ? await import('./ui/surfTrainer.js').then(
+ ({ SurfTrainer }) =>
+ new SurfTrainer(uiRoot, canvas, touchPad, () => {
+ surfUi?.hideFinish()
+ connection.send({ t: 'surf_restart' })
+ }),
+ )
+ : null
+ if (surfInfo) void import('./ui/surfTrainer.css')
const switchable = new SwitchableInputSource(keyboardSource)
const switchable = new SwitchableInputSource(keyboardSource)
// From above, WASD (or the stick) moves along the screen and the mouse aims; see topDownInputSource.ts.
// From above, WASD (or the stick) moves along the screen and the mouse aims; see topDownInputSource.ts.
const topDown = (await worldView) === 'top'
const topDown = (await worldView) === 'top'
@@ -353,7 +392,8 @@ async function start(): Promise<void> {
@@ -353,7 +392,8 @@ async function start(): Promise<void> {
}
}
switchable.current = next
switchable.current = next
if (touch) pointerOffset.known = false
if (touch) pointerOffset.known = false
- touchControls.setActive(touch)
+ touchControls.setActive(touch && !surfInfo)
+ surfTrainer?.setActive(touch && !!surfInfo)
}
}
useTouch(matchMedia('(pointer: coarse)').matches)
useTouch(matchMedia('(pointer: coarse)').matches)
// Capture phase: the switch happens before the press is handled.
// Capture phase: the switch happens before the press is handled.
@@ -368,11 +408,20 @@ async function start(): Promise<void> {
@@ -368,11 +408,20 @@ async function start(): Promise<void> {
)
)
const world = content.world
const world = content.world
- const player = new LocalPlayer(scene, physics, inputSource, connection, state, {
- x: world.spawnPoint[0],
- y: world.spawnPoint[1],
- z: world.spawnPoint[2],
- })
+ const placeSpawn = worldSpawn(world)
+ const player = new LocalPlayer(
+ scene,
+ physics,
+ inputSource,
+ connection,
+ state,
+ {
+ x: placeSpawn.pos[0],
+ y: placeSpawn.pos[1],
+ z: placeSpawn.pos[2],
+ },
+ movementParamsFor(movementStyleFor(content.world)),
+ )
scene.activeCamera = player.camera
scene.activeCamera = player.camera
releaseCamera()
releaseCamera()
environment.attachCamera(player.camera)
environment.attachCamera(player.camera)
@@ -411,6 +460,63 @@ async function start(): Promise<void> {
@@ -411,6 +460,63 @@ async function start(): Promise<void> {
const beams = new BeamRenderer(scene)
const beams = new BeamRenderer(scene)
const constraintLines = new ConstraintLineRenderer(scene, state, view)
const constraintLines = new ConstraintLineRenderer(scene, state, view)
const ghost = new PlacementGhost(scene, physics, content)
const ghost = new PlacementGhost(scene, physics, content)
+ const surfGhost = surfInfo
+ ? await import('./game/surfGhost.js').then(({ SurfGhost }) => new SurfGhost(scene))
+ : null
+ const surfGhostHeaders = (): Record<string, string> => {
+ const headers: Record<string, string> = {}
+ if (sso) headers.authorization = `Bearer ${sso}`
+ else headers['x-guest-token'] = identity.token
+ return headers
+ }
+ const loadSurfGhost = async (of: 'pb' | 'wr'): Promise<boolean> => {
+ if (!surfInfo || !surfGhost) return false
+ try {
+ const response = await fetch(
+ `/api/surf/maps/${encodeURIComponent(surfInfo.id)}/ghost?of=${of}`,
+ { cache: 'no-store', headers: surfGhostHeaders() },
+ )
+ if (!response.ok) return false
+ const bytes = new Uint8Array(await response.arrayBuffer())
+ surfGhost.setTrack(decodeSurfTrack(bytes))
+ return true
+ } catch {
+ return false
+ }
+ }
+ if (surfUi && surfGhost) {
+ surfUi.onRestart = () => {
+ surfUi.hideFinish()
+ connection.send({ t: 'surf_restart' })
+ }
+ surfUi.onWatchWr = () => {
+ surfGhost.setVisible(true)
+ void loadSurfGhost('wr')
+ }
+ // Your PB is the default ghost; the WR is the fallback before your first run.
+ void loadSurfGhost('pb').then((ok) => (ok ? true : loadSurfGhost('wr')))
+ }
+ // Ride's keys. Capture-phase: InputTracker would otherwise read R as
+ // reload and G as drop, neither of which exists in a surf place.
+ document.addEventListener(
+ 'keydown',
+ (e) => {
+ if (!surfInfo) return
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
+ if (input.uiCapture) return
+ if (e.code !== 'KeyR' && e.code !== 'KeyG') return
+ e.preventDefault()
+ e.stopImmediatePropagation()
+ if (e.code === 'KeyR') {
+ surfUi?.hideFinish()
+ connection.send({ t: 'surf_restart' })
+ } else {
+ const on = surfGhost?.toggle() ?? false
+ hud.toast(on ? 'Ghost on' : 'Ghost off', false)
+ }
+ },
+ true,
+ )
interact.placementPose = () => (ghost.valid ? ghost.pose : null)
interact.placementPose = () => (ghost.valid ? ghost.pose : null)
interact.onPlacementRotate = (delta) => ghost.rotate(delta)
interact.onPlacementRotate = (delta) => ghost.rotate(delta)
@@ -529,6 +635,12 @@ async function start(): Promise<void> {
@@ -529,6 +635,12 @@ async function start(): Promise<void> {
let rejected = false
let rejected = false
connection.onMessage = (msg) => {
connection.onMessage = (msg) => {
if (msg.t === 'salvage_round') roundUi?.update(msg)
if (msg.t === 'salvage_round') roundUi?.update(msg)
+ if (msg.t === 'surf_state') surfUi?.updateState(msg)
+ if (msg.t === 'surf_finish') {
+ surfUi?.showFinish(msg)
+ // The just-finished run may be the new PB: refresh its ghost.
+ setTimeout(() => void loadSurfGhost('pb'), 750)
+ }
if (msg.t === 'welcome' && state.myEntityId && msg.instanceId !== state.instanceId) {
if (msg.t === 'welcome' && state.myEntityId && msg.instanceId !== state.instanceId) {
player.onTransfer()
player.onTransfer()
}
}
@@ -705,6 +817,11 @@ async function start(): Promise<void> {
@@ -705,6 +817,11 @@ async function start(): Promise<void> {
}
}
physics.step(0) // query-only world: refresh broadphase, no dynamics
physics.step(0) // query-only world: refresh broadphase, no dynamics
player.frameUpdate(timestep.alpha, elapsed)
player.frameUpdate(timestep.alpha, elapsed)
+ if (surfUi) {
+ surfUi.setSpeed(Math.hypot(player.move.vel.x, player.move.vel.z))
+ surfUi.tick(now)
+ surfGhost?.update(surfUi.elapsedNow(now))
+ }
view.update(now / 1000)
view.update(now / 1000)
markers?.update()
markers?.update()
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
+.surf-hud {
+ position: fixed;
+ z-index: 12;
+ top: max(1rem, env(safe-area-inset-top));
+ left: max(1rem, env(safe-area-inset-left));
+ width: min(18rem, calc(100vw - 2rem));
+ padding: 0.8rem 0.95rem 0.9rem;
+ border: 1px solid var(--border, #2a3340);
+ border-left: 3px solid var(--g-accent, #f0b040);
+ border-radius: 0.85rem;
+ background: color-mix(in srgb, var(--bg-secondary, #0a0e12) 88%, transparent);
+ backdrop-filter: blur(6px);
+ box-shadow: 0 12px 34px rgba(0, 0, 0, 0.45);
+ color: var(--text-primary, #e6e6e6);
+ font: 500 calc(0.85rem * var(--ov-text-scale, 1)) / 1.35 system-ui, sans-serif;
+}
+.surf-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+.surf-title {
+ font-size: 0.7rem;
+ font-weight: 800;
+ letter-spacing: 0.16em;
+ color: var(--g-accent-text, #f0b040);
+}
+.surf-stage {
+ font-weight: 800;
+ font-variant-numeric: tabular-nums;
+ color: var(--text-secondary, #b0b0b8);
+}
+.surf-timer {
+ margin: 0.1rem 0 0.15rem;
+ font-size: calc(2.15rem * var(--ov-text-scale, 1));
+ font-weight: 900;
+ line-height: 1.05;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: 0.02em;
+}
+.surf-meta {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.5rem;
+ font-size: 0.72rem;
+ color: var(--text-secondary, #b0b0b8);
+}
+.surf-speed {
+ font-variant-numeric: tabular-nums;
+ color: var(--text-primary, #e6e6e6);
+}
+.surf-hint { opacity: 0.7; }
+.surf-splits {
+ margin: 0.55rem 0 0;
+ padding: 0;
+ list-style: none;
+ border-top: 1px solid var(--border, #2a3340);
+}
+.surf-split {
+ display: grid;
+ grid-template-columns: 1.8rem minmax(0, 1fr) auto;
+ align-items: baseline;
+ gap: 0.4rem;
+ padding: 0.22rem 0;
+ font-variant-numeric: tabular-nums;
+}
+.surf-split.is-empty {
+ display: block;
+ color: var(--text-secondary, #b0b0b8);
+ font-size: 0.75rem;
+}
+.surf-split-label { color: var(--text-secondary, #b0b0b8); font-size: 0.72rem; }
+.surf-split-time { font-weight: 700; }
+.surf-delta {
+ font-size: 0.72rem;
+ font-weight: 800;
+ padding: 0.05rem 0.3rem;
+ border-radius: 0.3rem;
+}
+.surf-delta.is-faster { color: #6ee7a0; background: rgba(110, 231, 160, 0.12); }
+.surf-delta.is-slower { color: #fca5a5; background: rgba(252, 165, 165, 0.12); }
+.surf-finish {
+ margin-top: 0.6rem;
+ padding-top: 0.6rem;
+ border-top: 1px solid var(--border, #2a3340);
+}
+.surf-finish[hidden] { display: none; }
+.surf-finish-title {
+ margin: 0;
+ font-size: 0.7rem;
+ letter-spacing: 0.16em;
+ color: var(--g-accent-text, #f0b040);
+}
+.surf-finish-time {
+ margin: 0.15rem 0 0.25rem;
+ font-size: calc(1.55rem * var(--ov-text-scale, 1));
+ font-weight: 900;
+ font-variant-numeric: tabular-nums;
+}
+.surf-finish.is-pb .surf-finish-pb { color: #6ee7a0; }
+.surf-finish.is-wr .surf-finish-wr { color: var(--g-accent-text, #f0b040); }
+.surf-finish-pb, .surf-finish-wr { margin: 0.1rem 0; font-size: 0.75rem; }
+.surf-finish-actions { display: flex; gap: 0.45rem; margin-top: 0.5rem; }
+.surf-finish-actions button {
+ flex: 1;
+ padding: 0.4rem 0.5rem;
+ border: 1px solid var(--border, #2a3340);
+ border-radius: 0.45rem;
+ background: var(--g-action, #2a5a8a);
+ color: var(--g-on-action, #fff);
+ font: inherit;
+ font-size: 0.75rem;
+ font-weight: 700;
+ cursor: pointer;
+}
+.surf-watch { background: transparent !important; color: var(--text-primary, #e6e6e6) !important; }
+.surf-finish-actions button:disabled { opacity: 0.45; cursor: default; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,212 @@
@@ -0,0 +1,212 @@
+import type { ServerMessage } from '@openvibe/protocol'
+import './surfHud.css'
+
+type SurfState = Extract<ServerMessage, { t: 'surf_state' }>
+type SurfFinish = Extract<ServerMessage, { t: 'surf_finish' }>
+
+/** m:ss.mmm, the surf timer's format. */
+export function formatSurfTime(ms: number): string {
+ const clamped = Math.max(0, Math.round(ms))
+ const minutes = Math.floor(clamped / 60_000)
+ const seconds = Math.floor((clamped % 60_000) / 1000)
+ const millis = clamped % 1000
+ return `${minutes}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`
+}
+
+/** Engine m/s → Source units/s (1 unit = 1 inch = 0.0254 m). */
+export function unitsPerSecond(mps: number): number {
+ return mps / 0.0254
+}
+
+/**
+ * Ride's HUD: the authoritative timer, speed, stage and split deltas, and the
+ * finish card. Markup is semantic and `surf-`-classed so the session can
+ * restyle it without touching behaviour; colours are the shared theme tokens.
+ */
+export class SurfHud {
+ private readonly root: HTMLElement
+ private readonly timer: HTMLElement
+ private readonly speed: HTMLElement
+ private readonly stage: HTMLElement
+ private readonly splits: HTMLElement
+ private readonly finishCard: HTMLElement
+ private readonly finishTime: HTMLElement
+ private readonly finishPb: HTMLElement
+ private readonly finishWr: HTMLElement
+ private readonly watchButton: HTMLButtonElement
+ private state: SurfState | null = null
+ private baseElapsed = 0
+ private baseAt = performance.now()
+ private deltaUntil = new Map<number, number>()
+ private lastSplitCount = 0
+
+ onRestart: (() => void) | null = null
+ onWatchWr: (() => void) | null = null
+
+ constructor(parent: HTMLElement) {
+ this.root = document.createElement('aside')
+ this.root.className = 'surf-hud'
+ this.root.setAttribute('aria-label', 'Surf run')
+
+ const head = document.createElement('div')
+ head.className = 'surf-head'
+ const title = document.createElement('strong')
+ title.className = 'surf-title'
+ title.textContent = 'RIDE'
+ this.stage = document.createElement('span')
+ this.stage.className = 'surf-stage'
+ this.stage.textContent = '1/3'
+ head.append(title, this.stage)
+
+ this.timer = document.createElement('p')
+ this.timer.className = 'surf-timer'
+ this.timer.setAttribute('aria-live', 'off')
+ this.timer.textContent = '0:00.000'
+
+ const meta = document.createElement('div')
+ meta.className = 'surf-meta'
+ this.speed = document.createElement('span')
+ this.speed.className = 'surf-speed'
+ this.speed.textContent = '0 u/s'
+ const hint = document.createElement('span')
+ hint.className = 'surf-hint'
+ hint.textContent = 'R restart · G ghost'
+ meta.append(this.speed, hint)
+
+ this.splits = document.createElement('ol')
+ this.splits.className = 'surf-splits'
+ this.splits.setAttribute('aria-label', 'Splits')
+
+ this.finishCard = document.createElement('section')
+ this.finishCard.className = 'surf-finish'
+ this.finishCard.hidden = true
+ const finishTitle = document.createElement('h2')
+ finishTitle.className = 'surf-finish-title'
+ finishTitle.textContent = 'RUN COMPLETE'
+ this.finishTime = document.createElement('p')
+ this.finishTime.className = 'surf-finish-time'
+ this.finishTime.setAttribute('aria-live', 'polite')
+ this.finishPb = document.createElement('p')
+ this.finishPb.className = 'surf-finish-pb'
+ this.finishWr = document.createElement('p')
+ this.finishWr.className = 'surf-finish-wr'
+ const actions = document.createElement('div')
+ actions.className = 'surf-finish-actions'
+ const again = document.createElement('button')
+ again.type = 'button'
+ again.className = 'surf-again'
+ again.textContent = 'Again (R)'
+ again.addEventListener('click', () => this.onRestart?.())
+ this.watchButton = document.createElement('button')
+ this.watchButton.type = 'button'
+ this.watchButton.className = 'surf-watch'
+ this.watchButton.textContent = 'Watch the WR'
+ this.watchButton.addEventListener('click', () => this.onWatchWr?.())
+ actions.append(again, this.watchButton)
+ this.finishCard.append(finishTitle, this.finishTime, this.finishPb, this.finishWr, actions)
+
+ this.root.append(head, this.timer, meta, this.splits, this.finishCard)
+ parent.append(this.root)
+ }
+
+ updateState(state: SurfState): void {
+ this.state = state
+ this.baseElapsed = state.elapsedMs
+ this.baseAt = performance.now()
+ this.stage.textContent = `${Math.min(state.stage + 1, state.stages)}/${state.stages}`
+ this.root.classList.toggle('is-running', state.running)
+ // A fresh run (idle, zero time) puts the card away even when the reset
+ // came from re-entering the start zone rather than the button.
+ if (!state.running && state.elapsedMs === 0) this.hideFinish()
+ this.renderSplits(state)
+ }
+
+ /** Speed in engine m/s, read from prediction; shown in Source units/s. */
+ setSpeed(mps: number): void {
+ this.speed.textContent = `${Math.round(unitsPerSecond(mps))} u/s`
+ }
+
+ showFinish(finish: SurfFinish): void {
+ this.finishCard.hidden = false
+ this.finishTime.textContent = formatSurfTime(finish.timeMs)
+ this.finishPb.textContent = finish.pb
+ ? 'NEW PERSONAL BEST'
+ : finish.pbMs === null
+ ? 'First run — set a PB'
+ : `PB ${formatSurfTime(finish.pbMs)}`
+ const rank = finish.rank ? ` · rank #${finish.rank}` : ''
+ this.finishWr.textContent = finish.wr
+ ? `WORLD RECORD${rank}`
+ : finish.wrMs === null
+ ? 'No WR yet'
+ : `WR ${formatSurfTime(finish.wrMs)}${rank}`
+ this.finishCard.classList.toggle('is-pb', finish.pb)
+ this.finishCard.classList.toggle('is-wr', finish.wr)
+ this.watchButton.disabled = finish.wrMs === null
+ }
+
+ hideFinish(): void {
+ this.finishCard.hidden = true
+ }
+
+ /** The currently displayed elapsed time (server base + local interpolation). */
+ elapsedNow(now = performance.now()): number {
+ const state = this.state
+ if (!state) return 0
+ return state.running ? this.baseElapsed + Math.max(0, now - this.baseAt) : state.elapsedMs
+ }
+
+ /** Per-frame: interpolate the server clock and expire split deltas. */
+ tick(now = performance.now()): void {
+ const state = this.state
+ if (!state) return
+ this.timer.textContent = formatSurfTime(this.elapsedNow(now))
+ let changed = false
+ for (const [stage, until] of this.deltaUntil)
+ if (now >= until) {
+ this.deltaUntil.delete(stage)
+ changed = true
+ }
+ if (changed) this.renderSplits(state)
+ }
+
+ private renderSplits(state: SurfState): void {
+ if (state.splits.length > this.lastSplitCount) {
+ const now = performance.now()
+ for (let i = this.lastSplitCount; i < state.splits.length; i++)
+ this.deltaUntil.set(i, now + 3000)
+ this.lastSplitCount = state.splits.length
+ } else if (state.splits.length < this.lastSplitCount) {
+ this.lastSplitCount = state.splits.length
+ this.deltaUntil.clear()
+ }
+ if (state.splits.length === 0) {
+ const empty = document.createElement('li')
+ empty.className = 'surf-split is-empty'
+ empty.textContent = 'Leave the start to begin.'
+ this.splits.replaceChildren(empty)
+ return
+ }
+ const rows = state.splits.map((timeMs, i) => {
+ const item = document.createElement('li')
+ item.className = 'surf-split'
+ const label = document.createElement('span')
+ label.className = 'surf-split-label'
+ label.textContent = `S${i + 1}`
+ const value = document.createElement('span')
+ value.className = 'surf-split-time'
+ value.textContent = formatSurfTime(timeMs)
+ item.append(label, value)
+ const pb = state.pbSplits?.[i]
+ if (pb !== undefined && this.deltaUntil.has(i)) {
+ const delta = timeMs - pb
+ const badge = document.createElement('span')
+ badge.className = `surf-delta ${delta <= 0 ? 'is-faster' : 'is-slower'}`
+ badge.textContent = `${delta <= 0 ? '−' : '+'}${formatSurfTime(Math.abs(delta))}`
+ item.append(badge)
+ }
+ return item
+ })
+ this.splits.replaceChildren(...rows)
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+.surf-trainer {
+ position: fixed;
+ inset: 0;
+ z-index: 11;
+ pointer-events: none;
+ user-select: none;
+ -webkit-user-select: none;
+}
+.surf-trainer[hidden] { display: none; }
+.surf-trainer-look {
+ position: absolute;
+ inset: 0;
+ pointer-events: auto;
+ touch-action: none;
+}
+.surf-trainer-strafe {
+ position: absolute;
+ bottom: max(1.2rem, env(safe-area-inset-bottom));
+ width: min(34vw, 11rem);
+ height: min(28vh, 9rem);
+ border: 2px solid color-mix(in srgb, var(--g-accent, #f0b040) 60%, transparent);
+ border-radius: 1.1rem;
+ background: color-mix(in srgb, var(--g-accent, #f0b040) 18%, rgba(8, 12, 20, 0.55));
+ color: var(--text-primary, #fff);
+ font: 800 calc(0.9rem * var(--ov-text-scale, 1)) / 1.2 system-ui, sans-serif;
+ pointer-events: auto;
+ touch-action: none;
+}
+.surf-trainer-left { left: max(1rem, env(safe-area-inset-left)); }
+.surf-trainer-right { right: max(1rem, env(safe-area-inset-right)); }
+.surf-trainer-strafe:active {
+ background: color-mix(in srgb, var(--g-accent, #f0b040) 45%, rgba(8, 12, 20, 0.5));
+}
+.surf-trainer-restart {
+ position: absolute;
+ top: max(0.8rem, env(safe-area-inset-top));
+ right: max(0.8rem, env(safe-area-inset-right));
+ padding: 0.5rem 0.8rem;
+ border: 1px solid var(--border, #2a3340);
+ border-radius: 0.5rem;
+ background: var(--g-action, #2a5a8a);
+ color: var(--g-on-action, #fff);
+ font: 700 0.8rem system-ui, sans-serif;
+ pointer-events: auto;
+ touch-action: none;
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+import type { TouchPad } from '../input/touchInputSource.js'
+
+/**
+ * Touch's Ride control: two big strafe zones (left/right) and drag-to-look.
+ * It is deliberately a trainer, not a virtual stick: surf is about holding a
+ * direction into the ramp and turning, and two thumb-sized zones make that
+ * possible on a phone. The zones drive the same TouchPad the rest of the
+ * game uses, so prediction and intent bits are identical.
+ */
+export class SurfTrainer {
+ private readonly root: HTMLElement
+ private readonly look: HTMLElement
+ private active = false
+
+ constructor(
+ parent: HTMLElement,
+ private readonly canvas: HTMLCanvasElement,
+ private readonly pad: TouchPad,
+ private readonly onRestart: () => void,
+ ) {
+ this.root = document.createElement('div')
+ this.root.className = 'surf-trainer'
+ this.root.hidden = true
+ this.look = document.createElement('div')
+ this.look.className = 'surf-trainer-look'
+ this.look.setAttribute('aria-hidden', 'true')
+ const left = this.zone('left', '◀ hold left', -1)
+ const right = this.zone('right', 'hold right ▶', 1)
+ const restart = document.createElement('button')
+ restart.type = 'button'
+ restart.className = 'surf-trainer-restart'
+ restart.textContent = 'Restart'
+ restart.addEventListener('click', () => this.onRestart())
+ this.root.append(this.look, left, right, restart)
+ parent.append(this.root)
+ this.bindLook()
+ }
+
+ setActive(active: boolean): void {
+ this.active = active
+ this.root.hidden = !active
+ if (!active) this.pad.releaseAll(performance.now())
+ this.canvas.style.touchAction = active ? 'none' : ''
+ }
+
+ private zone(side: 'left' | 'right', label: string, sign: -1 | 1): HTMLElement {
+ const zone = document.createElement('button')
+ zone.type = 'button'
+ zone.className = `surf-trainer-strafe surf-trainer-${side}`
+ zone.textContent = label
+ zone.addEventListener('pointerdown', (e) => {
+ if (e.pointerType !== 'touch') return
+ e.preventDefault()
+ zone.setPointerCapture(e.pointerId)
+ const r = zone.getBoundingClientRect()
+ const cx = r.left + r.width / 2
+ const cy = r.top + r.height / 2
+ this.pad.stickRadius = r.width / 2
+ this.pad.stickDown(e.pointerId, cx, cy, cx, cy)
+ this.pad.move(e.pointerId, cx + sign * r.width, cy)
+ })
+ const lift = (e: PointerEvent): void => {
+ if (e.pointerType !== 'touch') return
+ this.pad.up(e.pointerId, e.timeStamp)
+ }
+ zone.addEventListener('pointerup', lift)
+ zone.addEventListener('pointercancel', lift)
+ return zone
+ }
+
+ private bindLook(): void {
+ this.look.addEventListener('pointerdown', (e) => {
+ if (e.pointerType !== 'touch') return
+ e.preventDefault()
+ this.look.setPointerCapture(e.pointerId)
+ this.pad.lookDown(e.pointerId, e.clientX, e.clientY)
+ })
+ // TouchControls' window-level pointermove already forwards the look
+ // finger to the same TouchPad; registering it again would double the
+ // sensitivity.
+ const lift = (e: PointerEvent): void => {
+ if (e.pointerType !== 'touch') return
+ this.pad.up(e.pointerId, e.timeStamp)
+ }
+ this.look.addEventListener('pointerup', lift)
+ this.look.addEventListener('pointercancel', lift)
+ }
+}
@@ -24,6 +24,7 @@ import {
@@ -24,6 +24,7 @@ import {
ReplicationSystem,
ReplicationSystem,
SessionsSystem,
SessionsSystem,
SocialSystem,
SocialSystem,
+ SurfSystem,
VehicleSystem,
VehicleSystem,
WorldEventSystem,
WorldEventSystem,
buildHandlerTable,
buildHandlerTable,
@@ -82,6 +83,9 @@ export class GameServer {
@@ -82,6 +83,9 @@ export class GameServer {
const ctx = this.ctx
const ctx = this.ctx
const sessions = new SessionsSystem(ctx)
const sessions = new SessionsSystem(ctx)
const movement = new MovementSystem(ctx)
const movement = new MovementSystem(ctx)
+ // Runs see this tick's authoritative positions: construct after movement,
+ // insert in the ordered list before replication.
+ const surf = new SurfSystem(ctx)
// The wire surface every system sends through.
// The wire surface every system sends through.
const replication = new ReplicationSystem(ctx)
const replication = new ReplicationSystem(ctx)
ctx.net = replication
ctx.net = replication
@@ -104,6 +108,7 @@ export class GameServer {
@@ -104,6 +108,7 @@ export class GameServer {
this.systems = {
this.systems = {
sessions,
sessions,
movement,
movement,
+ surf,
replication,
replication,
manipulation,
manipulation,
combat,
combat,
@@ -127,6 +132,7 @@ export class GameServer {
@@ -127,6 +132,7 @@ export class GameServer {
this.ordered = [
this.ordered = [
sessions,
sessions,
movement,
movement,
+ surf,
replication,
replication,
manipulation,
manipulation,
combat,
combat,
@@ -279,6 +285,9 @@ export class GameServer {
@@ -279,6 +285,9 @@ 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)
+ // 1b. Surf run state: timer, checkpoints, fails, finish (after movement,
+ // before replication so surf_state travels in this tick's snapshot).
+ s.surf.tick(ctx, dt)
if (this.rush) {
if (this.rush) {
const now = Date.now()
const now = Date.now()
const results = this.rush.tick(
const results = this.rush.tick(
@@ -7,6 +7,7 @@ import {
@@ -7,6 +7,7 @@ import {
eyeOffsetFor,
eyeOffsetFor,
normalizeStats,
normalizeStats,
type PlayerMoveState,
type PlayerMoveState,
+ type SurfRunState,
type SurvivalStats,
type SurvivalStats,
} from '@openvibe/gameplay'
} from '@openvibe/gameplay'
import type { ItemStack, ReputationDto } from '@openvibe/gameplay'
import type { ItemStack, ReputationDto } from '@openvibe/gameplay'
@@ -93,6 +94,15 @@ export interface PlayerSession {
@@ -93,6 +94,15 @@ export interface PlayerSession {
openContainer: EntityId | null
openContainer: EntityId | null
/** Most negative airborne vertical velocity (fall-damage tracking). */
/** Most negative airborne vertical velocity (fall-damage tracking). */
fallVy: number
fallVy: number
+ /** Surf run tracker; null outside a surf place. */
+ surf: SurfRunState | null
+ /** Cached personal best/world record shown in surf_state. */
+ surfPb: number | null
+ surfWr: number | null
+ /** The PB's split times (HUD deltas). */
+ surfPbSplits: number[]
+ /** This tick's authoritative movement segments (cleared by MovementSystem). */
+ moveSegments: { from: Vec3; to: Vec3; yaw: number }[]
/** Tick of the last accepted use/swing (server-side swing cooldown). */
/** Tick of the last accepted use/swing (server-side swing cooldown). */
lastUseTick: number
lastUseTick: number
/** Stance the kinematic physics body was last built for. */
/** Stance the kinematic physics body was last built for. */
@@ -174,6 +184,11 @@ export function createSession(init: SessionInit): PlayerSession {
@@ -174,6 +184,11 @@ export function createSession(init: SessionInit): PlayerSession {
statsDirty: true,
statsDirty: true,
openContainer: null,
openContainer: null,
fallVy: 0,
fallVy: 0,
+ surf: null,
+ surfPb: null,
+ surfWr: null,
+ surfPbSplits: [],
+ moveSegments: [],
lastUseTick: 0,
lastUseTick: 0,
bodyStance: 0,
bodyStance: 0,
content: init.content,
content: init.content,
new file mode 100644
new file mode 100644
@@ -0,0 +1,157 @@
@@ -0,0 +1,157 @@
+import { readFileSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+import {
+ SURF_MAPS,
+ compileMapFileV2,
+ parseMapFile,
+ surfMap,
+ type MapFileV2,
+} from '@openvibe/content'
+import {
+ Buttons,
+ SurfRunState,
+ createMoveState,
+ movementParamsFor,
+ placeMoveStateAt,
+ stepMovement,
+ surfRunMapOf,
+} from '@openvibe/gameplay'
+import { CollisionLayer, type PhysicsWorld } from '@openvibe/physics'
+import { qfromEuler, quat, vec3 } from '@openvibe/shared'
+import { surfMapPath } from './surfMaps.js'
+
+/**
+ * The three authored maps: they compile as MapFileV2, carry a complete surf
+ * run, and are actually reachable. The reachability proof simulates a bot
+ * with scripted inputs through the real movement kernel and real Rapier
+ * collision — the same controller the server runs — in the surf style.
+ */
+const RAMP_MIN_DEG = 50
+const RAMP_MAX_DEG = 60
+
+function load(id: string): MapFileV2 {
+ const parsed = parseMapFile(JSON.parse(readFileSync(surfMapPath(id), 'utf8')))
+ if (!parsed.ok) throw new Error(parsed.issues.join('; '))
+ return parsed.map
+}
+
+async function rapierWorld(map: MapFileV2): Promise<{ world: PhysicsWorld; dispose(): void } | null> {
+ try {
+ const { createRapierWorld, loadRapier } = await import('@openvibe/physics/rapier')
+ const R = await loadRapier()
+ const world = createRapierWorld(R)
+ for (const body of map.statics) {
+ if (body.shape.type !== 'box') continue
+ world.addBody({
+ shape: body.shape,
+ motion: 'static',
+ pos: vec3(body.pos[0], body.pos[1], body.pos[2]),
+ ...(body.rot
+ ? { rot: qfromEuler(quat(), body.rot[0], body.rot[1], body.rot[2]) }
+ : {}),
+ layer: CollisionLayer.Static,
+ collidesWith: CollisionLayer.Player,
+ })
+ }
+ return { world, dispose: () => world.dispose() }
+ } catch {
+ return null
+ }
+}
+
+describe.each(SURF_MAPS)('$name', (info) => {
+ it('compiles with its movement, meta, spawn and ordered zones', () => {
+ const map = load(info.id)
+ expect(map.movement).toBe('surf')
+ expect(map.surf).toMatchObject({
+ title: info.name,
+ author: 'OpenVibe',
+ tier: info.tier,
+ stages: info.stages,
+ })
+ expect(map.spawn).toBeDefined()
+ const compiled = compileMapFileV2(map)
+ expect(compiled.movement).toBe('surf')
+ expect(compiled.surf?.stages).toBe(info.stages)
+ const run = surfRunMapOf(compiled.surf, compiled.zones ?? [], compiled.spawn, compiled.spawnYaw)
+ expect(run.checkpoints).toHaveLength(info.stages - 1)
+ expect(run.start.stage).toBe(0)
+ expect(run.finish.stage).toBe(info.stages)
+ expect(run.failY).toBeLessThan(run.spawn.y)
+ })
+
+ it('authors every ramp steeper than the 0.7 standable plane (50–60°)', () => {
+ const map = load(info.id)
+ const ramps = map.statics.filter((s) => s.id.includes('ramp') || s.id === 'transfer')
+ expect(ramps.length).toBeGreaterThan(1)
+ for (const ramp of ramps) {
+ const pitch = Math.abs(((ramp.rot?.[0] ?? 0) * 180) / Math.PI)
+ const roll = Math.abs(((ramp.rot?.[2] ?? 0) * 180) / Math.PI)
+ const tilt = Math.max(pitch, roll)
+ expect(tilt).toBeGreaterThanOrEqual(RAMP_MIN_DEG)
+ expect(tilt).toBeLessThanOrEqual(RAMP_MAX_DEG)
+ }
+ })
+
+ it('is reachable by a scripted bot in the surf style', async () => {
+ const map = load(info.id)
+ const world = await rapierWorld(map)
+ if (!world) return // wasm unavailable: the other map checks still ran
+ try {
+ const queries = {
+ sweepCapsule: (
+ from: Parameters<typeof world.world.sweepCapsule>[0],
+ to: Parameters<typeof world.world.sweepCapsule>[1],
+ radius: number,
+ height: number,
+ ) => world.world.sweepCapsule(from, to, radius, height, CollisionLayer.Static),
+ }
+ const compiled = compileMapFileV2(map)
+ const run = new SurfRunState(
+ surfRunMapOf(compiled.surf, compiled.zones ?? [], compiled.spawn, compiled.spawnYaw),
+ 30,
+ )
+ const move = createMoveState(vec3(run.map.spawn.x, run.map.spawn.y, run.map.spawn.z))
+ const params = movementParamsFor('surf')
+ let finished = false
+ for (let tick = 0; tick < 60 * 30 && !finished; tick++) {
+ const next =
+ run.reached < run.map.checkpoints.length
+ ? run.map.checkpoints[run.reached]!
+ : run.map.finish
+ const target = next.spawn ?? {
+ x: (next.min[0] + next.max[0]) / 2,
+ y: (next.min[1] + next.max[1]) / 2,
+ z: (next.min[2] + next.max[2]) / 2,
+ }
+ const yaw = Math.atan2(target.x - move.pos.x, target.z - move.pos.z)
+ const from = { x: move.pos.x, y: move.pos.y, z: move.pos.z }
+ // A hop pulse clears the gaps between landings; the surf line itself
+ // is a held forward run down the 50–60° ramps.
+ const buttons = tick % 20 < 2 ? Buttons.Jump : 0
+ stepMovement(move, { moveX: 0, moveZ: 1, yaw, pitch: 0, buttons }, params, queries, 1 / 30)
+ const events = run.observe(tick, from, move.pos, yaw)
+ for (const event of events) {
+ if (event.type === 'failed' || event.type === 'restarted') {
+ placeMoveStateAt(move, event.to)
+ } else if (event.type === 'finished') {
+ finished = true
+ }
+ }
+ }
+ expect(finished).toBe(true)
+ expect(run.finishResult?.timeMs).toBeGreaterThan(0)
+ } finally {
+ world.dispose()
+ }
+ })
+})
+
+describe('surf map catalogue', () => {
+ it('has three original maps, one place each, with the brief’s tiers', () => {
+ expect(SURF_MAPS.map((m) => m.id)).toEqual(['surf_lagoon', 'surf_canyon', 'surf_orbit'])
+ expect(SURF_MAPS.map((m) => m.tier)).toEqual([1, 2, 4])
+ expect(SURF_MAPS.map((m) => m.stages)).toEqual([3, 4, 6])
+ for (const info of SURF_MAPS) expect(surfMap(info.placeId)?.id).toBe(info.id)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
+import { existsSync, readFileSync } from 'node:fs'
+import { dirname, join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { compileMapFileV2, parseMapFile, surfMap, type MapFileV2, type MapOverride } from '@openvibe/content'
+
+/** The authored surf maps, versioned with the code (the runtime world under data/ is the editor's). */
+export const SURF_MAP_DIR = 'content/surf'
+
+/**
+ * The repository root, independent of the process cwd. Tests (and some
+ * launchers) start the server from `apps/server/`, while a deployed server
+ * runs from the repo root; `surfMapPath` accepts an explicit root for tests.
+ */
+function defaultRoot(): string {
+ for (const candidate of [process.cwd(), resolve(process.cwd(), '../..')]) {
+ if (existsSync(join(candidate, SURF_MAP_DIR))) return candidate
+ }
+ // Bundled layouts: apps/server/dist/… -> repo root.
+ return resolve(dirname(fileURLToPath(import.meta.url)), '../../../..')
+}
+
+export function surfMapPath(mapId: string, root = defaultRoot()): string {
+ const info = surfMap(mapId)
+ if (!info) throw new Error(`unknown surf map: ${mapId}`)
+ return join(root, SURF_MAP_DIR, info.file)
+}
+
+/** Read and validate one authored map. A bad document fails the place, not the process silently. */
+export function loadSurfMap(mapId: string, root = defaultRoot()): MapFileV2 {
+ const raw = JSON.parse(readFileSync(surfMapPath(mapId, root), 'utf8')) as unknown
+ const parsed = parseMapFile(raw)
+ if (!parsed.ok)
+ throw new Error(`surf map ${mapId} is invalid: ${parsed.issues.slice(0, 5).join('; ')}`)
+ return parsed.map
+}
+
+/** The compiled runtime override, with the map's movement/surf metadata. */
+export function loadSurfOverride(mapId: string, root = defaultRoot()): MapOverride {
+ return compileMapFileV2(loadSurfMap(mapId, root))
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+import type { SurfTrackSample } from '@openvibe/gameplay'
+
+/** One server-authoritative finished run, before persistence. */
+export interface SurfRunRecord {
+ id: string
+ mapId: string
+ accountKey: string
+ playerName: string
+ /** Server-derived milliseconds, sub-tick crossing included. */
+ timeMs: number
+ /** Split times relative to the start, finish last. */
+ splits: number[]
+ track: SurfTrackSample[]
+ tickRate: number
+ startedTick: number
+ finishedTick: number
+ startFrac: number
+ finishFrac: number
+ fails: number
+ createdAt: number
+}
+
+export interface SurfRecordOutcome {
+ /** This run became the player's personal best. */
+ pb: boolean
+ /** This run became the map's world record. */
+ wr: boolean
+ /** Play's rank for the run when it answered, else null. */
+ rank: number | null
+ pbMs: number | null
+ wrMs: number | null
+}
+
+/**
+ * The persistence/ranking seam the surf system writes runs through. Games'
+ * implementation is platform/surfRuns.ts; tests use a fake. The server never
+ * accepts a time from a client, so this is the only writer.
+ */
+export interface SurfRecords {
+ pbMs(mapId: string, accountKey: string): Promise<number | null>
+ /** The PB's total and splits, for the HUD's per-split delta. */
+ pbRun(
+ mapId: string,
+ accountKey: string,
+ ): Promise<{ timeMs: number; splits: number[] } | null>
+ wrMs(mapId: string): Promise<number | null>
+ record(run: SurfRunRecord): Promise<SurfRecordOutcome>
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,160 @@
@@ -0,0 +1,160 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { describe, expect, it } from 'vitest'
+import { createMoveState, normalizeStats } from '@openvibe/gameplay'
+import { vec3 } from '@openvibe/shared'
+import { loadSurfOverride } from './surfMaps.js'
+import { setMapOverride } from '@openvibe/content'
+import { EnvironmentSystem } from './systems/environment.js'
+import { MovementSystem } from './systems/movement.js'
+import { SurfSystem } from './systems/surf.js'
+
+/**
+ * Place-rule proof: in a surf place there is no fall damage, no void rescue,
+ * and a join puts the capsule at the start line. These are the three hooks the
+ * inventory names as conflicting with a time trial; the tests drive the real
+ * systems with a minimal context so a rule cannot regress silently.
+ */
+function surfWorld() {
+ const override = loadSurfOverride('surf_lagoon')
+ const world = { id: 'surf_lagoon', movement: 'surf', zones: [], spawnPoint: [0, 2, 0], spawnYaw: 0 }
+ setMapOverride(override, world.id)
+ return { world, override }
+}
+
+function sessionFor(override: ReturnType<typeof loadSurfOverride>) {
+ const spawn = override.spawn!
+ return {
+ playerId: 'p1',
+ entityId: 'e1',
+ token: 'guest:abc',
+ subjectId: null,
+ name: 'Tester',
+ driving: null,
+ lastInput: null,
+ starvedTicks: 0,
+ inputCredit: 0,
+ inputQueue: [],
+ lastProcessedSeq: 0,
+ move: createMoveState(vec3(spawn[0], spawn[1], spawn[2])),
+ yaw: 0,
+ pitch: 0,
+ buttons: 0,
+ bodyStance: 0,
+ moveSegments: [],
+ surf: null as null | SurfSystem['tick'] extends never ? never : any,
+ surfPb: null as number | null,
+ surfWr: null as number | null,
+ surfPbSplits: [] as number[],
+ fallVy: 0,
+ stats: normalizeStats(undefined),
+ statsDirty: false,
+ dirty: false,
+ }
+}
+
+function ctxFor(session: any, respawns: string[], sent: unknown[]) {
+ return {
+ clock: { tick: 30 },
+ config: { tickRate: 30, snapshotEvery: 2, placeId: 'surf-lagoon', instanceSlot: 0 },
+ env: {},
+ regions: { update() {} },
+ sessions: {
+ values: () => [session][Symbol.iterator](),
+ all: () => [session],
+ bodyOf: () => undefined,
+ },
+ world: {
+ content: { world: { id: 'surf_lagoon', movement: 'surf', zones: [] } },
+ physics: { addBody: () => 1, removeBody() {}, setTransform() {}, sweepCapsule: () => null },
+ entities: { get: () => undefined },
+ spatial: { move() {}, forEachInRadius() {} },
+ bodyOf: () => undefined,
+ },
+ systems: {
+ vehicles: { driveVehicle() {} },
+ combat: { respawn: (_s: unknown, died: boolean) => respawns.push(died ? 'died' : 'void') },
+ },
+ net: {
+ send: (_s: unknown, msg: unknown) => sent.push(msg),
+ broadcastAll() {},
+ timeWire: () => ({ t: 'time', frac: 0.3, weather: 'clear' }),
+ statsWire: () => ({}),
+ },
+ integrations: {
+ surf: {
+ async pbRun() {
+ return null
+ },
+ async pbMs() {
+ return null
+ },
+ async wrMs() {
+ return null
+ },
+ async record() {
+ return { pb: true, wr: false, rank: null, pbMs: 1000, wrMs: 1000 }
+ },
+ },
+ },
+ log: { info() {}, warn() {} },
+ }
+}
+
+describe('surf place rules', () => {
+ it('does not apply fall damage in a surf place (but does in the default style)', () => {
+ const { world } = surfWorld()
+ const surfSession = sessionFor(loadSurfOverride('surf_lagoon'))
+ surfSession.move.grounded = true
+ surfSession.fallVy = -30
+ const surfRespawns: string[] = []
+ const surf = new MovementSystem(ctxFor(surfSession, surfRespawns, []) as any)
+ surf.tick(ctxFor(surfSession, surfRespawns, []) as any, 1 / 30)
+ expect(surfRespawns).toEqual([])
+ expect(surfSession.fallVy).toBe(0)
+
+ setMapOverride({ terrains: [], movement: 'default' }, world.id)
+ const defaultSession = sessionFor(loadSurfOverride('surf_lagoon'))
+ defaultSession.move.grounded = true
+ defaultSession.fallVy = -300
+ const defaultRespawns: string[] = []
+ const movement = new MovementSystem(ctxFor(defaultSession, defaultRespawns, []) as any)
+ movement.tick(ctxFor(defaultSession, defaultRespawns, []) as any, 1 / 30)
+ expect(defaultRespawns).toEqual(['died'])
+ })
+
+ it('does not void-rescue a falling surf player, even below y = -25', () => {
+ const { world } = surfWorld()
+ const session = sessionFor(loadSurfOverride('surf_lagoon'))
+ session.move.pos.y = -100
+ const respawns: string[] = []
+ const ctx = ctxFor(session, respawns, []) as any
+ ctx.env = { timeOfDay: 0.3, weather: 'clear' }
+ new EnvironmentSystem(ctx).tick(ctx, 1 / 30)
+ expect(respawns).toEqual([])
+ setMapOverride({ terrains: [], movement: 'default' }, world.id)
+ const session2 = sessionFor(loadSurfOverride('surf_lagoon'))
+ session2.move.pos.y = -100
+ const respawns2: string[] = []
+ const ctx2 = ctxFor(session2, respawns2, []) as any
+ ctx2.env = { timeOfDay: 0.3, weather: 'clear' }
+ new EnvironmentSystem(ctx2).tick(ctx2, 1 / 30)
+ expect(respawns2).toContain('void')
+ })
+
+ it('spawns a surf join at the start line, never at a saved position', () => {
+ const { override } = surfWorld()
+ const session = sessionFor(override)
+ // Pretend the character was saved half-way through the map.
+ session.move.pos = vec3(123, 456, 789)
+ const sent: any[] = []
+ const surf = new SurfSystem(ctxFor(session, [], sent) as any)
+ surf.onJoin(session as any)
+ expect(session.surf).not.toBeNull()
+ expect(session.move.pos.x).toBeCloseTo(override.spawn![0])
+ expect(session.move.pos.y).toBeCloseTo(override.spawn![1])
+ expect(session.move.pos.z).toBeCloseTo(override.spawn![2])
+ expect(session.move.vel).toEqual(vec3(0, 0, 0))
+ const state = sent.find((m) => m.t === 'surf_state')
+ expect(state).toMatchObject({ running: false, stage: 0 })
+ })
+})
@@ -27,6 +27,8 @@ import type { PhysicsSystem } from './physics.js'
@@ -27,6 +27,8 @@ import type { PhysicsSystem } from './physics.js'
import type { ProductionSystem } from './production.js'
import type { ProductionSystem } from './production.js'
import type { ReplicationSystem } from './replication.js'
import type { ReplicationSystem } from './replication.js'
import type { SessionsSystem } from './sessions.js'
import type { SessionsSystem } from './sessions.js'
+import type { SurfSystem } from './surf.js'
+import type { SurfRecords } from '../surfRecords.js'
import type { SocialSystem } from './social.js'
import type { SocialSystem } from './social.js'
import type { VehicleSystem } from './vehicles.js'
import type { VehicleSystem } from './vehicles.js'
import type { WorldEventSystem } from './worldEvents.js'
import type { WorldEventSystem } from './worldEvents.js'
@@ -83,6 +85,8 @@ export interface GameIntegrations {
@@ -83,6 +85,8 @@ export interface GameIntegrations {
scripts?: ScriptModHost
scripts?: ScriptModHost
/** The person's games.progress.summary user module on Network, written when a character leaves. */
/** The person's games.progress.summary user module on Network, written when a character leaves. */
progressSummary?: ProgressSummaryWriter
progressSummary?: ProgressSummaryWriter
+ /** Surf run persistence + Play ranking (absent outside surf places). */
+ surf?: SurfRecords
}
}
/** The mutable tick counter, shared by every system that reads cadence or cooldowns. */
/** The mutable tick counter, shared by every system that reads cadence or cooldowns. */
@@ -188,6 +192,7 @@ export class SessionRegistry {
@@ -188,6 +192,7 @@ export class SessionRegistry {
export interface ServerSystems {
export interface ServerSystems {
sessions: SessionsSystem
sessions: SessionsSystem
movement: MovementSystem
movement: MovementSystem
+ surf: SurfSystem
replication: ReplicationSystem
replication: ReplicationSystem
manipulation: ManipulationSystem
manipulation: ManipulationSystem
combat: CombatSystem
combat: CombatSystem
@@ -3,6 +3,7 @@ import {
@@ -3,6 +3,7 @@ import {
activeStatuses,
activeStatuses,
ambientTemperature,
ambientTemperature,
precipitation,
precipitation,
+ movementStyleFor,
stepEnvironment,
stepEnvironment,
tickSurvival,
tickSurvival,
type WeatherKind,
type WeatherKind,
@@ -45,11 +46,17 @@ export class EnvironmentSystem implements System {
@@ -45,11 +46,17 @@ export class EnvironmentSystem implements System {
const ambientC = ambientTemperature(ctx.env)
const ambientC = ambientTemperature(ctx.env)
const raining = precipitation(ctx.env) > 0
const raining = precipitation(ctx.env) > 0
const nowMs = Date.now()
const nowMs = Date.now()
+ // A surf place is a time trial: no exposure/hunger simulation and no
+ // void rescue (a miss is the SurfSystem's checkpoint teleport). The void
+ // hook would otherwise yank a falling player to the world spawn once a
+ // second, before the run could fail them.
+ const surfPlace = movementStyleFor(ctx.world.content.world) === 'surf'
// Region activation follows players (coarse, 1 Hz).
// Region activation follows players (coarse, 1 Hz).
ctx.regions.update(
ctx.regions.update(
[...ctx.sessions.values()].map((s) => ({ x: s.move.pos.x, z: s.move.pos.z })),
[...ctx.sessions.values()].map((s) => ({ x: s.move.pos.x, z: s.move.pos.z })),
)
)
for (const session of ctx.sessions.values()) {
for (const session of ctx.sessions.values()) {
+ if (surfPlace) continue
const sprinting =
const sprinting =
(session.buttons & Buttons.Sprint) !== 0 &&
(session.buttons & Buttons.Sprint) !== 0 &&
Math.hypot(session.move.vel.x, session.move.vel.z) > 1
Math.hypot(session.move.vel.x, session.move.vel.z) > 1
@@ -17,6 +17,7 @@ export type { HandlerMap, MessageHandler, System } from './system.js'
@@ -17,6 +17,7 @@ export type { HandlerMap, MessageHandler, System } from './system.js'
export { SessionsSystem } from './sessions.js'
export { SessionsSystem } from './sessions.js'
export { MovementSystem, MOVE, MAX_INPUT_QUEUE } from './movement.js'
export { MovementSystem, MOVE, MAX_INPUT_QUEUE } from './movement.js'
+export { SurfSystem } from './surf.js'
export { ReplicationSystem } from './replication.js'
export { ReplicationSystem } from './replication.js'
export { ManipulationSystem } from './manipulation.js'
export { ManipulationSystem } from './manipulation.js'
export { CombatSystem } from './combat.js'
export { CombatSystem } from './combat.js'
@@ -2,9 +2,12 @@ import {
@@ -2,9 +2,12 @@ import {
DEFAULT_MOVEMENT,
DEFAULT_MOVEMENT,
applyDamage,
applyDamage,
hullHeightFor,
hullHeightFor,
+ movementParamsFor,
+ movementStyleFor,
stepMovement,
stepMovement,
type CollisionQueries,
type CollisionQueries,
type MoveInput,
type MoveInput,
+ type MovementParams,
} from '@openvibe/gameplay'
} from '@openvibe/gameplay'
import { CollisionLayer, type BodyId } from '@openvibe/physics'
import { CollisionLayer, type BodyId } from '@openvibe/physics'
import type { ClientEditMode, ClientInput } from '@openvibe/protocol'
import type { ClientEditMode, ClientInput } from '@openvibe/protocol'
@@ -130,17 +133,29 @@ export class MovementSystem implements System {
@@ -130,17 +133,29 @@ export class MovementSystem implements System {
}
}
tick(ctx: ServerContext, dt: number): void {
tick(ctx: ServerContext, dt: number): void {
+ // Resolve the place's movement once per tick, not once per player: the
+ // style is room-wide, and instanceHub's per-tick budget is tight.
+ const style = movementStyleFor(ctx.world.content.world)
+ const params = movementParamsFor(style)
+ const surfPlace = style === 'surf'
for (const session of ctx.sessions.values()) {
for (const session of ctx.sessions.values()) {
- this.stepSession(ctx, session, dt)
+ this.stepSession(ctx, session, dt, params, surfPlace)
}
}
}
}
- private stepSession(ctx: ServerContext, session: PlayerSession, dt: number): void {
+ private stepSession(
+ ctx: ServerContext,
+ session: PlayerSession,
+ dt: number,
+ params: MovementParams,
+ surfPlace: boolean,
+ ): void {
if (session.driving) {
if (session.driving) {
ctx.systems.vehicles.driveVehicle(session)
ctx.systems.vehicles.driveVehicle(session)
return
return
}
}
this.sweepSelf = ctx.sessions.bodyOf(session.playerId)
this.sweepSelf = ctx.sessions.bodyOf(session.playerId)
+ session.moveSegments.length = 0
let simulated = 0
let simulated = 0
for (const input of takeInputs(session)) {
for (const input of takeInputs(session)) {
session.lastInput = input
session.lastInput = input
@@ -148,7 +163,16 @@ export class MovementSystem implements System {
@@ -148,7 +163,16 @@ export class MovementSystem implements System {
session.lastProcessedSeq = input.seq
session.lastProcessedSeq = input.seq
applyLookInput(session, input)
applyLookInput(session, input)
session.buttons = input.intents
session.buttons = input.intents
- stepMovement(session.move, toMoveInput(session, input), MOVE, this.moveQueries, dt)
+ const from = surfPlace
+ ? vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z)
+ : null
+ stepMovement(session.move, toMoveInput(session, input), params, this.moveQueries, dt)
+ if (from)
+ session.moveSegments.push({
+ from,
+ to: vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z),
+ yaw: session.yaw,
+ })
simulated++
simulated++
}
}
if (simulated === 0 && session.lastInput) {
if (simulated === 0 && session.lastInput) {
@@ -159,13 +183,22 @@ export class MovementSystem implements System {
@@ -159,13 +183,22 @@ export class MovementSystem implements System {
if (session.starvedTicks > STALL_TICKS) {
if (session.starvedTicks > STALL_TICKS) {
// Zero MOVEMENT only: buttons stay held. Zeroing buttons fabricates release edges for toggle keys
// Zero MOVEMENT only: buttons stay held. Zeroing buttons fabricates release edges for toggle keys
// (prone/crouch), and the server flapped stances.
// (prone/crouch), and the server flapped stances.
+ const from = surfPlace
+ ? vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z)
+ : null
stepMovement(
stepMovement(
session.move,
session.move,
toMoveInput(session, { ...session.lastInput, moveX: 0, moveZ: 0 }),
toMoveInput(session, { ...session.lastInput, moveX: 0, moveZ: 0 }),
- MOVE,
+ params,
this.moveQueries,
this.moveQueries,
dt,
dt,
)
)
+ if (from)
+ session.moveSegments.push({
+ from,
+ to: vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z),
+ yaw: session.yaw,
+ })
}
}
}
}
let bodyId = ctx.sessions.bodyOf(session.playerId)
let bodyId = ctx.sessions.bodyOf(session.playerId)
@@ -176,7 +209,7 @@ export class MovementSystem implements System {
@@ -176,7 +209,7 @@ export class MovementSystem implements System {
bodyId = ctx.world.physics.addBody({
bodyId = ctx.world.physics.addBody({
shape: {
shape: {
type: 'capsule',
type: 'capsule',
- radius: MOVE.capsuleRadius,
+ radius: params.capsuleRadius,
height: Math.max(hull - 0.3, 0.4),
height: Math.max(hull - 0.3, 0.4),
},
},
motion: 'kinematic',
motion: 'kinematic',
@@ -195,7 +228,11 @@ export class MovementSystem implements System {
@@ -195,7 +228,11 @@ export class MovementSystem implements System {
}
}
// Fall damage: track the hardest downward velocity while airborne and
// Fall damage: track the hardest downward velocity while airborne and
// cash it in on landing. ~13 m/s (≈2.5 story drop) is the free threshold.
// cash it in on landing. ~13 m/s (≈2.5 story drop) is the free threshold.
- if (!session.move.grounded) {
+ // A surf place is a time trial: misses teleport to the last checkpoint and
+ // the clock keeps running, so falls must not kill (brief decision 4).
+ if (surfPlace) {
+ session.fallVy = 0
+ } else if (!session.move.grounded) {
session.fallVy = Math.min(session.fallVy, session.move.vel.y)
session.fallVy = Math.min(session.fallVy, session.move.vel.y)
} else if (session.fallVy < -13) {
} else if (session.fallVy < -13) {
const dmg = Math.round((-session.fallVy - 13) * 3.5)
const dmg = Math.round((-session.fallVy - 13) * 3.5)
@@ -1,10 +1,9 @@
@@ -1,10 +1,9 @@
-import { STARTER_ITEMS, worldSpawn } from '@openvibe/content'
-import { Inventory, SkillSet, type GameEntity } from '@openvibe/gameplay'
+import { STARTER_ITEMS, getMapOverride, worldSpawn } from '@openvibe/content'
+import { Inventory, SkillSet, movementParamsFor, movementStyleFor, type GameEntity } from '@openvibe/gameplay'
import { CollisionLayer } from '@openvibe/physics'
import { CollisionLayer } from '@openvibe/physics'
import { PROTOCOL_VERSION, encodeServerMessage, type ClientHello } from '@openvibe/protocol'
import { PROTOCOL_VERSION, encodeServerMessage, type ClientHello } from '@openvibe/protocol'
import { asPlayerId, newEntityId, newPlayerId, qfromYaw, quat, vec3 } from '@openvibe/shared'
import { asPlayerId, newEntityId, newPlayerId, qfromYaw, quat, vec3 } from '@openvibe/shared'
import type { GameConnection, ServerContext } from './context.js'
import type { GameConnection, ServerContext } from './context.js'
-import { MOVE } from './movement.js'
import { eventPlayer, progressOf } from './platform.js'
import { eventPlayer, progressOf } from './platform.js'
import { createSession, HOTBAR_SIZE, INVENTORY_SIZE } from '../playerSession.js'
import { createSession, HOTBAR_SIZE, INVENTORY_SIZE } from '../playerSession.js'
import type { HandlerMap, System } from './system.js'
import type { HandlerMap, System } from './system.js'
@@ -185,10 +184,16 @@ export class SessionsSystem implements System {
@@ -185,10 +184,16 @@ 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)
+ // A surf join always starts at the map's start line, never at a saved
+ // position: resuming half-way would skip the clock (brief decision 4).
+ const override = getMapOverride(world)
+ const surfSpawn = override?.surf ? override.spawn : undefined
const spawn =
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])
+ surfSpawn
+ ? vec3(surfSpawn[0], surfSpawn[1], surfSpawn[2])
+ : 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)
@@ -222,7 +227,11 @@ export class SessionsSystem implements System {
@@ -222,7 +227,11 @@ export class SessionsSystem implements System {
rank,
rank,
name: msg.name,
name: msg.name,
spawn,
spawn,
- yaw: existing && existing.positionInPlace !== false ? existing.yaw : mapSpawn.yaw,
+ yaw: surfSpawn
+ ? (override?.spawnYaw ?? 0)
+ : existing && existing.positionInPlace !== false
+ ? existing.yaw
+ : mapSpawn.yaw,
inventory,
inventory,
skills,
skills,
friends,
friends,
@@ -253,8 +262,9 @@ export class SessionsSystem implements System {
@@ -253,8 +262,9 @@ export class SessionsSystem implements System {
// Kinematic capsule so props collide with players. Shorter than the
// Kinematic capsule so props collide with players. Shorter than the
// movement hull and lifted off the feet: standing ON a prop must not
// movement hull and lifted off the feet: standing ON a prop must not
// press it down (that caused sink/jitter loops when prop-surfing).
// press it down (that caused sink/jitter loops when prop-surfing).
+ const movement = movementParamsFor(movementStyleFor(world))
const bodyId = ctx.world.physics.addBody({
const bodyId = ctx.world.physics.addBody({
- shape: { type: 'capsule', radius: MOVE.capsuleRadius, height: MOVE.capsuleHeight - 0.3 },
+ shape: { type: 'capsule', radius: movement.capsuleRadius, height: movement.capsuleHeight - 0.3 },
motion: 'kinematic',
motion: 'kinematic',
pos: vec3(session.move.pos.x, session.move.pos.y + 0.15, session.move.pos.z),
pos: vec3(session.move.pos.x, session.move.pos.y + 0.15, session.move.pos.z),
layer: CollisionLayer.Player,
layer: CollisionLayer.Player,
new file mode 100644
new file mode 100644
@@ -0,0 +1,204 @@
@@ -0,0 +1,204 @@
+import { getMapOverride } from '@openvibe/content'
+import { SurfRunState, placeMoveStateAt, surfRunMapOf, type SurfRunResult } from '@openvibe/gameplay'
+import { newEntityId, qfromYaw, vec3 } from '@openvibe/shared'
+import type { ClientSurfRestart } from '@openvibe/protocol'
+import type { PlayerSession } from '../playerSession.js'
+import type { ServerContext } from './context.js'
+import type { HandlerMap, System } from './system.js'
+
+/**
+ * Surf (OpenVibe.Surf Ride): the run clock, ordered checkpoints, fail
+ * teleports, ghosts and the finish card's data.
+ *
+ * It is inserted AFTER MovementSystem and BEFORE ReplicationSystem, so it
+ * sees this tick's authoritative positions and its `surf_state`/`surf_finish`
+ * messages travel in the same tick. Every movement segment MovementSystem
+ * simulated is fed to the run state machine, including catch-up inputs; no
+ * client time or position is involved.
+ */
+export class SurfSystem implements System {
+ readonly name = 'surf'
+ private readonly warned = new Set<string>()
+
+ readonly handlers: HandlerMap = {
+ surf_restart: (session, _msg: ClientSurfRestart) => {
+ if (!session.surf) return
+ const event = session.surf.restart(this.ctx.clock.tick)
+ if (event.type === 'restarted') this.teleport(session, event.to, event.yaw)
+ this.sendState(session)
+ },
+ }
+
+ /** True only where the loaded map authored surf metadata; the common place skips this system. */
+ private readonly enabled: boolean
+
+ constructor(private readonly ctx: ServerContext) {
+ this.enabled = getMapOverride(ctx.world.content.world)?.surf !== undefined
+ }
+
+ onJoin(session: PlayerSession): void {
+ const map = this.runMap()
+ if (!map) return
+ session.surf = new SurfRunState(map, this.ctx.config.tickRate)
+ session.surfPb = null
+ session.surfWr = null
+ session.surfPbSplits = []
+ this.teleport(session, map.spawn, map.spawnYaw)
+ void this.loadRecords(session)
+ this.sendState(session)
+ }
+
+ tick(ctx: ServerContext, _dt: number): void {
+ if (!this.enabled) return
+ for (const session of ctx.sessions.values()) {
+ const run = session.surf
+ if (!run) continue
+ for (const segment of session.moveSegments) {
+ const events = run.observe(ctx.clock.tick, segment.from, segment.to, segment.yaw)
+ for (const event of events) {
+ if (event.type === 'failed' || event.type === 'restarted') {
+ this.teleport(session, event.to, event.yaw)
+ this.sendState(session)
+ } else if (event.type === 'checkpoint') {
+ this.sendState(session)
+ } else if (event.type === 'finished') {
+ void this.finish(session, event.result)
+ }
+ }
+ }
+ if (ctx.clock.tick % ctx.config.snapshotEvery === 0) this.sendState(session)
+ }
+ }
+
+ private runMap() {
+ const override = getMapOverride(this.ctx.world.content.world)
+ if (!override?.surf) return null
+ return surfRunMapOf(
+ override.surf,
+ override.zones ?? [],
+ override.spawn ?? [0, 2, 0],
+ override.spawnYaw ?? 0,
+ this.ctx.world.content.world.id,
+ )
+ }
+
+ private teleport(session: PlayerSession, to: { x: number; y: number; z: number }, yaw: number): void {
+ placeMoveStateAt(session.move, to)
+ session.yaw = yaw
+ session.surf?.teleport(to)
+ const body = this.ctx.world.bodyOf(session.entityId)
+ if (body !== undefined)
+ this.ctx.world.physics.setTransform(body, vec3(to.x, to.y + 0.15, to.z))
+ const entity = this.ctx.world.entities.get(session.entityId)
+ if (entity) qfromYaw(entity.transform.rot, yaw)
+ }
+
+ private async loadRecords(session: PlayerSession): Promise<void> {
+ const records = this.ctx.integrations.surf
+ const map = this.runMap()
+ if (!records || !map) return
+ const key = session.subjectId ?? session.token
+ try {
+ const [pbRun, wr] = await Promise.all([
+ records.pbRun(map.id, key),
+ records.wrMs(map.id),
+ ])
+ session.surfPb = pbRun?.timeMs ?? null
+ session.surfPbSplits = pbRun?.splits ?? []
+ session.surfWr = wr
+ this.sendState(session)
+ } catch (err) {
+ this.warnOnce('load', `surf records load failed: ${String(err)}`)
+ }
+ }
+
+ private async finish(session: PlayerSession, result: SurfRunResult): Promise<void> {
+ const records = this.ctx.integrations.surf
+ let pb = session.surfPb === null || result.timeMs < session.surfPb
+ let wr = session.surfWr === null || result.timeMs < session.surfWr
+ let rank: number | null = null
+ if (records) {
+ try {
+ const outcome = await records.record({
+ id: newEntityId(),
+ mapId: result.mapId,
+ accountKey: session.subjectId ?? session.token,
+ playerName: session.name,
+ timeMs: result.timeMs,
+ splits: result.splits,
+ track: result.track,
+ tickRate: this.ctx.config.tickRate,
+ startedTick: result.startedTick,
+ finishedTick: result.finishedTick,
+ startFrac: result.startFrac,
+ finishFrac: result.finishFrac,
+ fails: result.fails,
+ createdAt: Date.now(),
+ })
+ pb = outcome.pb
+ wr = outcome.wr
+ rank = outcome.rank
+ session.surfPb = outcome.pbMs
+ session.surfWr = outcome.wrMs
+ if (pb) session.surfPbSplits = result.splits
+ } catch (err) {
+ // The run is already authoritative; a storage/Play failure must not
+ // turn a finish into an error. Keep the local PB comparison.
+ this.warnOnce('record', `surf run record failed: ${String(err)}`)
+ if (pb) {
+ session.surfPb = result.timeMs
+ session.surfPbSplits = result.splits
+ }
+ if (wr) session.surfWr = result.timeMs
+ }
+ } else {
+ if (pb) {
+ session.surfPb = result.timeMs
+ session.surfPbSplits = result.splits
+ }
+ if (wr) session.surfWr = result.timeMs
+ }
+ this.ctx.net.send(session, {
+ t: 'surf_finish',
+ timeMs: result.timeMs,
+ splits: result.splits,
+ pb,
+ wr,
+ rank,
+ pbMs: session.surfPb,
+ wrMs: session.surfWr,
+ })
+ this.sendState(session)
+ this.ctx.log.info('surf run finished', {
+ playerId: session.playerId,
+ map: result.mapId,
+ timeMs: result.timeMs,
+ pb,
+ wr,
+ fails: result.fails,
+ })
+ session.dirty = true
+ }
+
+ private sendState(session: PlayerSession): void {
+ const run = session.surf
+ if (!run) return
+ this.ctx.net.send(session, {
+ t: 'surf_state',
+ running: run.status === 'running',
+ elapsedMs: run.elapsedMs,
+ stage: run.reached,
+ stages: run.map.stages,
+ splits: run.splitTimes,
+ pbMs: session.surfPb,
+ wrMs: session.surfWr,
+ ...(session.surfPbSplits.length ? { pbSplits: session.surfPbSplits } : {}),
+ })
+ }
+
+ private warnOnce(tag: string, message: string): void {
+ if (this.warned.has(tag)) return
+ this.warned.add(tag)
+ this.ctx.log.warn(message)
+ }
+}
@@ -12,6 +12,9 @@ import {
@@ -12,6 +12,9 @@ import {
parseMapFile,
parseMapFile,
SCRAPLANDIA_PACK,
SCRAPLANDIA_PACK,
setMapOverride,
setMapOverride,
+ surfMapOfPlace,
+ surfPack,
+ SURF_MAPS,
type ContentRegistry,
type ContentRegistry,
} from '@openvibe/content'
} from '@openvibe/content'
import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'
import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'
@@ -30,6 +33,7 @@ import { telemetry } from 'openvibe-sdk/service'
@@ -30,6 +33,7 @@ import { telemetry } from 'openvibe-sdk/service'
import { createPgTelemetrySink, startTelemetryRetention } from 'openvibe-sdk/telemetry'
import { createPgTelemetrySink, startTelemetryRetention } from 'openvibe-sdk/telemetry'
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 { loadSurfOverride, surfMapPath } from './game/surfMaps.js'
import { InstanceHub } from './game/instanceHub.js'
import { InstanceHub } from './game/instanceHub.js'
import { PlaceRouter } from './game/placeRouter.js'
import { PlaceRouter } from './game/placeRouter.js'
import { attachEditorWs } from './net/editorWs.js'
import { attachEditorWs } from './net/editorWs.js'
@@ -83,6 +87,8 @@ import { GameProgressWriter, type Milestone } from './game/progression.js'
@@ -83,6 +87,8 @@ import { GameProgressWriter, type Milestone } from './game/progression.js'
import type { RoundResult } from './game/salvageRush.js'
import type { RoundResult } from './game/salvageRush.js'
import { handleGameRead } from './net/gameRoutes.js'
import { handleGameRead } from './net/gameRoutes.js'
import { createPlayBoards } from './platform/playBoards.js'
import { createPlayBoards } from './platform/playBoards.js'
+import { PgSurfRecords, createSurfPlay, surfBoardId } from './platform/surfRuns.js'
+import { handleSurfRead } from './net/surfRoutes.js'
import { createCasualRoutes } from './casual/routes.js'
import { createCasualRoutes } from './casual/routes.js'
import { casualLeaderboard, pruneCasualActions } from './casual/store.js'
import { casualLeaderboard, pruneCasualActions } from './casual/store.js'
import { createPortalHome, readPlayTitles } from './net/portalHome.js'
import { createPortalHome, readPlayTitles } from './net/portalHome.js'
@@ -141,7 +147,12 @@ async function main(): Promise<void> {
@@ -141,7 +147,12 @@ 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 builtInPack = config.placeId === 'salvage-rush' ? SALVAGE_RUSH_PACK : SCRAPLANDIA_PACK
+ const primarySurf = surfMapOfPlace(config.placeId)
+ const builtInPack = primarySurf
+ ? surfPack(primarySurf.id)
+ : config.placeId === 'salvage-rush'
+ ? SALVAGE_RUSH_PACK
+ : SCRAPLANDIA_PACK
const content = createContent(packs, builtInPack)
const content = createContent(packs, builtInPack)
let inventoryRewards: InventoryRewards | null = null
let inventoryRewards: InventoryRewards | null = null
const settleRewards = (subject: string): void => {
const settleRewards = (subject: string): void => {
@@ -162,11 +173,12 @@ async function main(): Promise<void> {
@@ -162,11 +173,12 @@ async function main(): Promise<void> {
// Edited map (from /editor): heightfield + extra statics replace the
// Edited map (from /editor): heightfield + extra statics replace the
// procedural terrain for EVERYTHING (physics, spawns, clients fetch the
// procedural terrain for EVERYTHING (physics, spawns, clients fetch the
// same file over /map.json).
// same file over /map.json).
- if (existsSync(config.mapPath)) {
+ const primaryMapPath = primarySurf ? surfMapPath(primarySurf.id) : config.mapPath
+ if (existsSync(primaryMapPath)) {
try {
try {
// The document is v2; the RUNTIME representation is v2 compiled straight
// The document is v2; the RUNTIME representation is v2 compiled straight
// through.
// through.
- const parsed = parseMapFile(JSON.parse(readFileSync(config.mapPath, 'utf8')))
+ const parsed = parseMapFile(JSON.parse(readFileSync(primaryMapPath, 'utf8')))
if (parsed.ok) {
if (parsed.ok) {
// The map's statics live in the override, NOT merged into
// The map's statics live in the override, NOT merged into
// content.world.statics: that merge was permanent, so the map layer
// content.world.statics: that merge was permanent, so the map layer
@@ -220,6 +232,13 @@ async function main(): Promise<void> {
@@ -220,6 +232,13 @@ async function main(): Promise<void> {
playSync.start()
playSync.start()
// The in-game boards are Play's (public reads, no token).
// The in-game boards are Play's (public reads, no token).
const playBoards = createPlayBoards({ url: config.platform.playUrl })
const playBoards = createPlayBoards({ url: config.platform.playUrl })
+ // Surf runs: local rows first (migration 0018), then Play's `time` board.
+ // A Play failure never fails a run.
+ const surfRecords = new PgSurfRecords(
+ db,
+ createSurfPlay({ url: config.platform.playUrl, tokens: platform?.tokens ?? null }),
+ (message) => log.warn(message),
+ )
const platformLog = log.child({ system: 'platform' })
const platformLog = log.child({ system: 'platform' })
const valkey = createValkey({ url: config.db.valkeyUrl ?? '', prefix: config.db.valkeyPrefix })
const valkey = createValkey({ url: config.db.valkeyUrl ?? '', prefix: config.db.valkeyPrefix })
let outbox: PgOutbox | null = null
let outbox: PgOutbox | null = null
@@ -389,6 +408,7 @@ async function main(): Promise<void> {
@@ -389,6 +408,7 @@ async function main(): Promise<void> {
},
},
}
}
: {}),
: {}),
+ ...(primarySurf ? { surf: surfRecords } : {}),
},
},
)
)
// 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.
@@ -461,6 +481,7 @@ async function main(): Promise<void> {
@@ -461,6 +481,7 @@ async function main(): Promise<void> {
},
},
}
}
: {}),
: {}),
+ ...(primarySurf ? { surf: surfRecords } : {}),
},
},
)
)
await roomGame.load()
await roomGame.load()
@@ -594,9 +615,97 @@ async function main(): Promise<void> {
@@ -594,9 +615,97 @@ async function main(): Promise<void> {
createSecondaryRoom,
createSecondaryRoom,
log.child({ system: 'instances', place: secondaryPlace }),
log.child({ system: 'instances', place: secondaryPlace }),
)
)
+ // ── OpenVibe.Surf (openvibe.surf): one place per map, all in this process ──
+ // A surf place is ordinary content (the surf pack) plus an authored MapFileV2
+ // override; its GameServer runs the SurfSystem. Places are separate so each
+ // room's world has exactly one map, the same way Scraplandia and Salvage Rush
+ // are separate places. The primary place already hosts one when it is surf.
+ const extraHubs = new Map<string, InstanceHub>()
+ const surfContentByPlace = new Map<string, ContentRegistry>()
+ for (const info of SURF_MAPS) {
+ if (info.placeId === config.placeId || info.placeId === secondaryPlace) continue
+ const placeContent = createContent(packs, surfPack(info.id))
+ setMapOverride(loadSurfOverride(info.id), placeContent.world.id)
+ surfContentByPlace.set(info.placeId, placeContent)
+ const surfRegistry = new InstanceRegistry(db, info.placeId)
+ let surfPrimaryLease: InstanceLease
+ const createSurfRoom = async (slot: number, lease: InstanceLease) => {
+ const roomStore = openPgStore(db, {
+ placeId: info.placeId,
+ instanceSlot: slot,
+ leaseGuard: () =>
+ surfRegistry.assertHeld(slot === 0 ? surfPrimaryLease : lease, Date.now()),
+ })
+ await roomStore.ensurePlace()
+ const roomPhysics = createRapierWorld(rapier)
+ const roomWorld = new GameWorld(
+ placeContent,
+ roomPhysics,
+ log.child({ system: 'world', place: info.placeId, slot }),
+ config.interestCell,
+ )
+ await roomWorld.seedOrRestore(roomStore)
+ const roomGame = new GameServer(
+ {
+ ...config,
+ placeId: info.placeId,
+ instanceSlot: slot,
+ // A surf room has no survival, NPC population or world events; the
+ // run system owns falls and respawns.
+ sandbox: true,
+ },
+ roomWorld,
+ roomStore,
+ metrics,
+ log.child({ system: 'game', place: info.placeId, slot }),
+ { surf: surfRecords },
+ )
+ await roomGame.load()
+ return {
+ lease,
+ game: roomGame,
+ world: roomWorld,
+ dispose: async () => {
+ roomPhysics.dispose()
+ },
+ }
+ }
+ // The place row must exist before a lease can reference it (the same
+ // order the primary/secondary rooms use: ensurePlace, then claim).
+ const surfBootstrap = openPgStore(db, {
+ placeId: info.placeId,
+ instanceSlot: 0,
+ leaseGuard: async () => {},
+ })
+ await surfBootstrap.ensurePlace()
+ const surfClaim = await surfRegistry.claim(
+ 0,
+ config.maxPlayers,
+ crypto.randomUUID(),
+ Date.now(),
+ 60_000,
+ )
+ if (!surfClaim) throw new Error(`${info.placeId} primary instance slot is already leased`)
+ surfPrimaryLease = surfClaim
+ const surfPrimary = await createSurfRoom(0, surfClaim)
+ const surfRunning = await surfRegistry.renew(surfClaim, Date.now(), 15_000, 'running')
+ if (!surfRunning) throw new Error(`${info.placeId} primary lease expired during boot`)
+ surfPrimary.lease = surfRunning
+ surfPrimaryLease = surfRunning
+ const surfHub = new InstanceHub(
+ surfRegistry,
+ surfPrimary,
+ config.instancesPerPlace,
+ config.maxPlayers,
+ createSurfRoom,
+ log.child({ system: 'instances', place: info.placeId }),
+ )
+ extraHubs.set(info.placeId, surfHub)
+ }
const hubs = new Map([
const hubs = new Map([
[config.placeId, hub],
[config.placeId, hub],
[secondaryPlace, secondaryHub],
[secondaryPlace, secondaryHub],
+ ...extraHubs,
])
])
// ── Creator worlds (worlds/): players' own places, started on the first join, stopped when empty ──
// ── Creator worlds (worlds/): players' own places, started on the first join, stopped when empty ──
// A creator world is the place `world:<id>` with the plain creator pack and its map as the override. Its room is
// A creator world is the place `world:<id>` with the plain creator pack and its map as the override. Its room is
@@ -654,6 +763,7 @@ async function main(): Promise<void> {
@@ -654,6 +763,7 @@ async function main(): Promise<void> {
const contentByPlace = new Map([
const contentByPlace = new Map([
[config.placeId, content],
[config.placeId, content],
[secondaryPlace, secondaryContent],
[secondaryPlace, secondaryContent],
+ ...surfContentByPlace,
])
])
const heartbeatTimer = setInterval(() => void router.heartbeat(), 5_000)
const heartbeatTimer = setInterval(() => void router.heartbeat(), 5_000)
heartbeatTimer.unref()
heartbeatTimer.unref()
@@ -1005,6 +1115,38 @@ async function main(): Promise<void> {
@@ -1005,6 +1115,38 @@ async function main(): Promise<void> {
if (serveAssets(req, res)) return true
if (serveAssets(req, res)) return true
if (serveWorldPage(req, res)) return true
if (serveWorldPage(req, res)) return true
if (serveSteam(req, res)) return true
if (serveSteam(req, res)) return true
+ if (
+ handleSurfRead(req, res, {
+ db,
+ networkAuthUrl: config.networkAuthUrl,
+ ...(config.platform.playUrl
+ ? {
+ playBoard: async (mapId: string) => {
+ const res = await fetch(
+ `${config.platform.playUrl}/api/v1/titles/surf/boards/${surfBoardId(mapId)}?period=all&page=1`,
+ {
+ headers: { accept: 'application/json' },
+ signal: AbortSignal.timeout(4_000),
+ },
+ )
+ if (!res.ok) return null
+ const body = (await res.json()) as {
+ entries?: { rank: number; name: string; score: number }[]
+ }
+ return {
+ entries: (body.entries ?? []).map((e) => ({
+ rank: Number(e.rank),
+ name: String(e.name),
+ score: Number(e.score),
+ })),
+ }
+ },
+ }
+ : {}),
+ log: (message) => log.warn(message),
+ })
+ )
+ return true
if (
if (
handleGameRead(req, res, {
handleGameRead(req, res, {
db,
db,
@@ -1139,6 +1281,15 @@ async function main(): Promise<void> {
@@ -1139,6 +1281,15 @@ async function main(): Promise<void> {
log.child({ system: 'indexnow' }),
log.child({ system: 'indexnow' }),
),
),
config.placeId,
config.placeId,
+ // One map document per place: surf maps are separate files, the rest
+ // share the configured editor map.
+ (place) => {
+ const surf = place ? surfMapOfPlace(place) : null
+ if (surf) return surfMapPath(surf.id)
+ if (!place || place === config.placeId)
+ return primarySurf ? surfMapPath(primarySurf.id) : config.mapPath
+ return undefined
+ },
)
)
const drainer = httpDrainer(http)
const drainer = httpDrainer(http)
world.reconcileMapNodes()
world.reconcileMapNodes()
@@ -192,6 +192,8 @@ export function createHttpServer(
@@ -192,6 +192,8 @@ export function createHttpServer(
tickets?: WsTicketStore,
tickets?: WsTicketStore,
indexnow?: SharedIndexNow,
indexnow?: SharedIndexNow,
defaultPlaceId = 'scraplandia',
defaultPlaceId = 'scraplandia',
+ /** Per-place map documents (surf places each have their own). */
+ mapPathFor?: (place: string | undefined) => string | undefined,
): 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
@@ -265,15 +267,16 @@ export function createHttpServer(
@@ -265,15 +267,16 @@ export function createHttpServer(
if (serveLegal(req, res)) return
if (serveLegal(req, res)) return
if (!playHost && serveUpdates(req, res)) return
if (!playHost && serveUpdates(req, res)) return
const requestedMapPlace = new URL(req.url ?? '/', 'http://games.local').searchParams.get('game')
const requestedMapPlace = new URL(req.url ?? '/', 'http://games.local').searchParams.get('game')
- if (url === '/map.json' && requestedMapPlace && requestedMapPlace !== defaultPlaceId) {
+ const resolvedMapPath = mapPathFor ? mapPathFor(requestedMapPlace ?? defaultPlaceId) : mapPath
+ if (url === '/map.json' && requestedMapPlace && requestedMapPlace !== defaultPlaceId && !resolvedMapPath) {
res.writeHead(404, { 'content-type': 'application/json', 'cache-control': 'no-store' })
res.writeHead(404, { 'content-type': 'application/json', 'cache-control': 'no-store' })
res.end('{"error":"no_editor_map"}')
res.end('{"error":"no_editor_map"}')
return
return
}
}
- if (url === '/map.json' && mapPath) {
+ if (url === '/map.json' && resolvedMapPath) {
// 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.
- void loadMap(mapPath).then((rec) => {
+ void loadMap(resolvedMapPath).then((rec) => {
res.writeHead(200, {
res.writeHead(200, {
'content-type': 'application/json',
'content-type': 'application/json',
'cache-control': 'no-cache',
'cache-control': 'no-cache',
@@ -737,8 +740,10 @@ export function createHttpServer(
@@ -737,8 +740,10 @@ export function createHttpServer(
res.end(prometheusText(metrics.snapshot() as unknown as Record<string, unknown>))
res.end(prometheusText(metrics.snapshot() as unknown as Record<string, unknown>))
return
return
}
}
+ // ?place=<id>: the world gauges of that place's rooms only (the process hosts several places).
+ const place = new URLSearchParams((req.url ?? '').split('?')[1] ?? '').get('place')
res.writeHead(200, { 'content-type': 'application/json' })
res.writeHead(200, { 'content-type': 'application/json' })
- res.end(JSON.stringify(metrics.snapshot()))
+ res.end(JSON.stringify(place && /^[a-z0-9-]{1,64}$/.test(place) ? metrics.placeSnapshot(place) : metrics.snapshot()))
return
return
}
}
// Every API route has answered above; what is left under /api is unknown.
// Every API route has answered above; what is left under /api is unknown.
@@ -808,7 +813,9 @@ export function createHttpServer(
@@ -808,7 +813,9 @@ export function createHttpServer(
? 'editor.html'
? 'editor.html'
: url === '/worlds'
: url === '/worlds'
? 'worlds.html'
? 'worlds.html'
- : null
+ : url === '/surf/embed'
+ ? 'play.html'
+ : null
// Static files with path traversal guard.
// Static files with path traversal guard.
const safePath = normalize(pageAlias ?? url).replace(/^(\.\.[/\\])+/, '')
const safePath = normalize(pageAlias ?? url).replace(/^(\.\.[/\\])+/, '')
@@ -858,6 +865,11 @@ export function createHttpServer(
@@ -858,6 +865,11 @@ export function createHttpServer(
: filePath.includes('/assets/')
: filePath.includes('/assets/')
? 'public, max-age=86400, stale-while-revalidate=604800'
? 'public, max-age=86400, stale-while-revalidate=604800'
: 'public, max-age=3600',
: 'public, max-age=3600',
+ // The embeddable surf route is frameable by openvibe.surf only; every
+ // other page keeps the deployment's no-CSP/no-frame-rule default.
+ ...(url === '/surf/embed'
+ ? { 'content-security-policy': "frame-ancestors https://openvibe.surf 'self'" }
+ : {}),
})
})
createReadStream(filePath)
createReadStream(filePath)
.on('error', (err) => {
.on('error', (err) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,185 @@
@@ -0,0 +1,185 @@
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import { SURF_MAPS, type SurfMapInfo } from '@openvibe/content'
+import type { Db } from 'openvibe-sdk/db'
+import { isGuestToken } from '../platform/accounts.js'
+import { PgSurfRecords } from '../platform/surfRuns.js'
+import { resolveNetworkUser } from './networkAuth.js'
+import { hashGuestKey } from './guestIdentity.js'
+
+/**
+ * Public surf reads. Mounted through the same `platform.handle` seam as the
+ * other public APIs (main.ts), before the static/SPA fallback:
+ *
+ * GET /api/surf/maps
+ * GET /api/surf/maps/:id
+ * GET /api/surf/maps/:id/ghost?of=wr|pb
+ *
+ * Maps and the WR ghost are briefly public-cached; a PB ghost is private.
+ * `pb` needs the caller's identity: a Network bearer or a guest token (the
+ * same identity the WebSocket upgrade uses, never a query parameter).
+ */
+export interface SurfRoutesOptions {
+ db: Db
+ networkAuthUrl: string | null
+ /** Optional Play board read for the map page; local runs are the fallback. */
+ playBoard?: (mapId: string) => Promise<{
+ entries: { rank: number; name: string; score: number }[]
+ } | null>
+ log?: (message: string) => void
+}
+
+const JSON_CACHE = 'public, max-age=15'
+const GHOST_CACHE = 'public, max-age=60'
+
+function send(res: ServerResponse, status: number, body: unknown, cache: string): void {
+ res.writeHead(status, {
+ 'content-type': 'application/json; charset=utf-8',
+ 'cache-control': cache,
+ })
+ res.end(JSON.stringify(body))
+}
+
+function noStore(res: ServerResponse, status: number, body: unknown): void {
+ send(res, status, body, 'no-store')
+}
+
+function mapInfo(id: string): SurfMapInfo | undefined {
+ return SURF_MAPS.find((m) => m.id === id)
+}
+
+export function handleSurfRead(
+ req: IncomingMessage,
+ res: ServerResponse,
+ options: SurfRoutesOptions,
+): boolean {
+ if (req.method !== 'GET') return false
+ const url = new URL(req.url ?? '/', 'http://games.local')
+ if (!url.pathname.startsWith('/api/surf/')) return false
+ const parts = url.pathname.split('/').filter(Boolean) // api surf maps <id> ghost
+ if (parts[0] !== 'api' || parts[1] !== 'surf' || parts[2] !== 'maps') return false
+ const records = new PgSurfRecords(options.db, null, options.log ?? (() => {}))
+ const id = parts[3]
+ const ghost = parts[4]
+
+ if (!id) {
+ if (parts.length !== 3) return false
+ void records
+ .maps()
+ .then((maps) =>
+ send(
+ res,
+ 200,
+ {
+ maps: maps.map((m) => ({
+ id: m.id,
+ name: m.name,
+ tier: m.tier,
+ stages: m.stages,
+ author: m.author,
+ records: m.records,
+ wrMs: m.wrMs,
+ wrHolder: m.wrHolder,
+ })),
+ },
+ JSON_CACHE,
+ ),
+ )
+ .catch(() => noStore(res, 503, { error: 'surf_unavailable' }))
+ return true
+ }
+
+ const info = mapInfo(id)
+ if (!info) {
+ noStore(res, 404, { error: 'unknown_map' })
+ return true
+ }
+
+ if (ghost) {
+ if (ghost !== 'ghost' || parts.length !== 5) return false
+ const of = url.searchParams.get('of') ?? 'wr'
+ if (of !== 'wr' && of !== 'pb') {
+ noStore(res, 400, { error: 'invalid_query' })
+ return true
+ }
+ void (async () => {
+ let key: string | undefined
+ if (of === 'pb') {
+ const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1]
+ const guest = req.headers['x-guest-token']
+ if (bearer && options.networkAuthUrl) {
+ const user = await resolveNetworkUser(options.networkAuthUrl, bearer)
+ key = user?.subjectId ?? undefined
+ } else if (typeof guest === 'string' && isGuestToken(guest)) {
+ key = `guest:${hashGuestKey(guest)}`
+ }
+ if (!key) {
+ noStore(res, 401, { error: 'unauthorized' })
+ return
+ }
+ }
+ const bytes = await records.ghostBytes(id, of, key)
+ if (!bytes) {
+ noStore(res, 404, { error: 'no_ghost' })
+ return
+ }
+ res.writeHead(200, {
+ 'content-type': 'application/octet-stream',
+ 'content-encoding': 'gzip',
+ 'cache-control': of === 'wr' ? GHOST_CACHE : 'private, no-store',
+ 'content-length': String(bytes.length),
+ })
+ res.end(bytes)
+ })().catch(() => {
+ if (!res.headersSent) noStore(res, 503, { error: 'surf_unavailable' })
+ })
+ return true
+ }
+
+ if (parts.length !== 4) return false
+ void (async () => {
+ const maps = await records.maps()
+ const summary = maps.find((m) => m.id === id)!
+ const local = await records.topRuns(id, 20)
+ let entries: { rank: number; name: string; timeMs: number; runId: string | null }[] = local.map((r, i) => ({
+ rank: i + 1,
+ name: r.name,
+ timeMs: r.timeMs,
+ runId: r.id,
+ }))
+ // Prefer Play's board when the platform is reachable; local runs are the
+ // honest fallback when it is not.
+ if (options.playBoard) {
+ try {
+ const board = await options.playBoard(id)
+ if (board?.entries.length)
+ entries = board.entries.map((e) => ({
+ rank: e.rank,
+ name: e.name,
+ timeMs: e.score,
+ runId: null,
+ }))
+ } catch {
+ // keep local
+ }
+ }
+ send(
+ res,
+ 200,
+ {
+ map: {
+ id: summary.id,
+ name: summary.name,
+ tier: summary.tier,
+ stages: summary.stages,
+ author: summary.author,
+ records: summary.records,
+ wrMs: summary.wrMs,
+ wrHolder: summary.wrHolder,
+ },
+ top: entries,
+ },
+ JSON_CACHE,
+ )
+ })().catch(() => noStore(res, 503, { error: 'surf_unavailable' }))
+ return true
+}
@@ -38,4 +38,15 @@ describe('ServerMetrics room gauges', () => {
@@ -38,4 +38,15 @@ describe('ServerMetrics room gauges', () => {
expect(m.mapZones).toBe(1)
expect(m.mapZones).toBe(1)
expect(m.roomCount).toBe(1)
expect(m.roomCount).toBe(1)
})
})
+ it("answers one place's gauges, not the other places' (the e2e reads its own place through /metrics?place=)", () => {
+ const m = new ServerMetrics()
+ m.sampleRoom('scraplandia:0', gauges({ mapStatics: 0, entities: 3 }), 1_000)
+ m.sampleRoom('surf-lagoon:0', gauges({ mapStatics: 47, entities: 1 }), 1_000)
+ expect(m.mapStatics).toBe(47)
+ const one = m.placeSnapshot('scraplandia')
+ expect(one['mapStatics']).toBe(0)
+ expect(one['entities']).toBe(3)
+ expect(one['rooms']).toBe(1)
+ expect(m.placeSnapshot('nowhere')['rooms']).toBe(0)
+ })
})
})
@@ -91,6 +91,21 @@ export class ServerMetrics {
@@ -91,6 +91,21 @@ export class ServerMetrics {
for (const k of ROOM_GAUGES) this[k] = totals[k]
for (const k of ROOM_GAUGES) this[k] = totals[k]
}
}
+ /**
+ * One place's view: the snapshot with the world gauges summed over that place's rooms only (`/metrics?place=`).
+ * The process also hosts other places (the second built-in place, one per surf map), whose maps add to the totals.
+ */
+ placeSnapshot(place: string): Record<string, number> {
+ const totals = Object.fromEntries(ROOM_GAUGES.map((k) => [k, 0])) as Record<keyof RoomGauges, number>
+ let rooms = 0
+ for (const [key, r] of this.rooms) {
+ if (!key.startsWith(`${place}:`)) continue
+ rooms++
+ for (const k of ROOM_GAUGES) totals[k] += r.gauges[k]
+ }
+ return { ...this.snapshot(), ...totals, rooms }
+ }
+
/** How many rooms reported recently. */
/** How many rooms reported recently. */
get roomCount(): number {
get roomCount(): number {
return this.rooms.size
return this.rooms.size
new file mode 100644
new file mode 100644
@@ -0,0 +1,176 @@
@@ -0,0 +1,176 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { gunzipSync } from 'node:zlib'
+import { describe, expect, it } from 'vitest'
+import { openTestDb } from '@openvibe/persistence/testing'
+import { decodeSurfTrack, type SurfTrackSample } from '@openvibe/gameplay'
+import { PgSurfRecords, createSurfPlay, surfBoardId, surfPlayKey } from './surfRuns.js'
+import type { SurfRunRecord } from '../game/surfRecords.js'
+
+/** A made-up person (Network subject). */
+const PERSON = 'usr_01ARZ3NDEKTSV4RRFFQ69G5FAV'
+
+const MAP = 'surf_lagoon'
+
+function track(n = 8): SurfTrackSample[] {
+ return Array.from({ length: n }, (_, i) => ({
+ tick: 100 + i,
+ x: i * 0.5,
+ y: 5 - i * 0.1,
+ z: -i * 0.25,
+ yaw: (i / n) * Math.PI,
+ }))
+}
+
+function run(partial: Partial<SurfRunRecord> = {}): SurfRunRecord {
+ return {
+ id: crypto.randomUUID(),
+ mapId: MAP,
+ accountKey: PERSON,
+ playerName: 'Tester',
+ timeMs: 12_345,
+ splits: [4_000, 8_000, 12_345],
+ track: track(),
+ tickRate: 30,
+ startedTick: 100,
+ finishedTick: 500,
+ startFrac: 0.25,
+ finishFrac: 0.75,
+ fails: 0,
+ createdAt: Date.now(),
+ ...partial,
+ }
+}
+
+function fakePlay() {
+ const defined: string[] = []
+ const posted: { board: string; time: number; ref: string }[] = []
+ return {
+ defined,
+ posted,
+ poster: {
+ async define() {
+ defined.push('surf')
+ },
+ async post(r: SurfRunRecord) {
+ posted.push({ board: surfBoardId(r.mapId), time: r.timeMs, ref: r.id })
+ return { rank: null }
+ },
+ },
+ }
+}
+
+describe('surf runs persistence', () => {
+ it('keeps the PB and the last five runs, derives the WR, and round-trips the ghost', async () => {
+ const db = await openTestDb()
+ const records = new PgSurfRecords(db)
+ const key = PERSON
+ const times = [20_000, 18_000, 16_000, 15_000, 14_000, 13_000, 12_000]
+ for (const [i, timeMs] of times.entries())
+ await records.record(run({ timeMs, createdAt: 1_000 + i, splits: [timeMs] }))
+ const leaderboard = await records.topRuns(MAP, 20)
+ expect(leaderboard[0]!.timeMs).toBe(12_000)
+ // One PB + five non-PB runs: the two slowest are gone.
+ const rows = await db.many<{ n: number | string }>(
+ `SELECT COUNT(*) AS n FROM surf_runs WHERE map_id = $1 AND account_key = $2`,
+ [MAP, key],
+ )
+ expect(Number(rows[0]!.n)).toBe(6)
+ const pbs = await db.many(
+ `SELECT time_ms FROM surf_runs WHERE map_id = $1 AND account_key = $2 AND is_pb`,
+ [MAP, key],
+ )
+ expect(pbs).toHaveLength(1)
+ expect((pbs[0] as { time_ms: number }).time_ms).toBe(12_000)
+ expect(await records.pbMs(MAP, key)).toBe(12_000)
+ expect(await records.wrMs(MAP)).toBe(12_000)
+ expect((await records.pbRun(MAP, key))?.splits).toEqual([12_000])
+ const summary = (await records.maps()).find((m) => m.id === MAP)!
+ expect(summary.records).toBe(6)
+ expect(summary.wrMs).toBe(12_000)
+ expect(summary.wrHolder).toBe('Tester')
+
+ const ghost = await records.ghostBytes(MAP, 'wr')
+ expect(ghost).not.toBeNull()
+ const decoded = decodeSurfTrack(new Uint8Array(gunzipSync(ghost!)))
+ expect(decoded.samples[0]!.x).toBeCloseTo(0)
+ expect(decoded.samples[7]!.z).toBeCloseTo(-1.75)
+ expect((await records.ghostBytes(MAP, 'pb', key))).not.toBeNull()
+ expect(await records.ghostBytes(MAP, 'pb', 'guest:unknown')).toBeNull()
+ })
+
+ it('play: defines and posts the `time` board with the run ms', async () => {
+ const db = await openTestDb()
+ const play = fakePlay()
+ const records = new PgSurfRecords(db, play.poster)
+ const outcome = await records.record(run({ id: 'run-1', timeMs: 9_876 }))
+ expect(outcome.pb).toBe(true)
+ expect(play.defined).toEqual(['surf'])
+ expect(play.posted).toEqual([{ board: 'lagoon', time: 9_876, ref: 'run-1' }])
+ expect(surfPlayKey('surf_lagoon')).toBe('surf:lagoon')
+ })
+
+ it('an unknown-kind Play answer leaves the run saved and reports no rank', async () => {
+ const db = await openTestDb()
+ const warnings: string[] = []
+ const records = new PgSurfRecords(
+ db,
+ {
+ async define() {},
+ async post() {
+ throw new Error('Play POST /titles/surf/scores answered 400: {"code":"play.bad_definitions","message":"boards[0].kind is best or sum"}')
+ },
+ },
+ (m) => warnings.push(m),
+ )
+ const outcome = await records.record(run({ timeMs: 7_000 }))
+ expect(outcome.pb).toBe(true)
+ expect(outcome.rank).toBeNull()
+ const rows = await db.many(`SELECT time_ms FROM surf_runs WHERE map_id = $1`, [MAP])
+ expect(rows).toHaveLength(1)
+ expect(warnings).toHaveLength(1)
+ // The disabled publisher is not asked again, and the run still saves.
+ await records.record(run({ timeMs: 8_000 }))
+ expect(warnings).toHaveLength(1)
+ })
+
+ it('defines the surf time boards and posts milliseconds through Play', async () => {
+ const calls: { method: string; path: string; body: any }[] = []
+ const poster = createSurfPlay({
+ url: 'https://play.test',
+ tokens: { getToken: async () => 'token' },
+ fetchImpl: (async (url: string, init?: RequestInit) => {
+ calls.push({
+ method: String(init?.method),
+ path: new URL(url).pathname,
+ body: init?.body ? JSON.parse(String(init.body)) : null,
+ })
+ return new Response('{"recorded":1,"rank":2}', {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ }) as typeof fetch,
+ })!
+ await poster.define()
+ expect(calls[0]!.path).toBe('/api/v1/titles/surf')
+ expect(calls[0]!.body.boards).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: 'lagoon', kind: 'time' }),
+ expect.objectContaining({ id: 'canyon', kind: 'time' }),
+ expect.objectContaining({ id: 'orbit', kind: 'time' }),
+ ]),
+ )
+ const result = await poster.post(run({ id: 'run-9', timeMs: 4_321 }))
+ expect(calls[1]!.path).toBe('/api/v1/titles/surf/scores')
+ expect(calls[1]!.body).toMatchObject({
+ board: 'lagoon',
+ ref: 'run-9',
+ entries: [{ score: 4_321 }],
+ })
+ expect(result.rank).toBe(2)
+ })
+
+ it('builds a Play poster only when URL and tokens exist', () => {
+ expect(createSurfPlay({ url: null, tokens: null })).toBeNull()
+ expect(createSurfPlay({ url: 'https://play.openvibe.games', tokens: null })).toBeNull()
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,330 @@
@@ -0,0 +1,330 @@
+import { gzipSync } from 'node:zlib'
+import { sql, type Db } from 'openvibe-sdk/db'
+import { SURF_MAPS, type SurfMapInfo } from '@openvibe/content'
+import { decodeSurfTrack, encodeSurfTrack, type DecodedGhost } from '@openvibe/gameplay'
+import { accountKey } from '@openvibe/persistence'
+import type { SurfRecordOutcome, SurfRecords, SurfRunRecord } from '../game/surfRecords.js'
+import { toPlayer } from './playSync.js'
+
+/**
+ * Games' own surf persistence (migration 0018) and the Play `time` board.
+ *
+ * Retention is part of the contract: one PB plus the last five runs per
+ * player per map. The world record is derived, never stored twice, so it
+ * cannot drift from the runs that produced it. A run is written locally
+ * before Play is asked; Play being down, or not knowing the `time` kind yet,
+ * can never lose or fail a run.
+ */
+export interface SurfMapSummary {
+ id: string
+ name: string
+ tier: number
+ stages: number
+ author: string
+ records: number
+ wrMs: number | null
+ wrHolder: string | null
+}
+
+export interface SurfRunRow {
+ id: string
+ name: string
+ timeMs: number
+ createdAt: number
+ isPb: boolean
+}
+
+export interface SurfPlayPoster {
+ /** Register the surf title/boards; safe to call repeatedly. */
+ define(): Promise<void>
+ /** Post one run's ms. Returns the rank when Play reports one. */
+ post(run: SurfRunRecord): Promise<{ rank: number | null }>
+}
+
+/** The Play board id for a map: `surf` title, `<slug>` board (`surf:lagoon`). */
+export function surfBoardId(mapId: string): string {
+ return mapId.replace(/^surf_/, '').replace(/[^a-z0-9_-]/g, '')
+}
+
+export function surfPlayKey(mapId: string): string {
+ return `surf:${surfBoardId(mapId)}`
+}
+
+function trackBytes(run: SurfRunRecord): Buffer {
+ const raw = encodeSurfTrack(run.track, run.tickRate, run.startedTick)
+ return gzipSync(Buffer.from(raw))
+}
+
+function toBuffer(value: unknown): Buffer {
+ if (Buffer.isBuffer(value)) return value
+ if (value instanceof Uint8Array) return Buffer.from(value)
+ if (typeof value === 'string') return Buffer.from(value, 'base64')
+ throw new Error('surf track is not bytes')
+}
+
+function splitArray(value: unknown): number[] {
+ if (Array.isArray(value)) return value.map(Number)
+ if (typeof value === 'string') {
+ try {
+ const parsed: unknown = JSON.parse(value)
+ return Array.isArray(parsed) ? parsed.map(Number) : []
+ } catch {
+ return []
+ }
+ }
+ return []
+}
+
+export function ghostOfRun(row: { track: unknown }): DecodedGhost {
+ return decodeSurfTrack(new Uint8Array(toBuffer(row.track)))
+}
+
+export class PgSurfRecords implements SurfRecords {
+ private playWarned = false
+ private playDisabled = false
+
+ constructor(
+ private readonly db: Db,
+ private readonly play: SurfPlayPoster | null = null,
+ private readonly warn: (message: string) => void = () => {},
+ ) {}
+
+ async pbMs(mapId: string, key: string): Promise<number | null> {
+ const row = await this.db.maybe<{ time_ms: number | string }>(sql`
+ SELECT time_ms FROM surf_runs
+ WHERE map_id = ${mapId} AND account_key = ${accountKey(key)} AND is_pb
+ LIMIT 1
+ `)
+ return row ? Number(row.time_ms) : null
+ }
+
+ async pbRun(
+ mapId: string,
+ key: string,
+ ): Promise<{ timeMs: number; splits: number[] } | null> {
+ const row = await this.db.maybe<{ time_ms: number | string; splits: unknown }>(sql`
+ SELECT time_ms, splits FROM surf_runs
+ WHERE map_id = ${mapId} AND account_key = ${accountKey(key)} AND is_pb
+ LIMIT 1
+ `)
+ if (!row) return null
+ return { timeMs: Number(row.time_ms), splits: splitArray(row.splits) }
+ }
+
+ async wrMs(mapId: string): Promise<number | null> {
+ const row = await this.db.maybe<{ time_ms: number | string | null }>(sql`
+ SELECT MIN(time_ms) AS time_ms FROM surf_runs WHERE map_id = ${mapId}
+ `)
+ return row?.time_ms === null || row?.time_ms === undefined ? null : Number(row.time_ms)
+ }
+
+ async record(run: SurfRunRecord): Promise<SurfRecordOutcome> {
+ const key = accountKey(run.accountKey)
+ const bytes = trackBytes(run)
+ let pb = false
+ let wr = false
+ let pbMs: number | null = null
+ let wrMs: number | null = null
+ await this.db.tx(async () => {
+ const previousPb = await this.db.maybe<{ time_ms: number | string }>(sql`
+ SELECT time_ms FROM surf_runs
+ WHERE map_id = ${run.mapId} AND account_key = ${key} AND is_pb
+ LIMIT 1
+ `)
+ const prev = previousPb ? Number(previousPb.time_ms) : null
+ pb = prev === null || run.timeMs < prev
+ if (pb) {
+ await this.db.exec(sql`
+ UPDATE surf_runs SET is_pb = false
+ WHERE map_id = ${run.mapId} AND account_key = ${key} AND is_pb
+ `)
+ }
+ await this.db.exec(sql`
+ INSERT INTO surf_runs
+ (id, map_id, account_key, player_name, time_ms, splits, track, is_pb, created_at)
+ VALUES
+ (${run.id}, ${run.mapId}, ${key}, ${run.playerName}, ${run.timeMs},
+ ${JSON.stringify(run.splits)}::jsonb, ${bytes}, ${pb}, ${run.createdAt})
+ ON CONFLICT (id) DO NOTHING
+ `)
+ // One PB plus the last five non-PB runs. "At most" is enforced here,
+ // not by a reader that might forget.
+ await this.db.exec(sql`
+ DELETE FROM surf_runs
+ WHERE id IN (
+ SELECT id FROM surf_runs
+ WHERE map_id = ${run.mapId} AND account_key = ${key} AND NOT is_pb
+ ORDER BY created_at DESC, id DESC
+ OFFSET 5
+ )
+ `)
+ const best = await this.db.maybe<{ time_ms: number | string | null }>(sql`
+ SELECT MIN(time_ms) AS time_ms FROM surf_runs WHERE map_id = ${run.mapId}
+ `)
+ wrMs = best?.time_ms === null || best?.time_ms === undefined ? null : Number(best.time_ms)
+ wr = wrMs === run.timeMs
+ const mine = await this.db.maybe<{ time_ms: number | string }>(sql`
+ SELECT time_ms FROM surf_runs
+ WHERE map_id = ${run.mapId} AND account_key = ${key} AND is_pb LIMIT 1
+ `)
+ pbMs = mine ? Number(mine.time_ms) : null
+ })
+
+ let rank: number | null = null
+ if (this.play && !this.playDisabled && toPlayer(key)) {
+ try {
+ await this.play.define()
+ rank = (await this.play.post(run)).rank
+ } catch (err) {
+ // Never fail a run because the platform is down or does not know
+ // `time` yet. Keep the local row (already committed) and log once.
+ if (!this.playWarned) {
+ this.playWarned = true
+ this.warn(`surf Play post skipped: ${String(err)}`)
+ }
+ if (/unknown|kind|400/i.test(String(err))) this.playDisabled = true
+ }
+ }
+ return { pb, wr, rank, pbMs, wrMs }
+ }
+
+ /** Every map with its record count and derived world record. */
+ async maps(): Promise<SurfMapSummary[]> {
+ const rows = await this.db.many<{
+ map_id: string
+ records: number | string
+ wr_ms: number | string | null
+ wr_holder: string | null
+ }>(sql`
+ SELECT r.map_id,
+ COUNT(*)::int AS records,
+ MIN(r.time_ms) AS wr_ms,
+ (SELECT player_name FROM surf_runs w
+ WHERE w.map_id = r.map_id
+ ORDER BY w.time_ms ASC, w.created_at ASC LIMIT 1) AS wr_holder
+ FROM surf_runs r GROUP BY r.map_id
+ `)
+ const byMap = new Map(rows.map((r) => [r.map_id, r]))
+ return SURF_MAPS.map((m: SurfMapInfo) => {
+ const row = byMap.get(m.id)
+ return {
+ id: m.id,
+ name: m.name,
+ tier: m.tier,
+ stages: m.stages,
+ author: m.author,
+ records: Number(row?.records ?? 0),
+ wrMs: row?.wr_ms === null || row?.wr_ms === undefined ? null : Number(row.wr_ms),
+ wrHolder: row?.wr_holder ?? null,
+ }
+ })
+ }
+
+ async topRuns(mapId: string, limit = 20): Promise<SurfRunRow[]> {
+ const rows = await this.db.many<{
+ id: string
+ player_name: string
+ time_ms: number | string
+ created_at: number | string
+ is_pb: boolean
+ }>(sql`
+ SELECT id, player_name, time_ms, created_at, is_pb
+ FROM surf_runs WHERE map_id = ${mapId}
+ ORDER BY time_ms ASC, created_at ASC
+ LIMIT ${Math.max(1, Math.min(20, limit))}
+ `)
+ return rows.map((r) => ({
+ id: r.id,
+ name: r.player_name,
+ timeMs: Number(r.time_ms),
+ createdAt: Number(r.created_at),
+ isPb: r.is_pb,
+ }))
+ }
+
+ async ghost(mapId: string, of: 'wr' | 'pb', key?: string): Promise<DecodedGhost | null> {
+ const bytes = await this.ghostBytes(mapId, of, key)
+ return bytes ? decodeSurfTrack(new Uint8Array(bytes)) : null
+ }
+
+ /** The stored gzipped track, ready to serve with `content-encoding: gzip`. */
+ async ghostBytes(mapId: string, of: 'wr' | 'pb', key?: string): Promise<Buffer | null> {
+ const row =
+ of === 'wr'
+ ? await this.db.maybe<{ track: unknown }>(sql`
+ SELECT track FROM surf_runs WHERE map_id = ${mapId}
+ ORDER BY time_ms ASC, created_at ASC LIMIT 1
+ `)
+ : await this.db.maybe<{ track: unknown }>(sql`
+ SELECT track FROM surf_runs
+ WHERE map_id = ${mapId} AND account_key = ${accountKey(key ?? '')} AND is_pb
+ LIMIT 1
+ `)
+ return row ? toBuffer(row.track) : null
+ }
+}
+
+const PLAY_TIMEOUT_MS = 4_000
+
+/** A minimal Play client for the surf time board (title `surf`, one board per map, kind `time`). */
+export function createSurfPlay(opts: {
+ url: string | null
+ tokens: { getToken(ctx?: { audience?: string; scope?: string }): Promise<string> } | null
+ fetchImpl?: typeof fetch
+}): SurfPlayPoster | null {
+ if (!opts.url || !opts.tokens) return null
+ const doFetch = opts.fetchImpl ?? fetch
+ const base = opts.url.replace(/\/+$/, '')
+ let defined = false
+ const call = async (method: string, path: string, body?: unknown): Promise<unknown> => {
+ const token = await opts.tokens!.getToken({
+ audience: 'openvibe.play',
+ scope: 'play.title.manage play.result.write',
+ })
+ const res = await doFetch(`${base}/api/v1${path}`, {
+ method,
+ headers: {
+ authorization: `Bearer ${token}`,
+ 'content-type': 'application/json',
+ accept: 'application/json',
+ },
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+ signal: AbortSignal.timeout(PLAY_TIMEOUT_MS),
+ })
+ const text = await res.text()
+ if (!res.ok)
+ throw new Error(`Play ${method} ${path} answered ${res.status}: ${text.slice(0, 200)}`)
+ return text ? JSON.parse(text) : null
+ }
+ return {
+ async define() {
+ if (defined) return
+ await call('PUT', '/titles/surf', {
+ name: 'OpenVibe.Surf',
+ url: 'https://openvibe.surf',
+ summary: 'Counter-Strike-style surf time trials on the OpenVibe.Games engine.',
+ boards: SURF_MAPS.map((m) => ({
+ id: surfBoardId(m.id),
+ name: `${m.name} — time`,
+ kind: 'time',
+ })),
+ })
+ defined = true
+ },
+ async post(run) {
+ const player = toPlayer(accountKey(run.accountKey))
+ if (!player) return { rank: null }
+ const body = await call('POST', '/titles/surf/scores', {
+ board: surfBoardId(run.mapId),
+ ref: run.id,
+ at: run.createdAt,
+ entries: [{ player, name: run.playerName.slice(0, 40) || 'Player', score: run.timeMs }],
+ })
+ const rank =
+ body && typeof body === 'object' && 'rank' in body
+ ? Number((body as { rank: unknown }).rank)
+ : null
+ return { rank: Number.isSafeInteger(rank) && (rank as number) > 0 ? (rank as number) : null }
+ },
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,452 @@
@@ -0,0 +1,452 @@
+{
+ "v": 2,
+ "movement": "surf",
+ "surf": {
+ "title": "Surf Canyon",
+ "author": "OpenVibe",
+ "tier": 2,
+ "stages": 4,
+ "failY": 0.527
+ },
+ "spawn": [
+ 0,
+ 59,
+ 3
+ ],
+ "spawnYaw": 0,
+ "terrains": [],
+ "statics": [
+ {
+ "id": "start-pad",
+ "shape": {
+ "type": "box",
+ "size": [
+ 17,
+ 1,
+ 12
+ ]
+ },
+ "pos": [
+ 0,
+ 57.5,
+ 6
+ ],
+ "color": "#285867"
+ },
+ {
+ "id": "practice-ramp",
+ "shape": {
+ "type": "box",
+ "size": [
+ 11,
+ 0.6,
+ 8.0
+ ]
+ },
+ "pos": [
+ 0,
+ 54.551,
+ -4.049
+ ],
+ "color": "#4aa3b5",
+ "rot": [
+ 0.959931,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "practice-return",
+ "shape": {
+ "type": "box",
+ "size": [
+ 11,
+ 0.6,
+ 6
+ ]
+ },
+ "pos": [
+ 0,
+ 56.6,
+ -8
+ ],
+ "color": "#3d8b9c"
+ },
+ {
+ "id": "ramp-1",
+ "shape": {
+ "type": "box",
+ "size": [
+ 11,
+ 0.6,
+ 14
+ ]
+ },
+ "pos": [
+ 0,
+ 52.094,
+ 15.769
+ ],
+ "color": "#2f6f7e",
+ "rot": [
+ 0.959931,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "landing-1",
+ "shape": {
+ "type": "box",
+ "size": [
+ 13,
+ 1,
+ 10
+ ]
+ },
+ "pos": [
+ 0,
+ 46.032,
+ 25.03
+ ],
+ "color": "#21505d"
+ },
+ {
+ "id": "ramp-2",
+ "shape": {
+ "type": "box",
+ "size": [
+ 11,
+ 0.6,
+ 14
+ ]
+ },
+ "pos": [
+ 0,
+ 39.426,
+ 35.999
+ ],
+ "color": "#3d8b9c",
+ "rot": [
+ 0.959931,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "landing-2",
+ "shape": {
+ "type": "box",
+ "size": [
+ 13,
+ 1,
+ 10
+ ]
+ },
+ "pos": [
+ 0,
+ 33.364,
+ 45.26
+ ],
+ "color": "#21505d"
+ },
+ {
+ "id": "ramp-3",
+ "shape": {
+ "type": "box",
+ "size": [
+ 11,
+ 0.6,
+ 14
+ ]
+ },
+ "pos": [
+ 0,
+ 26.758,
+ 56.229
+ ],
+ "color": "#285867",
+ "rot": [
+ 0.959931,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "landing-3",
+ "shape": {
+ "type": "box",
+ "size": [
+ 13,
+ 1,
+ 10
+ ]
+ },
+ "pos": [
+ 0,
+ 20.696,
+ 65.49
+ ],
+ "color": "#21505d"
+ },
+ {
+ "id": "ramp-4",
+ "shape": {
+ "type": "box",
+ "size": [
+ 11,
+ 0.6,
+ 14
+ ]
+ },
+ "pos": [
+ 0,
+ 14.089,
+ 76.459
+ ],
+ "color": "#4aa3b5",
+ "rot": [
+ 0.959931,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "landing-4",
+ "shape": {
+ "type": "box",
+ "size": [
+ 13,
+ 1,
+ 10
+ ]
+ },
+ "pos": [
+ 0,
+ 8.027,
+ 85.72
+ ],
+ "color": "#21505d"
+ },
+ {
+ "id": "finish-gate-l",
+ "shape": {
+ "type": "box",
+ "size": [
+ 0.4,
+ 5,
+ 0.4
+ ]
+ },
+ "pos": [
+ -6.3,
+ 10.527,
+ 90.72
+ ],
+ "color": "#f4cb65"
+ },
+ {
+ "id": "finish-gate-r",
+ "shape": {
+ "type": "box",
+ "size": [
+ 0.4,
+ 5,
+ 0.4
+ ]
+ },
+ "pos": [
+ 6.3,
+ 10.527,
+ 90.72
+ ],
+ "color": "#f4cb65"
+ },
+ {
+ "id": "bonus-ramp",
+ "shape": {
+ "type": "box",
+ "size": [
+ 5,
+ 0.6,
+ 10
+ ]
+ },
+ "pos": [
+ 10.5,
+ 10.027,
+ 73.72
+ ],
+ "color": "#ef8d4a",
+ "rot": [
+ 1.012291,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "bonus-pad",
+ "shape": {
+ "type": "box",
+ "size": [
+ 6,
+ 1,
+ 8
+ ]
+ },
+ "pos": [
+ 10.5,
+ 5.827,
+ 65.72
+ ],
+ "color": "#ef8d4a"
+ }
+ ],
+ "nodes": [],
+ "props": [],
+ "npcs": [],
+ "lights": [],
+ "zones": [
+ {
+ "id": "start",
+ "name": "Start",
+ "min": [
+ -7.5,
+ 57,
+ 0
+ ],
+ "max": [
+ 7.5,
+ 62,
+ 8
+ ],
+ "rules": {
+ "pvp": false,
+ "build": false,
+ "physgun": false
+ },
+ "surf": {
+ "kind": "start",
+ "stage": 0
+ }
+ },
+ {
+ "id": "cp-1",
+ "name": "Checkpoint 1",
+ "min": [
+ -6.5,
+ 45.532,
+ 26.03
+ ],
+ "max": [
+ 6.5,
+ 50.532,
+ 30.03
+ ],
+ "rules": {
+ "pvp": false,
+ "build": false,
+ "physgun": false
+ },
+ "surf": {
+ "kind": "checkpoint",
+ "stage": 1,
+ "spawn": [
+ 0,
+ 47.532,
+ 28.03
+ ],
+ "yaw": 0
+ }
+ },
+ {
+ "id": "cp-2",
+ "name": "Checkpoint 2",
+ "min": [
+ -6.5,
+ 32.864,
+ 46.26
+ ],
+ "max": [
+ 6.5,
+ 37.864,
+ 50.26
+ ],
+ "rules": {
+ "pvp": false,
+ "build": false,
+ "physgun": false
+ },
+ "surf": {
+ "kind": "checkpoint",
+ "stage": 2,
+ "spawn": [
+ 0,
+ 34.864,
+ 48.26
+ ],
+ "yaw": 0
+ }
+ },
+ {
+ "id": "cp-3",
+ "name": "Checkpoint 3",
+ "min": [
+ -6.5,
+ 20.196,
+ 66.49
+ ],
+ "max": [
+ 6.5,
+ 25.196,
+ 70.49
+ ],
+ "rules": {
+ "pvp": false,
+ "build": false,
+ "physgun": false
+ },
+ "surf": {
+ "kind": "checkpoint",
+ "stage": 3,
+ "spawn": [
+ 0,
+ 22.196,
+ 68.49
+ ],
+ "yaw": 0
+ }
+ },
+ {
+ "id": "finish",
+ "name": "Finish",
+ "min": [
+ -6.5,
+ 7.527,
+ 86.72
+ ],
+ "max": [
+ 6.5,
+ 12.527,
+ 90.72
+ ],
+ "rules": {
+ "pvp": false,
+ "build": false,
+ "physgun": false
+ },
+ "surf": {
+ "kind": "finish",
+ "stage": 4,
+ "spawn": [
+ 0,
+ 9.527,
+ 88.72
+ ],
+ "yaw": 0
+ }
+ }
+ ],
+ "models": [],
+ "textures": []
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,345 @@
@@ -0,0 +1,345 @@
+{
+ "v": 2,
+ "movement": "surf",
+ "surf": {
+ "title": "Surf Lagoon",
+ "author": "OpenVibe",
+ "tier": 1,
+ "stages": 3,
+ "failY": -7.096
+ },
+ "spawn": [
+ 0,
+ 35,
+ 3
+ ],
+ "spawnYaw": 0,
+ "terrains": [],
+ "statics": [
+ {
+ "id": "start-pad",
+ "shape": {
+ "type": "box",
+ "size": [
+ 24,
+ 1,
+ 12
+ ]
+ },
+ "pos": [
+ 0,
+ 33.5,
+ 6
+ ],
+ "color": "#285867"
+ },
+ {
+ "id": "practice-ramp",
+ "shape": {
+ "type": "box",
+ "size": [
+ 18,
+ 0.6,
+ 8.0
+ ]
+ },
+ "pos": [
+ 0,
+ 30.663,
+ -4.226
+ ],
+ "color": "#4aa3b5",
+ "rot": [
+ 0.907571,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "practice-return",
+ "shape": {
+ "type": "box",
+ "size": [
+ 18,
+ 0.6,
+ 6
+ ]
+ },
+ "pos": [
+ 0,
+ 32.6,
+ -8
+ ],
+ "color": "#3d8b9c"
+ },
+ {
+ "id": "ramp-1",
+ "shape": {
+ "type": "box",
+ "size": [
+ 18,
+ 0.6,
+ 14
+ ]
+ },
+ "pos": [
+ 0,
+ 28.299,
+ 16.073
+ ],
+ "color": "#2f6f7e",
+ "rot": [
+ 0.907571,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "landing-1",
+ "shape": {
+ "type": "box",
+ "size": [
+ 20,
+ 1,
+ 12
+ ]
+ },
+ "pos": [
+ 0,
+ 22.468,
+ 26.619
+ ],
+ "color": "#21505d"
+ },
+ {
+ "id": "ramp-2",
+ "shape": {
+ "type": "box",
+ "size": [
+ 18,
+ 0.6,
+ 14
+ ]
+ },
+ "pos": [
+ 0,
+ 17.267,
+ 36.692
+ ],
+ "color": "#3d8b9c",
+ "rot": [
+ 0.907571,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "landing-2",
+ "shape": {
+ "type": "box",
+ "size": [
+ 20,
+ 1,
+ 12
+ ]
+ },
+ "pos": [
+ 0,
+ 11.436,
+ 47.239
+ ],
+ "color": "#21505d"
+ },
+ {
+ "id": "ramp-3",
+ "shape": {
+ "type": "box",
+ "size": [
+ 18,
+ 0.6,
+ 14
+ ]
+ },
+ "pos": [
+ 0,
+ 6.235,
+ 57.312
+ ],
+ "color": "#285867",
+ "rot": [
+ 0.907571,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "landing-3",
+ "shape": {
+ "type": "box",
+ "size": [
+ 20,
+ 1,
+ 12
+ ]
+ },
+ "pos": [
+ 0,
+ 0.404,
+ 67.858
+ ],
+ "color": "#21505d"
+ },
+ {
+ "id": "finish-gate-l",
+ "shape": {
+ "type": "box",
+ "size": [
+ 0.4,
+ 5,
+ 0.4
+ ]
+ },
+ "pos": [
+ -9.8,
+ 2.904,
+ 73.858
+ ],
+ "color": "#f4cb65"
+ },
+ {
+ "id": "finish-gate-r",
+ "shape": {
+ "type": "box",
+ "size": [
+ 0.4,
+ 5,
+ 0.4
+ ]
+ },
+ "pos": [
+ 9.8,
+ 2.904,
+ 73.858
+ ],
+ "color": "#f4cb65"
+ }
+ ],
+ "nodes": [],
+ "props": [],
+ "npcs": [],
+ "lights": [],
+ "zones": [
+ {
+ "id": "start",
+ "name": "Start",
+ "min": [
+ -11.0,
+ 33,
+ 0
+ ],
+ "max": [
+ 11.0,
+ 38,
+ 8
+ ],
+ "rules": {
+ "pvp": false,
+ "build": false,
+ "physgun": false
+ },
+ "surf": {
+ "kind": "start",
+ "stage": 0
+ }
+ },
+ {
+ "id": "cp-1",
+ "name": "Checkpoint 1",
+ "min": [
+ -10.0,
+ 21.968,
+ 28.619
+ ],
+ "max": [
+ 10.0,
+ 26.968,
+ 32.619
+ ],
+ "rules": {
+ "pvp": false,
+ "build": false,
+ "physgun": false
+ },
+ "surf": {
+ "kind": "checkpoint",
+ "stage": 1,
+ "spawn": [
+ 0,
+ 23.968,
+ 30.619
+ ],
+ "yaw": 0
+ }
+ },
+ {
+ "id": "cp-2",
+ "name": "Checkpoint 2",
+ "min": [
+ -10.0,
+ 10.936,
+ 49.239
+ ],
+ "max": [
+ 10.0,
+ 15.936,
+ 53.239
+ ],
+ "rules": {
+ "pvp": false,
+ "build": false,
+ "physgun": false
+ },
+ "surf": {
+ "kind": "checkpoint",
+ "stage": 2,
+ "spawn": [
+ 0,
+ 12.936,
+ 51.239
+ ],
+ "yaw": 0
+ }
+ },
+ {
+ "id": "finish",
+ "name": "Finish",
+ "min": [
+ -10.0,
+ -0.096,
+ 69.858
+ ],
+ "max": [
+ 10.0,
+ 4.904,
+ 73.858
+ ],
+ "rules": {
+ "pvp": false,
+ "build": false,
+ "physgun": false
+ },
+ "surf": {
+ "kind": "finish",
+ "stage": 3,
+ "spawn": [
+ 0,
+ 1.904,
+ 71.858
+ ],
+ "yaw": 0
+ }
+ }
+ ],
+ "models": [],
+ "textures": []
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,588 @@
@@ -0,0 +1,588 @@
+{
+ "v": 2,
+ "movement": "surf",
+ "surf": {
+ "title": "Surf Orbit",
+ "author": "OpenVibe",
+ "tier": 4,
+ "stages": 6,
+ "failY": 3.852
+ },
+ "spawn": [
+ 0,
+ 79,
+ 3
+ ],
+ "spawnYaw": 0,
+ "terrains": [],
+ "statics": [
+ {
+ "id": "start-pad",
+ "shape": {
+ "type": "box",
+ "size": [
+ 12.5,
+ 1,
+ 12
+ ]
+ },
+ "pos": [
+ 0,
+ 77.5,
+ 6
+ ],
+ "color": "#285867"
+ },
+ {
+ "id": "practice-ramp",
+ "shape": {
+ "type": "box",
+ "size": [
+ 6.5,
+ 0.6,
+ 8.0
+ ]
+ },
+ "pos": [
+ 0,
+ 74.449,
+ -3.865
+ ],
+ "color": "#4aa3b5",
+ "rot": [
+ 1.012291,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "practice-return",
+ "shape": {
+ "type": "box",
+ "size": [
+ 6.5,
+ 0.6,
+ 6
+ ]
+ },
+ "pos": [
+ 0,
+ 76.6,
+ -8
+ ],
+ "color": "#3d8b9c"
+ },
+ {
+ "id": "ramp-1",
+ "shape": {
+ "type": "box",
+ "size": [
+ 6.5,
+ 0.6,
+ 13
+ ]
+ },
+ "pos": [
+ 0,
+ 72.329,
+ 15.19
+ ],
+ "color": "#2f6f7e",
+ "rot": [
+ 1.012291,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "landing-1",
+ "shape": {
+ "type": "box",
+ "size": [
+ 8.5,
+ 1,
+ 9
+ ]
+ },
+ "pos": [
+ 0,
+ 66.475,
+ 23.389
+ ],
+ "color": "#21505d"
+ },
+ {
+ "id": "ramp-2",
+ "shape": {
+ "type": "box",
+ "size": [
+ 6.5,
+ 0.6,
+ 13
+ ]
+ },
+ "pos": [
+ 0,
+ 61.304,
+ 31.079
+ ],
+ "color": "#3d8b9c",
+ "rot": [
+ 1.012291,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "landing-2",
+ "shape": {
+ "type": "box",
+ "size": [
+ 8.5,
+ 1,
+ 9
+ ]
+ },
+ "pos": [
+ 0,
+ 55.451,
+ 39.278
+ ],
+ "color": "#21505d"
+ },
+ {
+ "id": "ramp-3",
+ "shape": {
+ "type": "box",
+ "size": [
+ 6.5,
+ 0.6,
+ 13
+ ]
+ },
+ "pos": [
+ 0,
+ 50.279,
+ 46.968
+ ],
+ "color": "#285867",
+ "rot": [
+ 1.012291,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "landing-3",
+ "shape": {
+ "type": "box",
+ "size": [
+ 8.5,
+ 1,
+ 9
+ ]
+ },
+ "pos": [
+ 0,
+ 44.426,
+ 55.167
+ ],
+ "color": "#21505d"
+ },
+ {
+ "id": "ramp-4",
+ "shape": {
+ "type": "box",
+ "size": [
+ 6.5,
+ 0.6,
+ 13
+ ]
+ },
+ "pos": [
+ 0,
+ 39.255,
+ 62.857
+ ],
+ "color": "#4aa3b5",
+ "rot": [
+ 1.012291,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "landing-4",
+ "shape": {
+ "type": "box",
+ "size": [
+ 8.5,
+ 1,
+ 9
+ ]
+ },
+ "pos": [
+ 0,
+ 33.401,
+ 71.056
+ ],
+ "color": "#21505d"
+ },
+ {
+ "id": "ramp-5",
+ "shape": {
+ "type": "box",
+ "size": [
+ 6.5,
+ 0.6,
+ 13
+ ]
+ },
+ "pos": [
+ 0,
+ 28.23,
+ 78.746
+ ],
+ "color": "#21505d",
+ "rot": [
+ 1.012291,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "landing-5",
+ "shape": {
+ "type": "box",
+ "size": [
+ 8.5,
+ 1,
+ 9
+ ]
+ },
+ "pos": [
+ 0,
+ 22.377,
+ 86.945
+ ],
+ "color": "#21505d"
+ },
+ {
+ "id": "ramp-6",
+ "shape": {
+ "type": "box",
+ "size": [
+ 6.5,
+ 0.6,
+ 13
+ ]
+ },
+ "pos": [
+ 0,
+ 17.206,
+ 94.635
+ ],
+ "color": "#5a7f8c",
+ "rot": [
+ 1.012291,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "landing-6",
+ "shape": {
+ "type": "box",
+ "size": [
+ 8.5,
+ 1,
+ 9
+ ]
+ },
+ "pos": [
+ 0,
+ 11.352,
+ 102.834
+ ],
+ "color": "#21505d"
+ },
+ {
+ "id": "finish-gate-l",
+ "shape": {
+ "type": "box",
+ "size": [
+ 0.4,
+ 5,
+ 0.4
+ ]
+ },
+ "pos": [
+ -4.05,
+ 13.852,
+ 107.334
+ ],
+ "color": "#f4cb65"
+ },
+ {
+ "id": "finish-gate-r",
+ "shape": {
+ "type": "box",
+ "size": [
+ 0.4,
+ 5,
+ 0.4
+ ]
+ },
+ "pos": [
+ 4.05,
+ 13.852,
+ 107.334
+ ],
+ "color": "#f4cb65"
+ },
+ {
+ "id": "transfer",
+ "shape": {
+ "type": "box",
+ "size": [
+ 10,
+ 0.6,
+ 9
+ ]
+ },
+ "pos": [
+ -7.25,
+ 13.052,
+ 94.334
+ ],
+ "color": "#8e9ce9",
+ "rot": [
+ 0.872665,
+ 0,
+ 0.314159
+ ]
+ },
+ {
+ "id": "transfer-pad",
+ "shape": {
+ "type": "box",
+ "size": [
+ 8,
+ 1,
+ 8
+ ]
+ },
+ "pos": [
+ -12.25,
+ 8.652,
+ 87.334
+ ],
+ "color": "#8e9ce9"
+ }
+ ],
+ "nodes": [],
+ "props": [],
+ "npcs": [],
+ "lights": [],
+ "zones": [
+ {
+ "id": "start",
+ "name": "Start",
+ "min": [
+ -5.25,
+ 77,
+ 0
+ ],
+ "max": [
+ 5.25,
+ 82,
+ 8
+ ],
+ "rules": {
+ "pvp": false,
+ "build": false,
+ "physgun": false
+ },
+ "surf": {
+ "kind": "start",
+ "stage": 0
+ }
+ },
+ {
+ "id": "cp-1",
+ "name": "Checkpoint 1",
+ "min": [
+ -4.25,
+ 65.975,
+ 23.889
+ ],
+ "max": [
+ 4.25,
+ 70.975,
+ 27.889
+ ],
+ "rules": {
+ "pvp": false,
+ "build": false,
+ "physgun": false
+ },
+ "surf": {
+ "kind": "checkpoint",
+ "stage": 1,
+ "spawn": [
+ 0,
+ 67.975,
+ 25.889
+ ],
+ "yaw": 0
+ }
+ },
+ {
+ "id": "cp-2",
+ "name": "Checkpoint 2",
+ "min": [
+ -4.25,
+ 54.951,
+ 39.778
+ ],
+ "max": [
+ 4.25,
+ 59.951,
+ 43.778
+ ],
+ "rules": {
+ "pvp": false,
+ "build": false,
+ "physgun": false
+ },
+ "surf": {
+ "kind": "checkpoint",
+ "stage": 2,
+ "spawn": [
+ 0,
+ 56.951,
+ 41.778
+ ],
+ "yaw": 0
+ }
+ },
+ {
+ "id": "cp-3",
+ "name": "Checkpoint 3",
+ "min": [
+ -4.25,
+ 43.926,
+ 55.667
+ ],
+ "max": [
+ 4.25,
+ 48.926,
+ 59.667
+ ],
+ "rules": {
+ "pvp": false,
+ "build": false,
+ "physgun": false
+ },
+ "surf": {
+ "kind": "checkpoint",
+ "stage": 3,
+ "spawn": [
+ 0,
+ 45.926,
+ 57.667
+ ],
+ "yaw": 0
+ }
+ },
+ {
+ "id": "cp-4",
+ "name": "Checkpoint 4",
+ "min": [
+ -4.25,
+ 32.901,
+ 71.556
+ ],
+ "max": [
+ 4.25,
+ 37.901,
+ 75.556
+ ],
+ "rules": {
+ "pvp": false,
+ "build": false,
+ "physgun": false
+ },
+ "surf": {
+ "kind": "checkpoint",
+ "stage": 4,
+ "spawn": [
+ 0,
+ 34.901,
+ 73.556
+ ],
+ "yaw": 0
+ }
+ },
+ {
+ "id": "cp-5",
+ "name": "Checkpoint 5",
+ "min": [
+ -4.25,
+ 21.877,
+ 87.445
+ ],
+ "max": [
+ 4.25,
+ 26.877,
+ 91.445
+ ],
+ "rules": {
+ "pvp": false,
+ "build": false,
+ "physgun": false
+ },
+ "surf": {
+ "kind": "checkpoint",
+ "stage": 5,
+ "spawn": [
+ 0,
+ 23.877,
+ 89.445
+ ],
+ "yaw": 0
+ }
+ },
+ {
+ "id": "finish",
+ "name": "Finish",
+ "min": [
+ -4.25,
+ 10.852,
+ 103.334
+ ],
+ "max": [
+ 4.25,
+ 15.852,
+ 107.334
+ ],
+ "rules": {
+ "pvp": false,
+ "build": false,
+ "physgun": false
+ },
+ "surf": {
+ "kind": "finish",
+ "stage": 6,
+ "spawn": [
+ 0,
+ 12.852,
+ 105.334
+ ],
+ "yaw": 0
+ }
+ }
+ ],
+ "models": [],
+ "textures": []
+}
@@ -24,6 +24,7 @@ export * from './defs/resources.js'
@@ -24,6 +24,7 @@ export * from './defs/resources.js'
export * from './packs/scraplandia.js'
export * from './packs/scraplandia.js'
export * from './packs/creatorWorld.js'
export * from './packs/creatorWorld.js'
export * from './packs/creatorAsset.js'
export * from './packs/creatorAsset.js'
+export * from './packs/surf.js'
import { ContentRegistry, type ContentDefs } from './registry.js'
import { ContentRegistry, type ContentDefs } from './registry.js'
import { mergePackDefs, type ContentPackV2 } from './pack.js'
import { mergePackDefs, type ContentPackV2 } from './pack.js'
@@ -18,7 +18,11 @@ import {
@@ -18,7 +18,11 @@ import {
MapLightSchema,
MapLightSchema,
StaticBodySchema,
StaticBodySchema,
ZoneDefSchema,
ZoneDefSchema,
+ MovementStyleSchema,
+ SurfMapMetaSchema,
type FaceStyle,
type FaceStyle,
+ type MovementStyle,
+ type SurfMapMeta,
type ZoneDef,
type ZoneDef,
} from './schema/world.js'
} from './schema/world.js'
import { decodeHeights, encodeHeights, type MapLight } from './mapFile.js'
import { decodeHeights, encodeHeights, type MapLight } from './mapFile.js'
@@ -182,6 +186,10 @@ export const MapFileV2Schema = z.object({
@@ -182,6 +186,10 @@ export const MapFileV2Schema = z.object({
)
)
.max(512)
.max(512)
.default([]),
.default([]),
+ /** Movement preset for this map (resolved on both sides; the world default if absent). */
+ movement: MovementStyleSchema.optional(),
+ /** Surf run metadata (kind: time trial): fail plane, stage count, author and tier. */
+ surf: SurfMapMetaSchema.optional(),
spawn: finiteVec3.optional(),
spawn: finiteVec3.optional(),
spawnYaw: z.number().optional(),
spawnYaw: z.number().optional(),
})
})
@@ -195,6 +203,8 @@ export type MapNodeV2 = MapFileV2['nodes'][number]
@@ -195,6 +203,8 @@ export type MapNodeV2 = MapFileV2['nodes'][number]
export type MapPropV2 = MapFileV2['props'][number]
export type MapPropV2 = MapFileV2['props'][number]
export type MapNpcV2 = MapFileV2['npcs'][number]
export type MapNpcV2 = MapFileV2['npcs'][number]
export type MapModelV2 = MapFileV2['models'][number]
export type MapModelV2 = MapFileV2['models'][number]
+export type MapMovementStyle = MovementStyle
+export type MapSurfMeta = SurfMapMeta
/** The synthetic document id for the spawn point (stored top-level on the wire). */
/** The synthetic document id for the spawn point (stored top-level on the wire). */
export const SPAWN_OBJECT_ID = 'spawn'
export const SPAWN_OBJECT_ID = 'spawn'
@@ -356,6 +366,40 @@ export function validateMapFile(map: MapFileV2): string[] {
@@ -356,6 +366,40 @@ export function validateMapFile(map: MapFileV2): string[] {
for (const l of map.lights) claim(l.id, 'light')
for (const l of map.lights) claim(l.id, 'light')
for (const z0 of map.zones) claim(z0.id, 'zone')
for (const z0 of map.zones) claim(z0.id, 'zone')
if (seen.has(SPAWN_OBJECT_ID)) issues.push(`"${SPAWN_OBJECT_ID}" is reserved for the spawn point`)
if (seen.has(SPAWN_OBJECT_ID)) issues.push(`"${SPAWN_OBJECT_ID}" is reserved for the spawn point`)
+ if (map.surf) issues.push(...surfIssues(map))
+ return issues
+}
+
+/** A surf map's zones must describe exactly one ordered run. */
+function surfIssues(map: MapFileV2): string[] {
+ const issues: string[] = []
+ const surf = map.surf
+ if (!surf) return issues
+ const zones = map.zones.filter((z) => z.surf)
+ const starts = zones.filter((z) => z.surf!.kind === 'start')
+ const finishes = zones.filter((z) => z.surf!.kind === 'finish')
+ if (starts.length !== 1) issues.push(`surf: expected one start zone, found ${starts.length}`)
+ if (finishes.length !== 1) issues.push(`surf: expected one finish zone, found ${finishes.length}`)
+ if (map.movement !== 'surf') issues.push('surf: a surf map must set movement to "surf"')
+ for (const zone of zones) {
+ const meta = zone.surf!
+ const maxStage = meta.kind === 'finish' ? surf.stages : surf.stages - 1
+ if (meta.stage > maxStage)
+ issues.push(`surf zone "${zone.id}": stage ${meta.stage} exceeds ${maxStage}`)
+ if (meta.kind === 'checkpoint' && !meta.spawn)
+ issues.push(`surf zone "${zone.id}": a checkpoint needs a respawn point`)
+ }
+ const checkpoints = zones.filter((z) => z.surf!.kind === 'checkpoint')
+ if (checkpoints.length !== surf.stages - 1)
+ issues.push(
+ `surf: ${checkpoints.length} checkpoints for ${surf.stages} stages (expected ${surf.stages - 1})`,
+ )
+ const stages = checkpoints.map((z) => z.surf!.stage).sort((a, b) => a - b)
+ for (let i = 0; i < stages.length; i++)
+ if (stages[i] !== i + 1) {
+ issues.push(`surf: checkpoint stages must be 1..${surf.stages - 1}, got ${stages.join(',')}`)
+ break
+ }
return issues
return issues
}
}
@@ -427,6 +471,8 @@ export function compileMapFileV2(map: MapFileV2): MapOverride {
@@ -427,6 +471,8 @@ export function compileMapFileV2(map: MapFileV2): MapOverride {
})),
})),
statics: map.statics.map((b) => ({ ...b })),
statics: map.statics.map((b) => ({ ...b })),
zones: map.zones.map((z0): ZoneDef => ({ ...z0, min: [...z0.min], max: [...z0.max] })),
zones: map.zones.map((z0): ZoneDef => ({ ...z0, min: [...z0.min], max: [...z0.max] })),
+ ...(map.movement ? { movement: map.movement } : {}),
+ ...(map.surf ? { surf: map.surf } : {}),
...(map.spawn ? { spawn: map.spawn } : {}),
...(map.spawn ? { spawn: map.spawn } : {}),
...(map.spawnYaw !== undefined ? { spawnYaw: map.spawnYaw } : {}),
...(map.spawnYaw !== undefined ? { spawnYaw: map.spawnYaw } : {}),
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,121 @@
@@ -0,0 +1,121 @@
+import type { ContentPackV2 } from '../pack.js'
+import type { MovementStyle } from '../schema/world.js'
+
+/**
+ * OpenVibe.Surf (openvibe.surf) — Ride v1's three original maps.
+ *
+ * A surf map is an ordinary `MapFileV2` document (`content/surf/<id>.json`) with
+ * the `surf` block, surf zones and `movement: "surf"`. These records are the
+ * catalogue the client's `?map=` URL and the server's place router share; a
+ * place id is `surf-<slug>` so the existing `/play?game=…` scheme carries it
+ * unchanged. None of the maps, names or layouts are CS maps: they are authored
+ * for this engine and this brief.
+ */
+export interface SurfMapInfo {
+ /** Stable map id, also the content world id (`surf_lagoon`). */
+ id: `surf_${'lagoon' | 'canyon' | 'orbit'}`
+ /** Place id in the existing URL scheme (`surf-lagoon`). */
+ placeId: string
+ name: string
+ /** Difficulty: 1 beginner, 2 intermediate, 4 advanced. */
+ tier: number
+ /** Total stages, including the finish. */
+ stages: number
+ author: 'OpenVibe'
+ /** Map document under `content/surf/`. */
+ file: string
+}
+
+export const SURF_AUTHOR = 'OpenVibe' as const
+
+export const SURF_MAPS: readonly SurfMapInfo[] = [
+ {
+ id: 'surf_lagoon',
+ placeId: 'surf-lagoon',
+ name: 'Surf Lagoon',
+ tier: 1,
+ stages: 3,
+ author: SURF_AUTHOR,
+ file: 'surf_lagoon.json',
+ },
+ {
+ id: 'surf_canyon',
+ placeId: 'surf-canyon',
+ name: 'Surf Canyon',
+ tier: 2,
+ stages: 4,
+ author: SURF_AUTHOR,
+ file: 'surf_canyon.json',
+ },
+ {
+ id: 'surf_orbit',
+ placeId: 'surf-orbit',
+ name: 'Surf Orbit',
+ tier: 4,
+ stages: 6,
+ author: SURF_AUTHOR,
+ file: 'surf_orbit.json',
+ },
+] as const
+
+export type SurfMapId = SurfMapInfo['id']
+
+export function isSurfMapId(value: string): value is SurfMapId {
+ return SURF_MAPS.some((m) => m.id === value)
+}
+
+/** Resolve by map id or place id (`surf_lagoon` / `surf-lagoon`). */
+export function surfMap(value: string): SurfMapInfo | undefined {
+ return SURF_MAPS.find((m) => m.id === value || m.placeId === value)
+}
+
+export function surfPlaceId(mapId: SurfMapId): string {
+ return SURF_MAPS.find((m) => m.id === mapId)!.placeId
+}
+
+/** The place id a map id maps to, or null. */
+export function surfPlaceOf(value: string | undefined): string | null {
+ return value ? (surfMap(value)?.placeId ?? null) : null
+}
+
+/** The map id a place id maps to, or null. */
+export function surfMapOfPlace(placeId: string): SurfMapInfo | null {
+ return SURF_MAPS.find((m) => m.placeId === placeId) ?? null
+}
+
+export function isSurfPlace(placeId: string): boolean {
+ return surfMapOfPlace(placeId) !== null
+}
+
+/**
+ * The content pack for one surf place: a world whose movement is the surf
+ * preset. Geometry, zones and spawn all come from the map document, so this
+ * world def is deliberately empty. Both server and client build it, which is
+ * what puts `movement: "surf"` inside the content digest.
+ */
+export function surfPack(mapId: SurfMapId): ContentPackV2 {
+ const info = SURF_MAPS.find((m) => m.id === mapId)
+ if (!info) throw new Error(`unknown surf map: ${mapId}`)
+ const movement: MovementStyle = 'surf'
+ return {
+ map: {
+ id: info.id,
+ name: info.name,
+ flatTerrain: true,
+ groundHalfExtent: 120,
+ spawnPoint: [0, 2, 0],
+ spawnYaw: 0,
+ statics: [],
+ resourceNodes: [],
+ initialProps: [],
+ zones: [],
+ movement,
+ },
+ }
+}
+
+export const SURF_PACKS: Record<SurfMapId, ContentPackV2> = {
+ surf_lagoon: surfPack('surf_lagoon'),
+ surf_canyon: surfPack('surf_canyon'),
+ surf_orbit: surfPack('surf_orbit'),
+}
@@ -11,6 +11,16 @@ import { WorldShapeSchema } from './item.js'
@@ -11,6 +11,16 @@ import { WorldShapeSchema } from './item.js'
const vec3 = z.tuple([z.number(), z.number(), z.number()])
const vec3 = z.tuple([z.number(), z.number(), z.number()])
const finiteVec3 = vec3.refine((v) => v.every(Number.isFinite), 'must be finite')
const finiteVec3 = vec3.refine((v) => v.every(Number.isFinite), 'must be finite')
+/**
+ * How a place moves. The style name is content: both sides resolve it to the
+ * SAME MovementParams (gameplay/movement/styles.ts) and pass those to the one
+ * movement kernel, so a mismatch is a content-digest mismatch, not a feel
+ * difference. `default` is the sandbox controller; `surf` is the CS:S-like
+ * air-control preset used by the Ride maps.
+ */
+export const MovementStyleSchema = z.enum(['default', 'surf'])
+export type MovementStyle = z.infer<typeof MovementStyleSchema>
+
/**
/**
* Canonical authored colour: lower-case six-digit hex. One definition shared
* Canonical authored colour: lower-case six-digit hex. One definition shared
* by every schema that carries a colour, so a value cannot be accepted by one
* by every schema that carries a colour, so a value cannot be accepted by one
@@ -124,6 +134,29 @@ export const PropSpawnSchema = z.object({
@@ -124,6 +134,29 @@ export const PropSpawnSchema = z.object({
yaw: z.number().default(0),
yaw: z.number().default(0),
})
})
+/** What a surf zone means: the run's start, an ordered checkpoint, the finish, or a kill volume. */
+export const SurfZoneMetaSchema = z.object({
+ kind: z.enum(['start', 'checkpoint', 'finish', 'kill']),
+ /** 0 for the start, 1.. for checkpoints, the final stage for the finish. */
+ stage: z.number().int().min(0).max(63).default(0),
+ /** Checkpoint respawn (capsule centre) and yaw; the start uses the map spawn. */
+ spawn: finiteVec3.optional(),
+ yaw: z.number().optional(),
+})
+export type SurfZoneMeta = z.infer<typeof SurfZoneMetaSchema>
+
+/** Map-level surf metadata: the fail plane and the advertised run shape. */
+export const SurfMapMetaSchema = z.object({
+ title: z.string().min(1).max(80),
+ author: z.string().min(1).max(80),
+ tier: z.number().int().min(1).max(5),
+ /** Total stages, including the finish (checkpoints are stages - 1). */
+ stages: z.number().int().min(1).max(64),
+ /** Below this height (metres) the run fails to the last checkpoint. */
+ failY: z.number(),
+})
+export type SurfMapMeta = z.infer<typeof SurfMapMetaSchema>
+
export const ZoneDefSchema = z.object({
export const ZoneDefSchema = z.object({
/**
/**
* Stable zone id. Dashes are permitted so the map editor can mint ids in the
* Stable zone id. Dashes are permitted so the map editor can mint ids in the
@@ -140,6 +173,8 @@ export const ZoneDefSchema = z.object({
@@ -140,6 +173,8 @@ export const ZoneDefSchema = z.object({
build: z.boolean().default(true),
build: z.boolean().default(true),
physgun: z.boolean().default(true),
physgun: z.boolean().default(true),
}),
}),
+ /** Surf trigger metadata; absent on ordinary rule zones. */
+ surf: SurfZoneMetaSchema.optional(),
})
})
export const WorldDefSchema = z.object({
export const WorldDefSchema = z.object({
@@ -151,6 +186,8 @@ export const WorldDefSchema = z.object({
@@ -151,6 +186,8 @@ export const WorldDefSchema = z.object({
spawnYaw: z.number().default(0),
spawnYaw: z.number().default(0),
/** Blank-slate world: terrain is a flat floor (map editor builds the rest). */
/** Blank-slate world: terrain is a flat floor (map editor builds the rest). */
flatTerrain: z.boolean().default(false),
flatTerrain: z.boolean().default(false),
+ /** Movement preset for this place; resolved to params on both sides (default when absent). */
+ movement: MovementStyleSchema.optional(),
statics: z.array(StaticBodySchema),
statics: z.array(StaticBodySchema),
resourceNodes: z.array(ResourceNodeSpawnSchema),
resourceNodes: z.array(ResourceNodeSpawnSchema),
initialProps: z.array(PropSpawnSchema),
initialProps: z.array(PropSpawnSchema),
@@ -192,6 +192,10 @@ export interface MapOverride {
@@ -192,6 +192,10 @@ export interface MapOverride {
/** Editor-placed spawn point (overrides the world def). */
/** Editor-placed spawn point (overrides the world def). */
spawn?: [number, number, number]
spawn?: [number, number, number]
spawnYaw?: number
spawnYaw?: number
+ /** Movement preset authored by the map (overrides the world def). */
+ movement?: 'default' | 'surf'
+ /** Surf run metadata authored by the map (fail plane, stages, tier, author). */
+ surf?: { title: string; author: string; tier: number; stages: number; failY: number }
}
}
let mapOverride: MapOverride | null = null
let mapOverride: MapOverride | null = null
@@ -6,6 +6,9 @@ export * from './movement/buttons.js'
@@ -6,6 +6,9 @@ export * from './movement/buttons.js'
export * from './movement/params.js'
export * from './movement/params.js'
export * from './movement/collision.js'
export * from './movement/collision.js'
export * from './movement/simulate.js'
export * from './movement/simulate.js'
+export * from './movement/styles.js'
+export * from './surf/run.js'
+export * from './surf/ghost.js'
export * from './skills/skills.js'
export * from './skills/skills.js'
export * from './zones/zones.js'
export * from './zones/zones.js'
export * from './entity/entity.js'
export * from './entity/entity.js'
@@ -29,6 +29,8 @@ export interface MovementParams {
@@ -29,6 +29,8 @@ export interface MovementParams {
readonly eyeOffset: number
readonly eyeOffset: number
/** Collision skin to keep the capsule from touching surfaces exactly. */
/** Collision skin to keep the capsule from touching surfaces exactly. */
readonly skin: number
readonly skin: number
+ /** Horizontal speed ceiling (m/s); Infinity means the engine's old unbounded rule. */
+ readonly maxHorizontalSpeed: number
/** Hold-jump auto-hops when true (fun default for a sandbox). */
/** Hold-jump auto-hops when true (fun default for a sandbox). */
readonly autoBhop: boolean
readonly autoBhop: boolean
}
}
@@ -49,5 +51,6 @@ export const DEFAULT_MOVEMENT: MovementParams = {
@@ -49,5 +51,6 @@ export const DEFAULT_MOVEMENT: MovementParams = {
capsuleRadius: 0.4,
capsuleRadius: 0.4,
eyeOffset: 0.65,
eyeOffset: 0.65,
skin: 0.02,
skin: 0.02,
+ maxHorizontalSpeed: Number.POSITIVE_INFINITY,
autoBhop: true,
autoBhop: true,
}
}
@@ -507,4 +507,14 @@ export function stepMovement(
@@ -507,4 +507,14 @@ export function stepMovement(
// Terminal velocity guard.
// Terminal velocity guard.
state.vel.y = clamp(state.vel.y, -50, 50)
state.vel.y = clamp(state.vel.y, -50, 50)
+ // Horizontal ceiling (Source's sv_maxvelocity). The sandbox leaves it off;
+ // the surf style uses it so a long ramp cannot exceed CS's 3500 u/s.
+ if (Number.isFinite(params.maxHorizontalSpeed)) {
+ const speed = Math.hypot(state.vel.x, state.vel.z)
+ if (speed > params.maxHorizontalSpeed) {
+ const scale = params.maxHorizontalSpeed / speed
+ state.vel.x *= scale
+ state.vel.z *= scale
+ }
+ }
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+import { getMapOverride, type MovementStyle, type WorldDef } from '@openvibe/content'
+import { DEFAULT_MOVEMENT, type MovementParams } from './params.js'
+
+/**
+ * CS:S surf tuning, converted from Source units into this engine's metres.
+ *
+ * Unit scale: Source is authored in hammer units where **1 unit = 1 inch =
+ * 0.0254 m** (a 72-unit player is 1.8288 m, the same 1.8 m capsule this engine
+ * uses). Converting every velocity/length by 0.0254 keeps the map and the
+ * player the same physical size as CS.
+ *
+ * sv_gravity 800 u/s² × 0.0254 = 20.32 m/s²
+ * sv_maxvelocity 3500 u/s × 0.0254 = 88.9 m/s
+ * air wish cap 30 u/s × 0.0254 = 0.762 m/s
+ * walk speed 250 u/s × 0.0254 = 6.35 m/s
+ * jump 301.993 u/s × 0.0254 = 7.6706 m/s
+ * stop speed 80 u/s × 0.0254 = 2.032 m/s
+ *
+ * The cap is applied PER TICK, so the per-second gain is `cap × tickRate`.
+ * CS:S surf is played at 64 tick; this server keeps 30 Hz, so the cap is
+ * scaled by 64/30 to preserve the per-second acceleration:
+ *
+ * 0.762 × 64 / 30 = 1.6256 m/s at 30 Hz
+ * 1.6256 × 30 = 48.768 m/s² (same as 0.762 × 64)
+ *
+ * `airAccel` is `sv_airaccelerate 150` unchanged: it is already a per-second
+ * rate (the kernel multiplies it by dt), so it does not need a tick-rate
+ * scale. At 30 Hz its term is 150 × 6.35 × (1/30) = 31.75 m/s per tick,
+ * well above the 1.6256 cap — the cap is what limits surf, exactly as in CS.
+ * `sv_airaccelerate` only becomes visible when a very small wish speed makes
+ * the rate term smaller than the cap.
+ *
+ * `groundNormalY` stays at 0.7 (cos 45.57°), CS's standable plane. Ride ramps
+ * are authored at 50–60°, so cos(50°)=0.643 < 0.7 and the controller never
+ * treats them as ground; the 45° edge stays exactly where CS puts it.
+ */
+export const SURF_MOVEMENT: MovementParams = {
+ maxGroundSpeed: 6.35,
+ // CS has no sprint; equalling the two makes Shift harmless.
+ maxSprintSpeed: 6.35,
+ groundAccel: 10,
+ airAccel: 150,
+ airSpeedCap: 1.6256,
+ friction: 4,
+ stopSpeed: 2.032,
+ gravity: 20.32,
+ jumpSpeed: 7.6706,
+ groundNormalY: 0.7,
+ stepHeight: 0.45,
+ capsuleHeight: 1.8,
+ capsuleRadius: 0.4,
+ eyeOffset: 0.65,
+ skin: 0.02,
+ maxHorizontalSpeed: 88.9,
+ autoBhop: false,
+}
+
+/** The one table both server and client resolve a content style through. */
+export const MOVEMENT_STYLES: Readonly<Record<MovementStyle, MovementParams>> = {
+ default: DEFAULT_MOVEMENT,
+ surf: SURF_MOVEMENT,
+}
+
+/** Resolve a content style id; unknown ids are a content bug, so fail loudly. */
+export function movementParamsFor(style: MovementStyle): MovementParams {
+ const params = MOVEMENT_STYLES[style]
+ if (!params) throw new Error(`unknown movement style: ${String(style)}`)
+ return params
+}
+
+
+/**
+ * The movement style in force for a world: the loaded map's choice overrides
+ * the content world's. Both sides call this with their own (identical) map
+ * override, so server simulation and client prediction resolve the same
+ * params; the world's field is part of the content digest, so a client with a
+ * different default is refused at the handshake.
+ */
+export function movementStyleFor(world: WorldDef): MovementStyle {
+ return getMapOverride(world)?.movement ?? world.movement ?? 'default'
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+import { describe, expect, it } from 'vitest'
+import { vec3 } from '@openvibe/shared'
+import type { CollisionQueries, SweepHit } from './collision.js'
+import { DEFAULT_MOVEMENT } from './params.js'
+import { createMoveState, stepMovement, type PlayerMoveState } from './simulate.js'
+import { SURF_MOVEMENT } from './styles.js'
+
+/**
+ * The feel proof for Ride. A 55° ramp is steeper than cos⁻¹(0.7) = 45.57°, so
+ * NEITHER style ever grounds on it; the difference is the air controller.
+ * Surf's CS:S cap (scaled to 30 Hz) lets a strafe + turn keep or gain
+ * horizontal speed; the sandbox default's 0.85 m/s cap gives the ramp the
+ * speed instead. A 40° slope is ground in both styles.
+ *
+ * The collision world is an analytic half-space rather than Rapier: the test
+ * is about the movement rule, and the half-space has no box edges, so the
+ * comparison is not polluted by a capsule catching a lip.
+ */
+const RAD = Math.PI / 180
+
+function planeQueries(angleDeg: number): CollisionQueries {
+ const a = angleDeg * RAD
+ const n = { x: 0, y: Math.cos(a), z: Math.sin(a) }
+ return {
+ sweepCapsule(from, to, radius): SweepHit | null {
+ const da = from.x * n.x + from.y * n.y + from.z * n.z - radius
+ const db = to.x * n.x + to.y * n.y + to.z * n.z - radius
+ if (da <= 0)
+ return { fraction: 0, normal: { ...n }, point: { ...from } }
+ if (db >= 0) return null
+ const f = da / (da - db)
+ return {
+ fraction: f,
+ normal: { ...n },
+ point: {
+ x: from.x + (to.x - from.x) * f,
+ y: from.y + (to.y - from.y) * f,
+ z: from.z + (to.z - from.z) * f,
+ },
+ }
+ },
+ }
+}
+
+function spawnOnPlane(angleDeg: number, above = 1.2): PlayerMoveState {
+ const a = angleDeg * RAD
+ const n = { x: 0, y: Math.cos(a), z: Math.sin(a) }
+ return createMoveState(vec3(n.x * above, n.y * above, n.z * above))
+}
+
+/** 3 s of a strafe whose yaw turns 45°, the surf line. */
+function runLine(params: typeof DEFAULT_MOVEMENT, angle: number): number[] {
+ const state = spawnOnPlane(angle)
+ state.vel.x = 0
+ state.vel.y = 0
+ state.vel.z = 10
+ const speeds: number[] = []
+ for (let tick = 0; tick < 90; tick++) {
+ const yaw = (tick / 30) * 45 * RAD
+ stepMovement(
+ state,
+ { moveX: 1, moveZ: 0, yaw, pitch: 0, buttons: 0 },
+ params,
+ planeQueries(angle),
+ 1 / 30,
+ )
+ if (tick % 30 === 29) speeds.push(Math.hypot(state.vel.x, state.vel.z))
+ }
+ return speeds
+}
+
+describe('surf feel on a steep ramp', () => {
+ it('keeps or gains horizontal speed over 3 s in the surf style', () => {
+ const speeds = runLine(SURF_MOVEMENT, 55)
+ expect(speeds[0]).toBeGreaterThan(9)
+ expect(speeds[2]).toBeGreaterThanOrEqual(10 * 0.99)
+ })
+
+ it('loses horizontal speed in the default style on the same ramp and line', () => {
+ const speeds = runLine(DEFAULT_MOVEMENT, 55)
+ expect(speeds[2]).toBeLessThan(9)
+ expect(speeds[2]).toBeLessThan(runLine(SURF_MOVEMENT, 55)[2]! * 0.75)
+ })
+
+ it('a 40° slope is ground in both styles', () => {
+ for (const params of [SURF_MOVEMENT, DEFAULT_MOVEMENT]) {
+ const state = spawnOnPlane(40, 0.1)
+ stepMovement(
+ state,
+ { moveX: 0, moveZ: 0, yaw: 0, pitch: 0, buttons: 0 },
+ params,
+ planeQueries(40),
+ 1 / 30,
+ )
+ expect(state.grounded).toBe(true)
+ }
+ })
+
+ it('CS:S constants are the converted Source values, scaled at 30 Hz', () => {
+ expect(SURF_MOVEMENT.groundNormalY).toBe(0.7)
+ expect(SURF_MOVEMENT.gravity).toBeCloseTo(800 * 0.0254)
+ expect(SURF_MOVEMENT.maxHorizontalSpeed).toBeCloseTo(3500 * 0.0254)
+ expect(SURF_MOVEMENT.airSpeedCap).toBeCloseTo(30 * 0.0254 * (64 / 30))
+ expect(SURF_MOVEMENT.airAccel).toBe(150)
+ expect(SURF_MOVEMENT.autoBhop).toBe(false)
+ expect(SURF_MOVEMENT.maxSprintSpeed).toBe(SURF_MOVEMENT.maxGroundSpeed)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+import type { SurfTrackSample } from './run.js'
+
+/**
+ * The ghost transform track (inventory §5.3 option 1): authoritative positions
+ * quantised to 1 cm and yaw to 1/256 turn, little-endian. The server gzips the
+ * bytes for storage/HTTP; this codec stays pure so the client can decode it
+ * without a decompressor of its own (the browser's content-encoding does that).
+ *
+ * header magic "SURF", u8 version, u8 pad, u16 tickRate, u32 startTick, u32 count
+ * sample i32 x_cm, i32 y_cm, i32 z_cm, u8 yawQ
+ */
+const MAGIC = 0x46525553 // "SURF" little-endian
+const VERSION = 1
+export const GHOST_HEADER_BYTES = 16
+export const GHOST_SAMPLE_BYTES = 13
+
+export interface DecodedGhost {
+ tickRate: number
+ startTick: number
+ samples: SurfTrackSample[]
+}
+
+export function quantiseCentimetres(v: number): number {
+ return Math.max(-2_147_483_648, Math.min(2_147_483_647, Math.round(v * 100)))
+}
+
+/** One byte per 1/256 turn. */
+export function quantiseYaw8(yaw: number): number {
+ const turn = yaw / (Math.PI * 2)
+ const q = Math.round(turn * 256) % 256
+ return q < 0 ? q + 256 : q
+}
+
+export function dequantiseYaw8(q: number): number {
+ const yaw = (q / 256) * Math.PI * 2
+ return yaw > Math.PI ? yaw - Math.PI * 2 : yaw
+}
+
+export function encodeSurfTrack(
+ track: readonly SurfTrackSample[],
+ tickRate: number,
+ startTick: number,
+): Uint8Array {
+ const out = new Uint8Array(GHOST_HEADER_BYTES + GHOST_SAMPLE_BYTES * track.length)
+ const view = new DataView(out.buffer)
+ view.setUint32(0, MAGIC, true)
+ view.setUint8(4, VERSION)
+ view.setUint8(5, 0)
+ view.setUint16(6, tickRate, true)
+ view.setUint32(8, startTick >>> 0, true)
+ view.setUint32(12, track.length, true)
+ let o = GHOST_HEADER_BYTES
+ for (const s of track) {
+ view.setInt32(o, quantiseCentimetres(s.x), true)
+ view.setInt32(o + 4, quantiseCentimetres(s.y), true)
+ view.setInt32(o + 8, quantiseCentimetres(s.z), true)
+ view.setUint8(o + 12, quantiseYaw8(s.yaw))
+ o += GHOST_SAMPLE_BYTES
+ }
+ return out
+}
+
+export function decodeSurfTrack(bytes: Uint8Array): DecodedGhost {
+ if (bytes.byteLength < GHOST_HEADER_BYTES) throw new Error('ghost track: truncated header')
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
+ if (view.getUint32(0, true) !== MAGIC) throw new Error('ghost track: bad magic')
+ if (view.getUint8(4) !== VERSION) throw new Error('ghost track: unsupported version')
+ const tickRate = view.getUint16(6, true)
+ const startTick = view.getUint32(8, true)
+ const count = view.getUint32(12, true)
+ if (bytes.byteLength !== GHOST_HEADER_BYTES + count * GHOST_SAMPLE_BYTES)
+ throw new Error('ghost track: length does not match its header')
+ const samples: SurfTrackSample[] = []
+ let o = GHOST_HEADER_BYTES
+ for (let i = 0; i < count; i++) {
+ samples.push({
+ tick: startTick + i,
+ x: view.getInt32(o, true) / 100,
+ y: view.getInt32(o + 4, true) / 100,
+ z: view.getInt32(o + 8, true) / 100,
+ yaw: dequantiseYaw8(view.getUint8(o + 12)),
+ })
+ o += GHOST_SAMPLE_BYTES
+ }
+ return { tickRate, startTick, samples }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,230 @@
@@ -0,0 +1,230 @@
+import { describe, expect, it } from 'vitest'
+import { createMoveState } from '../movement/simulate.js'
+import { vec3 } from '@openvibe/shared'
+import {
+ SurfRunState,
+ insideBox,
+ placeMoveStateAt,
+ segmentAabbEntry,
+ segmentAabbExit,
+ surfRunMapOf,
+ type SurfRunMap,
+ type SurfRunZone,
+} from './run.js'
+import type { DecodedGhost } from './ghost.js'
+import { decodeSurfTrack, encodeSurfTrack } from './ghost.js'
+
+function zone(
+ id: string,
+ kind: SurfRunZone['kind'],
+ stage: number,
+ min: [number, number, number],
+ max: [number, number, number],
+ spawn?: [number, number, number],
+): SurfRunZone {
+ return {
+ id,
+ kind,
+ stage,
+ min,
+ max,
+ ...(spawn ? { spawn: { x: spawn[0], y: spawn[1], z: spawn[2] } } : {}),
+ ...(kind === 'checkpoint' ? { yaw: 0 } : {}),
+ }
+}
+
+function map(overrides: Partial<SurfRunMap> = {}): SurfRunMap {
+ return {
+ id: 'test',
+ failY: -10,
+ stages: 3,
+ start: zone('start', 'start', 0, [-2, -1, 0], [2, 2, 2]),
+ checkpoints: [
+ zone('cp1', 'checkpoint', 1, [-2, -1, 10], [2, 2, 12], [0, 0, 11]),
+ zone('cp2', 'checkpoint', 2, [-2, -1, 20], [2, 2, 22], [0, 0, 21]),
+ ],
+ finish: zone('finish', 'finish', 3, [-2, -1, 30], [2, 2, 32]),
+ kills: [],
+ spawn: { x: 0, y: 0, z: 1 },
+ spawnYaw: 0,
+ ...overrides,
+ }
+}
+
+const p = (z: number): { x: number; y: number; z: number } => ({ x: 0, y: 0, z })
+
+describe('slab crossing fractions', () => {
+ it('computes an entry fraction from the segment and the AABB', () => {
+ expect(segmentAabbEntry(p(8), p(12), { min: [-1, -1, 10], max: [1, 1, 12] })).toBeCloseTo(0.5)
+ expect(segmentAabbEntry(p(11), p(13), { min: [-1, -1, 10], max: [1, 1, 12] })).toBe(0)
+ expect(segmentAabbEntry(p(0), p(5), { min: [-1, -1, 10], max: [1, 1, 12] })).toBeNull()
+ expect(segmentAabbEntry(p(0), p(20), { min: [-1, -1, 10], max: [1, 1, 12] })).toBeCloseTo(0.5)
+ })
+
+ it('computes the exit fraction only when the segment starts inside', () => {
+ expect(segmentAabbExit(p(1), p(3), { min: [-2, -1, 0], max: [2, 2, 2] })).toBeCloseTo(0.5)
+ expect(segmentAabbExit(p(3), p(4), { min: [-2, -1, 0], max: [2, 2, 2] })).toBeNull()
+ expect(segmentAabbExit(p(1), p(1.5), { min: [-2, -1, 0], max: [2, 2, 2] })).toBeNull()
+ })
+})
+
+describe('surf timer', () => {
+ it('uses sub-tick fractions at both ends', () => {
+ const run = new SurfRunState(
+ map({ checkpoints: [], stages: 1, finish: zone('finish', 'finish', 1, [-2, -1, 30], [2, 2, 32]) }),
+ 30,
+ )
+ // Exit the start at 0.25 of tick 10: from z=1.5 to z=2.5, box ends at 2.
+ run.observe(10, p(1.5), p(2.5), 0)
+ expect(run.status).toBe('running')
+ expect(run.startTick).toBe(10)
+ expect(run.startFrac).toBeCloseTo(0.5)
+ expect(run.elapsedMs).toBe(17) // (11 - 10 - 0.5) * 33.333, rounded
+
+ // Finish at 0.75 of tick 20: z 28 → 32 crosses z=30 at 0.5, but use cp
+ // zones first (they are not on this line), then the finish.
+ run.observe(20, p(28), p(32), 0)
+ // fracEnd = 0.5, startFrac = 0.5 → (20-10+0.5-0.5)*33.333 = 333ms
+ expect(run.finishResult?.timeMs).toBe(333)
+ expect(run.finishResult?.timeMs).toBe(
+ Math.round((20 - 10 + 0.5 - 0.5) * (1000 / 30)),
+ )
+ })
+
+ it('crosses mid-tick at both ends and matches the formula exactly', () => {
+ const m = map({
+ start: zone('start', 'start', 0, [-2, -1, 0], [2, 2, 2]),
+ checkpoints: [],
+ finish: zone('finish', 'finish', 1, [-2, -1, 10], [2, 2, 12]),
+ stages: 1,
+ })
+ const run = new SurfRunState(m, 30)
+ run.observe(5, p(1.2), p(2.8), 0) // exit at (2-1.2)/1.6 = 0.5
+ expect(run.startFrac).toBeCloseTo(0.5)
+ run.observe(9, p(8), p(12), 0) // enter at (10-8)/4 = 0.5
+ const expected = Math.round((9 - 5 + 0.5 - 0.5) * (1000 / 30))
+ expect(run.finishResult?.timeMs).toBe(expected)
+ })
+})
+
+describe('surf zones', () => {
+ it('records ordered checkpoints and ignores a skipped one', () => {
+ const run = new SurfRunState(map(), 30)
+ run.observe(1, p(1), p(3), 0) // start
+ // Jump straight into checkpoint 2's volume: stage 1 is skipped, so nothing.
+ run.observe(2, p(18), p(21), 0)
+ expect(run.reached).toBe(0)
+ expect(run.splits).toEqual([])
+ run.observe(3, p(9), p(11), 0)
+ expect(run.reached).toBe(1)
+ expect(run.splits.map((s) => s.stage)).toEqual([1])
+ run.observe(4, p(19), p(21), 0)
+ expect(run.reached).toBe(2)
+ run.observe(5, p(29), p(31), 0)
+ expect(run.status).toBe('finished')
+ expect(run.finishResult?.splits).toHaveLength(3)
+ })
+
+ it('fails below failY to the last checkpoint with velocity zeroed, and the clock still runs', () => {
+ const run = new SurfRunState(map(), 30)
+ run.observe(10, p(1), p(3), 0)
+ run.observe(11, p(9), p(11), 0) // cp1
+ const before = run.elapsedMs
+ const events = run.observe(12, { x: 0, y: -11, z: 12 }, { x: 0, y: -12, z: 13 }, 0)
+ const failed = events.find((e) => e.type === 'failed')
+ expect(failed).toMatchObject({ type: 'failed', to: { x: 0, y: 0, z: 11 } })
+ expect(run.fails).toBe(1)
+ expect(run.status).toBe('running')
+ expect(run.elapsedMs).toBeGreaterThan(before)
+ const move = createMoveState(vec3(0, 0, 12))
+ move.vel.x = 9
+ move.vel.y = -4
+ placeMoveStateAt(move, (failed as Extract<typeof failed, { type: 'failed' }>).to)
+ expect(move.vel).toEqual(vec3(0, 0, 0))
+ expect(move.pos).toEqual(vec3(0, 0, 11))
+ })
+
+ it('fails inside a kill volume, even above failY', () => {
+ const kill = zone('kill', 'kill', 0, [-2, -1, 14], [2, 3, 18])
+ const run = new SurfRunState(map({ kills: [kill] }), 30)
+ run.observe(10, p(1), p(3), 0)
+ const events = run.observe(11, p(13), p(15), 0)
+ expect(events.some((e) => e.type === 'failed')).toBe(true)
+ expect(run.fails).toBe(1)
+ expect(run.status).toBe('running')
+ })
+
+ it('re-entering the start after a finish arms a fresh run', () => {
+ const run = new SurfRunState(map({ checkpoints: [], stages: 1 }), 30)
+ run.observe(1, p(1), p(3), 0)
+ run.observe(2, p(29), p(31), 0)
+ expect(run.status).toBe('finished')
+ const events = run.observe(3, p(3), p(1), 0)
+ expect(events.some((e) => e.type === 'restarted')).toBe(true)
+ expect(run.status).toBe('idle')
+ })
+
+ it('a fail before the first checkpoint respawns at the start without resetting the clock', () => {
+ const run = new SurfRunState(map(), 30)
+ run.observe(10, p(1), p(3), 0)
+ const startTick = run.startTick
+ const failed = run.observe(12, { x: 0, y: -11, z: 3 }, { x: 0, y: -12, z: 3 }, 0)
+ expect(failed.some((e) => e.type === 'failed')).toBe(true)
+ // Next tick starts at the start spawn (inside the start zone) and runs
+ // out again; that is not a fresh entry.
+ run.observe(13, p(1), p(3), 0)
+ expect(run.status).toBe('running')
+ expect(run.startTick).toBe(startTick)
+ })
+
+ it('resets to idle and can start again', () => {
+ const run = new SurfRunState(map(), 30)
+ run.observe(10, p(1), p(3), 0)
+ run.observe(11, p(9), p(11), 0)
+ run.restart(20)
+ expect(run.status).toBe('idle')
+ expect(run.reached).toBe(0)
+ expect(run.splits).toEqual([])
+ expect(run.restart(20)).toMatchObject({ type: 'restarted', to: { z: 1 } })
+ run.observe(21, p(1), p(3), 0)
+ expect(run.status).toBe('running')
+ expect(run.startTick).toBe(21)
+ })
+
+ it('builds the run from tagged zones and requires one ordered set', () => {
+ const zones = [
+ { id: 's', name: 'S', min: [0, 0, 0] as [number, number, number], max: [2, 2, 2] as [number, number, number], rules: { pvp: false, build: false, physgun: false }, surf: { kind: 'start' as const, stage: 0 } },
+ { id: 'f', name: 'F', min: [0, 0, 10] as [number, number, number], max: [2, 2, 12] as [number, number, number], rules: { pvp: false, build: false, physgun: false }, surf: { kind: 'finish' as const, stage: 1 } },
+ ]
+ const runMap = surfRunMapOf({ title: 'T', author: 'A', tier: 1, stages: 1, failY: -5 }, zones)
+ expect(runMap.finish.id).toBe('f')
+ expect(insideBox(runMap.start, { x: 1, y: 1, z: 1 })).toBe(true)
+ expect(insideBox(runMap.start, { x: 3, y: 1, z: 1 })).toBe(false)
+ })
+})
+
+describe('ghost track', () => {
+ it('round-trips positions at 1 cm and yaw at 1/256 turn', () => {
+ const samples = [
+ { tick: 100, x: 1.23, y: 4.56, z: -7.89, yaw: Math.PI / 4 },
+ { tick: 101, x: -10.01, y: 0.02, z: 300.4, yaw: -Math.PI / 2 },
+ ]
+ const bytes = encodeSurfTrack(samples, 30, 100)
+ const decoded: DecodedGhost = decodeSurfTrack(bytes)
+ expect(decoded.tickRate).toBe(30)
+ expect(decoded.startTick).toBe(100)
+ expect(decoded.samples[0]!.x).toBeCloseTo(1.23)
+ expect(decoded.samples[0]!.y).toBeCloseTo(4.56)
+ expect(decoded.samples[0]!.z).toBeCloseTo(-7.89)
+ expect(decoded.samples[0]!.yaw).toBeCloseTo(Math.PI / 4, 2)
+ expect(decoded.samples[1]!.x).toBeCloseTo(-10.01)
+ })
+
+ it('refuses a corrupt or truncated track', () => {
+ const bytes = encodeSurfTrack([{ tick: 0, x: 0, y: 0, z: 0, yaw: 0 }], 30, 0)
+ expect(() => decodeSurfTrack(bytes.subarray(0, 20))).toThrow()
+ const bad = bytes.slice()
+ bad[0] = 0
+ expect(() => decodeSurfTrack(bad)).toThrow()
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,376 @@
@@ -0,0 +1,376 @@
+import type { SurfMapMeta, ZoneDef } from '@openvibe/content'
+import type { PlayerMoveState } from '../movement/simulate.js'
+
+/**
+ * The authoritative surf run state machine.
+ *
+ * It is deliberately pure and engine-free: the server feeds it the segment
+ * between the previous and current AUTHORITATIVE capsule positions once per
+ * simulated input, and it returns what happened. No client time, no client
+ * position and no wall clock participate in a run.
+ *
+ * Timing: the server ticks at 30 Hz, so a tick count alone is 33.3 ms coarse.
+ * Every crossing is a slab intersection against the zone AABB, giving the
+ * fraction of the segment at which it happened:
+ *
+ * timeMs = (endTick - startTick + fracEnd - fracStart) * (1000 / tickRate)
+ *
+ * `startTick` is the tick in which the capsule EXITED the start zone
+ * (`fracStart` is that exit fraction, measured from the tick start), so the
+ * interval from the crossing to the end of that tick is `1 - fracStart`.
+ */
+
+export type SurfVec3 = { x: number; y: number; z: number }
+export type SurfZoneKind = 'start' | 'checkpoint' | 'finish' | 'kill'
+
+export interface SurfBox {
+ min: readonly [number, number, number]
+ max: readonly [number, number, number]
+}
+
+export interface SurfRunZone extends SurfBox {
+ id: string
+ kind: SurfZoneKind
+ stage: number
+ spawn?: SurfVec3
+ yaw?: number
+}
+
+export interface SurfRunMap {
+ id: string
+ failY: number
+ stages: number
+ start: SurfRunZone
+ checkpoints: readonly SurfRunZone[]
+ finish: SurfRunZone
+ kills: readonly SurfRunZone[]
+ spawn: SurfVec3
+ spawnYaw: number
+}
+
+export interface SurfTrackSample {
+ tick: number
+ x: number
+ y: number
+ z: number
+ yaw: number
+}
+
+export interface SurfSplit {
+ stage: number
+ timeMs: number
+}
+
+export interface SurfRunResult {
+ mapId: string
+ timeMs: number
+ splits: number[]
+ track: SurfTrackSample[]
+ fails: number
+ startedTick: number
+ finishedTick: number
+ startFrac: number
+ finishFrac: number
+}
+
+export type SurfRunEvent =
+ | { type: 'started'; tick: number; frac: number }
+ | { type: 'checkpoint'; stage: number; timeMs: number }
+ | { type: 'finished'; result: SurfRunResult }
+ | { type: 'failed'; to: SurfVec3; yaw: number; fails: number }
+ | { type: 'restarted'; to: SurfVec3; yaw: number }
+
+export type SurfRunStatus = 'idle' | 'running' | 'finished'
+
+const EPS = 1e-9
+
+export function insideBox(box: SurfBox, p: SurfVec3): boolean {
+ return (
+ p.x >= box.min[0] &&
+ p.x <= box.max[0] &&
+ p.y >= box.min[1] &&
+ p.y <= box.max[1] &&
+ p.z >= box.min[2] &&
+ p.z <= box.max[2]
+ )
+}
+
+/**
+ * Slab intersection. Returns the first fraction in [0,1] at which the segment
+ * enters the box, or null when it never does. A point already inside enters at
+ * 0; a zero-length segment inside returns 0 and outside returns null.
+ */
+export function segmentAabbEntry(a: SurfVec3, b: SurfVec3, box: SurfBox): number | null {
+ if (insideBox(box, a)) return 0
+ let tEnter = 0
+ let tExit = 1
+ const axes: ('x' | 'y' | 'z')[] = ['x', 'y', 'z']
+ for (let i = 0; i < 3; i++) {
+ const axis = axes[i]!
+ const lo = box.min[i]!
+ const hi = box.max[i]!
+ const p = a[axis]
+ const d = b[axis] - p
+ if (Math.abs(d) < EPS) {
+ if (p < lo || p > hi) return null
+ continue
+ }
+ let t1 = (lo - p) / d
+ let t2 = (hi - p) / d
+ if (t1 > t2) [t1, t2] = [t2, t1]
+ if (t1 > tEnter) tEnter = t1
+ if (t2 < tExit) tExit = t2
+ if (tEnter > tExit) return null
+ }
+ return tEnter >= 0 && tEnter <= 1 ? tEnter : null
+}
+
+/**
+ * The fraction at which a segment EXITS the box while starting inside, or null
+ * when it does not leave this tick. This is the start-line fraction: the run
+ * begins as the capsule crosses out of the start volume.
+ */
+export function segmentAabbExit(a: SurfVec3, b: SurfVec3, box: SurfBox): number | null {
+ if (!insideBox(box, a)) return null
+ if (insideBox(box, b)) return null
+ let tExit = 1
+ const axes: ('x' | 'y' | 'z')[] = ['x', 'y', 'z']
+ for (let i = 0; i < 3; i++) {
+ const axis = axes[i]!
+ const lo = box.min[i]!
+ const hi = box.max[i]!
+ const p = a[axis]
+ const d = b[axis] - p
+ if (Math.abs(d) < EPS) continue
+ const t1 = (lo - p) / d
+ const t2 = (hi - p) / d
+ const t = d > 0 ? t2 : t1
+ if (t < tExit) tExit = t
+ }
+ return tExit >= 0 && tExit <= 1 ? tExit : null
+}
+
+/** Build the run description from a compiled map's surf metadata and zones. */
+export function surfRunMapOf(
+ meta: SurfMapMeta | undefined,
+ zones: readonly ZoneDef[],
+ spawn: readonly [number, number, number] = [0, 0, 0],
+ spawnYaw = 0,
+ id = meta?.title ?? 'surf',
+): SurfRunMap {
+ const tagged = zones.filter((z) => z.surf)
+ const start = tagged.find((z) => z.surf!.kind === 'start')
+ const finish = tagged.find((z) => z.surf!.kind === 'finish')
+ if (!start || !finish)
+ throw new Error('surf map needs a start zone and a finish zone')
+ const toZone = (z: ZoneDef): SurfRunZone => ({
+ id: z.id,
+ kind: z.surf!.kind,
+ stage: z.surf!.stage,
+ min: [z.min[0], z.min[1], z.min[2]],
+ max: [z.max[0], z.max[1], z.max[2]],
+ ...(z.surf!.spawn
+ ? { spawn: { x: z.surf!.spawn[0], y: z.surf!.spawn[1], z: z.surf!.spawn[2] } }
+ : {}),
+ ...(z.surf!.yaw !== undefined ? { yaw: z.surf!.yaw } : {}),
+ })
+ const checkpoints = tagged
+ .filter((z) => z.surf!.kind === 'checkpoint')
+ .sort((a, b) => a.surf!.stage - b.surf!.stage)
+ .map(toZone)
+ return {
+ id,
+ failY: meta?.failY ?? -25,
+ stages: meta?.stages ?? checkpoints.length + 1,
+ start: toZone(start),
+ checkpoints,
+ finish: toZone(finish),
+ kills: tagged.filter((z) => z.surf!.kind === 'kill').map(toZone),
+ spawn: { x: spawn[0], y: spawn[1], z: spawn[2] },
+ spawnYaw,
+ }
+}
+
+/** One session's run. Create per player per surf place. */
+export class SurfRunState {
+ status: SurfRunStatus = 'idle'
+ /** Highest checkpoint stage reached this run (0 = none). */
+ reached = 0
+ fails = 0
+ startTick = 0
+ startFrac = 0
+ lastTick = 0
+ splits: SurfSplit[] = []
+ private track: SurfTrackSample[] = []
+ private result: SurfRunResult | null = null
+ private lastPos: SurfVec3 | null = null
+
+ constructor(
+ readonly map: SurfRunMap,
+ readonly tickRate: number,
+ ) {}
+
+ get elapsedMs(): number {
+ if (this.status !== 'running') return this.result?.timeMs ?? 0
+ return Math.max(
+ 0,
+ Math.round((this.lastTick + 1 - this.startTick - this.startFrac) * (1000 / this.tickRate)),
+ )
+ }
+
+ get finishResult(): SurfRunResult | null {
+ return this.result
+ }
+
+ get splitTimes(): number[] {
+ return this.splits.map((s) => s.timeMs)
+ }
+
+ /** The PB-format tracks use integer milliseconds; so does the wire. */
+ private timeAt(tick: number, frac: number): number {
+ return Math.max(
+ 0,
+ Math.round((tick + frac - this.startTick - this.startFrac) * (1000 / this.tickRate)),
+ )
+ }
+
+ /** Put the run back at the start line; the caller teleports the capsule. */
+ restart(tick: number): SurfRunEvent {
+ this.status = 'idle'
+ this.reached = 0
+ this.fails = 0
+ this.startTick = tick
+ this.startFrac = 0
+ this.lastTick = tick
+ this.splits = []
+ this.track = []
+ this.result = null
+ this.lastPos = null
+ return { type: 'restarted', to: this.map.spawn, yaw: this.map.spawnYaw }
+ }
+
+ /** The server moved the capsule outside movement (fail/reset/join): do not treat it as a crossing. */
+ teleport(pos: SurfVec3): void {
+ this.lastPos = { ...pos }
+ }
+
+ /**
+ * Feed one authoritative movement segment. Returns events in order; the
+ * caller applies `failed`/`restarted` teleports after this call.
+ */
+ observe(tick: number, from: SurfVec3, to: SurfVec3, yaw: number): SurfRunEvent[] {
+ const events: SurfRunEvent[] = []
+ this.lastTick = tick
+ if (this.status === 'finished') {
+ // Re-entering the start after a finish arms a fresh run, like CS surf's
+ // start zone. A respawn already inside it is not an entry.
+ if (!insideBox(this.map.start, from) && insideBox(this.map.start, to)) {
+ this.lastPos = { ...to }
+ return [this.restart(tick)]
+ }
+ this.lastPos = { ...to }
+ return events
+ }
+
+ if (this.status === 'idle') {
+ // Leaving the start volume starts the clock. Re-entering it while idle
+ // simply keeps the player at the line.
+ const exitFrac = segmentAabbExit(from, to, this.map.start)
+ if (exitFrac !== null && !insideBox(this.map.start, to)) {
+ this.status = 'running'
+ this.startTick = tick
+ this.startFrac = exitFrac
+ this.reached = 0
+ this.fails = 0
+ this.splits = []
+ this.track = [{ tick: tick + exitFrac, x: to.x, y: to.y, z: to.z, yaw }]
+ events.push({ type: 'started', tick, frac: exitFrac })
+ }
+ this.lastPos = { ...to }
+ return events
+ }
+
+ // Running. Only an actual entry restarts: a fail can respawn the player
+ // at the start while the clock keeps running (brief decision 4).
+ const enteredStart = !insideBox(this.map.start, from) && insideBox(this.map.start, to)
+ if (enteredStart) {
+ events.push(this.restart(tick) as SurfRunEvent)
+ this.lastPos = { ...to }
+ return events
+ }
+
+ this.track.push({ tick: tick + 1, x: to.x, y: to.y, z: to.z, yaw })
+
+ // Only the NEXT checkpoint counts: skipping one records nothing.
+ while (this.reached < this.map.checkpoints.length) {
+ const next = this.map.checkpoints[this.reached]!
+ const frac = segmentAabbEntry(from, to, next)
+ if (frac === null) break
+ this.reached++
+ const timeMs = this.timeAt(tick, frac)
+ this.splits.push({ stage: next.stage, timeMs })
+ events.push({ type: 'checkpoint', stage: next.stage, timeMs })
+ }
+
+ // Finish only after every checkpoint is in order.
+ if (this.reached === this.map.checkpoints.length) {
+ const fracEnd = segmentAabbEntry(from, to, this.map.finish)
+ if (fracEnd !== null) {
+ const timeMs = this.timeAt(tick, fracEnd)
+ this.status = 'finished'
+ this.result = {
+ mapId: this.map.id,
+ timeMs,
+ splits: [...this.splits.map((s) => s.timeMs), timeMs],
+ track: this.track,
+ fails: this.fails,
+ startedTick: this.startTick,
+ finishedTick: tick,
+ startFrac: this.startFrac,
+ finishFrac: fracEnd,
+ }
+ events.push({ type: 'finished', result: this.result })
+ this.lastPos = { ...to }
+ return events
+ }
+ }
+
+ // A fall or a kill volume puts the player back at the last checkpoint;
+ // the clock keeps running, as in CS surf's stage behaviour.
+ const kill = this.map.kills.some((k) => insideBox(k, to))
+ if (kill || to.y < this.map.failY) {
+ this.fails++
+ const last = this.map.checkpoints[this.reached - 1]
+ const respawn = last?.spawn ?? this.map.spawn
+ const respawnYaw = last?.yaw ?? this.map.spawnYaw
+ events.push({ type: 'failed', to: { ...respawn }, yaw: respawnYaw, fails: this.fails })
+ this.teleport(respawn)
+ } else {
+ this.lastPos = { ...to }
+ }
+ return events
+ }
+}
+
+
+/**
+ * Move a capsule to a respawn point with zero velocity. Shared by the server's
+ * checkpoint/fall teleport and the tests that prove a fail does not stop the
+ * clock.
+ */
+export function placeMoveStateAt(
+ state: PlayerMoveState,
+ to: SurfVec3,
+ yaw?: number,
+): void {
+ state.pos.x = to.x
+ state.pos.y = to.y
+ state.pos.z = to.z
+ state.vel.x = 0
+ state.vel.y = 0
+ state.vel.z = 0
+ state.grounded = false
+ if (yaw !== undefined) {
+ // The move state has no yaw; callers that own a session set its view.
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+-- phase: expand
+-- OpenVibe.Surf Ride: server-derived time-trial runs. A run is authoritative
+-- (the server simulated it); the client never supplies a time or position.
+-- The track is a gzipped transform track for the ghost: positions at 1 cm
+-- and yaw at 1/256 turn. `is_pb` marks the one personal best kept per
+-- (map, account); the last five other runs are kept beside it, and the world
+-- record is derived as MIN(time_ms) per map.
+CREATE TABLE IF NOT EXISTS surf_runs (
+ id text COLLATE "C" PRIMARY KEY,
+ map_id text COLLATE "C" NOT NULL,
+ account_key text COLLATE "C" NOT NULL,
+ player_name text NOT NULL,
+ time_ms integer NOT NULL CHECK (time_ms >= 0),
+ splits jsonb NOT NULL,
+ track bytea NOT NULL,
+ is_pb boolean NOT NULL DEFAULT false,
+ created_at bigint NOT NULL
+);
+CREATE INDEX IF NOT EXISTS surf_runs_board ON surf_runs (map_id, time_ms ASC, created_at ASC);
+CREATE INDEX IF NOT EXISTS surf_runs_player ON surf_runs (map_id, account_key, created_at DESC);
+CREATE UNIQUE INDEX IF NOT EXISTS surf_runs_one_pb
+ ON surf_runs (map_id, account_key) WHERE is_pb;
@@ -41,6 +41,7 @@ const TABLES = [
@@ -41,6 +41,7 @@ const TABLES = [
'event_outbox',
'event_outbox',
'token_revocations',
'token_revocations',
'steam_list_entries',
'steam_list_entries',
+ 'surf_runs',
].join(', ')
].join(', ')
let shared: Db | null = null
let shared: Db | null = null
@@ -3,5 +3,6 @@ export * from './appearance.js'
@@ -3,5 +3,6 @@ export * from './appearance.js'
export * from './wire.js'
export * from './wire.js'
export * from './messages/client.js'
export * from './messages/client.js'
export * from './messages/server.js'
export * from './messages/server.js'
+export * from './messages/surf.js'
export * from './codec.js'
export * from './codec.js'
export * from './editor.js'
export * from './editor.js'
@@ -1,6 +1,7 @@
@@ -1,6 +1,7 @@
import { z } from 'zod'
import { z } from 'zod'
import { ANGLE_Q_MAX, ANGLE_Q_MIN, PITCH_Q_MAX, intentMask } from '@openvibe/shared'
import { ANGLE_Q_MAX, ANGLE_Q_MIN, PITCH_Q_MAX, intentMask } from '@openvibe/shared'
import { AppearanceSchema } from '../appearance.js'
import { AppearanceSchema } from '../appearance.js'
+import { ClientSurfRestartSchema } from './surf.js'
/**
/**
* How many characters one account (or one guest browser) may keep in a game. Not a feature: a technical ceiling for the
* How many characters one account (or one guest browser) may keep in a game. Not a feature: a technical ceiling for the
@@ -328,6 +329,7 @@ export const ClientMessageSchema = z.union([
@@ -328,6 +329,7 @@ export const ClientMessageSchema = z.union([
ClientContainerOpenSchema,
ClientContainerOpenSchema,
ClientContainerMoveSchema,
ClientContainerMoveSchema,
ClientContainerSortSchema,
ClientContainerSortSchema,
+ ClientSurfRestartSchema,
])
])
export type ClientHello = z.infer<typeof ClientHelloSchema>
export type ClientHello = z.infer<typeof ClientHelloSchema>
@@ -358,4 +360,5 @@ export type ClientJobTurnIn = z.infer<typeof ClientJobTurnInSchema>
@@ -358,4 +360,5 @@ export type ClientJobTurnIn = z.infer<typeof ClientJobTurnInSchema>
export type ClientContainerOpen = z.infer<typeof ClientContainerOpenSchema>
export type ClientContainerOpen = z.infer<typeof ClientContainerOpenSchema>
export type ClientContainerMove = z.infer<typeof ClientContainerMoveSchema>
export type ClientContainerMove = z.infer<typeof ClientContainerMoveSchema>
export type ClientContainerSort = z.infer<typeof ClientContainerSortSchema>
export type ClientContainerSort = z.infer<typeof ClientContainerSortSchema>
+export type { ClientSurfRestart } from './surf.js'
export type ClientMessage = z.infer<typeof ClientMessageSchema>
export type ClientMessage = z.infer<typeof ClientMessageSchema>
@@ -7,6 +7,7 @@ import type {
@@ -7,6 +7,7 @@ import type {
WirePlayerState,
WirePlayerState,
WirePlant,
WirePlant,
} from '../wire.js'
} from '../wire.js'
+import type { ServerSurfFinish, ServerSurfState } from './surf.js'
/**
/**
* Server -> client messages. Plain types (no zod): the client trusts the
* Server -> client messages. Plain types (no zod): the client trusts the
@@ -271,6 +272,8 @@ export interface ServerContainer {
@@ -271,6 +272,8 @@ export interface ServerContainer {
}
}
export type ServerMessage =
export type ServerMessage =
+ | ServerSurfState
+ | ServerSurfFinish
| {
| {
t: 'salvage_round'
t: 'salvage_round'
roundId: string
roundId: string
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest'
+import { decodeClientMessage, encodeClientMessage } from '../codec.js'
+import { SurfFinishSchema, SurfStateSchema } from './surf.js'
+
+const state = {
+ t: 'surf_state' as const,
+ running: true,
+ elapsedMs: 12_345,
+ stage: 2,
+ stages: 6,
+ splits: [3_000, 7_000],
+ pbMs: 11_000,
+ wrMs: 9_500,
+ pbSplits: [2_900, 6_900],
+}
+
+const finish = {
+ t: 'surf_finish' as const,
+ timeMs: 10_000,
+ splits: [3_000, 7_000, 10_000],
+ pb: true,
+ wr: false,
+ rank: 4,
+ pbMs: 10_000,
+ wrMs: 9_500,
+}
+
+describe('surf protocol', () => {
+ it('accepts the client reset and round-trips it', () => {
+ const msg = { t: 'surf_restart' as const }
+ expect(decodeClientMessage(encodeClientMessage(msg))).toEqual(msg)
+ })
+
+ it('validates surf_state ranges', () => {
+ expect(SurfStateSchema.safeParse(state).success).toBe(true)
+ expect(SurfStateSchema.safeParse({ ...state, elapsedMs: -1 }).success).toBe(false)
+ expect(SurfStateSchema.safeParse({ ...state, elapsedMs: 86_400_001 }).success).toBe(false)
+ expect(SurfStateSchema.safeParse({ ...state, stage: 65 }).success).toBe(false)
+ expect(SurfStateSchema.safeParse({ ...state, stages: 0 }).success).toBe(false)
+ expect(SurfStateSchema.safeParse({ ...state, splits: [-1] }).success).toBe(false)
+ expect(SurfStateSchema.safeParse({ ...state, splits: new Array(65).fill(1) }).success).toBe(false)
+ expect(SurfStateSchema.safeParse({ ...state, pbMs: 86_400_001 }).success).toBe(false)
+ })
+
+ it('validates surf_finish ranges and accepts a Play-less finish', () => {
+ expect(SurfFinishSchema.safeParse(finish).success).toBe(true)
+ expect(SurfFinishSchema.safeParse({ ...finish, rank: null }).success).toBe(true)
+ expect(SurfFinishSchema.safeParse({ ...finish, timeMs: -5 }).success).toBe(false)
+ expect(SurfFinishSchema.safeParse({ ...finish, rank: 0 }).success).toBe(false)
+ expect(SurfFinishSchema.safeParse({ ...finish, splits: [-5] }).success).toBe(false)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
+import { z } from 'zod'
+
+/**
+ * Surf (OpenVibe.Surf Ride) protocol. Times are integer milliseconds measured
+ * by the server; a run's track and splits are server-derived, never client
+ * timestamps. The schemas are the range contract: values outside a plausible
+ * run (a day, 64 stages, 64 splits) are refused rather than rendered.
+ */
+export const SURF_MAX_TIME_MS = 86_400_000
+export const SURF_MAX_STAGES = 64
+export const SURF_MAX_SPLITS = 64
+
+const timeMs = z.number().int().min(0).max(SURF_MAX_TIME_MS)
+const splitList = z.array(timeMs).max(SURF_MAX_SPLITS)
+const stage = z.number().int().min(0).max(SURF_MAX_STAGES)
+
+export const ClientSurfRestartSchema = z.object({
+ t: z.literal('surf_restart'),
+})
+export type ClientSurfRestart = z.infer<typeof ClientSurfRestartSchema>
+
+export const SurfStateSchema = z.object({
+ t: z.literal('surf_state'),
+ /** The clock is running (start crossed, finish not yet). */
+ running: z.boolean(),
+ /** Server time since the start crossing, in ms (startTick-relative). */
+ elapsedMs: timeMs,
+ /** Checkpoints reached this run (0 = at the start). */
+ stage: stage,
+ /** Total stages on this map, including the finish. */
+ stages: z.number().int().min(1).max(SURF_MAX_STAGES),
+ /** Split times so far, in stage order, relative to the start. */
+ splits: splitList,
+ /** Personal best for this player on this map, if they have one. */
+ pbMs: timeMs.nullable(),
+ /** World record on this map, if one has been set. */
+ wrMs: timeMs.nullable(),
+ /** The PB's split times, for the per-split delta (absent until one exists). */
+ pbSplits: splitList.optional(),
+})
+export type ServerSurfState = z.infer<typeof SurfStateSchema>
+
+export const SurfFinishSchema = z.object({
+ t: z.literal('surf_finish'),
+ timeMs,
+ splits: splitList,
+ /** This run is the player's new PB. */
+ pb: z.boolean(),
+ /** This run is the map's new world record. */
+ wr: z.boolean(),
+ /** The result Play reported, when it accepted a `time` post. */
+ rank: z.number().int().min(1).max(1_000_000).nullable(),
+ pbMs: timeMs.nullable(),
+ wrMs: timeMs.nullable(),
+})
+export type ServerSurfFinish = z.infer<typeof SurfFinishSchema>
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/server/src/game/surfMaps.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/surfRules.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/platform/surfRuns.test.ts'
+// covers: 'apps/server/src/platform'
+// 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: 'packages/gameplay/src/movement/surf.test.ts'
+// covers: 'packages/gameplay/src/movement'
+// 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: 'packages/gameplay/src/surf/run.test.ts'
+// covers: 'packages/gameplay/src/surf'
+// 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: 'packages/protocol/src/messages/surf.test.ts'
+// covers: 'packages/protocol/src/messages'
+// Generated by node test/run.js --index; do not edit.