Corridor law: within ±0.30 of the pilot's personal line the marble gets NO lateral correction (the wobble reads as alive); a ramped spring-damper shepherds it back between 0.30 and the 0.55 corridor edge before the wall does. Zone centering (boost/split/gravity) unchanged and overrides. Personality: pilotFor(seed, index) draws bravery (corner-speed margin 0.92-1.10), pursuit lookahead gain, jump eagerness, and a wander amplitude + phases from the label-derived stream pilot/N — adding marbles never perturbs earlier ones. The lateral offset target is a closed-form two-tone wander (deterministic under fixed stepping, no draw-order coupling) fed to the pursuit as the ridden line. All three drivers (client, tests, phys-deep) thread the pilot. Gates: 112 tests green (trajectory determinism holds with personality), 90-seed x 8-segment fuzz 0 falls 0 wedges.
- commit
- 6e62ce75070c81ab0d1307fbbd577c0a1ddbe622
- parent
- 78a530f4b9e170f51627676f9b6d03cdbb4105e5
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 65 | +++++++++++++++----- |
| M | src/ | 94 | ++++++++++++++++++++++++++++-- |
| M | tests/ | 23 | ++++++- |
| M | tools/ | 28 | ++++++++- |
@@ -24,6 +24,7 @@ import { EndlessWorld, KEEP_SEGMENTS } from "../gen/endless/world.ts";
2424 import { TRACK_SCALE } from "../gen/endless/scale.ts";2525 import { ExitAnim, SpawnAnim } from "./streamAnim.ts";2626 import {27+ FIXED_DT,2728 MARBLE_SCALE,2829 PhysicsEngine,2930 type PhysicsModule,@@ -37,7 +38,13 @@ import {
3738 stationGravity,3839 stationRestitution,3940 } from "./physics/rail.ts";40-import { autopilotForce, wantJump } from "./physics/autopilot.ts";41+import {42+ autopilotForce,43+ type Pilot,44+ pilotFor,45+ pilotOffset,46+ wantJump,47+} from "./physics/autopilot.ts";4148 import {4249 bumperProxies,4350 channelProxies,@@ -282,10 +289,12 @@ class Session {
282289 private engine: PhysicsEngine | null = null;283290 /** The per-step control law: the autopilot (M14a) unless ?autopilot=0284291 * falls back to the old assist choreographer for A/B comparison. */285- private readonly law =286- new URLSearchParams(location.search).get("autopilot") === "0"287- ? stationForce288- : autopilotForce;292+ private readonly useAutopilot =293+ 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;289298 private readonly rails = new Map<number, Rail>();290299 /** Active diamond branch per segment — respawns alternate it, so a291300 * fall's retry may ride the OTHER branch (respawn variety). */@@ -721,19 +730,42 @@ class Session {
721730 engine.setRestitution(stationRestitution(rail, this.nearest.idx));722731 engine.setGravity(stationGravity(rail, this.nearest.idx));723732 const nextRail = this.rails.get(this.nearest.seg + 1) ?? null;724- const f = this.law(725- rail,726- this.nearest.idx,727- st.pos,728- st.vel,729- engine.mass,730- nextRail,731- );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+ }732757 if (f) engine.force(f[0], f[1], f[2]);733758 // The jump decision (M14b, autopilot only): top-up at a gap lip when734759 // 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);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!,768+ );737769 if (j) {738770 engine.jump(j[0], j[1]);739771 if (!this.wasHop) this.audio.cue("launch");@@ -792,8 +824,7 @@ class Session {
792824 // parked marble visibly ATTEMPTS the obstacle; if the hop frees it,793825 // progress resets the clock, else the respawn still comes.794826 if (795- this.stallClock > 2.5 && !this.stallHopped &&796- this.law === autopilotForce827+ this.stallClock > 2.5 && !this.stallHopped && this.useAutopilot797828 ) {798829 this.stallHopped = true;799830 this.engine?.jump([0, 1, 0], 4.2);@@ -30,21 +30,63 @@
3030 */3131 import { RAIL_STEP, type Rail, type RailMod } from "./rail.ts";3232 import { GRAVITY_Y } from "./engine.ts";33+import { rngFamily } from "../../gen/rng.ts";3334 34-/** Per-marble controller personality (M14c fills the jitter in; the35- * defaults are the deterministic baseline every gate runs on). */35+/** Per-marble controller personality (M14c). The defaults are the36+ * neutral baseline; pilotFor() draws seed-stable jitter around them. */3637 export interface Pilot {3738 /** Corner-speed margin: multiplies the curvature-derived corner speed3839 * (not the target floor) — >1 corners braver, <1 more cautious. */3940 bravery: number;4041 /** Pursuit lookahead gain k in L = clamp(k·|v|, L_MIN, L_MAX). */4142 lookGain: number;42- /** Lateral offset target from the rail line (world units; OU-noise43- * driven in M14c — 0 rides the line). */43+ /** Jump-eagerness: multiplies the max top-up the pilot will attempt. */44+ eagerness: number;45+ /** Lateral offset target from the rail line (world units; drivers set46+ * this per step from pilotOffset() — 0 rides the line). */4447 offset: number;48+ /** Wander parameters (closed-form line-offset noise): amplitude and49+ * two seed-drawn phases. Amp 0 = no wander. */50+ wanderAmp: number;51+ phase1: number;52+ phase2: number;4553 }4654 47-export const DEFAULT_PILOT: Pilot = { bravery: 1, lookGain: 0.55, offset: 0 };55+export const DEFAULT_PILOT: Pilot = {56+ bravery: 1,57+ lookGain: 0.55,58+ eagerness: 1,59+ offset: 0,60+ wanderAmp: 0,61+ phase1: 0,62+ phase2: 0,63+};64+65+/** Seed-stable personality for marble `index` (label-derived stream —66+ * adding marbles never perturbs earlier ones). */67+export function pilotFor(seed: string, index = 0): Pilot {68+ const rng = rngFamily(seed, `pilot/${index}`);69+ return {70+ bravery: 0.92 + rng.float() * 0.18,71+ lookGain: 0.48 + rng.float() * 0.16,72+ eagerness: 0.9 + rng.float() * 0.2,73+ offset: 0,74+ wanderAmp: 0.1 + rng.float() * 0.1,75+ phase1: rng.float() * Math.PI * 2,76+ phase2: rng.float() * Math.PI * 2,77+ };78+}79+80+/** Lateral offset target at sim-time t — a slow two-tone wander (closed81+ * form: deterministic under fixed stepping, no draw-order coupling, and82+ * mean-reverting like an OU process without carrying state). Stays well83+ * inside the corridor dead zone. */84+export function pilotOffset(p: Pilot, t: number): number {85+ if (p.wanderAmp === 0) return 0;86+ return p.wanderAmp *87+ (0.65 * Math.sin(0.31 * t + p.phase1) +88+ 0.35 * Math.sin(0.093 * t + p.phase2));89+}4890 4991 /** Cruise target on an unmodified tile (the proven M11 value — boost5092 * pricing multiplies it, so jump launch speeds stay ≈ 6.1). */@@ -72,6 +114,12 @@ const A_BRAKE_MAX = 10;
72114 const AIR_ACCEL = 5;73115 const L_MIN = 1.0;74116 const L_MAX = 4.0;117+/** Reynolds corridor (M14c): no lateral correction inside the dead zone118+ * (the marble may wobble — pursuit alone steers); a ramped spring119+ * shepherds it back between DEAD and EDGE. The PROFILE's flat floor runs120+ * ±0.30, gutters to ±0.74, walls at ±0.92. */121+const CORRIDOR_DEAD = 0.3;122+const CORRIDOR_EDGE = 0.55;75123 /** Brake-sweep window (world units) — covers VMAX → CRUISE at A_BRAKE. */76124 const SWEEP_DIST = 8;77125 /** Height above the rail floor that counts as airborne. The rail line is@@ -106,6 +154,7 @@ export function wantJump(
106154 pos: readonly [number, number, number],107155 vel: readonly [number, number, number],108156 nextRail: Rail | null = null,157+ pilot: Pilot = DEFAULT_PILOT,109158 ): [[number, number, number], number] | null {110159 const t = rail.tans[idx];111160 if (!t) return null;@@ -139,7 +188,7 @@ export function wantJump(
139188 const yAt = vel[1] * tF - 0.5 * gEff * tF * tF;140189 if (yAt >= drop + JUMP_CLEAR) return null; // priced flight already works141190 const vyNeed = (drop + JUMP_CLEAR + 0.5 * gEff * tF * tF) / tF;142- if (vyNeed <= vel[1] || vyNeed > JUMP_MAX) return null;191+ if (vyNeed <= vel[1] || vyNeed > JUMP_MAX * pilot.eagerness) return null;143192 // Slope-aware floor normal from the tangent plane (approach cells are144193 // near-flat by construction, so n ≈ up and the top-up is ≈ vertical).145194 let nx = -t[0] * t[1];@@ -357,10 +406,41 @@ export function autopilotForce(
357406 }358407 }359408 409+ // Reynolds corridor dead zone (M14c): outside centering zones, a410+ // marble within ±CORRIDOR_DEAD of its personal line (rail + wander411+ // offset) gets NO lateral correction — the wobble is what reads as412+ // alive. Toward the corridor edge, a ramped spring-damper shepherds it413+ // back before the wall does.414+ const centering = (mod?.speed !== undefined && mod.speed > 1) ||415+ mod?.center === true;416+ if (grounded && !centering) {417+ const r = rail.pts[idx];418+ const nl2 = Math.hypot(t[2], t[0]);419+ if (nl2 > 1e-6) {420+ const ux = t[2] / nl2;421+ const uz = -t[0] / nl2;422+ const off = (pos[0] - r[0]) * ux + (pos[2] - r[2]) * uz - pilot.offset;423+ const mag = Math.abs(off);424+ if (mag > CORRIDOR_DEAD) {425+ const ramp = Math.min(426+ 1,427+ (mag - CORRIDOR_DEAD) / (CORRIDOR_EDGE - CORRIDOR_DEAD),428+ );429+ const lvel = vel[0] * ux + vel[2] * uz;430+ const a = clamp(431+ (-28 * (off - Math.sign(off) * CORRIDOR_DEAD) - 8 * lvel) * ramp,432+ -10,433+ 10,434+ );435+ out[0] += ux * a * mass;436+ out[2] += uz * a * mass;437+ }438+ }439+ }360440 // --- shared zone extras (identical to the assist law) ---361441 // Lateral centering spring in boost/split/gravity zones: launches and362442 // merges must be straight; these zones are straight by construction.363- if ((mod?.speed && mod.speed > 1) || mod?.center) {443+ if (centering) {364444 const r = rail.pts[idx];365445 const nl = Math.hypot(t[2], t[0]) || 1;366446 const ux = t[2] / nl;@@ -31,7 +31,12 @@ import {
3131 stationGravity,3232 stationRestitution,3333 } from "../src/client/physics/rail.ts";34-import { autopilotForce, wantJump } from "../src/client/physics/autopilot.ts";34+import {35+ autopilotForce,36+ pilotFor,37+ pilotOffset,38+ wantJump,39+} from "../src/client/physics/autopilot.ts";3540 import type { DecorCatalog } from "../src/gen/decor.ts";3641 3742 const catalog: DecorCatalog = {@@ -104,6 +109,8 @@ function driveWith(
104109 [t0[0] * 2.2, 0, t0[2] * 2.2],105110 );106111 112+ const pilot = pilotFor(seed);113+ let simT = 0;107114 let nearest = spawnIdx;108115 let reachedS = rail.s[spawnIdx];109116 let minY = Infinity;@@ -141,9 +148,19 @@ function driveWith(
141148 }142149 engine.setRestitution(stationRestitution(rail, nearest));143150 engine.setGravity(stationGravity(rail, nearest));144- const f = autopilotForce(rail, nearest, st.pos, st.vel, engine.mass);151+ simT += 1 / 60;152+ pilot.offset = pilotOffset(pilot, simT);153+ const f = autopilotForce(154+ rail,155+ nearest,156+ st.pos,157+ st.vel,158+ engine.mass,159+ null,160+ pilot,161+ );145162 if (f) engine.force(f[0], f[1], f[2]);146- const j = wantJump(rail, nearest, st.pos, st.vel);163+ const j = wantJump(rail, nearest, st.pos, st.vel, null, pilot);147164 if (j) engine.jump(j[0], j[1]);148165 });149166 st = engine.state();@@ -21,7 +21,12 @@ import {
2121 stationGravity,2222 stationRestitution,2323 } from "../../src/client/physics/rail.ts";24-import { autopilotForce, wantJump } from "../../src/client/physics/autopilot.ts";24+import {25+ autopilotForce,26+ pilotFor,27+ pilotOffset,28+ wantJump,29+} from "../../src/client/physics/autopilot.ts";2530 2631 // LAW=assist runs the old choreographer for A/B; default is the autopilot.2732 const law = Deno.env.get("LAW") === "assist" ? stationForce : autopilotForce;@@ -58,6 +63,8 @@ engine.spawnMarble(
5863 );5964 let nearest = 0;6065 let lastS = 0, stall = 0, fell = false;66+const pilot = pilotFor(Deno.args[0] ?? "cua-seed-1");67+let simT = 0;6168 for (let step = 0; step < 240 * 60; step++) {6269 engine.step(1 / 60, () => {6370 const p = engine.state().pos;@@ -77,11 +84,24 @@ for (let step = 0; step < 240 * 60; step++) {
7784 const st = engine.state();7885 engine.setRestitution(stationRestitution(rail, nearest));7986 engine.setGravity(stationGravity(rail, nearest));80- const f = law(rail, nearest, st.pos, st.vel, engine.mass);81- if (f) engine.force(f[0], f[1], f[2]);8287 if (law === autopilotForce) {83- const j = wantJump(rail, nearest, st.pos, st.vel);88+ simT += 1 / 60;89+ pilot.offset = pilotOffset(pilot, simT);90+ const f = autopilotForce(91+ rail,92+ nearest,93+ st.pos,94+ st.vel,95+ engine.mass,96+ null,97+ pilot,98+ );99+ if (f) engine.force(f[0], f[1], f[2]);100+ const j = wantJump(rail, nearest, st.pos, st.vel, null, pilot);84101 if (j) engine.jump(j[0], j[1]);102+ } else {103+ const f = law(rail, nearest, st.pos, st.vel, engine.mass);104+ if (f) engine.force(f[0], f[1], f[2]);85105 }86106 });87107 {