openvibers/openvibe.games Commits

Surf mode: three maps, server-timed runs, ghosts, Play time boards, /surf/embed for OpenVibe.Surf (#191)

OP OpenVibers committed 16 days ago · b6283ccd3c · 57 files, +4932 −44

* Surf mode: CS-style surf in the browser (three maps, a millisecond timer on the server's tick, splits, ghosts, Play time boards) and /surf/embed for OpenVibe.Surf

- Movement style per place: surf places use CS:S air-acceleration (cap-limited air strafe), 50-60 degree ramps
  (groundNormalY 0.7), no fall damage and no void rescue; the 30 Hz tick keeps a sub-tick crossing fraction.
- SurfSystem after movement: start, checkpoints, finish and restart; times come from the server tick, never the client.
- Runs are kept locally (PB, WR, splits) and posted to Play's surf title, one time board per map.
- GET /api/surf/maps, /api/surf/maps/:id and its WR/PB ghost; /surf/embed is frameable by openvibe.surf only.
- Client: the surf HUD (timer, stage, speed, splits with deltas), the ghost, a touch ramp trainer on phones.
- The three maps (surf_lagoon tier 1, surf_canyon tier 2, surf_orbit tier 4) live in content/surf/.

* /metrics?place=<id> answers one place's world gauges; the editor e2e reads its own place (the surf places' maps add to the process totals)

Changes

ddiff --git a/apps/client/e2e/editorCheck.ts b/apps/client/e2e/editorCheck.ts
iindex 92d0ecc..82fdad2 100644
---- a/apps/client/e2e/editorCheck.ts
++++ b/apps/client/e2e/editorCheck.ts
@@@ -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 m
await 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 m
await new Promise((r) => setTimeout(r, 100))
}
ddiff --git a/apps/client/play.html b/apps/client/play.html
iindex 8500c33..3a57491 100644
---- a/apps/client/play.html
++++ b/apps/client/play.html
@@@ -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>
ddiff --git a/apps/client/src/game/localPlayer.ts b/apps/client/src/game/localPlayer.ts
iindex 17d056c..e48d14e 100644
---- a/apps/client/src/game/localPlayer.ts
++++ b/apps/client/src/game/localPlayer.ts
@@@ -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: UniversalCamera
readonly 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.eyeOffset
this.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. */
ddiff --git a/apps/client/src/game/surfGhost.ts b/apps/client/src/game/surfGhost.ts
nnew file mode 100644
iindex 0000000..0a26764
---- /dev/null
++++ b/apps/client/src/game/surfGhost.ts
@@@ -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
++ }
++}
ddiff --git a/apps/client/src/main.ts b/apps/client/src/main.ts
iindex b4898f8..a520ce1 100644
---- a/apps/client/src/main.ts
++++ b/apps/client/src/main.ts
@@@ -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) ??
++ null
const selectedPlace = 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}` : ''
// 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) : null
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)
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))
++ : null
hud.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 = next
if (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.camera
releaseCamera()
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 = false
connection.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 dynamics
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)
markers?.update()
ddiff --git a/apps/client/src/ui/surfHud.css b/apps/client/src/ui/surfHud.css
nnew file mode 100644
iindex 0000000..b97723d
---- /dev/null
++++ b/apps/client/src/ui/surfHud.css
@@@ -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; }
ddiff --git a/apps/client/src/ui/surfHud.ts b/apps/client/src/ui/surfHud.ts
nnew file mode 100644
iindex 0000000..e64d383
---- /dev/null
++++ b/apps/client/src/ui/surfHud.ts
@@@ -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)
++ }
++}
ddiff --git a/apps/client/src/ui/surfTrainer.css b/apps/client/src/ui/surfTrainer.css
nnew file mode 100644
iindex 0000000..012897e
---- /dev/null
++++ b/apps/client/src/ui/surfTrainer.css
@@@ -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;
++}
ddiff --git a/apps/client/src/ui/surfTrainer.ts b/apps/client/src/ui/surfTrainer.ts
nnew file mode 100644
iindex 0000000..98431d8
---- /dev/null
++++ b/apps/client/src/ui/surfTrainer.ts
@@@ -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)
++ }
++}
ddiff --git a/apps/server/src/game/gameServer.ts b/apps/server/src/game/gameServer.ts
iindex b40d9be..6e87605 100644
---- a/apps/server/src/game/gameServer.ts
++++ b/apps/server/src/game/gameServer.ts
@@@ -24,6 +24,7 @@ import {
ReplicationSystem,
SessionsSystem,
SocialSystem,
++ SurfSystem,
VehicleSystem,
WorldEventSystem,
buildHandlerTable,
@@@ -82,6 +83,9 @@ export class GameServer {
const ctx = this.ctx
const 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(
ddiff --git a/apps/server/src/game/playerSession.ts b/apps/server/src/game/playerSession.ts
iindex b2110d0..68249f2 100644
---- a/apps/server/src/game/playerSession.ts
++++ b/apps/server/src/game/playerSession.ts
@@@ -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,
ddiff --git a/apps/server/src/game/surfMaps.test.ts b/apps/server/src/game/surfMaps.test.ts
nnew file mode 100644
iindex 0000000..cf9fa72
---- /dev/null
++++ b/apps/server/src/game/surfMaps.test.ts
@@@ -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)
++ })
++})
ddiff --git a/apps/server/src/game/surfMaps.ts b/apps/server/src/game/surfMaps.ts
nnew file mode 100644
iindex 0000000..147e27f
---- /dev/null
++++ b/apps/server/src/game/surfMaps.ts
@@@ -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))
++}
ddiff --git a/apps/server/src/game/surfRecords.ts b/apps/server/src/game/surfRecords.ts
nnew file mode 100644
iindex 0000000..dfd2b29
---- /dev/null
++++ b/apps/server/src/game/surfRecords.ts
@@@ -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>
++}
ddiff --git a/apps/server/src/game/surfRules.test.ts b/apps/server/src/game/surfRules.test.ts
nnew file mode 100644
iindex 0000000..626b318
---- /dev/null
++++ b/apps/server/src/game/surfRules.test.ts
@@@ -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 })
++ })
++})
ddiff --git a/apps/server/src/game/systems/context.ts b/apps/server/src/game/systems/context.ts
iindex 6ce637b..3e45d0e 100644
---- a/apps/server/src/game/systems/context.ts
++++ b/apps/server/src/game/systems/context.ts
@@@ -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: SessionsSystem
movement: MovementSystem
++ surf: SurfSystem
replication: ReplicationSystem
manipulation: ManipulationSystem
combat: CombatSystem
ddiff --git a/apps/server/src/game/systems/environment.ts b/apps/server/src/game/systems/environment.ts
iindex a25715c..11bbab1 100644
---- a/apps/server/src/game/systems/environment.ts
++++ b/apps/server/src/game/systems/environment.ts
@@@ -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) > 0
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).
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) continue
const sprinting =
(session.buttons & Buttons.Sprint) !== 0 &&
Math.hypot(session.move.vel.x, session.move.vel.z) > 1
ddiff --git a/apps/server/src/game/systems/index.ts b/apps/server/src/game/systems/index.ts
iindex 4300a49..28d60b4 100644
---- a/apps/server/src/game/systems/index.ts
++++ b/apps/server/src/game/systems/index.ts
@@@ -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'
ddiff --git a/apps/server/src/game/systems/movement.ts b/apps/server/src/game/systems/movement.ts
iindex b089a56..2d30454 100644
---- a/apps/server/src/game/systems/movement.ts
++++ b/apps/server/src/game/systems/movement.ts
@@@ -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 = 0
let simulated = 0
for (const input of takeInputs(session)) {
session.lastInput = input
@@@ -148,7 +163,16 @@ export class MovementSystem implements System {
session.lastProcessedSeq = input.seq
applyLookInput(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)
++ : null
stepMovement(
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)
ddiff --git a/apps/server/src/game/systems/sessions.ts b/apps/server/src/game/systems/sessions.ts
iindex 79676e8..4676f5e 100644
---- a/apps/server/src/game/systems/sessions.ts
++++ b/apps/server/src/game/systems/sessions.ts
@@@ -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.world
const 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 : undefined
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
? 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,
ddiff --git a/apps/server/src/game/systems/surf.ts b/apps/server/src/game/systems/surf.ts
nnew file mode 100644
iindex 0000000..70d8c2c
---- /dev/null
++++ b/apps/server/src/game/systems/surf.ts
@@@ -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)
++ }
++}
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 16c74dc..51f2dca 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -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_PACK
const content = createContent(packs, builtInPack)
let inventoryRewards: InventoryRewards | null = null
const 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 true
if (serveWorldPage(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 (
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()
ddiff --git a/apps/server/src/net/httpServer.ts b/apps/server/src/net/httpServer.ts
iindex 45178f1..168d74b 100644
---- a/apps/server/src/net/httpServer.ts
++++ b/apps/server/src/net/httpServer.ts
@@@ -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)) return
if (!playHost && serveUpdates(req, res)) return
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.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) => {
ddiff --git a/apps/server/src/net/surfRoutes.ts b/apps/server/src/net/surfRoutes.ts
nnew file mode 100644
iindex 0000000..5402ae3
---- /dev/null
++++ b/apps/server/src/net/surfRoutes.ts
@@@ -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
++}
ddiff --git a/apps/server/src/observability/metrics.test.ts b/apps/server/src/observability/metrics.test.ts
iindex 725bb71..d8b3602 100644
---- a/apps/server/src/observability/metrics.test.ts
++++ b/apps/server/src/observability/metrics.test.ts
@@@ -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)
++ })
})
ddiff --git a/apps/server/src/observability/metrics.ts b/apps/server/src/observability/metrics.ts
iindex c736e55..fe5c4ae 100644
---- a/apps/server/src/observability/metrics.ts
++++ b/apps/server/src/observability/metrics.ts
@@@ -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.size
ddiff --git a/apps/server/src/platform/surfRuns.test.ts b/apps/server/src/platform/surfRuns.test.ts
nnew file mode 100644
iindex 0000000..70b4dcc
---- /dev/null
++++ b/apps/server/src/platform/surfRuns.test.ts
@@@ -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()
++ })
++})
ddiff --git a/apps/server/src/platform/surfRuns.ts b/apps/server/src/platform/surfRuns.ts
nnew file mode 100644
iindex 0000000..ade9ab5
---- /dev/null
++++ b/apps/server/src/platform/surfRuns.ts
@@@ -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 }
++ },
++ }
++}
ddiff --git a/content/surf/surf_canyon.json b/content/surf/surf_canyon.json
nnew file mode 100644
iindex 0000000..9790040
---- /dev/null
++++ b/content/surf/surf_canyon.json
@@@ -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": []
++}
ddiff --git a/content/surf/surf_lagoon.json b/content/surf/surf_lagoon.json
nnew file mode 100644
iindex 0000000..664d433
---- /dev/null
++++ b/content/surf/surf_lagoon.json
@@@ -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": []
++}
ddiff --git a/content/surf/surf_orbit.json b/content/surf/surf_orbit.json
nnew file mode 100644
iindex 0000000..9adaf52
---- /dev/null
++++ b/content/surf/surf_orbit.json
@@@ -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": []
++}
ddiff --git a/packages/content/src/index.ts b/packages/content/src/index.ts
iindex b5c69d7..20f2dce 100644
---- a/packages/content/src/index.ts
++++ b/packages/content/src/index.ts
@@@ -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'
ddiff --git a/packages/content/src/mapFileV2.ts b/packages/content/src/mapFileV2.ts
iindex eed33db..3ca7002 100644
---- a/packages/content/src/mapFileV2.ts
++++ b/packages/content/src/mapFileV2.ts
@@@ -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 } : {}),
}
ddiff --git a/packages/content/src/packs/surf.ts b/packages/content/src/packs/surf.ts
nnew file mode 100644
iindex 0000000..6790f06
---- /dev/null
++++ b/packages/content/src/packs/surf.ts
@@@ -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'),
++}
ddiff --git a/packages/content/src/schema/world.ts b/packages/content/src/schema/world.ts
iindex c8446aa..583d5b3 100644
---- a/packages/content/src/schema/world.ts
++++ b/packages/content/src/schema/world.ts
@@@ -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),
ddiff --git a/packages/content/src/terrain.ts b/packages/content/src/terrain.ts
iindex 21b4f2c..c2913bd 100644
---- a/packages/content/src/terrain.ts
++++ b/packages/content/src/terrain.ts
@@@ -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
ddiff --git a/packages/gameplay/src/index.ts b/packages/gameplay/src/index.ts
iindex 310c616..fb8858d 100644
---- a/packages/gameplay/src/index.ts
++++ b/packages/gameplay/src/index.ts
@@@ -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'
ddiff --git a/packages/gameplay/src/movement/params.ts b/packages/gameplay/src/movement/params.ts
iindex 683a175..1aad03a 100644
---- a/packages/gameplay/src/movement/params.ts
++++ b/packages/gameplay/src/movement/params.ts
@@@ -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,
}
ddiff --git a/packages/gameplay/src/movement/simulate.ts b/packages/gameplay/src/movement/simulate.ts
iindex 476606e..b47949c 100644
---- a/packages/gameplay/src/movement/simulate.ts
++++ b/packages/gameplay/src/movement/simulate.ts
@@@ -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
++ }
++ }
}
ddiff --git a/packages/gameplay/src/movement/styles.ts b/packages/gameplay/src/movement/styles.ts
nnew file mode 100644
iindex 0000000..0624d82
---- /dev/null
++++ b/packages/gameplay/src/movement/styles.ts
@@@ -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'
++}
ddiff --git a/packages/gameplay/src/movement/surf.test.ts b/packages/gameplay/src/movement/surf.test.ts
nnew file mode 100644
iindex 0000000..85632b9
---- /dev/null
++++ b/packages/gameplay/src/movement/surf.test.ts
@@@ -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)
++ })
++})
ddiff --git a/packages/gameplay/src/surf/ghost.ts b/packages/gameplay/src/surf/ghost.ts
nnew file mode 100644
iindex 0000000..39c8c1a
---- /dev/null
++++ b/packages/gameplay/src/surf/ghost.ts
@@@ -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 }
++}
ddiff --git a/packages/gameplay/src/surf/run.test.ts b/packages/gameplay/src/surf/run.test.ts
nnew file mode 100644
iindex 0000000..077b4f5
---- /dev/null
++++ b/packages/gameplay/src/surf/run.test.ts
@@@ -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()
++ })
++})
ddiff --git a/packages/gameplay/src/surf/run.ts b/packages/gameplay/src/surf/run.ts
nnew file mode 100644
iindex 0000000..c8e9e5c
---- /dev/null
++++ b/packages/gameplay/src/surf/run.ts
@@@ -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.
++ }
++}
ddiff --git a/packages/persistence/migrations/0018_surf_runs.sql b/packages/persistence/migrations/0018_surf_runs.sql
nnew file mode 100644
iindex 0000000..6dc0132
---- /dev/null
++++ b/packages/persistence/migrations/0018_surf_runs.sql
@@@ -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;
ddiff --git a/packages/persistence/src/testing.ts b/packages/persistence/src/testing.ts
iindex 8de8e74..19f6d94 100644
---- a/packages/persistence/src/testing.ts
++++ b/packages/persistence/src/testing.ts
@@@ -41,6 +41,7 @@ const TABLES = [
'event_outbox',
'token_revocations',
'steam_list_entries',
++ 'surf_runs',
].join(', ')
let shared: Db | null = null
ddiff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts
iindex f6bab8f..363b3de 100644
---- a/packages/protocol/src/index.ts
++++ b/packages/protocol/src/index.ts
@@@ -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'
ddiff --git a/packages/protocol/src/messages/client.ts b/packages/protocol/src/messages/client.ts
iindex fc2301b..807471f 100644
---- a/packages/protocol/src/messages/client.ts
++++ b/packages/protocol/src/messages/client.ts
@@@ -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>
ddiff --git a/packages/protocol/src/messages/server.ts b/packages/protocol/src/messages/server.ts
iindex cafdd8e..ff055d7 100644
---- a/packages/protocol/src/messages/server.ts
++++ b/packages/protocol/src/messages/server.ts
@@@ -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: string
ddiff --git a/packages/protocol/src/messages/surf.test.ts b/packages/protocol/src/messages/surf.test.ts
nnew file mode 100644
iindex 0000000..99a040d
---- /dev/null
++++ b/packages/protocol/src/messages/surf.test.ts
@@@ -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)
++ })
++})
ddiff --git a/packages/protocol/src/messages/surf.ts b/packages/protocol/src/messages/surf.ts
nnew file mode 100644
iindex 0000000..b36dddd
---- /dev/null
++++ b/packages/protocol/src/messages/surf.ts
@@@ -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>
ddiff --git a/test/apps__server__src__game__surfMaps.test.js b/test/apps__server__src__game__surfMaps.test.js
nnew file mode 100644
iindex 0000000..da96287
---- /dev/null
++++ b/test/apps__server__src__game__surfMaps.test.js
@@@ -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.
ddiff --git a/test/apps__server__src__game__surfRules.test.js b/test/apps__server__src__game__surfRules.test.js
nnew file mode 100644
iindex 0000000..4d4e942
---- /dev/null
++++ b/test/apps__server__src__game__surfRules.test.js
@@@ -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.
ddiff --git a/test/apps__server__src__platform__surfRuns.test.js b/test/apps__server__src__platform__surfRuns.test.js
nnew file mode 100644
iindex 0000000..98c39bd
---- /dev/null
++++ b/test/apps__server__src__platform__surfRuns.test.js
@@@ -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.
ddiff --git a/test/packages__gameplay__src__movement__surf.test.js b/test/packages__gameplay__src__movement__surf.test.js
nnew file mode 100644
iindex 0000000..183c10e
---- /dev/null
++++ b/test/packages__gameplay__src__movement__surf.test.js
@@@ -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.
ddiff --git a/test/packages__gameplay__src__surf__run.test.js b/test/packages__gameplay__src__surf__run.test.js
nnew file mode 100644
iindex 0000000..715dbcd
---- /dev/null
++++ b/test/packages__gameplay__src__surf__run.test.js
@@@ -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.
ddiff --git a/test/packages__protocol__src__messages__surf.test.js b/test/packages__protocol__src__messages__surf.test.js
nnew file mode 100644
iindex 0000000..5589d32
---- /dev/null
++++ b/test/packages__protocol__src__messages__surf.test.js
@@@ -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.