clone

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

main
marbles / tests / multimarble_test.ts
multimarble_test.ts 11.5K
1/**2 * M15 multi-marble gates (headless, real wasm): N racer marbles share one3 * Box3D world, each driven by its own autopilot personality, colliding4 * for real.5 *6 * - pack progress: all racers clear the goal without falling out.7 * - contact: the race actually produces marble-marble encounters8 * (min pairwise distance reaches contact range) — interaction is real,9 * not N parallel ghosts.10 * - determinism: same seed → identical finish order and trajectory hash11 * across two runs.12 */13import { assert, assertEquals } from "@std/assert";14import { EndlessWorld } from "../src/gen/endless/world.ts";15import {16 bumperProxies,17 islandProxies,18 segmentProxies,19} from "../src/gen/endless/proxies.ts";20import {21 MARBLE_RADIUS,22 PhysicsEngine,23 type PhysicsModule,24} from "../src/client/physics/engine.ts";25import {26 concatRails,27 type Rail,28 railForSegment,29 stationGravityDelta,30 stationRestitution,31} from "../src/client/physics/rail.ts";32import {33 autopilotForce,34 draftFactor,35 type BoostState,36 boostStep,37 FULL_TANK,38 type Neighbor,39 type Pilot,40 type PilotDebug,41 pilotFor,42 pilotOffset,43 wantJump,44} from "../src/client/physics/autopilot.ts";45import type { DecorCatalog } from "../src/gen/decor.ts";46import { goldenCatalog } from "../tools/pin_endless.ts";4748// The catalog the client streams with (all decor packs — M18a).49const catalog: DecorCatalog = goldenCatalog();5051const factory =52 (await import(new URL("../static/dist/box3d.mjs", import.meta.url).href))53 .default as () => Promise<PhysicsModule>;5455interface RaceResult {56 order: number[];57 s: number[];58 minPair: number;59 fell: boolean[];60 hash: bigint;61 /** Decision steps where any racer's draft factor exceeded 1 (M20a). */62 draftSteps: number;63}6465interface TestRacer {66 idx: number;67 pilot: Pilot;68 nearest: number;69 s: number;70 simT: number;71 boost: BoostState;72 dbg: PilotDebug;73}7475function race(76 seed: string,77 n: number,78 segments: number,79 simSeconds: number,80 module_: PhysicsModule,81): RaceResult {82 const w = new EndlessWorld(seed, catalog);83 const engine = new PhysicsEngine(module_);84 const rails: Rail[] = [];85 let lowest = Infinity;86 for (let i = 0; i < segments; i++) {87 const seg = w.get(i);88 rails.push(railForSegment(seg));89 engine.addSegment(i, [90 ...segmentProxies(seg),91 ...seg.islands.flatMap((isle) => islandProxies(isle.placements)),92 ...bumperProxies(seg.placements),93 ]);94 for (const c of seg.plan.cells) lowest = Math.min(lowest, c.baseY * 2);95 }96 const rail = concatRails(rails);97 const racers: TestRacer[] = [];98 for (let i = 0; i < n; i++) {99 const at = Math.min(rail.pts.length - 2, i * 2);100 const t = rail.tans[at];101 const nl = Math.hypot(t[2], t[0]) || 1;102 const lat = (i % 2 === 0 ? 1 : -1) * 0.22;103 engine.spawnMarble(i, [104 rail.pts[at][0] + (t[2] / nl) * lat,105 rail.pts[at][1] + 0.4,106 rail.pts[at][2] + (-t[0] / nl) * lat,107 ], [t[0] * 2.2, 0, t[2] * 2.2]);108 racers.push({109 idx: i,110 pilot: pilotFor(seed, i),111 nearest: at,112 s: rail.s[at],113 simT: 0,114 boost: { ...FULL_TANK },115 dbg: {},116 });117 }118 const mass = engine.massOf(0);119 let minPair = Infinity;120 let draftSteps = 0;121 const fell = new Array<boolean>(n).fill(false);122 let hash = 1469598103934665603n;123 const FNV = 1099511628211n;124 const mix = (v: number) => {125 const q = BigInt(Math.round(v * 1e6)) & 0xffffffffffffn;126 hash = ((hash ^ q) * FNV) & 0xffffffffffffffffn;127 };128129 // Stop BEFORE the horizon: racers reaching the rail's end all ratchet130 // to the same s and "order" degrades to the sort tie-breaker.131 const endS = rail.s[rail.s.length - 1] - 6;132 const totalSteps = Math.round(simSeconds * 60);133 for (let step = 0; step < totalSteps; step++) {134 if (racers.some((r) => r.s >= endS)) break;135 engine.step(1 / 60, () => {136 const states = racers.map((r) => engine.state(r.idx));137 for (const [ri, r] of racers.entries()) {138 if (fell[ri]) continue;139 const st = states[ri];140 let bestD = Infinity;141 const lo = Math.max(0, r.nearest - 30);142 const hi = Math.min(rail.pts.length - 1, r.nearest + 60);143 for (let i = lo; i <= hi; i++) {144 const p = rail.pts[i];145 const d = (p[0] - st.pos[0]) ** 2 + (p[1] - st.pos[1]) ** 2 +146 (p[2] - st.pos[2]) ** 2;147 if (d < bestD) {148 bestD = d;149 r.nearest = i;150 }151 }152 engine.setRestitution(r.idx, stationRestitution(rail, r.nearest));153 r.simT += 1 / 60;154 r.pilot.offset = pilotOffset(r.pilot, r.simT);155 const others: Neighbor[] = [];156 let gapAhead: number | null = null;157 let gapBehind: number | null = null;158 for (const [oi, o] of racers.entries()) {159 if (o === r || fell[oi]) continue;160 others.push({ pos: states[oi].pos, bravery: o.pilot.bravery });161 const d = o.s - r.s;162 if (d > 0.05) {163 gapAhead = gapAhead === null ? d : Math.min(gapAhead, d);164 } else if (d < -0.05) {165 gapBehind = gapBehind === null ? -d : Math.min(gapBehind, -d);166 }167 }168 const headroom =169 r.dbg.vSafe !== undefined && r.dbg.wishspeed !== undefined170 ? r.dbg.vSafe - r.dbg.wishspeed171 : null;172 r.boost = boostStep(173 r.boost,174 r.pilot,175 1 / 60,176 headroom,177 gapAhead,178 gapBehind,179 );180 // Draft law (M20a) — client parity: wake bonus behind the181 // nearest leader, through the same envelope.182 const draft = draftFactor(gapAhead, r.pilot.wake);183 if (draft > 1) draftSteps++;184 const f = autopilotForce(185 rail,186 r.nearest,187 st.pos,188 st.vel,189 mass,190 null,191 r.pilot,192 r.dbg,193 others,194 r.boost.boosting,195 draft,196 );197 const gd = stationGravityDelta(rail, r.nearest, mass);198 const fx = (f?.[0] ?? 0) + (gd?.[0] ?? 0);199 const fy = (f?.[1] ?? 0) + (gd?.[1] ?? 0);200 const fz = (f?.[2] ?? 0) + (gd?.[2] ?? 0);201 if (fx !== 0 || fy !== 0 || fz !== 0) {202 engine.force(r.idx, fx, fy, fz);203 }204 const j = wantJump(rail, r.nearest, st.pos, st.vel, null, r.pilot);205 if (j) engine.jump(r.idx, j[0], j[1]);206 }207 });208 const states = racers.map((r) => engine.state(r.idx));209 for (const [ri, r] of racers.entries()) {210 if (fell[ri]) continue;211 r.s = Math.max(r.s, rail.s[r.nearest]);212 if (states[ri].pos[1] < lowest - 8) fell[ri] = true;213 for (let oi = ri + 1; oi < n; oi++) {214 if (fell[oi]) continue;215 const a = states[ri].pos;216 const b = states[oi].pos;217 const d = Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);218 if (d < minPair) minPair = d;219 }220 if (step % 20 === 0) {221 mix(states[ri].pos[0]);222 mix(states[ri].pos[1]);223 mix(states[ri].pos[2]);224 }225 }226 }227 for (let i = 0; i < segments; i++) engine.removeSegment(i);228 for (let i = 0; i < n; i++) engine.destroyMarble(i);229 const order = racers.map((r) => r.idx).sort(230 (a, b) => racers[b].s - racers[a].s || a - b,231 );232 return { order, s: racers.map((r) => r.s), minPair, fell, hash, draftSteps };233}234235Deno.test("M15 race: the pack progresses, touches, and finishes deterministically", async () => {236 const module_ = await factory();237 const N = 4;238 const a = race("det-seed", N, 4, 60, module_);239 // Pack progress: nobody falls out of the world; everyone clears the240 // early segments (goal = segment 2's start, generous for laggards who241 // spent time shoving).242 const w = new EndlessWorld("det-seed", catalog);243 const goal = w.get(2).startS;244 assert(a.fell.every((f) => !f), `racers fell: ${JSON.stringify(a.fell)}`);245 assert(246 a.s.every((s) => s >= goal),247 `laggards below goal ${goal.toFixed(1)}: ${248 a.s.map((s) => s.toFixed(1)).join(", ")249 }`,250 );251 // Interaction is real: the pack came within contact range at least once252 // (2 radii + a hair).253 assert(254 a.minPair <= MARBLE_RADIUS * 2 + 0.05,255 `no marble-marble encounter (min pair distance ${a.minPair.toFixed(3)})`,256 );257258 // Determinism: a fresh module, the same seed → identical finish order259 // and trajectory hash.260 // Drafting is real (M20a): at least one wake window opened in a pack261 // spaced 1u apart at spawn.262 assert(a.draftSteps > 0, "no draft window ever opened across the race");263264 const module2 = await factory();265 const b = race("det-seed", N, 4, 60, module2);266 assertEquals(a.order, b.order);267 assertEquals(a.hash, b.hash);268});269270Deno.test("respawn ghost: materializing inside the pack kicks nobody, then resolidifies once clear", async () => {271 const module_ = await factory();272 const w = new EndlessWorld("ghost-seed", catalog);273 const engine = new PhysicsEngine(module_);274 const seg = w.get(0);275 engine.addSegment(0, [276 ...segmentProxies(seg),277 ...seg.islands.flatMap((isle) => islandProxies(isle.placements)),278 ...bumperProxies(seg.placements),279 ]);280 const rail = railForSegment(seg);281 const at = 6;282 const t = rail.tans[at];283 const nl = Math.hypot(t[2], t[0]) || 1;284 const side = [t[2] / nl, 0, -t[0] / nl] as const;285286 // A settled cluster of three SOLID marbles across the channel.287 const lanes = [-0.22, 0, 0.22];288 for (let i = 1; i <= 3; i++) {289 engine.spawnMarble(i, [290 rail.pts[at][0] + side[0] * lanes[i - 1],291 rail.pts[at][1] + 0.3,292 rail.pts[at][2] + side[2] * lanes[i - 1],293 ], [0, 0, 0]);294 }295 for (let s = 0; s < 60; s++) engine.step(1 / 60);296 const before = [1, 2, 3].map((i) => engine.state(i));297298 // Respawn marble 0 DEEP inside the middle marble (centers 0.05 apart,299 // 2r = 0.2), ghosted — setGhost AFTER spawnMarble (spawn resets the300 // shape to solid) — drifting forward so it can clear the pack.301 const mid = engine.state(2);302 engine.spawnMarble(0, [303 mid.pos[0] + side[0] * 0.05,304 mid.pos[1],305 mid.pos[2] + side[2] * 0.05,306 ], [t[0] * 3, 0, t[2] * 3]);307 engine.setGhost(0, true);308 engine.step(1 / 60);309310 // (a) No neighbor velocity explosion on the spawn step: a solid311 // overlap-spawn fires a depenetration impulse; the ghost must not.312 for (const [k, i] of [1, 2, 3].entries()) {313 const st = engine.state(i);314 const dv = Math.hypot(315 st.vel[0] - before[k].vel[0],316 st.vel[1] - before[k].vel[1],317 st.vel[2] - before[k].vel[2],318 );319 assert(320 dv < 0.6,321 `neighbor ${i} kicked by ghost spawn (|dv| ${dv.toFixed(2)})`,322 );323 }324325 // (b) It clears the pack and resolidifies within the 4s hard cap,326 // using the client's separation rule (no neighbor within 2.5 radii).327 const CLEAR = MARBLE_RADIUS * 2.5;328 let solidAt = -1;329 for (let s = 1; s <= 240; s++) {330 engine.step(1 / 60);331 const p0 = engine.state(0).pos;332 let minD = Infinity;333 for (const i of [1, 2, 3]) {334 const p = engine.state(i).pos;335 minD = Math.min(336 minD,337 Math.hypot(p[0] - p0[0], p[1] - p0[1], p[2] - p0[2]),338 );339 }340 if (minD > CLEAR) {341 engine.setGhost(0, false);342 solidAt = s;343 break;344 }345 }346 assert(solidAt >= 0, "ghost never cleared the pack within the 4s cap");347348 // Solid again and stable: no depenetration pop after resolidifying.349 for (let s = 0; s < 30; s++) engine.step(1 / 60);350 for (const i of [0, 1, 2, 3]) {351 const st = engine.state(i);352 const speed = Math.hypot(st.vel[0], st.vel[1], st.vel[2]);353 assert(354 speed < 12,355 `marble ${i} exploded after resolidify (|v| ${speed.toFixed(1)})`,356 );357 }358 engine.removeSegment(0);359 for (const i of [0, 1, 2, 3]) engine.destroyMarble(i);360});