Shim: marble ARRAY (16 max) — spawn/destroy/force/jump/restitution/state by index; world gravity FIXED at -20 (one global vector cannot serve N marbles in different zones) with gravity zones now per-marble delta forces (rail.ts stationGravityDelta — identical acceleration, correct per marble). Engine wrapper indexed; per-marble restitution cache. Client: peers PROMOTED from kinematic ghosts to real racer bodies — the decorative ring turns off in physics-autopilot mode and ?peers=N (default 5+hero) spawns real marbles on a staggered alternating-lane grid start, each with its own label-derived pilot personality (racer/N identity for tinted visuals, pilot/N for control). Separation steering: a slower pilot yields laterally to a neighbor ahead within 0.5u UNLESS at least as brave — shoving is content. Per-marble falls/stalls: the hero keeps checkpoint escalation; peers respawn behind the pack tail (never past the keep window). Leader-cam: the camera and HUD follow whoever leads; lead changes are the narrative. CUA state gains race telemetry. Gates: 113 tests green incl. new tests/multimarble_test.ts (pack progress, REAL contact — min pair distance reaches overlap — and deterministic finish order + trajectory hash across fresh modules); pack fuzz 30 seeds x 6 racers: 0 pack failures, 0 respawns needed, minPair 0.15; single-marble fuzz 60 seeds clean; browser soak 5 sim-minutes: 6-racer pack tight over 1198u, lead battles, 0 deaths, 0 console errors, nodes bounded.
- commit
- d17fe8f93cff6f6820613f6fab2f94ee08e7cdb9
- parent
- 6e62ce75070c81ab0d1307fbbd577c0a1ddbe622
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 468 | +++++++++++++++++++++++------- |
| M | src/ | 16 | + |
| M | src/ | 35 | ++- |
| M | src/ | 76 | +++-- |
| M | src/ | 14 | + |
| M | src/ | 82 | +++-- |
| A | tests/ | 231 | +++++++++++++++ |
| M | tests/ | 15 | +- |
| M | tools/ | 2 | +- |
| M | tools/ | 20 | +- |
| M | tools/ | 2 | +- |
| M | tools/ | 2 | +- |
@@ -35,16 +35,18 @@ import {
3535 type Rail,3636 railForSegment,3737 stationForce,38- stationGravity,38+ stationGravityDelta,3939 stationRestitution,4040 } from "./physics/rail.ts";4141 import {4242 autopilotForce,43+ type Neighbor,4344 type Pilot,4445 pilotFor,4546 pilotOffset,4647 wantJump,4748 } from "./physics/autopilot.ts";49+import { racerIdentity } from "./peersCore.ts";4850 import {4951 bumperProxies,5052 channelProxies,@@ -223,6 +225,8 @@ export function bootEndless(
223225 segment: session.currentIndex,224226 }225227 : null,228+ // The race (M15): every real racer's progress + the current leader.229+ race: session ? session.raceStats() : null,226230 stream: session227231 ? {228232 built: session.builtRange(),@@ -253,6 +257,24 @@ export function bootEndless(
253257 }));254258 }255259 260+/** One physics racer (M15): the hero (idx 0) or a real peer marble. */261+interface Racer {262+ idx: number;263+ pilot: Pilot;264+ mass: number;265+ obj: THREE.Object3D;266+ pivot: THREE.Object3D;267+ materials: THREE.Material[];268+ nearest: { seg: number; idx: number };269+ s: number;270+ speed: number;271+ simT: number;272+ lastProgressS: number;273+ stallClock: number;274+ stallHopped: boolean;275+ wasHop: boolean;276+}277+256278 class Session {257279 readonly world: EndlessWorld;258280 readonly seed: string;@@ -291,10 +313,12 @@ class Session {
291313 * falls back to the old assist choreographer for A/B comparison. */292314 private readonly useAutopilot =293315 new URLSearchParams(location.search).get("autopilot") !== "0";294- /** Seed-stable controller personality (M14c). */295- private pilot: Pilot | null = null;296- /** Fixed-step sim clock driving the pilot's wander offset. */297- private simT = 0;316+ /** The racers (M15): index 0 is the hero (existing marble visuals +317+ * checkpoint escalation); the rest are real peer marbles with their own318+ * pilots, respawning behind the pack tail. Empty in kinematic mode. */319+ private racers: Racer[] = [];320+ /** Real racer-peer count (0 outside physics-autopilot mode). */321+ private realPeerCount = 0;298322 private readonly rails = new Map<number, Rail>();299323 /** Active diamond branch per segment — respawns alternate it, so a300324 * fall's retry may ride the OTHER branch (respawn variety). */@@ -329,7 +353,6 @@ class Session {
329353 /** Audio cue edge detectors (launch/land/boost transitions). */330354 private wasFlight = false;331355 private wasSurge = false;332- private wasHop = false;333356 /** One escape hop per stall episode (M14b): a parked marble tries a334357 * jump before the respawn net takes it. */335358 private stallHopped = false;@@ -386,12 +409,21 @@ class Session {
386409 ctx.scene.add(this.marbleObj);387410 ctx.scene.add(this.trail.obj);388411 // Ring size is session config, not seed material: ?peers=N (0 turns389- // the ring off, capped at 24; default DEFAULT_PEER_COUNT).412+ // the ring off, capped at 24; default DEFAULT_PEER_COUNT). In physics413+ // autopilot mode the decorative ring is OFF — peers are REAL racer414+ // bodies instead (M15), same param, capped by the shim's 16 bodies.390415 const peersParam = new URLSearchParams(location.search).get("peers");391416 const peerCount = peersParam === null392417 ? undefined393418 : Math.max(0, Math.min(24, Number.parseInt(peersParam, 10) || 0));394- this.peerRing = new PeerRing(seed, assets, ctx.scene, peerCount);419+ const realRace = this.engine !== null && this.useAutopilot;420+ this.realPeerCount = realRace ? Math.min(15, peerCount ?? 5) : 0;421+ this.peerRing = new PeerRing(422+ seed,423+ assets,424+ ctx.scene,425+ realRace ? 0 : peerCount,426+ );395427 396428 this.ensure();397429 this.initialBuild = false;@@ -402,9 +434,56 @@ class Session {
402434 const rail = this.rails.get(0)!;403435 const t = rail.tans[0];404436 this.engine.spawnMarble(437+ 0,405438 [rail.pts[0][0], rail.pts[0][1] + 0.4, rail.pts[0][2]],406439 [t[0] * 2.2, 0, t[2] * 2.2],407440 );441+ this.racers.push({442+ idx: 0,443+ pilot: pilotFor(seed, 0),444+ mass: this.engine.massOf(0),445+ obj: this.marbleObj,446+ pivot: this.pivot,447+ materials: [],448+ nearest: { seg: 0, idx: 0 },449+ s: 0,450+ speed: 0,451+ simT: 0,452+ lastProgressS: 0,453+ stallClock: 0,454+ stallHopped: false,455+ wasHop: false,456+ });457+ // Racer peers (M15): real bodies staggered ahead of the hero on the458+ // first straight, alternating lanes so the grid start reads.459+ for (let i = 1; i <= this.realPeerCount; i++) {460+ const at = Math.min(rail.pts.length - 2, i * 2);461+ const tt = rail.tans[at];462+ const nl = Math.hypot(tt[2], tt[0]) || 1;463+ const lat = (i % 2 === 0 ? 1 : -1) * 0.22;464+ this.engine.spawnMarble(i, [465+ rail.pts[at][0] + (tt[2] / nl) * lat,466+ rail.pts[at][1] + 0.4,467+ rail.pts[at][2] + (-tt[0] / nl) * lat,468+ ], [tt[0] * 2.2, 0, tt[2] * 2.2]);469+ const { obj, pivot, materials } = this.buildRacerVisual(i);470+ this.racers.push({471+ idx: i,472+ pilot: pilotFor(seed, i),473+ mass: this.engine.massOf(i),474+ obj,475+ pivot,476+ materials,477+ nearest: { seg: 0, idx: at },478+ s: rail.s[at],479+ speed: 0,480+ simT: 0,481+ lastProgressS: rail.s[at],482+ stallClock: 0,483+ stallHopped: false,484+ wasHop: false,485+ });486+ }408487 }409488 410489 this.cameraProxy = {@@ -422,6 +501,26 @@ class Session {
422501 this.refreshStats();423502 }424503 504+ /** Race telemetry for CUA/debug: per-racer progress + the leader. */505+ raceStats(): {506+ n: number;507+ leader: number;508+ racers: { idx: number; s: number; speed: number }[];509+ } | null {510+ if (this.racers.length === 0) return null;511+ let leader = this.racers[0];512+ for (const r of this.racers) if (r.s > leader.s) leader = r;513+ return {514+ n: this.racers.length,515+ leader: leader.idx,516+ racers: this.racers.map((r) => ({517+ idx: r.idx,518+ s: Number(r.s.toFixed(1)),519+ speed: Number(r.speed.toFixed(2)),520+ })),521+ };522+ }523+425524 builtRange(): [number, number] {426525 const keys = [...this.built.keys()];427526 return [Math.min(...keys), Math.max(...keys)];@@ -717,126 +816,255 @@ class Session {
717816 }718817 }719818 720- /** Box3D driver: real rolling, real falls, checkpoint respawn. */819+ /** Box3D driver (M15): N real marbles — the hero plus racer peers —820+ * each running its own autopilot; real falls, per-marble respawn,821+ * leader-cam. */721822 private physicsMove(dt: number): void {722823 const engine = this.engine!;723824 engine.step(dt, () => {724- const st = engine.state();725- this.updateNearest(st.pos);726- const rail = this.rails.get(this.nearest.seg);727- if (!rail) return;728- // The complete per-step control law lives in rail.ts (shared with729- // the headless tests); lookahead chains into the next segment.730- engine.setRestitution(stationRestitution(rail, this.nearest.idx));731- engine.setGravity(stationGravity(rail, this.nearest.idx));732- const nextRail = this.rails.get(this.nearest.seg + 1) ?? null;733- let f: [number, number, number] | null;734- if (this.useAutopilot) {735- const pilot = this.pilot ??= pilotFor(this.seed);736- this.simT += FIXED_DT;737- pilot.offset = pilotOffset(pilot, this.simT);738- f = autopilotForce(739- rail,740- this.nearest.idx,741- st.pos,742- st.vel,743- engine.mass,744- nextRail,745- pilot,746- );747- } else {748- f = stationForce(749- rail,750- this.nearest.idx,751- st.pos,752- st.vel,753- engine.mass,754- nextRail,755- );756- }757- if (f) engine.force(f[0], f[1], f[2]);758- // The jump decision (M14b, autopilot only): top-up at a gap lip when759- // the actual approach is too slow for the priced arc.760- if (this.useAutopilot) {761- const j = wantJump(762- rail,763- this.nearest.idx,764- st.pos,765- st.vel,766- nextRail,767- this.pilot!,825+ // One state read per racer per fixed step, shared by all pilots826+ // (separation steering needs everyone's positions).827+ const states = this.racers.map((r) => engine.state(r.idx));828+ for (const [ri, r] of this.racers.entries()) {829+ const st = states[ri];830+ this.updateNearestFor(r, st.pos);831+ const rail = this.rails.get(r.nearest.seg);832+ if (!rail) continue;833+ engine.setRestitution(834+ r.idx,835+ stationRestitution(rail, r.nearest.idx),768836 );769- if (j) {770- engine.jump(j[0], j[1]);771- if (!this.wasHop) this.audio.cue("launch");772- this.wasHop = true;773- } else this.wasHop = false;837+ const nextRail = this.rails.get(r.nearest.seg + 1) ?? null;838+ let f: [number, number, number] | null;839+ if (this.useAutopilot) {840+ r.simT += FIXED_DT;841+ r.pilot.offset = pilotOffset(r.pilot, r.simT);842+ const others: Neighbor[] = [];843+ for (const [oi, o] of this.racers.entries()) {844+ if (o === r) continue;845+ others.push({ pos: states[oi].pos, bravery: o.pilot.bravery });846+ }847+ f = autopilotForce(848+ rail,849+ r.nearest.idx,850+ st.pos,851+ st.vel,852+ r.mass,853+ nextRail,854+ r.pilot,855+ undefined,856+ others,857+ );858+ } else {859+ f = stationForce(860+ rail,861+ r.nearest.idx,862+ st.pos,863+ st.vel,864+ r.mass,865+ nextRail,866+ );867+ }868+ // Gravity zones as per-marble delta forces (world gravity is869+ // global and fixed — one vector cannot serve N marbles).870+ const gd = stationGravityDelta(rail, r.nearest.idx, r.mass);871+ const fx = (f?.[0] ?? 0) + (gd?.[0] ?? 0);872+ const fy = (f?.[1] ?? 0) + (gd?.[1] ?? 0);873+ const fz = (f?.[2] ?? 0) + (gd?.[2] ?? 0);874+ if (fx !== 0 || fy !== 0 || fz !== 0) engine.force(r.idx, fx, fy, fz);875+ // The jump decision (M14b, autopilot only): top-up at a gap lip876+ // when the actual approach is too slow for the priced arc.877+ if (this.useAutopilot) {878+ const j = wantJump(879+ rail,880+ r.nearest.idx,881+ st.pos,882+ st.vel,883+ nextRail,884+ r.pilot,885+ );886+ if (j) {887+ engine.jump(r.idx, j[0], j[1]);888+ if (!r.wasHop && r.idx === 0) this.audio.cue("launch");889+ r.wasHop = true;890+ } else r.wasHop = false;891+ }774892 }775893 });776- const st = engine.state();777- this.marbleObj.position.set(st.pos[0], st.pos[1], st.pos[2]);778- this.pivot.quaternion.set(st.quat[0], st.quat[1], st.quat[2], st.quat[3]);779- this.speed = Math.hypot(st.vel[0], st.vel[1], st.vel[2]);780-781- // Event cues off the station modifier transitions (rule-derived).782- {783- const rail2 = this.rails.get(this.nearest.seg);784- const mod = rail2?.mods[this.nearest.idx] ?? null;785- const flight = !!mod?.flight;786- if (flight && !this.wasFlight) this.audio.cue("launch");787- if (!flight && this.wasFlight) this.audio.cue("land");788- this.wasFlight = flight;789- const surge = (mod?.speed !== undefined && mod.speed > 1) ||790- mod?.tilt !== undefined;791- if (surge && !this.wasSurge) this.audio.cue("boost");792- this.wasSurge = surge;793- }794- const seg = this.built.get(this.nearest.seg);795- const rail = this.rails.get(this.nearest.seg);796- if (seg && rail) {797- this.marbleS = Math.max(798- this.marbleS,799- seg.startS + rail.s[this.nearest.idx],800- );801- }802894 803- // Fall detection: below every co-live channel by a wide margin →804- // respawn at the current segment's entry (checkpoints move forward805- // with the stream, so respawn can never send the marble backward past806- // the render window).807895 let lowest = Infinity;808896 for (const b of this.built.values()) {809897 lowest = Math.min(lowest, b.lowY);810898 }811- if (st.pos[1] < lowest - 10) {899+ const windowLo = Math.min(...this.built.keys());900+ const windowLoS = this.built.get(windowLo)?.startS ?? 0;901+902+ let heroFell = false;903+ for (const r of this.racers) {904+ const st = engine.state(r.idx);905+ r.obj.position.set(st.pos[0], st.pos[1], st.pos[2]);906+ r.pivot.quaternion.set(st.quat[0], st.quat[1], st.quat[2], st.quat[3]);907+ r.speed = Math.hypot(st.vel[0], st.vel[1], st.vel[2]);908+ const seg = this.built.get(r.nearest.seg);909+ const rail = this.rails.get(r.nearest.seg);910+ if (seg && rail) {911+ r.s = Math.max(r.s, seg.startS + rail.s[r.nearest.idx]);912+ }913+914+ if (r.idx === 0) {915+ // Hero cues off the station modifier transitions (rule-derived).916+ const mod = rail?.mods[r.nearest.idx] ?? null;917+ const flight = !!mod?.flight;918+ if (flight && !this.wasFlight) this.audio.cue("launch");919+ if (!flight && this.wasFlight) this.audio.cue("land");920+ this.wasFlight = flight;921+ const surge = (mod?.speed !== undefined && mod.speed > 1) ||922+ mod?.tilt !== undefined;923+ if (surge && !this.wasSurge) this.audio.cue("boost");924+ this.wasSurge = surge;925+ this.nearest = r.nearest;926+ this.marbleS = Math.max(this.marbleS, r.s);927+ }928+929+ // Falls + stalls, per marble. The hero keeps the checkpoint930+ // escalation; peers respawn behind the pack tail.931+ const fell = st.pos[1] < lowest - 10;932+ if (r.idx === 0) {933+ if (fell) {934+ heroFell = true;935+ continue;936+ }937+ if (this.marbleS > this.lastProgressS + 0.4) {938+ this.lastProgressS = this.marbleS;939+ this.stallClock = 0;940+ this.stallHopped = false;941+ } else {942+ this.stallClock += dt;943+ // Escape hop (M14b): one visible attempt before the net.944+ if (945+ this.stallClock > 2.5 && !this.stallHopped && this.useAutopilot946+ ) {947+ this.stallHopped = true;948+ engine.jump(0, [0, 1, 0], 4.2);949+ this.audio.cue("launch");950+ }951+ if (this.stallClock > 6) this.respawn();952+ }953+ continue;954+ }955+ // Peer: fell, wedged, or dropped out of the keep window.956+ if (r.s > r.lastProgressS + 0.4) {957+ r.lastProgressS = r.s;958+ r.stallClock = 0;959+ r.stallHopped = false;960+ } else {961+ r.stallClock += dt;962+ if (r.stallClock > 2.5 && !r.stallHopped && this.useAutopilot) {963+ r.stallHopped = true;964+ engine.jump(r.idx, [0, 1, 0], 4.2);965+ }966+ }967+ if (fell || r.stallClock > 6 || r.s < windowLoS - 2) {968+ this.respawnPeer(r);969+ }970+ }971+ if (heroFell) {812972 this.respawn();813973 return;814974 }815- // Stall net: wedged/parked marbles respawn too — progress always816- // moves forward. (Every stall location is a future named scenario.)817- if (this.marbleS > this.lastProgressS + 0.4) {818- this.lastProgressS = this.marbleS;819- this.stallClock = 0;820- this.stallHopped = false;821- } else {822- this.stallClock += dt;823- // Escape hop (M14b, autopilot only): one try before the net — a824- // parked marble visibly ATTEMPTS the obstacle; if the hop frees it,825- // progress resets the clock, else the respawn still comes.826- if (827- this.stallClock > 2.5 && !this.stallHopped && this.useAutopilot828- ) {829- this.stallHopped = true;830- this.engine?.jump([0, 1, 0], 4.2);831- this.audio.cue("launch");975+976+ // Leader-cam: the camera follows whoever leads the race; the HUD977+ // speed reads the leader too. Lead changes are the narrative.978+ let leader = this.racers[0];979+ for (const r of this.racers) if (r.s > leader.s) leader = r;980+ this.speed = leader.speed;981+ this.marbleS = Math.max(this.marbleS, leader.s);982+ if (this.cameraProxy) {983+ this.cameraProxy.obj = leader.obj;984+ this.cameraProxy.s = this.marbleS;985+ this.cameraProxy.speed = this.speed;986+ }987+ }988+989+ /** Respawn a peer racer at the pack tail (never past the keep window's990+ * lower edge) — no checkpoint escalation, no death counting; the pack991+ * simply keeps itself together. */992+ private respawnPeer(r: Racer): void {993+ const engine = this.engine;994+ if (!engine) return;995+ let tailS = Infinity;996+ for (const o of this.racers) if (o !== r) tailS = Math.min(tailS, o.s);997+ const lo = Math.min(...this.built.keys());998+ const loS = (this.built.get(lo)?.startS ?? 0) + 1;999+ const at = Math.max(loS, tailS - 1.5);1000+ const seg = this.segmentAt(at);1001+ const rail = seg ? this.rails.get(seg.index) : undefined;1002+ if (!seg || !rail) return;1003+ let idx = 0;1004+ while (idx < rail.s.length - 1 && rail.s[idx] < at - seg.startS) idx++;1005+ const t = rail.tans[idx];1006+ engine.spawnMarble(1007+ r.idx,1008+ [rail.pts[idx][0], rail.pts[idx][1] + 0.4, rail.pts[idx][2]],1009+ [t[0] * 2.2, 0, t[2] * 2.2],1010+ );1011+ r.nearest = { seg: seg.index, idx };1012+ r.s = seg.startS + rail.s[idx];1013+ r.lastProgressS = r.s;1014+ r.stallClock = 0;1015+ r.stallHopped = false;1016+ }1017+1018+ /** Tinted marble visuals for a real racer peer (mirrors the ring's1019+ * buildVisual; scale stays MARBLE_SCALE so the mesh matches the shared1020+ * physics radius). */1021+ private buildRacerVisual(index: number): {1022+ obj: THREE.Group;1023+ pivot: THREE.Group;1024+ materials: THREE.Material[];1025+ } {1026+ const identity = racerIdentity(this.seed, index);1027+ const obj = new THREE.Group();1028+ obj.name = `racer:${index}`;1029+ const pivot = new THREE.Group();1030+ obj.add(pivot);1031+ const materials: THREE.Material[] = [];1032+ const mesh = this.assets.getPiece(identity.meshId) ??1033+ this.assets.getPiece(MARBLE_PIECE);1034+ if (mesh) {1035+ mesh.scale.setScalar(MARBLE_SCALE);1036+ if (identity.tint) {1037+ const [tr, tg, tb] = identity.tint;1038+ mesh.traverse((child: THREE.Object3D) => {1039+ const m = child as THREE.Mesh;1040+ if (!m.isMesh) return;1041+ const tintOne = (mat: THREE.Material): THREE.Material => {1042+ const clone = (mat as THREE.MeshStandardMaterial).clone();1043+ clone.color.setRGB(tr / 255, tg / 255, tb / 255);1044+ materials.push(clone);1045+ return clone;1046+ };1047+ m.material = Array.isArray(m.material)1048+ ? m.material.map(tintOne)1049+ : tintOne(m.material);1050+ });8321051 }833- if (this.stallClock > 6) this.respawn();1052+ const box = new THREE.Box3().setFromObject(mesh);1053+ const center = box.getCenter(new THREE.Vector3());1054+ pivot.position.copy(center);1055+ mesh.position.sub(center);1056+ pivot.add(mesh);8341057 }1058+ this.ctx.scene.add(obj);1059+ return { obj, pivot, materials };8351060 }8361061 837- /** Nearest rail station to the marble, advancing across segments. */838- private updateNearest(pos: readonly [number, number, number]): void {839- let { seg, idx } = this.nearest;1062+ /** Nearest rail station to a racer, advancing across segments. */1063+ private updateNearestFor(1064+ r: Racer,1065+ pos: readonly [number, number, number],1066+ ): void {1067+ let { seg, idx } = r.nearest;8401068 if (!this.rails.has(seg)) {8411069 seg = Math.min(...this.rails.keys());8421070 idx = 0;@@ -862,7 +1090,7 @@ class Session {
8621090 };8631091 consider(seg, idx - 25, idx + 50);8641092 consider(seg + 1, 0, 40); // the boundary is always in reach865- this.nearest = { seg: bestSeg, idx: bestIdx };1093+ r.nearest = { seg: bestSeg, idx: bestIdx };8661094 }8671095 8681096 /** Respawn at the last checkpoint (the current segment's entry), and@@ -909,11 +1137,20 @@ class Session {
9091137 if (!rail) return;9101138 const t = rail.tans[0];9111139 this.engine.spawnMarble(1140+ 0,9121141 [rail.pts[0][0], rail.pts[0][1] + 0.4, rail.pts[0][2]],9131142 [t[0] * 2.2, 0, t[2] * 2.2],9141143 );9151144 this.nearest = { seg: cp.index, idx: 0 };9161145 this.marbleS = cp.startS;1146+ const hero = this.racers[0];1147+ if (hero) {1148+ hero.nearest = { seg: cp.index, idx: 0 };1149+ hero.s = cp.startS;1150+ hero.lastProgressS = cp.startS;1151+ hero.stallClock = 0;1152+ hero.stallHopped = false;1153+ }9171154 }9181155 9191156 private finishFrame(dt: number): void {@@ -973,6 +1210,15 @@ class Session {
9731210 }9741211 9751212 dispose(): void {1213+ // Racer peers (M15): visuals + bodies go with the session.1214+ for (const r of this.racers) {1215+ if (r.idx === 0) continue;1216+ this.ctx.scene.remove(r.obj);1217+ r.obj.clear();1218+ for (const m of r.materials) m.dispose();1219+ this.engine?.destroyMarble(r.idx);1220+ }1221+ this.racers = [];9761222 // Session teardown is immediate — no exit waves on a seed change.9771223 for (const seg of [...this.built.values()]) {9781224 this.root.remove(seg.group);@@ -273,6 +273,22 @@ export class PeerRingCore {
273273 }274274 }275275 276+/** Seed-stable looks for a REAL racer marble (M15 — physics peers). Uses277+ * the same mesh pool + tint law as ring peers; label-derived, so adding278+ * racers never perturbs anything else. */279+export function racerIdentity(280+ seed: string,281+ index: number,282+): { meshId: string; tint: readonly [number, number, number] | null } {283+ const rng = rngFamily(seed, `racer/${index}`);284+ return {285+ meshId: rng.weighted(MESH_CHOICES),286+ tint: rng.chance(0.2)287+ ? null288+ : drawTint(rng.float(), rng.float(), rng.float()),289+ };290+}291+276292 /** Rich marble-glass tints (bounded like the pipe colors so peers read277293 * against every atmosphere). */278294 function drawTint(@@ -212,9 +212,16 @@ export interface PilotDebug {
212212 look?: readonly [number, number, number];213213 }214214 215+/** A neighboring racer the pilot can see (M15 separation steering). */216+export interface Neighbor {217+ pos: readonly [number, number, number];218+ bravery: number;219+}220+215221 /**216222 * The complete autopilot step at a station. Same contract as217- * rail.ts stationForce (drivers swap one call), plus a pilot.223+ * rail.ts stationForce (drivers swap one call), plus a pilot and the224+ * other racers on the track.218225 */219226 export function autopilotForce(220227 rail: Rail,@@ -225,6 +232,7 @@ export function autopilotForce(
225232 nextRail: Rail | null = null,226233 pilot: Pilot = DEFAULT_PILOT,227234 dbg?: PilotDebug,235+ others?: readonly Neighbor[],228236 ): [number, number, number] | null {229237 const t = rail.tans[idx];230238 if (!t) return null;@@ -406,6 +414,31 @@ export function autopilotForce(
406414 }407415 }408416 417+ // Separation steering (M15): a neighbor occupying the path just ahead418+ // gets a lateral yield — UNLESS this pilot is at least as brave, in419+ // which case the contact happens (shoving is content). Behind-me420+ // neighbors are ignored; the front marble never yields to a chaser.421+ if (others && grounded) {422+ const nl3 = Math.hypot(t[2], t[0]);423+ if (nl3 > 1e-6) {424+ const ux = t[2] / nl3;425+ const uz = -t[0] / nl3;426+ for (const o of others) {427+ const dx = o.pos[0] - pos[0];428+ const dy = o.pos[1] - pos[1];429+ const dz = o.pos[2] - pos[2];430+ const dist = Math.hypot(dx, dy, dz);431+ if (dist > 0.5 || dist < 1e-6) continue;432+ if (dx * vel[0] + dz * vel[2] <= 0) continue; // behind me433+ if (pilot.bravery >= o.bravery) continue; // shove through434+ const side = dx * ux + dz * uz;435+ const away = side >= 0 ? -1 : 1;436+ const a = 6 * (1 - dist / 0.5);437+ out[0] += ux * away * a * mass;438+ out[2] += uz * away * a * mass;439+ }440+ }441+ }409442 // Reynolds corridor dead zone (M14c): outside centering zones, a410443 // marble within ±CORRIDOR_DEAD of its personal line (rail + wander411444 // offset) gets NO lateral correction — the wobble is what reads as@@ -31,6 +31,7 @@ export interface PhysicsModule {
3131 _ph_end_static(): void;3232 _ph_remove_static(key: number): void;3333 _ph_spawn_marble(34+ idx: number,3435 x: number,3536 y: number,3637 z: number,@@ -39,6 +40,7 @@ export interface PhysicsModule {
3940 vz: number,4041 radius: number,4142 ): void;43+ _ph_destroy_marble(idx: number): void;4244 _ph_add_mesh(4345 vertsPtr: number,4446 vertCount: number,@@ -53,13 +55,19 @@ export interface PhysicsModule {
5355 height: number,5456 restitution: number,5557 ): void;56- _ph_marble_force(fx: number, fy: number, fz: number): void;57- _ph_marble_jump(nx: number, ny: number, nz: number, target: number): void;58- _ph_marble_mass(): number;59- _ph_marble_restitution(restitution: number): void;58+ _ph_marble_force(idx: number, fx: number, fy: number, fz: number): void;59+ _ph_marble_jump(60+ idx: number,61+ nx: number,62+ ny: number,63+ nz: number,64+ target: number,65+ ): void;66+ _ph_marble_mass(idx: number): number;67+ _ph_marble_restitution(idx: number, restitution: number): void;6068 _ph_gravity(gx: number, gy: number, gz: number): void;6169 _ph_step(dt: number, substeps: number): void;62- _ph_marble_state(outPtr: number): void;70+ _ph_marble_state(idx: number, outPtr: number): void;6371 _malloc(bytes: number): number;6472 _free(ptr: number): void;6573 HEAPF64: Float64Array;@@ -150,8 +158,8 @@ export function assistForce(
150158 export class PhysicsEngine {151159 private readonly out: number;152160 private accumulator = 0;153- private restitution = MARBLE_RESTITUTION;154- private gravity: [number, number, number] = [0, GRAVITY_Y, 0];161+ /** Per-marble restitution cache (index → last set value). */162+ private readonly restitutions = new Map<number, number>();155163 156164 constructor(private readonly m: PhysicsModule) {157165 m._ph_init(GRAVITY_Y);@@ -223,36 +231,35 @@ export class PhysicsEngine {
223231 }224232 225233 /** Marble mass (kg — Box3D defaults to SI density 1000). */226- get mass(): number {227- return this.m._ph_marble_mass();234+ massOf(idx: number): number {235+ return this.m._ph_marble_mass(idx);228236 }229237 230- /** Shock-absorber override (cached — the zone law calls this per step). */231- setRestitution(value: number): void {232- if (value === this.restitution) return;233- this.restitution = value;234- this.m._ph_marble_restitution(value);238+ /** Marble 0's mass (single-marble drivers). */239+ get mass(): number {240+ return this.m._ph_marble_mass(0);235241 }236242 237- /** Gravity-rotation override (M13): the zone law tilts the world238- * gravity vector while the marble rides a gravity zone (cached — the239- * driver calls this per step; the default is [0, GRAVITY_Y, 0]). */240- setGravity(g: readonly [number, number, number]): void {241- if (242- g[0] === this.gravity[0] && g[1] === this.gravity[1] &&243- g[2] === this.gravity[2]244- ) return;245- this.gravity = [g[0], g[1], g[2]];246- this.m._ph_gravity(g[0], g[1], g[2]);243+ /** Shock-absorber override (cached — the zone law calls this per step). */244+ setRestitution(idx: number, value: number): void {245+ if (this.restitutions.get(idx) === value) return;246+ this.restitutions.set(idx, value);247+ this.m._ph_marble_restitution(idx, value);247248 }248249 250+ /** Spawn (or respawn) marble `idx`. World gravity is FIXED at251+ * [0, GRAVITY_Y, 0] — per-marble gravity zones are delta forces252+ * (rail.ts stationGravityDelta), one global vector cannot serve N253+ * marbles in different zones. */249254 spawnMarble(255+ idx: number,250256 pos: readonly [number, number, number],251257 vel: readonly [number, number, number],252258 ): void {253- this.accumulator = 0;254- this.restitution = MARBLE_RESTITUTION; // shim recreates the shape259+ if (idx === 0) this.accumulator = 0;260+ this.restitutions.set(idx, MARBLE_RESTITUTION); // shim recreates the shape255261 this.m._ph_spawn_marble(262+ idx,256263 pos[0],257264 pos[1],258265 pos[2],@@ -263,6 +270,11 @@ export class PhysicsEngine {
263270 );264271 }265272 273+ destroyMarble(idx: number): void {274+ this.m._ph_destroy_marble(idx);275+ this.restitutions.delete(idx);276+ }277+266278 /**267279 * Advance by wall-clock dt on the fixed clock; `eachStep` runs before268280 * every fixed step (assist forces are per-step so behavior is@@ -280,20 +292,20 @@ export class PhysicsEngine {
280292 return steps;281293 }282294 283- force(fx: number, fy: number, fz: number): void {284- this.m._ph_marble_force(fx, fy, fz);295+ force(idx: number, fx: number, fy: number, fz: number): void {296+ this.m._ph_marble_force(idx, fx, fy, fz);285297 }286298 287299 /** Top-up jump (M14b): velocity along `n` is SET to `target`, never288300 * added — deterministic leave-speed (the MBHaxe jump contract), so the289301 * autopilot can price an arc before committing. Safe to call repeatedly290302 * while the condition holds; extra calls are no-ops. */291- jump(n: readonly [number, number, number], target: number): void {292- this.m._ph_marble_jump(n[0], n[1], n[2], target);303+ jump(idx: number, n: readonly [number, number, number], target: number): void {304+ this.m._ph_marble_jump(idx, n[0], n[1], n[2], target);293305 }294306 295- state(): MarbleState {296- this.m._ph_marble_state(this.out);307+ state(idx = 0): MarbleState {308+ this.m._ph_marble_state(idx, this.out);297309 const h = this.m.HEAPF64;298310 const i = this.out / 8;299311 return {@@ -180,6 +180,20 @@ export function stationGravity(
180180 return [t[0] * fwd, GRAVITY_Y * Math.cos(tilt), t[2] * fwd];181181 }182182 183+/** Per-marble gravity-zone force (M15): with N marbles in one world the184+ * global gravity vector stays fixed at [0, GRAVITY_Y, 0]; a marble whose185+ * station calls for tilted gravity gets m·(g_zone − g_world) as an extra186+ * force — identical acceleration, correct per marble. */187+export function stationGravityDelta(188+ rail: Rail,189+ idx: number,190+ mass: number,191+): [number, number, number] | null {192+ const g = stationGravity(rail, idx);193+ if (g[0] === 0 && g[1] === GRAVITY_Y && g[2] === 0) return null;194+ return [g[0] * mass, (g[1] - GRAVITY_Y) * mass, g[2] * mass];195+}196+183197 /** Stations of rail lookahead (≈4 world units at RAIL_STEP). */184198 const LOOKAHEAD = 8;185199 @@ -13,9 +13,11 @@
1313 1414 1515 static b3WorldId world;16-static b3BodyId marble;17-static b3ShapeId marbleShape;18-static int marbleAlive = 0;16+17+18+static b3BodyId marbles[MAX_MARBLES];19+static b3ShapeId marbleShapes[MAX_MARBLES];20+static int marblesAlive[MAX_MARBLES];1921 2022 2123 @@ -36,7 +38,7 @@ void ph_init(float gravityY) {
3638 world = b3CreateWorld(&def);3739 shapeDef = b3DefaultShapeDef();3840 for (int i = 0; i < MAX_REFS; i++) refUsed[i] = 0;39- marbleAlive = 0;41+ for (int i = 0; i < MAX_MARBLES; i++) marblesAlive[i] = 0;4042 }4143 4244 // --- static geometry, one body per stream ref ------------------------------@@ -153,41 +155,52 @@ void ph_remove_static(int key) {
153155 }154156 }155157 156-// --- the marble ------------------------------------------------------------158+// --- the marbles (M15: an array — peers are real bodies) -------------------157159 160+// Spawn (or respawn) marble `idx`. World gravity stays FIXED at the init161+// vector — per-marble gravity zones are delta forces applied by the162+// driver (one global vector cannot serve N marbles in different zones).158163 EMSCRIPTEN_KEEPALIVE159164 void ph_spawn_marble(165+ int idx,160166 double x, double y, double z,161167 float vx, float vy, float vz, float radius) {162- if (marbleAlive) b3DestroyBody(marble);168+ if (idx < 0 || idx >= MAX_MARBLES) return;169+ if (marblesAlive[idx]) b3DestroyBody(marbles[idx]);163170 b3BodyDef def = b3DefaultBodyDef();164171 def.type = b3_dynamicBody;165172 def.position = (b3Pos){x, y, z};166173 def.linearVelocity = (b3Vec3){vx, vy, vz};167- marble = b3CreateBody(world, &def);174+ marbles[idx] = b3CreateBody(world, &def);168175 b3Sphere sphere = {{0, 0, 0}, radius};169176 b3ShapeDef sd = b3DefaultShapeDef();170177 // Marble Blast: bounceRestitution 0.5 (we run a touch lower — the void171- // has no shock-absorber powerup yet), friction 0.7.178+ // has no shock-absorber powerup yet), friction 0.7. Marble-marble179+ // restitution combines via MAX -> 0.35, the pack-friendly range.172180 sd.baseMaterial.restitution = 0.35f;173181 sd.baseMaterial.friction = 0.7f;174- marbleShape = b3CreateSphereShape(marble, &sd, &sphere);175- marbleAlive = 1;182+ marbleShapes[idx] = b3CreateSphereShape(marbles[idx], &sd, &sphere);183+ marblesAlive[idx] = 1;184+}185+186+EMSCRIPTEN_KEEPALIVE187+void ph_destroy_marble(int idx) {188+ if (idx < 0 || idx >= MAX_MARBLES || !marblesAlive[idx]) return;189+ b3DestroyBody(marbles[idx]);190+ marblesAlive[idx] = 0;176191 }177192 178-// Shock-absorber zones (M11): the shared rail law overrides the marble's179-// restitution near hard landings (Marble Blast: shock absorber 0.01) and180-// restores it after. Spawn resets to the 0.35 default.193+// Shock-absorber zones (M11): the shared rail law overrides a marble's194+// restitution near hard landings and restores it after. Spawn resets to195+// the 0.35 default.181196 EMSCRIPTEN_KEEPALIVE182-void ph_marble_restitution(float restitution) {183- if (!marbleAlive) return;184- b3Shape_SetRestitution(marbleShape, restitution);197+void ph_marble_restitution(int idx, float restitution) {198+ if (idx < 0 || idx >= MAX_MARBLES || !marblesAlive[idx]) return;199+ b3Shape_SetRestitution(marbleShapes[idx], restitution);185200 }186201 187-// Gravity rotation zones (M13): the rail law tilts the WORLD gravity188-// vector while the marble rides a gravity zone (Box3D has no up-direction189-// concept — b3World_SetGravity is the whole feature). The driver restores190-// the plain -Y vector when the zone ends.202+// Kept for compatibility (spike-era tools); drivers no longer tilt the203+// world — gravity zones are per-marble delta forces now.191204 EMSCRIPTEN_KEEPALIVE192205 void ph_gravity(float gx, float gy, float gz) {193206 b3World_SetGravity(world, (b3Vec3){gx, gy, gz});@@ -198,27 +211,28 @@ void ph_gravity(float gx, float gy, float gz) {
198211 // nothing; deterministic leave-speed is what lets an autopilot PLAN a199212 // jump arc before committing).200213 EMSCRIPTEN_KEEPALIVE201-void ph_marble_jump(float nx, float ny, float nz, float target) {202- if (!marbleAlive) return;203- b3Vec3 v = b3Body_GetLinearVelocity(marble);214+void ph_marble_jump(int idx, float nx, float ny, float nz, float target) {215+ if (idx < 0 || idx >= MAX_MARBLES || !marblesAlive[idx]) return;216+ b3Vec3 v = b3Body_GetLinearVelocity(marbles[idx]);204217 float sv = v.x * nx + v.y * ny + v.z * nz;205218 if (sv >= target) return;206219 float add = target - sv;207220 v.x += nx * add;208221 v.y += ny * add;209222 v.z += nz * add;210- b3Body_SetLinearVelocity(marble, v);223+ b3Body_SetLinearVelocity(marbles[idx], v);211224 }212225 213226 EMSCRIPTEN_KEEPALIVE214-float ph_marble_mass(void) {215- return marbleAlive ? b3Body_GetMass(marble) : 0.0f;227+float ph_marble_mass(int idx) {228+ if (idx < 0 || idx >= MAX_MARBLES || !marblesAlive[idx]) return 0.0f;229+ return b3Body_GetMass(marbles[idx]);216230 }217231 218232 EMSCRIPTEN_KEEPALIVE219-void ph_marble_force(float fx, float fy, float fz) {220- if (!marbleAlive) return;221- b3Body_ApplyForceToCenter(marble, (b3Vec3){fx, fy, fz}, true);233+void ph_marble_force(int idx, float fx, float fy, float fz) {234+ if (idx < 0 || idx >= MAX_MARBLES || !marblesAlive[idx]) return;235+ b3Body_ApplyForceToCenter(marbles[idx], (b3Vec3){fx, fy, fz}, true);222236 }223237 224238 EMSCRIPTEN_KEEPALIVE@@ -228,11 +242,11 @@ void ph_step(float dt, int substeps) {
228242 229243 // [px py pz vx vy vz qx qy qz qw] written to `out` (10 doubles).230244 EMSCRIPTEN_KEEPALIVE231-void ph_marble_state(double* out) {232- if (!marbleAlive) return;233- b3Pos p = b3Body_GetPosition(marble);234- b3Vec3 v = b3Body_GetLinearVelocity(marble);235- b3WorldTransform t = b3Body_GetTransform(marble);245+void ph_marble_state(int idx, double* out) {246+ if (idx < 0 || idx >= MAX_MARBLES || !marblesAlive[idx]) return;247+ b3Pos p = b3Body_GetPosition(marbles[idx]);248+ b3Vec3 v = b3Body_GetLinearVelocity(marbles[idx]);249+ b3WorldTransform t = b3Body_GetTransform(marbles[idx]);236250 out[0] = p.x;237251 out[1] = p.y;238252 out[2] = p.z;@@ -0,0 +1,231 @@
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+ */13+import { assert, assertEquals } from "@std/assert";14+import { EndlessWorld } from "../src/gen/endless/world.ts";15+import {16+ bumperProxies,17+ channelProxies,18+ islandProxies,19+ wideScalesOf,20+} from "../src/gen/endless/proxies.ts";21+import {22+ MARBLE_RADIUS,23+ PhysicsEngine,24+ type PhysicsModule,25+} from "../src/client/physics/engine.ts";26+import {27+ concatRails,28+ gapSegmentsOf,29+ type Rail,30+ railForSegment,31+ stationGravityDelta,32+ stationRestitution,33+} from "../src/client/physics/rail.ts";34+import {35+ autopilotForce,36+ type Neighbor,37+ type Pilot,38+ pilotFor,39+ pilotOffset,40+ wantJump,41+} from "../src/client/physics/autopilot.ts";42+import type { DecorCatalog } from "../src/gen/decor.ts";43+44+const catalog: DecorCatalog = {45+ packs: [46+ {47+ slug: "nature-kit",48+ ids: JSON.parse(Deno.readTextFileSync(49+ new URL("../assets/nature-kit.manifest.json", import.meta.url),50+ )).pieces.map((p: { id: string }) => p.id),51+ },52+ ],53+};54+55+const factory =56+ (await import(new URL("../static/dist/box3d.mjs", import.meta.url).href))57+ .default as () => Promise<PhysicsModule>;58+59+interface RaceResult {60+ order: number[];61+ s: number[];62+ minPair: number;63+ fell: boolean[];64+ hash: bigint;65+}66+67+interface TestRacer {68+ idx: number;69+ pilot: Pilot;70+ nearest: number;71+ s: number;72+ simT: number;73+}74+75+function 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+ ...channelProxies(seg.path, true, gapSegmentsOf(seg), wideScalesOf(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+ });115+ }116+ const mass = engine.massOf(0);117+ let minPair = Infinity;118+ const fell = new Array<boolean>(n).fill(false);119+ let hash = 1469598103934665603n;120+ const FNV = 1099511628211n;121+ const mix = (v: number) => {122+ const q = BigInt(Math.round(v * 1e6)) & 0xffffffffffffn;123+ hash = ((hash ^ q) * FNV) & 0xffffffffffffffffn;124+ };125+126+ const totalSteps = Math.round(simSeconds * 60);127+ for (let step = 0; step < totalSteps; step++) {128+ engine.step(1 / 60, () => {129+ const states = racers.map((r) => engine.state(r.idx));130+ for (const [ri, r] of racers.entries()) {131+ if (fell[ri]) continue;132+ const st = states[ri];133+ let bestD = Infinity;134+ const lo = Math.max(0, r.nearest - 30);135+ const hi = Math.min(rail.pts.length - 1, r.nearest + 60);136+ for (let i = lo; i <= hi; i++) {137+ const p = rail.pts[i];138+ const d = (p[0] - st.pos[0]) ** 2 + (p[1] - st.pos[1]) ** 2 +139+ (p[2] - st.pos[2]) ** 2;140+ if (d < bestD) {141+ bestD = d;142+ r.nearest = i;143+ }144+ }145+ engine.setRestitution(r.idx, stationRestitution(rail, r.nearest));146+ r.simT += 1 / 60;147+ r.pilot.offset = pilotOffset(r.pilot, r.simT);148+ const others: Neighbor[] = [];149+ for (const [oi, o] of racers.entries()) {150+ if (o === r || fell[oi]) continue;151+ others.push({ pos: states[oi].pos, bravery: o.pilot.bravery });152+ }153+ const f = autopilotForce(154+ rail,155+ r.nearest,156+ st.pos,157+ st.vel,158+ mass,159+ null,160+ r.pilot,161+ undefined,162+ others,163+ );164+ const gd = stationGravityDelta(rail, r.nearest, mass);165+ const fx = (f?.[0] ?? 0) + (gd?.[0] ?? 0);166+ const fy = (f?.[1] ?? 0) + (gd?.[1] ?? 0);167+ const fz = (f?.[2] ?? 0) + (gd?.[2] ?? 0);168+ if (fx !== 0 || fy !== 0 || fz !== 0) {169+ engine.force(r.idx, fx, fy, fz);170+ }171+ const j = wantJump(rail, r.nearest, st.pos, st.vel, null, r.pilot);172+ if (j) engine.jump(r.idx, j[0], j[1]);173+ }174+ });175+ const states = racers.map((r) => engine.state(r.idx));176+ for (const [ri, r] of racers.entries()) {177+ if (fell[ri]) continue;178+ r.s = Math.max(r.s, rail.s[r.nearest]);179+ if (states[ri].pos[1] < lowest - 8) fell[ri] = true;180+ for (let oi = ri + 1; oi < n; oi++) {181+ if (fell[oi]) continue;182+ const a = states[ri].pos;183+ const b = states[oi].pos;184+ const d = Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);185+ if (d < minPair) minPair = d;186+ }187+ if (step % 20 === 0) {188+ mix(states[ri].pos[0]);189+ mix(states[ri].pos[1]);190+ mix(states[ri].pos[2]);191+ }192+ }193+ }194+ for (let i = 0; i < segments; i++) engine.removeSegment(i);195+ for (let i = 0; i < n; i++) engine.destroyMarble(i);196+ const order = racers.map((r) => r.idx).sort(197+ (a, b) => racers[b].s - racers[a].s || a - b,198+ );199+ return { order, s: racers.map((r) => r.s), minPair, fell, hash };200+}201+202+Deno.test("M15 race: the pack progresses, touches, and finishes deterministically", async () => {203+ const module_ = await factory();204+ const N = 4;205+ const a = race("det-seed", N, 4, 60, module_);206+ // Pack progress: nobody falls out of the world; everyone clears the207+ // early segments (goal = segment 2's start, generous for laggards who208+ // spent time shoving).209+ const w = new EndlessWorld("det-seed", catalog);210+ const goal = w.get(2).startS;211+ assert(a.fell.every((f) => !f), `racers fell: ${JSON.stringify(a.fell)}`);212+ assert(213+ a.s.every((s) => s >= goal),214+ `laggards below goal ${goal.toFixed(1)}: ${215+ a.s.map((s) => s.toFixed(1)).join(", ")216+ }`,217+ );218+ // Interaction is real: the pack came within contact range at least once219+ // (2 radii + a hair).220+ assert(221+ a.minPair <= MARBLE_RADIUS * 2 + 0.05,222+ `no marble-marble encounter (min pair distance ${a.minPair.toFixed(3)})`,223+ );224+225+ // Determinism: a fresh module, the same seed → identical finish order226+ // and trajectory hash.227+ const module2 = await factory();228+ const b = race("det-seed", N, 4, 60, module2);229+ assertEquals(a.order, b.order);230+ assertEquals(a.hash, b.hash);231+});@@ -29,6 +29,7 @@ import {
2929 type Rail,3030 railForSegment,3131 stationGravity,32+ stationGravityDelta,3233 stationRestitution,3334 } from "../src/client/physics/rail.ts";3435 import {@@ -105,6 +106,7 @@ function driveWith(
105106 const start = rail.pts[spawnIdx];106107 const t0 = rail.tans[spawnIdx];107108 engine.spawnMarble(109+ 0,108110 [start[0], start[1] + 0.4, start[2]],109111 [t0[0] * 2.2, 0, t0[2] * 2.2],110112 );@@ -146,8 +148,7 @@ function driveWith(
146148 nearest = i;147149 }148150 }149- engine.setRestitution(stationRestitution(rail, nearest));150- engine.setGravity(stationGravity(rail, nearest));151+ engine.setRestitution(0, stationRestitution(rail, nearest));151152 simT += 1 / 60;152153 pilot.offset = pilotOffset(pilot, simT);153154 const f = autopilotForce(@@ -159,9 +160,15 @@ function driveWith(
159160 null,160161 pilot,161162 );162- if (f) engine.force(f[0], f[1], f[2]);163+ // Gravity zones as per-marble delta forces (M15 — world gravity is164+ // global and fixed).165+ const gd = stationGravityDelta(rail, nearest, engine.mass);166+ const fx = (f?.[0] ?? 0) + (gd?.[0] ?? 0);167+ const fy = (f?.[1] ?? 0) + (gd?.[1] ?? 0);168+ const fz = (f?.[2] ?? 0) + (gd?.[2] ?? 0);169+ if (fx !== 0 || fy !== 0 || fz !== 0) engine.force(0, fx, fy, fz);163170 const j = wantJump(rail, nearest, st.pos, st.vel, null, pilot);164- if (j) engine.jump(j[0], j[1]);171+ if (j) engine.jump(0, j[0], j[1]);165172 });166173 st = engine.state();167174 reachedS = Math.max(reachedS, rail.s[nearest]);@@ -30,7 +30,7 @@ emcc "$ROOT/src/client/physics/shim.c" \
3030 -O2 \3131 -sMODULARIZE=1 -sEXPORT_ES6=1 -sENVIRONMENT=web,node \3232 -sALLOW_MEMORY_GROWTH=1 \33- -sEXPORTED_FUNCTIONS=_ph_init,_ph_begin_static,_ph_add_box,_ph_add_slab,_ph_add_mesh,_ph_add_bumper,_ph_end_static,_ph_remove_static,_ph_spawn_marble,_ph_marble_force,_ph_marble_jump,_ph_marble_mass,_ph_marble_restitution,_ph_gravity,_ph_step,_ph_marble_state,_malloc,_free \33+ -sEXPORTED_FUNCTIONS=_ph_init,_ph_begin_static,_ph_add_box,_ph_add_slab,_ph_add_mesh,_ph_add_bumper,_ph_end_static,_ph_remove_static,_ph_spawn_marble,_ph_destroy_marble,_ph_marble_force,_ph_marble_jump,_ph_marble_mass,_ph_marble_restitution,_ph_gravity,_ph_step,_ph_marble_state,_malloc,_free \3434 -sEXPORTED_RUNTIME_METHODS=HEAPF64,HEAPF32,HEAP32 \3535 -o "$ROOT/static/dist/box3d.mjs"3636 @@ -18,7 +18,7 @@ import {
1818 gapSegmentsOf,1919 railForSegment,2020 stationForce,21- stationGravity,21+ stationGravityDelta,2222 stationRestitution,2323 } from "../../src/client/physics/rail.ts";2424 import {@@ -58,6 +58,7 @@ for (let i = 0; i < 8; i++) {
5858 }5959 const rail = concatRails(rails);6060 engine.spawnMarble(61+ 0,6162 [rail.pts[0][0], rail.pts[0][1] + 0.4, rail.pts[0][2]],6263 [rail.tans[0][0] * 2.2, 0, rail.tans[0][2] * 2.2],6364 );@@ -82,12 +83,13 @@ for (let step = 0; step < 240 * 60; step++) {
8283 }8384 }8485 const st = engine.state();85- engine.setRestitution(stationRestitution(rail, nearest));86- engine.setGravity(stationGravity(rail, nearest));86+ engine.setRestitution(0, stationRestitution(rail, nearest));87+ const gd = stationGravityDelta(rail, nearest, engine.mass);88+ let f: [number, number, number] | null;8789 if (law === autopilotForce) {8890 simT += 1 / 60;8991 pilot.offset = pilotOffset(pilot, simT);90- const f = autopilotForce(92+ f = autopilotForce(9193 rail,9294 nearest,9395 st.pos,@@ -96,13 +98,15 @@ for (let step = 0; step < 240 * 60; step++) {
9698 null,9799 pilot,98100 );99- if (f) engine.force(f[0], f[1], f[2]);100101 const j = wantJump(rail, nearest, st.pos, st.vel, null, pilot);101- if (j) engine.jump(j[0], j[1]);102+ if (j) engine.jump(0, j[0], j[1]);102103 } else {103- const f = law(rail, nearest, st.pos, st.vel, engine.mass);104- if (f) engine.force(f[0], f[1], f[2]);104+ f = law(rail, nearest, st.pos, st.vel, engine.mass);105105 }106+ const fx = (f?.[0] ?? 0) + (gd?.[0] ?? 0);107+ const fy = (f?.[1] ?? 0) + (gd?.[1] ?? 0);108+ const fz = (f?.[2] ?? 0) + (gd?.[2] ?? 0);109+ if (fx !== 0 || fy !== 0 || fz !== 0) engine.force(0, fx, fy, fz);106110 });107111 {108112 const stq = engine.state();@@ -5,7 +5,7 @@ async function drop(name: string, vertices: number[], indices: number[]) {
55 const m = await factory();66 const engine = new PhysicsEngine(m);77 engine.addSegment(0, [{ kind: "mesh", vertices, indices }]);8- engine.spawnMarble([0.5, 1, 0.2], [0, 0, 0]);8+ engine.spawnMarble(0, [0.5, 1, 0.2], [0, 0, 0]);99 for (let i = 0; i < 120; i++) engine.step(1 / 60);1010 const st = engine.state();1111 console.log(`${name}: y=${st.pos[1].toFixed(3)} ${st.pos[1] > 0 ? "OK" : "FELL"}`);@@ -41,7 +41,7 @@ for (const [name, path, spawn, vel] of [
4141 const mesh = proxies.find((p) => p.kind === "mesh");4242 console.log(`${name}: proxies=${proxies.length} meshVerts=${mesh && mesh.kind === "mesh" ? mesh.vertices.length / 3 : 0} tris=${mesh && mesh.kind === "mesh" ? mesh.indices.length / 3 : 0}`);4343 engine.addSegment(0, proxies);44- engine.spawnMarble(spawn as [number, number, number], vel as [number, number, number]);44+ engine.spawnMarble(0, spawn as [number, number, number], vel as [number, number, number]);4545 for (let i = 0; i < 300; i++) engine.step(1 / 60);4646 const st = engine.state();4747 console.log(` after 5s: pos=(${st.pos.map((v) => v.toFixed(2))}) — ${st.pos[1] > 0 ? "RESTING/ROLLING ✓" : "FELL THROUGH ✗"}`);