clone

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

M10: collision proxy law + headless physics survivability (all seeds pass)

Dami committed b21700e
src/gen/endless/proxies.ts - ONE generic law, no per-piece authoring:
curvature-aware slab sampling of segment centerlines (floors follow slope
pitch; walls are vertical columns topping at floor+0.55 - pitched walls
scissored wedge pockets, taller walls speared stacked decks), drop-stack
shafts as single cups per vertical run (mouth at the cell edge, tight
3-wall guide tube open toward the receiver, full-width bottom pan).
VERTICAL_LIMIT=0.93: only true plunges cup; steep chutes keep floors.
Bumps excluded from endless WFC until M11 prices momentum vocabulary.

engine.ts: shared assist law (cruise force, climb carry canceling
along-slope gravity, brake, hands-off on near-vertical rails) used by
client and tests alike. Shim: enableSleep off, real mass exported
(Box3D defaults density 1000 - the marble weighs 33.5kg).

tests/physics_test.ts: real Box3D wasm under Deno drives assisted marbles
over 5 seeds x 5 segments - all complete without falling out; trajectory
hash identical across runs.
commit
b21700e2509a49e989f2201bdf32087992c0b584
parent
6c61d7b8157dc24fa928e9a60704900981862f97
author
Dami <dami.etoile@gmail.com>
7 files changed, +668 -1
Asrc/client/physics/engine.ts194++++++++++++++++++++++
Msrc/client/physics/shim.c6+
Asrc/gen/endless/proxies.ts266++++++++++++++++++++++++++++++
Msrc/gen/endless/world.ts4+
Msrc/gen/wfc.ts7+
Atests/physics_test.ts190+++++++++++++++++++++
Mtools/build_physics.sh2+-
A src/client/physics/engine.ts +194 -0
@@ -0,0 +1,194 @@
1+/**2+ * Typed wrapper over the Box3D wasm shim (see shim.c / build_physics.sh).3+ * Pure interface over an already-instantiated emscripten module so the4+ * browser client and the headless Deno survivability tests share it.5+ */6+import type { CollisionProxy } from "../../gen/endless/proxies.ts";7+8+/** The emscripten module surface the shim exports. */9+export interface PhysicsModule {10+ _ph_init(gravityY: number): void;11+ _ph_begin_static(key: number): void;12+ _ph_add_box(13+ cx: number,14+ cy: number,15+ cz: number,16+ hx: number,17+ hy: number,18+ hz: number,19+ yawQuarter: number,20+ ): void;21+ _ph_add_slab(22+ cx: number,23+ cy: number,24+ cz: number,25+ hx: number,26+ hy: number,27+ hz: number,28+ yawRad: number,29+ pitchRad: number,30+ ): void;31+ _ph_end_static(): void;32+ _ph_remove_static(key: number): void;33+ _ph_spawn_marble(34+ x: number,35+ y: number,36+ z: number,37+ vx: number,38+ vy: number,39+ vz: number,40+ radius: number,41+ ): void;42+ _ph_marble_force(fx: number, fy: number, fz: number): void;43+ _ph_marble_mass(): number;44+ _ph_step(dt: number, substeps: number): void;45+ _ph_marble_state(outPtr: number): void;46+ _malloc(bytes: number): number;47+ HEAPF64: Float64Array;48+}49+50+export interface MarbleState {51+ pos: [number, number, number];52+ vel: [number, number, number];53+ quat: [number, number, number, number];54+}55+56+export const MARBLE_RADIUS = 0.2;57+export const GRAVITY_Y = -10;58+/** Fixed physics clock: 60 Hz × 4 substeps (the spike's validated rate). */59+export const FIXED_DT = 1 / 60;60+export const SUBSTEPS = 4;61+62+/**63+ * The assist law, shared by the client driver and the survivability tests64+ * (ONE law, one truth): keep the marble rolling at cruising speed on65+ * flats, carry it up climbs (the track lifts, like classic marble-run66+ * conveyors — gravity-along-slope is cancelled plus a net push), and67+ * brake gently when gravity has piled on too much speed.68+ * Returns a world-space force or null.69+ */70+export function assistForce(71+ vel: readonly [number, number, number],72+ tangent: readonly [number, number, number],73+ mass: number,74+): [number, number, number] | null {75+ // Near-vertical rail (drop shafts): the fall is gravity's job — an76+ // assist here just grinds the marble into the cup.77+ if (Math.abs(tangent[1]) > 0.6) return null;78+ const vAlong = vel[0] * tangent[0] + vel[1] * tangent[1] +79+ vel[2] * tangent[2];80+ const TARGET = 2.6;81+ const MAX = 7.5;82+ if (vAlong > MAX) {83+ const f = mass * 1.8;84+ return [-tangent[0] * f, -tangent[1] * f, -tangent[2] * f];85+ }86+ const climbing = tangent[1] > 0.12;87+ if (climbing && vAlong < TARGET) {88+ // Cancel the along-slope gravity component and push a little extra.89+ const a = -GRAVITY_Y * tangent[1] + 1.6;90+ const f = mass * a;91+ return [tangent[0] * f, tangent[1] * f, tangent[2] * f];92+ }93+ if (vAlong < TARGET) {94+ const f = mass * 3.2;95+ return [tangent[0] * f, tangent[1] * f, tangent[2] * f];96+ }97+ return null;98+}99+100+export class PhysicsEngine {101+ private readonly out: number;102+ private accumulator = 0;103+104+ constructor(private readonly m: PhysicsModule) {105+ m._ph_init(GRAVITY_Y);106+ this.out = m._malloc(10 * 8);107+ }108+109+ addSegment(key: number, proxies: readonly CollisionProxy[]): void {110+ this.m._ph_begin_static(key);111+ for (const p of proxies) {112+ if (p.kind === "box") {113+ this.m._ph_add_box(114+ p.center[0],115+ p.center[1],116+ p.center[2],117+ p.half[0],118+ p.half[1],119+ p.half[2],120+ 0,121+ );122+ } else {123+ this.m._ph_add_slab(124+ p.center[0],125+ p.center[1],126+ p.center[2],127+ p.half[0],128+ p.half[1],129+ p.half[2],130+ p.yaw,131+ p.pitch,132+ );133+ }134+ }135+ this.m._ph_end_static();136+ }137+138+ removeSegment(key: number): void {139+ this.m._ph_remove_static(key);140+ }141+142+ /** Marble mass (kg — Box3D defaults to SI density 1000). */143+ get mass(): number {144+ return this.m._ph_marble_mass();145+ }146+147+ spawnMarble(148+ pos: readonly [number, number, number],149+ vel: readonly [number, number, number],150+ ): void {151+ this.accumulator = 0;152+ this.m._ph_spawn_marble(153+ pos[0],154+ pos[1],155+ pos[2],156+ vel[0],157+ vel[1],158+ vel[2],159+ MARBLE_RADIUS,160+ );161+ }162+163+ /**164+ * Advance by wall-clock dt on the fixed clock; `eachStep` runs before165+ * every fixed step (assist forces are per-step so behavior is166+ * frame-rate independent). Returns the number of fixed steps taken.167+ */168+ step(dt: number, eachStep?: () => void): number {169+ this.accumulator = Math.min(this.accumulator + dt, 0.25);170+ let steps = 0;171+ while (this.accumulator >= FIXED_DT) {172+ eachStep?.();173+ this.m._ph_step(FIXED_DT, SUBSTEPS);174+ this.accumulator -= FIXED_DT;175+ steps++;176+ }177+ return steps;178+ }179+180+ force(fx: number, fy: number, fz: number): void {181+ this.m._ph_marble_force(fx, fy, fz);182+ }183+184+ state(): MarbleState {185+ this.m._ph_marble_state(this.out);186+ const h = this.m.HEAPF64;187+ const i = this.out / 8;188+ return {189+ pos: [h[i], h[i + 1], h[i + 2]],190+ vel: [h[i + 3], h[i + 4], h[i + 5]],191+ quat: [h[i + 6], h[i + 7], h[i + 8], h[i + 9]],192+ };193+ }194+}
M src/client/physics/shim.c +6 -0
A src/gen/endless/proxies.ts +266 -0
@@ -0,0 +1,266 @@
1+/**2+ * Collision proxies (M10) — generic, no per-piece authoring.3+ *4+ * The channel's physical shape is the same law everywhere (floor at the5+ * centerline, walls at ±0.35 track units rising 0.5 above the floor), so6+ * proxies are SAMPLED from each segment's world-space centerline: short7+ * overlapping slabs (floor + two walls) oriented by the local tangent.8+ * Curved pieces just get more slabs. Near-vertical samples (drop-stack9+ * shafts — the centerline dives through the hole) emit NOTHING: the marble10+ * genuinely falls and the receiving channel below catches it. Island11+ * masses are axis-aligned block boxes on the world grid.12+ *13+ * Shared by the client driver and the headless survivability tests —14+ * one sampling law, one truth.15+ */16+import type { LoopPath, Placement } from "../../shared/types.ts";17+import { segLength, segPoint } from "../geom.ts";18+import { TRACK_SCALE } from "./scale.ts";19+20+/** Oriented slab: yaw about +y then pitch about local +x (shim contract). */21+export interface SlabProxy {22+ kind: "slab";23+ center: readonly [number, number, number];24+ half: readonly [number, number, number];25+ yaw: number;26+ pitch: number;27+}28+29+/** Axis-aligned box (island blocks), yaw in quarter turns. */30+export interface BoxProxy {31+ kind: "box";32+ center: readonly [number, number, number];33+ half: readonly [number, number, number];34+}35+36+export type CollisionProxy = SlabProxy | BoxProxy;37+38+/** Sampling step along the centerline, world units. */39+const STEP = 0.6;40+/** Slab half-length: overlaps the next sample so seams can't catch. */41+const HALF_LEN = STEP * 0.75;42+/** Channel geometry at slalom scale (track walls ±0.35, height +0.5). */43+const FLOOR_HALF_W = 0.38 * TRACK_SCALE;44+const FLOOR_HALF_T = 0.15;45+const WALL_HALF_W = 0.09 * TRACK_SCALE;46+/** Walls span [floor − 0.25, floor + 0.55] world: real kit walls top out at47+ * floor + 0.5; the slight over-height forgives curve seams, and the cap48+ * matters — taller walls spear through the floor of a channel stacked49+ * 1.0 above (drop-stack receivers barricaded their own upper deck). */50+const WALL_HALF_H = 0.40;51+const WALL_CENTER_LIFT = 0.15;52+/** Samples steeper than this are drop shafts (cup + tube, no floor).53+ * Only genuine plunges qualify — drop-stack dives are ty ≈ 1.0; steep54+ * chutes reach ty ≈ 0.8 mid-cubic and MUST keep their floors (a 0.7755+ * chute got cup-walled and threw the marble into the void). */56+const VERTICAL_LIMIT = 0.93;57+58+/** Floor slabs sink slightly so tilt ridges at sample seams stay below the59+ * contact surface (poke ≈ halfLen·δ − SINK). */60+const SINK = 0.035;61+/** Max tangent turn per slab, radians — bounds seam ridges on curves. */62+const MAX_TURN = 0.12;63+64+/** One sampled station along the path. */65+interface Station {66+ p: readonly [number, number, number];67+ yaw: number;68+ pitch: number;69+ halfLen: number;70+ /** Vertical drop sample (no floor; cup + guide tube instead). */71+ vertical: boolean;72+ dy: number;73+}74+75+/** Channel slabs for one segment's world-space path. Two passes: sample76+ * every part into stations, then emit — drop shafts need LOOKAHEAD (the77+ * guide tube opens toward the receiver's exit heading). */78+export function channelProxies(path: LoopPath): SlabProxy[] {79+ const stations: Station[] = [];80+ for (const seg of path.segments) {81+ for (const part of seg.curve) {82+ const len = segLength(part);83+ // Curvature-aware sampling: arcs bound the per-slab turn angle;84+ // cubics (jogs) sample finely; lines stay coarse.85+ const steps = part.kind === "arc"86+ ? Math.max(87+ Math.ceil(len / STEP),88+ Math.ceil(Math.abs(part.a1 - part.a0) / MAX_TURN),89+ )90+ : part.kind === "cubic"91+ ? Math.max(1, Math.ceil(len / 0.3))92+ : Math.max(1, Math.ceil(len / STEP));93+ const stepLen = len / steps;94+ const halfLen = Math.max(0.12, stepLen * 0.72);95+ for (let i = 0; i < steps; i++) {96+ const p = segPoint(part, (i + 0.5) / steps);97+ const a = segPoint(part, i / steps);98+ const b = segPoint(part, Math.min(1, (i + 1) / steps));99+ const dx = b[0] - a[0];100+ const dy = b[1] - a[1];101+ const dz = b[2] - a[2];102+ const horiz = Math.hypot(dx, dz);103+ const total = Math.hypot(dx, dy, dz);104+ if (total < 1e-9) continue;105+ const vertical = Math.abs(dy / total) > VERTICAL_LIMIT;106+ stations.push({107+ p: [p[0], p[1], p[2]],108+ yaw: vertical ? 0 : Math.atan2(dx, dz),109+ pitch: vertical ? 0 : -Math.atan2(dy, horiz),110+ halfLen,111+ vertical,112+ dy,113+ });114+ }115+ }116+ }117+ // Vertical stations inherit the approach yaw; their exit heading is the118+ // next non-vertical station's yaw (the receiver's port).119+ for (let i = 0; i < stations.length; i++) {120+ if (!stations[i].vertical) continue;121+ let j = i - 1;122+ while (j >= 0 && stations[j].vertical) j--;123+ stations[i] = { ...stations[i], yaw: j >= 0 ? stations[j].yaw : 0 };124+ }125+126+ const out: SlabProxy[] = [];127+ for (let i = 0; i < stations.length; i++) {128+ const st = stations[i];129+ const [px, py, pz] = st.p;130+ if (st.vertical) {131+ // One CUP PER CONTIGUOUS VERTICAL RUN (per-station emission stacked132+ // overlapping cups that wedged the marble — found the hard way).133+ // Frame: approach yaw; open side: receiver exit yaw.134+ let k = i;135+ while (k < stations.length && stations[k].vertical) k++;136+ const group = stations.slice(i, k);137+ const topY = Math.max(...group.map((g) => g.p[1] + Math.abs(g.dy) / 2));138+ const botY = Math.min(...group.map((g) => g.p[1] - Math.abs(g.dy) / 2));139+ const exitYaw = k < stations.length ? stations[k].yaw : st.yaw;140+ const exitX = Math.sin(exitYaw);141+ const exitZ = Math.cos(exitYaw);142+ const lx = Math.cos(st.yaw);143+ const lz = -Math.sin(st.yaw);144+ const fwdX = Math.sin(st.yaw);145+ const fwdZ = Math.cos(st.yaw);146+ // Cup end wall ABOVE the upper floor at the far side of the mouth —147+ // at the cell edge (1.0 from the shaft), leaving a full marble-width148+ // mouth (a closer wall + floor overhang wedged the marble in the149+ // mouth; the contact math nailed it to the millimeter).150+ out.push({151+ kind: "slab",152+ center: [px + fwdX * 1.0, topY + 0.45, pz + fwdZ * 1.0],153+ half: [0.55, 0.5, 0.1],154+ yaw: st.yaw,155+ pitch: 0,156+ });157+ // Guide tube: TIGHT (interior ~±0.5 — a wide tube let the marble158+ // miss the receiver floor through a corner pocket), three walls159+ // from just under the mouth to just above the receiver floor; the160+ // receiver-exit side stays open.161+ const tubeTop = topY - 0.1;162+ const tubeBot = botY + 0.1;163+ const tubeHalf = Math.max(0.15, (tubeTop - tubeBot) / 2);164+ const tubeCy = (tubeTop + tubeBot) / 2;165+ for (166+ const [ox, oz, hx, hz] of [167+ [fwdX * 0.62, fwdZ * 0.62, 0.55, 0.1],168+ [-fwdX * 0.62, -fwdZ * 0.62, 0.55, 0.1],169+ [lx * 0.62, lz * 0.62, 0.1, 0.55],170+ [-lx * 0.62, -lz * 0.62, 0.1, 0.55],171+ ] as const172+ ) {173+ const along = (ox * exitX + oz * exitZ) / 0.62;174+ if (along > 0.7) continue;175+ out.push({176+ kind: "slab",177+ center: [px + ox, tubeCy, pz + oz],178+ half: [hx, tubeHalf, hz],179+ yaw: st.yaw,180+ pitch: 0,181+ });182+ }183+ // Bottom pan: the full shaft cross-section at receiver-floor level —184+ // a falling marble can drift off the rail line and the receiver's185+ // own floor only starts AT its rail (a 0.4 gap swallowed abs-1).186+ out.push({187+ kind: "slab",188+ center: [px, botY - FLOOR_HALF_T - SINK, pz],189+ half: [0.75, FLOOR_HALF_T, 0.75],190+ yaw: st.yaw,191+ pitch: 0,192+ });193+ i = k - 1; // consume the group194+ continue;195+ }196+ // The floor APPROACHING a drop shaft pulls back so it doesn't197+ // overhang the mouth (the marble must clear the hole edge cleanly).198+ // The RECEIVER floor keeps its full extent — it must reach under the199+ // shaft to catch the fall (trimming it opened a slip-through gap).200+ const nextVert = i + 1 < stations.length && stations[i + 1].vertical;201+ const trim = nextVert ? 0.3 : 0;202+ const tx = Math.sin(st.yaw);203+ const tz = Math.cos(st.yaw);204+ const shift = nextVert ? -trim : 0;205+ out.push({206+ kind: "slab",207+ center: [208+ px + tx * shift,209+ py - FLOOR_HALF_T - SINK,210+ pz + tz * shift,211+ ],212+ half: [FLOOR_HALF_W, FLOOR_HALF_T, Math.max(0.1, st.halfLen - trim)],213+ yaw: st.yaw,214+ pitch: st.pitch,215+ });216+ // Walls are VERTICAL columns (yaw only): pitched walls scissor at217+ // slope transitions into overhang pockets that wedge the marble —218+ // found the hard way (cua-seed-1 froze at a chute valley). Tall219+ // enough to cover the slope span within a sample, dipped below the220+ // floor so no gap opens underneath.221+ const lx = Math.cos(st.yaw);222+ const lz = -Math.sin(st.yaw);223+ const off = FLOOR_HALF_W + WALL_HALF_W - 0.04;224+ for (const side of [-1, 1]) {225+ out.push({226+ kind: "slab",227+ center: [228+ px + lx * off * side,229+ py + WALL_CENTER_LIFT,230+ pz + lz * off * side,231+ ],232+ half: [WALL_HALF_W, WALL_HALF_H, st.halfLen + 0.06],233+ yaw: st.yaw,234+ pitch: 0,235+ });236+ }237+ }238+ return out;239+}240+241+/** Heights of the cliff block family (world units). */242+function blockHeight(pieceId: string): number | null {243+ if (pieceId.includes("cliff_blockQuarter")) return 0.25;244+ if (pieceId.includes("cliff_blockHalf")) return 0.5;245+ if (pieceId.includes("cliff_block")) return 1;246+ return null;247+}248+249+/** Box proxies for an island's structural blocks (caps/decor are cosmetic250+ * — the marble never touches them from the channel). */251+export function islandProxies(252+ placements: readonly Placement[],253+): BoxProxy[] {254+ const out: BoxProxy[] = [];255+ for (const p of placements) {256+ if (p.role !== "support") continue;257+ const h = blockHeight(p.pieceId);258+ if (h === null) continue;259+ out.push({260+ kind: "box",261+ center: [p.pos[0], p.pos[1] + h / 2, p.pos[2]],262+ half: [0.5, h / 2, 0.5],263+ });264+ }265+ return out;266+}
M src/gen/endless/world.ts +4 -0
M src/gen/wfc.ts +7 -0
A tests/physics_test.ts +190 -0
@@ -0,0 +1,190 @@
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+ */13+import { assert, assertEquals } from "@std/assert";14+import { EndlessWorld } from "../src/gen/endless/world.ts";15+import { channelProxies, islandProxies } from "../src/gen/endless/proxies.ts";16+import {17+ assistForce,18+ type MarbleState,19+ PhysicsEngine,20+ type PhysicsModule,21+} from "../src/client/physics/engine.ts";22+import { segLength, segPoint } from "../src/gen/geom.ts";23+import type { DecorCatalog } from "../src/gen/decor.ts";24+import type { LoopPath } from "../src/shared/types.ts";25+26+const catalog: DecorCatalog = {27+ packs: [28+ {29+ slug: "nature-kit",30+ ids: JSON.parse(Deno.readTextFileSync(31+ new URL("../assets/nature-kit.manifest.json", import.meta.url),32+ )).pieces.map((p: { id: string }) => p.id),33+ },34+ ],35+};36+37+const factory =38+ (await import(new URL("../static/dist/box3d.mjs", import.meta.url).href))39+ .default as () => Promise<PhysicsModule>;40+41+/** Dense polyline over a path: points + tangents + cumulative s. */42+interface Rail {43+ pts: [number, number, number][];44+ tans: [number, number, number][];45+ s: number[];46+}47+48+function railOf(paths: LoopPath[]): Rail {49+ const rail: Rail = { pts: [], tans: [], s: [] };50+ let acc = 0;51+ for (const path of paths) {52+ for (const seg of path.segments) {53+ for (const part of seg.curve) {54+ const len = segLength(part);55+ const steps = Math.max(1, Math.ceil(len / 0.5));56+ for (let i = 0; i < steps; i++) {57+ const a = segPoint(part, i / steps);58+ const b = segPoint(part, Math.min(1, (i + 1) / steps));59+ const d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];60+ const l = Math.hypot(d[0], d[1], d[2]) || 1;61+ rail.pts.push([a[0], a[1], a[2]]);62+ rail.tans.push([d[0] / l, d[1] / l, d[2] / l]);63+ rail.s.push(acc);64+ acc += l;65+ }66+ }67+ }68+ }69+ return rail;70+}71+72+interface RunResult {73+ reachedS: number;74+ minY: number;75+ fell: boolean;76+ hash: bigint;77+}78+79+function drive(seed: string, segments: number, simSeconds: number): RunResult {80+ return driveWith(seed, segments, simSeconds);81+}82+83+let module_: PhysicsModule | null = null;84+85+function driveWith(86+ seed: string,87+ segments: number,88+ simSeconds: number,89+): RunResult {90+ const w = new EndlessWorld(seed, catalog);91+ const engine = new PhysicsEngine(module_!);92+ const paths: LoopPath[] = [];93+ let lowest = Infinity;94+ for (let i = 0; i < segments; i++) {95+ const seg = w.get(i);96+ paths.push(seg.path);97+ engine.addSegment(i, [98+ ...channelProxies(seg.path),99+ ...seg.islands.flatMap((isle) => islandProxies(isle.placements)),100+ ]);101+ for (const c of seg.plan.cells) lowest = Math.min(lowest, c.baseY * 2);102+ }103+ const rail = railOf(paths);104+ const start = rail.pts[0];105+ const t0 = rail.tans[0];106+ engine.spawnMarble(107+ [start[0], start[1] + 0.4, start[2]],108+ [t0[0] * 2.2, 0, t0[2] * 2.2],109+ );110+111+ let nearest = 0;112+ let reachedS = 0;113+ let minY = Infinity;114+ let fell = false;115+ let hash = 1469598103934665603n;116+ const FNV = 1099511628211n;117+ const mix = (v: number) => {118+ // Quantize to survive f64 printing differences; determinism is bitwise119+ // anyway, this keeps the hash cheap.120+ const q = BigInt(Math.round(v * 1e6)) & 0xffffffffffffn;121+ hash = ((hash ^ q) * FNV) & 0xffffffffffffffffn;122+ };123+124+ const totalSteps = Math.round(simSeconds * 60);125+ let st: MarbleState = engine.state();126+ for (let step = 0; step < totalSteps && !fell; step++) {127+ engine.step(1 / 60, () => {128+ // Assist toward the local tangent when slow (per fixed step).129+ const p = st.pos;130+ let bestD = Infinity;131+ const lo = Math.max(0, nearest - 30);132+ const hi = Math.min(rail.pts.length - 1, nearest + 60);133+ for (let i = lo; i <= hi; i++) {134+ const r = rail.pts[i];135+ const d = (r[0] - p[0]) ** 2 + (r[1] - p[1]) ** 2 +136+ (r[2] - p[2]) ** 2;137+ if (d < bestD) {138+ bestD = d;139+ nearest = i;140+ }141+ }142+ const t = rail.tans[nearest];143+ const f = assistForce(st.vel, t, engine.mass);144+ if (f) engine.force(f[0], f[1], f[2]);145+ });146+ st = engine.state();147+ reachedS = Math.max(reachedS, rail.s[nearest]);148+ minY = Math.min(minY, st.pos[1]);149+ if (st.pos[1] < lowest - 8) fell = true;150+ if (step % 15 === 0) {151+ mix(st.pos[0]);152+ mix(st.pos[1]);153+ mix(st.pos[2]);154+ }155+ }156+ return { reachedS, minY, fell, hash };157+}158+159+Deno.test("physics survivability: assisted marble clears early segments", async () => {160+ module_ = await factory();161+ const SEEDS = ["cua-seed-1", "abs-1", "fuzz-0", "det-seed", "drift-seed"];162+ const results: { seed: string; ok: boolean; r: RunResult; goal: number }[] =163+ [];164+ for (const seed of SEEDS) {165+ const w = new EndlessWorld(seed, catalog);166+ const goal = w.get(3).startS; // past the end of segment 2167+ const r = drive(seed, 5, 90);168+ results.push({ seed, ok: !r.fell && r.reachedS >= goal, r, goal });169+ }170+ const failures = results.filter((x) => !x.ok);171+ assert(172+ failures.length === 0,173+ `marble did not survive:\n${174+ failures.map((f) =>175+ `${f.seed}: fell=${f.r.fell} reached=${f.r.reachedS.toFixed(1)}/${176+ f.goal.toFixed(1)177+ } minY=${f.r.minY.toFixed(1)}`178+ ).join("\n")179+ }`,180+ );181+});182+183+Deno.test("physics determinism: identical trajectory hash across runs", async () => {184+ module_ = await factory();185+ const a = drive("det-seed", 4, 45);186+ module_ = await factory(); // fresh module — same-build reproducibility187+ const b = drive("det-seed", 4, 45);188+ assertEquals(a.hash, b.hash);189+ assertEquals(a.fell, b.fell);190+});
M tools/build_physics.sh +1 -1