openvibers/openvibe.games Commits
Rapier replaces Havok behind the unchanged physics seam (ADR-0007 M1 steps 4-7), and Games serves its own legal pages (plan T11 decision 6).
Changes
@@@ -6,7 +6,7 @@every gameplay-relevant decision (movement, inventory, crafting, placement,physgun, damage, economy) is validated or simulated server-side. No clientfield is ever trusted.--2. **Domain logic is engine-free.** `@openvibe/gameplay` has no Babylon, Havok,++2. **Domain logic is engine-free.** `@openvibe/gameplay` has no Babylon, Rapier,DOM, or network imports; it runs identically in vitest, on the server, andinside client prediction. Engines live behind adapters.3. **Data-driven content.** Items, recipes, and worlds are declarative@@@ -24,7 +24,7 @@```shared/ | \-- protocol content physics ──(havok adapter: @babylonjs/core + havok wasm)++ protocol content physics ──(rapier adapter: deterministic Rapier wasm)\ | /gameplay|@@@ -34,10 +34,13 @@(authoritative) (Babylon render + prediction + HUD)```--Only `apps/*` and `packages/physics`'s adapter know Babylon exists. Only--`apps/server` and `packages/persistence` know PostgreSQL (openvibe-sdk/db) exists. `packages/*`--never import browser-only APIs (the physics adapter's NullEngine path is--Node-safe; the client hands it a rendered scene instead).++Only `apps/client` knows Babylon exists: ADR-0007 decision 2 removed the engine++from the authoritative server, and an architecture audit++(`apps/server/src/architectureAudit.audit.ts`) fails if `apps/server` imports++`@babylonjs/*`. Only `apps/server` and `packages/persistence` know PostgreSQL++(openvibe-sdk/db) exists. `packages/*` never import browser-only APIs: the++physics adapter is Rapier, which runs identically in Node and the browser with no++scene or engine, so there is no Node-only path to keep separate.## Simulation model@@@ -46,18 +49,19 @@ Node-safe; the client hands it a rendered scene instead).- **Movement** is a kinematic Source/Quake controller (`gameplay/movement`):explicit velocity + capsule sweeps + clip-plane sliding + step-up, withfriction / ground-accelerate / air-accelerate (air-strafing works). The-- player is _not_ a dynamic rigid body; a kinematic Havok capsule mirrors the++ player is _not_ a dynamic rigid body; a kinematic Rapier capsule mirrors theplayer so props collide with them. The controller consumes a-- `CollisionQueries` interface — the Havok adapter provides it on both sides,-- the tests provide analytic worlds.++ `CollisionQueries` interface — the Rapier adapter provides it on both sides++ (one implementation, no scene), the tests provide analytic worlds.- **Client prediction:** every input command (seq-stamped) is sent and appliedlocally; snapshots carry the last processed seq; the client rewinds to theauthoritative state and replays unacked inputs. Remote entities render from~130 ms interpolation buffers.--- **Physics:** Havok via Babylon Physics V2 behind `PhysicsWorld`-- (`@openvibe/physics`). Stepping is always manual (`executeStep`) from the fixed-- loop — never coupled to render frames. Collision layers: Static / Prop /-- Player, filtered at the shape level for rays and sweeps.++- **Physics:** Rapier (`@dimforge/rapier3d-deterministic-compat`, one pinned++ build on both sides) behind `PhysicsWorld` (`@openvibe/physics`). Stepping is++ always manual (`world.step(dt)` from the fixed loop) — never coupled to render++ frames. Collision layers: Static / Prop / Player, filtered at the shape level++ for rays and sweeps.## Networking@@@ -8,7 +8,8 @@ physgun-driven building from crafted physical objects, gathering, crafting, andextraction risk — running against an authoritative dedicated server.**Stack:** TypeScript everywhere · Babylon.js (WebGPU with WebGL fallback) ·--Havok physics (Babylon Physics V2, headless on the server via NullEngine) ·++Rapier physics (`@dimforge/rapier3d-deterministic-compat`, one pinned build++headless on the server and in client prediction) ·WebSocket protocol · PostgreSQL 18 + Valkey persistence (openvibe-sdk) · pnpm monorepo.## Owns@@@ -32,7 +33,7 @@ WebSocket protocol · PostgreSQL 18 + Valkey persistence (openvibe-sdk) · pnpm- OpenVibe.Network (SSO, JWKS, client-credentials tokens, identity resolve, the `games.progress.summary`user module, mod grants), OpenVibe.Events (outbox relay, subscriptions), OpenVibe.Media (asset mirror)- `openvibe-contracts` v0.77.0, `openvibe-sdk` v0.22.0 and `openvibe-shared` v1.30.1 (pinned by release-- tarball in `apps/server/package.json`), Babylon.js, Havok, PostgreSQL (`pg`, or embedded PGlite in++ tarball in `apps/server/package.json`), Babylon.js, Rapier, PostgreSQL (`pg`, or embedded PGlite indevelopment), Valkey (`iovalkey`, optional)## Repository layout@@@ -46,7 +47,7 @@ packages/protocol/ Versioned wire messages + codec (zod-validated inbound)content/ Data-driven item/recipe/world definitions + validation registrygameplay/ Pure domain logic: inventory, crafting, movement sim, zones, entities-- physics/ PhysicsWorld abstraction + Havok adapter (@openvibe/physics/havok)++ physics/ PhysicsWorld abstraction + Rapier adapter (@openvibe/physics/rapier)persistence/ DTOs, repository interfaces, PostgreSQL implementation, migrationsdocs/adr/ Architecture decision records```@@@ -10,7 +10,7 @@ real playable vertical implementation.Proved every major system cooperates end-to-end (verified by`apps/server/scripts/sliceTest.ts` on every change): authoritative server,Babylon client (WebGPU/WebGL), Source-style predicted movement, interest---managed replication, Havok props with sleep-aware snapshotting, gathering,++managed replication, physics props with sleep-aware snapshotting, gathering,inventory, data-driven crafting, physgun building, persistence acrossrestart, zone rules, logs//metrics//healthz.@@@ -3,7 +3,7 @@"stage": "stable","code": true,"product": "Scraplandia",-- "runtime": "node 22, TypeScript (tsx), pnpm workspace; Babylon.js + Havok; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in development); Valkey optional",++ "runtime": "node 22, TypeScript (tsx), pnpm workspace; Babylon.js (client) + deterministic Rapier physics; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in development); Valkey optional","port": 8000,"domain": "openvibe.games, play.openvibe.games","contracts": "openvibe-contracts v0.71.0, openvibe-sdk v0.22.0, openvibe-shared v1.30.1 (apps/server/package.json)",@@@ -16,7 +16,7 @@"deployed": true,"deployment": "Public at https://openvibe.games and https://play.openvibe.games: deployed with `sudo ovhost deploy games` (OpenVibe.Host strategy pnpm-build since the 2026-09-27 cutover; deploy/scripts/deploy.sh is the wrapper), unit openvibe-games on 127.0.0.1:8000, env /etc/openvibe/games.env, principal games.","features": [-- "authoritative multiplayer physics sandbox (Havok on the server)",++ "authoritative multiplayer physics sandbox (deterministic Rapier on the server)","Source-style movement, physgun building, gathering, crafting, skills","persistent world, characters and props in PostgreSQL (write-behind flusher; the tick never awaits I/O)","map editor with assets mirrored to OpenVibe.Media",@@@ -11,7 +11,6 @@"dependencies": {"@babylonjs/addons": "^9.20.0","@babylonjs/core": "^9.20.0",-- "@babylonjs/havok": "^1.3.10","@babylonjs/loaders": "^9.20.0","@babylonjs/materials": "^9.20.0","@babylonjs/procedural-textures": "^9.20.0",@@@ -1,5 +1,3 @@--import HavokPhysics from '@babylonjs/havok'--import havokWasmUrl from '@babylonjs/havok/lib/esm/HavokPhysics.wasm?url'import {createContent,setMapOverride,@@@ -11,7 +9,7 @@ import {type MapTextureEntry,} from '@openvibe/content'import { MapLightLayer, mapDiff, staticWork, terrainWork } from './render/mapRuntimeLayers.js'--import { createHavokWorldForScene } from '@openvibe/physics/havok'++import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'import { FixedTimestep } from '@openvibe/shared'import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'import type { Appearance } from '@openvibe/protocol'@@@ -90,8 +88,9 @@ async function start(): Promise<void> {const mapLights = new MapLightLayer(scene)mapLights.reconcile(mapLightsBoot ?? [])-- // Havok loads while the player customizes their character.-- const havokPromise = HavokPhysics({ locateFile: () => havokWasmUrl })++ // Rapier loads while the player customizes their character (dynamic import,++ // so physics is never part of the initial JavaScript).++ const rapierPromise = loadRapier()const environment = new Environment(scene, engine)@@@ -156,8 +155,8 @@ async function start(): Promise<void> {// away so the canvas gets the whole viewport (see public/frame.js).window.dispatchEvent(new CustomEvent('ovg:playing'))-- const havok = await havokPromise-- const physics = createHavokWorldForScene(scene, havok)++ const rapier = await rapierPromise++ const physics = createRapierWorld(rapier)const { buildStaticPhysics, rebuildTerrainPhysics } = await import('./game/staticPhysics.js')buildStaticPhysics(physics, content)@@@ -7,22 +7,22 @@"dev": "tsx watch --env-file-if-exists=.env src/main.ts","start": "tsx src/main.ts","build": "tsc --build",-- "bundle": "esbuild src/main.ts --bundle --platform=node --format=esm --target=node22 --external:pg --external:iovalkey --external:@electric-sql/pglite --external:@babylonjs/havok --outfile=dist/server.mjs --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\""++ "bundle": "esbuild src/main.ts --bundle --platform=node --format=esm --target=node22 --external:pg --external:iovalkey --external:@electric-sql/pglite --outfile=dist/server.mjs --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\""},"dependencies": {-- "@babylonjs/havok": "^1.3.10",++ "@dimforge/rapier3d-deterministic-compat": "0.21.0",++ "@electric-sql/pglite": "^0.5.8","@openvibe/content": "workspace:*","@openvibe/gameplay": "workspace:*","@openvibe/persistence": "workspace:*","@openvibe/physics": "workspace:*","@openvibe/protocol": "workspace:*","@openvibe/shared": "workspace:*",-- "@electric-sql/pglite": "^0.5.8","ajv": "^8.17.1","iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.78.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0","pg": "^8.23.0","tsx": "^4.19.3","ws": "^8.18.1"@@@ -1747,9 +1747,12 @@ async function main(): Promise<void> {while (Date.now() - start < 45_000) {const me = a.meif (me && Math.hypot(me.pos[0] - 0, me.pos[2] - 4) < 6 && secured()) break // both frames in-- // Nudge back toward the beacon in case physics drift pushed us out.++ // Nudge back toward the beacon in case physics drift pushed us out, but only while we are not already home++ // (after the recall the site is behind us), and only as a best effort: the recall is the server's and lands++ // wherever we stand, so a nudge that runs out of time must not end the run.++ const home = me && Math.hypot(me.pos[0] - 0, me.pos[2] - 4) < 6const dist = me ? Math.hypot(me.pos[0] - site[0], me.pos[2] - site[1]) : 99-- if (me && dist > 3) await walkTo(a, site[0], site[1], 6000)++ if (me && !home && dist > 3) await walkTo(a, site[0], site[1], 6000).catch(() => undefined)await sleep(500)}}nnew file mode 100644@@@ -0,0 +1,90 @@++/**++ * ADR-0007 completion audit: the authoritative server is Babylon-free.++ *++ * Deleting Havok (ADR-0007 decision 2) removes `@babylonjs/core` and the++ * NullEngine scene from the server; the server now runs Rapier only. That win++ * is invisible to the type checker and to behavioural tests — a stray Babylon++ * import "works" — so it needs a test of its own or the renderer quietly creeps++ * back into the authoritative process.++ *++ * Only ACTIVE code counts: a mention in a comment is not an import.++ */++import { readdirSync, readFileSync, statSync } from 'node:fs'++import { extname, join, relative } from 'node:path'++import { describe, expect, it } from 'vitest'++++const REPO = new URL('../../../', import.meta.url).pathname++const ROOT = 'apps/server'++++/** Server sources that legitimately mention Babylon: only this audit, which names the forbidden module. */++const ALLOWED = ['apps/server/src/architectureAudit.audit.ts']++++interface SourceFile {++ path: string++ text: string++}++++function collect(): SourceFile[] {++ const out: SourceFile[] = []++ const walk = (dir: string): void => {++ for (const entry of readdirSync(dir)) {++ const full = join(dir, entry)++ if (statSync(full).isDirectory()) {++ if (entry === 'node_modules' || entry === 'dist' || entry === 'dist-types') continue++ walk(full)++ continue++ }++ if (!['.ts', '.tsx'].includes(extname(entry))) continue++ const rel = relative(REPO, full)++ if (ALLOWED.some((a) => rel === a)) continue++ out.push({ path: rel, text: readFileSync(full, 'utf8') })++ }++ }++ walk(join(REPO, ROOT))++ return out++}++++const FILES = collect()++++/** Occurrences outside comments — a mention in prose is not an import. */++function activeHits(needle: RegExp): { path: string; line: number; text: string }[] {++ const hits: { path: string; line: number; text: string }[] = []++ for (const f of FILES) {++ let inBlockComment = false++ f.text.split('\n').forEach((raw, i) => {++ const line = raw.trim()++ if (inBlockComment) {++ if (line.includes('*/')) inBlockComment = false++ return++ }++ if (line.startsWith('/*')) {++ if (!line.includes('*/')) inBlockComment = true++ return++ }++ if (line.startsWith('//') || line.startsWith('*')) return++ if (needle.test(raw)) hits.push({ path: f.path, line: i + 1, text: line.slice(0, 110) })++ })++ }++ return hits++}++++const report = (hits: { path: string; line: number; text: string }[]): string =>++ hits.map((h) => `${h.path}:${h.line} ${h.text}`).join('\n')++++describe('architecture audit: the server is Babylon-free (ADR-0007)', () => {++ it('scans a meaningful number of server source files', () => {++ expect(FILES.length).toBeGreaterThan(40)++ })++++ it('imports no @babylonjs/* anywhere in the server', () => {++ expect(report(activeHits(/@babylonjs\//))) .toBe('')++ })++++ it('drives Rapier (not Havok) and has no Havok loader', () => {++ // Positive half of the claim: the server names Rapier and nothing else.++ const main = FILES.find((f) => f.path === 'apps/server/src/main.ts')++ expect(main, 'server main.ts should exist').toBeDefined()++ expect(main!.text).toContain('@openvibe/physics/rapier')++ expect(main!.text).not.toMatch(/havok/i)++ })++})@@@ -91,9 +91,9 @@ export class MapTerrainLayer {private addBody(patch: TerrainPatchData): BodyId {const grid = buildPatchGrid(patch.halfExtent, patch.sub, patch.heights)const rot = patch.rot ? qfromEuler(quat(), patch.rot[0], patch.rot[1], patch.rot[2]) : quat()-- // Scale the VERTICES rather than the body: Babylon/Havok apply-- // scale→rotate→translate, matching the client mesh's `scaling`, so the-- // two cannot disagree about where the ground is.++ // Scale the VERTICES rather than the body: Rapier has no body scale, so++ // baking it into the local vertices is the only way to match the client++ // mesh's `scaling`, and the two cannot disagree about where the ground is.const positions = scalePatchPositions(grid.positions, patch.scale)return this.physics.addBody({shape: { type: 'trimesh', positions, indices: grid.indices },@@@ -1,6 +1,6 @@/*** The Wave 12 platform boundary exercised on the real authoritative server-- * (headless Havok, real PostgreSQL schema through in-memory PGlite,++ * (headless Rapier, real PostgreSQL schema through in-memory PGlite,* protocol-level connections), across a restart:** - a signed-in player is keyed by the canonical subject;@@@ -19,7 +19,7 @@ import { join } from 'node:path'import { createContent } from '@openvibe/content'import { openPgStore, type PgPersistenceStore } from '@openvibe/persistence'import { openTestDb } from '@openvibe/persistence/testing'--import { createHeadlessHavokWorld } from '@openvibe/physics/havok'++import { createRapierWorld, loadRapier, type RapierModule } from '@openvibe/physics/rapier'import {PROTOCOL_VERSION,defaultAppearance,@@@ -32,7 +32,6 @@ import { createEventsClient, createPgOutbox, type PgOutbox } from 'openvibe-sdk/import { createMockPlatform } from 'openvibe-sdk/testing'import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'import { loadConfig } from '../config.js'--import { loadHavok } from '../havokLoader.js'import { hashGuestKey } from '../net/guestIdentity.js'import { CAP_ANNOUNCE, CAP_PLACE_PROP } from '../mods/contentPack.js'import { ModRegistry, type ModActor } from '../mods/registry.js'@@@ -55,12 +54,12 @@ const config = loadConfig({MAP_PATH: join(dir, 'map.json'),OV_NETWORK_AUTH_URL: 'http://network.test/api/auth/me',})--let havok: unknown++let rapier: RapierModule/** One in-memory PGlite database for the whole file; the restart tests share its state. */let db: DbbeforeAll(async () => {-- havok = await loadHavok()++ rapier = await loadRapier()db = await openTestDb()// openvibe.network's /api/auth/me for the one signed-in test account.vi.stubGlobal('fetch', async (_url: string, init?: RequestInit) => {@@@ -85,7 +84,7 @@ interface Boot {async function boot(platform: ReturnType<typeof createMockPlatform>): Promise<Boot> {const content = createContent()-- const physics = createHeadlessHavokWorld(havok)++ const physics = createRapierWorld(rapier)// The store rides on the shared database; close() is never called (the instance is shared).const store = openPgStore(db, { placeId: config.placeId })await store.ensurePlace()@@@ -12,6 +12,15 @@ const _vehicleForce = vec3()const _vehicleAng = vec3()const _vehicleVel = vec3()++/**++ * Vertical clearance between a riding player's replicated perch and their++ * collision hull. Rapier resolves an animated hull that overlaps a dynamic++ * chassis into a depenetration impulse, so a hull left at the perch pushes the++ * cart into the ground and pins it. The standing capsule half-height is 0.75 m++ * (STANCE_HULL 1.8 minus the 0.3 shorter body hull), which clears the vehicle.++ */++const RIDER_HULL_LIFT = 0.75++export class VehicleSystem implements System {readonly name = 'vehicles'@@@ -174,8 +183,13 @@ export class VehicleSystem implements System {session.fallVy = 0const body = this.ctx.sessions.bodyOf(session.playerId)if (body !== undefined) {++ // Rapier resolves an animated body that overlaps a dynamic body into a++ // depenetration impulse, so a rider's hull left inside the chassis pushes++ // the cart into the ground and brakes it. Perch the rider's hull clear of++ // the vehicle instead; the replicated/rendered perch above++ // (session.move.pos) is unchanged, so nothing visible moves._bodyPosScratch.x = session.move.pos.x-- _bodyPosScratch.y = session.move.pos.y + 0.15++ _bodyPosScratch.y = session.move.pos.y + RIDER_HULL_LIFT_bodyPosScratch.z = session.move.pos.zthis.ctx.world.physics.setTransform(body, _bodyPosScratch)}ddeleted file mode 100644@@@ -1,14 +0,0 @@--import { readFile } from 'node:fs/promises'--import { createRequire } from 'node:module'--import HavokPhysics from '@babylonjs/havok'----/**-- * Loads the Havok wasm module in Node. Emscripten's default file resolution-- * expects a browser, so the wasm binary is read from the package explicitly.-- */--export async function loadHavok(): Promise<unknown> {-- const require = createRequire(import.meta.url)-- const wasmPath = require.resolve('@babylonjs/havok/lib/esm/HavokPhysics.wasm')-- const wasmBinary = (await readFile(wasmPath)).buffer as ArrayBuffer-- return HavokPhysics({ wasmBinary })--}@@@ -5,13 +5,12 @@ import { createEventsClient, createPgOutbox, type PgOutbox } from 'openvibe-sdk/import { createValkey } from 'openvibe-sdk/valkey'import { compileMapFileV2, createContent, parseMapFile, setMapOverride } from '@openvibe/content'import { openPgStore } from '@openvibe/persistence'--import { createHeadlessHavokWorld } from '@openvibe/physics/havok'++import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'import { createConsoleLogger } from '@openvibe/shared'import { loadConfig } from './config.js'import { openDb } from './db.js'import { GameServer } from './game/gameServer.js'import { GameWorld } from './game/gameWorld.js'--import { loadHavok } from './havokLoader.js'import { attachEditorWs } from './net/editorWs.js'import {closeSockets,@@@ -99,8 +98,7 @@ async function main(): Promise<void> {world: content.world.id,})-- const havok = await loadHavok()-- const physics = createHeadlessHavokWorld(havok)++ const physics = createRapierWorld(await loadRapier())// PostgreSQL (or embedded PGlite in development), migrated before anything writes.const db = await openDb(config.db, log)@@@ -30,6 +30,7 @@ import type { GamesActorLimits } from './actorLimits.js'import { resolveClientAddress } from './clientAddress.js'import { sendNotFound, type NotFoundLink } from './notFound.js'import { discoveryHandler, homeJsonLdTags } from './discovery.js'++import { legalHandler } from './legalPages.js'/** The portal page with its JSON-LD, built once per file version: the game server's event loop never reads the file* per request (a new build changes the mtime and rebuilds it once). */@@@ -181,6 +182,8 @@ export function createHttpServer({ href: '/editor', label: 'Map editor' },{ href: portalHome, label: 'OpenVibe.Games home' },]++ // The site's own /terms, /privacy and /dmca (openvibe-shared/legal), built once for the process.++ const serveLegal = legalHandler()return createServer((req: IncomingMessage, res: ServerResponse) => {const url = (req.url ?? '/').split('?')[0] ?? '/'// Host-based routing: play.openvibe.games serves the game at its root@@@ -215,6 +218,9 @@ export function createHttpServer(if (platform?.handle?.(req, res)) return// Crawl artifacts (plan T11): /robots.txt, /sitemap.xml and /llms.txt, built by openvibe-shared/seo.if (discoveryHandler(req, res)) return++ // The site's own legal pages (plan T11 decision 6): /terms, /privacy, /dmca. Before the SPA and the++ // static fallback so they never resolve to a file or the landing page.++ if (serveLegal(req, res)) returnif (url === '/map.json' && mapPath) {// The canonical v2 document, with its revision as an ETag. This IS the// wire format.nnew file mode 100644@@@ -0,0 +1,115 @@++import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'++import { request, type IncomingHttpHeaders, type OutgoingHttpHeaders, type Server } from 'node:http'++import type { AddressInfo } from 'node:net'++import { tmpdir } from 'node:os'++import { join } from 'node:path'++import { createConsoleLogger } from '@openvibe/shared'++import { afterAll, beforeAll, describe, expect, it } from 'vitest'++import { ServerMetrics } from '../observability/metrics.js'++import { createHttpServer } from './httpServer.js'++++/**++ * The site's own legal pages (plan T11 decision 6): /terms, /privacy and /dmca are rendered by++ * openvibe-shared/legal with the Games profile, fetched from the real HTTP layer over a stand-in++ * client build. An unknown /legal-ish path is still a real 404 (notFound.ts), never the landing page.++ */++++const log = createConsoleLogger({ app: 'test' }, 'error')++const APEX = 'openvibe.games'++const PAGES: readonly (readonly [string, string])[] = [++ ['/terms', 'Terms of Service'],++ ['/privacy', 'Privacy Policy'],++ ['/dmca', 'DMCA & Copyright'],++]++++interface Reply {++ status: number++ headers: IncomingHttpHeaders++ body: string++}++++let dir: string++let server: Server++let port = 0++++function listen(s: Server): Promise<number> {++ return new Promise((resolve) =>++ s.listen(0, '127.0.0.1', () => resolve((s.address() as AddressInfo).port)),++ )++}++++function send(++ path: string,++ opts: { method?: string; headers?: OutgoingHttpHeaders } = {},++): Promise<Reply> {++ return new Promise((resolve, reject) => {++ const req = request(++ {++ host: '127.0.0.1',++ port,++ path,++ method: opts.method ?? 'GET',++ headers: { host: APEX, ...opts.headers },++ },++ (res) => {++ const chunks: Buffer[] = []++ res.on('data', (c: Buffer) => chunks.push(c))++ res.on('end', () =>++ resolve({++ status: res.statusCode ?? 0,++ headers: res.headers,++ body: Buffer.concat(chunks).toString('utf8'),++ }),++ )++ },++ )++ req.on('error', reject)++ req.end()++ })++}++++beforeAll(async () => {++ dir = mkdtempSync(join(tmpdir(), 'ovg-legal-'))++ const dist = join(dir, 'dist')++ mkdirSync(dist, { recursive: true })++ writeFileSync(join(dist, 'index.html'), '<!doctype html><title>portal page</title>')++ writeFileSync(join(dist, 'play.html'), '<!doctype html><title>game page</title>')++ server = createHttpServer(dist, new ServerMetrics(), log)++ port = await listen(server)++})++++afterAll(() => {++ server.close()++ rmSync(dir, { recursive: true, force: true })++})++++describe('legal pages (plan T11 decision 6)', () => {++ it('serves each path as its own page: text/html, the title and a canonical on openvibe.games', async () => {++ for (const [path, title] of PAGES) {++ const r = await send(path)++ expect(r.status).toBe(200)++ expect(r.headers['content-type']).toBe('text/html; charset=utf-8')++ expect(r.body).toContain(`<title>${title} — OpenVibe.Games</title>`)++ expect(r.body).toContain(`<link rel="canonical" href="https://openvibe.games${path}">`)++ expect(r.body).toContain(`<h1>${title}</h1>`)++ }++ })++++ it('answers HEAD like GET, headers only', async () => {++ for (const [path] of PAGES) {++ const r = await send(path, { method: 'HEAD' })++ expect(r.status).toBe(200)++ expect(r.headers['content-type']).toBe('text/html; charset=utf-8')++ expect(r.body).toBe('')++ }++ })++++ it('still 404s a legal-ish path it does not own, through notFound.ts', async () => {++ for (const path of ['/legal', '/terms/extra', '/tos', '/privacy.html']) {++ const r = await send(path)++ expect(r.status).toBe(404)++ expect(r.headers['content-type']).toBe('text/html; charset=utf-8')++ expect(r.body).toContain('Page not found')++ }++ })++})nnew file mode 100644@@@ -0,0 +1,80 @@++import type { IncomingMessage, ServerResponse } from 'node:http'++import { createRequire } from 'node:module'++++/**++ * GET /terms, /privacy and /dmca (plan T11 decision 6): the site answers its own legal pages with++ * openvibe-shared/legal, as every other product does. openvibe.games used to be answered by the++ * OpenVibe.Sites placeholder (nginx served /opt/openvibe.sites/dist/openvibe.games for them); Sites++ * is going away, so the pages move here.++ *++ * The profile is 'games': legal.js switches clauses by profile, and 'games' is the one that fits a++ * game with accounts, chat and user-made maps — it adds the Games section (in development, play++ * fair, no cheats, game-chat rules), the user-content licence (user-made maps) and, in the privacy++ * document, the Game data bullet. The account clauses are included for every non-'info' profile.++ *++ * legal.handler is Express-shaped (this server is plain node:http, like sharedAssets.ts), so a tiny++ * req/res shim stands in for Express: req.path and res.set/res.send are all it touches.++ */++interface LegalSite {++ id: string++ service: string++ host: string++ name: string++ profile: string++}++interface LegalReq {++ path: string++}++interface LegalRes {++ set(name: string, value: string): void++ send(body: string): void++}++interface LegalModule {++ PATHS: string[]++ handler(site: LegalSite): (req: LegalReq, res: LegalRes, next: () => void) => void++}++++const SITE: LegalSite = {++ id: 'games',++ service: 'games',++ host: 'openvibe.games',++ name: 'OpenVibe.Games',++ profile: 'games',++}++++export function legalHandler(req: NodeRequire = createRequire(import.meta.url)) {++ const legal = req('openvibe-shared/legal') as LegalModule++ const paths = new Set(legal.PATHS)++ // One handler for the process: openvibe-shared caches each rendered page after the first request.++ const handle = legal.handler(SITE)++ return (rq: IncomingMessage, res: ServerResponse): boolean => {++ const url = (rq.url ?? '/').split('?')[0] ?? '/'++ if (!paths.has(url)) return false++ if (rq.method !== 'GET' && rq.method !== 'HEAD') {++ res.writeHead(405, { allow: 'GET, HEAD' })++ res.end()++ return true++ }++ const headers: Record<string, string> = {}++ handle(++ { path: url },++ {++ set: (name, value) => {++ headers[name.toLowerCase()] = value++ },++ send: (body) => {++ res.writeHead(200, headers)++ // node:http writes no body for HEAD; the headers are the same.++ res.end(rq.method === 'HEAD' ? undefined : body)++ },++ },++ () => {++ // legal.PATHS and the shared handler's documents are the same list; unreachable, but a++ // path it does not know must not fall through as a 200.++ res.writeHead(404)++ res.end()++ },++ )++ return true++ }++}@@@ -1,6 +1,12 @@# ADR-0003: Server physics = Havok wasm via Babylon NullEngine adapter--**Status:** accepted · **Date:** 2026-08-09++**Status:** superseded by ADR-0007 · **Date:** 2026-08-09++++> Superseded 2026-09-29: ADR-0007 decision 2 replaces Havok and the Babylon++> NullEngine host with Rapier on both sides, so `packages/physics/src/havok` and++> the NullEngine scene are gone. The rule this ADR established — one physics++> implementation behind `PhysicsWorld`, manual stepping, no engine types across++> the boundary — is kept; only the engine changed.## Decision@@@ -60,6 +60,47 @@ no mobile path, and pins that lag the platform.12. **Pin drift is a defect.** Contracts and SDK pins move to current; CI fails when the installed contracts packagerejects a manifest Games accepts, or the other way round.++## M1 physics decisions (2026-09-29)++++Answers to the open questions in the M1 survey (`brief-games-m1-next.out.md`), decided after the state-hash++harness (M1 step 2) had measured the mock against a real engine. Decisions 8–9 were taken because the++cross-engine tolerance test forked on a discrete branch, not on floating-point noise.++++1. **Determinism means two things, tested separately.** Same runtime (Node against Node, the same inputs):++ the movement state hash is bit-identical every tick; the test fails on any difference. Across runtimes++ (server Node against a browser): positions agree within 1 mm and velocities within 1 cm/s per tick, and++ the client reconciles (snaps to the server) past 5 cm. The movement kernel stays plain JavaScript (f64);++ no shared f32 math kernel in M1. The tolerance is written here as a tolerance, never called bit-identity.++2. **Rapier builds.** One pinned version of the deterministic build on both sides: the `-compat` package on++ the server (Node, inlined WASM, no loader), the non-compat package in the browser with the `.wasm` as a++ separate, cached, compressed asset. Physics loads with a dynamic import while the WebSocket ticket is++ fetched, so it is not initial JavaScript and the M6 budget (2.5 MB initial JS) is untouched. A test++ asserts both packages resolve to the same version.++3. **Look input stays absolute.** Each input carries yaw and pitch as absolute angles quantized to int16++ (1/65536 of a turn). Deltas add replay complexity for no M1 gain; revisit only if replay telemetry needs++ them.++4. **Actions are a bitfield of named flags.** The names and bits live in `packages/shared` as a frozen,++ versioned list; adding a flag bumps the protocol version. Compact at 30 Hz, and M2's touch controls map++ onto the same names.++5. **No Rapier snapshots in M1.** Movement state lives in `PlayerMoveState`, not in the engine; prop and++ island rollback are deferred past M1, so the snapshot API is not used and nothing is built for it.++6. **Joints: functional equivalence in M1.** Welds hold rigid, ropes respect their length, springs return to++ rest, each with a test; exact visual equivalence with Havok is refined in M2.++7. **The intent enum lives in `packages/shared`**, the single source for protocol, gameplay and M2 touch;++ `packages/gameplay/src/movement/buttons.ts` re-exports it.++8. **`tryStepMove` gets a hysteresis band.** A contact within ±2 mm of the step threshold keeps the previous++ tick's branch, so a millimetre of contact ambiguity between engines cannot flip step-up or auto-bhop.++9. **The mock resolves a rounded capsule against a box edge** (a real convex sweep, not a padded AABB), with++ the conformance suite's landing heights re-measured.++++## M1 step 4 finding: Rapier shape casts lose precision on very large colliders++++Measured while building the Rapier adapter (M1 steps 4–7). A diagonal `castShape` of the player capsule++against a 400 m-long static box reports contact ~14 mm before the true face, while the same cast against a++20 m box is exact to the f32 epsilon. The returned `time_of_impact` is inconsistent with the returned++contact witnesses in that case. Consequences: determinism scene geometry is kept to sane extents, and the++cross-engine tolerance is a per-tick bound, not bit-identity (decision 1).++## DeletedThe Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegacy.ts`, `legacy_live_rows`,@@@ -1,9 +1,6 @@--import { readFile } from 'node:fs/promises'--import { createRequire } from 'node:module'--import { pathToFileURL } from 'node:url'--import { vec3 } from '@openvibe/shared'++import { vec3, type Vec3 } from '@openvibe/shared'import { afterEach, describe, expect, it } from 'vitest'--import { CollisionLayer, type PhysicsWorld } from '@openvibe/physics'++import { CollisionLayer, type BodyDesc, type PhysicsWorld } from '@openvibe/physics'import { createMockPhysicsWorld, mockCollisionQueries } from '@openvibe/physics/mock'import { Buttons } from './buttons.js'import type { CollisionQueries } from './collision.js'@@@ -34,13 +31,12 @@ import { createMoveState, stepMovement, type MoveInput, type PlayerMoveState } f* state in place through module-level scratch vectors, a test that re-uses* one state object for both runs would "pass" trivially — running two* genuinely separate states is the point.-- * b. **Cross-world, within tolerance.** The same script against the Havok++ * b. **Cross-world, within tolerance.** The same script against the Rapier* adapter and the mock. Position ≤ 1 mm and velocity ≤ 1 cm/s per tick* (ADR-0007 M1 decision 1). This is a TOLERANCE between two engines, not-- * bit-identity, and it is the one that will force tuning of the mock or the-- * adapter when Rapier lands. If the engines genuinely disagree past the-- * tolerance, the tolerance is NOT to be loosened: the case names the first-- * diverging tick and field, and the fix is the engine, not the number.++ * bit-identity. If the engines genuinely disagree past the tolerance, the++ * tolerance is NOT to be loosened: the case names the first diverging tick++ * and field, and the fix is the engine, not the number.* c. **Replay.** Inputs (seq, input) recorded for 600 ticks, state snapshotted* at tick 300, replay 300..600 from the snapshot — equal hashes. This is* the input-replay path (apps/client/src/game/localPlayer.ts and the@@@ -139,13 +135,13 @@ interface Engine {}/**-- * The wasm-backed adapter, loaded from `@openvibe/physics/havok` — the same-- * entry point apps/server uses. It is loaded through a DYNAMIC import from-- * the real path rather than a static one because `@babylonjs/havok` is a-- * dependency of `@openvibe/physics`, not of `@openvibe/gameplay`, and this++ * The wasm-backed adapter, loaded from `@openvibe/physics/rapier` — the same++ * entry point apps/server and apps/client use. It is loaded through a DYNAMIC++ * import rather than a static one because `@dimforge/rapier3d-deterministic-compat`++ * is a dependency of `@openvibe/physics`, not of `@openvibe/gameplay`, and this* package deliberately does not import an engine (ADR-0007 step 6 wants the-- * gameplay kernel engine-free; a static import here would put Babylon back on-- * the gameplay side of the boundary).++ * gameplay kernel engine-free; a static import would put an engine back on the++ * gameplay side of the boundary).** This test is the ONLY case in the file that needs an engine, so when the* load fails the failure is a skip, never a red suite: a missing wasm is an@@@ -154,30 +150,35 @@ interface Engine {*/const notes: string[] = []++/** The loaded engine, kept for the scene corpus below; null skips its cases. */++let rapierEngine: { createWorld: (bodies: BodyDesc[]) => PhysicsWorld } | null = null++/*** Loaded at collection time, not in `beforeAll`: the cross-world `describe`s* below are registered from the engine list, and vitest collects before it* runs any hook, so a `beforeAll` would always see an empty list.++ *++ * Loaded through `@openvibe/physics/rapier`, whose `loadRapier()` owns the++ * dynamic import of the wasm build — the gameplay kernel never imports an++ * engine (ADR-0007 step 6 wants it engine-free). The failure is a skip, never++ * a red suite: a missing wasm is an environment fact, not a determinism defect.*/const engines: Engine[] = []try {-- // Resolved from inside @openvibe/physics, which is where @babylonjs/havok is-- // a declared dependency. Asking for it from this package (which does not-- // depend on it) would be a bare specifier vite cannot resolve — and adding-- // the dependency here would put an engine back on the gameplay side of the-- // boundary ADR-0007 is removing. The package's exports map allows only the-- // bare specifier for the module itself, so resolve the bare name to a real-- // path with the physics package's own require and import that file directly.-- const fromPhysics = createRequire(createRequire(import.meta.url).resolve('@openvibe/physics'))-- const havokEntry = fromPhysics.resolve('@babylonjs/havok')-- const wasmPath = fromPhysics.resolve('@babylonjs/havok/lib/esm/HavokPhysics.wasm')-- const { default: HavokPhysics } = await import(pathToFileURL(havokEntry).href)-- const { createHeadlessHavokWorld } = await import('@openvibe/physics/havok')-- const havok = await HavokPhysics({ wasmBinary: (await readFile(wasmPath)).buffer as ArrayBuffer })++ const { createRapierWorld, loadRapier } = await import('@openvibe/physics/rapier')++ const rapier = await loadRapier()++ rapierEngine = {++ createWorld: (bodies) => {++ const world = createRapierWorld(rapier)++ for (const body of bodies) world.addBody(body)++ live.push(world)++ return world++ },++ }engines.push({-- name: 'HavokWorld',++ name: 'RapierWorld',create: () => {-- const world = createHeadlessHavokWorld(havok)++ const world = createRapierWorld(rapier)buildScriptedGeometry((desc) => world.addBody(desc))live.push(world)return {@@@ -186,7 +187,7 @@ try {}},})-- notes.push('cross-world cases: MockPhysicsWorld vs HavokWorld (wasm loaded)')++ notes.push('cross-world cases: MockPhysicsWorld vs RapierWorld (wasm loaded)')} catch (err) {notes.push('cross-world cases NOT RUN — the wasm engine could not be loaded: ' +@@@ -340,38 +341,38 @@ describe('movement determinism: same runtime', () => {* change of engine rather than a change of game, and it is the one case in* this file that must be re-measured when the engine changes.*-- * ── MEASURED 2026-09-29, MockPhysicsWorld vs HavokWorld 1.3.10 ───────────-- * The scripted run does NOT meet the 1 mm / 1 cm/s tolerance, and the reason-- * is a branch, not floating-point noise — so no tolerance can absorb it. Two-- * discrete events fork, in this order:++ * ── MEASURED 2026-09-29, MockPhysicsWorld vs RapierWorld (deterministic 0.21.0) ──++ * The 600-tick scripted run does NOT meet the 1 mm / 1 cm/s tolerance, and the++ * reason is not floating-point noise:*-- * 1. **tick 102, first contact with the wall.** Both engines are grounded at-- * the same point, but `slideMove`'s blocked→`tryStepMove` probe sweeps the-- * capsule UP by `stepHeight` (0.45) and the wall is 3 m tall, so the probe-- * is a face-on sweep at head height. Havok's convex-cast shape queries-- * report the exact 0.4 m capsule width to its own f32; the mock's-- * conservative AABB expansion reports it a millimetre wider, so the mock-- * stops fraction 0.0 earlier and the two land 15 mm apart on x. Positions-- * that were bit-identical to the tick before stop being.-- * Measured: pos.x 1.507e-2, pos.z 1.998e-2, rising to 5.9e-2 by tick 106.-- * 2. **tick 147, the auto-bhop.** The divergence from (1) is inside the-- * 0.7 m/s `checkGround` release threshold, so one engine leaves the ground-- * a tick before the other, lands a tick later, and the two never rejoin:-- * pos.y 1.953e-3 at tick 147, 6.478e-3 by tick 151. Removing the wall does-- * not remove this one — it simply moves it.-- * 3. By tick 599 the accumulated difference is 460 m: the player has been-- * falling through the world for four hundred ticks, and the last ticks of-- * the script put the two hundreds of metres apart.++ * 1. **The engines disagree by a few tenths of a millimetre per contact.**++ * Rapier's `castShape` reports the wall face ~0.28 mm early (measured: the++ * mock's contact fraction 0.51105 against Rapier's 0.49065 on a sweep that++ * grazes the face at 13:1). On a face-on contact that is invisible; on a++ * sweep nearly PARALLEL to the face a small normal-direction error becomes a++ * large tangential one, so each sliding tick advances a few millimetres++ * further in the mock than in Rapier and the gap accumulates. First++ * divergence: tick 97–100 [pos.x, pos.z].++ * 2. **A discrete branch then flips.** Once the accumulated gap reaches the++ * step-up / auto-bhop thresholds, one engine takes the step or the hop and++ * the other does not, and the runs never rejoin: max drift 4.6e+2 m at tick++ * 599 (the bhop phase from tick 140 leaves one run falling through the++ * world).*-- * That is the honest answer, and it is what step 4's Rapier swap is measured-- * against. The tolerance assertion below is marked `it.fails`, as the brief-- * directs, with the divergence named above and the numbers printed on every-- * run; the tolerance itself is NOT widened to make it pass. The two cases-- * beside it — the same runtime against a second real engine, and the-- * resting-state control — both pass, so what is failing is specifically-- * cross-engine contact geometry, not determinism itself.++ * Consequences, kept honest rather than papered over:++ * - The BIT-EXACT claim (two independently built Rapier worlds) IS met, on this++ * script and on every scene of the corpus below. That is the claim prediction++ * needs (ADR-0007 decision 1, "same runtime").++ * - The 1 mm / 1 cm/s tolerance is a CROSS-RUNTIME bound for the SAME build++ * (decision 1), and it holds there; between the mock and Rapier it holds only++ * where no contact is sustained (flat ground: measured 1.2e-5 m over 90++ * ticks). It is therefore asserted for the flat-ground scene and recorded as++ * `it.fails` for the contact scenes, with the numbers printed on every run.++ * Widening POSITION_TOLERANCE_M or VELOCITY_TOLERANCE_MPS would hide a real++ * engine property, so it is not done.*-- * `engines` is where step 4 adds `createRapierWorld()`.++ * `engines` is where the Rapier adapter is registered; the corpus describes its++ * own geometry per scene.*//** The worst cross-world drift over a full scripted run, and where it starts. */@@@ -510,6 +511,202 @@ describe('movement determinism: cross-world within tolerance', () => {for (const engine of engines) crossWorld(engine)})++// ── b2. the scene corpus (ADR-0007 M1 step 7) ────────────────────────────++++/**++ * The five scenes the M1 definition of done names: flat ground, stairs, a wall++ * slide, a terrain trimesh patch, a prop island. Each is static geometry plus++ * one scripted 90-tick input run, so a scene failure names itself.++ *++ * Two claims are tested per scene:++ *++ * 1. **Two independently built worlds agree bit-for-bit.** Server and client++ * build their own world from the same content and run the same input stream;++ * this asserts the same `stateHash` on every tick, which is exactly++ * "same input stream → same stateHash at the same tick". Green everywhere,++ * the trimesh patch included — this is the claim prediction rests on.++ * 2. **The mock and Rapier stay inside the cross-engine tolerance.** Measured++ * below and printed every run; only flat ground meets it, and the block++ * comment above says why the contact scenes do not.++ */++++const corpusBox = (size: [number, number, number], pos: Vec3): BodyDesc => ({++ shape: { type: 'box', size },++ motion: 'static',++ pos,++ layer: CollisionLayer.Static,++ collidesWith: CollisionLayer.Prop | CollisionLayer.Player,++})++++const corpusFloor = (): BodyDesc => corpusBox([60, 1, 60], vec3(0, -0.5, 0))++++/** Four 0.2 m steps rising from z = 3 — what `tryStepMove` exists for. */++const corpusStairs = (): BodyDesc[] =>++ Array.from({ length: 4 }, (_, i) => corpusBox([6, 0.2, 0.4], vec3(0, 0.1 + i * 0.2, 3 + i * 0.4)))++++/** A 4 m ramp patch of two triangles — the shape the terrain layer builds. */++const corpusTerrain = (): BodyDesc => ({++ shape: {++ type: 'trimesh',++ positions: new Float32Array([-4, 0, 3, 4, 0, 3, 4, 0.4, 7, -4, 0.4, 7]),++ indices: new Uint32Array([0, 1, 2, 0, 2, 3]),++ },++ motion: 'static',++ pos: vec3(0, 0, 0),++ layer: CollisionLayer.Static,++ collidesWith: CollisionLayer.Prop | CollisionLayer.Player,++})++++/**++ * A 3x3 island of boxes. Static, because the mock has no body-body contacts:++ * what a prop island exercises at the seam is its collision silhouettes (box++ * faces, edges and corners), not the stacking the mock cannot model.++ */++const corpusIsland = (): BodyDesc[] => {++ const out: BodyDesc[] = []++ for (let x = -1; x <= 1; x++)++ for (let z = -1; z <= 1; z++) out.push(corpusBox([0.8, 0.8, 0.8], vec3(x * 0.9, 0.4, 6 + z * 0.9)))++ return out++}++++const corpusTicks = (n: number, f: (t: number) => MoveInput): RecordedInput[] =>++ Array.from({ length: n }, (_, t) => ({ seq: t, input: f(t) }))++++const corpusWalk = (_t: number): MoveInput => ({ moveX: 0, moveZ: 1, yaw: 0, pitch: 0, buttons: 0 })++/** Walks into the wall at x = 4 at an angle, so the capsule has to slide along it. */++const corpusIntoWall = (t: number): MoveInput => ({++ moveX: 0,++ moveZ: 1,++ yaw: 0.35,++ pitch: 0,++ buttons: t >= 45 ? Buttons.Sprint : 0,++})++++interface CorpusScene {++ name: string++ bodies: BodyDesc[]++ script: RecordedInput[]++ /** The mock has no triangle queries, so the terrain patch runs on real worlds only. */++ mockExpressible: boolean++}++++const CORPUS: CorpusScene[] = [++ { name: 'flat ground', bodies: [corpusFloor()], script: corpusTicks(90, corpusWalk), mockExpressible: true },++ {++ name: 'stairs',++ bodies: [corpusFloor(), ...corpusStairs()],++ script: corpusTicks(90, corpusWalk),++ mockExpressible: true,++ },++ {++ name: 'wall slide',++ bodies: [corpusFloor(), corpusBox([1, 3, 20], vec3(4, 1.5, 0))],++ script: corpusTicks(90, corpusIntoWall),++ mockExpressible: true,++ },++ {++ name: 'terrain trimesh patch',++ bodies: [corpusFloor(), corpusTerrain()],++ script: corpusTicks(90, corpusWalk),++ mockExpressible: false,++ },++ {++ name: 'prop island',++ bodies: [corpusFloor(), ...corpusIsland()],++ script: corpusTicks(90, corpusWalk),++ mockExpressible: true,++ },++]++++function mockQueries(bodies: BodyDesc[]): CollisionQueries {++ const world = createMockPhysicsWorld()++ for (const body of bodies) world.addBody(body)++ live.push(world)++ return mockCollisionQueries(world, MOVEMENT_MASK)++}++++function engineQueries(scene: CorpusScene): CollisionQueries {++ const world = rapierEngine!.createWorld(scene.bodies)++ return {++ sweepCapsule: (from, to, radius, height) =>++ world.sweepCapsule(from, to, radius, height, MOVEMENT_MASK),++ }++}++++/** Runs one script through two queries, tick by tick, and reports the worst drift. */++function measureScriptPair(++ a: CollisionQueries,++ b: CollisionQueries,++ script: RecordedInput[],++): { position: number; velocity: number; firstBadTick: number } {++ const sa = spawn()++ const sb = spawn()++ let position = 0++ let velocity = 0++ let firstBadTick = -1++ for (let t = 0; t < script.length; t++) {++ stepMovement(sa, script[t]!.input, P, a, DT)++ stepMovement(sb, script[t]!.input, P, b, DT)++ const d = divergence(sa, sb)++ if (d.maxPosition > position) position = d.maxPosition++ if (d.maxVelocity > velocity) velocity = d.maxVelocity++ if (!d.equal && firstBadTick < 0) firstBadTick = t++ }++ return { position, velocity, firstBadTick }++}++++describe('movement determinism: the scene corpus (ADR-0007 M1 step 7)', () => {++ for (const scene of CORPUS) {++ it(`${scene.name}: the same input stream hashes identically in two independently built worlds`, () => {++ expect(rapierEngine, notes.join('; ')).not.toBeNull()++ const a = runScript(engineQueries(scene), scene.script)++ const b = runScript(engineQueries(scene), scene.script)++ for (let t = 0; t < scene.script.length; t++) {++ expect(a[t], `tick ${t}: ${a[t]} vs ${b[t]}`).toBe(b[t])++ }++ })++ }++++ it('measures the mock-vs-Rapier drift for every scene the mock can express', () => {++ expect(rapierEngine, notes.join('; ')).not.toBeNull()++ const lines: string[] = []++ for (const scene of CORPUS) {++ if (!scene.mockExpressible) continue++ const m = measureScriptPair(mockQueries(scene.bodies), engineQueries(scene), scene.script)++ lines.push(++ `${scene.name}: pos ${m.position.toExponential(3)} m, vel ${m.velocity.toExponential(3)} m/s ` +++ `(first tick ${m.firstBadTick})`,++ )++ }++ console.log(`[determinism corpus] ${lines.join(' | ')}`)++ expect(lines.length).toBeGreaterThan(0)++ })++++ it('holds the cross-engine tolerance on flat ground', () => {++ expect(rapierEngine, notes.join('; ')).not.toBeNull()++ const scene = CORPUS[0] as CorpusScene++ const m = measureScriptPair(mockQueries(scene.bodies), engineQueries(scene), scene.script)++ expect(m.position).toBeLessThanOrEqual(POSITION_TOLERANCE_M)++ expect(m.velocity).toBeLessThanOrEqual(VELOCITY_TOLERANCE_MPS)++ })++++ it.fails('holds the cross-engine tolerance on the contact scenes', () => {++ // Stairs, the wall slide and the prop island all exceed it, for the reason++ // measured in the block comment above (a few tenths of a millimetre of++ // contact slop per touch, amplified tangentially by a near-parallel slide,++ // then a discrete branch flip). Inverted so the suite stays green while the++ // claim is visibly unproven; if it ever starts passing, vitest flips this++ // line to FAILING and `it.fails` comes off.++ expect(rapierEngine, notes.join('; ')).not.toBeNull()++ for (const scene of CORPUS) {++ if (!scene.mockExpressible) continue++ const m = measureScriptPair(mockQueries(scene.bodies), engineQueries(scene), scene.script)++ expect(m.position, `${scene.name} position`).toBeLessThanOrEqual(POSITION_TOLERANCE_M)++ expect(m.velocity, `${scene.name} velocity`).toBeLessThanOrEqual(VELOCITY_TOLERANCE_MPS)++ }++ })++})++// ── c. replay ─────────────────────────────────────────────────────────────describe('movement determinism: replay from a snapshot', () => {@@@ -5,15 +5,14 @@"type": "module","exports": {".": "./src/index.ts",-- "./havok": "./src/havok/index.ts",++ "./rapier": "./src/rapier/index.ts","./mock": "./src/mock.ts"},"scripts": {"build": "tsc --build"},"dependencies": {-- "@babylonjs/core": "^9.20.0",-- "@babylonjs/havok": "^1.3.10",++ "@dimforge/rapier3d-deterministic-compat": "0.21.0","@openvibe/shared": "workspace:*"}}@@@ -1,9 +1,6 @@import { readFile } from 'node:fs/promises'--import { createRequire } from 'node:module'import { quat, vec3, type Quat, type Vec3 } from '@openvibe/shared'import { beforeAll, describe, expect, it } from 'vitest'--import HavokPhysics from '@babylonjs/havok'--import { createHeadlessHavokWorld } from './havok/index.js'import {MOCK_GRAVITY,MOCK_SETTLE_ANGULAR,@@@ -11,62 +8,46 @@ import {MOCK_TICK,createMockPhysicsWorld,} from './mock.js'++import { createRapierWorld, loadRapier, type RapierModule } from './rapier/index.js'import { CollisionLayer, type BodyDesc, type PhysicsWorld } from './types.js'/*** Seam conformance.** Every case runs against whatever PhysicsWorld factory is handed to-- * `conformance()`, so a second engine (ADR-0007 decision 2 names Rapier) can be-- * held to exactly the behaviour Havok has today. Nothing in this file may reach-- * around the seam: no Babylon, no Havok, no assumption about internals beyond-- * what `PhysicsWorld` promises.++ * `conformance()`. The real engine is Rapier (ADR-0007 decision 2); the mock is++ * the wasm-free oracle. Nothing in this file may reach around the seam: no++ * engine type, no assumption about internals beyond what `PhysicsWorld`++ * promises.*-- * ── Constants, read off havokWorld.ts rather than the brief ──────────────-- * - gravity (0, -16.5, 0) havokWorld.ts:588-- * - material friction 0.75 / static 0.85 / restitution 0.05 :123-- * - linear damping 0.05, angular 0.6 :131-132-- * - default mass 10 kg :128-- * - settle thresholds 0.05 m/s, 0.15 rad/s :72-73 (squared compare, angular `<=`)++ * ── Constants the adapters are built from ────────────────────────────────++ * - gravity (0, -16.5, 0) rapierWorld.ts RAPIER_GRAVITY++ * - material friction 0.75, restitution 0.05 rapierWorld.ts++ * - linear damping 0.05, angular 0.6 rapierWorld.ts++ * - default mass 10 kg rapierWorld.ts++ * - settle thresholds 0.05 m/s, 0.15 rad/s (squared compare, angular `<=`)** ── Behaviour asserted here that is NOT in the seam doc comment ──────────-- * These are properties of today's Havok adapter, documented so the Rapier port-- * reproduces them rather than trips over them:++ * The Rapier adapter sets these explicitly and this suite holds it to them:*-- * 1. **`step(dt)` does not honour its argument.** `HavokWorld.step` calls-- * `plugin.executeStep(dt, bodies)`, and the Havok plugin resolves the world-- * step time as `this._useDeltaForWorldStep ? delta : this._fixedTimeStep`-- * with `_useDeltaForWorldStep` false by default — so every step() advances a-- * fixed 1/60 s of simulation regardless of the dt passed in. Measured: 30-- * step(1/30) and 30 step(1/15) both yield v.y = -8.14 m/s, while 60-- * step(1/60) yields -16.09. The seam comment says "advances by a fixed dt",-- * which is the *intent*; the caller-side 30 Hz tick is what the game relies-- * on and it is unaffected, but a Rapier adapter that actually honours dt-- * would be ~2× faster per tick at 30 Hz and would change every simulation-- * constant tuned against Havok. Listed for Opus.++ * 1. **`step(dt)` honours its argument.** Rapier's integration parameters take++ * the dt directly, so 30 steps of 1/30 and 60 steps of 1/60 are the same++ * simulated second (the mock agrees; Havok did not, which is why the adapter++ * exposes `stepSeconds` and the gravity cases convert through it).* 2. **Waking or settling a body at the exact threshold.** The code compares-- * `len² > threshold²` for linear (so a body moving at exactly 0.05 m/s is-- * reported *unsettled* by Havok's float storage, though the comparison says-- * settled) and `<=` for angular. The mock implements the comparison as-- * written; this suite therefore probes just under and just over each-- * threshold and does not pin the exact-boundary behaviour.-- * 3. **Raycast filtering is by the body's own `layer`.** `plugin.raycast` is-- * called with `membership: 0xffffffff, collideWith: collidesWith`-- * (havokWorld.ts:415-418), but the shape filter is a membership/collide-- * pair, so a body is hit when `rayMask & body.layer` and `body.layer &-- * body.collidesWith` — the body's `collidesWith` must include its own layer-- * for the pair to pass. Measured: layer Player / collidesWith Static is hit-- * by mask 4 but not by mask 1, while layer Static / collidesWith Prop is hit-- * by mask 1, 2, 3 and 7. The mock filters on `rayMask & body.layer` only,-- * which is a superset — see "what the mock does not model".++ * `len² > threshold²` for linear and `<=` for angular; this suite probes just++ * under and just over each threshold and does not pin exact-boundary float++ * behaviour.++ * 3. **Raycast filtering is by the target's own `layer`.** The query's++ * membership is all-on and the filter is the caller's mask, so a body is hit++ * exactly when `rayMask & body.layer`. The mock filters the same way.* 4. **A welded body resists velocity changes for a few steps.** `setLinearVelocity`* on one half of a weld writes the velocity, but the joint's relative-pose* preservation pulls the pair back over the next couple of steps. Asserted* here as "the pair stays together", not as free sliding.-- * 5. **`isSettled`, `wake` and `applyForce` ignore non-dynamic bodies**-- * (havokWorld.ts:225, :233, :406), and `removeBody` / `removeConstraint` on-- * an unknown id are silent no-ops (:142, :399) while every other body-- * accessor throws `unknown physics body <id>` (:482).++ * 5. **`isSettled`, `wake` and `applyForce` ignore non-dynamic bodies**, and++ * `removeBody` / `removeConstraint` on an unknown id are silent no-ops while++ * every other body accessor throws `unknown physics body <id>`.*/const DT = 1 / 30@@@ -90,20 +71,17 @@ interface Adapter {stepSeconds: number}--/** HavokWorld over a NullEngine scene — the exact headless server code path. */--let havok: unknown++/** RapierWorld over the deterministic wasm build — the exact headless server code path. */++let rapier: RapierModulebeforeAll(async () => {-- const require = createRequire(import.meta.url)-- const wasmPath = require.resolve('@babylonjs/havok/lib/esm/HavokPhysics.wasm')-- havok = await HavokPhysics({ wasmBinary: (await readFile(wasmPath)).buffer as ArrayBuffer })++ rapier = await loadRapier()}, 60_000)--const havokAdapter: Adapter = {-- name: 'HavokWorld',-- create: () => createHeadlessHavokWorld(havok),-- // Measured (header note 1): the plugin ignores the dt passed to step() and-- // advances its own fixed 1/60 s.-- stepSeconds: 1 / 60,++const rapierAdapter: Adapter = {++ name: 'RapierWorld',++ create: () => createRapierWorld(rapier),++ // Rapier honours the dt passed to step(); 30 Hz is the tick the game runs at.++ stepSeconds: DT,}/**@@@ -775,7 +753,10 @@ function conformance(a: Adapter): void {stepFor(w, 60)w.getTransform(a, pa, rot())w.getTransform(b, pb, rot())-- expect(pb.y - pa.y).toBeCloseTo(0.5, 1)++ // "Stays rigid" is the claim worth pinning: the weld keeps the pair's++ // separation at the creation-time offset. (A Rapier fixed joint also++ // allows the island to rotate, so the offset need not stay vertical.)++ expect(Math.hypot(pb.x - pa.x, pb.y - pa.y, pb.z - pa.z)).toBeCloseTo(0.5, 1)expect(pb.z - pa.z).toBeCloseTo(0, 1)close()},@@@ -784,20 +765,10 @@ function conformance(a: Adapter): void {it('removing a weld frees the follower', () => {// What "freed" means at the seam: once the joint is gone the two bodies// are independent, so a velocity change on the follower moves the-- // follower and leaves the driver exactly where it was.-- //-- // It deliberately does NOT assert that the follower then rests at some-- // particular height. On a stacked pair resting on the ground that is-- // engine behaviour and the two engines disagree: the mock has no-- // body-body contacts, so the follower drops straight through back to the-- // floor (0.25) inside this window, while Havok's solver keeps it on the-- // driver and carries it to 1.609. Both are "no longer welded", which is-- // the property that belongs to the seam; a per-engine landing height is-- // pinned separately in each engine branch. Measured: with the pair-- // grounded and the follower kicked to +6 m/s after removal, Havok leaves-- // the driver at 0.250 and the follower at 1.609 after 30 steps; the mock-- // leaves the driver at 0.250 and the follower at 0.250, having already-- // arced up and landed back on the floor.++ // follower and leaves the driver exactly where it was. The follower is++ // kicked HORIZONTALLY so the claim does not depend on either engine's++ // contact solver: under a weld it could not move without dragging the++ // driver, and the mock's missing body-body contacts cannot mask it.const w = open()w.addBody(groundDesc())const a = w.addBody(dynamicBoxDesc(vec3(0, 0.25, 0), 0.5, 10))@@@ -808,17 +779,17 @@ function conformance(a: Adapter): void {const pa = pos()w.getTransform(a, pa, rot())expect(pa.y).toBeCloseTo(0.25, 2)-- w.setLinearVelocity(b, vec3(0, 6, 0))++ w.setLinearVelocity(b, vec3(0, 0, 6))stepFor(w, 30)const pa2 = pos()const pb = pos()w.getTransform(a, pa2, rot())w.getTransform(b, pb, rot())-- // The driver did not move at all, and the follower has left the pose the-- // weld held it in (0.75) — up on Havok, down through the missing contact-- // on the mock. A still-welded follower could not do either.++ // The driver did not move, and the follower slid away — a still-welded++ // follower could not do either.expect(pa2.y).toBeCloseTo(0.25, 2)-- expect(Math.abs(pb.y - 0.75)).toBeGreaterThan(0.1)++ expect(pa2.z).toBeCloseTo(0, 2)++ expect(Math.abs(pb.z)).toBeGreaterThan(0.1)close()})@@@ -1364,11 +1335,10 @@ function conformance(a: Adapter): void {expect(dead).toEqual([])})} else {-- it('a freed follower stays on the driver on Havok’s solver', () => {-- // The Havok half of "removing a weld frees the follower": the contact-- // solver keeps the upper box stacked on the lower one, so after the same-- // 30-step window the shared case finds it up at ~1.6 rather than back-- // down on the floor. Measured: 1.609.++ it('a freed follower lands back on the driver via the contact solver', () => {++ // Rapier, like Havok, has a contact solver: after the follower is kicked++ // vertically off a freed weld it lands back on top of the lower box++ // (measured ~0.75), rather than falling through as the mock does.const w = open()w.addBody(groundDesc())const a = w.addBody(dynamicBoxDesc(vec3(0, 0.25, 0), 0.5, 10))@@@ -1377,16 +1347,17 @@ function conformance(a: Adapter): void {stepFor(w, 60)w.removeConstraint(weld)w.setLinearVelocity(b, vec3(0, 6, 0))++ // 30 steps at 30 Hz is one second: the follower arcs up ~1 m and lands.stepFor(w, 30)const pb = pos()w.getTransform(b, pb, rot())-- expect(pb.y).toBeGreaterThan(0.75)-- expect(pb.y).toBeLessThan(2)++ expect(pb.y).toBeGreaterThan(0.5)++ expect(pb.y).toBeLessThan(1.2)close()})it.skipIf(maybe('step advances'))(-- 'step advances a fixed 1/60 s regardless of the dt passed (Havok plugin)',++ 'step advances exactly the dt it is given',() => {const probe = (dt: number, ticks: number) => {const w = open()@@@ -1397,15 +1368,16 @@ function conformance(a: Adapter): void {close()return v.y}-- // 30 steps at 30 Hz and 30 steps at 15 Hz are the same amount of-- // simulated time, because the plugin uses its own fixed step time.-- expect(probe(1 / 30, 30)).toBeCloseTo(probe(1 / 15, 30), 3)-- // An extra second of simulation is very nearly twice the velocity.-- expect(probe(1 / 60, 120) / probe(1 / 60, 60)).toBeGreaterThan(1.9)-- expect(probe(1 / 60, 120) / probe(1 / 60, 60)).toBeLessThan(2.1)++ // Rapier honours the dt it is given: 30 steps of 1/30 s and 60 steps++ // of 1/60 s are the same simulated second (the damping is applied per++ // step, so they agree to within a few mm/s, not bit-exactly).++ expect(probe(1 / 30, 30)).toBeCloseTo(probe(1 / 60, 60), 1)++ // An extra simulated second is very nearly twice the velocity.++ expect(probe(1 / 30, 60) / probe(1 / 30, 30)).toBeGreaterThan(1.9)++ expect(probe(1 / 30, 60) / probe(1 / 30, 30)).toBeLessThan(2.1)// One simulated second of this gravity: about -16.5 m/s.-- expect(probe(1 / 30, 60)).toBeLessThan(-15)-- expect(probe(1 / 30, 60)).toBeGreaterThan(-17)++ expect(probe(1 / 30, 30)).toBeLessThan(-15)++ expect(probe(1 / 30, 30)).toBeGreaterThan(-17)},)@@@ -1532,5 +1504,5 @@ function conformance(a: Adapter): void {})}--conformance(havokAdapter)++conformance(rapierAdapter)conformance(mockAdapter)ddeleted file mode 100644@@@ -1,604 +0,0 @@--import { NullEngine } from '@babylonjs/core/Engines/nullEngine.js'--import { Quaternion, Vector3 } from '@babylonjs/core/Maths/math.vector.js'--import { TransformNode } from '@babylonjs/core/Meshes/transformNode.js'--import { PhysicsRaycastResult } from '@babylonjs/core/Physics/physicsRaycastResult.js'--import { ShapeCastResult } from '@babylonjs/core/Physics/shapeCastResult.js'--import { HavokPlugin } from '@babylonjs/core/Physics/v2/Plugins/havokPlugin.js'--import {-- PhysicsActivationControl,-- PhysicsMotionType,-- PhysicsPrestepType,--} from '@babylonjs/core/Physics/v2/IPhysicsEnginePlugin.js'--import { PhysicsBody } from '@babylonjs/core/Physics/v2/physicsBody.js'--import type { PhysicsShape } from '@babylonjs/core/Physics/v2/physicsShape.js'--import {-- PhysicsShapeBox,-- PhysicsShapeCapsule,-- PhysicsShapeCylinder,-- PhysicsShapeMesh,-- PhysicsShapeSphere,--} from '@babylonjs/core/Physics/v2/physicsShape.js'--import { Mesh } from '@babylonjs/core/Meshes/mesh.js'--import { VertexData } from '@babylonjs/core/Meshes/mesh.vertexData.js'--import {-- Physics6DoFConstraint,-- PhysicsConstraint,--} from '@babylonjs/core/Physics/v2/physicsConstraint.js'--import {-- PhysicsConstraintAxis,-- PhysicsConstraintMotorType,-- PhysicsConstraintType,--} from '@babylonjs/core/Physics/v2/IPhysicsEnginePlugin.js'--import { Scene } from '@babylonjs/core/scene.js'--import '@babylonjs/core/Physics/joinedPhysicsEngineComponent.js'--import type { Quat, Vec3 } from '@openvibe/shared'--import type {-- BodyDesc,-- BodyId,-- ConstraintDesc,-- ConstraintId,-- ConstraintMotor,-- MotionType,-- PhysicsWorld,-- RayHit,-- ShapeDesc,-- SweepHit,--} from '../types.js'--import { CollisionLayer } from '../types.js'----/**-- * Havok (via Babylon Physics V2) implementation of PhysicsWorld.-- *-- * The same adapter runs headless on the server (NullEngine scene, never-- * rendered) and inside the client's rendered scene. Stepping is always-- * manual through `plugin.executeStep` from the fixed-timestep loop — the-- * scene's own render-driven physics stepping is disabled so simulation can-- * never couple to frame rate.-- */----interface BodyRecord {-- body: PhysicsBody-- node: TransformNode-- shape: PhysicsShape-- motion: MotionType--}----const MOTION_MAP: Record<MotionType, PhysicsMotionType> = {-- dynamic: PhysicsMotionType.DYNAMIC,-- static: PhysicsMotionType.STATIC,-- kinematic: PhysicsMotionType.ANIMATED,--}----const SETTLE_LIN_SQ = 0.05 * 0.05--const SETTLE_ANG_SQ = 0.15 * 0.15----export class HavokWorld implements PhysicsWorld {-- private nextId: BodyId = 1-- private nextConstraintId: ConstraintId = 1-- private readonly bodies = new Map<BodyId, BodyRecord>()-- private readonly constraints = new Map<-- ConstraintId,-- { constraint: PhysicsConstraint; type: ConstraintDesc['type'] }-- >()-- private readonly bodyIds = new WeakMap<PhysicsBody, BodyId>()-- private bodyList: PhysicsBody[] = []-- private bodyListDirty = false---- // Scratch objects — adapter methods are not re-entrant.-- private readonly _v1 = new Vector3()-- private readonly _v2 = new Vector3()-- private readonly _q1 = new Quaternion()-- private readonly _rayResult = new PhysicsRaycastResult()-- private readonly _castInput = new ShapeCastResult()-- private readonly _castHit = new ShapeCastResult()-- private readonly sweepShapes = new Map<string, PhysicsShapeCapsule>()---- constructor(-- private readonly scene: Scene,-- private readonly plugin: HavokPlugin,-- private readonly ownsScene: boolean,-- ) {}---- step(dt: number): void {-- if (this.bodyListDirty) {-- this.bodyList = [...this.bodies.values()].map((r) => r.body)-- this.bodyListDirty = false-- }-- this.plugin.executeStep(dt, this.bodyList)-- }---- addBody(desc: BodyDesc): BodyId {-- const id = this.nextId++-- const node = new TransformNode(`body:${id}`, this.scene)-- node.position.set(desc.pos.x, desc.pos.y, desc.pos.z)-- node.rotationQuaternion = desc.rot-- ? new Quaternion(desc.rot.x, desc.rot.y, desc.rot.z, desc.rot.w)-- : Quaternion.Identity()---- const shape = this.makeShape(desc.shape)-- shape.filterMembershipMask = desc.layer-- shape.filterCollideMask = desc.collidesWith-- // Grippy, dead-on-impact surfaces: Source props bite the ground instead-- // of gliding (Havok defaults are ice-like) and barely bounce.-- shape.material = { friction: 0.75, staticFriction: 0.85, restitution: 0.05 }---- const body = new PhysicsBody(node, MOTION_MAP[desc.motion], false, this.scene)-- body.shape = shape-- if (desc.motion === 'dynamic') {-- body.setMassProperties({ mass: desc.massKg ?? 10 })-- // Light linear + strong angular damping kill the floaty drift and-- // endless micro-rolling that read as "low gravity".-- body.setLinearDamping(0.05)-- body.setAngularDamping(0.6)-- }---- this.bodies.set(id, { body, node, shape, motion: desc.motion })-- this.bodyIds.set(body, id)-- this.bodyListDirty = true-- return id-- }---- removeBody(id: BodyId): void {-- const rec = this.bodies.get(id)-- if (!rec) return-- rec.body.dispose()-- rec.shape.dispose()-- rec.node.dispose()-- this.bodies.delete(id)-- this.bodyListDirty = true-- }---- getTransform(id: BodyId, outPos: Vec3, outRot: Quat): void {-- const rec = this.mustGet(id)-- const p = rec.node.position-- outPos.x = p.x-- outPos.y = p.y-- outPos.z = p.z-- const q = rec.node.rotationQuaternion-- if (q) {-- outRot.x = q.x-- outRot.y = q.y-- outRot.z = q.z-- outRot.w = q.w-- }-- }---- setTransform(id: BodyId, pos: Vec3, rot?: Quat): void {-- const rec = this.mustGet(id)-- rec.node.position.set(pos.x, pos.y, pos.z)-- if (rot && rec.node.rotationQuaternion) {-- rec.node.rotationQuaternion.set(rot.x, rot.y, rot.z, rot.w)-- }-- if (rec.motion === 'kinematic') {-- // ANIMATED bodies chase the node target with computed velocity so they-- // push dynamic bodies convincingly.-- this._v1.set(pos.x, pos.y, pos.z)-- const q = rec.node.rotationQuaternion ?? Quaternion.Identity()-- rec.body.setTargetTransform(this._v1, q)-- } else {-- // CRITICAL: with the default prestep (DISABLED),-- // setPhysicsBodyTransformation is a silent no-op — static mirror-- // bodies would never actually move. Flip to TELEPORT for the call.-- rec.body.setPrestepType(PhysicsPrestepType.TELEPORT)-- this.plugin.setPhysicsBodyTransformation(rec.body, rec.node)-- rec.body.setPrestepType(PhysicsPrestepType.DISABLED)-- this.wake(id)-- }-- }---- setMotionType(id: BodyId, motion: MotionType): void {-- const rec = this.mustGet(id)-- if (rec.motion === motion) return-- rec.body.setMotionType(MOTION_MAP[motion])-- rec.motion = motion-- if (motion === 'dynamic') {-- this.wake(id)-- }-- }---- getLinearVelocity(id: BodyId, out: Vec3): void {-- this.mustGet(id).body.getLinearVelocityToRef(this._v1)-- out.x = this._v1.x-- out.y = this._v1.y-- out.z = this._v1.z-- }---- setLinearVelocity(id: BodyId, v: Vec3): void {-- this._v1.set(v.x, v.y, v.z)-- this.mustGet(id).body.setLinearVelocity(this._v1)-- }---- getAngularVelocity(id: BodyId, out: Vec3): void {-- this.mustGet(id).body.getAngularVelocityToRef(this._v1)-- out.x = this._v1.x-- out.y = this._v1.y-- out.z = this._v1.z-- }---- setAngularVelocity(id: BodyId, v: Vec3): void {-- this._v1.set(v.x, v.y, v.z)-- this.mustGet(id).body.setAngularVelocity(this._v1)-- }---- isSettled(id: BodyId): boolean {-- const rec = this.mustGet(id)-- if (rec.motion !== 'dynamic') return true-- rec.body.getLinearVelocityToRef(this._v1)-- if (this._v1.lengthSquared() > SETTLE_LIN_SQ) return false-- rec.body.getAngularVelocityToRef(this._v1)-- return this._v1.lengthSquared() <= SETTLE_ANG_SQ-- }---- wake(id: BodyId): void {-- const rec = this.mustGet(id)-- if (rec.motion !== 'dynamic') return-- // Toggling activation control is the portable way to wake a Havok body-- // without disturbing its velocities.-- this.plugin.setActivationControl(rec.body, PhysicsActivationControl.ALWAYS_ACTIVE)-- this.plugin.setActivationControl(rec.body, PhysicsActivationControl.SIMULATION_CONTROLLED)-- }---- addConstraint(desc: ConstraintDesc): ConstraintId {-- const recA = this.mustGet(desc.bodyA)-- const recB = this.mustGet(desc.bodyB)-- const constraint = this.buildConstraint(desc, recA, recB)-- recA.body.addConstraint(recB.body, constraint)-- // Motors must be configured AFTER the joint exists in the engine.-- if ((desc.type === 'hinge' || desc.type === 'slider') && desc.motor) {-- const axis =-- desc.type === 'hinge' ? PhysicsConstraintAxis.ANGULAR_X : PhysicsConstraintAxis.LINEAR_X-- const c = constraint as Physics6DoFConstraint-- c.setAxisMotorType(axis, PhysicsConstraintMotorType.VELOCITY)-- c.setAxisMotorTarget(axis, desc.motor.targetVelocity)-- c.setAxisMotorMaxForce(axis, desc.motor.maxForce)-- }-- if (desc.type === 'hinge' && desc.friction !== undefined) {-- ;(constraint as Physics6DoFConstraint).setAxisFriction(-- PhysicsConstraintAxis.ANGULAR_X,-- desc.friction,-- )-- }-- const id = this.nextConstraintId++-- this.constraints.set(id, { constraint, type: desc.type })-- if (recA.motion === 'dynamic') this.wake(desc.bodyA)-- if (recB.motion === 'dynamic') this.wake(desc.bodyB)-- return id-- }---- private buildConstraint(-- desc: ConstraintDesc,-- recA: BodyRecord,-- recB: BodyRecord,-- ): PhysicsConstraint {-- switch (desc.type) {-- case 'weld': {-- const rotA = recA.node.rotationQuaternion ?? Quaternion.Identity()-- const rotB = recB.node.rotationQuaternion ?? Quaternion.Identity()-- // Anchor frames preserving the CURRENT relative pose:-- // pivotA = B's origin in A's local space; axes = B's basis in A's space.-- const invRotA = rotA.conjugate()-- const relRot = invRotA.multiply(rotB)-- const pivotA = recB.node.position-- .subtract(recA.node.position)-- .applyRotationQuaternion(invRotA)-- const axisA = new Vector3(1, 0, 0).applyRotationQuaternion(relRot)-- const perpA = new Vector3(0, 1, 0).applyRotationQuaternion(relRot)-- return new PhysicsConstraint(-- PhysicsConstraintType.LOCK,-- {-- pivotA,-- pivotB: Vector3.Zero(),-- axisA,-- axisB: new Vector3(1, 0, 0),-- perpAxisA: perpA,-- perpAxisB: new Vector3(0, 1, 0),-- collision: false,-- },-- this.scene,-- )-- }-- case 'rope':-- // A pure max-distance tether: slack below `length`, taut at it.-- // The pair keeps colliding by default — a crate on a rope bonks-- // the post — unless a rigid joint already links them.-- return new PhysicsConstraint(-- PhysicsConstraintType.DISTANCE,-- {-- pivotA: toBjs(desc.anchorA),-- pivotB: toBjs(desc.anchorB),-- maxDistance: desc.length,-- collision: desc.collision ?? true,-- },-- this.scene,-- )-- case 'spring':-- // Soft equality at restLength: force = stiffness·error − damping·vel.-- // (Built as the 6DoF SpringConstraint does, plus the collision flag.)-- return new Physics6DoFConstraint(-- {-- pivotA: toBjs(desc.anchorA),-- pivotB: toBjs(desc.anchorB),-- axisA: new Vector3(1, 0, 0),-- axisB: new Vector3(1, 0, 0),-- collision: desc.collision ?? true,-- },-- [-- {-- axis: PhysicsConstraintAxis.LINEAR_DISTANCE,-- minLimit: desc.restLength,-- maxLimit: desc.restLength,-- stiffness: desc.stiffness,-- damping: desc.damping,-- },-- ],-- this.scene,-- )-- case 'hinge':-- case 'slider': {-- const frames = jointFrames(desc.axisA, desc.axisB, recA, recB)-- const limits: { axis: PhysicsConstraintAxis; minLimit: number; maxLimit: number }[] = []-- const lock = (axis: PhysicsConstraintAxis) =>-- limits.push({ axis, minLimit: 0, maxLimit: 0 })-- if (desc.type === 'hinge') {-- lock(PhysicsConstraintAxis.LINEAR_X)-- lock(PhysicsConstraintAxis.LINEAR_Y)-- lock(PhysicsConstraintAxis.LINEAR_Z)-- lock(PhysicsConstraintAxis.ANGULAR_Y)-- lock(PhysicsConstraintAxis.ANGULAR_Z)-- if (desc.limits) {-- limits.push({-- axis: PhysicsConstraintAxis.ANGULAR_X,-- minLimit: desc.limits.min,-- maxLimit: desc.limits.max,-- })-- }-- } else {-- lock(PhysicsConstraintAxis.LINEAR_Y)-- lock(PhysicsConstraintAxis.LINEAR_Z)-- lock(PhysicsConstraintAxis.ANGULAR_X)-- lock(PhysicsConstraintAxis.ANGULAR_Y)-- lock(PhysicsConstraintAxis.ANGULAR_Z)-- if (desc.limits) {-- limits.push({-- axis: PhysicsConstraintAxis.LINEAR_X,-- minLimit: desc.limits.min,-- maxLimit: desc.limits.max,-- })-- }-- }-- return new Physics6DoFConstraint(-- {-- pivotA: toBjs(desc.anchorA),-- pivotB: toBjs(desc.anchorB),-- axisA: frames.axisA,-- axisB: frames.axisB,-- perpAxisA: frames.perpA,-- perpAxisB: frames.perpB,-- collision: false,-- },-- limits,-- this.scene,-- )-- }-- }-- }---- setConstraintMotor(id: ConstraintId, motor: ConstraintMotor): void {-- const rec = this.constraints.get(id)-- if (!rec || (rec.type !== 'hinge' && rec.type !== 'slider')) return-- const axis =-- rec.type === 'hinge' ? PhysicsConstraintAxis.ANGULAR_X : PhysicsConstraintAxis.LINEAR_X-- const c = rec.constraint as Physics6DoFConstraint-- c.setAxisMotorType(axis, PhysicsConstraintMotorType.VELOCITY)-- c.setAxisMotorTarget(axis, motor.targetVelocity)-- c.setAxisMotorMaxForce(axis, motor.maxForce)-- }---- removeConstraint(id: ConstraintId): void {-- const rec = this.constraints.get(id)-- if (!rec) return-- rec.constraint.dispose()-- this.constraints.delete(id)-- }---- applyForce(id: BodyId, force: Vec3): void {-- const rec = this.mustGet(id)-- if (rec.motion !== 'dynamic') return-- this._v1.set(force.x, force.y, force.z)-- this._v2.copyFrom(rec.node.position)-- rec.body.applyForce(this._v1, this._v2)-- }---- raycast(from: Vec3, to: Vec3, collidesWith: number): RayHit | null {-- this._v1.set(from.x, from.y, from.z)-- this._v2.set(to.x, to.y, to.z)-- this.plugin.raycast(this._v1, this._v2, this._rayResult, {-- membership: 0xffffffff,-- collideWith: collidesWith,-- })-- const r = this._rayResult-- if (!r.hasHit || !r.body) return null-- const bodyId = this.bodyIds.get(r.body)-- if (bodyId === undefined) return null-- return {-- bodyId,-- point: { x: r.hitPointWorld.x, y: r.hitPointWorld.y, z: r.hitPointWorld.z },-- normal: { x: r.hitNormalWorld.x, y: r.hitNormalWorld.y, z: r.hitNormalWorld.z },-- fraction: r.hitDistance / Math.max(this._v2.subtract(this._v1).length(), 1e-9),-- }-- }---- sweepCapsule(-- from: Vec3,-- to: Vec3,-- radius: number,-- height: number,-- collidesWith: number,-- exclude?: BodyId,-- ): SweepHit | null {-- const shape = this.getSweepShape(radius, height, collidesWith)-- this._v1.set(from.x, from.y, from.z)-- this._v2.set(to.x, to.y, to.z)-- this._q1.set(0, 0, 0, 1)-- // ignoreBody lets a player's movement sweep collide with the Player-- // layer without hitting their own kinematic body.-- const ignoreBody = exclude === undefined ? undefined : this.bodies.get(exclude)?.body-- this.plugin.shapeCast(-- {-- shape,-- rotation: this._q1,-- startPosition: this._v1,-- endPosition: this._v2,-- shouldHitTriggers: false,-- ...(ignoreBody ? { ignoreBody } : {}),-- },-- this._castInput,-- this._castHit,-- )-- if (!this._castHit.hasHit) return null-- const n = this._castHit.hitNormal-- const p = this._castHit.hitPoint-- return {-- fraction: this._castHit.hitFraction,-- normal: { x: n.x, y: n.y, z: n.z },-- point: { x: p.x, y: p.y, z: p.z },-- }-- }---- dispose(): void {-- for (const id of [...this.constraints.keys()]) this.removeConstraint(id)-- for (const id of [...this.bodies.keys()]) this.removeBody(id)-- for (const shape of this.sweepShapes.values()) shape.dispose()-- this.sweepShapes.clear()-- if (this.ownsScene) {-- const engine = this.scene.getEngine()-- this.scene.dispose()-- engine.dispose()-- }-- }---- private mustGet(id: BodyId): BodyRecord {-- const rec = this.bodies.get(id)-- if (!rec) throw new Error(`unknown physics body ${id}`)-- return rec-- }---- private makeShape(desc: ShapeDesc): PhysicsShape {-- switch (desc.type) {-- case 'box':-- return new PhysicsShapeBox(-- Vector3.Zero(),-- Quaternion.Identity(),-- new Vector3(desc.size[0], desc.size[1], desc.size[2]),-- this.scene,-- )-- case 'cylinder':-- return new PhysicsShapeCylinder(-- new Vector3(0, -desc.height / 2, 0),-- new Vector3(0, desc.height / 2, 0),-- desc.radius,-- this.scene,-- )-- case 'sphere':-- return new PhysicsShapeSphere(Vector3.Zero(), desc.radius, this.scene)-- case 'capsule': {-- const half = Math.max(desc.height / 2 - desc.radius, 0.01)-- return new PhysicsShapeCapsule(-- new Vector3(0, -half, 0),-- new Vector3(0, half, 0),-- desc.radius,-- this.scene,-- )-- }-- case 'trimesh': {-- // Build a transient mesh purely as the geometry source for the-- // Havok mesh shape; it never renders.-- const mesh = new Mesh(`trimesh-src-${this.nextId}`, this.scene)-- const vd = new VertexData()-- vd.positions = desc.positions-- vd.indices = desc.indices-- vd.applyToMesh(mesh)-- mesh.isVisible = false-- const shape = new PhysicsShapeMesh(mesh, this.scene)-- mesh.dispose()-- return shape-- }-- }-- }---- private getSweepShape(radius: number, height: number, collidesWith: number): PhysicsShapeCapsule {-- const key = `${radius}:${height}:${collidesWith}`-- let shape = this.sweepShapes.get(key)-- if (!shape) {-- const half = Math.max(height / 2 - radius, 0.01)-- shape = new PhysicsShapeCapsule(-- new Vector3(0, -half, 0),-- new Vector3(0, half, 0),-- radius,-- this.scene,-- )-- shape.filterMembershipMask = CollisionLayer.Player-- shape.filterCollideMask = collidesWith-- this.sweepShapes.set(key, shape)-- }-- return shape-- }--}----function toBjs(v: Vec3): Vector3 {-- return new Vector3(v.x, v.y, v.z)--}----/**-- * Perpendicular joint frames that make the bodies' CURRENT pose the joint's-- * zero: perpA is an arbitrary unit vector ⊥ axisA (in A local space) and-- * perpB is its image through the current world alignment, re-orthogonalized-- * against axisB. Without this, hinge/slider limits and motor angles would-- * be measured from an arbitrary datum instead of "where you built it".-- */--function jointFrames(-- axisALocal: Vec3,-- axisBLocal: Vec3,-- recA: BodyRecord,-- recB: BodyRecord,--): { axisA: Vector3; axisB: Vector3; perpA: Vector3; perpB: Vector3 } {-- const axisA = toBjs(axisALocal).normalize()-- const axisB = toBjs(axisBLocal).normalize()-- // Arbitrary but stable perpendicular to axisA.-- const seed = Math.abs(axisA.y) < 0.9 ? new Vector3(0, 1, 0) : new Vector3(1, 0, 0)-- const perpA = Vector3.Cross(axisA, seed).normalize()-- const rotA = recA.node.rotationQuaternion ?? Quaternion.Identity()-- const rotB = recB.node.rotationQuaternion ?? Quaternion.Identity()-- const worldPerpA = perpA.applyRotationQuaternion(rotA)-- const perpBRaw = worldPerpA.applyRotationQuaternion(rotB.conjugate())-- // Project out any axisB component (axes may not be perfectly aligned in-- // world space when the player links two tilted props).-- const perpB = perpBRaw.subtract(axisB.scale(Vector3.Dot(axisB, perpBRaw)))-- if (perpB.lengthSquared() < 1e-8) {-- const seedB = Math.abs(axisB.y) < 0.9 ? new Vector3(0, 1, 0) : new Vector3(1, 0, 0)-- return { axisA, axisB, perpA, perpB: Vector3.Cross(axisB, seedB).normalize() }-- }-- return { axisA, axisB, perpA, perpB: perpB.normalize() }--}----function setupWorld(scene: Scene, havok: unknown, ownsScene: boolean): HavokWorld {-- const plugin = new HavokPlugin(false, havok)-- // Source-engine gravity (sv_gravity 600 ≈ 15.24 m/s²) — real-world 9.81-- // reads floaty at game scale.-- scene.enablePhysics(new Vector3(0, -16.5, 0), plugin)-- // All stepping goes through HavokWorld.step(); never the render loop.-- scene.physicsEnabled = false-- return new HavokWorld(scene, plugin, ownsScene)--}----/** Server-side world: owns a NullEngine scene that is never rendered. */--export function createHeadlessHavokWorld(havok: unknown): HavokWorld {-- const engine = new NullEngine()-- const scene = new Scene(engine)-- return setupWorld(scene, havok, true)--}----/** Client-side world sharing the rendered scene (bodies stay invisible; rendering reads game state, not physics nodes). */--export function createHavokWorldForScene(scene: Scene, havok: unknown): HavokWorld {-- return setupWorld(scene, havok, false)--}ddeleted file mode 100644@@@ -1 +0,0 @@--export { HavokWorld, createHavokWorldForScene, createHeadlessHavokWorld } from './havokWorld.js'@@@ -1 +1,2 @@export * from './types.js'++export * from './rapier/index.js'@@@ -649,14 +649,21 @@ function rayPlane(/*** Sweeps a vertical capsule (centre segment of half-length `half`, radius-- * `radius`) against an AABB. The Minkowski sum of a vertical capsule and an-- * AABB is not an AABB, so this inflates the box by `radius` in x/z and by-- * `half + radius` in y: conservative at the corners, exact on every face. For-- * an axis-aligned world — all the mock models — that is the whole story.++ * `radius`) against an AABB, as a real convex sweep (ADR-0007 M1 decision 9).*-- * Havok reports the contact point on the *surface*, not at the capsule centre-- * (measured: a sweep onto ground at y = 0 returns y = 0 exactly), so the point-- * is walked back onto the inflated face along −normal.++ * The capsule touches the box when the distance between the centre SEGMENT and++ * the box equals `radius`. Because an axis-aligned box is a product of intervals,++ * that squared distance separates per axis: the horizontal gap from the segment's++ * axis to the box's x/z rectangle, plus the vertical gap between the capsule's++ * segment interval and the box's y interval. As a function of the sweep parameter++ * it is convex, so the first contact is the first root of `distance² = radius²`:++ * a fixed-iteration ternary search for the minimum, then a bisection back to the++ * crossing. Both are deterministic to the bit, which a padded-AABB approximation++ * was not — it rounded the box's edges and corners by up to `radius·(√2−1)`.++ *++ * A sweep that *starts* already overlapping the box (distance < radius) has no++ * first contact to report and returns null, exactly as the analytic-plane path++ * below does; reporting one would block every upward sweep out of a resting hull.*/function sweepCapsuleAabb(from: Vec3,@@@ -666,63 +673,68 @@ function sweepCapsuleAabb(center: Vec3,boxHalf: Vec3,): SweepHit | null {-- const lo = [-- center.x - boxHalf.x - radius,-- center.y - boxHalf.y - half - radius,-- center.z - boxHalf.z - radius,-- ]-- const hi = [-- center.x + boxHalf.x + radius,-- center.y + boxHalf.y + half + radius,-- center.z + boxHalf.z + radius,-- ]-- const o = [from.x, from.y, from.z]-- const dd = [d.x, d.y, d.z]-- let tMin = -Infinity-- let tMax = Infinity-- let axis = 0-- let sign = -1-- for (let a = 0; a < 3; a++) {-- if (Math.abs(dd[a]!) < 1e-12) {-- if (o[a]! < lo[a]! || o[a]! > hi[a]!) return null-- continue-- }-- const inv = 1 / dd[a]!-- let t1 = (lo[a]! - o[a]!) * inv-- let t2 = (hi[a]! - o[a]!) * inv-- let s = -1-- if (t1 > t2) {-- const tmp = t1-- t1 = t2-- t2 = tmp-- s = 1-- }-- if (t1 > tMin) {-- tMin = t1-- axis = a-- sign = s-- }-- if (t2 < tMax) tMax = t2-- if (tMin > tMax) return null-- }-- // Already overlapping at t = 0: the capsule starts inside, which is not a-- // contact the caller can act on.-- if (tMin <= 0 || tMin > 1) return null-- const normal = vec3(0, 0, 0)-- if (axis === 0) normal.x = sign-- else if (axis === 1) normal.y = sign-- else normal.z = sign-- // Walk the centre back onto the true surface: distance is the capsule's-- // support along the normal, `radius` on the sides and `half + radius` on-- // the caps.-- const support = axis === 1 ? half + radius : radius-- const centre = vec3(from.x + d.x * tMin, from.y + d.y * tMin, from.z + d.z * tMin)-- const point = vec3(-- centre.x - normal.x * support,-- centre.y - normal.y * support,-- centre.z - normal.z * support,-- )-- return { fraction: tMin, normal, point }++ const ax = center.x - boxHalf.x++ const bx = center.x + boxHalf.x++ const ay = center.y - boxHalf.y++ const by = center.y + boxHalf.y++ const az = center.z - boxHalf.z++ const bz = center.z + boxHalf.z++++ /** Squared distance between the swept segment and the box at parameter t. */++ const distanceSq = (t: number): number => {++ const x = from.x + d.x * t++ const y = from.y + d.y * t++ const z = from.z + d.z * t++ const dx = x < ax ? ax - x : x > bx ? x - bx : 0++ const dz = z < az ? az - z : z > bz ? z - bz : 0++ const top = y + half++ const bot = y - half++ const dy = top < ay ? ay - top : bot > by ? bot - by : 0++ return dx * dx + dy * dy + dz * dz++ }++++ const r2 = radius * radius++ if (distanceSq(0) < r2) return null++++ // Minimum over the segment, by ternary search (convex, so this is exact to the++ // iteration count; the count is fixed so the result is reproducible).++ let lo = 0++ let hi = 1++ for (let i = 0; i < 80; i++) {++ const m1 = lo + (hi - lo) / 3++ const m2 = hi - (hi - lo) / 3++ if (distanceSq(m1) < distanceSq(m2)) hi = m2++ else lo = m1++ }++ const tMin = (lo + hi) / 2++ if (distanceSq(tMin) > r2) return null++++ // First crossing: bisect [0, tMin] keeping the distance above `radius` on the++ // left and at or below it on the right.++ let left = 0++ let right = tMin++ for (let i = 0; i < 60; i++) {++ const mid = (left + right) / 2++ if (distanceSq(mid) > r2) left = mid++ else right = mid++ }++ const t = right++++ const x = from.x + d.x * t++ const y = from.y + d.y * t++ const z = from.z + d.z * t++ // Closest point on the segment: its vertical position within the capsule's own++ // interval that is nearest the box.++ const segY = y + half < ay ? y + half : y - half > by ? y - half : Math.min(Math.max(y, ay), by)++ const px = Math.min(Math.max(x, ax), bx)++ const py = Math.min(Math.max(segY, ay), by)++ const pz = Math.min(Math.max(z, az), bz)++ const nx = x - px++ const ny = segY - py++ const nz = z - pz++ const len = Math.sqrt(nx * nx + ny * ny + nz * nz)++ const normal = len > 1e-12 ? vec3(nx / len, ny / len, nz / len) : vec3(0, 1, 0)++ return { fraction: t, normal, point: vec3(px, py, pz) }}/**nnew file mode 100644@@@ -0,0 +1,12 @@++export {++ RAPIER_ANGULAR_DAMPING,++ RAPIER_DEFAULT_MASS_KG,++ RAPIER_FRICTION,++ RAPIER_GRAVITY,++ RAPIER_LINEAR_DAMPING,++ RAPIER_RESTITUTION,++ RapierWorld,++ createRapierWorld,++ type RapierModule,++} from './rapierWorld.js'++export { loadRapier } from './load.js'nnew file mode 100644@@@ -0,0 +1,16 @@++import type { RapierModule } from './rapierWorld.js'++++/**++ * Loads and initializes the pinned deterministic Rapier build (ADR-0007++ * decision 2): `@dimforge/rapier3d-deterministic-compat`, whose wasm is inlined++ * as base64 so a single entry works in Node and the browser with no asset++ * plumbing and no loader.++ *++ * The import is dynamic on purpose: physics is loaded while the WebSocket++ * ticket is fetched, so it is never part of the initial JavaScript.++ */++export async function loadRapier(): Promise<RapierModule> {++ const R = (await import('@dimforge/rapier3d-deterministic-compat')) as unknown as RapierModule++ await R.init()++ return R++}nnew file mode 100644@@@ -0,0 +1,478 @@++import type * as Rapier from '@dimforge/rapier3d-deterministic-compat'++import type { Quat, Vec3 } from '@openvibe/shared'++import type {++ BodyDesc,++ BodyId,++ ConstraintDesc,++ ConstraintId,++ ConstraintMotor,++ MotionType,++ PhysicsWorld,++ RayHit,++ ShapeDesc,++ SweepHit,++} from '../types.js'++import { CollisionLayer } from '../types.js'++++/**++ * Rapier implementation of PhysicsWorld (ADR-0007 decision 2).++ *++ * The same adapter runs headless on the server and inside the client's rendered++ * scene — Rapier has no scene or engine indirection, so the two Havok factories++ * (a NullEngine scene and a scene-sharing world) collapse to one. Stepping is++ * manual and honours the dt it is given; there is no render-driven coupling.++ *++ * The module is injected rather than imported at runtime: the physics package++ * declares the deterministic `-compat` build, but the seam stays free of it in++ * type position only, so no wasm or engine code is reached through this file.++ */++++/** The Rapier module surface the adapter drives. */++export type RapierModule = typeof Rapier++++interface BodyRecord {++ body: Rapier.RigidBody++ collider: Rapier.Collider++ motion: MotionType++}++++interface ConstraintRecord {++ joint: Rapier.ImpulseJoint++ type: ConstraintDesc['type']++}++++/**++ * The unit-joint surface (revolute/prismatic) used to drive motors and limits.++ * Declared structurally so the adapter never has to name a class that differs++ * between the compat and non-compat builds.++ */++interface MotorJoint {++ setLimits(min: number, max: number): void++ configureMotorModel(model: number): void++ setMotorMaxForce(maxForce: number): void++ configureMotorVelocity(targetVelocity: number, factor: number): void++}++++/** Grippy, dead-on-impact surfaces — the same material Havok used. */++export const RAPIER_FRICTION = 0.75++export const RAPIER_RESTITUTION = 0.05++export const RAPIER_LINEAR_DAMPING = 0.05++export const RAPIER_ANGULAR_DAMPING = 0.6++export const RAPIER_DEFAULT_MASS_KG = 10++export const RAPIER_GRAVITY = -16.5++++const SETTLE_LIN_SQ = 0.05 * 0.05++const SETTLE_ANG_SQ = 0.15 * 0.15++++const IDENTITY: Rapier.Rotation = { x: 0, y: 0, z: 0, w: 1 }++++/** Rapier interaction groups: membership in the high 16 bits, filter in the low. */++function groups(membership: number, filter: number): number {++ return ((membership & 0xffff) << 16) | (filter & 0xffff)++}++++function motionType(R: RapierModule, motion: MotionType): Rapier.RigidBodyType {++ switch (motion) {++ case 'dynamic':++ return R.RigidBodyType.Dynamic++ case 'static':++ return R.RigidBodyType.Fixed++ case 'kinematic':++ return R.RigidBodyType.KinematicPositionBased++ }++}++++function bodyDesc(R: RapierModule, desc: BodyDesc): Rapier.RigidBodyDesc {++ const rb =++ desc.motion === 'dynamic'++ ? R.RigidBodyDesc.dynamic()++ : desc.motion === 'static'++ ? R.RigidBodyDesc.fixed()++ : R.RigidBodyDesc.kinematicPositionBased()++ rb.setTranslation(desc.pos.x, desc.pos.y, desc.pos.z)++ if (desc.rot) rb.setRotation(desc.rot)++ if (desc.motion === 'dynamic') {++ rb.setLinearDamping(RAPIER_LINEAR_DAMPING)++ rb.setAngularDamping(RAPIER_ANGULAR_DAMPING)++ }++ return rb++}++++function colliderDesc(R: RapierModule, shape: ShapeDesc): Rapier.ColliderDesc {++ switch (shape.type) {++ case 'box':++ return R.ColliderDesc.cuboid(shape.size[0] / 2, shape.size[1] / 2, shape.size[2] / 2)++ case 'cylinder':++ return R.ColliderDesc.cylinder(shape.height / 2, shape.radius)++ case 'sphere':++ return R.ColliderDesc.ball(shape.radius)++ case 'capsule':++ // A Rapier capsule's local axis is +Y, as Havok's is; `height` is the++ // total height including caps, so the cylindrical segment is height/2 -++ // radius either side of centre.++ return R.ColliderDesc.capsule(Math.max(shape.height / 2 - shape.radius, 0.01), shape.radius)++ case 'trimesh':++ return R.ColliderDesc.trimesh(shape.positions, shape.indices)++ }++}++++// ── small quaternion helpers for the joint frames ────────────────────────++++function qConj(q: Quat): Quat {++ return { x: -q.x, y: -q.y, z: -q.z, w: q.w }++}++++function qMul(a: Quat, b: Quat): Quat {++ return {++ x: a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,++ y: a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,++ z: a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,++ w: a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z,++ }++}++++function qRotate(q: Quat, v: Vec3): Vec3 {++ const ix = q.w * v.x + q.y * v.z - q.z * v.y++ const iy = q.w * v.y + q.z * v.x - q.x * v.z++ const iz = q.w * v.z + q.x * v.y - q.y * v.x++ const iw = -q.x * v.x - q.y * v.y - q.z * v.z++ return {++ x: ix * q.w + iw * -q.x + iy * -q.z - iz * -q.y,++ y: iy * q.w + iw * -q.y + iz * -q.x - ix * -q.z,++ z: iz * q.w + iw * -q.z + ix * -q.y - iy * -q.x,++ }++}++++export class RapierWorld implements PhysicsWorld {++ private nextId: BodyId = 1++ private nextConstraintId: ConstraintId = 1++ private readonly bodies = new Map<BodyId, BodyRecord>()++ private readonly constraints = new Map<ConstraintId, ConstraintRecord>()++ /** Rigid-body handle → seam id, for raycast hits. */++ private readonly bodyIds = new Map<number, BodyId>()++ private readonly sweepShapes = new Map<string, Rapier.Capsule>()++ private readonly forced = new Set<BodyId>()++ /**++ * Rapier builds its broad phase (which every scene query reads) inside++ * `World.step`; a collider added or moved since the last step is invisible to++ * `castRay`/`castShape` until then. The seam (and Havok) allowed querying a++ * freshly built world, so a stale query is served by one zero-dt step, which++ * updates the broad phase without advancing the simulation.++ */++ private broadPhaseStale = false++ private disposed = false++++ private readonly world: Rapier.World++++ constructor(private readonly R: RapierModule) {++ this.world = new R.World({ x: 0, y: RAPIER_GRAVITY, z: 0 })++ }++++ step(dt: number): void {++ this.world.integrationParameters.dt = dt++ this.world.step()++ this.broadPhaseStale = false++ // Havok applied a force for exactly the step that followed it; Rapier keeps++ // user forces until they are reset, so clear the ones applied this tick.++ if (this.forced.size > 0) {++ for (const id of this.forced) this.bodies.get(id)?.body.resetForces(false)++ this.forced.clear()++ }++ }++++ addBody(desc: BodyDesc): BodyId {++ const id = this.nextId++++ const body = this.world.createRigidBody(bodyDesc(this.R, desc))++ const cd = colliderDesc(this.R, desc.shape)++ cd.setCollisionGroups(groups(desc.layer, desc.collidesWith))++ cd.setFriction(RAPIER_FRICTION)++ cd.setRestitution(RAPIER_RESTITUTION)++ if (desc.motion === 'dynamic') cd.setMass(desc.massKg ?? RAPIER_DEFAULT_MASS_KG)++ const collider = this.world.createCollider(cd, body)++++ this.bodies.set(id, { body, collider, motion: desc.motion })++ this.bodyIds.set(body.handle, id)++ this.broadPhaseStale = true++ return id++ }++++ removeBody(id: BodyId): void {++ const rec = this.bodies.get(id)++ if (!rec) return++ this.bodyIds.delete(rec.body.handle)++ // Removes the body and its colliders and joints.++ this.world.removeRigidBody(rec.body)++ this.bodies.delete(id)++ this.forced.delete(id)++ this.broadPhaseStale = true++ for (const [cid, c] of this.constraints) {++ if (!c.joint.isValid()) this.constraints.delete(cid)++ }++ }++++ getTransform(id: BodyId, outPos: Vec3, outRot: Quat): void {++ const p = this.mustGet(id).body.translation()++ outPos.x = p.x++ outPos.y = p.y++ outPos.z = p.z++ const q = this.mustGet(id).body.rotation()++ outRot.x = q.x++ outRot.y = q.y++ outRot.z = q.z++ outRot.w = q.w++ }++++ setTransform(id: BodyId, pos: Vec3, rot?: Quat): void {++ const rec = this.mustGet(id)++ this.broadPhaseStale = true++ if (rec.motion === 'kinematic') {++ // A kinematic body chases the target with a computed velocity, so it++ // pushes dynamic bodies convincingly (Havok: setTargetTransform).++ rec.body.setNextKinematicTranslation({ x: pos.x, y: pos.y, z: pos.z })++ if (rot) rec.body.setNextKinematicRotation(rot)++ return++ }++ rec.body.setTranslation({ x: pos.x, y: pos.y, z: pos.z }, true)++ if (rot) rec.body.setRotation(rot, true)++ rec.body.wakeUp()++ }++++ setMotionType(id: BodyId, motion: MotionType): void {++ const rec = this.mustGet(id)++ if (rec.motion === motion) return++ rec.body.setBodyType(motionType(this.R, motion), true)++ rec.motion = motion++ this.broadPhaseStale = true++ }++++ getLinearVelocity(id: BodyId, out: Vec3): void {++ const v = this.mustGet(id).body.linvel()++ out.x = v.x++ out.y = v.y++ out.z = v.z++ }++++ setLinearVelocity(id: BodyId, v: Vec3): void {++ this.mustGet(id).body.setLinvel({ x: v.x, y: v.y, z: v.z }, true)++ }++++ getAngularVelocity(id: BodyId, out: Vec3): void {++ const v = this.mustGet(id).body.angvel()++ out.x = v.x++ out.y = v.y++ out.z = v.z++ }++++ setAngularVelocity(id: BodyId, v: Vec3): void {++ this.mustGet(id).body.setAngvel({ x: v.x, y: v.y, z: v.z }, true)++ }++++ isSettled(id: BodyId): boolean {++ const rec = this.mustGet(id)++ if (rec.motion !== 'dynamic') return true++ const v = rec.body.linvel()++ if (v.x * v.x + v.y * v.y + v.z * v.z > SETTLE_LIN_SQ) return false++ const w = rec.body.angvel()++ return w.x * w.x + w.y * w.y + w.z * w.z <= SETTLE_ANG_SQ++ }++++ wake(id: BodyId): void {++ const rec = this.mustGet(id)++ if (rec.motion !== 'dynamic') return++ rec.body.wakeUp()++ }++++ addConstraint(desc: ConstraintDesc): ConstraintId {++ const recA = this.mustGet(desc.bodyA)++ const recB = this.mustGet(desc.bodyB)++ const data = this.buildConstraint(desc, recA, recB)++ const joint = this.world.createImpulseJoint(data, recA.body, recB.body, true)++ if (desc.type === 'rope' || desc.type === 'spring') {++ joint.setContactsEnabled(desc.collision ?? true)++ } else {++ joint.setContactsEnabled(false)++ }++ if (desc.type === 'hinge' || desc.type === 'slider') {++ if (desc.limits) (joint as unknown as MotorJoint).setLimits(desc.limits.min, desc.limits.max)++ if (desc.motor) this.applyMotor(joint, desc.motor)++ }++ const id = this.nextConstraintId++++ this.constraints.set(id, { joint, type: desc.type })++ return id++ }++++ private buildConstraint(++ desc: ConstraintDesc,++ recA: BodyRecord,++ recB: BodyRecord,++ ): Rapier.JointData {++ const R = this.R++ switch (desc.type) {++ case 'weld': {++ const pA = recA.body.translation()++ const pB = recB.body.translation()++ const rA = recA.body.rotation()++ const rB = recB.body.rotation()++ // Preserve the CURRENT relative pose: pivotA = B's origin in A's local++ // space; frameA = the rotation taking A's basis to B's.++ const rel = qMul(qConj(rA), rB)++ const pivot = qRotate(qConj(rA), { x: pB.x - pA.x, y: pB.y - pA.y, z: pB.z - pA.z })++ return R.JointData.fixed(pivot, rel, { x: 0, y: 0, z: 0 }, IDENTITY)++ }++ case 'rope':++ return R.JointData.rope(desc.length, desc.anchorA, desc.anchorB)++ case 'spring':++ return R.JointData.spring(++ desc.restLength,++ desc.stiffness,++ desc.damping,++ desc.anchorA,++ desc.anchorB,++ )++ case 'hinge':++ return R.JointData.revolute(desc.anchorA, desc.anchorB, normalize(desc.axisA))++ case 'slider':++ return R.JointData.prismatic(desc.anchorA, desc.anchorB, normalize(desc.axisA))++ }++ }++++ /** Drives a revolute (hinge) or prismatic (slider) joint's motor along its free axis. */++ private applyMotor(joint: Rapier.ImpulseJoint, motor: ConstraintMotor): void {++ const j = joint as unknown as MotorJoint++ j.configureMotorModel(this.R.MotorModel.ForceBased)++ j.setMotorMaxForce(motor.maxForce)++ // A velocity target with a strong damping factor, capped by maxForce above.++ j.configureMotorVelocity(motor.targetVelocity, Math.max(motor.maxForce, 1))++ }++++ setConstraintMotor(id: ConstraintId, motor: ConstraintMotor): void {++ const rec = this.constraints.get(id)++ if (!rec || (rec.type !== 'hinge' && rec.type !== 'slider')) return++ this.applyMotor(rec.joint, motor)++ }++++ removeConstraint(id: ConstraintId): void {++ const rec = this.constraints.get(id)++ if (!rec) return++ this.world.removeImpulseJoint(rec.joint, false)++ this.constraints.delete(id)++ }++++ applyForce(id: BodyId, force: Vec3): void {++ const rec = this.mustGet(id)++ if (rec.motion !== 'dynamic') return++ rec.body.addForce({ x: force.x, y: force.y, z: force.z }, true)++ this.forced.add(id)++ }++++ raycast(from: Vec3, to: Vec3, collidesWith: number): RayHit | null {++ this.ensureQueriesCurrent()++ const origin = { x: from.x, y: from.y, z: from.z }++ const dir = { x: to.x - from.x, y: to.y - from.y, z: to.z - from.z }++ const ray = new this.R.Ray(origin, dir)++ // The query's own membership is all-on; the filter is the caller's mask, so++ // a collider is hit exactly when its layer intersects the mask.++ const hit = this.world.castRayAndGetNormal(++ ray,++ 1,++ true,++ undefined,++ groups(0xffff, collidesWith),++ )++ if (!hit) return null++ const parent = hit.collider.parent()++ if (!parent) return null++ const bodyId = this.bodyIds.get(parent.handle)++ if (bodyId === undefined) return null++ return {++ bodyId,++ point: {++ x: origin.x + dir.x * hit.timeOfImpact,++ y: origin.y + dir.y * hit.timeOfImpact,++ z: origin.z + dir.z * hit.timeOfImpact,++ },++ normal: { x: hit.normal.x, y: hit.normal.y, z: hit.normal.z },++ fraction: hit.timeOfImpact,++ }++ }++++ sweepCapsule(++ from: Vec3,++ to: Vec3,++ radius: number,++ height: number,++ collidesWith: number,++ exclude?: BodyId,++ ): SweepHit | null {++ this.ensureQueriesCurrent()++ const shape = this.getSweepShape(radius, height)++ // A vertical capsule cast at a constant velocity, capped at one segment.++ const hit = this.world.castShape(++ { x: from.x, y: from.y, z: from.z },++ IDENTITY,++ { x: to.x - from.x, y: to.y - from.y, z: to.z - from.z },++ shape,++ 0,++ 1,++ false,++ undefined,++ groups(CollisionLayer.Player, collidesWith),++ undefined,++ exclude === undefined ? undefined : this.bodies.get(exclude)?.body,++ )++ if (!hit) return null++ return {++ fraction: hit.time_of_impact,++ normal: { x: hit.normal1.x, y: hit.normal1.y, z: hit.normal1.z },++ point: { x: hit.witness1.x, y: hit.witness1.y, z: hit.witness1.z },++ }++ }++++ dispose(): void {++ if (this.disposed) return++ this.disposed = true++ this.constraints.clear()++ this.bodies.clear()++ this.bodyIds.clear()++ this.sweepShapes.clear()++ this.forced.clear()++ this.world.free()++ }++++ private mustGet(id: BodyId): BodyRecord {++ const rec = this.bodies.get(id)++ if (!rec) throw new Error(`unknown physics body ${id}`)++ return rec++ }++++ /** One zero-dt step to rebuild the broad phase when a query precedes a step. */++ private ensureQueriesCurrent(): void {++ if (!this.broadPhaseStale) return++ const dt = this.world.integrationParameters.dt++ this.world.integrationParameters.dt = 0++ this.world.step()++ this.world.integrationParameters.dt = dt++ this.broadPhaseStale = false++ }++++ private getSweepShape(radius: number, height: number): Rapier.Capsule {++ const key = `${radius}:${height}`++ let shape = this.sweepShapes.get(key)++ if (!shape) {++ shape = new this.R.Capsule(Math.max(height / 2 - radius, 0.01), radius)++ this.sweepShapes.set(key, shape)++ }++ return shape++ }++}++++function normalize(v: Vec3): Vec3 {++ const len = Math.hypot(v.x, v.y, v.z)++ if (len < 1e-9) return { x: 1, y: 0, z: 0 }++ return { x: v.x / len, y: v.y / len, z: v.z / len }++}++++/** A fresh Rapier world. No scene, no engine: the same call on server and client. */++export function createRapierWorld(R: RapierModule): RapierWorld {++ return new RapierWorld(R)++}@@@ -2,10 +2,10 @@ import type { Quat, Vec3 } from '@openvibe/shared'/*** Engine-agnostic physics facade. Gameplay and server code depend on these-- * types only; the Havok/Babylon implementation lives behind `@openvibe/physics/havok`-- * and no Havok or Babylon type ever crosses this boundary. That keeps-- * simulation testable without wasm and leaves room to swap/isolate the-- * engine (e.g. worker-side physics) later.++ * types only; the Rapier implementation lives behind `@openvibe/physics/rapier`++ * and no Rapier type ever crosses this boundary. That keeps simulation++ * testable without wasm and leaves room to swap/isolate the engine (e.g.++ * worker-side physics) later.*//** Opaque handle to a body inside a PhysicsWorld instance. Not persistent — never serialize it. */@@@ -47,9 +47,6 @@ importers:'@babylonjs/core':specifier: ^9.20.0version: 9.20.0-- '@babylonjs/havok':-- specifier: ^1.3.10-- version: 1.3.14'@babylonjs/loaders':specifier: ^9.20.0version: 9.20.0(@babylonjs/[email protected])([email protected])@@@ -84,9 +81,9 @@ importers:apps/server:dependencies:-- '@babylonjs/havok':-- specifier: ^1.3.10-- version: 1.3.14++ '@dimforge/rapier3d-deterministic-compat':++ specifier: 0.21.0++ version: 0.21.0'@electric-sql/pglite':specifier: ^0.5.8version: 0.5.8@@@ -121,8 +118,8 @@ importers:specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1(@electric-sql/[email protected])([email protected])([email protected])openvibe-shared:-- specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1-- version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1++ specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0++ version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0pg:specifier: ^8.23.0version: 8.23.0@@@ -182,12 +179,9 @@ importers:packages/physics:dependencies:-- '@babylonjs/core':-- specifier: ^9.20.0-- version: 9.20.0-- '@babylonjs/havok':-- specifier: ^1.3.10-- version: 1.3.14++ '@dimforge/rapier3d-deterministic-compat':++ specifier: 0.21.0++ version: 0.21.0'@openvibe/shared':specifier: workspace:*version: link:../shared@@@ -221,9 +215,6 @@ packages:'@babylonjs/[email protected]':resolution: {integrity: sha512-iWM/Re+FnqTEOxkGxvjRjChcPZzydcb0OGtnVk5o0vDQizqwkcpWFlJzfIv1Ij7Li2edJJJDZd6jRzC8QSWuuw==}-- '@babylonjs/[email protected]':-- resolution: {integrity: sha512-+j2FDwLtkh5Gb//KizE8Ks3GIB/3vH8Yx9cQsMvZQpsKkH5gn089wIlJ9Yt8dJxbwBmWKGpMTIXxZE7YdOHUnQ==}--'@babylonjs/[email protected]':resolution: {integrity: sha512-zGu6SRvN4uzk5KF0exoSjBZbBcvkEz6iteSvgCn4EP4nQD60W2D4WCtF8m2usZ/yALUOQE5EQON6Tuq1I4LWXg==}peerDependencies:@@@ -280,6 +271,9 @@ packages:resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}engines: {node: '>=20.19.0'}++ '@dimforge/[email protected]':++ resolution: {integrity: sha512-n7JuU62Obljlgq+OeH2DHhTmRSjRzwuJPl54tS9pFIzVQyvXsO5CH+a5KkIRBtnFWDbg1Zicv9BulG0jfBAopA==}++'@electric-sql/[email protected]':resolution: {integrity: sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==}@@@ -805,9 +799,6 @@ packages:'@types/[email protected]':resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}-- '@types/[email protected]':-- resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==}--'@types/[email protected]':resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}@@@ -1343,9 +1334,9 @@ packages:pg:optional: true-- openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1:-- resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1}-- version: 1.30.1++ openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0:++ resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0}++ version: 2.2.0resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}@@@ -1814,10 +1805,6 @@ snapshots:'@babylonjs/[email protected]': {}-- '@babylonjs/[email protected]':-- dependencies:-- '@types/emscripten': 1.41.5--'@babylonjs/[email protected](@babylonjs/[email protected])([email protected])':dependencies:'@babylonjs/core': 9.20.0@@@ -1859,6 +1846,8 @@ snapshots:'@csstools/[email protected]': {}++ '@dimforge/[email protected]': {}++'@electric-sql/[email protected]': {}'@esbuild/[email protected]':@@@ -2170,8 +2159,6 @@ snapshots:'@types/[email protected]': {}-- '@types/[email protected]': {}--'@types/[email protected]': {}'@types/[email protected]': {}@@@ -2786,7 +2773,7 @@ snapshots:iovalkey: 0.4.0pg: 8.23.0-- openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1:++ openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0:dependencies:jsonwebtoken: 9.0.3
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
every gameplay-relevant decision (movement, inventory, crafting, placement,
every gameplay-relevant decision (movement, inventory, crafting, placement,
physgun, damage, economy) is validated or simulated server-side. No client
physgun, damage, economy) is validated or simulated server-side. No client
field is ever trusted.
field is ever trusted.
-2. **Domain logic is engine-free.** `@openvibe/gameplay` has no Babylon, Havok,
+2. **Domain logic is engine-free.** `@openvibe/gameplay` has no Babylon, Rapier,
DOM, or network imports; it runs identically in vitest, on the server, and
DOM, or network imports; it runs identically in vitest, on the server, and
inside client prediction. Engines live behind adapters.
inside client prediction. Engines live behind adapters.
3. **Data-driven content.** Items, recipes, and worlds are declarative
3. **Data-driven content.** Items, recipes, and worlds are declarative
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
```
```
shared
shared
/ | \
/ | \
- protocol content physics ──(havok adapter: @babylonjs/core + havok wasm)
+ protocol content physics ──(rapier adapter: deterministic Rapier wasm)
\ | /
\ | /
gameplay
gameplay
|
|
@@ -34,10 +34,13 @@
@@ -34,10 +34,13 @@
(authoritative) (Babylon render + prediction + HUD)
(authoritative) (Babylon render + prediction + HUD)
```
```
-Only `apps/*` and `packages/physics`'s adapter know Babylon exists. Only
-`apps/server` and `packages/persistence` know PostgreSQL (openvibe-sdk/db) exists. `packages/*`
-never import browser-only APIs (the physics adapter's NullEngine path is
-Node-safe; the client hands it a rendered scene instead).
+Only `apps/client` knows Babylon exists: ADR-0007 decision 2 removed the engine
+from the authoritative server, and an architecture audit
+(`apps/server/src/architectureAudit.audit.ts`) fails if `apps/server` imports
+`@babylonjs/*`. Only `apps/server` and `packages/persistence` know PostgreSQL
+(openvibe-sdk/db) exists. `packages/*` never import browser-only APIs: the
+physics adapter is Rapier, which runs identically in Node and the browser with no
+scene or engine, so there is no Node-only path to keep separate.
## Simulation model
## Simulation model
@@ -46,18 +49,19 @@ Node-safe; the client hands it a rendered scene instead).
@@ -46,18 +49,19 @@ Node-safe; the client hands it a rendered scene instead).
- **Movement** is a kinematic Source/Quake controller (`gameplay/movement`):
- **Movement** is a kinematic Source/Quake controller (`gameplay/movement`):
explicit velocity + capsule sweeps + clip-plane sliding + step-up, with
explicit velocity + capsule sweeps + clip-plane sliding + step-up, with
friction / ground-accelerate / air-accelerate (air-strafing works). The
friction / ground-accelerate / air-accelerate (air-strafing works). The
- player is _not_ a dynamic rigid body; a kinematic Havok capsule mirrors the
+ player is _not_ a dynamic rigid body; a kinematic Rapier capsule mirrors the
player so props collide with them. The controller consumes a
player so props collide with them. The controller consumes a
- `CollisionQueries` interface — the Havok adapter provides it on both sides,
- the tests provide analytic worlds.
+ `CollisionQueries` interface — the Rapier adapter provides it on both sides
+ (one implementation, no scene), the tests provide analytic worlds.
- **Client prediction:** every input command (seq-stamped) is sent and applied
- **Client prediction:** every input command (seq-stamped) is sent and applied
locally; snapshots carry the last processed seq; the client rewinds to the
locally; snapshots carry the last processed seq; the client rewinds to the
authoritative state and replays unacked inputs. Remote entities render from
authoritative state and replays unacked inputs. Remote entities render from
~130 ms interpolation buffers.
~130 ms interpolation buffers.
-- **Physics:** Havok via Babylon Physics V2 behind `PhysicsWorld`
- (`@openvibe/physics`). Stepping is always manual (`executeStep`) from the fixed
- loop — never coupled to render frames. Collision layers: Static / Prop /
- Player, filtered at the shape level for rays and sweeps.
+- **Physics:** Rapier (`@dimforge/rapier3d-deterministic-compat`, one pinned
+ build on both sides) behind `PhysicsWorld` (`@openvibe/physics`). Stepping is
+ always manual (`world.step(dt)` from the fixed loop) — never coupled to render
+ frames. Collision layers: Static / Prop / Player, filtered at the shape level
+ for rays and sweeps.
## Networking
## Networking
@@ -8,7 +8,8 @@ physgun-driven building from crafted physical objects, gathering, crafting, and
@@ -8,7 +8,8 @@ physgun-driven building from crafted physical objects, gathering, crafting, and
extraction risk — running against an authoritative dedicated server.
extraction risk — running against an authoritative dedicated server.
**Stack:** TypeScript everywhere · Babylon.js (WebGPU with WebGL fallback) ·
**Stack:** TypeScript everywhere · Babylon.js (WebGPU with WebGL fallback) ·
-Havok physics (Babylon Physics V2, headless on the server via NullEngine) ·
+Rapier physics (`@dimforge/rapier3d-deterministic-compat`, one pinned build
+headless on the server and in client prediction) ·
WebSocket protocol · PostgreSQL 18 + Valkey persistence (openvibe-sdk) · pnpm monorepo.
WebSocket protocol · PostgreSQL 18 + Valkey persistence (openvibe-sdk) · pnpm monorepo.
## Owns
## Owns
@@ -32,7 +33,7 @@ WebSocket protocol · PostgreSQL 18 + Valkey persistence (openvibe-sdk) · pnpm
@@ -32,7 +33,7 @@ WebSocket protocol · PostgreSQL 18 + Valkey persistence (openvibe-sdk) · pnpm
- OpenVibe.Network (SSO, JWKS, client-credentials tokens, identity resolve, the `games.progress.summary`
- OpenVibe.Network (SSO, JWKS, client-credentials tokens, identity resolve, the `games.progress.summary`
user module, mod grants), OpenVibe.Events (outbox relay, subscriptions), OpenVibe.Media (asset mirror)
user module, mod grants), OpenVibe.Events (outbox relay, subscriptions), OpenVibe.Media (asset mirror)
- `openvibe-contracts` v0.77.0, `openvibe-sdk` v0.22.0 and `openvibe-shared` v1.30.1 (pinned by release
- `openvibe-contracts` v0.77.0, `openvibe-sdk` v0.22.0 and `openvibe-shared` v1.30.1 (pinned by release
- tarball in `apps/server/package.json`), Babylon.js, Havok, PostgreSQL (`pg`, or embedded PGlite in
+ tarball in `apps/server/package.json`), Babylon.js, Rapier, PostgreSQL (`pg`, or embedded PGlite in
development), Valkey (`iovalkey`, optional)
development), Valkey (`iovalkey`, optional)
## Repository layout
## Repository layout
@@ -46,7 +47,7 @@ packages/
@@ -46,7 +47,7 @@ packages/
protocol/ Versioned wire messages + codec (zod-validated inbound)
protocol/ Versioned wire messages + codec (zod-validated inbound)
content/ Data-driven item/recipe/world definitions + validation registry
content/ Data-driven item/recipe/world definitions + validation registry
gameplay/ Pure domain logic: inventory, crafting, movement sim, zones, entities
gameplay/ Pure domain logic: inventory, crafting, movement sim, zones, entities
- physics/ PhysicsWorld abstraction + Havok adapter (@openvibe/physics/havok)
+ physics/ PhysicsWorld abstraction + Rapier adapter (@openvibe/physics/rapier)
persistence/ DTOs, repository interfaces, PostgreSQL implementation, migrations
persistence/ DTOs, repository interfaces, PostgreSQL implementation, migrations
docs/adr/ Architecture decision records
docs/adr/ Architecture decision records
```
```
@@ -10,7 +10,7 @@ real playable vertical implementation.
@@ -10,7 +10,7 @@ real playable vertical implementation.
Proved every major system cooperates end-to-end (verified by
Proved every major system cooperates end-to-end (verified by
`apps/server/scripts/sliceTest.ts` on every change): authoritative server,
`apps/server/scripts/sliceTest.ts` on every change): authoritative server,
Babylon client (WebGPU/WebGL), Source-style predicted movement, interest-
Babylon client (WebGPU/WebGL), Source-style predicted movement, interest-
-managed replication, Havok props with sleep-aware snapshotting, gathering,
+managed replication, physics props with sleep-aware snapshotting, gathering,
inventory, data-driven crafting, physgun building, persistence across
inventory, data-driven crafting, physgun building, persistence across
restart, zone rules, logs//metrics//healthz.
restart, zone rules, logs//metrics//healthz.
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "stable",
"stage": "stable",
"code": true,
"code": true,
"product": "Scraplandia",
"product": "Scraplandia",
- "runtime": "node 22, TypeScript (tsx), pnpm workspace; Babylon.js + Havok; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in development); Valkey optional",
+ "runtime": "node 22, TypeScript (tsx), pnpm workspace; Babylon.js (client) + deterministic Rapier physics; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in development); Valkey optional",
"port": 8000,
"port": 8000,
"domain": "openvibe.games, play.openvibe.games",
"domain": "openvibe.games, play.openvibe.games",
"contracts": "openvibe-contracts v0.71.0, openvibe-sdk v0.22.0, openvibe-shared v1.30.1 (apps/server/package.json)",
"contracts": "openvibe-contracts v0.71.0, openvibe-sdk v0.22.0, openvibe-shared v1.30.1 (apps/server/package.json)",
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"deployed": true,
"deployed": true,
"deployment": "Public at https://openvibe.games and https://play.openvibe.games: deployed with `sudo ovhost deploy games` (OpenVibe.Host strategy pnpm-build since the 2026-09-27 cutover; deploy/scripts/deploy.sh is the wrapper), unit openvibe-games on 127.0.0.1:8000, env /etc/openvibe/games.env, principal games.",
"deployment": "Public at https://openvibe.games and https://play.openvibe.games: deployed with `sudo ovhost deploy games` (OpenVibe.Host strategy pnpm-build since the 2026-09-27 cutover; deploy/scripts/deploy.sh is the wrapper), unit openvibe-games on 127.0.0.1:8000, env /etc/openvibe/games.env, principal games.",
"features": [
"features": [
- "authoritative multiplayer physics sandbox (Havok on the server)",
+ "authoritative multiplayer physics sandbox (deterministic Rapier on the server)",
"Source-style movement, physgun building, gathering, crafting, skills",
"Source-style movement, physgun building, gathering, crafting, skills",
"persistent world, characters and props in PostgreSQL (write-behind flusher; the tick never awaits I/O)",
"persistent world, characters and props in PostgreSQL (write-behind flusher; the tick never awaits I/O)",
"map editor with assets mirrored to OpenVibe.Media",
"map editor with assets mirrored to OpenVibe.Media",
@@ -11,7 +11,6 @@
@@ -11,7 +11,6 @@
"dependencies": {
"dependencies": {
"@babylonjs/addons": "^9.20.0",
"@babylonjs/addons": "^9.20.0",
"@babylonjs/core": "^9.20.0",
"@babylonjs/core": "^9.20.0",
- "@babylonjs/havok": "^1.3.10",
"@babylonjs/loaders": "^9.20.0",
"@babylonjs/loaders": "^9.20.0",
"@babylonjs/materials": "^9.20.0",
"@babylonjs/materials": "^9.20.0",
"@babylonjs/procedural-textures": "^9.20.0",
"@babylonjs/procedural-textures": "^9.20.0",
@@ -1,5 +1,3 @@
@@ -1,5 +1,3 @@
-import HavokPhysics from '@babylonjs/havok'
-import havokWasmUrl from '@babylonjs/havok/lib/esm/HavokPhysics.wasm?url'
import {
import {
createContent,
createContent,
setMapOverride,
setMapOverride,
@@ -11,7 +9,7 @@ import {
@@ -11,7 +9,7 @@ import {
type MapTextureEntry,
type MapTextureEntry,
} from '@openvibe/content'
} from '@openvibe/content'
import { MapLightLayer, mapDiff, staticWork, terrainWork } from './render/mapRuntimeLayers.js'
import { MapLightLayer, mapDiff, staticWork, terrainWork } from './render/mapRuntimeLayers.js'
-import { createHavokWorldForScene } from '@openvibe/physics/havok'
+import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'
import { FixedTimestep } from '@openvibe/shared'
import { FixedTimestep } from '@openvibe/shared'
import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
import type { Appearance } from '@openvibe/protocol'
import type { Appearance } from '@openvibe/protocol'
@@ -90,8 +88,9 @@ async function start(): Promise<void> {
@@ -90,8 +88,9 @@ async function start(): Promise<void> {
const mapLights = new MapLightLayer(scene)
const mapLights = new MapLightLayer(scene)
mapLights.reconcile(mapLightsBoot ?? [])
mapLights.reconcile(mapLightsBoot ?? [])
- // Havok loads while the player customizes their character.
- const havokPromise = HavokPhysics({ locateFile: () => havokWasmUrl })
+ // Rapier loads while the player customizes their character (dynamic import,
+ // so physics is never part of the initial JavaScript).
+ const rapierPromise = loadRapier()
const environment = new Environment(scene, engine)
const environment = new Environment(scene, engine)
@@ -156,8 +155,8 @@ async function start(): Promise<void> {
@@ -156,8 +155,8 @@ async function start(): Promise<void> {
// away so the canvas gets the whole viewport (see public/frame.js).
// away so the canvas gets the whole viewport (see public/frame.js).
window.dispatchEvent(new CustomEvent('ovg:playing'))
window.dispatchEvent(new CustomEvent('ovg:playing'))
- const havok = await havokPromise
- const physics = createHavokWorldForScene(scene, havok)
+ const rapier = await rapierPromise
+ const physics = createRapierWorld(rapier)
const { buildStaticPhysics, rebuildTerrainPhysics } = await import('./game/staticPhysics.js')
const { buildStaticPhysics, rebuildTerrainPhysics } = await import('./game/staticPhysics.js')
buildStaticPhysics(physics, content)
buildStaticPhysics(physics, content)
@@ -7,22 +7,22 @@
@@ -7,22 +7,22 @@
"dev": "tsx watch --env-file-if-exists=.env src/main.ts",
"dev": "tsx watch --env-file-if-exists=.env src/main.ts",
"start": "tsx src/main.ts",
"start": "tsx src/main.ts",
"build": "tsc --build",
"build": "tsc --build",
- "bundle": "esbuild src/main.ts --bundle --platform=node --format=esm --target=node22 --external:pg --external:iovalkey --external:@electric-sql/pglite --external:@babylonjs/havok --outfile=dist/server.mjs --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\""
+ "bundle": "esbuild src/main.ts --bundle --platform=node --format=esm --target=node22 --external:pg --external:iovalkey --external:@electric-sql/pglite --outfile=dist/server.mjs --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\""
},
},
"dependencies": {
"dependencies": {
- "@babylonjs/havok": "^1.3.10",
+ "@dimforge/rapier3d-deterministic-compat": "0.21.0",
+ "@electric-sql/pglite": "^0.5.8",
"@openvibe/content": "workspace:*",
"@openvibe/content": "workspace:*",
"@openvibe/gameplay": "workspace:*",
"@openvibe/gameplay": "workspace:*",
"@openvibe/persistence": "workspace:*",
"@openvibe/persistence": "workspace:*",
"@openvibe/physics": "workspace:*",
"@openvibe/physics": "workspace:*",
"@openvibe/protocol": "workspace:*",
"@openvibe/protocol": "workspace:*",
"@openvibe/shared": "workspace:*",
"@openvibe/shared": "workspace:*",
- "@electric-sql/pglite": "^0.5.8",
"ajv": "^8.17.1",
"ajv": "^8.17.1",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.78.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.78.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"tsx": "^4.19.3",
"tsx": "^4.19.3",
"ws": "^8.18.1"
"ws": "^8.18.1"
@@ -1747,9 +1747,12 @@ async function main(): Promise<void> {
@@ -1747,9 +1747,12 @@ async function main(): Promise<void> {
while (Date.now() - start < 45_000) {
while (Date.now() - start < 45_000) {
const me = a.me
const me = a.me
if (me && Math.hypot(me.pos[0] - 0, me.pos[2] - 4) < 6 && secured()) break // both frames in
if (me && Math.hypot(me.pos[0] - 0, me.pos[2] - 4) < 6 && secured()) break // both frames in
- // Nudge back toward the beacon in case physics drift pushed us out.
+ // Nudge back toward the beacon in case physics drift pushed us out, but only while we are not already home
+ // (after the recall the site is behind us), and only as a best effort: the recall is the server's and lands
+ // wherever we stand, so a nudge that runs out of time must not end the run.
+ const home = me && Math.hypot(me.pos[0] - 0, me.pos[2] - 4) < 6
const dist = me ? Math.hypot(me.pos[0] - site[0], me.pos[2] - site[1]) : 99
const dist = me ? Math.hypot(me.pos[0] - site[0], me.pos[2] - site[1]) : 99
- if (me && dist > 3) await walkTo(a, site[0], site[1], 6000)
+ if (me && !home && dist > 3) await walkTo(a, site[0], site[1], 6000).catch(() => undefined)
await sleep(500)
await sleep(500)
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
+/**
+ * ADR-0007 completion audit: the authoritative server is Babylon-free.
+ *
+ * Deleting Havok (ADR-0007 decision 2) removes `@babylonjs/core` and the
+ * NullEngine scene from the server; the server now runs Rapier only. That win
+ * is invisible to the type checker and to behavioural tests — a stray Babylon
+ * import "works" — so it needs a test of its own or the renderer quietly creeps
+ * back into the authoritative process.
+ *
+ * Only ACTIVE code counts: a mention in a comment is not an import.
+ */
+import { readdirSync, readFileSync, statSync } from 'node:fs'
+import { extname, join, relative } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+const REPO = new URL('../../../', import.meta.url).pathname
+const ROOT = 'apps/server'
+
+/** Server sources that legitimately mention Babylon: only this audit, which names the forbidden module. */
+const ALLOWED = ['apps/server/src/architectureAudit.audit.ts']
+
+interface SourceFile {
+ path: string
+ text: string
+}
+
+function collect(): SourceFile[] {
+ const out: SourceFile[] = []
+ const walk = (dir: string): void => {
+ for (const entry of readdirSync(dir)) {
+ const full = join(dir, entry)
+ if (statSync(full).isDirectory()) {
+ if (entry === 'node_modules' || entry === 'dist' || entry === 'dist-types') continue
+ walk(full)
+ continue
+ }
+ if (!['.ts', '.tsx'].includes(extname(entry))) continue
+ const rel = relative(REPO, full)
+ if (ALLOWED.some((a) => rel === a)) continue
+ out.push({ path: rel, text: readFileSync(full, 'utf8') })
+ }
+ }
+ walk(join(REPO, ROOT))
+ return out
+}
+
+const FILES = collect()
+
+/** Occurrences outside comments — a mention in prose is not an import. */
+function activeHits(needle: RegExp): { path: string; line: number; text: string }[] {
+ const hits: { path: string; line: number; text: string }[] = []
+ for (const f of FILES) {
+ let inBlockComment = false
+ f.text.split('\n').forEach((raw, i) => {
+ const line = raw.trim()
+ if (inBlockComment) {
+ if (line.includes('*/')) inBlockComment = false
+ return
+ }
+ if (line.startsWith('/*')) {
+ if (!line.includes('*/')) inBlockComment = true
+ return
+ }
+ if (line.startsWith('//') || line.startsWith('*')) return
+ if (needle.test(raw)) hits.push({ path: f.path, line: i + 1, text: line.slice(0, 110) })
+ })
+ }
+ return hits
+}
+
+const report = (hits: { path: string; line: number; text: string }[]): string =>
+ hits.map((h) => `${h.path}:${h.line} ${h.text}`).join('\n')
+
+describe('architecture audit: the server is Babylon-free (ADR-0007)', () => {
+ it('scans a meaningful number of server source files', () => {
+ expect(FILES.length).toBeGreaterThan(40)
+ })
+
+ it('imports no @babylonjs/* anywhere in the server', () => {
+ expect(report(activeHits(/@babylonjs\//))) .toBe('')
+ })
+
+ it('drives Rapier (not Havok) and has no Havok loader', () => {
+ // Positive half of the claim: the server names Rapier and nothing else.
+ const main = FILES.find((f) => f.path === 'apps/server/src/main.ts')
+ expect(main, 'server main.ts should exist').toBeDefined()
+ expect(main!.text).toContain('@openvibe/physics/rapier')
+ expect(main!.text).not.toMatch(/havok/i)
+ })
+})
@@ -91,9 +91,9 @@ export class MapTerrainLayer {
@@ -91,9 +91,9 @@ export class MapTerrainLayer {
private addBody(patch: TerrainPatchData): BodyId {
private addBody(patch: TerrainPatchData): BodyId {
const grid = buildPatchGrid(patch.halfExtent, patch.sub, patch.heights)
const grid = buildPatchGrid(patch.halfExtent, patch.sub, patch.heights)
const rot = patch.rot ? qfromEuler(quat(), patch.rot[0], patch.rot[1], patch.rot[2]) : quat()
const rot = patch.rot ? qfromEuler(quat(), patch.rot[0], patch.rot[1], patch.rot[2]) : quat()
- // Scale the VERTICES rather than the body: Babylon/Havok apply
- // scale→rotate→translate, matching the client mesh's `scaling`, so the
- // two cannot disagree about where the ground is.
+ // Scale the VERTICES rather than the body: Rapier has no body scale, so
+ // baking it into the local vertices is the only way to match the client
+ // mesh's `scaling`, and the two cannot disagree about where the ground is.
const positions = scalePatchPositions(grid.positions, patch.scale)
const positions = scalePatchPositions(grid.positions, patch.scale)
return this.physics.addBody({
return this.physics.addBody({
shape: { type: 'trimesh', positions, indices: grid.indices },
shape: { type: 'trimesh', positions, indices: grid.indices },
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
/**
/**
* The Wave 12 platform boundary exercised on the real authoritative server
* The Wave 12 platform boundary exercised on the real authoritative server
- * (headless Havok, real PostgreSQL schema through in-memory PGlite,
+ * (headless Rapier, real PostgreSQL schema through in-memory PGlite,
* protocol-level connections), across a restart:
* protocol-level connections), across a restart:
*
*
* - a signed-in player is keyed by the canonical subject;
* - a signed-in player is keyed by the canonical subject;
@@ -19,7 +19,7 @@ import { join } from 'node:path'
@@ -19,7 +19,7 @@ import { join } from 'node:path'
import { createContent } from '@openvibe/content'
import { createContent } from '@openvibe/content'
import { openPgStore, type PgPersistenceStore } from '@openvibe/persistence'
import { openPgStore, type PgPersistenceStore } from '@openvibe/persistence'
import { openTestDb } from '@openvibe/persistence/testing'
import { openTestDb } from '@openvibe/persistence/testing'
-import { createHeadlessHavokWorld } from '@openvibe/physics/havok'
+import { createRapierWorld, loadRapier, type RapierModule } from '@openvibe/physics/rapier'
import {
import {
PROTOCOL_VERSION,
PROTOCOL_VERSION,
defaultAppearance,
defaultAppearance,
@@ -32,7 +32,6 @@ import { createEventsClient, createPgOutbox, type PgOutbox } from 'openvibe-sdk/
@@ -32,7 +32,6 @@ import { createEventsClient, createPgOutbox, type PgOutbox } from 'openvibe-sdk/
import { createMockPlatform } from 'openvibe-sdk/testing'
import { createMockPlatform } from 'openvibe-sdk/testing'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { loadConfig } from '../config.js'
import { loadConfig } from '../config.js'
-import { loadHavok } from '../havokLoader.js'
import { hashGuestKey } from '../net/guestIdentity.js'
import { hashGuestKey } from '../net/guestIdentity.js'
import { CAP_ANNOUNCE, CAP_PLACE_PROP } from '../mods/contentPack.js'
import { CAP_ANNOUNCE, CAP_PLACE_PROP } from '../mods/contentPack.js'
import { ModRegistry, type ModActor } from '../mods/registry.js'
import { ModRegistry, type ModActor } from '../mods/registry.js'
@@ -55,12 +54,12 @@ const config = loadConfig({
@@ -55,12 +54,12 @@ const config = loadConfig({
MAP_PATH: join(dir, 'map.json'),
MAP_PATH: join(dir, 'map.json'),
OV_NETWORK_AUTH_URL: 'http://network.test/api/auth/me',
OV_NETWORK_AUTH_URL: 'http://network.test/api/auth/me',
})
})
-let havok: unknown
+let rapier: RapierModule
/** One in-memory PGlite database for the whole file; the restart tests share its state. */
/** One in-memory PGlite database for the whole file; the restart tests share its state. */
let db: Db
let db: Db
beforeAll(async () => {
beforeAll(async () => {
- havok = await loadHavok()
+ rapier = await loadRapier()
db = await openTestDb()
db = await openTestDb()
// openvibe.network's /api/auth/me for the one signed-in test account.
// openvibe.network's /api/auth/me for the one signed-in test account.
vi.stubGlobal('fetch', async (_url: string, init?: RequestInit) => {
vi.stubGlobal('fetch', async (_url: string, init?: RequestInit) => {
@@ -85,7 +84,7 @@ interface Boot {
@@ -85,7 +84,7 @@ interface Boot {
async function boot(platform: ReturnType<typeof createMockPlatform>): Promise<Boot> {
async function boot(platform: ReturnType<typeof createMockPlatform>): Promise<Boot> {
const content = createContent()
const content = createContent()
- const physics = createHeadlessHavokWorld(havok)
+ const physics = createRapierWorld(rapier)
// The store rides on the shared database; close() is never called (the instance is shared).
// The store rides on the shared database; close() is never called (the instance is shared).
const store = openPgStore(db, { placeId: config.placeId })
const store = openPgStore(db, { placeId: config.placeId })
await store.ensurePlace()
await store.ensurePlace()
@@ -12,6 +12,15 @@ const _vehicleForce = vec3()
@@ -12,6 +12,15 @@ const _vehicleForce = vec3()
const _vehicleAng = vec3()
const _vehicleAng = vec3()
const _vehicleVel = vec3()
const _vehicleVel = vec3()
+/**
+ * Vertical clearance between a riding player's replicated perch and their
+ * collision hull. Rapier resolves an animated hull that overlaps a dynamic
+ * chassis into a depenetration impulse, so a hull left at the perch pushes the
+ * cart into the ground and pins it. The standing capsule half-height is 0.75 m
+ * (STANCE_HULL 1.8 minus the 0.3 shorter body hull), which clears the vehicle.
+ */
+const RIDER_HULL_LIFT = 0.75
+
export class VehicleSystem implements System {
export class VehicleSystem implements System {
readonly name = 'vehicles'
readonly name = 'vehicles'
@@ -174,8 +183,13 @@ export class VehicleSystem implements System {
@@ -174,8 +183,13 @@ export class VehicleSystem implements System {
session.fallVy = 0
session.fallVy = 0
const body = this.ctx.sessions.bodyOf(session.playerId)
const body = this.ctx.sessions.bodyOf(session.playerId)
if (body !== undefined) {
if (body !== undefined) {
+ // Rapier resolves an animated body that overlaps a dynamic body into a
+ // depenetration impulse, so a rider's hull left inside the chassis pushes
+ // the cart into the ground and brakes it. Perch the rider's hull clear of
+ // the vehicle instead; the replicated/rendered perch above
+ // (session.move.pos) is unchanged, so nothing visible moves.
_bodyPosScratch.x = session.move.pos.x
_bodyPosScratch.x = session.move.pos.x
- _bodyPosScratch.y = session.move.pos.y + 0.15
+ _bodyPosScratch.y = session.move.pos.y + RIDER_HULL_LIFT
_bodyPosScratch.z = session.move.pos.z
_bodyPosScratch.z = session.move.pos.z
this.ctx.world.physics.setTransform(body, _bodyPosScratch)
this.ctx.world.physics.setTransform(body, _bodyPosScratch)
}
}
deleted file mode 100644
deleted file mode 100644
@@ -1,14 +0,0 @@
@@ -1,14 +0,0 @@
-import { readFile } from 'node:fs/promises'
-import { createRequire } from 'node:module'
-import HavokPhysics from '@babylonjs/havok'
-
-/**
- * Loads the Havok wasm module in Node. Emscripten's default file resolution
- * expects a browser, so the wasm binary is read from the package explicitly.
- */
-export async function loadHavok(): Promise<unknown> {
- const require = createRequire(import.meta.url)
- const wasmPath = require.resolve('@babylonjs/havok/lib/esm/HavokPhysics.wasm')
- const wasmBinary = (await readFile(wasmPath)).buffer as ArrayBuffer
- return HavokPhysics({ wasmBinary })
-}
@@ -5,13 +5,12 @@ import { createEventsClient, createPgOutbox, type PgOutbox } from 'openvibe-sdk/
@@ -5,13 +5,12 @@ import { createEventsClient, createPgOutbox, type PgOutbox } from 'openvibe-sdk/
import { createValkey } from 'openvibe-sdk/valkey'
import { createValkey } from 'openvibe-sdk/valkey'
import { compileMapFileV2, createContent, parseMapFile, setMapOverride } from '@openvibe/content'
import { compileMapFileV2, createContent, parseMapFile, setMapOverride } from '@openvibe/content'
import { openPgStore } from '@openvibe/persistence'
import { openPgStore } from '@openvibe/persistence'
-import { createHeadlessHavokWorld } from '@openvibe/physics/havok'
+import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'
import { createConsoleLogger } from '@openvibe/shared'
import { createConsoleLogger } from '@openvibe/shared'
import { loadConfig } from './config.js'
import { loadConfig } from './config.js'
import { openDb } from './db.js'
import { openDb } from './db.js'
import { GameServer } from './game/gameServer.js'
import { GameServer } from './game/gameServer.js'
import { GameWorld } from './game/gameWorld.js'
import { GameWorld } from './game/gameWorld.js'
-import { loadHavok } from './havokLoader.js'
import { attachEditorWs } from './net/editorWs.js'
import { attachEditorWs } from './net/editorWs.js'
import {
import {
closeSockets,
closeSockets,
@@ -99,8 +98,7 @@ async function main(): Promise<void> {
@@ -99,8 +98,7 @@ async function main(): Promise<void> {
world: content.world.id,
world: content.world.id,
})
})
- const havok = await loadHavok()
- const physics = createHeadlessHavokWorld(havok)
+ const physics = createRapierWorld(await loadRapier())
// PostgreSQL (or embedded PGlite in development), migrated before anything writes.
// PostgreSQL (or embedded PGlite in development), migrated before anything writes.
const db = await openDb(config.db, log)
const db = await openDb(config.db, log)
@@ -30,6 +30,7 @@ import type { GamesActorLimits } from './actorLimits.js'
@@ -30,6 +30,7 @@ import type { GamesActorLimits } from './actorLimits.js'
import { resolveClientAddress } from './clientAddress.js'
import { resolveClientAddress } from './clientAddress.js'
import { sendNotFound, type NotFoundLink } from './notFound.js'
import { sendNotFound, type NotFoundLink } from './notFound.js'
import { discoveryHandler, homeJsonLdTags } from './discovery.js'
import { discoveryHandler, homeJsonLdTags } from './discovery.js'
+import { legalHandler } from './legalPages.js'
/** The portal page with its JSON-LD, built once per file version: the game server's event loop never reads the file
/** The portal page with its JSON-LD, built once per file version: the game server's event loop never reads the file
* per request (a new build changes the mtime and rebuilds it once). */
* per request (a new build changes the mtime and rebuilds it once). */
@@ -181,6 +182,8 @@ export function createHttpServer(
@@ -181,6 +182,8 @@ export function createHttpServer(
{ href: '/editor', label: 'Map editor' },
{ href: '/editor', label: 'Map editor' },
{ href: portalHome, label: 'OpenVibe.Games home' },
{ href: portalHome, label: 'OpenVibe.Games home' },
]
]
+ // The site's own /terms, /privacy and /dmca (openvibe-shared/legal), built once for the process.
+ const serveLegal = legalHandler()
return createServer((req: IncomingMessage, res: ServerResponse) => {
return createServer((req: IncomingMessage, res: ServerResponse) => {
const url = (req.url ?? '/').split('?')[0] ?? '/'
const url = (req.url ?? '/').split('?')[0] ?? '/'
// Host-based routing: play.openvibe.games serves the game at its root
// Host-based routing: play.openvibe.games serves the game at its root
@@ -215,6 +218,9 @@ export function createHttpServer(
@@ -215,6 +218,9 @@ export function createHttpServer(
if (platform?.handle?.(req, res)) return
if (platform?.handle?.(req, res)) return
// Crawl artifacts (plan T11): /robots.txt, /sitemap.xml and /llms.txt, built by openvibe-shared/seo.
// Crawl artifacts (plan T11): /robots.txt, /sitemap.xml and /llms.txt, built by openvibe-shared/seo.
if (discoveryHandler(req, res)) return
if (discoveryHandler(req, res)) return
+ // The site's own legal pages (plan T11 decision 6): /terms, /privacy, /dmca. Before the SPA and the
+ // static fallback so they never resolve to a file or the landing page.
+ if (serveLegal(req, res)) return
if (url === '/map.json' && mapPath) {
if (url === '/map.json' && mapPath) {
// The canonical v2 document, with its revision as an ETag. This IS the
// The canonical v2 document, with its revision as an ETag. This IS the
// wire format.
// wire format.
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { request, type IncomingHttpHeaders, type OutgoingHttpHeaders, type Server } from 'node:http'
+import type { AddressInfo } from 'node:net'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { createConsoleLogger } from '@openvibe/shared'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { ServerMetrics } from '../observability/metrics.js'
+import { createHttpServer } from './httpServer.js'
+
+/**
+ * The site's own legal pages (plan T11 decision 6): /terms, /privacy and /dmca are rendered by
+ * openvibe-shared/legal with the Games profile, fetched from the real HTTP layer over a stand-in
+ * client build. An unknown /legal-ish path is still a real 404 (notFound.ts), never the landing page.
+ */
+
+const log = createConsoleLogger({ app: 'test' }, 'error')
+const APEX = 'openvibe.games'
+const PAGES: readonly (readonly [string, string])[] = [
+ ['/terms', 'Terms of Service'],
+ ['/privacy', 'Privacy Policy'],
+ ['/dmca', 'DMCA & Copyright'],
+]
+
+interface Reply {
+ status: number
+ headers: IncomingHttpHeaders
+ body: string
+}
+
+let dir: string
+let server: Server
+let port = 0
+
+function listen(s: Server): Promise<number> {
+ return new Promise((resolve) =>
+ s.listen(0, '127.0.0.1', () => resolve((s.address() as AddressInfo).port)),
+ )
+}
+
+function send(
+ path: string,
+ opts: { method?: string; headers?: OutgoingHttpHeaders } = {},
+): Promise<Reply> {
+ return new Promise((resolve, reject) => {
+ const req = request(
+ {
+ host: '127.0.0.1',
+ port,
+ path,
+ method: opts.method ?? 'GET',
+ headers: { host: APEX, ...opts.headers },
+ },
+ (res) => {
+ const chunks: Buffer[] = []
+ res.on('data', (c: Buffer) => chunks.push(c))
+ res.on('end', () =>
+ resolve({
+ status: res.statusCode ?? 0,
+ headers: res.headers,
+ body: Buffer.concat(chunks).toString('utf8'),
+ }),
+ )
+ },
+ )
+ req.on('error', reject)
+ req.end()
+ })
+}
+
+beforeAll(async () => {
+ dir = mkdtempSync(join(tmpdir(), 'ovg-legal-'))
+ const dist = join(dir, 'dist')
+ mkdirSync(dist, { recursive: true })
+ writeFileSync(join(dist, 'index.html'), '<!doctype html><title>portal page</title>')
+ writeFileSync(join(dist, 'play.html'), '<!doctype html><title>game page</title>')
+ server = createHttpServer(dist, new ServerMetrics(), log)
+ port = await listen(server)
+})
+
+afterAll(() => {
+ server.close()
+ rmSync(dir, { recursive: true, force: true })
+})
+
+describe('legal pages (plan T11 decision 6)', () => {
+ it('serves each path as its own page: text/html, the title and a canonical on openvibe.games', async () => {
+ for (const [path, title] of PAGES) {
+ const r = await send(path)
+ expect(r.status).toBe(200)
+ expect(r.headers['content-type']).toBe('text/html; charset=utf-8')
+ expect(r.body).toContain(`<title>${title} — OpenVibe.Games</title>`)
+ expect(r.body).toContain(`<link rel="canonical" href="https://openvibe.games${path}">`)
+ expect(r.body).toContain(`<h1>${title}</h1>`)
+ }
+ })
+
+ it('answers HEAD like GET, headers only', async () => {
+ for (const [path] of PAGES) {
+ const r = await send(path, { method: 'HEAD' })
+ expect(r.status).toBe(200)
+ expect(r.headers['content-type']).toBe('text/html; charset=utf-8')
+ expect(r.body).toBe('')
+ }
+ })
+
+ it('still 404s a legal-ish path it does not own, through notFound.ts', async () => {
+ for (const path of ['/legal', '/terms/extra', '/tos', '/privacy.html']) {
+ const r = await send(path)
+ expect(r.status).toBe(404)
+ expect(r.headers['content-type']).toBe('text/html; charset=utf-8')
+ expect(r.body).toContain('Page not found')
+ }
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import { createRequire } from 'node:module'
+
+/**
+ * GET /terms, /privacy and /dmca (plan T11 decision 6): the site answers its own legal pages with
+ * openvibe-shared/legal, as every other product does. openvibe.games used to be answered by the
+ * OpenVibe.Sites placeholder (nginx served /opt/openvibe.sites/dist/openvibe.games for them); Sites
+ * is going away, so the pages move here.
+ *
+ * The profile is 'games': legal.js switches clauses by profile, and 'games' is the one that fits a
+ * game with accounts, chat and user-made maps — it adds the Games section (in development, play
+ * fair, no cheats, game-chat rules), the user-content licence (user-made maps) and, in the privacy
+ * document, the Game data bullet. The account clauses are included for every non-'info' profile.
+ *
+ * legal.handler is Express-shaped (this server is plain node:http, like sharedAssets.ts), so a tiny
+ * req/res shim stands in for Express: req.path and res.set/res.send are all it touches.
+ */
+interface LegalSite {
+ id: string
+ service: string
+ host: string
+ name: string
+ profile: string
+}
+interface LegalReq {
+ path: string
+}
+interface LegalRes {
+ set(name: string, value: string): void
+ send(body: string): void
+}
+interface LegalModule {
+ PATHS: string[]
+ handler(site: LegalSite): (req: LegalReq, res: LegalRes, next: () => void) => void
+}
+
+const SITE: LegalSite = {
+ id: 'games',
+ service: 'games',
+ host: 'openvibe.games',
+ name: 'OpenVibe.Games',
+ profile: 'games',
+}
+
+export function legalHandler(req: NodeRequire = createRequire(import.meta.url)) {
+ const legal = req('openvibe-shared/legal') as LegalModule
+ const paths = new Set(legal.PATHS)
+ // One handler for the process: openvibe-shared caches each rendered page after the first request.
+ const handle = legal.handler(SITE)
+ return (rq: IncomingMessage, res: ServerResponse): boolean => {
+ const url = (rq.url ?? '/').split('?')[0] ?? '/'
+ if (!paths.has(url)) return false
+ if (rq.method !== 'GET' && rq.method !== 'HEAD') {
+ res.writeHead(405, { allow: 'GET, HEAD' })
+ res.end()
+ return true
+ }
+ const headers: Record<string, string> = {}
+ handle(
+ { path: url },
+ {
+ set: (name, value) => {
+ headers[name.toLowerCase()] = value
+ },
+ send: (body) => {
+ res.writeHead(200, headers)
+ // node:http writes no body for HEAD; the headers are the same.
+ res.end(rq.method === 'HEAD' ? undefined : body)
+ },
+ },
+ () => {
+ // legal.PATHS and the shared handler's documents are the same list; unreachable, but a
+ // path it does not know must not fall through as a 200.
+ res.writeHead(404)
+ res.end()
+ },
+ )
+ return true
+ }
+}
@@ -1,6 +1,12 @@
@@ -1,6 +1,12 @@
# ADR-0003: Server physics = Havok wasm via Babylon NullEngine adapter
# ADR-0003: Server physics = Havok wasm via Babylon NullEngine adapter
-**Status:** accepted · **Date:** 2026-08-09
+**Status:** superseded by ADR-0007 · **Date:** 2026-08-09
+
+> Superseded 2026-09-29: ADR-0007 decision 2 replaces Havok and the Babylon
+> NullEngine host with Rapier on both sides, so `packages/physics/src/havok` and
+> the NullEngine scene are gone. The rule this ADR established — one physics
+> implementation behind `PhysicsWorld`, manual stepping, no engine types across
+> the boundary — is kept; only the engine changed.
## Decision
## Decision
@@ -60,6 +60,47 @@ no mobile path, and pins that lag the platform.
@@ -60,6 +60,47 @@ no mobile path, and pins that lag the platform.
12. **Pin drift is a defect.** Contracts and SDK pins move to current; CI fails when the installed contracts package
12. **Pin drift is a defect.** Contracts and SDK pins move to current; CI fails when the installed contracts package
rejects a manifest Games accepts, or the other way round.
rejects a manifest Games accepts, or the other way round.
+## M1 physics decisions (2026-09-29)
+
+Answers to the open questions in the M1 survey (`brief-games-m1-next.out.md`), decided after the state-hash
+harness (M1 step 2) had measured the mock against a real engine. Decisions 8–9 were taken because the
+cross-engine tolerance test forked on a discrete branch, not on floating-point noise.
+
+1. **Determinism means two things, tested separately.** Same runtime (Node against Node, the same inputs):
+ the movement state hash is bit-identical every tick; the test fails on any difference. Across runtimes
+ (server Node against a browser): positions agree within 1 mm and velocities within 1 cm/s per tick, and
+ the client reconciles (snaps to the server) past 5 cm. The movement kernel stays plain JavaScript (f64);
+ no shared f32 math kernel in M1. The tolerance is written here as a tolerance, never called bit-identity.
+2. **Rapier builds.** One pinned version of the deterministic build on both sides: the `-compat` package on
+ the server (Node, inlined WASM, no loader), the non-compat package in the browser with the `.wasm` as a
+ separate, cached, compressed asset. Physics loads with a dynamic import while the WebSocket ticket is
+ fetched, so it is not initial JavaScript and the M6 budget (2.5 MB initial JS) is untouched. A test
+ asserts both packages resolve to the same version.
+3. **Look input stays absolute.** Each input carries yaw and pitch as absolute angles quantized to int16
+ (1/65536 of a turn). Deltas add replay complexity for no M1 gain; revisit only if replay telemetry needs
+ them.
+4. **Actions are a bitfield of named flags.** The names and bits live in `packages/shared` as a frozen,
+ versioned list; adding a flag bumps the protocol version. Compact at 30 Hz, and M2's touch controls map
+ onto the same names.
+5. **No Rapier snapshots in M1.** Movement state lives in `PlayerMoveState`, not in the engine; prop and
+ island rollback are deferred past M1, so the snapshot API is not used and nothing is built for it.
+6. **Joints: functional equivalence in M1.** Welds hold rigid, ropes respect their length, springs return to
+ rest, each with a test; exact visual equivalence with Havok is refined in M2.
+7. **The intent enum lives in `packages/shared`**, the single source for protocol, gameplay and M2 touch;
+ `packages/gameplay/src/movement/buttons.ts` re-exports it.
+8. **`tryStepMove` gets a hysteresis band.** A contact within ±2 mm of the step threshold keeps the previous
+ tick's branch, so a millimetre of contact ambiguity between engines cannot flip step-up or auto-bhop.
+9. **The mock resolves a rounded capsule against a box edge** (a real convex sweep, not a padded AABB), with
+ the conformance suite's landing heights re-measured.
+
+## M1 step 4 finding: Rapier shape casts lose precision on very large colliders
+
+Measured while building the Rapier adapter (M1 steps 4–7). A diagonal `castShape` of the player capsule
+against a 400 m-long static box reports contact ~14 mm before the true face, while the same cast against a
+20 m box is exact to the f32 epsilon. The returned `time_of_impact` is inconsistent with the returned
+contact witnesses in that case. Consequences: determinism scene geometry is kept to sane extents, and the
+cross-engine tolerance is a per-tick bound, not bit-identity (decision 1).
+
## Deleted
## Deleted
The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegacy.ts`, `legacy_live_rows`,
The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegacy.ts`, `legacy_live_rows`,
@@ -1,9 +1,6 @@
@@ -1,9 +1,6 @@
-import { readFile } from 'node:fs/promises'
-import { createRequire } from 'node:module'
-import { pathToFileURL } from 'node:url'
-import { vec3 } from '@openvibe/shared'
+import { vec3, type Vec3 } from '@openvibe/shared'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
-import { CollisionLayer, type PhysicsWorld } from '@openvibe/physics'
+import { CollisionLayer, type BodyDesc, type PhysicsWorld } from '@openvibe/physics'
import { createMockPhysicsWorld, mockCollisionQueries } from '@openvibe/physics/mock'
import { createMockPhysicsWorld, mockCollisionQueries } from '@openvibe/physics/mock'
import { Buttons } from './buttons.js'
import { Buttons } from './buttons.js'
import type { CollisionQueries } from './collision.js'
import type { CollisionQueries } from './collision.js'
@@ -34,13 +31,12 @@ import { createMoveState, stepMovement, type MoveInput, type PlayerMoveState } f
@@ -34,13 +31,12 @@ import { createMoveState, stepMovement, type MoveInput, type PlayerMoveState } f
* state in place through module-level scratch vectors, a test that re-uses
* state in place through module-level scratch vectors, a test that re-uses
* one state object for both runs would "pass" trivially — running two
* one state object for both runs would "pass" trivially — running two
* genuinely separate states is the point.
* genuinely separate states is the point.
- * b. **Cross-world, within tolerance.** The same script against the Havok
+ * b. **Cross-world, within tolerance.** The same script against the Rapier
* adapter and the mock. Position ≤ 1 mm and velocity ≤ 1 cm/s per tick
* adapter and the mock. Position ≤ 1 mm and velocity ≤ 1 cm/s per tick
* (ADR-0007 M1 decision 1). This is a TOLERANCE between two engines, not
* (ADR-0007 M1 decision 1). This is a TOLERANCE between two engines, not
- * bit-identity, and it is the one that will force tuning of the mock or the
- * adapter when Rapier lands. If the engines genuinely disagree past the
- * tolerance, the tolerance is NOT to be loosened: the case names the first
- * diverging tick and field, and the fix is the engine, not the number.
+ * bit-identity. If the engines genuinely disagree past the tolerance, the
+ * tolerance is NOT to be loosened: the case names the first diverging tick
+ * and field, and the fix is the engine, not the number.
* c. **Replay.** Inputs (seq, input) recorded for 600 ticks, state snapshotted
* c. **Replay.** Inputs (seq, input) recorded for 600 ticks, state snapshotted
* at tick 300, replay 300..600 from the snapshot — equal hashes. This is
* at tick 300, replay 300..600 from the snapshot — equal hashes. This is
* the input-replay path (apps/client/src/game/localPlayer.ts and the
* the input-replay path (apps/client/src/game/localPlayer.ts and the
@@ -139,13 +135,13 @@ interface Engine {
@@ -139,13 +135,13 @@ interface Engine {
}
}
/**
/**
- * The wasm-backed adapter, loaded from `@openvibe/physics/havok` — the same
- * entry point apps/server uses. It is loaded through a DYNAMIC import from
- * the real path rather than a static one because `@babylonjs/havok` is a
- * dependency of `@openvibe/physics`, not of `@openvibe/gameplay`, and this
+ * The wasm-backed adapter, loaded from `@openvibe/physics/rapier` — the same
+ * entry point apps/server and apps/client use. It is loaded through a DYNAMIC
+ * import rather than a static one because `@dimforge/rapier3d-deterministic-compat`
+ * is a dependency of `@openvibe/physics`, not of `@openvibe/gameplay`, and this
* package deliberately does not import an engine (ADR-0007 step 6 wants the
* package deliberately does not import an engine (ADR-0007 step 6 wants the
- * gameplay kernel engine-free; a static import here would put Babylon back on
- * the gameplay side of the boundary).
+ * gameplay kernel engine-free; a static import would put an engine back on the
+ * gameplay side of the boundary).
*
*
* This test is the ONLY case in the file that needs an engine, so when the
* This test is the ONLY case in the file that needs an engine, so when the
* load fails the failure is a skip, never a red suite: a missing wasm is an
* load fails the failure is a skip, never a red suite: a missing wasm is an
@@ -154,30 +150,35 @@ interface Engine {
@@ -154,30 +150,35 @@ interface Engine {
*/
*/
const notes: string[] = []
const notes: string[] = []
+/** The loaded engine, kept for the scene corpus below; null skips its cases. */
+let rapierEngine: { createWorld: (bodies: BodyDesc[]) => PhysicsWorld } | null = null
+
/**
/**
* Loaded at collection time, not in `beforeAll`: the cross-world `describe`s
* Loaded at collection time, not in `beforeAll`: the cross-world `describe`s
* below are registered from the engine list, and vitest collects before it
* below are registered from the engine list, and vitest collects before it
* runs any hook, so a `beforeAll` would always see an empty list.
* runs any hook, so a `beforeAll` would always see an empty list.
+ *
+ * Loaded through `@openvibe/physics/rapier`, whose `loadRapier()` owns the
+ * dynamic import of the wasm build — the gameplay kernel never imports an
+ * engine (ADR-0007 step 6 wants it engine-free). The failure is a skip, never
+ * a red suite: a missing wasm is an environment fact, not a determinism defect.
*/
*/
const engines: Engine[] = []
const engines: Engine[] = []
try {
try {
- // Resolved from inside @openvibe/physics, which is where @babylonjs/havok is
- // a declared dependency. Asking for it from this package (which does not
- // depend on it) would be a bare specifier vite cannot resolve — and adding
- // the dependency here would put an engine back on the gameplay side of the
- // boundary ADR-0007 is removing. The package's exports map allows only the
- // bare specifier for the module itself, so resolve the bare name to a real
- // path with the physics package's own require and import that file directly.
- const fromPhysics = createRequire(createRequire(import.meta.url).resolve('@openvibe/physics'))
- const havokEntry = fromPhysics.resolve('@babylonjs/havok')
- const wasmPath = fromPhysics.resolve('@babylonjs/havok/lib/esm/HavokPhysics.wasm')
- const { default: HavokPhysics } = await import(pathToFileURL(havokEntry).href)
- const { createHeadlessHavokWorld } = await import('@openvibe/physics/havok')
- const havok = await HavokPhysics({ wasmBinary: (await readFile(wasmPath)).buffer as ArrayBuffer })
+ const { createRapierWorld, loadRapier } = await import('@openvibe/physics/rapier')
+ const rapier = await loadRapier()
+ rapierEngine = {
+ createWorld: (bodies) => {
+ const world = createRapierWorld(rapier)
+ for (const body of bodies) world.addBody(body)
+ live.push(world)
+ return world
+ },
+ }
engines.push({
engines.push({
- name: 'HavokWorld',
+ name: 'RapierWorld',
create: () => {
create: () => {
- const world = createHeadlessHavokWorld(havok)
+ const world = createRapierWorld(rapier)
buildScriptedGeometry((desc) => world.addBody(desc))
buildScriptedGeometry((desc) => world.addBody(desc))
live.push(world)
live.push(world)
return {
return {
@@ -186,7 +187,7 @@ try {
@@ -186,7 +187,7 @@ try {
}
}
},
},
})
})
- notes.push('cross-world cases: MockPhysicsWorld vs HavokWorld (wasm loaded)')
+ notes.push('cross-world cases: MockPhysicsWorld vs RapierWorld (wasm loaded)')
} catch (err) {
} catch (err) {
notes.push(
notes.push(
'cross-world cases NOT RUN — the wasm engine could not be loaded: ' +
'cross-world cases NOT RUN — the wasm engine could not be loaded: ' +
@@ -340,38 +341,38 @@ describe('movement determinism: same runtime', () => {
@@ -340,38 +341,38 @@ describe('movement determinism: same runtime', () => {
* change of engine rather than a change of game, and it is the one case in
* change of engine rather than a change of game, and it is the one case in
* this file that must be re-measured when the engine changes.
* this file that must be re-measured when the engine changes.
*
*
- * ── MEASURED 2026-09-29, MockPhysicsWorld vs HavokWorld 1.3.10 ───────────
- * The scripted run does NOT meet the 1 mm / 1 cm/s tolerance, and the reason
- * is a branch, not floating-point noise — so no tolerance can absorb it. Two
- * discrete events fork, in this order:
+ * ── MEASURED 2026-09-29, MockPhysicsWorld vs RapierWorld (deterministic 0.21.0) ──
+ * The 600-tick scripted run does NOT meet the 1 mm / 1 cm/s tolerance, and the
+ * reason is not floating-point noise:
*
*
- * 1. **tick 102, first contact with the wall.** Both engines are grounded at
- * the same point, but `slideMove`'s blocked→`tryStepMove` probe sweeps the
- * capsule UP by `stepHeight` (0.45) and the wall is 3 m tall, so the probe
- * is a face-on sweep at head height. Havok's convex-cast shape queries
- * report the exact 0.4 m capsule width to its own f32; the mock's
- * conservative AABB expansion reports it a millimetre wider, so the mock
- * stops fraction 0.0 earlier and the two land 15 mm apart on x. Positions
- * that were bit-identical to the tick before stop being.
- * Measured: pos.x 1.507e-2, pos.z 1.998e-2, rising to 5.9e-2 by tick 106.
- * 2. **tick 147, the auto-bhop.** The divergence from (1) is inside the
- * 0.7 m/s `checkGround` release threshold, so one engine leaves the ground
- * a tick before the other, lands a tick later, and the two never rejoin:
- * pos.y 1.953e-3 at tick 147, 6.478e-3 by tick 151. Removing the wall does
- * not remove this one — it simply moves it.
- * 3. By tick 599 the accumulated difference is 460 m: the player has been
- * falling through the world for four hundred ticks, and the last ticks of
- * the script put the two hundreds of metres apart.
+ * 1. **The engines disagree by a few tenths of a millimetre per contact.**
+ * Rapier's `castShape` reports the wall face ~0.28 mm early (measured: the
+ * mock's contact fraction 0.51105 against Rapier's 0.49065 on a sweep that
+ * grazes the face at 13:1). On a face-on contact that is invisible; on a
+ * sweep nearly PARALLEL to the face a small normal-direction error becomes a
+ * large tangential one, so each sliding tick advances a few millimetres
+ * further in the mock than in Rapier and the gap accumulates. First
+ * divergence: tick 97–100 [pos.x, pos.z].
+ * 2. **A discrete branch then flips.** Once the accumulated gap reaches the
+ * step-up / auto-bhop thresholds, one engine takes the step or the hop and
+ * the other does not, and the runs never rejoin: max drift 4.6e+2 m at tick
+ * 599 (the bhop phase from tick 140 leaves one run falling through the
+ * world).
*
*
- * That is the honest answer, and it is what step 4's Rapier swap is measured
- * against. The tolerance assertion below is marked `it.fails`, as the brief
- * directs, with the divergence named above and the numbers printed on every
- * run; the tolerance itself is NOT widened to make it pass. The two cases
- * beside it — the same runtime against a second real engine, and the
- * resting-state control — both pass, so what is failing is specifically
- * cross-engine contact geometry, not determinism itself.
+ * Consequences, kept honest rather than papered over:
+ * - The BIT-EXACT claim (two independently built Rapier worlds) IS met, on this
+ * script and on every scene of the corpus below. That is the claim prediction
+ * needs (ADR-0007 decision 1, "same runtime").
+ * - The 1 mm / 1 cm/s tolerance is a CROSS-RUNTIME bound for the SAME build
+ * (decision 1), and it holds there; between the mock and Rapier it holds only
+ * where no contact is sustained (flat ground: measured 1.2e-5 m over 90
+ * ticks). It is therefore asserted for the flat-ground scene and recorded as
+ * `it.fails` for the contact scenes, with the numbers printed on every run.
+ * Widening POSITION_TOLERANCE_M or VELOCITY_TOLERANCE_MPS would hide a real
+ * engine property, so it is not done.
*
*
- * `engines` is where step 4 adds `createRapierWorld()`.
+ * `engines` is where the Rapier adapter is registered; the corpus describes its
+ * own geometry per scene.
*/
*/
/** The worst cross-world drift over a full scripted run, and where it starts. */
/** The worst cross-world drift over a full scripted run, and where it starts. */
@@ -510,6 +511,202 @@ describe('movement determinism: cross-world within tolerance', () => {
@@ -510,6 +511,202 @@ describe('movement determinism: cross-world within tolerance', () => {
for (const engine of engines) crossWorld(engine)
for (const engine of engines) crossWorld(engine)
})
})
+// ── b2. the scene corpus (ADR-0007 M1 step 7) ────────────────────────────
+
+/**
+ * The five scenes the M1 definition of done names: flat ground, stairs, a wall
+ * slide, a terrain trimesh patch, a prop island. Each is static geometry plus
+ * one scripted 90-tick input run, so a scene failure names itself.
+ *
+ * Two claims are tested per scene:
+ *
+ * 1. **Two independently built worlds agree bit-for-bit.** Server and client
+ * build their own world from the same content and run the same input stream;
+ * this asserts the same `stateHash` on every tick, which is exactly
+ * "same input stream → same stateHash at the same tick". Green everywhere,
+ * the trimesh patch included — this is the claim prediction rests on.
+ * 2. **The mock and Rapier stay inside the cross-engine tolerance.** Measured
+ * below and printed every run; only flat ground meets it, and the block
+ * comment above says why the contact scenes do not.
+ */
+
+const corpusBox = (size: [number, number, number], pos: Vec3): BodyDesc => ({
+ shape: { type: 'box', size },
+ motion: 'static',
+ pos,
+ layer: CollisionLayer.Static,
+ collidesWith: CollisionLayer.Prop | CollisionLayer.Player,
+})
+
+const corpusFloor = (): BodyDesc => corpusBox([60, 1, 60], vec3(0, -0.5, 0))
+
+/** Four 0.2 m steps rising from z = 3 — what `tryStepMove` exists for. */
+const corpusStairs = (): BodyDesc[] =>
+ Array.from({ length: 4 }, (_, i) => corpusBox([6, 0.2, 0.4], vec3(0, 0.1 + i * 0.2, 3 + i * 0.4)))
+
+/** A 4 m ramp patch of two triangles — the shape the terrain layer builds. */
+const corpusTerrain = (): BodyDesc => ({
+ shape: {
+ type: 'trimesh',
+ positions: new Float32Array([-4, 0, 3, 4, 0, 3, 4, 0.4, 7, -4, 0.4, 7]),
+ indices: new Uint32Array([0, 1, 2, 0, 2, 3]),
+ },
+ motion: 'static',
+ pos: vec3(0, 0, 0),
+ layer: CollisionLayer.Static,
+ collidesWith: CollisionLayer.Prop | CollisionLayer.Player,
+})
+
+/**
+ * A 3x3 island of boxes. Static, because the mock has no body-body contacts:
+ * what a prop island exercises at the seam is its collision silhouettes (box
+ * faces, edges and corners), not the stacking the mock cannot model.
+ */
+const corpusIsland = (): BodyDesc[] => {
+ const out: BodyDesc[] = []
+ for (let x = -1; x <= 1; x++)
+ for (let z = -1; z <= 1; z++) out.push(corpusBox([0.8, 0.8, 0.8], vec3(x * 0.9, 0.4, 6 + z * 0.9)))
+ return out
+}
+
+const corpusTicks = (n: number, f: (t: number) => MoveInput): RecordedInput[] =>
+ Array.from({ length: n }, (_, t) => ({ seq: t, input: f(t) }))
+
+const corpusWalk = (_t: number): MoveInput => ({ moveX: 0, moveZ: 1, yaw: 0, pitch: 0, buttons: 0 })
+/** Walks into the wall at x = 4 at an angle, so the capsule has to slide along it. */
+const corpusIntoWall = (t: number): MoveInput => ({
+ moveX: 0,
+ moveZ: 1,
+ yaw: 0.35,
+ pitch: 0,
+ buttons: t >= 45 ? Buttons.Sprint : 0,
+})
+
+interface CorpusScene {
+ name: string
+ bodies: BodyDesc[]
+ script: RecordedInput[]
+ /** The mock has no triangle queries, so the terrain patch runs on real worlds only. */
+ mockExpressible: boolean
+}
+
+const CORPUS: CorpusScene[] = [
+ { name: 'flat ground', bodies: [corpusFloor()], script: corpusTicks(90, corpusWalk), mockExpressible: true },
+ {
+ name: 'stairs',
+ bodies: [corpusFloor(), ...corpusStairs()],
+ script: corpusTicks(90, corpusWalk),
+ mockExpressible: true,
+ },
+ {
+ name: 'wall slide',
+ bodies: [corpusFloor(), corpusBox([1, 3, 20], vec3(4, 1.5, 0))],
+ script: corpusTicks(90, corpusIntoWall),
+ mockExpressible: true,
+ },
+ {
+ name: 'terrain trimesh patch',
+ bodies: [corpusFloor(), corpusTerrain()],
+ script: corpusTicks(90, corpusWalk),
+ mockExpressible: false,
+ },
+ {
+ name: 'prop island',
+ bodies: [corpusFloor(), ...corpusIsland()],
+ script: corpusTicks(90, corpusWalk),
+ mockExpressible: true,
+ },
+]
+
+function mockQueries(bodies: BodyDesc[]): CollisionQueries {
+ const world = createMockPhysicsWorld()
+ for (const body of bodies) world.addBody(body)
+ live.push(world)
+ return mockCollisionQueries(world, MOVEMENT_MASK)
+}
+
+function engineQueries(scene: CorpusScene): CollisionQueries {
+ const world = rapierEngine!.createWorld(scene.bodies)
+ return {
+ sweepCapsule: (from, to, radius, height) =>
+ world.sweepCapsule(from, to, radius, height, MOVEMENT_MASK),
+ }
+}
+
+/** Runs one script through two queries, tick by tick, and reports the worst drift. */
+function measureScriptPair(
+ a: CollisionQueries,
+ b: CollisionQueries,
+ script: RecordedInput[],
+): { position: number; velocity: number; firstBadTick: number } {
+ const sa = spawn()
+ const sb = spawn()
+ let position = 0
+ let velocity = 0
+ let firstBadTick = -1
+ for (let t = 0; t < script.length; t++) {
+ stepMovement(sa, script[t]!.input, P, a, DT)
+ stepMovement(sb, script[t]!.input, P, b, DT)
+ const d = divergence(sa, sb)
+ if (d.maxPosition > position) position = d.maxPosition
+ if (d.maxVelocity > velocity) velocity = d.maxVelocity
+ if (!d.equal && firstBadTick < 0) firstBadTick = t
+ }
+ return { position, velocity, firstBadTick }
+}
+
+describe('movement determinism: the scene corpus (ADR-0007 M1 step 7)', () => {
+ for (const scene of CORPUS) {
+ it(`${scene.name}: the same input stream hashes identically in two independently built worlds`, () => {
+ expect(rapierEngine, notes.join('; ')).not.toBeNull()
+ const a = runScript(engineQueries(scene), scene.script)
+ const b = runScript(engineQueries(scene), scene.script)
+ for (let t = 0; t < scene.script.length; t++) {
+ expect(a[t], `tick ${t}: ${a[t]} vs ${b[t]}`).toBe(b[t])
+ }
+ })
+ }
+
+ it('measures the mock-vs-Rapier drift for every scene the mock can express', () => {
+ expect(rapierEngine, notes.join('; ')).not.toBeNull()
+ const lines: string[] = []
+ for (const scene of CORPUS) {
+ if (!scene.mockExpressible) continue
+ const m = measureScriptPair(mockQueries(scene.bodies), engineQueries(scene), scene.script)
+ lines.push(
+ `${scene.name}: pos ${m.position.toExponential(3)} m, vel ${m.velocity.toExponential(3)} m/s ` +
+ `(first tick ${m.firstBadTick})`,
+ )
+ }
+ console.log(`[determinism corpus] ${lines.join(' | ')}`)
+ expect(lines.length).toBeGreaterThan(0)
+ })
+
+ it('holds the cross-engine tolerance on flat ground', () => {
+ expect(rapierEngine, notes.join('; ')).not.toBeNull()
+ const scene = CORPUS[0] as CorpusScene
+ const m = measureScriptPair(mockQueries(scene.bodies), engineQueries(scene), scene.script)
+ expect(m.position).toBeLessThanOrEqual(POSITION_TOLERANCE_M)
+ expect(m.velocity).toBeLessThanOrEqual(VELOCITY_TOLERANCE_MPS)
+ })
+
+ it.fails('holds the cross-engine tolerance on the contact scenes', () => {
+ // Stairs, the wall slide and the prop island all exceed it, for the reason
+ // measured in the block comment above (a few tenths of a millimetre of
+ // contact slop per touch, amplified tangentially by a near-parallel slide,
+ // then a discrete branch flip). Inverted so the suite stays green while the
+ // claim is visibly unproven; if it ever starts passing, vitest flips this
+ // line to FAILING and `it.fails` comes off.
+ expect(rapierEngine, notes.join('; ')).not.toBeNull()
+ for (const scene of CORPUS) {
+ if (!scene.mockExpressible) continue
+ const m = measureScriptPair(mockQueries(scene.bodies), engineQueries(scene), scene.script)
+ expect(m.position, `${scene.name} position`).toBeLessThanOrEqual(POSITION_TOLERANCE_M)
+ expect(m.velocity, `${scene.name} velocity`).toBeLessThanOrEqual(VELOCITY_TOLERANCE_MPS)
+ }
+ })
+})
+
// ── c. replay ─────────────────────────────────────────────────────────────
// ── c. replay ─────────────────────────────────────────────────────────────
describe('movement determinism: replay from a snapshot', () => {
describe('movement determinism: replay from a snapshot', () => {
@@ -5,15 +5,14 @@
@@ -5,15 +5,14 @@
"type": "module",
"type": "module",
"exports": {
"exports": {
".": "./src/index.ts",
".": "./src/index.ts",
- "./havok": "./src/havok/index.ts",
+ "./rapier": "./src/rapier/index.ts",
"./mock": "./src/mock.ts"
"./mock": "./src/mock.ts"
},
},
"scripts": {
"scripts": {
"build": "tsc --build"
"build": "tsc --build"
},
},
"dependencies": {
"dependencies": {
- "@babylonjs/core": "^9.20.0",
- "@babylonjs/havok": "^1.3.10",
+ "@dimforge/rapier3d-deterministic-compat": "0.21.0",
"@openvibe/shared": "workspace:*"
"@openvibe/shared": "workspace:*"
}
}
}
}
@@ -1,9 +1,6 @@
@@ -1,9 +1,6 @@
import { readFile } from 'node:fs/promises'
import { readFile } from 'node:fs/promises'
-import { createRequire } from 'node:module'
import { quat, vec3, type Quat, type Vec3 } from '@openvibe/shared'
import { quat, vec3, type Quat, type Vec3 } from '@openvibe/shared'
import { beforeAll, describe, expect, it } from 'vitest'
import { beforeAll, describe, expect, it } from 'vitest'
-import HavokPhysics from '@babylonjs/havok'
-import { createHeadlessHavokWorld } from './havok/index.js'
import {
import {
MOCK_GRAVITY,
MOCK_GRAVITY,
MOCK_SETTLE_ANGULAR,
MOCK_SETTLE_ANGULAR,
@@ -11,62 +8,46 @@ import {
@@ -11,62 +8,46 @@ import {
MOCK_TICK,
MOCK_TICK,
createMockPhysicsWorld,
createMockPhysicsWorld,
} from './mock.js'
} from './mock.js'
+import { createRapierWorld, loadRapier, type RapierModule } from './rapier/index.js'
import { CollisionLayer, type BodyDesc, type PhysicsWorld } from './types.js'
import { CollisionLayer, type BodyDesc, type PhysicsWorld } from './types.js'
/**
/**
* Seam conformance.
* Seam conformance.
*
*
* Every case runs against whatever PhysicsWorld factory is handed to
* Every case runs against whatever PhysicsWorld factory is handed to
- * `conformance()`, so a second engine (ADR-0007 decision 2 names Rapier) can be
- * held to exactly the behaviour Havok has today. Nothing in this file may reach
- * around the seam: no Babylon, no Havok, no assumption about internals beyond
- * what `PhysicsWorld` promises.
+ * `conformance()`. The real engine is Rapier (ADR-0007 decision 2); the mock is
+ * the wasm-free oracle. Nothing in this file may reach around the seam: no
+ * engine type, no assumption about internals beyond what `PhysicsWorld`
+ * promises.
*
*
- * ── Constants, read off havokWorld.ts rather than the brief ──────────────
- * - gravity (0, -16.5, 0) havokWorld.ts:588
- * - material friction 0.75 / static 0.85 / restitution 0.05 :123
- * - linear damping 0.05, angular 0.6 :131-132
- * - default mass 10 kg :128
- * - settle thresholds 0.05 m/s, 0.15 rad/s :72-73 (squared compare, angular `<=`)
+ * ── Constants the adapters are built from ────────────────────────────────
+ * - gravity (0, -16.5, 0) rapierWorld.ts RAPIER_GRAVITY
+ * - material friction 0.75, restitution 0.05 rapierWorld.ts
+ * - linear damping 0.05, angular 0.6 rapierWorld.ts
+ * - default mass 10 kg rapierWorld.ts
+ * - settle thresholds 0.05 m/s, 0.15 rad/s (squared compare, angular `<=`)
*
*
* ── Behaviour asserted here that is NOT in the seam doc comment ──────────
* ── Behaviour asserted here that is NOT in the seam doc comment ──────────
- * These are properties of today's Havok adapter, documented so the Rapier port
- * reproduces them rather than trips over them:
+ * The Rapier adapter sets these explicitly and this suite holds it to them:
*
*
- * 1. **`step(dt)` does not honour its argument.** `HavokWorld.step` calls
- * `plugin.executeStep(dt, bodies)`, and the Havok plugin resolves the world
- * step time as `this._useDeltaForWorldStep ? delta : this._fixedTimeStep`
- * with `_useDeltaForWorldStep` false by default — so every step() advances a
- * fixed 1/60 s of simulation regardless of the dt passed in. Measured: 30
- * step(1/30) and 30 step(1/15) both yield v.y = -8.14 m/s, while 60
- * step(1/60) yields -16.09. The seam comment says "advances by a fixed dt",
- * which is the *intent*; the caller-side 30 Hz tick is what the game relies
- * on and it is unaffected, but a Rapier adapter that actually honours dt
- * would be ~2× faster per tick at 30 Hz and would change every simulation
- * constant tuned against Havok. Listed for Opus.
+ * 1. **`step(dt)` honours its argument.** Rapier's integration parameters take
+ * the dt directly, so 30 steps of 1/30 and 60 steps of 1/60 are the same
+ * simulated second (the mock agrees; Havok did not, which is why the adapter
+ * exposes `stepSeconds` and the gravity cases convert through it).
* 2. **Waking or settling a body at the exact threshold.** The code compares
* 2. **Waking or settling a body at the exact threshold.** The code compares
- * `len² > threshold²` for linear (so a body moving at exactly 0.05 m/s is
- * reported *unsettled* by Havok's float storage, though the comparison says
- * settled) and `<=` for angular. The mock implements the comparison as
- * written; this suite therefore probes just under and just over each
- * threshold and does not pin the exact-boundary behaviour.
- * 3. **Raycast filtering is by the body's own `layer`.** `plugin.raycast` is
- * called with `membership: 0xffffffff, collideWith: collidesWith`
- * (havokWorld.ts:415-418), but the shape filter is a membership/collide
- * pair, so a body is hit when `rayMask & body.layer` and `body.layer &
- * body.collidesWith` — the body's `collidesWith` must include its own layer
- * for the pair to pass. Measured: layer Player / collidesWith Static is hit
- * by mask 4 but not by mask 1, while layer Static / collidesWith Prop is hit
- * by mask 1, 2, 3 and 7. The mock filters on `rayMask & body.layer` only,
- * which is a superset — see "what the mock does not model".
+ * `len² > threshold²` for linear and `<=` for angular; this suite probes just
+ * under and just over each threshold and does not pin exact-boundary float
+ * behaviour.
+ * 3. **Raycast filtering is by the target's own `layer`.** The query's
+ * membership is all-on and the filter is the caller's mask, so a body is hit
+ * exactly when `rayMask & body.layer`. The mock filters the same way.
* 4. **A welded body resists velocity changes for a few steps.** `setLinearVelocity`
* 4. **A welded body resists velocity changes for a few steps.** `setLinearVelocity`
* on one half of a weld writes the velocity, but the joint's relative-pose
* on one half of a weld writes the velocity, but the joint's relative-pose
* preservation pulls the pair back over the next couple of steps. Asserted
* preservation pulls the pair back over the next couple of steps. Asserted
* here as "the pair stays together", not as free sliding.
* here as "the pair stays together", not as free sliding.
- * 5. **`isSettled`, `wake` and `applyForce` ignore non-dynamic bodies**
- * (havokWorld.ts:225, :233, :406), and `removeBody` / `removeConstraint` on
- * an unknown id are silent no-ops (:142, :399) while every other body
- * accessor throws `unknown physics body <id>` (:482).
+ * 5. **`isSettled`, `wake` and `applyForce` ignore non-dynamic bodies**, and
+ * `removeBody` / `removeConstraint` on an unknown id are silent no-ops while
+ * every other body accessor throws `unknown physics body <id>`.
*/
*/
const DT = 1 / 30
const DT = 1 / 30
@@ -90,20 +71,17 @@ interface Adapter {
@@ -90,20 +71,17 @@ interface Adapter {
stepSeconds: number
stepSeconds: number
}
}
-/** HavokWorld over a NullEngine scene — the exact headless server code path. */
-let havok: unknown
+/** RapierWorld over the deterministic wasm build — the exact headless server code path. */
+let rapier: RapierModule
beforeAll(async () => {
beforeAll(async () => {
- const require = createRequire(import.meta.url)
- const wasmPath = require.resolve('@babylonjs/havok/lib/esm/HavokPhysics.wasm')
- havok = await HavokPhysics({ wasmBinary: (await readFile(wasmPath)).buffer as ArrayBuffer })
+ rapier = await loadRapier()
}, 60_000)
}, 60_000)
-const havokAdapter: Adapter = {
- name: 'HavokWorld',
- create: () => createHeadlessHavokWorld(havok),
- // Measured (header note 1): the plugin ignores the dt passed to step() and
- // advances its own fixed 1/60 s.
- stepSeconds: 1 / 60,
+const rapierAdapter: Adapter = {
+ name: 'RapierWorld',
+ create: () => createRapierWorld(rapier),
+ // Rapier honours the dt passed to step(); 30 Hz is the tick the game runs at.
+ stepSeconds: DT,
}
}
/**
/**
@@ -775,7 +753,10 @@ function conformance(a: Adapter): void {
@@ -775,7 +753,10 @@ function conformance(a: Adapter): void {
stepFor(w, 60)
stepFor(w, 60)
w.getTransform(a, pa, rot())
w.getTransform(a, pa, rot())
w.getTransform(b, pb, rot())
w.getTransform(b, pb, rot())
- expect(pb.y - pa.y).toBeCloseTo(0.5, 1)
+ // "Stays rigid" is the claim worth pinning: the weld keeps the pair's
+ // separation at the creation-time offset. (A Rapier fixed joint also
+ // allows the island to rotate, so the offset need not stay vertical.)
+ expect(Math.hypot(pb.x - pa.x, pb.y - pa.y, pb.z - pa.z)).toBeCloseTo(0.5, 1)
expect(pb.z - pa.z).toBeCloseTo(0, 1)
expect(pb.z - pa.z).toBeCloseTo(0, 1)
close()
close()
},
},
@@ -784,20 +765,10 @@ function conformance(a: Adapter): void {
@@ -784,20 +765,10 @@ function conformance(a: Adapter): void {
it('removing a weld frees the follower', () => {
it('removing a weld frees the follower', () => {
// What "freed" means at the seam: once the joint is gone the two bodies
// What "freed" means at the seam: once the joint is gone the two bodies
// are independent, so a velocity change on the follower moves the
// are independent, so a velocity change on the follower moves the
- // follower and leaves the driver exactly where it was.
- //
- // It deliberately does NOT assert that the follower then rests at some
- // particular height. On a stacked pair resting on the ground that is
- // engine behaviour and the two engines disagree: the mock has no
- // body-body contacts, so the follower drops straight through back to the
- // floor (0.25) inside this window, while Havok's solver keeps it on the
- // driver and carries it to 1.609. Both are "no longer welded", which is
- // the property that belongs to the seam; a per-engine landing height is
- // pinned separately in each engine branch. Measured: with the pair
- // grounded and the follower kicked to +6 m/s after removal, Havok leaves
- // the driver at 0.250 and the follower at 1.609 after 30 steps; the mock
- // leaves the driver at 0.250 and the follower at 0.250, having already
- // arced up and landed back on the floor.
+ // follower and leaves the driver exactly where it was. The follower is
+ // kicked HORIZONTALLY so the claim does not depend on either engine's
+ // contact solver: under a weld it could not move without dragging the
+ // driver, and the mock's missing body-body contacts cannot mask it.
const w = open()
const w = open()
w.addBody(groundDesc())
w.addBody(groundDesc())
const a = w.addBody(dynamicBoxDesc(vec3(0, 0.25, 0), 0.5, 10))
const a = w.addBody(dynamicBoxDesc(vec3(0, 0.25, 0), 0.5, 10))
@@ -808,17 +779,17 @@ function conformance(a: Adapter): void {
@@ -808,17 +779,17 @@ function conformance(a: Adapter): void {
const pa = pos()
const pa = pos()
w.getTransform(a, pa, rot())
w.getTransform(a, pa, rot())
expect(pa.y).toBeCloseTo(0.25, 2)
expect(pa.y).toBeCloseTo(0.25, 2)
- w.setLinearVelocity(b, vec3(0, 6, 0))
+ w.setLinearVelocity(b, vec3(0, 0, 6))
stepFor(w, 30)
stepFor(w, 30)
const pa2 = pos()
const pa2 = pos()
const pb = pos()
const pb = pos()
w.getTransform(a, pa2, rot())
w.getTransform(a, pa2, rot())
w.getTransform(b, pb, rot())
w.getTransform(b, pb, rot())
- // The driver did not move at all, and the follower has left the pose the
- // weld held it in (0.75) — up on Havok, down through the missing contact
- // on the mock. A still-welded follower could not do either.
+ // The driver did not move, and the follower slid away — a still-welded
+ // follower could not do either.
expect(pa2.y).toBeCloseTo(0.25, 2)
expect(pa2.y).toBeCloseTo(0.25, 2)
- expect(Math.abs(pb.y - 0.75)).toBeGreaterThan(0.1)
+ expect(pa2.z).toBeCloseTo(0, 2)
+ expect(Math.abs(pb.z)).toBeGreaterThan(0.1)
close()
close()
})
})
@@ -1364,11 +1335,10 @@ function conformance(a: Adapter): void {
@@ -1364,11 +1335,10 @@ function conformance(a: Adapter): void {
expect(dead).toEqual([])
expect(dead).toEqual([])
})
})
} else {
} else {
- it('a freed follower stays on the driver on Havok’s solver', () => {
- // The Havok half of "removing a weld frees the follower": the contact
- // solver keeps the upper box stacked on the lower one, so after the same
- // 30-step window the shared case finds it up at ~1.6 rather than back
- // down on the floor. Measured: 1.609.
+ it('a freed follower lands back on the driver via the contact solver', () => {
+ // Rapier, like Havok, has a contact solver: after the follower is kicked
+ // vertically off a freed weld it lands back on top of the lower box
+ // (measured ~0.75), rather than falling through as the mock does.
const w = open()
const w = open()
w.addBody(groundDesc())
w.addBody(groundDesc())
const a = w.addBody(dynamicBoxDesc(vec3(0, 0.25, 0), 0.5, 10))
const a = w.addBody(dynamicBoxDesc(vec3(0, 0.25, 0), 0.5, 10))
@@ -1377,16 +1347,17 @@ function conformance(a: Adapter): void {
@@ -1377,16 +1347,17 @@ function conformance(a: Adapter): void {
stepFor(w, 60)
stepFor(w, 60)
w.removeConstraint(weld)
w.removeConstraint(weld)
w.setLinearVelocity(b, vec3(0, 6, 0))
w.setLinearVelocity(b, vec3(0, 6, 0))
+ // 30 steps at 30 Hz is one second: the follower arcs up ~1 m and lands.
stepFor(w, 30)
stepFor(w, 30)
const pb = pos()
const pb = pos()
w.getTransform(b, pb, rot())
w.getTransform(b, pb, rot())
- expect(pb.y).toBeGreaterThan(0.75)
- expect(pb.y).toBeLessThan(2)
+ expect(pb.y).toBeGreaterThan(0.5)
+ expect(pb.y).toBeLessThan(1.2)
close()
close()
})
})
it.skipIf(maybe('step advances'))(
it.skipIf(maybe('step advances'))(
- 'step advances a fixed 1/60 s regardless of the dt passed (Havok plugin)',
+ 'step advances exactly the dt it is given',
() => {
() => {
const probe = (dt: number, ticks: number) => {
const probe = (dt: number, ticks: number) => {
const w = open()
const w = open()
@@ -1397,15 +1368,16 @@ function conformance(a: Adapter): void {
@@ -1397,15 +1368,16 @@ function conformance(a: Adapter): void {
close()
close()
return v.y
return v.y
}
}
- // 30 steps at 30 Hz and 30 steps at 15 Hz are the same amount of
- // simulated time, because the plugin uses its own fixed step time.
- expect(probe(1 / 30, 30)).toBeCloseTo(probe(1 / 15, 30), 3)
- // An extra second of simulation is very nearly twice the velocity.
- expect(probe(1 / 60, 120) / probe(1 / 60, 60)).toBeGreaterThan(1.9)
- expect(probe(1 / 60, 120) / probe(1 / 60, 60)).toBeLessThan(2.1)
+ // Rapier honours the dt it is given: 30 steps of 1/30 s and 60 steps
+ // of 1/60 s are the same simulated second (the damping is applied per
+ // step, so they agree to within a few mm/s, not bit-exactly).
+ expect(probe(1 / 30, 30)).toBeCloseTo(probe(1 / 60, 60), 1)
+ // An extra simulated second is very nearly twice the velocity.
+ expect(probe(1 / 30, 60) / probe(1 / 30, 30)).toBeGreaterThan(1.9)
+ expect(probe(1 / 30, 60) / probe(1 / 30, 30)).toBeLessThan(2.1)
// One simulated second of this gravity: about -16.5 m/s.
// One simulated second of this gravity: about -16.5 m/s.
- expect(probe(1 / 30, 60)).toBeLessThan(-15)
- expect(probe(1 / 30, 60)).toBeGreaterThan(-17)
+ expect(probe(1 / 30, 30)).toBeLessThan(-15)
+ expect(probe(1 / 30, 30)).toBeGreaterThan(-17)
},
},
)
)
@@ -1532,5 +1504,5 @@ function conformance(a: Adapter): void {
@@ -1532,5 +1504,5 @@ function conformance(a: Adapter): void {
})
})
}
}
-conformance(havokAdapter)
+conformance(rapierAdapter)
conformance(mockAdapter)
conformance(mockAdapter)
deleted file mode 100644
deleted file mode 100644
@@ -1,604 +0,0 @@
@@ -1,604 +0,0 @@
-import { NullEngine } from '@babylonjs/core/Engines/nullEngine.js'
-import { Quaternion, Vector3 } from '@babylonjs/core/Maths/math.vector.js'
-import { TransformNode } from '@babylonjs/core/Meshes/transformNode.js'
-import { PhysicsRaycastResult } from '@babylonjs/core/Physics/physicsRaycastResult.js'
-import { ShapeCastResult } from '@babylonjs/core/Physics/shapeCastResult.js'
-import { HavokPlugin } from '@babylonjs/core/Physics/v2/Plugins/havokPlugin.js'
-import {
- PhysicsActivationControl,
- PhysicsMotionType,
- PhysicsPrestepType,
-} from '@babylonjs/core/Physics/v2/IPhysicsEnginePlugin.js'
-import { PhysicsBody } from '@babylonjs/core/Physics/v2/physicsBody.js'
-import type { PhysicsShape } from '@babylonjs/core/Physics/v2/physicsShape.js'
-import {
- PhysicsShapeBox,
- PhysicsShapeCapsule,
- PhysicsShapeCylinder,
- PhysicsShapeMesh,
- PhysicsShapeSphere,
-} from '@babylonjs/core/Physics/v2/physicsShape.js'
-import { Mesh } from '@babylonjs/core/Meshes/mesh.js'
-import { VertexData } from '@babylonjs/core/Meshes/mesh.vertexData.js'
-import {
- Physics6DoFConstraint,
- PhysicsConstraint,
-} from '@babylonjs/core/Physics/v2/physicsConstraint.js'
-import {
- PhysicsConstraintAxis,
- PhysicsConstraintMotorType,
- PhysicsConstraintType,
-} from '@babylonjs/core/Physics/v2/IPhysicsEnginePlugin.js'
-import { Scene } from '@babylonjs/core/scene.js'
-import '@babylonjs/core/Physics/joinedPhysicsEngineComponent.js'
-import type { Quat, Vec3 } from '@openvibe/shared'
-import type {
- BodyDesc,
- BodyId,
- ConstraintDesc,
- ConstraintId,
- ConstraintMotor,
- MotionType,
- PhysicsWorld,
- RayHit,
- ShapeDesc,
- SweepHit,
-} from '../types.js'
-import { CollisionLayer } from '../types.js'
-
-/**
- * Havok (via Babylon Physics V2) implementation of PhysicsWorld.
- *
- * The same adapter runs headless on the server (NullEngine scene, never
- * rendered) and inside the client's rendered scene. Stepping is always
- * manual through `plugin.executeStep` from the fixed-timestep loop — the
- * scene's own render-driven physics stepping is disabled so simulation can
- * never couple to frame rate.
- */
-
-interface BodyRecord {
- body: PhysicsBody
- node: TransformNode
- shape: PhysicsShape
- motion: MotionType
-}
-
-const MOTION_MAP: Record<MotionType, PhysicsMotionType> = {
- dynamic: PhysicsMotionType.DYNAMIC,
- static: PhysicsMotionType.STATIC,
- kinematic: PhysicsMotionType.ANIMATED,
-}
-
-const SETTLE_LIN_SQ = 0.05 * 0.05
-const SETTLE_ANG_SQ = 0.15 * 0.15
-
-export class HavokWorld implements PhysicsWorld {
- private nextId: BodyId = 1
- private nextConstraintId: ConstraintId = 1
- private readonly bodies = new Map<BodyId, BodyRecord>()
- private readonly constraints = new Map<
- ConstraintId,
- { constraint: PhysicsConstraint; type: ConstraintDesc['type'] }
- >()
- private readonly bodyIds = new WeakMap<PhysicsBody, BodyId>()
- private bodyList: PhysicsBody[] = []
- private bodyListDirty = false
-
- // Scratch objects — adapter methods are not re-entrant.
- private readonly _v1 = new Vector3()
- private readonly _v2 = new Vector3()
- private readonly _q1 = new Quaternion()
- private readonly _rayResult = new PhysicsRaycastResult()
- private readonly _castInput = new ShapeCastResult()
- private readonly _castHit = new ShapeCastResult()
- private readonly sweepShapes = new Map<string, PhysicsShapeCapsule>()
-
- constructor(
- private readonly scene: Scene,
- private readonly plugin: HavokPlugin,
- private readonly ownsScene: boolean,
- ) {}
-
- step(dt: number): void {
- if (this.bodyListDirty) {
- this.bodyList = [...this.bodies.values()].map((r) => r.body)
- this.bodyListDirty = false
- }
- this.plugin.executeStep(dt, this.bodyList)
- }
-
- addBody(desc: BodyDesc): BodyId {
- const id = this.nextId++
- const node = new TransformNode(`body:${id}`, this.scene)
- node.position.set(desc.pos.x, desc.pos.y, desc.pos.z)
- node.rotationQuaternion = desc.rot
- ? new Quaternion(desc.rot.x, desc.rot.y, desc.rot.z, desc.rot.w)
- : Quaternion.Identity()
-
- const shape = this.makeShape(desc.shape)
- shape.filterMembershipMask = desc.layer
- shape.filterCollideMask = desc.collidesWith
- // Grippy, dead-on-impact surfaces: Source props bite the ground instead
- // of gliding (Havok defaults are ice-like) and barely bounce.
- shape.material = { friction: 0.75, staticFriction: 0.85, restitution: 0.05 }
-
- const body = new PhysicsBody(node, MOTION_MAP[desc.motion], false, this.scene)
- body.shape = shape
- if (desc.motion === 'dynamic') {
- body.setMassProperties({ mass: desc.massKg ?? 10 })
- // Light linear + strong angular damping kill the floaty drift and
- // endless micro-rolling that read as "low gravity".
- body.setLinearDamping(0.05)
- body.setAngularDamping(0.6)
- }
-
- this.bodies.set(id, { body, node, shape, motion: desc.motion })
- this.bodyIds.set(body, id)
- this.bodyListDirty = true
- return id
- }
-
- removeBody(id: BodyId): void {
- const rec = this.bodies.get(id)
- if (!rec) return
- rec.body.dispose()
- rec.shape.dispose()
- rec.node.dispose()
- this.bodies.delete(id)
- this.bodyListDirty = true
- }
-
- getTransform(id: BodyId, outPos: Vec3, outRot: Quat): void {
- const rec = this.mustGet(id)
- const p = rec.node.position
- outPos.x = p.x
- outPos.y = p.y
- outPos.z = p.z
- const q = rec.node.rotationQuaternion
- if (q) {
- outRot.x = q.x
- outRot.y = q.y
- outRot.z = q.z
- outRot.w = q.w
- }
- }
-
- setTransform(id: BodyId, pos: Vec3, rot?: Quat): void {
- const rec = this.mustGet(id)
- rec.node.position.set(pos.x, pos.y, pos.z)
- if (rot && rec.node.rotationQuaternion) {
- rec.node.rotationQuaternion.set(rot.x, rot.y, rot.z, rot.w)
- }
- if (rec.motion === 'kinematic') {
- // ANIMATED bodies chase the node target with computed velocity so they
- // push dynamic bodies convincingly.
- this._v1.set(pos.x, pos.y, pos.z)
- const q = rec.node.rotationQuaternion ?? Quaternion.Identity()
- rec.body.setTargetTransform(this._v1, q)
- } else {
- // CRITICAL: with the default prestep (DISABLED),
- // setPhysicsBodyTransformation is a silent no-op — static mirror
- // bodies would never actually move. Flip to TELEPORT for the call.
- rec.body.setPrestepType(PhysicsPrestepType.TELEPORT)
- this.plugin.setPhysicsBodyTransformation(rec.body, rec.node)
- rec.body.setPrestepType(PhysicsPrestepType.DISABLED)
- this.wake(id)
- }
- }
-
- setMotionType(id: BodyId, motion: MotionType): void {
- const rec = this.mustGet(id)
- if (rec.motion === motion) return
- rec.body.setMotionType(MOTION_MAP[motion])
- rec.motion = motion
- if (motion === 'dynamic') {
- this.wake(id)
- }
- }
-
- getLinearVelocity(id: BodyId, out: Vec3): void {
- this.mustGet(id).body.getLinearVelocityToRef(this._v1)
- out.x = this._v1.x
- out.y = this._v1.y
- out.z = this._v1.z
- }
-
- setLinearVelocity(id: BodyId, v: Vec3): void {
- this._v1.set(v.x, v.y, v.z)
- this.mustGet(id).body.setLinearVelocity(this._v1)
- }
-
- getAngularVelocity(id: BodyId, out: Vec3): void {
- this.mustGet(id).body.getAngularVelocityToRef(this._v1)
- out.x = this._v1.x
- out.y = this._v1.y
- out.z = this._v1.z
- }
-
- setAngularVelocity(id: BodyId, v: Vec3): void {
- this._v1.set(v.x, v.y, v.z)
- this.mustGet(id).body.setAngularVelocity(this._v1)
- }
-
- isSettled(id: BodyId): boolean {
- const rec = this.mustGet(id)
- if (rec.motion !== 'dynamic') return true
- rec.body.getLinearVelocityToRef(this._v1)
- if (this._v1.lengthSquared() > SETTLE_LIN_SQ) return false
- rec.body.getAngularVelocityToRef(this._v1)
- return this._v1.lengthSquared() <= SETTLE_ANG_SQ
- }
-
- wake(id: BodyId): void {
- const rec = this.mustGet(id)
- if (rec.motion !== 'dynamic') return
- // Toggling activation control is the portable way to wake a Havok body
- // without disturbing its velocities.
- this.plugin.setActivationControl(rec.body, PhysicsActivationControl.ALWAYS_ACTIVE)
- this.plugin.setActivationControl(rec.body, PhysicsActivationControl.SIMULATION_CONTROLLED)
- }
-
- addConstraint(desc: ConstraintDesc): ConstraintId {
- const recA = this.mustGet(desc.bodyA)
- const recB = this.mustGet(desc.bodyB)
- const constraint = this.buildConstraint(desc, recA, recB)
- recA.body.addConstraint(recB.body, constraint)
- // Motors must be configured AFTER the joint exists in the engine.
- if ((desc.type === 'hinge' || desc.type === 'slider') && desc.motor) {
- const axis =
- desc.type === 'hinge' ? PhysicsConstraintAxis.ANGULAR_X : PhysicsConstraintAxis.LINEAR_X
- const c = constraint as Physics6DoFConstraint
- c.setAxisMotorType(axis, PhysicsConstraintMotorType.VELOCITY)
- c.setAxisMotorTarget(axis, desc.motor.targetVelocity)
- c.setAxisMotorMaxForce(axis, desc.motor.maxForce)
- }
- if (desc.type === 'hinge' && desc.friction !== undefined) {
- ;(constraint as Physics6DoFConstraint).setAxisFriction(
- PhysicsConstraintAxis.ANGULAR_X,
- desc.friction,
- )
- }
- const id = this.nextConstraintId++
- this.constraints.set(id, { constraint, type: desc.type })
- if (recA.motion === 'dynamic') this.wake(desc.bodyA)
- if (recB.motion === 'dynamic') this.wake(desc.bodyB)
- return id
- }
-
- private buildConstraint(
- desc: ConstraintDesc,
- recA: BodyRecord,
- recB: BodyRecord,
- ): PhysicsConstraint {
- switch (desc.type) {
- case 'weld': {
- const rotA = recA.node.rotationQuaternion ?? Quaternion.Identity()
- const rotB = recB.node.rotationQuaternion ?? Quaternion.Identity()
- // Anchor frames preserving the CURRENT relative pose:
- // pivotA = B's origin in A's local space; axes = B's basis in A's space.
- const invRotA = rotA.conjugate()
- const relRot = invRotA.multiply(rotB)
- const pivotA = recB.node.position
- .subtract(recA.node.position)
- .applyRotationQuaternion(invRotA)
- const axisA = new Vector3(1, 0, 0).applyRotationQuaternion(relRot)
- const perpA = new Vector3(0, 1, 0).applyRotationQuaternion(relRot)
- return new PhysicsConstraint(
- PhysicsConstraintType.LOCK,
- {
- pivotA,
- pivotB: Vector3.Zero(),
- axisA,
- axisB: new Vector3(1, 0, 0),
- perpAxisA: perpA,
- perpAxisB: new Vector3(0, 1, 0),
- collision: false,
- },
- this.scene,
- )
- }
- case 'rope':
- // A pure max-distance tether: slack below `length`, taut at it.
- // The pair keeps colliding by default — a crate on a rope bonks
- // the post — unless a rigid joint already links them.
- return new PhysicsConstraint(
- PhysicsConstraintType.DISTANCE,
- {
- pivotA: toBjs(desc.anchorA),
- pivotB: toBjs(desc.anchorB),
- maxDistance: desc.length,
- collision: desc.collision ?? true,
- },
- this.scene,
- )
- case 'spring':
- // Soft equality at restLength: force = stiffness·error − damping·vel.
- // (Built as the 6DoF SpringConstraint does, plus the collision flag.)
- return new Physics6DoFConstraint(
- {
- pivotA: toBjs(desc.anchorA),
- pivotB: toBjs(desc.anchorB),
- axisA: new Vector3(1, 0, 0),
- axisB: new Vector3(1, 0, 0),
- collision: desc.collision ?? true,
- },
- [
- {
- axis: PhysicsConstraintAxis.LINEAR_DISTANCE,
- minLimit: desc.restLength,
- maxLimit: desc.restLength,
- stiffness: desc.stiffness,
- damping: desc.damping,
- },
- ],
- this.scene,
- )
- case 'hinge':
- case 'slider': {
- const frames = jointFrames(desc.axisA, desc.axisB, recA, recB)
- const limits: { axis: PhysicsConstraintAxis; minLimit: number; maxLimit: number }[] = []
- const lock = (axis: PhysicsConstraintAxis) =>
- limits.push({ axis, minLimit: 0, maxLimit: 0 })
- if (desc.type === 'hinge') {
- lock(PhysicsConstraintAxis.LINEAR_X)
- lock(PhysicsConstraintAxis.LINEAR_Y)
- lock(PhysicsConstraintAxis.LINEAR_Z)
- lock(PhysicsConstraintAxis.ANGULAR_Y)
- lock(PhysicsConstraintAxis.ANGULAR_Z)
- if (desc.limits) {
- limits.push({
- axis: PhysicsConstraintAxis.ANGULAR_X,
- minLimit: desc.limits.min,
- maxLimit: desc.limits.max,
- })
- }
- } else {
- lock(PhysicsConstraintAxis.LINEAR_Y)
- lock(PhysicsConstraintAxis.LINEAR_Z)
- lock(PhysicsConstraintAxis.ANGULAR_X)
- lock(PhysicsConstraintAxis.ANGULAR_Y)
- lock(PhysicsConstraintAxis.ANGULAR_Z)
- if (desc.limits) {
- limits.push({
- axis: PhysicsConstraintAxis.LINEAR_X,
- minLimit: desc.limits.min,
- maxLimit: desc.limits.max,
- })
- }
- }
- return new Physics6DoFConstraint(
- {
- pivotA: toBjs(desc.anchorA),
- pivotB: toBjs(desc.anchorB),
- axisA: frames.axisA,
- axisB: frames.axisB,
- perpAxisA: frames.perpA,
- perpAxisB: frames.perpB,
- collision: false,
- },
- limits,
- this.scene,
- )
- }
- }
- }
-
- setConstraintMotor(id: ConstraintId, motor: ConstraintMotor): void {
- const rec = this.constraints.get(id)
- if (!rec || (rec.type !== 'hinge' && rec.type !== 'slider')) return
- const axis =
- rec.type === 'hinge' ? PhysicsConstraintAxis.ANGULAR_X : PhysicsConstraintAxis.LINEAR_X
- const c = rec.constraint as Physics6DoFConstraint
- c.setAxisMotorType(axis, PhysicsConstraintMotorType.VELOCITY)
- c.setAxisMotorTarget(axis, motor.targetVelocity)
- c.setAxisMotorMaxForce(axis, motor.maxForce)
- }
-
- removeConstraint(id: ConstraintId): void {
- const rec = this.constraints.get(id)
- if (!rec) return
- rec.constraint.dispose()
- this.constraints.delete(id)
- }
-
- applyForce(id: BodyId, force: Vec3): void {
- const rec = this.mustGet(id)
- if (rec.motion !== 'dynamic') return
- this._v1.set(force.x, force.y, force.z)
- this._v2.copyFrom(rec.node.position)
- rec.body.applyForce(this._v1, this._v2)
- }
-
- raycast(from: Vec3, to: Vec3, collidesWith: number): RayHit | null {
- this._v1.set(from.x, from.y, from.z)
- this._v2.set(to.x, to.y, to.z)
- this.plugin.raycast(this._v1, this._v2, this._rayResult, {
- membership: 0xffffffff,
- collideWith: collidesWith,
- })
- const r = this._rayResult
- if (!r.hasHit || !r.body) return null
- const bodyId = this.bodyIds.get(r.body)
- if (bodyId === undefined) return null
- return {
- bodyId,
- point: { x: r.hitPointWorld.x, y: r.hitPointWorld.y, z: r.hitPointWorld.z },
- normal: { x: r.hitNormalWorld.x, y: r.hitNormalWorld.y, z: r.hitNormalWorld.z },
- fraction: r.hitDistance / Math.max(this._v2.subtract(this._v1).length(), 1e-9),
- }
- }
-
- sweepCapsule(
- from: Vec3,
- to: Vec3,
- radius: number,
- height: number,
- collidesWith: number,
- exclude?: BodyId,
- ): SweepHit | null {
- const shape = this.getSweepShape(radius, height, collidesWith)
- this._v1.set(from.x, from.y, from.z)
- this._v2.set(to.x, to.y, to.z)
- this._q1.set(0, 0, 0, 1)
- // ignoreBody lets a player's movement sweep collide with the Player
- // layer without hitting their own kinematic body.
- const ignoreBody = exclude === undefined ? undefined : this.bodies.get(exclude)?.body
- this.plugin.shapeCast(
- {
- shape,
- rotation: this._q1,
- startPosition: this._v1,
- endPosition: this._v2,
- shouldHitTriggers: false,
- ...(ignoreBody ? { ignoreBody } : {}),
- },
- this._castInput,
- this._castHit,
- )
- if (!this._castHit.hasHit) return null
- const n = this._castHit.hitNormal
- const p = this._castHit.hitPoint
- return {
- fraction: this._castHit.hitFraction,
- normal: { x: n.x, y: n.y, z: n.z },
- point: { x: p.x, y: p.y, z: p.z },
- }
- }
-
- dispose(): void {
- for (const id of [...this.constraints.keys()]) this.removeConstraint(id)
- for (const id of [...this.bodies.keys()]) this.removeBody(id)
- for (const shape of this.sweepShapes.values()) shape.dispose()
- this.sweepShapes.clear()
- if (this.ownsScene) {
- const engine = this.scene.getEngine()
- this.scene.dispose()
- engine.dispose()
- }
- }
-
- private mustGet(id: BodyId): BodyRecord {
- const rec = this.bodies.get(id)
- if (!rec) throw new Error(`unknown physics body ${id}`)
- return rec
- }
-
- private makeShape(desc: ShapeDesc): PhysicsShape {
- switch (desc.type) {
- case 'box':
- return new PhysicsShapeBox(
- Vector3.Zero(),
- Quaternion.Identity(),
- new Vector3(desc.size[0], desc.size[1], desc.size[2]),
- this.scene,
- )
- case 'cylinder':
- return new PhysicsShapeCylinder(
- new Vector3(0, -desc.height / 2, 0),
- new Vector3(0, desc.height / 2, 0),
- desc.radius,
- this.scene,
- )
- case 'sphere':
- return new PhysicsShapeSphere(Vector3.Zero(), desc.radius, this.scene)
- case 'capsule': {
- const half = Math.max(desc.height / 2 - desc.radius, 0.01)
- return new PhysicsShapeCapsule(
- new Vector3(0, -half, 0),
- new Vector3(0, half, 0),
- desc.radius,
- this.scene,
- )
- }
- case 'trimesh': {
- // Build a transient mesh purely as the geometry source for the
- // Havok mesh shape; it never renders.
- const mesh = new Mesh(`trimesh-src-${this.nextId}`, this.scene)
- const vd = new VertexData()
- vd.positions = desc.positions
- vd.indices = desc.indices
- vd.applyToMesh(mesh)
- mesh.isVisible = false
- const shape = new PhysicsShapeMesh(mesh, this.scene)
- mesh.dispose()
- return shape
- }
- }
- }
-
- private getSweepShape(radius: number, height: number, collidesWith: number): PhysicsShapeCapsule {
- const key = `${radius}:${height}:${collidesWith}`
- let shape = this.sweepShapes.get(key)
- if (!shape) {
- const half = Math.max(height / 2 - radius, 0.01)
- shape = new PhysicsShapeCapsule(
- new Vector3(0, -half, 0),
- new Vector3(0, half, 0),
- radius,
- this.scene,
- )
- shape.filterMembershipMask = CollisionLayer.Player
- shape.filterCollideMask = collidesWith
- this.sweepShapes.set(key, shape)
- }
- return shape
- }
-}
-
-function toBjs(v: Vec3): Vector3 {
- return new Vector3(v.x, v.y, v.z)
-}
-
-/**
- * Perpendicular joint frames that make the bodies' CURRENT pose the joint's
- * zero: perpA is an arbitrary unit vector ⊥ axisA (in A local space) and
- * perpB is its image through the current world alignment, re-orthogonalized
- * against axisB. Without this, hinge/slider limits and motor angles would
- * be measured from an arbitrary datum instead of "where you built it".
- */
-function jointFrames(
- axisALocal: Vec3,
- axisBLocal: Vec3,
- recA: BodyRecord,
- recB: BodyRecord,
-): { axisA: Vector3; axisB: Vector3; perpA: Vector3; perpB: Vector3 } {
- const axisA = toBjs(axisALocal).normalize()
- const axisB = toBjs(axisBLocal).normalize()
- // Arbitrary but stable perpendicular to axisA.
- const seed = Math.abs(axisA.y) < 0.9 ? new Vector3(0, 1, 0) : new Vector3(1, 0, 0)
- const perpA = Vector3.Cross(axisA, seed).normalize()
- const rotA = recA.node.rotationQuaternion ?? Quaternion.Identity()
- const rotB = recB.node.rotationQuaternion ?? Quaternion.Identity()
- const worldPerpA = perpA.applyRotationQuaternion(rotA)
- const perpBRaw = worldPerpA.applyRotationQuaternion(rotB.conjugate())
- // Project out any axisB component (axes may not be perfectly aligned in
- // world space when the player links two tilted props).
- const perpB = perpBRaw.subtract(axisB.scale(Vector3.Dot(axisB, perpBRaw)))
- if (perpB.lengthSquared() < 1e-8) {
- const seedB = Math.abs(axisB.y) < 0.9 ? new Vector3(0, 1, 0) : new Vector3(1, 0, 0)
- return { axisA, axisB, perpA, perpB: Vector3.Cross(axisB, seedB).normalize() }
- }
- return { axisA, axisB, perpA, perpB: perpB.normalize() }
-}
-
-function setupWorld(scene: Scene, havok: unknown, ownsScene: boolean): HavokWorld {
- const plugin = new HavokPlugin(false, havok)
- // Source-engine gravity (sv_gravity 600 ≈ 15.24 m/s²) — real-world 9.81
- // reads floaty at game scale.
- scene.enablePhysics(new Vector3(0, -16.5, 0), plugin)
- // All stepping goes through HavokWorld.step(); never the render loop.
- scene.physicsEnabled = false
- return new HavokWorld(scene, plugin, ownsScene)
-}
-
-/** Server-side world: owns a NullEngine scene that is never rendered. */
-export function createHeadlessHavokWorld(havok: unknown): HavokWorld {
- const engine = new NullEngine()
- const scene = new Scene(engine)
- return setupWorld(scene, havok, true)
-}
-
-/** Client-side world sharing the rendered scene (bodies stay invisible; rendering reads game state, not physics nodes). */
-export function createHavokWorldForScene(scene: Scene, havok: unknown): HavokWorld {
- return setupWorld(scene, havok, false)
-}
deleted file mode 100644
deleted file mode 100644
@@ -1 +0,0 @@
@@ -1 +0,0 @@
-export { HavokWorld, createHavokWorldForScene, createHeadlessHavokWorld } from './havokWorld.js'
@@ -1 +1,2 @@
@@ -1 +1,2 @@
export * from './types.js'
export * from './types.js'
+export * from './rapier/index.js'
@@ -649,14 +649,21 @@ function rayPlane(
@@ -649,14 +649,21 @@ function rayPlane(
/**
/**
* Sweeps a vertical capsule (centre segment of half-length `half`, radius
* Sweeps a vertical capsule (centre segment of half-length `half`, radius
- * `radius`) against an AABB. The Minkowski sum of a vertical capsule and an
- * AABB is not an AABB, so this inflates the box by `radius` in x/z and by
- * `half + radius` in y: conservative at the corners, exact on every face. For
- * an axis-aligned world — all the mock models — that is the whole story.
+ * `radius`) against an AABB, as a real convex sweep (ADR-0007 M1 decision 9).
*
*
- * Havok reports the contact point on the *surface*, not at the capsule centre
- * (measured: a sweep onto ground at y = 0 returns y = 0 exactly), so the point
- * is walked back onto the inflated face along −normal.
+ * The capsule touches the box when the distance between the centre SEGMENT and
+ * the box equals `radius`. Because an axis-aligned box is a product of intervals,
+ * that squared distance separates per axis: the horizontal gap from the segment's
+ * axis to the box's x/z rectangle, plus the vertical gap between the capsule's
+ * segment interval and the box's y interval. As a function of the sweep parameter
+ * it is convex, so the first contact is the first root of `distance² = radius²`:
+ * a fixed-iteration ternary search for the minimum, then a bisection back to the
+ * crossing. Both are deterministic to the bit, which a padded-AABB approximation
+ * was not — it rounded the box's edges and corners by up to `radius·(√2−1)`.
+ *
+ * A sweep that *starts* already overlapping the box (distance < radius) has no
+ * first contact to report and returns null, exactly as the analytic-plane path
+ * below does; reporting one would block every upward sweep out of a resting hull.
*/
*/
function sweepCapsuleAabb(
function sweepCapsuleAabb(
from: Vec3,
from: Vec3,
@@ -666,63 +673,68 @@ function sweepCapsuleAabb(
@@ -666,63 +673,68 @@ function sweepCapsuleAabb(
center: Vec3,
center: Vec3,
boxHalf: Vec3,
boxHalf: Vec3,
): SweepHit | null {
): SweepHit | null {
- const lo = [
- center.x - boxHalf.x - radius,
- center.y - boxHalf.y - half - radius,
- center.z - boxHalf.z - radius,
- ]
- const hi = [
- center.x + boxHalf.x + radius,
- center.y + boxHalf.y + half + radius,
- center.z + boxHalf.z + radius,
- ]
- const o = [from.x, from.y, from.z]
- const dd = [d.x, d.y, d.z]
- let tMin = -Infinity
- let tMax = Infinity
- let axis = 0
- let sign = -1
- for (let a = 0; a < 3; a++) {
- if (Math.abs(dd[a]!) < 1e-12) {
- if (o[a]! < lo[a]! || o[a]! > hi[a]!) return null
- continue
- }
- const inv = 1 / dd[a]!
- let t1 = (lo[a]! - o[a]!) * inv
- let t2 = (hi[a]! - o[a]!) * inv
- let s = -1
- if (t1 > t2) {
- const tmp = t1
- t1 = t2
- t2 = tmp
- s = 1
- }
- if (t1 > tMin) {
- tMin = t1
- axis = a
- sign = s
- }
- if (t2 < tMax) tMax = t2
- if (tMin > tMax) return null
- }
- // Already overlapping at t = 0: the capsule starts inside, which is not a
- // contact the caller can act on.
- if (tMin <= 0 || tMin > 1) return null
- const normal = vec3(0, 0, 0)
- if (axis === 0) normal.x = sign
- else if (axis === 1) normal.y = sign
- else normal.z = sign
- // Walk the centre back onto the true surface: distance is the capsule's
- // support along the normal, `radius` on the sides and `half + radius` on
- // the caps.
- const support = axis === 1 ? half + radius : radius
- const centre = vec3(from.x + d.x * tMin, from.y + d.y * tMin, from.z + d.z * tMin)
- const point = vec3(
- centre.x - normal.x * support,
- centre.y - normal.y * support,
- centre.z - normal.z * support,
- )
- return { fraction: tMin, normal, point }
+ const ax = center.x - boxHalf.x
+ const bx = center.x + boxHalf.x
+ const ay = center.y - boxHalf.y
+ const by = center.y + boxHalf.y
+ const az = center.z - boxHalf.z
+ const bz = center.z + boxHalf.z
+
+ /** Squared distance between the swept segment and the box at parameter t. */
+ const distanceSq = (t: number): number => {
+ const x = from.x + d.x * t
+ const y = from.y + d.y * t
+ const z = from.z + d.z * t
+ const dx = x < ax ? ax - x : x > bx ? x - bx : 0
+ const dz = z < az ? az - z : z > bz ? z - bz : 0
+ const top = y + half
+ const bot = y - half
+ const dy = top < ay ? ay - top : bot > by ? bot - by : 0
+ return dx * dx + dy * dy + dz * dz
+ }
+
+ const r2 = radius * radius
+ if (distanceSq(0) < r2) return null
+
+ // Minimum over the segment, by ternary search (convex, so this is exact to the
+ // iteration count; the count is fixed so the result is reproducible).
+ let lo = 0
+ let hi = 1
+ for (let i = 0; i < 80; i++) {
+ const m1 = lo + (hi - lo) / 3
+ const m2 = hi - (hi - lo) / 3
+ if (distanceSq(m1) < distanceSq(m2)) hi = m2
+ else lo = m1
+ }
+ const tMin = (lo + hi) / 2
+ if (distanceSq(tMin) > r2) return null
+
+ // First crossing: bisect [0, tMin] keeping the distance above `radius` on the
+ // left and at or below it on the right.
+ let left = 0
+ let right = tMin
+ for (let i = 0; i < 60; i++) {
+ const mid = (left + right) / 2
+ if (distanceSq(mid) > r2) left = mid
+ else right = mid
+ }
+ const t = right
+
+ const x = from.x + d.x * t
+ const y = from.y + d.y * t
+ const z = from.z + d.z * t
+ // Closest point on the segment: its vertical position within the capsule's own
+ // interval that is nearest the box.
+ const segY = y + half < ay ? y + half : y - half > by ? y - half : Math.min(Math.max(y, ay), by)
+ const px = Math.min(Math.max(x, ax), bx)
+ const py = Math.min(Math.max(segY, ay), by)
+ const pz = Math.min(Math.max(z, az), bz)
+ const nx = x - px
+ const ny = segY - py
+ const nz = z - pz
+ const len = Math.sqrt(nx * nx + ny * ny + nz * nz)
+ const normal = len > 1e-12 ? vec3(nx / len, ny / len, nz / len) : vec3(0, 1, 0)
+ return { fraction: t, normal, point: vec3(px, py, pz) }
}
}
/**
/**
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+export {
+ RAPIER_ANGULAR_DAMPING,
+ RAPIER_DEFAULT_MASS_KG,
+ RAPIER_FRICTION,
+ RAPIER_GRAVITY,
+ RAPIER_LINEAR_DAMPING,
+ RAPIER_RESTITUTION,
+ RapierWorld,
+ createRapierWorld,
+ type RapierModule,
+} from './rapierWorld.js'
+export { loadRapier } from './load.js'
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+import type { RapierModule } from './rapierWorld.js'
+
+/**
+ * Loads and initializes the pinned deterministic Rapier build (ADR-0007
+ * decision 2): `@dimforge/rapier3d-deterministic-compat`, whose wasm is inlined
+ * as base64 so a single entry works in Node and the browser with no asset
+ * plumbing and no loader.
+ *
+ * The import is dynamic on purpose: physics is loaded while the WebSocket
+ * ticket is fetched, so it is never part of the initial JavaScript.
+ */
+export async function loadRapier(): Promise<RapierModule> {
+ const R = (await import('@dimforge/rapier3d-deterministic-compat')) as unknown as RapierModule
+ await R.init()
+ return R
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,478 @@
@@ -0,0 +1,478 @@
+import type * as Rapier from '@dimforge/rapier3d-deterministic-compat'
+import type { Quat, Vec3 } from '@openvibe/shared'
+import type {
+ BodyDesc,
+ BodyId,
+ ConstraintDesc,
+ ConstraintId,
+ ConstraintMotor,
+ MotionType,
+ PhysicsWorld,
+ RayHit,
+ ShapeDesc,
+ SweepHit,
+} from '../types.js'
+import { CollisionLayer } from '../types.js'
+
+/**
+ * Rapier implementation of PhysicsWorld (ADR-0007 decision 2).
+ *
+ * The same adapter runs headless on the server and inside the client's rendered
+ * scene — Rapier has no scene or engine indirection, so the two Havok factories
+ * (a NullEngine scene and a scene-sharing world) collapse to one. Stepping is
+ * manual and honours the dt it is given; there is no render-driven coupling.
+ *
+ * The module is injected rather than imported at runtime: the physics package
+ * declares the deterministic `-compat` build, but the seam stays free of it in
+ * type position only, so no wasm or engine code is reached through this file.
+ */
+
+/** The Rapier module surface the adapter drives. */
+export type RapierModule = typeof Rapier
+
+interface BodyRecord {
+ body: Rapier.RigidBody
+ collider: Rapier.Collider
+ motion: MotionType
+}
+
+interface ConstraintRecord {
+ joint: Rapier.ImpulseJoint
+ type: ConstraintDesc['type']
+}
+
+/**
+ * The unit-joint surface (revolute/prismatic) used to drive motors and limits.
+ * Declared structurally so the adapter never has to name a class that differs
+ * between the compat and non-compat builds.
+ */
+interface MotorJoint {
+ setLimits(min: number, max: number): void
+ configureMotorModel(model: number): void
+ setMotorMaxForce(maxForce: number): void
+ configureMotorVelocity(targetVelocity: number, factor: number): void
+}
+
+/** Grippy, dead-on-impact surfaces — the same material Havok used. */
+export const RAPIER_FRICTION = 0.75
+export const RAPIER_RESTITUTION = 0.05
+export const RAPIER_LINEAR_DAMPING = 0.05
+export const RAPIER_ANGULAR_DAMPING = 0.6
+export const RAPIER_DEFAULT_MASS_KG = 10
+export const RAPIER_GRAVITY = -16.5
+
+const SETTLE_LIN_SQ = 0.05 * 0.05
+const SETTLE_ANG_SQ = 0.15 * 0.15
+
+const IDENTITY: Rapier.Rotation = { x: 0, y: 0, z: 0, w: 1 }
+
+/** Rapier interaction groups: membership in the high 16 bits, filter in the low. */
+function groups(membership: number, filter: number): number {
+ return ((membership & 0xffff) << 16) | (filter & 0xffff)
+}
+
+function motionType(R: RapierModule, motion: MotionType): Rapier.RigidBodyType {
+ switch (motion) {
+ case 'dynamic':
+ return R.RigidBodyType.Dynamic
+ case 'static':
+ return R.RigidBodyType.Fixed
+ case 'kinematic':
+ return R.RigidBodyType.KinematicPositionBased
+ }
+}
+
+function bodyDesc(R: RapierModule, desc: BodyDesc): Rapier.RigidBodyDesc {
+ const rb =
+ desc.motion === 'dynamic'
+ ? R.RigidBodyDesc.dynamic()
+ : desc.motion === 'static'
+ ? R.RigidBodyDesc.fixed()
+ : R.RigidBodyDesc.kinematicPositionBased()
+ rb.setTranslation(desc.pos.x, desc.pos.y, desc.pos.z)
+ if (desc.rot) rb.setRotation(desc.rot)
+ if (desc.motion === 'dynamic') {
+ rb.setLinearDamping(RAPIER_LINEAR_DAMPING)
+ rb.setAngularDamping(RAPIER_ANGULAR_DAMPING)
+ }
+ return rb
+}
+
+function colliderDesc(R: RapierModule, shape: ShapeDesc): Rapier.ColliderDesc {
+ switch (shape.type) {
+ case 'box':
+ return R.ColliderDesc.cuboid(shape.size[0] / 2, shape.size[1] / 2, shape.size[2] / 2)
+ case 'cylinder':
+ return R.ColliderDesc.cylinder(shape.height / 2, shape.radius)
+ case 'sphere':
+ return R.ColliderDesc.ball(shape.radius)
+ case 'capsule':
+ // A Rapier capsule's local axis is +Y, as Havok's is; `height` is the
+ // total height including caps, so the cylindrical segment is height/2 -
+ // radius either side of centre.
+ return R.ColliderDesc.capsule(Math.max(shape.height / 2 - shape.radius, 0.01), shape.radius)
+ case 'trimesh':
+ return R.ColliderDesc.trimesh(shape.positions, shape.indices)
+ }
+}
+
+// ── small quaternion helpers for the joint frames ────────────────────────
+
+function qConj(q: Quat): Quat {
+ return { x: -q.x, y: -q.y, z: -q.z, w: q.w }
+}
+
+function qMul(a: Quat, b: Quat): Quat {
+ return {
+ x: a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
+ y: a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
+ z: a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,
+ w: a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z,
+ }
+}
+
+function qRotate(q: Quat, v: Vec3): Vec3 {
+ const ix = q.w * v.x + q.y * v.z - q.z * v.y
+ const iy = q.w * v.y + q.z * v.x - q.x * v.z
+ const iz = q.w * v.z + q.x * v.y - q.y * v.x
+ const iw = -q.x * v.x - q.y * v.y - q.z * v.z
+ return {
+ x: ix * q.w + iw * -q.x + iy * -q.z - iz * -q.y,
+ y: iy * q.w + iw * -q.y + iz * -q.x - ix * -q.z,
+ z: iz * q.w + iw * -q.z + ix * -q.y - iy * -q.x,
+ }
+}
+
+export class RapierWorld implements PhysicsWorld {
+ private nextId: BodyId = 1
+ private nextConstraintId: ConstraintId = 1
+ private readonly bodies = new Map<BodyId, BodyRecord>()
+ private readonly constraints = new Map<ConstraintId, ConstraintRecord>()
+ /** Rigid-body handle → seam id, for raycast hits. */
+ private readonly bodyIds = new Map<number, BodyId>()
+ private readonly sweepShapes = new Map<string, Rapier.Capsule>()
+ private readonly forced = new Set<BodyId>()
+ /**
+ * Rapier builds its broad phase (which every scene query reads) inside
+ * `World.step`; a collider added or moved since the last step is invisible to
+ * `castRay`/`castShape` until then. The seam (and Havok) allowed querying a
+ * freshly built world, so a stale query is served by one zero-dt step, which
+ * updates the broad phase without advancing the simulation.
+ */
+ private broadPhaseStale = false
+ private disposed = false
+
+ private readonly world: Rapier.World
+
+ constructor(private readonly R: RapierModule) {
+ this.world = new R.World({ x: 0, y: RAPIER_GRAVITY, z: 0 })
+ }
+
+ step(dt: number): void {
+ this.world.integrationParameters.dt = dt
+ this.world.step()
+ this.broadPhaseStale = false
+ // Havok applied a force for exactly the step that followed it; Rapier keeps
+ // user forces until they are reset, so clear the ones applied this tick.
+ if (this.forced.size > 0) {
+ for (const id of this.forced) this.bodies.get(id)?.body.resetForces(false)
+ this.forced.clear()
+ }
+ }
+
+ addBody(desc: BodyDesc): BodyId {
+ const id = this.nextId++
+ const body = this.world.createRigidBody(bodyDesc(this.R, desc))
+ const cd = colliderDesc(this.R, desc.shape)
+ cd.setCollisionGroups(groups(desc.layer, desc.collidesWith))
+ cd.setFriction(RAPIER_FRICTION)
+ cd.setRestitution(RAPIER_RESTITUTION)
+ if (desc.motion === 'dynamic') cd.setMass(desc.massKg ?? RAPIER_DEFAULT_MASS_KG)
+ const collider = this.world.createCollider(cd, body)
+
+ this.bodies.set(id, { body, collider, motion: desc.motion })
+ this.bodyIds.set(body.handle, id)
+ this.broadPhaseStale = true
+ return id
+ }
+
+ removeBody(id: BodyId): void {
+ const rec = this.bodies.get(id)
+ if (!rec) return
+ this.bodyIds.delete(rec.body.handle)
+ // Removes the body and its colliders and joints.
+ this.world.removeRigidBody(rec.body)
+ this.bodies.delete(id)
+ this.forced.delete(id)
+ this.broadPhaseStale = true
+ for (const [cid, c] of this.constraints) {
+ if (!c.joint.isValid()) this.constraints.delete(cid)
+ }
+ }
+
+ getTransform(id: BodyId, outPos: Vec3, outRot: Quat): void {
+ const p = this.mustGet(id).body.translation()
+ outPos.x = p.x
+ outPos.y = p.y
+ outPos.z = p.z
+ const q = this.mustGet(id).body.rotation()
+ outRot.x = q.x
+ outRot.y = q.y
+ outRot.z = q.z
+ outRot.w = q.w
+ }
+
+ setTransform(id: BodyId, pos: Vec3, rot?: Quat): void {
+ const rec = this.mustGet(id)
+ this.broadPhaseStale = true
+ if (rec.motion === 'kinematic') {
+ // A kinematic body chases the target with a computed velocity, so it
+ // pushes dynamic bodies convincingly (Havok: setTargetTransform).
+ rec.body.setNextKinematicTranslation({ x: pos.x, y: pos.y, z: pos.z })
+ if (rot) rec.body.setNextKinematicRotation(rot)
+ return
+ }
+ rec.body.setTranslation({ x: pos.x, y: pos.y, z: pos.z }, true)
+ if (rot) rec.body.setRotation(rot, true)
+ rec.body.wakeUp()
+ }
+
+ setMotionType(id: BodyId, motion: MotionType): void {
+ const rec = this.mustGet(id)
+ if (rec.motion === motion) return
+ rec.body.setBodyType(motionType(this.R, motion), true)
+ rec.motion = motion
+ this.broadPhaseStale = true
+ }
+
+ getLinearVelocity(id: BodyId, out: Vec3): void {
+ const v = this.mustGet(id).body.linvel()
+ out.x = v.x
+ out.y = v.y
+ out.z = v.z
+ }
+
+ setLinearVelocity(id: BodyId, v: Vec3): void {
+ this.mustGet(id).body.setLinvel({ x: v.x, y: v.y, z: v.z }, true)
+ }
+
+ getAngularVelocity(id: BodyId, out: Vec3): void {
+ const v = this.mustGet(id).body.angvel()
+ out.x = v.x
+ out.y = v.y
+ out.z = v.z
+ }
+
+ setAngularVelocity(id: BodyId, v: Vec3): void {
+ this.mustGet(id).body.setAngvel({ x: v.x, y: v.y, z: v.z }, true)
+ }
+
+ isSettled(id: BodyId): boolean {
+ const rec = this.mustGet(id)
+ if (rec.motion !== 'dynamic') return true
+ const v = rec.body.linvel()
+ if (v.x * v.x + v.y * v.y + v.z * v.z > SETTLE_LIN_SQ) return false
+ const w = rec.body.angvel()
+ return w.x * w.x + w.y * w.y + w.z * w.z <= SETTLE_ANG_SQ
+ }
+
+ wake(id: BodyId): void {
+ const rec = this.mustGet(id)
+ if (rec.motion !== 'dynamic') return
+ rec.body.wakeUp()
+ }
+
+ addConstraint(desc: ConstraintDesc): ConstraintId {
+ const recA = this.mustGet(desc.bodyA)
+ const recB = this.mustGet(desc.bodyB)
+ const data = this.buildConstraint(desc, recA, recB)
+ const joint = this.world.createImpulseJoint(data, recA.body, recB.body, true)
+ if (desc.type === 'rope' || desc.type === 'spring') {
+ joint.setContactsEnabled(desc.collision ?? true)
+ } else {
+ joint.setContactsEnabled(false)
+ }
+ if (desc.type === 'hinge' || desc.type === 'slider') {
+ if (desc.limits) (joint as unknown as MotorJoint).setLimits(desc.limits.min, desc.limits.max)
+ if (desc.motor) this.applyMotor(joint, desc.motor)
+ }
+ const id = this.nextConstraintId++
+ this.constraints.set(id, { joint, type: desc.type })
+ return id
+ }
+
+ private buildConstraint(
+ desc: ConstraintDesc,
+ recA: BodyRecord,
+ recB: BodyRecord,
+ ): Rapier.JointData {
+ const R = this.R
+ switch (desc.type) {
+ case 'weld': {
+ const pA = recA.body.translation()
+ const pB = recB.body.translation()
+ const rA = recA.body.rotation()
+ const rB = recB.body.rotation()
+ // Preserve the CURRENT relative pose: pivotA = B's origin in A's local
+ // space; frameA = the rotation taking A's basis to B's.
+ const rel = qMul(qConj(rA), rB)
+ const pivot = qRotate(qConj(rA), { x: pB.x - pA.x, y: pB.y - pA.y, z: pB.z - pA.z })
+ return R.JointData.fixed(pivot, rel, { x: 0, y: 0, z: 0 }, IDENTITY)
+ }
+ case 'rope':
+ return R.JointData.rope(desc.length, desc.anchorA, desc.anchorB)
+ case 'spring':
+ return R.JointData.spring(
+ desc.restLength,
+ desc.stiffness,
+ desc.damping,
+ desc.anchorA,
+ desc.anchorB,
+ )
+ case 'hinge':
+ return R.JointData.revolute(desc.anchorA, desc.anchorB, normalize(desc.axisA))
+ case 'slider':
+ return R.JointData.prismatic(desc.anchorA, desc.anchorB, normalize(desc.axisA))
+ }
+ }
+
+ /** Drives a revolute (hinge) or prismatic (slider) joint's motor along its free axis. */
+ private applyMotor(joint: Rapier.ImpulseJoint, motor: ConstraintMotor): void {
+ const j = joint as unknown as MotorJoint
+ j.configureMotorModel(this.R.MotorModel.ForceBased)
+ j.setMotorMaxForce(motor.maxForce)
+ // A velocity target with a strong damping factor, capped by maxForce above.
+ j.configureMotorVelocity(motor.targetVelocity, Math.max(motor.maxForce, 1))
+ }
+
+ setConstraintMotor(id: ConstraintId, motor: ConstraintMotor): void {
+ const rec = this.constraints.get(id)
+ if (!rec || (rec.type !== 'hinge' && rec.type !== 'slider')) return
+ this.applyMotor(rec.joint, motor)
+ }
+
+ removeConstraint(id: ConstraintId): void {
+ const rec = this.constraints.get(id)
+ if (!rec) return
+ this.world.removeImpulseJoint(rec.joint, false)
+ this.constraints.delete(id)
+ }
+
+ applyForce(id: BodyId, force: Vec3): void {
+ const rec = this.mustGet(id)
+ if (rec.motion !== 'dynamic') return
+ rec.body.addForce({ x: force.x, y: force.y, z: force.z }, true)
+ this.forced.add(id)
+ }
+
+ raycast(from: Vec3, to: Vec3, collidesWith: number): RayHit | null {
+ this.ensureQueriesCurrent()
+ const origin = { x: from.x, y: from.y, z: from.z }
+ const dir = { x: to.x - from.x, y: to.y - from.y, z: to.z - from.z }
+ const ray = new this.R.Ray(origin, dir)
+ // The query's own membership is all-on; the filter is the caller's mask, so
+ // a collider is hit exactly when its layer intersects the mask.
+ const hit = this.world.castRayAndGetNormal(
+ ray,
+ 1,
+ true,
+ undefined,
+ groups(0xffff, collidesWith),
+ )
+ if (!hit) return null
+ const parent = hit.collider.parent()
+ if (!parent) return null
+ const bodyId = this.bodyIds.get(parent.handle)
+ if (bodyId === undefined) return null
+ return {
+ bodyId,
+ point: {
+ x: origin.x + dir.x * hit.timeOfImpact,
+ y: origin.y + dir.y * hit.timeOfImpact,
+ z: origin.z + dir.z * hit.timeOfImpact,
+ },
+ normal: { x: hit.normal.x, y: hit.normal.y, z: hit.normal.z },
+ fraction: hit.timeOfImpact,
+ }
+ }
+
+ sweepCapsule(
+ from: Vec3,
+ to: Vec3,
+ radius: number,
+ height: number,
+ collidesWith: number,
+ exclude?: BodyId,
+ ): SweepHit | null {
+ this.ensureQueriesCurrent()
+ const shape = this.getSweepShape(radius, height)
+ // A vertical capsule cast at a constant velocity, capped at one segment.
+ const hit = this.world.castShape(
+ { x: from.x, y: from.y, z: from.z },
+ IDENTITY,
+ { x: to.x - from.x, y: to.y - from.y, z: to.z - from.z },
+ shape,
+ 0,
+ 1,
+ false,
+ undefined,
+ groups(CollisionLayer.Player, collidesWith),
+ undefined,
+ exclude === undefined ? undefined : this.bodies.get(exclude)?.body,
+ )
+ if (!hit) return null
+ return {
+ fraction: hit.time_of_impact,
+ normal: { x: hit.normal1.x, y: hit.normal1.y, z: hit.normal1.z },
+ point: { x: hit.witness1.x, y: hit.witness1.y, z: hit.witness1.z },
+ }
+ }
+
+ dispose(): void {
+ if (this.disposed) return
+ this.disposed = true
+ this.constraints.clear()
+ this.bodies.clear()
+ this.bodyIds.clear()
+ this.sweepShapes.clear()
+ this.forced.clear()
+ this.world.free()
+ }
+
+ private mustGet(id: BodyId): BodyRecord {
+ const rec = this.bodies.get(id)
+ if (!rec) throw new Error(`unknown physics body ${id}`)
+ return rec
+ }
+
+ /** One zero-dt step to rebuild the broad phase when a query precedes a step. */
+ private ensureQueriesCurrent(): void {
+ if (!this.broadPhaseStale) return
+ const dt = this.world.integrationParameters.dt
+ this.world.integrationParameters.dt = 0
+ this.world.step()
+ this.world.integrationParameters.dt = dt
+ this.broadPhaseStale = false
+ }
+
+ private getSweepShape(radius: number, height: number): Rapier.Capsule {
+ const key = `${radius}:${height}`
+ let shape = this.sweepShapes.get(key)
+ if (!shape) {
+ shape = new this.R.Capsule(Math.max(height / 2 - radius, 0.01), radius)
+ this.sweepShapes.set(key, shape)
+ }
+ return shape
+ }
+}
+
+function normalize(v: Vec3): Vec3 {
+ const len = Math.hypot(v.x, v.y, v.z)
+ if (len < 1e-9) return { x: 1, y: 0, z: 0 }
+ return { x: v.x / len, y: v.y / len, z: v.z / len }
+}
+
+/** A fresh Rapier world. No scene, no engine: the same call on server and client. */
+export function createRapierWorld(R: RapierModule): RapierWorld {
+ return new RapierWorld(R)
+}
@@ -2,10 +2,10 @@ import type { Quat, Vec3 } from '@openvibe/shared'
@@ -2,10 +2,10 @@ import type { Quat, Vec3 } from '@openvibe/shared'
/**
/**
* Engine-agnostic physics facade. Gameplay and server code depend on these
* Engine-agnostic physics facade. Gameplay and server code depend on these
- * types only; the Havok/Babylon implementation lives behind `@openvibe/physics/havok`
- * and no Havok or Babylon type ever crosses this boundary. That keeps
- * simulation testable without wasm and leaves room to swap/isolate the
- * engine (e.g. worker-side physics) later.
+ * types only; the Rapier implementation lives behind `@openvibe/physics/rapier`
+ * and no Rapier type ever crosses this boundary. That keeps simulation
+ * testable without wasm and leaves room to swap/isolate the engine (e.g.
+ * worker-side physics) later.
*/
*/
/** Opaque handle to a body inside a PhysicsWorld instance. Not persistent — never serialize it. */
/** Opaque handle to a body inside a PhysicsWorld instance. Not persistent — never serialize it. */
@@ -47,9 +47,6 @@ importers:
@@ -47,9 +47,6 @@ importers:
'@babylonjs/core':
'@babylonjs/core':
specifier: ^9.20.0
specifier: ^9.20.0
version: 9.20.0
version: 9.20.0
- '@babylonjs/havok':
- specifier: ^1.3.10
- version: 1.3.14
'@babylonjs/loaders':
'@babylonjs/loaders':
specifier: ^9.20.0
specifier: ^9.20.0
version: 9.20.0(@babylonjs/[email protected])([email protected])
version: 9.20.0(@babylonjs/[email protected])([email protected])
@@ -84,9 +81,9 @@ importers:
@@ -84,9 +81,9 @@ importers:
apps/server:
apps/server:
dependencies:
dependencies:
- '@babylonjs/havok':
- specifier: ^1.3.10
- version: 1.3.14
+ '@dimforge/rapier3d-deterministic-compat':
+ specifier: 0.21.0
+ version: 0.21.0
'@electric-sql/pglite':
'@electric-sql/pglite':
specifier: ^0.5.8
specifier: ^0.5.8
version: 0.5.8
version: 0.5.8
@@ -121,8 +118,8 @@ importers:
@@ -121,8 +118,8 @@ importers:
specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1
specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1
version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1(@electric-sql/[email protected])([email protected])([email protected])
version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1(@electric-sql/[email protected])([email protected])([email protected])
openvibe-shared:
openvibe-shared:
- specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1
- version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1
+ specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0
+ version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0
pg:
pg:
specifier: ^8.23.0
specifier: ^8.23.0
version: 8.23.0
version: 8.23.0
@@ -182,12 +179,9 @@ importers:
@@ -182,12 +179,9 @@ importers:
packages/physics:
packages/physics:
dependencies:
dependencies:
- '@babylonjs/core':
- specifier: ^9.20.0
- version: 9.20.0
- '@babylonjs/havok':
- specifier: ^1.3.10
- version: 1.3.14
+ '@dimforge/rapier3d-deterministic-compat':
+ specifier: 0.21.0
+ version: 0.21.0
'@openvibe/shared':
'@openvibe/shared':
specifier: workspace:*
specifier: workspace:*
version: link:../shared
version: link:../shared
@@ -221,9 +215,6 @@ packages:
@@ -221,9 +215,6 @@ packages:
'@babylonjs/[email protected]':
'@babylonjs/[email protected]':
resolution: {integrity: sha512-iWM/Re+FnqTEOxkGxvjRjChcPZzydcb0OGtnVk5o0vDQizqwkcpWFlJzfIv1Ij7Li2edJJJDZd6jRzC8QSWuuw==}
resolution: {integrity: sha512-iWM/Re+FnqTEOxkGxvjRjChcPZzydcb0OGtnVk5o0vDQizqwkcpWFlJzfIv1Ij7Li2edJJJDZd6jRzC8QSWuuw==}
- '@babylonjs/[email protected]':
- resolution: {integrity: sha512-+j2FDwLtkh5Gb//KizE8Ks3GIB/3vH8Yx9cQsMvZQpsKkH5gn089wIlJ9Yt8dJxbwBmWKGpMTIXxZE7YdOHUnQ==}
-
'@babylonjs/[email protected]':
'@babylonjs/[email protected]':
resolution: {integrity: sha512-zGu6SRvN4uzk5KF0exoSjBZbBcvkEz6iteSvgCn4EP4nQD60W2D4WCtF8m2usZ/yALUOQE5EQON6Tuq1I4LWXg==}
resolution: {integrity: sha512-zGu6SRvN4uzk5KF0exoSjBZbBcvkEz6iteSvgCn4EP4nQD60W2D4WCtF8m2usZ/yALUOQE5EQON6Tuq1I4LWXg==}
peerDependencies:
peerDependencies:
@@ -280,6 +271,9 @@ packages:
@@ -280,6 +271,9 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
engines: {node: '>=20.19.0'}
+ '@dimforge/[email protected]':
+ resolution: {integrity: sha512-n7JuU62Obljlgq+OeH2DHhTmRSjRzwuJPl54tS9pFIzVQyvXsO5CH+a5KkIRBtnFWDbg1Zicv9BulG0jfBAopA==}
+
'@electric-sql/[email protected]':
'@electric-sql/[email protected]':
resolution: {integrity: sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==}
resolution: {integrity: sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==}
@@ -805,9 +799,6 @@ packages:
@@ -805,9 +799,6 @@ packages:
'@types/[email protected]':
'@types/[email protected]':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
- '@types/[email protected]':
- resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==}
-
'@types/[email protected]':
'@types/[email protected]':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
@@ -1343,9 +1334,9 @@ packages:
@@ -1343,9 +1334,9 @@ packages:
pg:
pg:
optional: true
optional: true
- openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1:
- resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1}
- version: 1.30.1
+ openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0:
+ resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0}
+ version: 2.2.0
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
@@ -1814,10 +1805,6 @@ snapshots:
@@ -1814,10 +1805,6 @@ snapshots:
'@babylonjs/[email protected]': {}
'@babylonjs/[email protected]': {}
- '@babylonjs/[email protected]':
- dependencies:
- '@types/emscripten': 1.41.5
-
dependencies:
dependencies:
'@babylonjs/core': 9.20.0
'@babylonjs/core': 9.20.0
@@ -1859,6 +1846,8 @@ snapshots:
@@ -1859,6 +1846,8 @@ snapshots:
'@csstools/[email protected]': {}
'@csstools/[email protected]': {}
+ '@dimforge/[email protected]': {}
+
'@electric-sql/[email protected]': {}
'@electric-sql/[email protected]': {}
'@esbuild/[email protected]':
'@esbuild/[email protected]':
@@ -2170,8 +2159,6 @@ snapshots:
@@ -2170,8 +2159,6 @@ snapshots:
'@types/[email protected]': {}
'@types/[email protected]': {}
- '@types/[email protected]': {}
-
'@types/[email protected]': {}
'@types/[email protected]': {}
'@types/[email protected]': {}
'@types/[email protected]': {}
@@ -2786,7 +2773,7 @@ snapshots:
@@ -2786,7 +2773,7 @@ snapshots:
iovalkey: 0.4.0
iovalkey: 0.4.0
pg: 8.23.0
pg: 8.23.0
- openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1:
+ openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0:
dependencies:
dependencies:
jsonwebtoken: 9.0.3
jsonwebtoken: 9.0.3