1/**2 * M10 physics survivability (headless): the real Box3D wasm module drives3 * a marble down real generated segments over the shared collision-proxy4 * law. Scenario-style gates (user rule — no time-based fuzz):5 *6 * - survivability: for fixed seeds, an assisted marble starting at the7 * line's origin must progress past segment 2's end without falling out8 * of the world (falls below the lowest live channel − margin).9 * - determinism: identical trajectory hash across two full runs.10 *11 * Requires static/dist/box3d.mjs (tools/build_physics.sh).12 */13import { assert, assertEquals } from "@std/assert";14import { EndlessWorld } from "../src/gen/endless/world.ts";15import {16 bumperProxies,17 islandProxies,18 segmentProxies,19 worldifyProxies,20} from "../src/gen/endless/proxies.ts";21import {22 type MarbleState,23 PhysicsEngine,24 type PhysicsModule,25} from "../src/client/physics/engine.ts";26import {27 attachFrames,28 concatRails,29 type Rail,30 railForSegment,31 railFrameAt,32 stationGravity,33 stationGravityDelta,34 stationRestitution,35} from "../src/client/physics/rail.ts";36import {37 affineApply,38 affineUnapply,39 quatRotate,40 quatUnrotate,41 twistParam,42} from "../src/shared/frame.ts";43import {44 autopilotForce,45 boostStep,46 FULL_TANK,47 type PilotDebug,48 pilotFor,49 pilotOffset,50 wantJump,51} from "../src/client/physics/autopilot.ts";52import type { DecorCatalog } from "../src/gen/decor.ts";53import { goldenCatalog } from "../tools/pin_endless.ts";54import { DIR_STEP } from "../src/gen/geom.ts";55import { TRACK_SCALE } from "../src/gen/endless/scale.ts";56import { FanField, fanVolumesOf } from "../src/client/fanField.ts";57import { segmentFrameAt } from "../src/shared/frame.ts";5859// The catalog the client streams with (all decor packs — M18a).60const catalog: DecorCatalog = goldenCatalog();6162const factory =63 (await import(new URL("../static/dist/box3d.mjs", import.meta.url).href))64 .default as () => Promise<PhysicsModule>;6566interface RunResult {67 reachedS: number;68 minY: number;69 fell: boolean;70 hash: bigint;71}7273function drive(74 seed: string,75 segments: number,76 simSeconds: number,77 stopAtS = Infinity,78 spawnAtS = 0,79 useAlt = false,80 onStep?: (pos: readonly [number, number, number]) => void,81): RunResult {82 return driveWith(83 seed,84 segments,85 simSeconds,86 stopAtS,87 spawnAtS,88 useAlt,89 onStep,90 );91}9293let module_: PhysicsModule | null = null;9495Deno.test("physics engine: repeated disposal releases and reinitializes the world", async () => {96 const module = await factory();97 for (let round = 0; round < 4; round++) {98 const engine = new PhysicsEngine(module);99 engine.addSegment(round, [100 {101 kind: "slab",102 center: [0, -0.1, 0],103 half: [2, 0.1, 2],104 yaw: 0,105 pitch: 0,106 },107 ]);108 engine.spawnMarble(0, [0, 0.4, 0], [1, 0, 0]);109 engine.statesAll(1);110 engine.hits();111 engine.step(1 / 30);112 const state = engine.state();113 assert(114 state.pos.every(Number.isFinite),115 `non-finite state in round ${round}`,116 );117 engine.dispose();118 engine.dispose(); // idempotent owner cleanup119 }120});121122function driveWith(123 seed: string,124 segments: number,125 simSeconds: number,126 stopAtS = Infinity,127 spawnAtS = 0,128 useAlt = false,129 /** Per-step trajectory observer (scenario assertions about WHERE the130 * marble actually went, e.g. the spur shaft transit). */131 onStep?: (pos: readonly [number, number, number]) => void,132 /** M19a: mirror the client's fan wind pass (pulsed gusts included).133 * Opt-in — the older survivability corpora were calibrated windless. */134 withWind = false,135): RunResult {136 const w = new EndlessWorld(seed, catalog);137 const engine = new PhysicsEngine(module_!);138 const rails: Rail[] = [];139 const stationSegments: number[] = [];140 const fanField = new FanField();141 let lowest = Infinity;142 for (let i = 0; i < segments; i++) {143 const seg = w.get(i);144 const segRail = railForSegment(seg, useAlt);145 // Gravity reorientation (M16): mirror the client — frames on the146 // rail, proxies presented in world space (the twist ribbon really147 // corkscrews; downstream statics really rotate).148 if (seg.frame) attachFrames(segRail, seg.frame);149 rails.push(segRail);150 stationSegments.push(...segRail.pts.map(() => i));151 let proxies = [152 // The shared channel builder (T7 parity — same call as the client153 // and the bench), including the braid alt lane when present.154 ...segmentProxies(seg),155 ...seg.islands.flatMap((isle) => islandProxies(isle.placements)),156 ...bumperProxies(seg.placements),157 ];158 if (seg.frame) proxies = worldifyProxies(proxies, seg.frame);159 engine.addSegment(i, proxies);160 if (withWind) {161 // Same volume derivation + presentation-frame transform as the162 // client build path (endless.ts).163 let volumes = fanVolumesOf(seg.placements);164 const fr = seg.frame;165 if (fr) {166 volumes = volumes.map((v) => {167 const vf = fr.twist168 ? segmentFrameAt(fr, twistParam(fr, v.origin))169 : fr.entry;170 return {171 ...v,172 origin: affineApply(vf, v.origin) as [number, number, number],173 dir: quatRotate(vf.q, v.dir) as [number, number, number],174 };175 });176 }177 fanField.addSegment(i, volumes);178 }179 for (const c of seg.plan.cells) lowest = Math.min(lowest, c.baseY * 2);180 }181 // ONE rail law with the client driver: rail.ts builds stations + mods.182 const rail = concatRails(rails);183 let spawnIdx = 0;184 while (spawnIdx < rail.s.length - 1 && rail.s[spawnIdx] < spawnAtS) {185 spawnIdx++;186 }187 const spawnPos: [number, number, number] = [...rail.pts[spawnIdx]];188 spawnPos[1] += 0.4;189 const t0 = rail.tans[spawnIdx];190 const spawnVel: [number, number, number] = [t0[0] * 2.2, 0, t0[2] * 2.2];191 const spawnFrame = railFrameAt(rail, spawnIdx);192 if (spawnFrame) {193 affineApply(spawnFrame, spawnPos, spawnPos);194 quatRotate(spawnFrame.q, spawnVel, spawnVel);195 }196 engine.spawnMarble(0, spawnPos, spawnVel);197198 const pilot = pilotFor(seed);199 let simT = 0;200 let boost = { ...FULL_TANK };201 const dbg: PilotDebug = {};202 let nearest = spawnIdx;203 let reachedS = rail.s[spawnIdx];204 let minY = Infinity;205 let fell = false;206 let hash = 1469598103934665603n;207 const FNV = 1099511628211n;208 const mix = (v: number) => {209 // Quantize to survive f64 printing differences; determinism is bitwise210 // anyway, this keeps the hash cheap.211 const q = BigInt(Math.round(v * 1e6)) & 0xffffffffffffn;212 hash = ((hash ^ q) * FNV) & 0xffffffffffffffffn;213 };214215 const totalSteps = Math.round(simSeconds * 60);216 let st: MarbleState = engine.state();217 for (218 let step = 0;219 step < totalSteps && !fell && reachedS < stopAtS;220 step++221 ) {222 engine.step(1 / 60, () => {223 // Nearest-station scan (presented world space), then the shared224 // per-step control law.225 const p = st.pos;226 const scanPts = rail.worldPts ?? rail.pts;227 let bestD = Infinity;228 const lo = Math.max(0, nearest - 30);229 const hi = Math.min(scanPts.length - 1, nearest + 60);230 for (let i = lo; i <= hi; i++) {231 const r = scanPts[i];232 const d = (r[0] - p[0]) ** 2 + (r[1] - p[1]) ** 2 +233 (r[2] - p[2]) ** 2;234 if (d < bestD) {235 bestD = d;236 nearest = i;237 }238 }239 engine.setTrackSegment(0, stationSegments[nearest]);240 engine.setRestitution(0, stationRestitution(rail, nearest));241 // Conjugate marble state through the station frame — the control242 // laws run in plan space (the client's decide() does the same).243 const frame = railFrameAt(rail, nearest);244 const cpos = frame ? affineUnapply(frame, st.pos) : st.pos;245 const cvel = frame ? quatUnrotate(frame.q, st.vel) : st.vel;246 simT += 1 / 60;247 pilot.offset = pilotOffset(pilot, simT);248 // The boost economy (competition round): a lone marble still bleeds249 // an overflowing tank on open track — surges are part of the law.250 const headroom = dbg.vSafe !== undefined && dbg.wishspeed !== undefined251 ? dbg.vSafe - dbg.wishspeed252 : null;253 boost = boostStep(boost, pilot, 1 / 60, headroom, null, null);254 let f = autopilotForce(255 rail,256 nearest,257 cpos,258 cvel,259 engine.mass,260 null,261 pilot,262 dbg,263 undefined,264 boost.boosting,265 );266 if (f && frame) f = quatRotate(frame.q, f) as [number, number, number];267 // Gravity zones as per-marble delta forces (M15 — world gravity is268 // global and fixed).269 const gd = stationGravityDelta(rail, nearest, engine.mass);270 // Fan wind (client parity, opt-in): pulsed volumes gate on simT.271 const wf: [number, number, number] = [0, 0, 0];272 if (withWind) {273 fanField.setTime(simT);274 fanField.forceAt(st.pos, engine.mass, wf);275 }276 const fx = (f?.[0] ?? 0) + (gd?.[0] ?? 0) + wf[0];277 const fy = (f?.[1] ?? 0) + (gd?.[1] ?? 0) + wf[1];278 const fz = (f?.[2] ?? 0) + (gd?.[2] ?? 0) + wf[2];279 if (fx !== 0 || fy !== 0 || fz !== 0) engine.force(0, fx, fy, fz);280 const j = wantJump(rail, nearest, cpos, cvel, null, pilot);281 if (j) engine.jump(0, frame ? quatRotate(frame.q, j[0]) : j[0], j[1]);282 });283 st = engine.state();284 onStep?.(st.pos);285 reachedS = Math.max(reachedS, rail.s[nearest]);286 minY = Math.min(minY, st.pos[1]);287 {288 const fr = railFrameAt(rail, nearest);289 const planY = fr ? affineUnapply(fr, st.pos)[1] : st.pos[1];290 if (planY < lowest - 8) fell = true;291 }292 if (step % 15 === 0) {293 mix(st.pos[0]);294 mix(st.pos[1]);295 mix(st.pos[2]);296 }297 }298 engine.dispose();299 return { reachedS, minY, fell, hash };300}301302Deno.test("recorded stalls: ramp/helix and both icy braid lanes clear without respawns", async () => {303 module_ = await factory();304 for (305 const [seed, segment, window, alt] of [306 ["rail-sample-008", 11, 4, false],307 ["rail-sample-015", 14, 0, false],308 ["rail-sample-015", 14, 0, true],309 ] as const310 ) {311 const world = new EndlessWorld(seed, catalog);312 const rails: Rail[] = [];313 for (let i = 0; i <= segment; i++) {314 rails.push(railForSegment(world.get(i), alt));315 }316 const target = rails[segment];317 const joined = concatRails(rails);318 const base = rails.slice(0, segment).reduce(319 (sum, r) => sum + r.pts.length,320 0,321 );322 const entry = target.sources!.findIndex((s) => s.window === window);323 assert(entry >= 0, "recorded approach must still exist");324 const spawn = joined.s[base + entry];325 const goal = joined.s[base + target.pts.length - 2];326 // Build the same three forward chunks as the stream, including the327 // older rotated geometry that used to block the ramp. No escape hop328 // or respawn is available in this driver: it must roll through.329 const result = driveWith(seed, segment + 4, 90, goal, spawn, alt);330 assert(331 !result.fell && result.reachedS >= goal,332 `${seed} ${alt ? "alt" : "main"}: reached ${result.reachedS.toFixed(1)}/${333 goal.toFixed(1)334 }, fell=${result.fell}`,335 );336 }337});338339Deno.test("track contact masks survive ghost toggles and respawns", async () => {340 const engine = new PhysicsEngine(await factory());341 try {342 engine.addSegment(10, [{343 kind: "box",344 center: [0, -0.1, 0],345 half: [5, 0.1, 2],346 }]);347 // An unrelated checkpoint crosses directly through this channel.348 engine.addSegment(8, [{349 kind: "box",350 center: [0, 0.5, 0],351 half: [0.1, 0.5, 2],352 }]);353 for (const ghost of [false, true, false]) {354 engine.spawnMarble(0, [-1, 0.2, 0], [3, 0, 0]);355 engine.setTrackSegment(0, 10);356 engine.setGhost(0, ghost);357 for (let i = 0; i < 45; i++) engine.step(1 / 60);358 const state = engine.state();359 assert(360 state.pos[0] > 0.2,361 "unrelated checkpoint must not block the marble",362 );363 assert(state.pos[1] > 0, "its own track must stay solid while ghosted");364 }365 // Adjacent checkpoint contact must remain solid at a stream seam.366 engine.addSegment(11, [{367 kind: "box",368 center: [0, 0.5, 0],369 half: [0.1, 0.5, 2],370 }]);371 engine.spawnMarble(0, [-1, 0.2, 0], [3, 0, 0]);372 engine.setTrackSegment(0, 10);373 for (let i = 0; i < 45; i++) engine.step(1 / 60);374 assert(375 engine.state().pos[0] < 0,376 "connected checkpoint geometry must still collide",377 );378 } finally {379 engine.dispose();380 }381});382383Deno.test("physics survivability: assisted marble clears early segments", async () => {384 module_ = await factory();385 const SEEDS = [386 "cua-seed-1",387 "abs-1",388 "fuzz-0",389 "det-seed",390 "drift-seed",391 "atmo-seed",392 "biome-3",393 ];394 const results: { seed: string; ok: boolean; r: RunResult; goal: number }[] =395 [];396 for (const seed of SEEDS) {397 const w = new EndlessWorld(seed, catalog);398 const goal = w.get(4).startS; // past the end of segment 3399 // Stop at the goal — a fast marble outruns the finite test world and400 // "falls" off the unbuilt horizon otherwise.401 const r = drive(seed, 6, 120, goal);402 results.push({ seed, ok: !r.fell && r.reachedS >= goal, r, goal });403 }404 const failures = results.filter((x) => !x.ok);405 assert(406 failures.length === 0,407 `marble did not survive:\n${408 failures.map((f) =>409 `${f.seed}: fell=${f.r.fell} reached=${f.r.reachedS.toFixed(1)}/${410 f.goal.toFixed(1)411 } minY=${f.r.minY.toFixed(1)}`412 ).join("\n")413 }`,414 );415});416417Deno.test("scenario gust bank (wind-scan-9): pulsed walls never stall the line", async () => {418 module_ = await factory();419 // wind-scan-9 grows a phase-locked gust bank on the island run in420 // segment 4 (pinned in scenario_test). Drive THROUGH it with the fan421 // wind pass on: the marble must clear the bank without stalling or422 // being blown off the channel, and the run must replay bit-identically423 // (the pulse clock is sim time, not wall time).424 const w = new EndlessWorld("wind-scan-9", catalog);425 const goal = w.get(5).startS; // past the bank segment426 const a = driveWith("wind-scan-9", 7, 300, goal, 0, false, undefined, true);427 assert(428 !a.fell && a.reachedS >= goal,429 `gust bank stalled the marble: fell=${a.fell} reached=${430 a.reachedS.toFixed(1)431 }/${goal.toFixed(1)}`,432 );433 const b = driveWith("wind-scan-9", 7, 300, goal, 0, false, undefined, true);434 assertEquals(a.hash, b.hash, "windy drive must replay bit-identically");435});436437Deno.test("scenario braid lanes (grav-scan-6): both lanes of a tagged braid drive clean", async () => {438 module_ = await factory();439 // grav-scan-6 seg 4 pins an icy/wanderer TAGGED braid (M19b scan). The440 // lane surfaces must never park a marble: drive the WHOLE early run on441 // the main line, then again riding every alt lane. An icy lane corners442 // slowly but the junction-lip exclusion keeps the mouths grippy — both443 // runs must clear the braid segment.444 const w = new EndlessWorld("grav-scan-6", catalog);445 const goal = w.get(5).startS; // past the braid segment446 for (const useAlt of [false, true]) {447 const r = driveWith("grav-scan-6", 7, 300, goal, 0, useAlt);448 assert(449 !r.fell && r.reachedS >= goal,450 `lane ${useAlt ? "alt" : "main"} stalled: fell=${r.fell} reached=${451 r.reachedS.toFixed(1)452 }/${goal.toFixed(1)}`,453 );454 }455});456457Deno.test("scenario open fork (grav-scan-32): both lanes cross the boundary clean", async () => {458 module_ = await factory();459 // grav-scan-32 opens a fork in segment 0 that resolves in segment 1460 // (V3 scan). The alt lane crosses a SEGMENT BOUNDARY mid-braid — the461 // concatenated alt rails must carry a rider across the seam and home462 // through the merge, and the main lane must be unbothered.463 const w = new EndlessWorld("grav-scan-32", catalog);464 assert(465 w.get(0).plan.braids.some((b) => b.open) &&466 w.get(1).plan.braids.some((b) => b.cont),467 "grav-scan-32 lost its open fork — re-scan",468 );469 const goal = w.get(3).startS; // well past the merge470 for (const useAlt of [false, true]) {471 const r = driveWith("grav-scan-32", 5, 300, goal, 0, useAlt);472 assert(473 !r.fell && r.reachedS >= goal,474 `open-fork lane ${useAlt ? "alt" : "main"} failed: fell=${r.fell} ` +475 `reached=${r.reachedS.toFixed(1)}/${goal.toFixed(1)}`,476 );477 }478});479480Deno.test("contact events: the shim reports real knocks, pull-based", async () => {481 module_ = await factory();482 const engine = new PhysicsEngine(module_!);483 engine.addSegment(0, [484 { kind: "slab", center: [0, -0.1, 0], half: [8, 0.1, 8], yaw: 0, pitch: 0 },485 ]);486 // Head-on pair well above the 1.5 u/s hit threshold.487 engine.spawnMarble(0, [-1, 0.15, 0], [3, 0, 0]);488 engine.spawnMarble(1, [1, 0.15, 0], [-3, 0, 0]);489 let pair = false;490 let pairSpeed = 0;491 let statik = false;492 for (let s = 0; s < 180; s++) {493 engine.step(1 / 60, () => {494 const h = engine.hits();495 for (let i = 0; i < h.count; i++) {496 const a = h.data[i * 6];497 const b = h.data[i * 6 + 1];498 const speed = h.data[i * 6 + 2];499 assert(speed > 0, "hit speed must be positive");500 if (a >= 0 && b >= 0) {501 pair = true;502 pairSpeed = Math.max(pairSpeed, speed);503 // The pair is exactly our two marbles.504 assert(505 (a === 0 && b === 1) || (a === 1 && b === 0),506 `unexpected pair ${a}/${b}`,507 );508 }509 if (a >= 0 !== b >= 0) statik = true;510 }511 });512 if (pair && statik) break;513 }514 if (!statik) {515 // A hard drop guarantees a static knock above threshold.516 engine.spawnMarble(2, [0, 2.5, 3], [0, 0, 0]);517 for (let s = 0; s < 120 && !statik; s++) {518 engine.step(1 / 60, () => {519 const h = engine.hits();520 for (let i = 0; i < h.count; i++) {521 if (h.data[i * 6] >= 0 !== h.data[i * 6 + 1] >= 0) statik = true;522 }523 });524 }525 engine.destroyMarble(2);526 }527 assert(pair, "head-on marble pair never reported a hit");528 assert(pairSpeed > 1.5, `pair hit under the threshold (${pairSpeed})`);529 assert(statik, "no marble-static knock reported");530 engine.destroyMarble(0);531 engine.destroyMarble(1);532 engine.removeSegment(0);533});534535Deno.test("scenario braid chain (grav-scan-32): 3-segment fork rides home on both lanes", async () => {536 module_ = await factory();537 // grav-scan-32: open fork @0, STAY-OPEN middle spanning all of @1,538 // merge continuation @2 (M23 braid networks). The alt lane crosses TWO539 // segment boundaries — the longest legal chain. Both lanes must drive540 // clean through the whole network.541 const w = new EndlessWorld("grav-scan-32", catalog);542 assert(543 w.get(0).plan.braids.some((b) => b.open && !b.cont) &&544 w.get(1).plan.braids.some((b) => b.open && b.cont) &&545 w.get(2).plan.braids.some((b) => b.cont && !b.open),546 "grav-scan-32 lost its 3-segment chain — re-scan",547 );548 const goal = w.get(3).startS + 6;549 for (const useAlt of [false, true]) {550 const r = driveWith("grav-scan-32", 6, 360, goal, 0, useAlt);551 assert(552 !r.fell && r.reachedS >= goal,553 `chain lane ${useAlt ? "alt" : "main"} failed: fell=${r.fell} ` +554 `reached=${r.reachedS.toFixed(1)}/${goal.toFixed(1)}`,555 );556 }557});558559Deno.test("scenario jump-gap: cold entry launches, flies, and is caught (both kinds)", async () => {560 module_ = await factory();561 // One pinned case per gap kind: helix-scan-6 early segments plan a562 // PROPELLER (2-cell gap at 0.35 gravity), grav-scan-25 a plain JUMP563 // (re-pinned after the braid clause reshuffled the streams; the564 // reorient and spur clauses moved them before that — no seed freeze,565 // so pins follow the stream.)566 // Locate the flight span on the shared rail, spawn the marble COLD567 // (base speed 2.2) just before the boost approach, and require it to568 // cross the gap and keep rolling. This is the envelope MINIMUM — the569 // boost approach alone must build launch speed. (The envelope maximum570 // is bounded by the assist brake at 9 u/s, which the catch pan + the571 // landing cells absorb by construction; hot arrivals are exercised by572 // the 7-seed survivability gate above.)573 for (574 const { seed, kind } of [575 { seed: "grav-scan-0", kind: "propeller" },576 { seed: "grav-scan-2", kind: "jump" },577 ]578 ) {579 const w = new EndlessWorld(seed, catalog);580 const rails: Rail[] = [];581 for (let i = 0; i < 3; i++) rails.push(railForSegment(w.get(i)));582 const rail = concatRails(rails);583 const first = rail.mods.findIndex((m) => m?.flight);584 assert(first >= 0, `${seed} early segments lost their ${kind}`);585 let last = first;586 while (rail.mods[last + 1]?.flight) last++;587 const flightStart = rail.s[first];588 const flightEnd = rail.s[last];589 const r = drive(590 seed,591 4,592 30,593 flightEnd + 10,594 Math.max(0, flightStart - 14),595 );596 assert(!r.fell, `${seed}: cold-entry marble fell at the ${kind}`);597 assert(598 r.reachedS >= flightEnd + 9,599 `${seed}: cold-entry marble stalled at s=${r.reachedS.toFixed(1)} ` +600 `(${kind} flight ${flightStart.toFixed(1)}–${flightEnd.toFixed(1)})`,601 );602 }603});604605Deno.test("scenario lip-undershoot rescue: the jump decision saves a slow arrival (M14b)", async () => {606 module_ = await factory();607 // Spawn COLD only ~3.5u before each gap's flight span — far too little608 // boost runway to reach the priced launch speed. The autopilot must609 // detect the undershoot AT THE LIP (wantJump ballistic test against the610 // landing point) and top-up vertical speed so the longer hang time611 // still crosses. This is the M14b contract: jumping is a DECISION from612 // the marble's actual state, not a pre-priced launch.613 for (614 const { seed, kind } of [615 { seed: "grav-scan-0", kind: "propeller" },616 { seed: "grav-scan-2", kind: "jump" },617 ]618 ) {619 const w = new EndlessWorld(seed, catalog);620 const rails: Rail[] = [];621 for (let i = 0; i < 3; i++) rails.push(railForSegment(w.get(i)));622 const rail = concatRails(rails);623 const first = rail.mods.findIndex((m) => m?.flight);624 assert(first >= 0, `${seed} early segments lost their ${kind}`);625 let last = first;626 while (rail.mods[last + 1]?.flight) last++;627 const flightStart = rail.s[first];628 const flightEnd = rail.s[last];629 const r = drive(630 seed,631 4,632 30,633 flightEnd + 8,634 Math.max(0, flightStart - 3.5),635 );636 assert(!r.fell, `${seed}: undershooting marble fell at the ${kind}`);637 assert(638 r.reachedS >= flightEnd + 7,639 `${seed}: undershooting marble stalled at s=${r.reachedS.toFixed(1)} ` +640 `(${kind} flight ${flightStart.toFixed(1)}–${flightEnd.toFixed(1)})`,641 );642 }643});644645Deno.test("scenario diamond-branch: both branches carry the marble through", async () => {646 module_ = await factory();647 // grav-scan-9 early segments host island diamonds (re-pinned after648 // the braid clause reshuffled the streams; the viaduct and gravity649 // clauses moved the pin before that). Drive through segment 2650 // twice — once on the main (left) branch rail, once on the alt (right)651 // — the marble must clear the split, branch, and merge on BOTH. This is652 // the branch-respawn contract: after a fall, the retry may ride the653 // other branch, so both must be survivable.654 const w = new EndlessWorld("grav-scan-9", catalog);655 const hasDiamond = [0, 1, 2].some((i) =>656 w.get(i).plan.reservations.some((r) => r.pieceIds[0]?.startsWith("diamond"))657 );658 assert(hasDiamond, "grav-scan-9 early segments lost their diamond");659 const goal = w.get(3).startS;660 for (const useAlt of [false, true]) {661 const r = drive("grav-scan-9", 5, 90, goal, 0, useAlt);662 assert(663 !r.fell && r.reachedS >= goal,664 `${useAlt ? "alt" : "main"} branch failed: fell=${r.fell} ` +665 `reached=${r.reachedS.toFixed(1)}/${goal.toFixed(1)}`,666 );667 }668});669670Deno.test("scenario braid: both lanes carry the marble through fork and merge", async () => {671 module_ = await factory();672 // grav-scan-17 hosts braids in segments 0 AND 1 (breadth-2 wanders,673 // nothing else alt-flavored in range — the useAlt drive rides exactly674 // the braid alt chains). Main lane: straight through both junction675 // composites. Alt lane: out the fork mouth, along the wandering side676 // lane, back in through the merge mouth — twice. Both lanes must clear677 // both braids and keep rolling; this is the fork/merge-proxy physical678 // acceptance (M17).679 const w = new EndlessWorld("grav-scan-17", catalog);680 const nBraids = [0, 1].reduce(681 (acc, i) => acc + w.get(i).plan.braids.length,682 0,683 );684 assert(nBraids >= 2, "grav-scan-17 lost its early braids — re-scan");685 const goal = w.get(2).startS;686 for (const useAlt of [false, true]) {687 const r = drive("grav-scan-17", 4, 120, goal, 0, useAlt);688 assert(689 !r.fell && r.reachedS >= goal,690 `${useAlt ? "alt" : "main"} lane failed: fell=${r.fell} ` +691 `reached=${r.reachedS.toFixed(1)}/${goal.toFixed(1)}`,692 );693 }694});695696Deno.test("physics determinism: identical trajectory hash across runs", async () => {697 module_ = await factory();698 const a = drive("dia-2", 4, 45);699 module_ = await factory(); // fresh module — same-build reproducibility700 const b = drive("dia-2", 4, 45);701 assertEquals(a.hash, b.hash);702 assertEquals(a.fell, b.fell);703});704705Deno.test("scenario helix-dive: marble rides the full spiral and the underpass", async () => {706 module_ = await factory();707 // jump-scan-14 segment 0 hosts a full-helix ensemble (re-pinned after708 // the braid clause reshuffled the streams; the reorient clause moved709 // it before that). The marble must enter the approach corner,710 // ride the 360° spiral down 2.0 (helix family mod: banked, ridden711 // fast), come out through its own underpass, and keep rolling.712 const w = new EndlessWorld("jump-scan-14", catalog);713 const seg = w.get(0);714 const r0 = seg.plan.reservations.find((r) =>715 /^helix-(large-)?(left|right)$/.test(r.pieceIds[0])716 );717 assert(r0 !== undefined, "jump-scan-14 seg 0 lost its full helix");718 const wi = seg.windowRanges.findIndex((wr) => wr.start === r0.start);719 let endS = 0;720 for (let k = 0; k <= wi; k++) endS += seg.path.segments[k].length;721 const r = drive("jump-scan-14", 3, 60, endS + 8);722 assert(!r.fell, "marble fell during the helix dive");723 assert(724 r.reachedS >= endS + 7,725 `marble stalled at s=${r.reachedS.toFixed(1)} (helix ends ${726 endS.toFixed(1)727 })`,728 );729});730731Deno.test("scenario wide-run: marble rides funnel → wide channel → funnel", async () => {732 module_ = await factory();733 // wide-scan-7 segment 0 hosts an island wide run (funnel-bracketed by734 // construction). The proxies widen the ribbon over those windows735 // (wideScalesOf) — the marble must enter the funnel, cross the wide736 // deck, and be gathered back to the narrow channel.737 const w = new EndlessWorld("wide-scan-7", catalog);738 const seg = w.get(0);739 const wi = seg.pieceFamilies.indexOf("wide");740 assert(wi >= 0, "wide-scan-7 seg 0 lost its wide run");741 let endS = 0;742 for (let k = 0; k <= wi + 1; k++) endS += seg.path.segments[k].length;743 const r = drive("wide-scan-7", 3, 60, endS + 8);744 assert(!r.fell, "marble fell in the wide run");745 assert(746 r.reachedS >= endS + 7,747 `marble stalled at s=${r.reachedS.toFixed(1)} (wide run ends ${748 endS.toFixed(1)749 })`,750 );751});752753Deno.test("scenario icy-run: marble slides the low-grip patch and carries on", async () => {754 module_ = await factory();755 // wide-scan-17 early segments plan an ICY zone (re-pinned after the756 // spur clause; assist authority drops to 0.3 there — the marble must757 // coast across on carried momentum and regain the line after).758 const w = new EndlessWorld("wide-scan-17", catalog);759 let icyAt: { seg: number; start: number; len: number } | null = null;760 for (let i = 0; i < 3 && !icyAt; i++) {761 const m = w.get(i).plan.mods.find((m) => m.kind === "icy");762 if (m) icyAt = { seg: i, start: m.start, len: m.len };763 }764 assert(icyAt !== null, "wide-scan-17 early segments lost their icy zone");765 const rails: Rail[] = [];766 for (let i = 0; i < 4; i++) rails.push(railForSegment(w.get(i)));767 const rail = concatRails(rails);768 const first = rail.mods.findIndex((m) => m?.icy);769 assert(first >= 0, "icy zone missing from the rail mods");770 let last = first;771 while (rail.mods[last + 1]?.icy) last++;772 const r = drive(773 "wide-scan-17",774 4,775 40,776 rail.s[last] + 10,777 Math.max(0, rail.s[first] - 12),778 );779 assert(!r.fell, "marble fell in the icy run");780 assert(781 r.reachedS >= rail.s[last] + 9,782 `marble stalled at s=${r.reachedS.toFixed(1)} (ice ends ${783 rail.s[last].toFixed(1)784 })`,785 );786});787788Deno.test("scenario bouncer-pins: marble caroms the wide-deck pins and carries on", async () => {789 module_ = await factory();790 // wide-scan-10 segment 0 hosts a wide island run carrying THREE mushroom791 // bumpers (the densest early pin field the scan found). The pins are792 // high-restitution cylinders standing in the wide channel; the marble793 // must thread or carom past them — outcome stochastic, recapture794 // guaranteed by the wide walls — and exit the run still rolling.795 const w = new EndlessWorld("wide-scan-10", catalog);796 const seg = w.get(0);797 const pins = seg.placements.filter((p) => p.bumper);798 assert(pins.length >= 2, "wide-scan-10 seg 0 lost its pin field");799 const wi = seg.pieceFamilies.indexOf("wide");800 assert(wi >= 0, "wide-scan-10 seg 0 lost its wide run");801 let endS = 0;802 for (let k = 0; k <= wi + 1; k++) endS += seg.path.segments[k].length;803 const r = drive("wide-scan-10", 3, 60, endS + 8);804 assert(!r.fell, "marble fell in the pin field");805 assert(806 r.reachedS >= endS + 7,807 `marble stalled at s=${r.reachedS.toFixed(1)} (pin field ends ${808 endS.toFixed(1)809 })`,810 );811});812813Deno.test("scenario gravity-run: tilted gravity surges the marble on the flat", async () => {814 module_ = await factory();815 // wide-scan-21 segment 0 plans a GRAVITY zone: world gravity tilts816 // forward along the run (shim gravityDir), so a geometrically flat817 // straight accelerates the marble — the assist stays silent between818 // cruise and the envelope cap, gravity itself does the work. The zone819 // stops 2 cells short of its clause (brake runway) and the marble must820 // roll on past it.821 const w = new EndlessWorld("wide-scan-21", catalog);822 let gravAt: number | null = null;823 for (let i = 0; i < 3 && gravAt === null; i++) {824 if (w.get(i).plan.mods.some((m) => m.kind === "gravity")) gravAt = i;825 }826 assert(827 gravAt !== null,828 "wide-scan-21 early segments lost their gravity zone",829 );830 const rails: Rail[] = [];831 for (let i = 0; i < 4; i++) rails.push(railForSegment(w.get(i)));832 const rail = concatRails(rails);833 const first = rail.mods.findIndex((m) => m?.tilt);834 assert(first >= 0, "gravity zone missing from the rail mods");835 let last = first;836 while (rail.mods[last + 1]?.tilt) last++;837 // The station law tilts gravity forward along the tangent, magnitude838 // preserved (|g| = 20) — and returns plain -Y everywhere else.839 const g = stationGravity(rail, first);840 const mag = Math.hypot(g[0], g[1], g[2]);841 assert(Math.abs(mag - 20) < 1e-6, `tilted |g| = ${mag}`);842 const t = rail.tans[first];843 const fwd = g[0] * t[0] + g[2] * t[2];844 assert(fwd > 4, `forward gravity pull ${fwd.toFixed(2)} too weak`);845 const plain = stationGravity(rail, 0);846 assertEquals(plain, [0, -20, 0]);847 const r = drive(848 "wide-scan-21",849 4,850 40,851 rail.s[last] + 10,852 Math.max(0, rail.s[first] - 12),853 );854 assert(!r.fell, "marble fell in the gravity run");855 assert(856 r.reachedS >= rail.s[last] + 9,857 `marble stalled at s=${r.reachedS.toFixed(1)} (gravity zone ends ${858 rail.s[last].toFixed(1)859 })`,860 );861});862863Deno.test("scenario reorient: marble corkscrews the twist and rides the wall", async () => {864 module_ = await factory();865 // grav-scan-20 segment 0 rolls +90° about the travel axis (re-pinned866 // after the braid clause reshuffled the streams; biome-3 held the pin867 // before that). The marble must enter the twist chute,868 // corkscrew through it, and keep making progress DOWNSTREAM of the869 // run — where the channel is a WALL in world space and only the870 // conjugated control law + rotated gravity delta keep it in the pipe.871 const w = new EndlessWorld("grav-scan-20", catalog);872 const seg = w.get(0);873 const f = seg.frame;874 assert(f?.twist, "grav-scan-20 seg 0 lost its reorient — re-scan");875 const tw = f.twist!;876 // Drive well past the twist (its axial end, plus a margin).877 let twistEndS = 0;878 for (let k = 0; k <= tw.to; k++) twistEndS += seg.path.segments[k].length;879 const goal = twistEndS + 10;880 let wallProof = false;881 const r = drive("grav-scan-20", 4, 120, goal, 0, false, (pos) => {882 // Wall-riding evidence: past the run, the marble's PLAN height sits883 // near the floor while its world position is genuinely rotated away884 // from the plan point (the twist really presented).885 const t = twistParam(f, affineUnapply(f.exit, pos));886 if (t > tw.len + 2) {887 const plan = affineUnapply(f.exit, pos);888 const dx = pos[0] - plan[0];889 const dy = pos[1] - plan[1];890 const dz = pos[2] - plan[2];891 if (Math.hypot(dx, dy, dz) > 0.5) wallProof = true;892 }893 });894 assert(!r.fell, "marble fell in/after the twist");895 assert(896 r.reachedS >= goal - 1,897 `marble stalled at s=${r.reachedS.toFixed(1)} (goal ${goal.toFixed(1)})`,898 );899 assert(900 wallProof,901 "marble was never observed on presented (rotated) geometry past the twist",902 );903});904905Deno.test("scenario spur-drop: the branch rider falls through onto its own line", async () => {906 module_ = await factory();907 // grav-scan-22 segment 1 hosts a takeable spur (pinned by spur_test).908 // Ride the ALT rails: the marble takes the branch at the split, rolls909 // out over the end-hole board, and must fall THROUGH the hole — proven910 // by a mid-shaft transit inside the branch cell's column, a place the911 // MAIN hook never visits at that height — landing on the receiver912 // passing directly below (Δ 1.0 track), then keep progressing. The913 // respawn net (fell) must never fire.914 const w = new EndlessWorld("grav-scan-22", catalog);915 const seg = w.get(1);916 const spur = seg.plan.spurs[0];917 assert(spur !== undefined, "grav-scan-22 seg 1 lost its spur");918 const host = seg.plan.cells[spur.host];919 const [wx, wz] = DIR_STEP[spur.dir];920 const holeX = (host.x + wx) * TRACK_SCALE;921 const holeZ = (host.z + wz) * TRACK_SCALE;922 const deckFloor = (host.baseY + 0.25) * TRACK_SCALE;923 const recFloor = deckFloor - TRACK_SCALE;924 let shaftTransit = false;925 const goal = w.get(2).startS;926 const r = drive("grav-scan-22", 4, 120, goal, 0, true, (pos) => {927 if (928 Math.hypot(pos[0] - holeX, pos[2] - holeZ) < 0.7 &&929 pos[1] > recFloor + 0.5 && pos[1] < recFloor + 1.6930 ) shaftTransit = true;931 });932 assert(!r.fell, "branch rider fell out of the world at the spur");933 assert(934 shaftTransit,935 "marble never transited the drop shaft — it did not take the branch",936 );937 assert(938 r.reachedS >= goal,939 `marble stalled at s=${r.reachedS.toFixed(1)} (goal ${goal.toFixed(1)})`,940 );941});