clone

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

main
marbles / src / client / physics / autopilot.ts
autopilot.ts 30.2K
1/**2 * The autopilot (M14a) — the centerline demoted from choreographer to3 * WISHDIR. One general control law replaces the assist law's special4 * cases (climb carry, hands-off threshold, curvature term):5 *6 * - Pure pursuit: chase a lookahead point on the rail at7 * L = clamp(lookGain·|v|, L_MIN, L_MAX) → wishdir (Coulter '92; for a8 * marble the controller degenerates to "seek the point").9 * - Speed planning: per-station corner speeds from curvature split into10 * yaw (wall-launch risk) and CONVEX pitch (crest-launch risk) — concave11 * valleys don't brake, so chute dives stay dramatic — swept backward12 * online: allowed(k) = sqrt(vc_k² + 2·A_BRAKE·Δs). wishspeed is the13 * minimum of the local target and that envelope.14 * - Accelerate with the Quake/Marble Blast projection contract:15 * deficit = wishspeed − dot(v, wishdir); deficit ≤ 0 → NO force16 * (momentum is sacred — a downhill marble is never fought; this is what17 * replaces the hands-off threshold). Positive deficit → a saturating18 * authority a = min(KP·deficit, A_TRACTION)·grip; climbs emerge (the19 * marble slows, the deficit grows, authority rises above along-slope20 * gravity) instead of being scripted by a gravity-cancelling carry.21 * - Decisive braking only above the swept envelope (separate clamp, like22 * MBHaxe's brakingAcceleration vs angularAcceleration).23 * - Airborne (rail floor left behind): weak linear air control toward the24 * lookahead — enough to line up a landing, never enough to fly (MBHaxe25 * airAccel 5 vs g 20).26 *27 * Zone semantics (flight pricing, gravity tilt, restitution, centering28 * springs, descent pulls) stay shared with rail.ts — the autopilot only29 * replaces the propulsion/braking half of stationForce.30 */31import { type Rail, RAIL_STEP, type RailMod } from "./rail.ts";32import { GRAVITY_Y } from "./engine.ts";33import { rngFamily } from "../../gen/rng.ts";3435/** Per-marble controller personality (M14c). The defaults are the36 * neutral baseline; pilotFor() draws seed-stable jitter around them. */37export interface Pilot {38 /** Corner-speed margin: multiplies the curvature-derived corner speed39 * (not the target floor) — >1 corners braver, <1 more cautious. */40 bravery: number;41 /** Pursuit lookahead gain k in L = clamp(k·|v|, L_MIN, L_MAX). */42 lookGain: number;43 /** Jump-eagerness: multiplies the max top-up the pilot will attempt. */44 eagerness: number;45 /** Boost aggression: scales the attack/defend trigger ranges — when46 * this pilot chooses to SPEND fuel (drawn last in pilotFor, so adding47 * it never re-rolled the older traits). */48 aggro: number;49 /** Recharge-rate multiplier: identical rates locked every tank into50 * the same cycle and the pack surged in unison — regen spread turns51 * the waves into duels. */52 regen: number;53 /** Boost speed increase: this pilot's wishspeed multiplier while54 * burning (the envelope still caps it — corner-safe at any value). */55 boostMult: number;56 /** Boost fuel efficiency: divides the burn rate (1.25 = the tank57 * lasts 25% longer at the same speed). */58 efficiency: number;59 /** Lateral offset target from the rail line (world units; drivers set60 * this per step from pilotOffset() — 0 rides the line). */61 offset: number;62 /** Wander parameters (closed-form line-offset noise): amplitude and63 * two seed-drawn phases. Amp 0 = no wander. */64 wanderAmp: number;65 phase1: number;66 phase2: number;67 /** Wake-rider appetite (M20a): scales the draft bonus. */68 wake: number;69 /** Braid alt-lane threshold (M20a): braidLaneAlt compares its single70 * draw against this — lane identity is CHARACTER, so it reads the71 * base pilot, never the rubber-banded scratch. */72 lanePref: number;73 /** Overtake chirality (M20a): pass-left (−1) or pass-right (+1). */74 chir: number;75 /** Cornering style (M20b): scales the PLANNING decel in the brake76 * sweep. >1 = late-braker (dive-bombs entries), <1 = early-coaster.77 * Hard cap 1.4: planned decel 6·1.4 = 8.4 stays under A_BRAKE_MAX 10,78 * so a late-braker can always shed the speed it planned to — envelope79 * violations impossible by construction. */80 brakeStyle: number;81 /** Post-contact temper (M20b): 0-1 scales the aggression window a82 * shove opens (temperStep). */83 temper: number;84}8586export const DEFAULT_PILOT: Pilot = {87 bravery: 1,88 lookGain: 0.55,89 eagerness: 1,90 aggro: 1,91 regen: 1,92 boostMult: 1.55,93 efficiency: 1,94 offset: 0,95 wanderAmp: 0,96 phase1: 0,97 phase2: 0,98 wake: 1,99 lanePref: 0.5,100 chir: 1,101 brakeStyle: 1,102 temper: 0,103};104105/** Seed-stable personality for marble `index` (label-derived stream —106 * adding marbles never perturbs earlier ones; new traits draw AFTER all107 * older ones so existing personalities stay byte-identical). */108export function pilotFor(seed: string, index = 0): Pilot {109 const rng = rngFamily(seed, `pilot/${index}`);110 const bravery = 0.92 + rng.float() * 0.18;111 const lookGain = 0.48 + rng.float() * 0.16;112 const eagerness = 0.9 + rng.float() * 0.2;113 const wanderAmp = 0.1 + rng.float() * 0.1;114 const phase1 = rng.float() * Math.PI * 2;115 const phase2 = rng.float() * Math.PI * 2;116 const aggro = 0.7 + rng.float() * 0.6; // appended draw (boost round)117 const regen = 0.75 + rng.float() * 0.5; // appended draw (boost round)118 const boostMult = 1.35 + rng.float() * 0.4; // appended draw119 const efficiency = 0.8 + rng.float() * 0.45; // appended draw120 const wake = 0.6 + rng.float() * 0.8; // appended draw (M20a)121 const lanePref = 0.35 + rng.float() * 0.3; // appended draw (M20a)122 const chir = rng.float() < 0.5 ? -1 : 1; // appended draw (M20a)123 const brakeStyle = 0.8 + rng.float() * 0.55; // appended draw (M20b)124 const temper = rng.float(); // appended draw (M20b)125 return {126 bravery,127 lookGain,128 eagerness,129 aggro,130 regen,131 boostMult,132 efficiency,133 offset: 0,134 wanderAmp,135 phase1,136 phase2,137 wake,138 lanePref,139 chir,140 brakeStyle,141 temper,142 };143}144145// --- post-shove temper (M20b) ----------------------------------------------146//147// The shim has no contact events, so "shoved" is the deterministic proxy148// the pack already computes: a peer inside contact distance during the149// decision's neighbor scan. A contact opens a ~3s window during which150// the pilot's aggression (boost triggers) and shove-through bravery run151// hot, scaled by the temper trait. Pure state law, boostStep's shape —152// drivers hold the clock per racer.153154/** Aggression window a contact opens (seconds). */155export const TEMPER_WINDOW = 3;156157/** One fixed-step of the temper clock: `contactNear` = a peer within158 * contact distance this decision. Returns the remaining hot-window time159 * (0 = cool). Re-contact re-arms the full window (no accumulation). */160export function temperStep(161 clock: number,162 dt: number,163 contactNear: boolean,164): number {165 if (contactNear) return TEMPER_WINDOW;166 return Math.max(0, clock - dt);167}168169/** Effective aggro/bravery multipliers while the temper window runs. */170export function temperHeat(171 clock: number,172 temper: number,173): { aggro: number; bravery: number } {174 if (clock <= 0 || temper <= 0) return { aggro: 1, bravery: 1 };175 return { aggro: 1 + 0.5 * temper, bravery: 1 + 0.08 * temper };176}177178/** Braid lane choice (M17): true = the alt lane. Each racer draws from179 * ITS OWN label-derived stream, `lane/${idx}/${segIndex}` — the project's180 * rng discipline: no sequential draws, no draw-order coupling, seed-181 * stable, and every racer's stream is independent exactly like pilotFor182 * personalities (changing one racer's inputs can never affect another's183 * choice). Deterministic per (seed, racer, braid); over a pack it splits184 * roughly in half, so the stream visibly forks and rejoins. */185export function braidLaneAlt(186 seed: string,187 idx: number,188 segIndex: number,189 /** Personality threshold (M20a): SAME label, SAME single draw — only190 * the comparison moves, so racer streams stay untouched and lane191 * splits lean with character instead of a fixed coin. */192 pref = 0.5,193): boolean {194 return rngFamily(seed, `lane/${idx}/${segIndex}`).float() < pref;195}196197// --- drafting (M20a) --------------------------------------------------------198//199// A trailing marble rides the leader's wake: a wishspeed multiplier in a200// window behind the same-lane racer ahead, applied through the SAME201// envelope law as boost (wishspeed = min(target·mult, vSafe)) — drafting202// into a corner brakes identically, so the slingshot can only happen203// where the track permits it. Riders fake the same law kinematically.204205/** Wake window: closer than this, contact/separation owns the pair. */206export const DRAFT_MIN = 1.2;207/** Wake dissipates by this progress gap (world units). */208export const DRAFT_MAX = 5.0;209/** Peak bonus at DRAFT_MIN for wake = 1 (a ~10% slingshot). */210export const DRAFT_GAIN = 0.1;211212/** Pure draft law: target-speed multiplier for a racer `gapAhead` behind213 * its same-lane leader. 1 outside the window; linear falloff inside. */214export function draftFactor(gapAhead: number | null, wake: number): number {215 if (gapAhead === null || gapAhead < DRAFT_MIN || gapAhead >= DRAFT_MAX) {216 return 1;217 }218 const falloff = (DRAFT_MAX - gapAhead) / (DRAFT_MAX - DRAFT_MIN);219 return 1 + DRAFT_GAIN * wake * falloff;220}221222// --- the boost economy (competition round) ---------------------------------223//224// Racing on autopilot needs a resource to SPEND or every outcome is225// contact luck: each pilot gets a fuel tank that burns in ~3s of boost226// and recharges in ~12s of restraint. Ignition is a decision — attack a227// rival ahead, break away from a chaser, or bleed an overflowing tank —228// gated on envelope HEADROOM (wishspeed stays min(target·mult, vSafe),229// so a boosting marble still brakes for every corner: safety is230// unchanged by construction; boost only buys speed where the track231// permits it). Aggro scales the trigger ranges: personalities differ in232// WHEN they spend, which is what makes the race a contest.233234/** Baseline boost wishspeed multiplier (DEFAULT_PILOT.boostMult;235 * pilotFor draws each racer's own around it). */236export const BOOST_MULT = 1.55;237/** Full tank burns in ~3s. */238const BOOST_DRAIN = 1 / 3;239/** Empty tank refills in ~12s. */240const BOOST_RECHARGE = 1 / 12;241/** M22: extra refill per second at full draft depth — a committed wake242 * rider roughly DOUBLES its regen. The economy makes trains: line up,243 * hoard, detonate. */244export const DRAFT_REFILL = 1 / 14;245/** Don't ignite below this. Sized for RHYTHM: in a tight pack a rival is246 * always in range, so pilots ignite the moment they may — a low floor247 * gave 0.6s sputters at the floor level. Half a tank commits to a248 * ~1.4s attack with ~6s of visible restraint between burns. */249const BOOST_MIN_FUEL = 0.5;250/** Attack range: a rival ahead within aggro·this many units. */251const BOOST_ATTACK = 8;252/** Defend range: a chaser behind within aggro·this many units. */253const BOOST_DEFEND = 4;254255export interface BoostState {256 /** Tank level 0..1. */257 fuel: number;258 boosting: boolean;259}260261export const FULL_TANK: BoostState = { fuel: 1, boosting: false };262263/**264 * One fixed-step of the boost economy. `headroom` is vSafe − wishspeed265 * from the pilot's last force step (PilotDebug) — while boosting, the266 * envelope capping the boost (headroom ≈ 0) ends the burn; rival gaps267 * are rail-progress deltas (null = nobody there). Pure and268 * deterministic; drivers hold the state per racer.269 */270export function boostStep(271 st: BoostState,272 pilot: Pilot,273 dt: number,274 headroom: number | null,275 gapAhead: number | null,276 gapBehind: number | null,277 /** M22 slipstream economy: 0..~1.4 draft depth ((draft−1)/DRAFT_GAIN).278 * Riding a wake REFILLS the tank on top of the base regen — trains279 * form because they pay, then detonate at attack range. */280 draftDepth = 0,281): BoostState {282 if (st.boosting) {283 const fuel = Math.max(0, st.fuel - dt * BOOST_DRAIN / pilot.efficiency);284 const capped = headroom !== null && headroom < 0.1;285 return { fuel, boosting: fuel > 0.02 && !capped };286 }287 const fuel = Math.min(288 1,289 st.fuel + dt * (BOOST_RECHARGE * pilot.regen + DRAFT_REFILL * draftDepth),290 );291 const room = headroom !== null && headroom > 0.8;292 const attack = gapAhead !== null && gapAhead < BOOST_ATTACK * pilot.aggro;293 const defend = gapBehind !== null && gapBehind < BOOST_DEFEND * pilot.aggro;294 const overflow = fuel >= 1;295 const ignite = fuel >= BOOST_MIN_FUEL && room &&296 (attack || defend || overflow);297 return { fuel, boosting: ignite };298}299300// --- rubber-banding through pilot SKILL (Melder, Game AI Pro) --------------301//302// Never thrust: a trailing racer gets no extra engine — it gets a braver303// PILOT. Corner margins widen (bravery multiplies the curvature-derived304// corner speeds) and rescue jumps commit earlier (eagerness raises the305// top-up ceiling); the leader's pilot turns conservative by the same law.306// Capped so personality still dominates the modulation: at the extremes307// corners swing ±8% and jump eagerness ±25% around the seed-drawn traits.308309/** Corner-speed swing at the field's extremes (fraction of bravery). */310const RUBBER_BRAVERY = 0.16;311/** Jump-eagerness swing at the extremes (fraction of eagerness). */312const RUBBER_EAGERNESS = 0.5;313314/**315 * Race-position → pilot-skill modulation. `behind` ∈ [0, 1]: 0 = leading316 * the field, 1 = dead last, 0.5 = mid-pack (neutral — a lone racer or an317 * exact midfielder keeps its base traits). Drivers smooth `behind` (rank318 * order flaps in a tight pack) and write the result onto a per-racer319 * scratch pilot each decision; `base` is never mutated.320 */321export function rubberMod(base: Pilot, behind: number, out: Pilot): Pilot {322 const x = Math.min(1, Math.max(0, behind)) - 0.5;323 Object.assign(out, base);324 out.bravery = base.bravery * (1 + RUBBER_BRAVERY * x);325 out.eagerness = base.eagerness * (1 + RUBBER_EAGERNESS * x);326 return out;327}328329/** Lateral offset target at sim-time t — a slow two-tone wander (closed330 * form: deterministic under fixed stepping, no draw-order coupling, and331 * mean-reverting like an OU process without carrying state). Stays well332 * inside the corridor dead zone. */333export function pilotOffset(p: Pilot, t: number): number {334 if (p.wanderAmp === 0) return 0;335 return p.wanderAmp *336 (0.65 * Math.sin(0.31 * t + p.phase1) +337 0.35 * Math.sin(0.093 * t + p.phase2));338}339340/** Cruise target on an unmodified tile (the proven M11 value — boost341 * pricing multiplies it, so jump launch speeds stay ≈ 6.1). */342const CRUISE = 3.2;343/** Hard speed ceiling (the proven envelope cap). */344const VMAX = 9;345/** Lateral grip budget for corner speeds: vc = sqrt(A_LAT·r). Sized so a346 * small curve (r = 1 world) corners at ≈ cruise — the old law's proven347 * entry speed for tight bends — while large curves earn more. */348const A_LAT = 10;349/** Crest budget: centripetal v²·κ beyond this fraction of g lifts the350 * marble off the floor — brake below launch, with margin. */351const A_CREST = 16;352/** Planning deceleration for the brake sweep (the brake itself can pull353 * harder; planning softer means braking starts earlier and reads as354 * intent, not panic). */355const A_BRAKE = 6;356/** Authority: accel per unit of speed deficit… */357const KP = 5;358/** …saturating at the static-friction budget (MBHaxe μs 1.1 · g 20). */359const A_TRACTION = 22;360/** Max brake accel (matches the old law's clamp). */361const A_BRAKE_MAX = 10;362/** Linear air control (MBHaxe _airAccel). */363const AIR_ACCEL = 5;364const L_MIN = 1.0;365const L_MAX = 4.0;366/** Reynolds corridor (M14c): no lateral correction inside the dead zone367 * (the marble may wobble — pursuit alone steers); a ramped spring368 * shepherds it back between DEAD and EDGE. The PROFILE's flat floor runs369 * ±0.30, gutters to ±0.74, walls at ±0.92. */370const CORRIDOR_DEAD = 0.3;371const CORRIDOR_EDGE = 0.55;372/** Brake-sweep window (world units) — covers VMAX → CRUISE at A_BRAKE. */373const SWEEP_DIST = 8;374/** Height above the rail floor that counts as airborne. The rail line is375 * the channel floor centerline; a rolling marble's center rides ≈ its376 * radius (0.1) above it, bumps and seams excursion within ~0.3. */377const AIR_HEIGHT = 0.45;378379const clamp = (v: number, lo: number, hi: number): number =>380 v < lo ? lo : v > hi ? hi : v;381382/** Max top-up jump speed (≈0.7 world of height at g 20 — a hop, not a383 * rocket; a rescue needing more than this is a respawn, not a jump). */384const JUMP_MAX = 5.2;385/** Landing clearance: the rescue aims exactly at the landing point — the386 * catch pan reaches ~1.4 under the flight path (M11), so the pan IS the387 * margin; any positive clearance here made the pilot top-up even priced388 * launches. */389const JUMP_CLEAR = 0;390391/**392 * The jump decision (M14b): approaching a flight gap too slow for the393 * priced ballistic arc, the pilot may TOP-UP its vertical speed at the394 * lip so the longer hang time still crosses — computed from the marble's395 * ACTUAL state against the landing point, committed only when the396 * resulting arc lands inside the corridor. Returns [normal, targetSpeed]397 * for PhysicsEngine.jump, or null. Deterministic and repeat-safe (the398 * top-up contract makes held calls no-ops).399 */400export function wantJump(401 rail: Rail,402 idx: number,403 pos: readonly [number, number, number],404 vel: readonly [number, number, number],405 nextRail: Rail | null = null,406 pilot: Pilot = DEFAULT_PILOT,407): [[number, number, number], number] | null {408 const t = rail.tans[idx];409 if (!t) return null;410 if (rail.mods[idx]?.flight) return null; // already committed411 if (pos[1] - rail.pts[idx][1] >= 0.3) return null; // must be rolling412 if (vel[1] > 1.0) return null; // already rising (jumped/bounced) — once413 const n = rail.tans.length;414 const modAt = (i: number): RailMod | null =>415 i < n ? rail.mods[i] : nextRail ? nextRail.mods[i - n] ?? null : null;416 const ptAt = (i: number): readonly [number, number, number] | null =>417 rail.pts[i] ?? (nextRail ? nextRail.pts[i - n] ?? null : null);418 // Decide ONLY at the lip — the last rolling station before the flight419 // run. Any earlier there is still floor under the arc and the ballistic420 // test is meaningless (a premature version of this fired 2u early and421 // helicoptered marbles off the line).422 if (!modAt(idx + 1)?.flight) return null;423 let land = idx + 2;424 while (modAt(land)?.flight) land++;425 const ld = ptAt(land);426 if (!ld) return null;427 const gravScale = modAt(idx + 1)?.gravScale ?? 1;428 const gEff = -GRAVITY_Y * gravScale;429 const gx = ld[0] - pos[0];430 const gz = ld[2] - pos[2];431 const G = Math.hypot(gx, gz);432 if (G < 0.3) return null;433 const drop = ld[1] - pos[1]; // negative: landing below434 const vh = (vel[0] * gx + vel[2] * gz) / G;435 if (vh < 0.8) return null; // can't cross at any hang time worth having436 const tF = G / vh;437 const yAt = vel[1] * tF - 0.5 * gEff * tF * tF;438 if (yAt >= drop + JUMP_CLEAR) return null; // priced flight already works439 const vyNeed = (drop + JUMP_CLEAR + 0.5 * gEff * tF * tF) / tF;440 if (vyNeed <= vel[1] || vyNeed > JUMP_MAX * pilot.eagerness) return null;441 // Slope-aware floor normal from the tangent plane (approach cells are442 // near-flat by construction, so n ≈ up and the top-up is ≈ vertical).443 let nx = -t[0] * t[1];444 let ny = 1 - t[1] * t[1];445 let nz = -t[2] * t[1];446 const nl = Math.hypot(nx, ny, nz);447 if (nl < 1e-6 || ny <= 0.5) return null;448 nx /= nl;449 ny /= nl;450 nz /= nl;451 return [[nx, ny, nz], vyNeed / ny];452}453454/** Per-step introspection (debug harnesses pass one in). */455export interface PilotDebug {456 mode?: string;457 wishspeed?: number;458 vSafe?: number;459 deficit?: number;460 grounded?: boolean;461 look?: readonly [number, number, number];462}463464/** A neighboring racer the pilot can see (M15 separation steering). */465export interface Neighbor {466 pos: readonly [number, number, number];467 bravery: number;468}469470/**471 * The complete autopilot step at a station. Same contract as472 * rail.ts stationForce (drivers swap one call), plus a pilot and the473 * other racers on the track.474 */475export function autopilotForce(476 rail: Rail,477 idx: number,478 pos: readonly [number, number, number],479 vel: readonly [number, number, number],480 mass: number,481 nextRail: Rail | null = null,482 pilot: Pilot = DEFAULT_PILOT,483 dbg?: PilotDebug,484 others?: readonly Neighbor[],485 /** Fuel burn (boostStep): raises the target inside the same envelope. */486 boosting = false,487 /** Draft multiplier (M20a, draftFactor): raises the target inside the488 * same envelope — stacks multiplicatively with boost; vSafe still489 * caps, so a drafting marble brakes for every corner unchanged. */490 targetMult = 1,491): [number, number, number] | null {492 const t = rail.tans[idx];493 if (!t) return null;494 const mod = rail.mods[idx];495 // Flight zones keep their plan-time pricing: no propulsion, propeller496 // zones cancel part of gravity. (M14b adds the jump decision + air497 // lineup on top.)498 if (mod?.flight) {499 if (dbg) dbg.mode = "flight";500 if (mod.gravScale !== undefined && mod.gravScale !== 1) {501 return [0, (1 - mod.gravScale) * -GRAVITY_Y * mass, 0];502 }503 return null;504 }505506 const n = rail.tans.length;507 const tanAt = (i: number): readonly [number, number, number] | null =>508 rail.tans[i] ?? (nextRail ? nextRail.tans[i - n] ?? null : null);509 const ptAt = (i: number): readonly [number, number, number] | null =>510 rail.pts[i] ?? (nextRail ? nextRail.pts[i - n] ?? null : null);511 const modAt = (i: number): RailMod | null =>512 i < n ? rail.mods[i] : nextRail ? nextRail.mods[i - n] ?? null : null;513 const endS = rail.s[n - 1] ?? 0;514 const sAt = (i: number): number | null =>515 i < n516 ? rail.s[i]517 : nextRail && nextRail.s[i - n] !== undefined518 ? endS + RAIL_STEP + nextRail.s[i - n]519 : null;520521 // --- speed planning: online backward brake sweep over the lookahead ---522 const sHere = rail.s[idx];523 const targetHere = CRUISE * (mod?.speed ?? 1) *524 (boosting ? pilot.boostMult : 1) * targetMult;525 let vSafe = VMAX;526 let climbTy = t[1];527 let prev: readonly [number, number, number] = t;528 let prevS = sHere;529 for (let k = idx + 1;; k++) {530 const look = tanAt(k);531 const sk = sAt(k);532 if (!look || sk === null) break;533 // The sweep stops at a flight zone: its rail "curvature" is the priced534 // ballistic profile diving through the gap, not floor geometry —535 // reading it as a crest braked marbles below launch speed at jump lips536 // (they crept off the lip and wedged in the catch pan).537 if (modAt(k)?.flight) break;538 const ds = sk - prevS;539 if (ds > 1e-6) {540 const hp = Math.hypot(prev[0], prev[2]);541 const hl = Math.hypot(look[0], look[2]);542 let yaw = 0;543 if (hp > 1e-4 && hl > 1e-4) {544 const dot = clamp(545 (prev[0] * look[0] + prev[2] * look[2]) / (hp * hl),546 -1,547 1,548 );549 yaw = Math.acos(dot);550 }551 const pitchD = Math.asin(clamp(look[1], -1, 1)) -552 Math.asin(clamp(prev[1], -1, 1));553 const kYaw = yaw / ds;554 const kCrest = Math.max(0, -pitchD) / ds; // convex only — valleys free555 const targetK = CRUISE * (modAt(k)?.speed ?? 1);556 const grip = pilot.bravery;557 const vYaw = kYaw > 1e-4 ? Math.sqrt(A_LAT / kYaw) * grip : VMAX;558 const vCrest = kCrest > 1e-4 ? Math.sqrt(A_CREST / kCrest) * grip : VMAX;559 const vc = Math.min(VMAX, Math.max(targetK, Math.min(vYaw, vCrest)));560 const dist = sk - sHere;561 // brakeStyle (M20b): late-brakers PLAN a harder decel (capped so562 // the actual brake clamp A_BRAKE_MAX can always deliver it).563 const allowed = Math.sqrt(564 vc * vc + 2 * A_BRAKE * pilot.brakeStyle * dist,565 );566 if (allowed < vSafe) vSafe = allowed;567 }568 climbTy = Math.max(climbTy, look[1]);569 prev = look;570 prevS = sk;571 if (sk - sHere > SWEEP_DIST) break;572 }573574 // Momentum arrival: a climb ahead raises the target — the pilot builds575 // speed BEFORE the slope instead of being carried up it.576 const climbing = climbTy > 0.12;577 const target = climbing ? targetHere * (1 + 0.5 * climbTy) : targetHere;578 const wishspeed = Math.min(target, vSafe);579580 // --- pure pursuit: the lookahead point → wishdir ---581 const speed = Math.hypot(vel[0], vel[1], vel[2]);582 const L = clamp(pilot.lookGain * speed, L_MIN, L_MAX);583 let j = idx;584 for (;;) {585 const sj = sAt(j + 1);586 if (sj === null || sj - sHere > L) break;587 j++;588 }589 const look = ptAt(j) ?? rail.pts[idx];590 // Pursuit steers YAW ONLY: the wishdir is the HORIZONTAL direction to591 // the lookahead point — pitch comes from the local ground plane below592 // (grounded projection) or stays level (air). Seeking the point in 3D593 // aimed the pilot INTO the floor at crests (ramp up into immediate594 // down: the point sits below the marble) — it accelerated toward the595 // ground and jittered against the crest face.596 let wx = look[0] - pos[0];597 let wy = 0;598 let wz = look[2] - pos[2];599 if (pilot.offset !== 0) {600 const tj = tanAt(j) ?? t;601 const nl = Math.hypot(tj[2], tj[0]);602 if (nl > 1e-6) {603 wx += (tj[2] / nl) * pilot.offset;604 wz += (-tj[0] / nl) * pilot.offset;605 }606 }607 let wl = Math.hypot(wx, wz);608 if (wl < 1e-6) {609 // Degenerate (drop shafts: the point is straight below) — fall back610 // to the local tangent's heading.611 wx = t[0];612 wz = t[2];613 wl = Math.hypot(wx, wz);614 if (wl < 1e-6) return null;615 }616 wx /= wl;617 wz /= wl;618619 const grounded = pos[1] - rail.pts[idx][1] < AIR_HEIGHT;620 const out: [number, number, number] = [0, 0, 0];621 if (dbg) {622 dbg.wishspeed = wishspeed;623 dbg.vSafe = vSafe;624 dbg.grounded = grounded;625 dbg.look = look;626 dbg.mode = grounded ? "coast" : "air";627 }628629 if (grounded) {630 // Project wishdir onto the ground plane (normal ≈ tangent-plane up)631 // so authority pushes along the slope, not into the floor.632 let nx = -t[0] * t[1];633 let ny = 1 - t[1] * t[1];634 let nz = -t[2] * t[1];635 const nlen = Math.hypot(nx, ny, nz);636 if (nlen > 1e-6) {637 nx /= nlen;638 ny /= nlen;639 nz /= nlen;640 const d = wx * nx + wy * ny + wz * nz;641 wx -= d * nx;642 wy -= d * ny;643 wz -= d * nz;644 const l2 = Math.hypot(wx, wy, wz);645 if (l2 > 1e-6) {646 wx /= l2;647 wy /= l2;648 wz /= l2;649 } else {650 wx = t[0];651 wy = t[1];652 wz = t[2];653 }654 }655 const vAlongT = vel[0] * t[0] + vel[1] * t[1] + vel[2] * t[2];656 if (vAlongT > vSafe) {657 if (dbg) dbg.mode = "brake";658 // Decisive brake above the planned envelope — its own clamp, like659 // MBHaxe braking vs rolling authority.660 const a = Math.min(A_BRAKE_MAX, 3 + (vAlongT - vSafe) * 2);661 out[0] -= t[0] * a * mass;662 out[1] -= t[1] * a * mass;663 out[2] -= t[2] * a * mass;664 } else {665 const vAlongW = vel[0] * wx + vel[1] * wy + vel[2] * wz;666 const deficit = wishspeed - vAlongW;667 if (dbg) dbg.deficit = deficit;668 if (deficit > 0) {669 if (dbg) dbg.mode = "accel";670 // Icy: grip scales BOTH responsiveness and the ceiling — on ice671 // the pilot wants the same speed and simply cannot have it.672 const grip = mod?.icy ? 0.3 : 1;673 const a = Math.min(KP * grip * deficit, A_TRACTION * grip);674 out[0] += wx * a * mass;675 out[1] += wy * a * mass;676 out[2] += wz * a * mass;677 }678 }679 } else {680 // Airborne: weak linear control toward the lookahead, horizontal only681 // (lift is gravity's business).682 const hl = Math.hypot(wx, wz);683 if (hl > 1e-4) {684 out[0] += (wx / hl) * AIR_ACCEL * mass;685 out[2] += (wz / hl) * AIR_ACCEL * mass;686 }687 }688689 // Separation steering (M15): a neighbor occupying the path just ahead690 // gets a lateral yield — UNLESS this pilot is at least as brave, in691 // which case the contact happens (shoving is content). Behind-me692 // neighbors are ignored; the front marble never yields to a chaser.693 if (others && grounded) {694 const nl3 = Math.hypot(t[2], t[0]);695 if (nl3 > 1e-6) {696 const ux = t[2] / nl3;697 const uz = -t[0] / nl3;698 for (const o of others) {699 const dx = o.pos[0] - pos[0];700 const dy = o.pos[1] - pos[1];701 const dz = o.pos[2] - pos[2];702 const dist = Math.hypot(dx, dy, dz);703 if (dist > 0.5 || dist < 1e-6) continue;704 if (dx * vel[0] + dz * vel[2] <= 0) continue; // behind me705 if (pilot.bravery >= o.bravery) continue; // shove through706 const side = dx * ux + dz * uz;707 const away = side >= 0 ? -1 : 1;708 const a = 6 * (1 - dist / 0.5);709 out[0] += ux * away * a * mass;710 out[2] += uz * away * a * mass;711 }712 }713 }714 // Reynolds corridor dead zone (M14c): outside centering zones, a715 // marble within ±CORRIDOR_DEAD of its personal line (rail + wander716 // offset) gets NO lateral correction — the wobble is what reads as717 // alive. Toward the corridor edge, a ramped spring-damper shepherds it718 // back before the wall does.719 const centering = (mod?.speed !== undefined && mod.speed > 1) ||720 mod?.center === true;721 if (grounded && !centering) {722 const r = rail.pts[idx];723 const nl2 = Math.hypot(t[2], t[0]);724 if (nl2 > 1e-6) {725 const ux = t[2] / nl2;726 const uz = -t[0] / nl2;727 const off = (pos[0] - r[0]) * ux + (pos[2] - r[2]) * uz - pilot.offset;728 const mag = Math.abs(off);729 if (mag > CORRIDOR_DEAD) {730 const ramp = Math.min(731 1,732 (mag - CORRIDOR_DEAD) / (CORRIDOR_EDGE - CORRIDOR_DEAD),733 );734 const lvel = vel[0] * ux + vel[2] * uz;735 const a = clamp(736 (-28 * (off - Math.sign(off) * CORRIDOR_DEAD) - 8 * lvel) * ramp,737 -10,738 10,739 );740 out[0] += ux * a * mass;741 out[2] += uz * a * mass;742 }743 }744 }745 // --- shared zone extras (identical to the assist law) ---746 // Lateral centering spring in boost/split/gravity zones: launches and747 // merges must be straight; these zones are straight by construction.748 if (centering) {749 const r = rail.pts[idx];750 const nl = Math.hypot(t[2], t[0]) || 1;751 const ux = t[2] / nl;752 const uz = -t[0] / nl;753 const off = (pos[0] - r[0]) * ux + (pos[2] - r[2]) * uz;754 const lvel = vel[0] * ux + vel[2] * uz;755 const a = clamp(-40 * off - 12 * lvel, -14, 14);756 out[0] += ux * a * mass;757 out[2] += uz * a * mass;758 }759 // Chutes pull harder — descending stations only (grav mods never fight760 // a climb; that wedged marbles at ramp bases).761 if (mod?.grav && mod.grav !== 1 && t[1] < -0.05 && climbTy < 0.12) {762 out[1] += (mod.grav - 1) * GRAVITY_Y * mass;763 }764 return out[0] === 0 && out[1] === 0 && out[2] === 0 ? null : out;765}