Shim ph_marble_jump(n, target): MBHaxe's top-up contract (velocity along n SET to target, never added — deterministic leave-speed so the pilot can price an arc; wasm rebuilt). autopilot wantJump: fires ONLY at a gap lip (one station before flight — earlier there is floor under the arc and a premature version helicoptered marbles off the line), only when the ballistic arc from the marble's ACTUAL state misses the landing, only when a top-up <= JUMP_MAX can fix it; rising-velocity guard makes it fire-once. JUMP_CLEAR=0: rescues aim exactly at the landing — the catch pan's 1.4u reach is the margin (any positive clearance topped up even priced launches). Client also gains a one-shot stall-escape hop at 2.5s before the 6s respawn net (visible intent). Finding recorded: planner-priced gaps never NEED the rescue (M11 pans are undershoot-tolerant by construction) — the decision's value is unpriced situations + spectacle, so the gate is rule-based: new tests/autopilot_test.ts pins the accelerate contract (momentum-sacred coast, envelope brake, emergent climb authority, icy grip, weak air control) and the full wantJump decision table on synthetic rails. 112 tests green; 90-seed fuzz unchanged (0 falls, the 2 known generation-bug wedges).
- commit
- 2a22e08c7d02a04342eda9b8e8a825273e4b6a90
- parent
- 21441f9b17c0f95d42f5788cc5bb66a143ba1a15
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 32 | +++++- |
| M | src/ | 71 | +++++++++++ |
| M | src/ | 9 | + |
| M | src/ | 17 | +++ |
| A | tests/ | 187 | ++++++++++++++++++++++++++++++ |
| M | tests/ | 42 | +++++++- |
| M | tools/ | 2 | +- |
| M | tools/ | 6 | +- |
@@ -37,7 +37,7 @@ import {
3737 stationGravity,3838 stationRestitution,3939 } from "./physics/rail.ts";40-import { autopilotForce } from "./physics/autopilot.ts";40+import { autopilotForce, wantJump } from "./physics/autopilot.ts";4141 import {4242 bumperProxies,4343 channelProxies,@@ -320,6 +320,10 @@ class Session {
320320 /** Audio cue edge detectors (launch/land/boost transitions). */321321 private wasFlight = false;322322 private wasSurge = false;323+ private wasHop = false;324+ /** One escape hop per stall episode (M14b): a parked marble tries a325+ * jump before the respawn net takes it. */326+ private stallHopped = false;323327 private focus: { center: THREE.Vector3; radius: number } | null = null;324328 325329 private readonly trail = createTrail();@@ -716,15 +720,26 @@ class Session {
716720 // the headless tests); lookahead chains into the next segment.717721 engine.setRestitution(stationRestitution(rail, this.nearest.idx));718722 engine.setGravity(stationGravity(rail, this.nearest.idx));723+ const nextRail = this.rails.get(this.nearest.seg + 1) ?? null;719724 const f = this.law(720725 rail,721726 this.nearest.idx,722727 st.pos,723728 st.vel,724729 engine.mass,725- this.rails.get(this.nearest.seg + 1) ?? null,730+ nextRail,726731 );727732 if (f) engine.force(f[0], f[1], f[2]);733+ // The jump decision (M14b, autopilot only): top-up at a gap lip when734+ // the actual approach is too slow for the priced arc.735+ if (this.law === autopilotForce) {736+ const j = wantJump(rail, this.nearest.idx, st.pos, st.vel, nextRail);737+ if (j) {738+ engine.jump(j[0], j[1]);739+ if (!this.wasHop) this.audio.cue("launch");740+ this.wasHop = true;741+ } else this.wasHop = false;742+ }728743 });729744 const st = engine.state();730745 this.marbleObj.position.set(st.pos[0], st.pos[1], st.pos[2]);@@ -770,8 +785,20 @@ class Session {
770785 if (this.marbleS > this.lastProgressS + 0.4) {771786 this.lastProgressS = this.marbleS;772787 this.stallClock = 0;788+ this.stallHopped = false;773789 } else {774790 this.stallClock += dt;791+ // Escape hop (M14b, autopilot only): one try before the net — a792+ // parked marble visibly ATTEMPTS the obstacle; if the hop frees it,793+ // progress resets the clock, else the respawn still comes.794+ if (795+ this.stallClock > 2.5 && !this.stallHopped &&796+ this.law === autopilotForce797+ ) {798+ this.stallHopped = true;799+ this.engine?.jump([0, 1, 0], 4.2);800+ this.audio.cue("launch");801+ }775802 if (this.stallClock > 6) this.respawn();776803 }777804 }@@ -814,6 +841,7 @@ class Session {
814841 this.deaths++;815842 this.audio.cue("fall");816843 this.stallClock = 0;844+ this.stallHopped = false;817845 this.lastProgressS = 0;818846 let target = this.currentIndex;819847 if (this.lastRespawnSeg === target) {@@ -82,6 +82,77 @@ const AIR_HEIGHT = 0.45;
8282 const clamp = (v: number, lo: number, hi: number): number =>8383 v < lo ? lo : v > hi ? hi : v;8484 85+/** Max top-up jump speed (≈0.7 world of height at g 20 — a hop, not a86+ * rocket; a rescue needing more than this is a respawn, not a jump). */87+const JUMP_MAX = 5.2;88+/** Landing clearance: the rescue aims exactly at the landing point — the89+ * catch pan reaches ~1.4 under the flight path (M11), so the pan IS the90+ * margin; any positive clearance here made the pilot top-up even priced91+ * launches. */92+const JUMP_CLEAR = 0;93+94+/**95+ * The jump decision (M14b): approaching a flight gap too slow for the96+ * priced ballistic arc, the pilot may TOP-UP its vertical speed at the97+ * lip so the longer hang time still crosses — computed from the marble's98+ * ACTUAL state against the landing point, committed only when the99+ * resulting arc lands inside the corridor. Returns [normal, targetSpeed]100+ * for PhysicsEngine.jump, or null. Deterministic and repeat-safe (the101+ * top-up contract makes held calls no-ops).102+ */103+export function wantJump(104+ rail: Rail,105+ idx: number,106+ pos: readonly [number, number, number],107+ vel: readonly [number, number, number],108+ nextRail: Rail | null = null,109+): [[number, number, number], number] | null {110+ const t = rail.tans[idx];111+ if (!t) return null;112+ if (rail.mods[idx]?.flight) return null; // already committed113+ if (pos[1] - rail.pts[idx][1] >= 0.3) return null; // must be rolling114+ if (vel[1] > 1.0) return null; // already rising (jumped/bounced) — once115+ const n = rail.tans.length;116+ const modAt = (i: number): RailMod | null =>117+ i < n ? rail.mods[i] : nextRail ? nextRail.mods[i - n] ?? null : null;118+ const ptAt = (i: number): readonly [number, number, number] | null =>119+ rail.pts[i] ?? (nextRail ? nextRail.pts[i - n] ?? null : null);120+ // Decide ONLY at the lip — the last rolling station before the flight121+ // run. Any earlier there is still floor under the arc and the ballistic122+ // test is meaningless (a premature version of this fired 2u early and123+ // helicoptered marbles off the line).124+ if (!modAt(idx + 1)?.flight) return null;125+ let land = idx + 2;126+ while (modAt(land)?.flight) land++;127+ const ld = ptAt(land);128+ if (!ld) return null;129+ const gravScale = modAt(idx + 1)?.gravScale ?? 1;130+ const gEff = -GRAVITY_Y * gravScale;131+ const gx = ld[0] - pos[0];132+ const gz = ld[2] - pos[2];133+ const G = Math.hypot(gx, gz);134+ if (G < 0.3) return null;135+ const drop = ld[1] - pos[1]; // negative: landing below136+ const vh = (vel[0] * gx + vel[2] * gz) / G;137+ if (vh < 0.8) return null; // can't cross at any hang time worth having138+ const tF = G / vh;139+ const yAt = vel[1] * tF - 0.5 * gEff * tF * tF;140+ if (yAt >= drop + JUMP_CLEAR) return null; // priced flight already works141+ const vyNeed = (drop + JUMP_CLEAR + 0.5 * gEff * tF * tF) / tF;142+ if (vyNeed <= vel[1] || vyNeed > JUMP_MAX) return null;143+ // Slope-aware floor normal from the tangent plane (approach cells are144+ // near-flat by construction, so n ≈ up and the top-up is ≈ vertical).145+ let nx = -t[0] * t[1];146+ let ny = 1 - t[1] * t[1];147+ let nz = -t[2] * t[1];148+ const nl = Math.hypot(nx, ny, nz);149+ if (nl < 1e-6 || ny <= 0.5) return null;150+ nx /= nl;151+ ny /= nl;152+ nz /= nl;153+ return [[nx, ny, nz], vyNeed / ny];154+}155+85156 /** Per-step introspection (debug harnesses pass one in). */86157 export interface PilotDebug {87158 mode?: string;@@ -54,6 +54,7 @@ export interface PhysicsModule {
5454 restitution: number,5555 ): void;5656 _ph_marble_force(fx: number, fy: number, fz: number): void;57+ _ph_marble_jump(nx: number, ny: number, nz: number, target: number): void;5758 _ph_marble_mass(): number;5859 _ph_marble_restitution(restitution: number): void;5960 _ph_gravity(gx: number, gy: number, gz: number): void;@@ -283,6 +284,14 @@ export class PhysicsEngine {
283284 this.m._ph_marble_force(fx, fy, fz);284285 }285286 287+ /** Top-up jump (M14b): velocity along `n` is SET to `target`, never288+ * added — deterministic leave-speed (the MBHaxe jump contract), so the289+ * autopilot can price an arc before committing. Safe to call repeatedly290+ * 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);293+ }294+286295 state(): MarbleState {287296 this.m._ph_marble_state(this.out);288297 const h = this.m.HEAPF64;@@ -193,6 +193,23 @@ void ph_gravity(float gx, float gy, float gz) {
193193 b3World_SetGravity(world, (b3Vec3){gx, gy, gz});194194 }195195 196+// Jump (M14b): Marble Blast's top-up — velocity along `n` is SET to197+// `target`, never added (MBHaxe: jumping while already rising does198+// nothing; deterministic leave-speed is what lets an autopilot PLAN a199+// jump arc before committing).200+EMSCRIPTEN_KEEPALIVE201+void ph_marble_jump(float nx, float ny, float nz, float target) {202+ if (!marbleAlive) return;203+ b3Vec3 v = b3Body_GetLinearVelocity(marble);204+ float sv = v.x * nx + v.y * ny + v.z * nz;205+ if (sv >= target) return;206+ float add = target - sv;207+ v.x += nx * add;208+ v.y += ny * add;209+ v.z += nz * add;210+ b3Body_SetLinearVelocity(marble, v);211+}212+196213 EMSCRIPTEN_KEEPALIVE197214 float ph_marble_mass(void) {198215 return marbleAlive ? b3Body_GetMass(marble) : 0.0f;@@ -0,0 +1,187 @@
1+/**2+ * Autopilot contract tests (M14a/M14b) — rule-based, no wasm: the control3+ * law and the jump decision are pure functions of (rail, state), so their4+ * contracts are asserted directly on synthetic rails.5+ */6+import { assert, assertEquals } from "@std/assert";7+import type { Rail } from "../src/client/physics/rail.ts";8+import {9+ autopilotForce,10+ type PilotDebug,11+ wantJump,12+} from "../src/client/physics/autopilot.ts";13+14+/** Synthetic rail builder: stations every 0.5 along +x from the origin,15+ * with per-station y and mods. */16+function railOf(17+ ys: number[],18+ mods: (Rail["mods"][number])[] = [],19+): Rail {20+ const rail: Rail = { pts: [], tans: [], s: [], mods: [] };21+ let s = 0;22+ for (let i = 0; i < ys.length; i++) {23+ const dy = (ys[i + 1] ?? ys[i]) - ys[i];24+ const l = Math.hypot(0.5, dy) || 1;25+ rail.pts.push([i * 0.5, ys[i], 0]);26+ rail.tans.push([0.5 / l, dy / l, 0]);27+ rail.s.push(s);28+ rail.mods.push(mods[i] ?? null);29+ s += l;30+ }31+ return rail;32+}33+34+const flat = (n: number): number[] => new Array(n).fill(0);35+36+Deno.test("autopilot: momentum is sacred — no force between wishspeed and the envelope", () => {37+ const rail = railOf(flat(40));38+ // Rolling at 5 on a flat straight: above cruise 3.2, below VMAX 9 —39+ // the old law would brake to target; the autopilot must coast.40+ const f = autopilotForce(rail, 10, [5, 0.1, 0], [5, 0, 0], 1);41+ assertEquals(f, null);42+});43+44+Deno.test("autopilot: brakes above the swept envelope before a tight bend", () => {45+ // Flat run into a hard 90° yaw bend (r ≈ 1): stations turn MAX yaw.46+ const rail: Rail = { pts: [], tans: [], s: [], mods: [] };47+ let s = 0;48+ let x = 0, z = 0;49+ for (let i = 0; i < 40; i++) {50+ const a = Math.min(Math.PI / 2, Math.max(0, (i - 20) * 0.5));51+ rail.pts.push([x, 0, z]);52+ rail.tans.push([Math.cos(a), 0, Math.sin(a)]);53+ rail.s.push(s);54+ rail.mods.push(null);55+ x += Math.cos(a) * 0.5;56+ z += Math.sin(a) * 0.5;57+ s += 0.5;58+ }59+ // Arriving hot (8.5) with the bend inside the sweep window: brake.60+ const dbg: PilotDebug = {};61+ const f = autopilotForce(62+ rail,63+ 14,64+ [rail.pts[14][0], 0.1, rail.pts[14][2]],65+ [8.5, 0, 0],66+ 1,67+ null,68+ undefined,69+ dbg,70+ );71+ assert(dbg.mode === "brake", `expected brake, got ${dbg.mode}`);72+ assert(f !== null && f[0] < 0, "brake force must oppose travel");73+});74+75+Deno.test("autopilot: climb authority exceeds along-slope gravity (emergent carry)", () => {76+ // 0.5-per-station climb (ty ≈ 0.45, the steepest slant family) with the77+ // marble slowed to 1.0 — the deficit-driven authority must beat78+ // g·ty ≈ 9 so the marble climbs instead of cycling at the base.79+ const ys: number[] = [];80+ for (let i = 0; i < 30; i++) ys.push(i < 10 ? 0 : (i - 10) * 0.25);81+ const rail = railOf(ys);82+ const idx = 14; // mid-climb83+ const t = rail.tans[idx];84+ const f = autopilotForce(85+ rail,86+ idx,87+ [rail.pts[idx][0], rail.pts[idx][1] + 0.1, 0],88+ [t[0] * 1.0, t[1] * 1.0, 0],89+ 1,90+ );91+ assert(f !== null, "climb must produce force");92+ const along = f[0] * t[0] + f[1] * t[1] + f[2] * t[2];93+ assert(94+ along > -(-20) * t[1],95+ `authority ${along.toFixed(1)} must exceed slope gravity ${96+ (20 * t[1]).toFixed(1)97+ }`,98+ );99+});100+101+Deno.test("autopilot: icy grip caps both response and ceiling", () => {102+ const rail = railOf(flat(40), flat(40).map(() => ({ icy: true })));103+ const dry = railOf(flat(40));104+ const fIcy = autopilotForce(rail, 10, [5, 0.1, 0], [0.5, 0, 0], 1);105+ const fDry = autopilotForce(dry, 10, [5, 0.1, 0], [0.5, 0, 0], 1);106+ assert(fIcy !== null && fDry !== null);107+ const aIcy = Math.hypot(...fIcy);108+ const aDry = Math.hypot(...fDry);109+ assert(110+ aIcy < aDry * 0.45,111+ `icy authority ${aIcy.toFixed(1)} must be a sliver of dry ${112+ aDry.toFixed(1)113+ }`,114+ );115+});116+117+Deno.test("autopilot: airborne control is weak and horizontal", () => {118+ const rail = railOf(flat(40));119+ // 1.2 above the rail floor: airborne — only the small air accel,120+ // horizontal only.121+ const f = autopilotForce(rail, 10, [5, 1.2, 0], [3, 2, 0], 1);122+ assert(f !== null);123+ assertEquals(f[1], 0);124+ assert(Math.hypot(...f) <= 5.01, "air authority must be ≤ AIR_ACCEL");125+});126+127+/** Gap rail at real jump proportions (M11: 2-world gap, 1.0 drop): 8 flat128+ * approach stations, 3 flight stations descending to the landing 1.0129+ * below, then a flat landing run. */130+function gapRail(): Rail {131+ const ys: number[] = [];132+ const mods: (Rail["mods"][number])[] = [];133+ for (let i = 0; i < 8; i++) {134+ ys.push(0);135+ mods.push(null);136+ }137+ // The dive begins INSIDE the flight window (real jump rails follow the138+ // reversed-slant piece), so the lip station's tangent stays flat.139+ for (const y of [0, -0.5, -1]) {140+ ys.push(y);141+ mods.push({ flight: true });142+ }143+ for (let i = 0; i < 8; i++) {144+ ys.push(-1);145+ mods.push(null);146+ }147+ return railOf(ys, mods);148+}149+150+Deno.test("wantJump: fires only at the lip, only on a rescuable undershoot", () => {151+ const rail = gapRail();152+ const lip = 7;153+ const lipPos: [number, number, number] = [154+ rail.pts[lip][0],155+ rail.pts[lip][1] + 0.1,156+ 0,157+ ];158+ // Moderate undershoot (4.2 vs the priced ≈6.1): the flat arc falls159+ // short of the landing → top-up, aimed to make the landing exactly.160+ const j = wantJump(rail, lip, lipPos, [4.2, 0, 0], null);161+ assert(j !== null, "undershooting lip arrival must jump");162+ const [n, target] = j;163+ assert(n[1] > 0.99, "approach is flat — normal ≈ up");164+ const land = rail.pts.findIndex((_, i) => i > lip && !rail.mods[i]?.flight);165+ const G = Math.hypot(rail.pts[land][0] - lipPos[0], -lipPos[2]);166+ const tF = G / 4.2;167+ const yAt = target * n[1] * tF - 10 * tF * tF;168+ assert(169+ yAt >= rail.pts[land][1] - lipPos[1] - 1e-6,170+ `topped-up arc lands at ${yAt.toFixed(2)}, below the landing`,171+ );172+173+ // Away from the lip: silent (there is still floor under the arc).174+ assertEquals(wantJump(rail, lip - 3, lipPos, [4.2, 0, 0], null), null);175+ // Priced arrival: the flight works — silent.176+ assertEquals(wantJump(rail, lip, lipPos, [6.1, 0, 0], null), null);177+ // Already rising (jumped/bounced): once only — silent.178+ assertEquals(wantJump(rail, lip, lipPos, [4.2, 1.5, 0], null), null);179+ // Airborne: silent.180+ assertEquals(181+ wantJump(rail, lip, [lipPos[0], 0.8, 0], [4.2, 0, 0], null),182+ null,183+ );184+ // Hopeless (creeping — the needed top-up exceeds JUMP_MAX): silent;185+ // the catch pan or the respawn net owns it.186+ assertEquals(wantJump(rail, lip, lipPos, [1.2, 0, 0], null), null);187+});@@ -31,7 +31,7 @@ import {
3131 stationGravity,3232 stationRestitution,3333 } from "../src/client/physics/rail.ts";34-import { autopilotForce } from "../src/client/physics/autopilot.ts";34+import { autopilotForce, wantJump } from "../src/client/physics/autopilot.ts";3535 import type { DecorCatalog } from "../src/gen/decor.ts";3636 3737 const catalog: DecorCatalog = {@@ -143,6 +143,8 @@ function driveWith(
143143 engine.setGravity(stationGravity(rail, nearest));144144 const f = autopilotForce(rail, nearest, st.pos, st.vel, engine.mass);145145 if (f) engine.force(f[0], f[1], f[2]);146+ const j = wantJump(rail, nearest, st.pos, st.vel);147+ if (j) engine.jump(j[0], j[1]);146148 });147149 st = engine.state();148150 reachedS = Math.max(reachedS, rail.s[nearest]);@@ -234,6 +236,44 @@ Deno.test("scenario jump-gap: cold entry launches, flies, and is caught (both ki
234236 }235237 });236238 239+Deno.test("scenario lip-undershoot rescue: the jump decision saves a slow arrival (M14b)", async () => {240+ module_ = await factory();241+ // Spawn COLD only ~3.5u before each gap's flight span — far too little242+ // boost runway to reach the priced launch speed. The autopilot must243+ // detect the undershoot AT THE LIP (wantJump ballistic test against the244+ // landing point) and top-up vertical speed so the longer hang time245+ // still crosses. This is the M14b contract: jumping is a DECISION from246+ // the marble's actual state, not a pre-priced launch.247+ for (const { seed, kind } of [248+ { seed: "grav-scan-0", kind: "propeller" },249+ { seed: "wide-scan-0", kind: "jump" },250+ ]) {251+ const w = new EndlessWorld(seed, catalog);252+ const rails: Rail[] = [];253+ for (let i = 0; i < 3; i++) rails.push(railForSegment(w.get(i)));254+ const rail = concatRails(rails);255+ const first = rail.mods.findIndex((m) => m?.flight);256+ assert(first >= 0, `${seed} early segments lost their ${kind}`);257+ let last = first;258+ while (rail.mods[last + 1]?.flight) last++;259+ const flightStart = rail.s[first];260+ const flightEnd = rail.s[last];261+ const r = drive(262+ seed,263+ 4,264+ 30,265+ flightEnd + 8,266+ Math.max(0, flightStart - 3.5),267+ );268+ assert(!r.fell, `${seed}: undershooting marble fell at the ${kind}`);269+ assert(270+ r.reachedS >= flightEnd + 7,271+ `${seed}: undershooting marble stalled at s=${r.reachedS.toFixed(1)} ` +272+ `(${kind} flight ${flightStart.toFixed(1)}–${flightEnd.toFixed(1)})`,273+ );274+ }275+});276+237277 Deno.test("scenario diamond-branch: both branches carry the marble through", async () => {238278 module_ = await factory();239279 // grav-scan-5 early segments host an island diamond (re-pinned after@@ -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_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_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 @@ -21,7 +21,7 @@ import {
2121 stationGravity,2222 stationRestitution,2323 } from "../../src/client/physics/rail.ts";24-import { autopilotForce } from "../../src/client/physics/autopilot.ts";24+import { autopilotForce, wantJump } from "../../src/client/physics/autopilot.ts";2525 2626 // LAW=assist runs the old choreographer for A/B; default is the autopilot.2727 const law = Deno.env.get("LAW") === "assist" ? stationForce : autopilotForce;@@ -79,6 +79,10 @@ for (let step = 0; step < 240 * 60; step++) {
7979 engine.setGravity(stationGravity(rail, nearest));8080 const f = law(rail, nearest, st.pos, st.vel, engine.mass);8181 if (f) engine.force(f[0], f[1], f[2]);82+ if (law === autopilotForce) {83+ const j = wantJump(rail, nearest, st.pos, st.vel);84+ if (j) engine.jump(j[0], j[1]);85+ }8286 });8387 {8488 const stq = engine.state();