clone

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

main
marbles / tests / reorient_test.ts
reorient_test.ts 3.7K
1/**2 * Gravity reorientation (M16) — generation invariants. The physics half3 * (a marble genuinely riding a wall through a twist) lives in4 * physics_test.ts; the survivability corpus also crosses live twists5 * (abs-1 composes −90° then +90°).6 */7import { assert, assertAlmostEquals, assertEquals } from "@std/assert";8import { EndlessWorld } from "../src/gen/endless/world.ts";9import {10 affineApply,11 frameUp,12 quatRotate,13 segmentFrameAt,14 twistParam,15} from "../src/shared/frame.ts";1617const close = (a: readonly number[], b: readonly number[], eps = 1e-9) => {18 for (let i = 0; i < a.length; i++) assertAlmostEquals(a[i], b[i], eps);19};2021Deno.test("reorient: pinned seeds twist early and frames stay continuous", () => {22 // grav-scan-20 rolls +90° in segment 0 (re-pinned after the M2123 // climate-bias reshuffle; jump-scan-25 and biome-3 held it before).24 const w = new EndlessWorld("grav-scan-20");25 const s0 = w.get(0);26 const m = s0.plan.mods.find((mm) => mm.kind === "reorient");27 assert(m, "grav-scan-20 seg 0 lost its reorient — re-scan");28 assertAlmostEquals(m.angle!, Math.PI / 2, 1e-12);29 assert(s0.frame?.twist, "reorient must stamp a twist frame");3031 // Frame continuity: every segment's entry is its predecessor's exit,32 // and the presented centerline is C0 across the twist (the pivot law).33 let prevExit = s0.frame!.exit;34 for (let i = 1; i <= 6; i++) {35 const s = w.get(i);36 const entry = s.frame?.entry ?? {37 q: [0, 0, 0, 1] as [number, number, number, number],38 t: [0, 0, 0] as [number, number, number],39 };40 close(entry.q, prevExit.q);41 close(entry.t, prevExit.t);42 prevExit = s.frame?.exit ?? entry;43 }4445 // Local up after a +90° roll is horizontal — the run downstream is a46 // WALL in world space.47 const up = frameUp(s0.frame!.exit);48 assertAlmostEquals(up[1], 0, 1e-9);49 assertAlmostEquals(Math.hypot(up[0], up[2]), 1, 1e-9);5051 // The twist window's kit placements were dropped (the corkscrew chute52 // renders the collision ribbon instead): no placement projects into53 // the run's interior span.54 const tw = s0.frame!.twist!;55 for (const p of s0.placements) {56 const t = twistParam(s0.frame!, p.pos);57 assert(58 t < 0.51 || t > tw.len - 0.51,59 `placement ${p.pieceId} inside the twist run (t=${t.toFixed(2)})`,60 );61 }62});6364Deno.test("reorient: 180° roll presents the descent as a world CLIMB", () => {65 // grav-scan-33 seg 0 carries a 180° roll: plan-down maps to world-up66 // past the twist — the ceiling-world signature (re-pinned after the67 // braid clause reshuffled the streams; wide-scan-2 before that).68 const w = new EndlessWorld("grav-scan-33");69 const s = w.get(0);70 const m = s.plan.mods.find((mm) =>71 mm.kind === "reorient" && Math.abs(Math.abs(mm.angle!) - Math.PI) < 1e-972 );73 assert(m, "grav-scan-33 seg 0 lost its 180° reorient — re-scan");74 assertAlmostEquals(Math.abs(m.angle!), Math.PI, 1e-12);75 const down = quatRotate(s.frame!.exit.q, [0, -1, 0]);76 close(down, [0, 1, 0], 1e-9);77});7879Deno.test("reorient: frame law is exact at the run's ends", () => {80 const w = new EndlessWorld("grav-scan-20");81 const f = w.get(0).frame!;82 const tw = f.twist!;83 // At the pivot the frames agree; past the run the exit frame owns it.84 close(85 affineApply(segmentFrameAt(f, 0), tw.pivot),86 affineApply(f.entry, tw.pivot),87 );88 const past: [number, number, number] = [89 tw.pivot[0] + tw.axis[0] * (tw.len + 3),90 tw.pivot[1] + tw.axis[1] * (tw.len + 3),91 tw.pivot[2] + tw.axis[2] * (tw.len + 3),92 ];93 close(94 affineApply(segmentFrameAt(f, twistParam(f, past)), past),95 affineApply(f.exit, past),96 );97 // Determinism: a second world reproduces the identical frame.98 const w2 = new EndlessWorld("grav-scan-20");99 assertEquals(w2.get(0).frame, f);100});