clone

an endless marble-race generator in the browser; the url is the seed, so every track can be shared

Wave 1: camera clip fixes, endless goldens, check clean, shim groundwork

Dami committed 10e347e
- Follow-cam island clipping: pull-in could invert past the hit (0.8
  floor beat a nearer hit and parked the camera INSIDE the mass); only
  the desired position was ever tested, so the slow lerp + stale rays
  left the real camera clipping for many frames. New pure clampBoom/
  resolveBoom (snap-in, eased release, near-plane clearance), a second
  post-lerp clamp against the ACTUAL camera ray, boom reset on retarget
  (tests/camera_test.ts).
- Endless stream goldens: tests/golden_endless.json pins 4 seeds x 12
  segment hashes (canonical integer-domain serialization, cyrb128);
  tools/pin_endless.ts regenerates the pins with the same exported code
  path. Planner refactors can no longer silently reshuffle streams.
- deno task check is fully clean: server Timeout typing via
  ReturnType<typeof setTimeout>; phys-contact skips bumper proxies (the
  box-distance math would have thrown on p.half at runtime).
- Shim groundwork for the respawn + gravity rounds: ph_marble_ghost
  (marble category bits; ghost masks out other marbles both ways),
  ph_add_box_q / ph_add_bumper_q (full-quaternion statics for reoriented
  chunks), engine setGhost with spawn-reset cache; frame.ts pure
  quat/affine law (composeTwist pivots on the twist point) + tests.
commit
10e347eb7e5b2d5c0362857d03290682b8be54f2
parent
d16b36267744182c14f8d545b65b2477955f9d59
author
Dami <dami.etoile@gmail.com>
12 files changed, +784 -28
Msrc/client/camera.ts137++++++++++++++++++++++++-----
Msrc/client/physics/engine.ts43+++++++++-
Asrc/client/physics/frame.ts138+++++++++++++++++++++++++++++
Msrc/client/physics/shim.c57++++++++++++
Msrc/server/main.ts2+-
Atests/camera_test.ts90+++++++++++++++++++
Atests/endless_golden_test.ts60+++++++++++++
Atests/frame_test.ts80+++++++++++++++++
Atests/golden_endless.json59++++++++++++
Mtools/build_physics.sh2+-
Mtools/physics-debug/phys-contact.ts2+-
Atools/pin_endless.ts142++++++++++++++++++++++++++++++
M src/client/camera.ts +113 -24
@@ -46,6 +46,58 @@ const ADJUST_RETURN_AFTER = 3;
4646 const ADJUST_RETURN_RATE = 2.2; // exp decay per second4747 const ZOOM_MIN = 0.35;4848 const ZOOM_MAX = 3;49+/** Occlusion pull-in (follow mode). CLIP_MARGIN is how far in front of a50+ * hit surface the boom settles; NEAR_CLEARANCE is the hard floor between51+ * lens and surface — it must exceed the camera near plane (0.1 in52+ * scene.ts) at the frustum CORNERS (near/cos of the diagonal half-fov),53+ * or the near plane slices the wall even though the camera origin is54+ * outside it. MIN_BOOM keeps the shot from collapsing onto the marble55+ * when it hugs a wall — but never wins over staying outside the mass. */56+const CLIP_MARGIN = 0.35;57+const NEAR_CLEARANCE = 0.2;58+const MIN_BOOM = 0.8;59+/** Boom release: snap IN when a mass intrudes (clipping is worse than a60+ * pop), ease back OUT at this exp rate per second once it clears. */61+const BOOM_RELEASE_RATE = 3;62+63+/**64+ * Occlusion clamp for the follow boom (pure — tests/camera_test.ts).65+ * Given the unobstructed boom length and the nearest occluder hit along66+ * the look→camera ray (Infinity when clear), returns how long the boom67+ * may be. Invariants: never longer than desired, never past the hit68+ * (strictly closer than hitDist), and MIN_BOOM only applies when it69+ * doesn't violate either.70+ */71+export function clampBoom(desiredDist: number, hitDist: number): number {72+ const target = Math.min(73+ desiredDist,74+ Math.max(75+ hitDist - CLIP_MARGIN,76+ Math.min(MIN_BOOM, hitDist - NEAR_CLEARANCE),77+ ),78+ );79+ // Floor: hold the minimum boom, but cap it at half the hit distance so80+ // the camera NEVER crosses to the far side of a wall, and at the81+ // desired distance so a deliberate user zoom-in is respected.82+ return Math.max(target, Math.min(MIN_BOOM, hitDist * 0.5, desiredDist));83+}84+85+/**86+ * Boom length with asymmetric smoothing (pure — tests/camera_test.ts):87+ * snap in to the clamped length when it shrinks, ease back out when it88+ * grows. `prevBoom < 0` means uninitialized (first frame / retarget).89+ */90+export function resolveBoom(91+ desiredDist: number,92+ hitDist: number,93+ prevBoom: number,94+ dt: number,95+): number {96+ const target = clampBoom(desiredDist, hitDist);97+ if (!(prevBoom >= 0) || target <= prevBoom) return target; // snap in98+ const k = Math.exp(-BOOM_RELEASE_RATE * Math.max(dt, 1e-4));99+ return target + (prevBoom - target) * k; // ease out100+}49101 50102 export interface CameraOptions {51103 /** Follow-cam trail distance/height — endless mode rides higher and
@@ -130,6 +182,21 @@ export function createCameras(
130182 const lookNow = new THREE.Vector3();131183 const raycaster = new THREE.Raycaster();132184 const rayDir = new THREE.Vector3();185+ /** Smoothed boom length (occlusion pull-in state); <0 = uninitialized. */186+ let boomNow = -1;187+ /** Nearest occluder hit distance from `origin` along `dir` (unit),188+ * looking no farther than `far`; Infinity when clear. */189+ const nearestHit = (190+ origin: THREE.Vector3,191+ dir: THREE.Vector3,192+ far: number,193+ obs: readonly THREE.Object3D[],194+ ): number => {195+ raycaster.set(origin, dir);196+ raycaster.far = far;197+ const hits = raycaster.intersectObjects(obs as THREE.Object3D[], true);198+ return hits.length > 0 ? hits[0].distance : Infinity;199+ };133200 const bankA = new THREE.Vector3();134201 const bankB = new THREE.Vector3();135202 const bankC = new THREE.Vector3();
@@ -166,6 +233,7 @@ export function createCameras(
166233 activeMarble = nextMarble;167234 pathSampler = null;168235 lookInit = false;236+ boomNow = -1;169237 },170238 171239 retargetPath(getPointAtS, nextMarble) {
@@ -173,6 +241,7 @@ export function createCameras(
173241 activeCurve = null;174242 activeMarble = nextMarble;175243 lookInit = false;244+ boomNow = -1;176245 },177246 178247 setMode(next: CamMode) {
@@ -189,6 +258,7 @@ export function createCameras(
189258 }190259 }191260 lookInit = false;261+ boomNow = -1;192262 api.onModeChange?.(mode);193263 },194264
@@ -245,35 +315,54 @@ export function createCameras(
245315 }246316 247317 // Occlusion: never let island mass sit between marble and camera —248- // pull the desired position in front of the first hit (small249- // margin), so the cam slides over shoulders instead of clipping250- // through them.251- if (opts.occluders) {252- const obs = opts.occluders();253- if (obs.length > 0) {254- rayDir.copy(desired).sub(lookTarget);255- const dist = rayDir.length();256- if (dist > 1e-4) {257- rayDir.divideScalar(dist);258- raycaster.set(lookTarget, rayDir);259- raycaster.far = dist;260- const hits = raycaster.intersectObjects(261- obs as THREE.Object3D[],262- true,263- );264- if (hits.length > 0) {265- desired.copy(lookTarget).addScaledVector(266- rayDir,267- Math.max(0.8, hits[0].distance - 0.35),268- );269- }270- }271- }318+ // the boom shortens to sit in front of the first hit along the319+ // marble → camera ray (looking a margin PAST the desired spot, so320+ // a wall just behind it still counts), snapping in and easing back321+ // out via resolveBoom. The cam slides over shoulders instead of322+ // clipping through them.323+ const obs = opts.occluders ? opts.occluders() : [];324+ rayDir.copy(desired).sub(lookTarget);325+ const dist = rayDir.length();326+ if (obs.length > 0 && dist > 1e-4) {327+ rayDir.divideScalar(dist);328+ const hit = nearestHit(329+ lookTarget,330+ rayDir,331+ dist + CLIP_MARGIN + NEAR_CLEARANCE,332+ obs,333+ );334+ boomNow = resolveBoom(dist, hit, boomNow, dt);335+ desired.copy(lookTarget).addScaledVector(rayDir, boomNow);336+ } else {337+ boomNow = dist;272338 }273339 274340 // Frame-rate independent smoothing of the nominal 0.08/frame at 60fps.275341 const a = 1 - Math.pow(1 - LERP, Math.max(dt, 1e-4) * 60);276342 camera.position.lerp(desired, a);343+344+ // The lerp above moves the REAL camera on its own path — while it345+ // eases toward a freshly pulled-in `desired` (or the boom swings346+ // around a corner the desired-ray missed) it can still be inside a347+ // mass. Clamp the actual position the same way, along its own ray:348+ // snap-in only, the ease-out happens through the lerp next frames.349+ if (obs.length > 0) {350+ rayDir.copy(camera.position).sub(lookTarget);351+ const camDist = rayDir.length();352+ if (camDist > 1e-4) {353+ rayDir.divideScalar(camDist);354+ const hit = nearestHit(355+ lookTarget,356+ rayDir,357+ camDist + NEAR_CLEARANCE,358+ obs,359+ );360+ const safe = clampBoom(camDist, hit);361+ if (safe < camDist - 1e-6) {362+ camera.position.copy(lookTarget).addScaledVector(rayDir, safe);363+ }364+ }365+ }277366 if (!lookInit) {278367 lookNow.copy(lookTarget);279368 lookInit = true;
M src/client/physics/engine.ts +42 -1
@@ -41,6 +41,31 @@ export interface PhysicsModule {
4141 radius: number,4242 ): void;4343 _ph_destroy_marble(idx: number): void;44+ _ph_marble_ghost(idx: number, ghost: number): void;45+ _ph_add_box_q(46+ cx: number,47+ cy: number,48+ cz: number,49+ hx: number,50+ hy: number,51+ hz: number,52+ qx: number,53+ qy: number,54+ qz: number,55+ qw: number,56+ ): void;57+ _ph_add_bumper_q(58+ cx: number,59+ cy: number,60+ cz: number,61+ radius: number,62+ height: number,63+ restitution: number,64+ qx: number,65+ qy: number,66+ qz: number,67+ qw: number,68+ ): void;4469 _ph_add_mesh(4570 vertsPtr: number,4671 vertCount: number,
@@ -163,6 +188,8 @@ export class PhysicsEngine {
163188 private accumulator = 0;164189 /** Per-marble restitution cache (index → last set value). */165190 private readonly restitutions = new Map<number, number>();191+ /** Per-marble ghost cache (respawn immunity; spawn resets to solid). */192+ private readonly ghosts = new Map<number, boolean>();166193 167194 constructor(private readonly m: PhysicsModule) {168195 m._ph_init(GRAVITY_Y);
@@ -261,6 +288,7 @@ export class PhysicsEngine {
261288 ): void {262289 if (idx === 0) this.accumulator = 0;263290 this.restitutions.set(idx, MARBLE_RESTITUTION); // shim recreates the shape291+ this.ghosts.set(idx, false);264292 this.m._ph_spawn_marble(265293 idx,266294 pos[0],
@@ -276,6 +304,15 @@ export class PhysicsEngine {
276304 destroyMarble(idx: number): void {277305 this.m._ph_destroy_marble(idx);278306 this.restitutions.delete(idx);307+ this.ghosts.delete(idx);308+ }309+310+ /** Respawn immunity (cached): a ghost marble keeps colliding with the311+ * track but passes through other marbles. Spawn resets to solid. */312+ setGhost(idx: number, ghost: boolean): void {313+ if ((this.ghosts.get(idx) ?? false) === ghost) return;314+ this.ghosts.set(idx, ghost);315+ this.m._ph_marble_ghost(idx, ghost ? 1 : 0);279316 }280317 281318 /**
@@ -303,7 +340,11 @@ export class PhysicsEngine {
303340 * added — deterministic leave-speed (the MBHaxe jump contract), so the304341 * autopilot can price an arc before committing. Safe to call repeatedly305342 * while the condition holds; extra calls are no-ops. */306- jump(idx: number, n: readonly [number, number, number], target: number): void {343+ jump(344+ idx: number,345+ n: readonly [number, number, number],346+ target: number,347+ ): void {307348 this.m._ph_marble_jump(idx, n[0], n[1], n[2], target);308349 }309350
A src/client/physics/frame.ts +138 -0
@@ -0,0 +1,138 @@
1+/**2+ * Frame math for gravity reorientation (M16): a reorient zone rolls the3+ * stream about its travel axis, so every station downstream of a twist4+ * carries a rigid AFFINE mapping plan space (the generator's +y-up world)5+ * into presented world space. Rails, autopilot and all control math stay6+ * in plan space — drivers conjugate marble state in, run the unchanged7+ * laws, and rotate forces back out. Pure data + functions (no three.js):8+ * one truth for the client driver and the headless tests.9+ */10+11+/** Unit quaternion [x, y, z, w]. */12+export type Quat = [number, number, number, number];13+14+/** Rigid transform: world = rotate(q, plan) + t. */15+export interface Affine {16+ q: Quat;17+ t: [number, number, number];18+}19+20+export const Q_IDENTITY: Quat = [0, 0, 0, 1];21+22+export const AFFINE_IDENTITY: Affine = {23+ q: [0, 0, 0, 1],24+ t: [0, 0, 0],25+};26+27+export function quatFromAxisAngle(28+ axis: readonly [number, number, number],29+ angle: number,30+): Quat {31+ const h = angle / 2;32+ const s = Math.sin(h);33+ return [axis[0] * s, axis[1] * s, axis[2] * s, Math.cos(h)];34+}35+36+export function quatMul(a: Quat, b: Quat): Quat {37+ return [38+ a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],39+ a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],40+ a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],41+ a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],42+ ];43+}44+45+/** Rotate vector v by quaternion q (v' = q v q⁻¹). */46+export function quatRotate(47+ q: Quat,48+ v: readonly [number, number, number],49+ out: [number, number, number] = [0, 0, 0],50+): [number, number, number] {51+ // t = 2 q_v × v; v' = v + q_w t + q_v × t52+ const tx = 2 * (q[1] * v[2] - q[2] * v[1]);53+ const ty = 2 * (q[2] * v[0] - q[0] * v[2]);54+ const tz = 2 * (q[0] * v[1] - q[1] * v[0]);55+ out[0] = v[0] + q[3] * tx + q[1] * tz - q[2] * ty;56+ out[1] = v[1] + q[3] * ty + q[2] * tx - q[0] * tz;57+ out[2] = v[2] + q[3] * tz + q[0] * ty - q[1] * tx;58+ return out;59+}60+61+/** Rotate v by the INVERSE of q (unit quats: conjugate). */62+export function quatUnrotate(63+ q: Quat,64+ v: readonly [number, number, number],65+ out: [number, number, number] = [0, 0, 0],66+): [number, number, number] {67+ return quatRotate([-q[0], -q[1], -q[2], q[3]], v, out);68+}69+70+export function isIdentityQuat(q: Quat): boolean {71+ return q[0] === 0 && q[1] === 0 && q[2] === 0;72+}73+74+export function isIdentityAffine(a: Affine): boolean {75+ return isIdentityQuat(a.q) && a.t[0] === 0 && a.t[1] === 0 && a.t[2] === 0;76+}77+78+/** world = q·plan + t. */79+export function affineApply(80+ a: Affine,81+ p: readonly [number, number, number],82+ out: [number, number, number] = [0, 0, 0],83+): [number, number, number] {84+ quatRotate(a.q, p, out);85+ out[0] += a.t[0];86+ out[1] += a.t[1];87+ out[2] += a.t[2];88+ return out;89+}90+91+/** plan = q⁻¹·(world − t). */92+export function affineUnapply(93+ a: Affine,94+ w: readonly [number, number, number],95+ out: [number, number, number] = [0, 0, 0],96+): [number, number, number] {97+ const v: [number, number, number] = [98+ w[0] - a.t[0],99+ w[1] - a.t[1],100+ w[2] - a.t[2],101+ ];102+ return quatUnrotate(a.q, v, out);103+}104+105+/**106+ * Compose a twist onto a running frame: rotate everything downstream by107+ * `angle` about the world-space `axisWorld` line through `pivotWorld`,108+ * AFTER the existing frame. The pivot is the twist point itself, so the109+ * channel centerline stays continuous through the roll.110+ */111+export function composeTwist(112+ frame: Affine,113+ axisWorld: readonly [number, number, number],114+ angle: number,115+ pivotWorld: readonly [number, number, number],116+): Affine {117+ const r = quatFromAxisAngle(axisWorld, angle);118+ const q = quatMul(r, frame.q);119+ // world' = r·(world − pivot) + pivot; world = q0·plan + t0120+ // ⇒ t' = r·(t0 − pivot) + pivot121+ const rt = quatRotate(r, [122+ frame.t[0] - pivotWorld[0],123+ frame.t[1] - pivotWorld[1],124+ frame.t[2] - pivotWorld[2],125+ ]);126+ return {127+ q,128+ t: [rt[0] + pivotWorld[0], rt[1] + pivotWorld[1], rt[2] + pivotWorld[2]],129+ };130+}131+132+/** Local up (plan +y) expressed in world space. */133+export function frameUp(134+ a: Affine,135+ out: [number, number, number] = [0, 0, 0],136+): [number, number, number] {137+ return quatRotate(a.q, [0, 1, 0], out);138+}
M src/client/physics/shim.c +57 -0
@@ -98,6 +98,22 @@ void ph_add_box(
9898 b3CreateHullShape(refBodies[pendingSlot], &shapeDef, &box.base);9999 }100100 101+// Fully-oriented box: center + local half-extents + a world quaternion —102+// gravity-reoriented chunks rotate their proxies rigidly (yaw/pitch slabs103+// can't express a roll).104+EMSCRIPTEN_KEEPALIVE105+void ph_add_box_q(106+ double cx, double cy, double cz,107+ float hx, float hy, float hz,108+ float qx, float qy, float qz, float qw) {109+ if (pendingSlot < 0) return;110+ b3Transform xf;111+ xf.p = (b3Vec3){(float)cx, (float)cy, (float)cz};112+ xf.q = (b3Quat){{qx, qy, qz}, qw};113+ b3BoxHull box = b3MakeTransformedBoxHull(hx, hy, hz, xf);114+ b3CreateHullShape(refBodies[pendingSlot], &shapeDef, &box.base);115+}116+101117 // Arbitrarily-oriented slab: center + local half-extents + a basis given as102118 // tangent (x-ish) and up handled via yaw+pitch — used for sloped floors.103119 EMSCRIPTEN_KEEPALIVE
@@ -138,6 +154,27 @@ void ph_add_bumper(
138154 b3DestroyHull(cyl);139155 }140156 157+// Bumper pin with a full world orientation (reoriented island decks stand158+// their pins sideways along the local gravity).159+EMSCRIPTEN_KEEPALIVE160+void ph_add_bumper_q(161+ double cx, double cy, double cz,162+ float radius, float height, float restitution,163+ float qx, float qy, float qz, float qw) {164+ if (pendingSlot < 0) return;165+ b3HullData* cyl = b3CreateCylinder(height, radius, 0.0f, 12);166+ if (!cyl) return;167+ b3ShapeDef sd = b3DefaultShapeDef();168+ sd.baseMaterial.restitution = restitution;169+ sd.baseMaterial.friction = 0.4f;170+ b3Transform xf;171+ xf.p = (b3Vec3){(float)cx, (float)cy, (float)cz};172+ xf.q = (b3Quat){{qx, qy, qz}, qw};173+ b3CreateTransformedHullShape(refBodies[pendingSlot], &sd, cyl, xf,174+ (b3Vec3){1, 1, 1});175+ b3DestroyHull(cyl);176+}177+141178 EMSCRIPTEN_KEEPALIVE142179 void ph_end_static(void) {143180 pendingSlot = -1;
@@ -157,6 +194,13 @@ void ph_remove_static(int key) {
157194 158195 // --- the marbles (M15: an array — peers are real bodies) -------------------159196 197+// Collision categories: statics keep the default category (0x1); marbles198+// are 0x2. A respawn-ghost marble masks out other marbles — it falls onto199+// the track through the pack, then resolidifies (both directions stop200+// colliding because Box3D requires the mask test to pass on BOTH shapes).201+#define CAT_STATIC 0x1ull202+#define CAT_MARBLE 0x2ull203+160204 // Spawn (or respawn) marble `idx`. World gravity stays FIXED at the init161205 // vector — per-marble gravity zones are delta forces applied by the162206 // driver (one global vector cannot serve N marbles in different zones).
@@ -179,10 +223,23 @@ void ph_spawn_marble(
179223 // restitution combines via MAX -> 0.35, the pack-friendly range.180224 sd.baseMaterial.restitution = 0.35f;181225 sd.baseMaterial.friction = 0.7f;226+ sd.filter.categoryBits = CAT_MARBLE;182227 marbleShapes[idx] = b3CreateSphereShape(marbles[idx], &sd, &sphere);183228 marblesAlive[idx] = 1;184229 }185230 231+// Respawn-immunity toggle: a ghost marble collides with the track but not232+// with other marbles (materializing inside the pack must not detonate it).233+// Spawn resets to solid (the shape is recreated with the default mask).234+EMSCRIPTEN_KEEPALIVE235+void ph_marble_ghost(int idx, int ghost) {236+ if (idx < 0 || idx >= MAX_MARBLES || !marblesAlive[idx]) return;237+ b3Filter f = b3DefaultFilter();238+ f.categoryBits = CAT_MARBLE;239+ if (ghost) f.maskBits = ~CAT_MARBLE;240+ b3Shape_SetFilter(marbleShapes[idx], f, true);241+}242+186243 EMSCRIPTEN_KEEPALIVE187244 void ph_destroy_marble(int idx) {188245 if (idx < 0 || idx >= MAX_MARBLES || !marblesAlive[idx]) return;
M src/server/main.ts +1 -1
A tests/camera_test.ts +90 -0
@@ -0,0 +1,90 @@
1+/**2+ * Follow-cam boom occlusion law (camera.ts clampBoom/resolveBoom).3+ *4+ * The clamp must keep the camera strictly on the near side of any hit5+ * surface (clipping is worse than any framing compromise), respect a6+ * deliberate user zoom-in, and hold the minimum boom only when there is7+ * room for it. The smoother snaps in and eases out.8+ */9+import { assert, assertEquals } from "@std/assert";10+import { clampBoom, resolveBoom } from "../src/client/camera.ts";11+12+Deno.test("clampBoom: clear ray keeps the desired boom exactly", () => {13+ assertEquals(clampBoom(7, Infinity), 7);14+ // A user zoom-in below the minimum boom is respected when nothing15+ // occludes — the floor must not push the camera back out.16+ assertEquals(clampBoom(0.6, Infinity), 0.6);17+});18+19+Deno.test("clampBoom: hit pulls the boom in front of the surface", () => {20+ // Plenty of room: settles CLIP_MARGIN (0.35) short of the hit.21+ const d = clampBoom(7, 3);22+ assert(Math.abs(d - 2.65) < 1e-9, `expected 2.65, got ${d}`);23+ // Hit closer than MIN_BOOM + margin: the floor holds MIN_BOOM as long24+ // as NEAR_CLEARANCE to the wall survives.25+ assertEquals(clampBoom(7, 1.0), 0.8);26+});27+28+Deno.test("clampBoom: wall just BEHIND the desired spot still pulls in", () => {29+ // The near plane extends past the camera origin — a surface within the30+ // margin beyond the desired position must shorten the boom too.31+ const d = clampBoom(7, 7.1);32+ assert(d < 7, `boom must shrink, got ${d}`);33+ assert(d <= 7.1 - 0.2 + 1e-9, `near clearance violated: ${d}`);34+});35+36+Deno.test("clampBoom: never inverts past the hit, never negative", () => {37+ for (let i = 0; i < 400; i++) {38+ // Deterministic sweep over awkward combinations, including hits39+ // closer than the marble's own look offset.40+ const hit = 0.01 + (i % 20) * 0.5;41+ const desired = 0.05 + Math.floor(i / 20) * 0.6;42+ const d = clampBoom(desired, hit);43+ assert(d > 0, `boom must stay positive (hit=${hit} desired=${desired})`);44+ assert(45+ d < hit,46+ `camera crossed the wall: boom=${d} hit=${hit} desired=${desired}`,47+ );48+ assert(49+ d <= desired + 1e-9,50+ `boom overshot desired: boom=${d} desired=${desired}`,51+ );52+ }53+});54+55+Deno.test("resolveBoom: uninitialized state adopts the clamp directly", () => {56+ assertEquals(resolveBoom(7, Infinity, -1, 1 / 60), 7);57+ assertEquals(resolveBoom(7, 3, -1, 1 / 60), clampBoom(7, 3));58+});59+60+Deno.test("resolveBoom: snaps in, eases out", () => {61+ // Snap in: an intruding mass takes effect in ONE frame.62+ assertEquals(resolveBoom(7, 3, 7, 1 / 60), clampBoom(7, 3));63+ // Ease out: once clear, the boom releases gradually toward desired...64+ let boom = clampBoom(7, 3);65+ const first = resolveBoom(7, Infinity, boom, 1 / 60);66+ assert(first > boom && first < 7, `ease-out jumped: ${first}`);67+ // ...monotonically, converging within a few seconds.68+ for (let t = 0; t < 5 * 60; t++) {69+ const next = resolveBoom(7, Infinity, boom, 1 / 60);70+ assert(next >= boom - 1e-9, "release must be monotone");71+ boom = next;72+ }73+ assert(Math.abs(boom - 7) < 1e-3, `did not converge: ${boom}`);74+});75+76+Deno.test("resolveBoom: release never overshoots the safe clamp", () => {77+ // While easing out, a hit reappearing mid-release still bounds the78+ // boom by the clamp for THAT hit — the smoothed value may never sit79+ // beyond what clampBoom allows this frame.80+ let boom = 0.5;81+ for (let t = 0; t < 120; t++) {82+ const hit = 2 + Math.sin(t * 0.3); // wobbling occluder, 1..383+ boom = resolveBoom(7, hit, boom, 1 / 60);84+ assert(85+ boom <= clampBoom(7, hit) + 1e-9,86+ `boom ${boom} exceeds clamp ${clampBoom(7, hit)} at t=${t}`,87+ );88+ assert(boom < hit, `crossed the wall mid-release at t=${t}`);89+ }90+});
A tests/endless_golden_test.ts +60 -0
@@ -0,0 +1,60 @@
1+/**2+ * Endless stream goldens: per-segment hashes for fixed seeds.3+ *4+ * The stream is a pure function of the seed (culling is generation-driven,5+ * KEEP_SEGMENTS — never marble-driven), so segment k of seed X must be6+ * bit-stable across refactors. Canonicalization and hashing live in7+ * tools/pin_endless.ts — this test and the pin tool share one code path.8+ */9+import { assert, assertEquals } from "@std/assert";10+import {11+ computeEndlessGoldens,12+ GOLDEN_SEEDS,13+ GOLDEN_SEGMENTS,14+} from "../tools/pin_endless.ts";15+16+const REGEN_HINT = "If this reshuffle is INTENTIONAL, regenerate the pins:\n" +17+ " deno run --allow-read --allow-write tools/pin_endless.ts\n" +18+ "and commit tests/golden_endless.json. If it is NOT intentional, your " +19+ "change perturbed the endless stream (planner/WFC/island/rng order) — " +20+ "existing seeds in the wild would silently change.";21+22+Deno.test("endless golden: pinned per-segment hashes for fixed seeds", () => {23+ const goldenFile = new URL("./golden_endless.json", import.meta.url);24+ let expected: Record<string, string | string[]>;25+ try {26+ expected = JSON.parse(Deno.readTextFileSync(goldenFile));27+ } catch {28+ throw new Error(29+ "tests/golden_endless.json missing — generate it:\n" +30+ " deno run --allow-read --allow-write tools/pin_endless.ts",31+ );32+ }33+ const actual = computeEndlessGoldens();34+35+ // Compare seed-by-seed, segment-by-segment: the FIRST diverging segment36+ // is the diagnosis (everything after it inherits the reshuffle).37+ assertEquals(38+ actual.__version,39+ expected.__version,40+ `generator version changed (${expected.__version} → ${actual.__version}` +41+ `) — the finite pipeline shifted too.\n${REGEN_HINT}`,42+ );43+ for (const seed of GOLDEN_SEEDS) {44+ const want = expected[seed];45+ assert(46+ Array.isArray(want) && want.length === GOLDEN_SEGMENTS,47+ `golden file has no ${GOLDEN_SEGMENTS}-segment pin for seed ` +48+ `"${seed}" — stale file?\n${REGEN_HINT}`,49+ );50+ const got = actual[seed] as string[];51+ for (let i = 0; i < GOLDEN_SEGMENTS; i++) {52+ assertEquals(53+ got[i],54+ want[i],55+ `endless stream reshuffled: seed "${seed}" diverges at segment ` +56+ `${i} (pinned ${want[i]}, got ${got[i]}).\n${REGEN_HINT}`,57+ );58+ }59+ }60+});
A tests/frame_test.ts +80 -0
@@ -0,0 +1,80 @@
1+import { assert, assertAlmostEquals } from "@std/assert";2+import {3+ AFFINE_IDENTITY,4+ affineApply,5+ affineUnapply,6+ composeTwist,7+ frameUp,8+ isIdentityAffine,9+ quatFromAxisAngle,10+ quatMul,11+ quatRotate,12+ quatUnrotate,13+} from "../src/client/physics/frame.ts";14+15+const close = (16+ a: readonly number[],17+ b: readonly number[],18+ eps = 1e-9,19+): void => {20+ for (let i = 0; i < a.length; i++) assertAlmostEquals(a[i], b[i], eps);21+};22+23+Deno.test("quat rotate matches axis-angle expectations", () => {24+ // 90° about +x maps +y → +z (right-handed).25+ const q = quatFromAxisAngle([1, 0, 0], Math.PI / 2);26+ close(quatRotate(q, [0, 1, 0]), [0, 0, 1]);27+ // 180° about +z maps +x → −x.28+ const q2 = quatFromAxisAngle([0, 0, 1], Math.PI);29+ close(quatRotate(q2, [1, 0, 0]), [-1, 0, 0]);30+});31+32+Deno.test("unrotate inverts rotate; mul composes", () => {33+ const a = quatFromAxisAngle([0, 1, 0], 0.7);34+ const b = quatFromAxisAngle([1, 0, 0], -1.2);35+ const v: [number, number, number] = [0.3, -1.7, 2.2];36+ close(quatUnrotate(a, quatRotate(a, v)), v);37+ // (a·b) applied = a applied after b.38+ close(quatRotate(quatMul(a, b), v), quatRotate(a, quatRotate(b, v)));39+});40+41+Deno.test("affine apply/unapply round-trip", () => {42+ const f = composeTwist(AFFINE_IDENTITY, [1, 0, 0], Math.PI / 2, [0, 3, 5]);43+ const p: [number, number, number] = [4, 1, -2];44+ close(affineUnapply(f, affineApply(f, p)), p);45+});46+47+Deno.test("twist about the travel axis keeps the pivot line fixed", () => {48+ // Travel along +x at pivot (0, 2, 0): any point ON the axis line maps49+ // to itself; a point 1 below the line maps 1 to the side (90° roll).50+ const f = composeTwist(AFFINE_IDENTITY, [1, 0, 0], Math.PI / 2, [0, 2, 0]);51+ close(affineApply(f, [7, 2, 0]), [7, 2, 0]);52+ const side = affineApply(f, [7, 1, 0]); // 1 below the line53+ assertAlmostEquals(Math.hypot(side[1] - 2, side[2]), 1, 1e-9);54+ assertAlmostEquals(side[0], 7, 1e-9);55+ // Local up after a 90° roll about +x: +y → +z.56+ close(frameUp(f), [0, 0, 1]);57+});58+59+Deno.test("two 90° twists compose to a 180° frame", () => {60+ const f1 = composeTwist(AFFINE_IDENTITY, [1, 0, 0], Math.PI / 2, [0, 0, 0]);61+ const f2 = composeTwist(f1, [1, 0, 0], Math.PI / 2, [10, 0, 0]);62+ const up = frameUp(f2);63+ close(up, [0, -1, 0]);64+ // Ceiling world: plan-down (net-descent) now maps to world-up.65+ close(quatRotate(f2.q, [0, -1, 0]), [0, 1, 0]);66+});67+68+Deno.test("second twist axis follows the presented frame", () => {69+ // After a 90° roll about +x, the line still travels +x; a later twist70+ // about the CURRENT world travel axis composes cleanly and the pivot71+ // continuity holds at the second twist point.72+ const f1 = composeTwist(AFFINE_IDENTITY, [1, 0, 0], Math.PI / 2, [0, 0, 0]);73+ const pivotPlan: [number, number, number] = [6, 0, 0];74+ const pivotWorld = affineApply(f1, pivotPlan);75+ const f2 = composeTwist(f1, [1, 0, 0], -Math.PI / 2, pivotWorld);76+ close(affineApply(f2, pivotPlan), pivotWorld);77+ assert(isIdentityAffine(AFFINE_IDENTITY));78+ // Roll then unroll: back to identity orientation.79+ close(frameUp(f2), [0, 1, 0]);80+});
A tests/golden_endless.json +59 -0
M tools/build_physics.sh +1 -1
M tools/physics-debug/phys-contact.ts +1 -1
A tools/pin_endless.ts +142 -0
@@ -0,0 +1,142 @@
1+/**2+ * Endless stream goldens — pin per-segment hashes for fixed seeds.3+ *4+ * The endless stream is BY DESIGN a pure function of the seed (culling is5+ * generation-driven via KEEP_SEGMENTS, never marble-driven), so segment k of6+ * seed X is fully deterministic. These pins make that promise enforceable:7+ * a planner/WFC/island refactor that reshuffles the stream trips the golden8+ * test loudly instead of silently changing every endless run in the wild.9+ *10+ * Regenerate (only when a reshuffle is INTENTIONAL):11+ *12+ * deno run --allow-read --allow-write tools/pin_endless.ts13+ *14+ * tests/endless_golden_test.ts imports the canonicalization from here — the15+ * pins and the check are the same code path by construction.16+ */17+import type { Placement } from "../src/shared/types.ts";18+import { cyrb128 } from "../src/gen/rng.ts";19+import { genVersion } from "../src/gen/version.ts";20+import type { DecorCatalog } from "../src/gen/decor.ts";21+import { type EndlessSegment, EndlessWorld } from "../src/gen/endless/world.ts";22+23+/** Fixed probe seeds — same labels as the finite goldens ("one seed, one24+ * theme, both modes"), plus one endless-only. */25+export const GOLDEN_SEEDS = ["alpha", "zx9k2m4p1q", "dami", "marbles"];26+27+/** Pinned prefix per seed. Islands anchor every 14–38 cells; 12 segments28+ * cover ~3 island cycles per seed (verified at pin time: every seed must29+ * anchor at least 2 islands inside the pinned window). */30+export const GOLDEN_SEGMENTS = 12;31+32+/** The real committed manifest — the catalog the client actually streams33+ * with. A manifest edit that changes decor draws is a real reshuffle too;34+ * regenerate the pins knowingly. */35+export function goldenCatalog(): DecorCatalog {36+ return {37+ packs: [38+ {39+ slug: "nature-kit",40+ ids: JSON.parse(Deno.readTextFileSync(41+ new URL("../assets/nature-kit.manifest.json", import.meta.url),42+ )).pieces.map((p: { id: string }) => p.id),43+ },44+ ],45+ };46+}47+48+/** Integer-domain canonical form of one placement — float formatting must49+ * never affect the hash (positions ×4, scales/spins/boosts ×16, the50+ * genVersion() idiom). Every semantically meaningful field rides along:51+ * chapter/zone/bumper/goal are gameplay, not cosmetics. */52+function canonPlacement(p: Placement): string {53+ return [54+ p.pieceId,55+ p.pos.map((v) => Math.round(v * 4)).join(","),56+ p.rotation,57+ p.mirrored ? 1 : 0,58+ p.role,59+ p.scale !== undefined ? Math.round(p.scale * 16) : "",60+ p.spin61+ ? `${p.spin.axis.map((v) => Math.round(v * 16)).join(",")}~${62+ Math.round(p.spin.radPerSec * 16)63+ }`64+ : "",65+ p.boost !== undefined ? Math.round(p.boost * 16) : "",66+ p.chapter ?? "",67+ p.zone ?? "",68+ p.bumper ? 1 : 0,69+ p.goal ? 1 : 0,70+ ].join("|");71+}72+73+/** Canonical serialization of one endless segment: track placements (decor,74+ * furniture and zone pads included), islands (meta + their placements +75+ * mass cells), the window structure, and the plan's entry→exit contract. */76+export function canonSegment(seg: EndlessSegment): string {77+ const lines: string[] = [];78+ lines.push(`seg:${seg.index}|startS:${Math.round(seg.startS * 4)}`);79+ lines.push(`families:${seg.pieceFamilies.join(",")}`);80+ lines.push(`mods:${seg.windowMods.map((m) => m ?? ".").join(",")}`);81+ lines.push(82+ `windows:${seg.windowRanges.map((w) => `${w.start}+${w.len}`).join(",")}`,83+ );84+ const { entry, exit } = seg.plan;85+ lines.push(86+ `entry:${entry.x},${entry.z},${Math.round(entry.y * 4)},${entry.dir}` +87+ `|exit:${exit.x},${exit.z},${Math.round(exit.y * 4)},${exit.dir}`,88+ );89+ for (const p of seg.placements) lines.push(canonPlacement(p));90+ for (const isle of seg.islands) {91+ lines.push(92+ `island:${isle.id}|${isle.biome}|${Math.round(isle.topY * 4)}` +93+ `|cells:${isle.cells.join(";")}`,94+ );95+ for (const p of isle.placements) lines.push(canonPlacement(p));96+ }97+ return lines.join("\n");98+}99+100+/** 16-hex-char hash of one segment (cyrb128 — pure 32-bit integer ops,101+ * bit-identical across JS engines). */102+export function segmentHash(seg: EndlessSegment): string {103+ const [a, b] = cyrb128(canonSegment(seg));104+ return (a >>> 0).toString(16).padStart(8, "0") +105+ (b >>> 0).toString(16).padStart(8, "0");106+}107+108+/** Regenerate all pins — the test computes exactly this and compares. */109+export function computeEndlessGoldens(): Record<string, string | string[]> {110+ const catalog = goldenCatalog();111+ const goldens: Record<string, string | string[]> = {112+ __version: genVersion(),113+ };114+ for (const seed of GOLDEN_SEEDS) {115+ const world = new EndlessWorld(seed, catalog);116+ const hashes: string[] = [];117+ let islands = 0;118+ for (let i = 0; i < GOLDEN_SEGMENTS; i++) {119+ const seg = world.get(i);120+ islands += seg.islands.length;121+ hashes.push(segmentHash(seg));122+ }123+ if (islands < 2) {124+ throw new Error(125+ `seed ${seed}: only ${islands} island(s) in ${GOLDEN_SEGMENTS} ` +126+ `segments — pinned window too short to cover anchor cycles`,127+ );128+ }129+ goldens[seed] = hashes;130+ }131+ return goldens;132+}133+134+if (import.meta.main) {135+ const goldens = computeEndlessGoldens();136+ const file = new URL("../tests/golden_endless.json", import.meta.url);137+ await Deno.writeTextFile(file, JSON.stringify(goldens, null, 2) + "\n");138+ console.log(139+ `tests/golden_endless.json written — ${GOLDEN_SEEDS.length} seeds × ` +140+ `${GOLDEN_SEGMENTS} segments (version ${goldens.__version}). Commit it.`,141+ );142+}