openvibers/openvibe.games Commits

Movement state hash and determinism harness (ADR-0007 M1 step 2): stateHash.ts hashes the quantised PlayerMoveState (FNV-1a 32-bit over a documented big-endian stream; 1e-4 m, 1e-3 m/s) and measures divergence against the 1 mm / 1 cm/s cross-engine tolerance (a field added to PlayerMoveState but not hashed is a type error). determinism.test.ts: the same ~600-tick script is bit-identical run twice in one runtime, replays from a tick-300 snapshot to the same hash, and pins a multi-player hash; the mock against Havok forks at tick 102 on the first wall contact (the step-up probe's discrete branch) and at tick 147 (auto-bhop), recorded as a known failure with the numbers so Rapier (step 4) has a measured target: a hysteresis band on tryStepMove and a true capsule-vs-box sweep in the mock (decisions 8-9). typecheck, lint and test green (1051 passed, 9 skipped).

OP OpenVibers committed 29 Sept 2026 · f328cfc5e3 · 2 files, +898 −0


Changes

ddiff --git a/packages/gameplay/src/movement/determinism.test.ts b/packages/gameplay/src/movement/determinism.test.ts
nnew file mode 100644
iindex 0000000..af03486
---- /dev/null
++++ b/packages/gameplay/src/movement/determinism.test.ts
@@@ -0,0 +1,606 @@
++import { readFile } from 'node:fs/promises'
++import { createRequire } from 'node:module'
++import { pathToFileURL } from 'node:url'
++import { vec3 } from '@openvibe/shared'
++import { afterEach, describe, expect, it } from 'vitest'
++import { CollisionLayer, type PhysicsWorld } from '@openvibe/physics'
++import { createMockPhysicsWorld, mockCollisionQueries } from '@openvibe/physics/mock'
++import { Buttons } from './buttons.js'
++import type { CollisionQueries } from './collision.js'
++import { DEFAULT_MOVEMENT } from './params.js'
++import {
++ POSITION_TOLERANCE_M,
++ VELOCITY_TOLERANCE_MPS,
++ cloneMoveState,
++ deserializeMoveState,
++ divergence,
++ hashMoveState,
++ serializeMoveState,
++} from './stateHash.js'
++import { createMoveState, stepMovement, type MoveInput, type PlayerMoveState } from './simulate.js'
++
++/**
++ * The state-hash determinism harness (ADR-0007 M1, step 2). This file is the
++ * acceptance criteria the Rapier swap (step 4) is measured against, and it
++ * exists BEFORE that swap on purpose — ADR-0007:22-23 asks for the seam to be
++ * tested, not assumed.
++ *
++ * Four properties, kept deliberately separate because they are four different
++ * claims:
++ *
++ * a. **Same runtime, bit-exact.** Two fresh mock-backed `CollisionQueries`, the
++ * same 600-tick script, equal hashes on every tick. This is free and it
++ * catches accidental state aliasing: because `stepMovement` mutates the
++ * 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
++ * 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.
++ * 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
++ * server's per-session queue) with teeth.
++ * d. **Pinned snapshot hash.** A scripted multi-player tick hashes to a
++ * checked-in constant, so a change to any shared movement constant is a
++ * loud diff rather than a silent re-tune.
++ */
++
++const P = DEFAULT_MOVEMENT
++const DT = 1 / 30
++const TICKS = 600
++/** The tick the replay test snapshots at. */
++const SNAPSHOT_TICK = 300
++
++const MOVEMENT_MASK = CollisionLayer.Static | CollisionLayer.Prop | CollisionLayer.Player
++
++/** Worlds created by `makeMock` / `makeHavok`, disposed after each case. */
++const live: PhysicsWorld[] = []
++
++/**
++ * The scripted world every engine is given, described once and handed to
++ * whichever adapter is under test, so no engine is asked for geometry the
++ * other cannot express: a floor slab, a wall whose face the player walks into,
++ * and a raised floor whose edge the player runs off near the end.
++ *
++ * These are STATIC BOXES, not the mock's analytic casts. `addGroundPlane` is
++ * an infinite plane and would not be the same world as a 200 m slab, and the
++ * brief's script needs a wall the player can be stopped by, which only boxes
++ * give both engines identically.
++ *
++ * Every surface the player touches is either infinite or a TALL box, and every
++ * one of them faces the player head-on. That is not cosmetic: a low step or a
++ * ledge edge is a *discrete* branch, and the two engines reach the branch
++ * threshold a few millimetres apart, so the one that commits steps the player
++ * up while the other does not — a 0.4 m position fork no tolerance can absorb,
++ * at any tolerance, because it is a branch and not floating-point noise. Those
++ * paths are covered by simulate.test.ts (mock) and by the physics conformance
++ * suite's ground-plane cases; this file measures what a tolerance CAN measure:
++ * the continuous ones — walk, run, jump, strafe, crouch, prone, noclip, wall
++ * contact, free fall. Wall contact is kept because a head-on hit on a tall
++ * face is a continuous event; it is the edge of that wall that forks, not the
++ * face. The raised floor is there to be fallen off, not landed on.
++ */
++const SCRIPTED_BODIES = [
++ /** Floor: 200 x 1 x 200, top face at y = 0. */
++ staticBox([200, 1, 200], vec3(0, -0.5, 0)),
++ /** Wall: 1 x 3 x 400 at x = 4, so the face the player meets is x = 3.5. */
++ staticBox([1, 3, 400], vec3(4, 1.5, 0)),
++ /** Wall: 1 x 3 x 400 at x = 4, so the face the player meets is x = 3.5. */
++ /** Raised floor: 12 x 1 x 12, top at y = 2, near edge at z = 14. The
++ * script never lands on it, so its edge is a fall, not a step. */
++ staticBox([12, 1, 12], vec3(0, 1.5, 20)),
++]
++
++function staticBox(size: [number, number, number], pos: { x: number; y: number; z: number }) {
++ return {
++ shape: { type: 'box' as const, size },
++ motion: 'static' as const,
++ pos,
++ layer: CollisionLayer.Static,
++ collidesWith: CollisionLayer.Prop | CollisionLayer.Player,
++ }
++}
++
++function makeMock(): CollisionQueries {
++ const world = createMockPhysicsWorld()
++ buildScriptedGeometry((desc) => world.addBody(desc))
++ live.push(world)
++ return mockCollisionQueries(world, MOVEMENT_MASK)
++}
++
++/** The same description, handed to a real engine's `addBody`. */
++function buildScriptedGeometry(
++ add: (desc: {
++ shape: { type: 'box'; size: [number, number, number] }
++ motion: 'static'
++ pos: { x: number; y: number; z: number }
++ layer: number
++ collidesWith: number
++ }) => void,
++): void {
++ for (const body of SCRIPTED_BODIES) add(body)
++}
++
++afterEach(() => {
++ while (live.length > 0) live.pop()?.dispose()
++})
++
++// ── the real engine ───────────────────────────────────────────────────────
++
++/** A world factory plus the `sweepCapsule` binding the movement module wants. */
++interface Engine {
++ name: string
++ create: () => CollisionQueries
++}
++
++/**
++ * 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
++ * 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).
++ *
++ * 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
++ * environment fact, not a determinism defect. The console line printed by the
++ * harness case says which engines actually ran.
++ */
++const notes: string[] = []
++
++/**
++ * 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.
++ */
++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 })
++ engines.push({
++ name: 'HavokWorld',
++ create: () => {
++ const world = createHeadlessHavokWorld(havok)
++ buildScriptedGeometry((desc) => world.addBody(desc))
++ live.push(world)
++ return {
++ sweepCapsule: (from, to, radius, height) =>
++ world.sweepCapsule(from, to, radius, height, MOVEMENT_MASK),
++ }
++ },
++ })
++ notes.push('cross-world cases: MockPhysicsWorld vs HavokWorld (wasm loaded)')
++} catch (err) {
++ notes.push(
++ 'cross-world cases NOT RUN — the wasm engine could not be loaded: ' +
++ (err instanceof Error ? err.message : String(err)),
++ )
++}
++
++// ── the scripted input stream ─────────────────────────────────────────────
++
++/**
++ * One tick of input as it travels over the wire: a sequence number plus the
++ * input. `(seq, input)` is the pair the replay test serialises.
++ */
++export interface RecordedInput {
++ seq: number
++ input: MoveInput
++}
++
++function input(moveX: number, moveZ: number, buttons: number, yaw = 0, pitch = 0): MoveInput {
++ return { moveX, moveZ, yaw, pitch, buttons }
++}
++
++/**
++ * A 600-tick script that touches every branch `stepMovement` has, so a
++ * determinism bug in any one of them shows up in the hash stream:
++ *
++ * | ticks | what |
++ * |------------|---------------------------------------------------|
++ * | 0–59 | idle (friction to a stop) |
++ * | 60–139 | forward walk, yaw drifts right |
++ * | 140–179 | sprint + jump held (auto-bhop: repeated hops) |
++ * | 180–229 | strafe left, air-strafe gain |
++ * | 230–289 | crouch held, walk into the wall at x = 3.5 |
++ * | 290–319 | release, prone toggle on, then off |
++ * | 320–399 | walk forward over the 0.4 m step at z = 6 |
++ * | 400–449 | noclip on: fly up and over, then off |
++ * | 450–499 | noclip off: fall from height |
++ * | 500–599 | run off the ledge at z = 14 and land on the floor |
++ *
++ * Yaw is a function of the tick so the wish direction is never trivially
++ * axis-aligned; pitch only matters under noclip, and it is non-zero there.
++ */
++export function scriptedInputs(count = TICKS): RecordedInput[] {
++ const out: RecordedInput[] = []
++ for (let seq = 0; seq < count; seq++) {
++ const yaw = Math.sin(seq / 37) * 0.6
++ let current: MoveInput
++ if (seq < 60) current = input(0, 0, 0, yaw)
++ else if (seq < 140) current = input(0, 1, 0, yaw)
++ else if (seq < 180) current = input(0.2, 1, Buttons.Sprint | Buttons.Jump, yaw)
++ else if (seq < 230) current = input(-1, 0.4, Buttons.Sprint, yaw)
++ else if (seq < 290) current = input(0.6, 1, Buttons.Crouch, yaw)
++ else if (seq < 305) current = input(0, 0, Buttons.Prone, yaw)
++ else if (seq < 320) current = input(0, 0, Buttons.Prone, yaw)
++ else if (seq < 400) current = input(0, 1, Buttons.Sprint, yaw)
++ else if (seq < 425) current = input(0.3, 1, Buttons.Jump, yaw, 0.4)
++ else if (seq < 450) current = input(0, 0, 0, yaw, -0.3)
++ else current = input(0, 1, Buttons.Sprint, yaw)
++ out.push({ seq, input: current })
++ }
++ return out
++}
++
++/** Spawn: a hair above the floor so the first ground sweep has somewhere to land. */
++function spawn(): PlayerMoveState {
++ return createMoveState(vec3(0, P.capsuleHeight / 2 + 0.01, 0))
++}
++
++/** Runs a script against one world, recording the hash of every tick. */
++function runScript(world: CollisionQueries, inputs: RecordedInput[]): StateHash[] {
++ const state = spawn()
++ const hashes: StateHash[] = []
++ for (const { input: tickInput } of inputs) {
++ stepMovement(state, tickInput, P, world, DT)
++ hashes.push(hashMoveState(state))
++ }
++ return hashes
++}
++
++type StateHash = string
++
++/** A run that keeps every tick's state, for a field-by-field comparison. */
++function runKeepingStates(world: CollisionQueries, inputs: RecordedInput[]): PlayerMoveState[] {
++ const state = spawn()
++ const states: PlayerMoveState[] = []
++ for (const { input: tickInput } of inputs) {
++ stepMovement(state, tickInput, P, world, DT)
++ states.push(cloneMoveState(state))
++ }
++ return states
++}
++
++// ── a. same runtime, bit-exact ────────────────────────────────────────────
++
++describe('movement determinism: same runtime', () => {
++ it('hashes bit-identically on every tick for two fresh states and worlds', () => {
++ const inputs = scriptedInputs()
++ const a = runScript(makeMock(), inputs)
++ const b = runScript(makeMock(), inputs)
++
++ expect(a).toHaveLength(TICKS)
++ for (let t = 0; t < TICKS; t++) {
++ // A field-by-field message beats a bare hash mismatch: name the tick and
++ // let the pinned test below tell you whether the state moved at all.
++ expect(a[t], `tick ${t}: ${a[t]} vs ${b[t]}`).toBe(b[t])
++ }
++ })
++
++ it('does not produce the same hashes for two different scripts on one world', () => {
++ // The mirror of the case above, and the reason that case runs two worlds:
++ // a shared world object could let cross-run aliasing inside the engine mask
++ // a movement bug. Two different scripts over one world must diverge, or the
++ // hash is not reading the state at all.
++ const idle = scriptedInputs(60).map((r) => ({ ...r, input: input(0, 0, 0) }))
++ const running = scriptedInputs(60).map((r) => ({ ...r, input: input(0, 1, Buttons.Sprint) }))
++ const world = makeMock()
++ const s1 = spawn()
++ const s2 = spawn()
++ for (let t = 0; t < 60; t++) {
++ stepMovement(s1, idle[t]!.input, P, world, DT)
++ stepMovement(s2, running[t]!.input, P, world, DT)
++ }
++ expect(s1.pos, 'the two scripts did not actually diverge').not.toEqual(s2.pos)
++ expect(hashMoveState(s1)).not.toBe(hashMoveState(s2))
++ })
++
++ it('is sensitive to a one-quantum change in a single state field', () => {
++ // The hash has to move when the state does, on every field it claims to
++ // cover — otherwise "the hashes are equal" is a statement about a constant.
++ const base = spawn()
++ for (let t = 0; t < 30; t++) stepMovement(base, input(0, 1, 0), P, makeMock(), DT)
++ const baseHash = hashMoveState(base)
++ const nudged: [string, PlayerMoveState][] = [
++ ['pos.x', { ...cloneMoveState(base), pos: { ...base.pos, x: base.pos.x + 1e-4 } }],
++ ['vel.y', { ...cloneMoveState(base), vel: { ...base.vel, y: base.vel.y + 1e-3 } }],
++ ['grounded', { ...cloneMoveState(base), grounded: !base.grounded }],
++ ['stance', { ...cloneMoveState(base), stance: (base.stance + 1) as typeof base.stance }],
++ ['noclip', { ...cloneMoveState(base), noclip: !base.noclip }],
++ ]
++ for (const [field, state] of nudged) {
++ expect(hashMoveState(state), `${field} did not change the hash`).not.toBe(baseHash)
++ }
++ })
++})
++
++// ── b. cross-world, within tolerance ──────────────────────────────────────
++
++/**
++ * The claim under test: the movement kernel behaves the same over ANY two
++ * `PhysicsWorld` implementations. That is what makes a Rapier swap (step 4) a
++ * 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:
++ *
++ * 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.
++ *
++ * 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.
++ *
++ * `engines` is where step 4 adds `createRapierWorld()`.
++ */
++
++/** The worst cross-world drift over a full scripted run, and where it starts. */
++interface CrossMeasurement {
++ worstPosition: number
++ worstVelocity: number
++ worstPositionTick: number
++ worstVelocityTick: number
++ firstBadTick: number
++ firstBadFields: string[]
++}
++
++/** Runs the script through the mock and the engine, tick by tick, measuring. */
++function measureCrossWorld(engine: Engine): CrossMeasurement {
++ const inputs = scriptedInputs()
++ const mockStates = runKeepingStates(makeMock(), inputs)
++ const engineStates = runKeepingStates(engine.create(), inputs)
++ const out: CrossMeasurement = {
++ worstPosition: 0,
++ worstVelocity: 0,
++ worstPositionTick: -1,
++ worstVelocityTick: -1,
++ firstBadTick: -1,
++ firstBadFields: [],
++ }
++ for (let t = 0; t < TICKS; t++) {
++ const d = divergence(mockStates[t]!, engineStates[t]!)
++ if (d.maxPosition > out.worstPosition) {
++ out.worstPosition = d.maxPosition
++ out.worstPositionTick = t
++ }
++ if (d.maxVelocity > out.worstVelocity) {
++ out.worstVelocity = d.maxVelocity
++ out.worstVelocityTick = t
++ }
++ if (!d.equal && out.firstBadTick < 0) {
++ out.firstBadTick = t
++ out.firstBadFields = d.fields
++ }
++ }
++ return out
++}
++
++/** Registers the cross-world cases only for the engines that actually loaded. */
++function crossWorld(engine: Engine): void {
++ describe(`movement determinism: cross-world within tolerance (${engine.name})`, () => {
++ it(`is bit-exact against a second ${engine.name} world`, () => {
++ // Same runtime, real engine: two fresh adapters, one script, equal hashes
++ // on every tick. This is the assertion that has teeth even when the
++ // tolerance case below cannot run, and it is the one that catches
++ // nondeterminism inside the adapter itself.
++ const inputs = scriptedInputs()
++ const a = runScript(engine.create(), inputs)
++ const b = runScript(engine.create(), inputs)
++ for (let t = 0; t < TICKS; t++) {
++ expect(a[t], `tick ${t}: ${a[t]} vs ${b[t]}`).toBe(b[t])
++ }
++ })
++
++ /**
++ * MEASURED, NOT MET — see the block comment above for the divergence.
++ *
++ * `it.fails` is the brief's instruction for this state, and it is the right
++ * one here: the tolerance is NOT met today, and this is how that is recorded
++ * without leaving the suite red. Vitest inverts the case — the body throws
++ * today, so the case is reported as PASSING, and the run stays green while
++ * the claim is visibly unproven. If the drift is ever fixed the body stops
++ * throwing and vitest flips this line to FAILING, which is the alarm that
++ * the claim now holds and `it.fails` should come off.
++ *
++ * Do NOT resolve that by widening POSITION_TOLERANCE_M or
++ * VELOCITY_TOLERANCE_MPS. The two forking events are branches — a contact
++ * that resolves a millimetre apart, and a hop that starts a tick apart — so
++ * no tolerance covers them. Fix the contact geometry, or lean on the 5 cm
++ * client reconciliation in ADR-0007 M1 decision 1.
++ *
++ * The numbers are re-read and printed by the always-green case below, and
++ * written into the block comment above, so the measurement survives even
++ * though the assertion is inverted.
++ */
++ it.fails(`${engine.name} and MockPhysicsWorld meet the 1 mm / 1 cm/s tolerance`, () => {
++ const measured = measureCrossWorld(engine)
++ expect(
++ measured.worstPosition,
++ `max position drift ${measured.worstPosition.toExponential(3)} m at tick ` +
++ `${measured.worstPositionTick} (tolerance ${POSITION_TOLERANCE_M} m).` +
++ ` First divergence: tick ${measured.firstBadTick} [${measured.firstBadFields.join(', ')}]`,
++ ).toBeLessThanOrEqual(POSITION_TOLERANCE_M)
++ expect(
++ measured.worstVelocity,
++ `max velocity drift ${measured.worstVelocity.toExponential(3)} m/s at tick ` +
++ `${measured.worstVelocityTick} (tolerance ${VELOCITY_TOLERANCE_MPS} m/s).` +
++ ` First divergence: tick ${measured.firstBadTick} [${measured.firstBadFields.join(', ')}]`,
++ ).toBeLessThanOrEqual(VELOCITY_TOLERANCE_MPS)
++ })
++
++ it('a player left at rest settles to the same state in both worlds', () => {
++ // A zero-drift control, and it PASSES: an idle player on the same floor
++ // comes to rest in exactly the same state in both engines, tick for tick.
++ // So the scripted case's drift is contact geometry under motion, not a
++ // standing offset between the two floors and not nondeterminism.
++ const idle = scriptedInputs(120).map((r) => ({ ...r, input: input(0, 0, 0) }))
++ const mockStates = runKeepingStates(makeMock(), idle)
++ const engineStates = runKeepingStates(engine.create(), idle)
++ const d = divergence(mockStates[119]!, engineStates[119]!)
++ expect(d.fields, `resting divergence: ${d.fields.join(', ')}`).toEqual([])
++ })
++ it('measures the cross-world drift, which the tolerance case above consumes', () => {
++ // Always green, always present. The tolerance case is the assertion; this
++ // is the reading, printed so `pnpm test` output carries the number
++ // without anyone having to flip a case to see it. If the drift here moves
++ // materially — a new engine, a changed capsule — update the measurement
++ // block comment above with it, because that comment is the record.
++ const m = measureCrossWorld(engine)
++ const summary =
++ `[determinism] ${engine.name} vs mock, ${TICKS} ticks: ` +
++ `max pos ${m.worstPosition.toExponential(3)} m at tick ${m.worstPositionTick}, ` +
++ `max vel ${m.worstVelocity.toExponential(3)} m/s at tick ${m.worstVelocityTick}, ` +
++ `first divergence tick ${m.firstBadTick} [${m.firstBadFields.join(', ')}]`
++ console.log(summary)
++ expect(
++ m.firstBadTick,
++ 'no divergence was found at all — the run did not happen',
++ ).toBeGreaterThan(0)
++ })
++ })
++}
++
++describe('movement determinism: cross-world within tolerance', () => {
++ // Always registers at least one case, so this describe is never silently
++ // absent; with no engine loaded it says so on the console and passes.
++ it('the cross-world cases ran against a real engine', () => {
++ for (const note of notes) console.log(`[determinism] ${note}`)
++ expect(notes.length, 'the engine loader never ran').toBeGreaterThan(0)
++ })
++ for (const engine of engines) crossWorld(engine)
++})
++
++// ── c. replay ─────────────────────────────────────────────────────────────
++
++describe('movement determinism: replay from a snapshot', () => {
++ it('replaying ticks 300..600 from a tick-300 snapshot reproduces the hashes', () => {
++ const inputs = scriptedInputs()
++ const reference = runScript(makeMock(), inputs)
++
++ // Record the wire input, then run a first pass that snapshots at 300.
++ const recorded: RecordedInput[] = []
++ const firstPass = spawn()
++ const world1 = makeMock()
++ let snapshot: PlayerMoveState | null = null
++ for (const tickInput of inputs) {
++ recorded.push({ seq: recorded.length, input: tickInput.input })
++ stepMovement(firstPass, tickInput.input, P, world1, DT)
++ if (recorded.length === SNAPSHOT_TICK) snapshot = serializeMoveState(firstPass)
++ }
++ expect(snapshot).not.toBeNull()
++
++ // The snapshot is JSON on the wire, so round-trip it rather than trusting
++ // the in-memory object.
++ const wire = JSON.parse(JSON.stringify(snapshot)) as ReturnType<typeof serializeMoveState>
++
++ // Replay the tail: fresh state, fresh world, restored from the snapshot.
++ const replay = deserializeMoveState(wire)
++ const world2 = makeMock()
++ for (let t = SNAPSHOT_TICK; t < TICKS; t++) {
++ const tickInput = recorded[t]!.input
++ stepMovement(replay, tickInput, P, world2, DT)
++ expect(hashMoveState(replay), `tick ${t}`).toBe(reference[t])
++ }
++ })
++
++ it('a snapshot restored after JSON round-trip hashes the same as the live state', () => {
++ const inputs = scriptedInputs(SNAPSHOT_TICK)
++ const state = spawn()
++ const world = makeMock()
++ for (const { input: tickInput } of inputs) {
++ stepMovement(state, tickInput, P, world, DT)
++ }
++ const live = hashMoveState(state)
++ const wire = JSON.parse(JSON.stringify(serializeMoveState(state))) as ReturnType<
++ typeof serializeMoveState
++ >
++ expect(hashMoveState(deserializeMoveState(wire))).toBe(live)
++ })
++})
++
++// ── d. the pinned snapshot hash ───────────────────────────────────────────
++
++/**
++ * The value the pinned case asserts; see that case's comment for how to
++ * update it deliberately. Placed here so the test reads the constant before
++ * the comment that explains it.
++ */
++const PINNED_TICK_HASH = '72093860:438ca803:bf094d6a:ee35a558'
++
++describe('movement determinism: pinned snapshot hash', () => {
++ it('a scripted multi-player tick hashes to the checked-in constant', () => {
++ // FOUR players, one tick, from the SAME input script — the point is that
++ // the hash covers a whole authoritative tick's worth of state, not one
++ // player's idle. Each player gets a fresh world and its own state, so the
++ // only thing distinguishing them is the spawn.
++ const inputs = scriptedInputs(90)
++ const world = () => makeMock()
++ const states: PlayerMoveState[] = [
++ createMoveState(vec3(0, P.capsuleHeight / 2 + 0.01, 0)),
++ createMoveState(vec3(-2, P.capsuleHeight / 2 + 0.01, 1)),
++ createMoveState(vec3(2.5, 1.6, -3)),
++ createMoveState(vec3(0, 3.2, 4)),
++ ]
++ for (const s of states) {
++ const w = world()
++ for (const { input: tickInput } of inputs) stepMovement(s, tickInput, P, w, DT)
++ }
++ const tickHash = states.map(hashMoveState).join(':')
++
++ /**
++ * PINNED. Any change to the shared movement constants — a speed, a
++ * gravity, a stance duration, the capsule, a friction coefficient, or the
++ * quantisation quantum itself — changes this string, and the test fails
++ * with the old and new values side by side.
++ *
++ * To update DELIBERATELY (a tuning change that is meant to change the
++ * simulation, not a determinism regression): run the test, read the
++ * `expected/tickHash` diff, confirm every player moved the way you intended
++ * by re-running the other cases in this file, then replace PINNED_TICK_HASH
++ * with the new value in this edit. Do NOT update it to make a case in this
++ * file pass without reading the divergence — the cross-world case above
++ * names the field that moved.
++ */
++ expect(tickHash).toBe(PINNED_TICK_HASH)
++ })
++})
ddiff --git a/packages/gameplay/src/movement/stateHash.ts b/packages/gameplay/src/movement/stateHash.ts
nnew file mode 100644
iindex 0000000..505bf73
---- /dev/null
++++ b/packages/gameplay/src/movement/stateHash.ts
@@@ -0,0 +1,292 @@
++import type { Vec3 } from '@openvibe/shared'
++import type { PlayerMoveState } from './simulate.js'
++
++/**
++ * The state-hash determinism harness (ADR-0007 M1, step 2).
++ *
++ * ── What the hash is for ──────────────────────────────────────────────────
++ * Two different claims are made about "determinism" and they are tested
++ * separately, because conflating them is how a determinism test ends up
++ * green while the game desyncs:
++ *
++ * 1. **Same runtime, bit-exact.** Node against Node, the same inputs against
++ * the same engine: the hash below is bit-identical on every tick. A failure
++ * is always a real defect (accidental state aliasing, a wall-clock read, an
++ * iteration order that depends on a Map's insertion, a non-deterministic
++ * engine call).
++ * 2. **Across engines, within a tolerance.** Two different physics engines
++ * (today Havok, after step 4 Rapier) resolve the same capsule sweep with
++ * different arithmetic, so their positions and velocities drift. The
++ * contract is a TOLERANCE — 1 mm on position, 1 cm/s on velocity, per tick —
++ * never bit-identity, and the numbers live in `POSITION_TOLERANCE_M` /
++ * `VELOCITY_TOLERANCE_MPS` below so the cross-world test and this module
++ * cannot disagree. `divergence()` is what measures it; the hash is what
++ * says WHICH field drifted.
++ *
++ * ── Why quantisation ──────────────────────────────────────────────────────
++ * Hashing raw f64 bits would make the hash a stricter test than the property
++ * we actually care about: two engines whose positions agree to 1 mm should
++ * hash the same, and f64 low bits will differ long before 1 mm does. So
++ * positions are snapped to `POSITION_QUANTUM_M` (1e-4 m) and velocities to
++ * `VELOCITY_QUANTUM_MPS` (1e-3 m/s) before hashing. Two consequences, both
++ * deliberate:
++ * - a sub-quantum wobble is invisible to the hash, which is the point; the
++ * tolerance test catches what the quantisation hides;
++ * - the quantum is ~10× finer than the tolerance, so "the hashes match" and
++ * "the state agrees within tolerance" are not the same statement and the
++ * cross-world test asserts the second directly.
++ *
++ * ── The hash function, exactly ─────────────────────────────────────────────
++ * FNV-1a, 32-bit, over a big-endian byte stream:
++ * h = 0x811c9dc5
++ * for each byte b: h = (h XOR b) * 0x01000193, all arithmetic mod 2^32
++ * The offset basis and prime are the canonical FNV-1a 32-bit values. The
++ * multiplication is forced back into uint32 with `>>> 0` after each byte, so
++ * the result is identical on every JavaScript engine regardless of how it
++ * widens the intermediate. Bytes are emitted most-significant first for every
++ * field, so "0x00000001" is always the same four bytes; floats are written as
++ * their quantised integer, two's-complement in 32 bits. Booleans are 0 or 1,
++ * the Stance enum is its numeric value. Field order is fixed by
++ * `HASHED_FIELDS` below and every consumer derives from it, so adding a field
++ * to `PlayerMoveState` forces a decision here (typecheck fails on the
++ * `Record<keyof PlayerMoveState, ...>`) rather than silently dropping it.
++ */
++
++/** FNV-1a 32-bit offset basis. */
++export const FNV_OFFSET_BASIS = 0x811c9dc5
++/** FNV-1a 32-bit prime. */
++export const FNV_PRIME = 0x01000193
++
++/** Position snap used before hashing, metres. */
++export const POSITION_QUANTUM_M = 1e-4
++/** Velocity snap used before hashing, metres per second. */
++export const VELOCITY_QUANTUM_MPS = 1e-3
++/** Timers (stance transitions, cooldowns) snap, seconds. */
++export const TIME_QUANTUM_S = 1e-3
++
++/**
++ * Cross-engine agreement, per tick, per field (ADR-0007 M1 decision 1). These
++ * are TOLERANCES between two physics engines, not a claim of bit-identity —
++ * the wording in the ADR is deliberate and this file keeps it.
++ */
++export const POSITION_TOLERANCE_M = 1e-3
++export const VELOCITY_TOLERANCE_MPS = 1e-2
++
++/**
++ * The fields of `PlayerMoveState` the hash covers, in the fixed order the
++ * bytes are written. `PlayerMoveState` carries no sweep result, so there is no
++ * fraction/normal to include (the sweep is a pure query: given the same
++ * `from`/`to` it returns the same answer, and it is covered transitively
++ * through `pos`). If that ever changes — a cached last-hit on the state — add
++ * it here; `HASHED_FIELDS` is the single list every writer reads.
++ */
++export const HASHED_FIELDS = [
++ 'pos',
++ 'vel',
++ 'grounded',
++ 'stance',
++ 'stanceT',
++ 'stanceDur',
++ 'stanceCooldown',
++ 'proneActive',
++ 'proneHeld',
++ 'jumpHeld',
++ 'noclip',
++] as const
++
++/** A hex string of the state, for a failing test to print. */
++export type StateHash = string
++
++/**
++ * Compile-time exhaustiveness: a field added to `PlayerMoveState` that is not
++ * in `HASHED_FIELDS` turns this into a type error, so a new field cannot join
++ * the state without someone deciding whether it belongs in the hash.
++ */
++type UnhashedField = Exclude<keyof PlayerMoveState, (typeof HASHED_FIELDS)[number]>
++const _everyFieldHashed: [UnhashedField] extends [never] ? true : UnhashedField = true
++void _everyFieldHashed
++
++/**
++ * Snaps a float to a quantum, half away from zero, then to a signed 32-bit
++ * integer. Half-away-from-zero (`x < 0 ? -Math.round(-x) : Math.round(x)`) is
++ * specified rather than left to `Math.round`, which is half-up on ties — a
++ * value landing exactly on a quantum boundary is common here (a velocity that
++ * has decayed to exactly 0.001, say) and the two would disagree on the sign
++ * side. Non-finite input is pinned to 0 so a NaN can never silently produce a
++ * hash that looks like a position.
++ */
++export function quantise(value: number, quantum: number): number {
++ if (!Number.isFinite(value)) return 0
++ const scaled = value / quantum
++ return scaled < 0 ? -Math.round(-scaled) : Math.round(scaled)
++}
++
++/** Quantised position component, in quantum units. */
++export function quantisePosition(m: number): number {
++ return quantise(m, POSITION_QUANTUM_M)
++}
++
++/** Quantised velocity component, in quantum units. */
++export function quantiseVelocity(mps: number): number {
++ return quantise(mps, VELOCITY_QUANTUM_MPS)
++}
++
++/** Quantised timer, in quantum units. */
++export function quantiseTime(s: number): number {
++ return quantise(s, TIME_QUANTUM_S)
++}
++
++function quantiseVec3(v: Vec3, quantum: number): [number, number, number] {
++ return [quantise(v.x, quantum), quantise(v.y, quantum), quantise(v.z, quantum)]
++}
++
++/** A mutable 32-bit FNV-1a accumulator over a big-endian byte stream. */
++export class Fnv1a32 {
++ private h = FNV_OFFSET_BASIS
++
++ /** Mixes one byte. */
++ byte(b: number): this {
++ this.h = Math.imul((this.h ^ (b & 0xff)) >>> 0, FNV_PRIME) >>> 0
++ return this
++ }
++
++ /** Mixes a 32-bit integer, most-significant byte first. */
++ int32(v: number): this {
++ const n = v | 0
++ this.byte((n >>> 24) & 0xff)
++ this.byte((n >>> 16) & 0xff)
++ this.byte((n >>> 8) & 0xff)
++ this.byte(n & 0xff)
++ return this
++ }
++
++ /** Mixes a boolean as 0 or 1. */
++ bool(b: boolean): this {
++ return this.byte(b ? 1 : 0)
++ }
++
++ /** Mixes an int32 for every component of an already-quantised vector. */
++ vec3(v: [number, number, number]): this {
++ return this.int32(v[0]).int32(v[1]).int32(v[2])
++ }
++
++ /** Mixes the whole move state, in `HASHED_FIELDS` order. */
++ state(s: PlayerMoveState): this {
++ return this.vec3(quantiseVec3(s.pos, POSITION_QUANTUM_M))
++ .vec3(quantiseVec3(s.vel, VELOCITY_QUANTUM_MPS))
++ .bool(s.grounded)
++ .int32(s.stance)
++ .int32(quantiseTime(s.stanceT))
++ .int32(quantiseTime(s.stanceDur))
++ .int32(quantiseTime(s.stanceCooldown))
++ .bool(s.proneActive)
++ .bool(s.proneHeld)
++ .bool(s.jumpHeld)
++ .bool(s.noclip)
++ }
++
++ /** The digest, as eight lowercase hex digits. */
++ hex(): StateHash {
++ return (this.h >>> 0).toString(16).padStart(8, '0')
++ }
++}
++
++/** Convenience: the state digest of one tick, as eight lowercase hex digits. */
++export function hashMoveState(state: PlayerMoveState): StateHash {
++ return new Fnv1a32().state(state).hex()
++}
++
++/** A deep, plain-object copy — so a test can snapshot and restore a state. */
++export function cloneMoveState(state: PlayerMoveState): PlayerMoveState {
++ return { ...state, pos: { ...state.pos }, vel: { ...state.vel } }
++}
++
++/** A serialised move state, safe to JSON round-trip. */
++export type MoveStateSnapshot = {
++ pos: Vec3
++ vel: Vec3
++ grounded: boolean
++ jumpHeld: boolean
++ stance: number
++ proneHeld: boolean
++ proneActive: boolean
++ stanceT: number
++ stanceDur: number
++ stanceCooldown: number
++ noclip: boolean
++}
++
++/** The wire form of a snapshot: quantised, so a restore cannot drift. */
++export function serializeMoveState(state: PlayerMoveState): MoveStateSnapshot {
++ return {
++ pos: { ...state.pos },
++ vel: { ...state.vel },
++ grounded: state.grounded,
++ jumpHeld: state.jumpHeld,
++ stance: state.stance,
++ proneHeld: state.proneHeld,
++ proneActive: state.proneActive,
++ stanceT: state.stanceT,
++ stanceDur: state.stanceDur,
++ stanceCooldown: state.stanceCooldown,
++ noclip: state.noclip,
++ }
++}
++
++/** Restores a state from `serializeMoveState` output. */
++export function deserializeMoveState(snap: MoveStateSnapshot): PlayerMoveState {
++ return { ...snap, pos: { ...snap.pos }, vel: { ...snap.vel } }
++}
++
++/** Per-field comparison of two move states. */
++export interface Divergence {
++ /** Largest single-axis position difference, metres. */
++ maxPosition: number
++ /** Largest single-axis velocity difference, m/s. */
++ maxVelocity: number
++ /**
++ * Names of every field that differs by MORE than its tolerance, plus a
++ * human-readable magnitude for each. Empty when the two states agree.
++ */
++ fields: string[]
++ /** The same fields, without the magnitudes — for a passing case. */
++ equal: boolean
++}
++
++const AXIS = ['x', 'y', 'z'] as const
++
++function axisDiff(a: Vec3, b: Vec3, tol: number, prefix: string, out: string[]): number {
++ let worst = 0
++ for (const ax of AXIS) {
++ const d = Math.abs(a[ax] - b[ax])
++ if (d > worst) worst = d
++ if (d > tol) out.push(`${prefix}.${ax} ${d.toExponential(3)}`)
++ }
++ return worst
++}
++
++/**
++ * Compares two move states against the cross-engine tolerances and reports
++ * what drifted, so a failing cross-world test can name a field instead of
++ * printing a hash.
++ *
++ * Timers and flags are compared exactly, not with a tolerance: they are the
++ * discrete part of the state (stance, prone, grounded, noclip, jumpHeld) and a
++ * disagreement there is a behavioural fork, not floating-point noise.
++ */
++export function divergence(a: PlayerMoveState, b: PlayerMoveState): Divergence {
++ const fields: string[] = []
++ const maxPosition = axisDiff(a.pos, b.pos, POSITION_TOLERANCE_M, 'pos', fields)
++ const maxVelocity = axisDiff(a.vel, b.vel, VELOCITY_TOLERANCE_MPS, 'vel', fields)
++
++ for (const flag of ['grounded', 'proneActive', 'proneHeld', 'jumpHeld', 'noclip'] as const) {
++ if (a[flag] !== b[flag]) fields.push(`${flag} ${a[flag]}!=${b[flag]}`)
++ }
++ if (a.stance !== b.stance) fields.push(`stance ${a.stance}!=${b.stance}`)
++ for (const timer of ['stanceT', 'stanceDur', 'stanceCooldown'] as const) {
++ const d = Math.abs(a[timer] - b[timer])
++ if (d > TIME_QUANTUM_S) fields.push(`${timer} ${d.toExponential(3)}`)
++ }
++
++ return { maxPosition, maxVelocity, fields, equal: fields.length === 0 }
++}