clone

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

M11: JUMP set-piece - first real channel gap (planner trajectory math, catch pan, shared rail law)

Dami committed 7360d55
commit
7360d5598681d856b91d69f04299a9c2a5663ccf
parent
3560b68744224d8155bc134c2bfa7586982a4bf3
author
Dami <dami.etoile@gmail.com>
10 files changed, +614 -238
Mprogress.md26+++++
Msrc/client/endless.ts107++++---------------
Msrc/client/physics/engine.ts23+++-
Asrc/client/physics/rail.ts167++++++++++++++++++++++++++++++
Msrc/gen/endless/path.ts60++++++++++-
Msrc/gen/endless/proxies.ts100++++++++++++++----
Msrc/gen/endless/world.ts53+++++++--
Mtests/physics_test.ts118+++++++++++-----------
Mtests/scenario_test.ts65++++++++++++
Mtools/physics-debug/phys-deep.ts133+++++++++++++++---------
M progress.md +26 -0
@@ -594,6 +594,32 @@ b3CreateMeshShape/b3World_Step/b3Body_GetPosition); double-precision positions
594594 by default — fits the endless line's unbounded coords; spike .wasm 369 KB.595595 b3MakeOffsetBoxHull is the workhorse for channel proxies.]596596 597+**M11 UPDATE (jump set-piece live, this round):** JUMP = the first real598+channel gap. Planner clause (path.ts tryJump, weight 0.8): 3 flat approach599+cells under a boost zone → 1 gap cell reserved as slant-b REVERSED (the only600+1-cell 0.5-descent that isn't a drop-stack) → 2 flat landing cells at601+lip−0.5. Trajectory priced at plan time: launch ≈ 6.1 world u/s (boost602+target 3.2×1.9), 1.0-world drop takes 0.316 s ≈ the 2.0-world gap. world.ts603+DROPS the gap window's placements (windows are ring-sorted; placement spans604+recovered via LoopSegment.placementIndex) — absence of the piece IS the gap;605+windowMods carries "jump". proxies.ts: gap path-segments emit no ribbon606+(strips extend to the gap edges, unlike shafts) + a catch pan reaching ~1.4607+world back under the flight path (top a hair above the landing sheet, the608+shaft-pan trick) + funnel walls at ±0.98 — undershoot-tolerant down to609+~1 u/s lip speed by construction. NEW SHARED RAIL LAW610+(src/client/physics/rail.ts): railForSegment/concatRails/stationForce — ONE611+control law for client, tests/physics_test.ts, and phys-deep (paths now612+relative). assistForce gained speedScale (boost scales TARGET + authority —613+the old force-scale hack couldn't pass 3.2 on flats, which jumps need);614+grav tile mods apply ONLY on descending stations (extra gravity on ramp615+CLIMBS wedged marbles); hands-off threshold 0.6→0.7 (climb cubics peak at616+ty 0.63 and the carry died mid-slope — chute dives start at 0.77, still617+hands-off). Scenarios: jump trajectory contract (scenario_test.ts,618+rule-based over 15 seeds×5 segs) + cold-entry jump survivability619+(physics_test.ts, spawn at 2.2 before the approach). Gates: 67 tests green,620+deep fuzz 150 seeds × 40 segs = 0 failures, 0.49 jumps/seg, ~2 ms/seg.621+Goldens/genVersion untouched (finite pipeline byte-identical).622+597623 **M11 IN PROGRESS — ribbon GREEN + Marble Blast feel live.** The smooth channel598624 trimesh is the default collision law (repro chain: upV cross order,599625 layout-handed winding, boundary-port extrapolation, crease rounding, tall
M src/client/endless.ts +21 -86
@@ -22,32 +22,22 @@ import { type Cameras, type CamMode, createCameras } from "./camera.ts";
2222 import { EndlessWorld, KEEP_SEGMENTS } from "../gen/endless/world.ts";2323 import { TRACK_SCALE } from "../gen/endless/scale.ts";2424 import { ExitAnim, SpawnAnim } from "./streamAnim.ts";25+import { PhysicsEngine, type PhysicsModule } from "./physics/engine.ts";2526 import {26- assistForce,27- PhysicsEngine,28- type PhysicsModule,29-} from "./physics/engine.ts";27+ gapSegmentsOf,28+ type Rail,29+ railForSegment,30+ stationForce,31+} from "./physics/rail.ts";3032 import { channelProxies, islandProxies } from "../gen/endless/proxies.ts";31-import { segLength, segPoint } from "../gen/geom.ts";3233 import { buildIslandInstanced, type InstancedBuild } from "./islandRender.ts";3334 import { Atmosphere, drawZone } from "./atmosphere.ts";3435 import type { DecorCatalog } from "../gen/decor.ts";3536 import { genVersion } from "../gen/version.ts";3637 import { installCua } from "./cua.ts";3738 38-/**39- * Per-tile physics modifiers (M11, Marble Blast-inspired): piece families40- * carry movement character. `speed` scales the assist's target/safe41- * speeds (helix = banked spiral, ride it faster); `grav` scales gravity42- * while on the tile (chute families pull harder — the momentum that43- * carries the next climb). The same channel will carry icy/boost/bouncer44- * surfaces and island gravity rotation later.45- */46-const TILE_MODS: Record<string, { speed?: number; grav?: number }> = {47- helix: { speed: 1.35 },48- slant: { grav: 1.2 },49- ramp: { grav: 1.15 },50-};39+// Per-tile physics modifiers + planner zones live in physics/rail.ts now40+// (M11): ONE rail law shared with the headless survivability tests.5141 5242 /** Segments built ahead of / kept behind the marble's segment. */5343 const AHEAD = 3;
@@ -220,15 +210,7 @@ class Session {
220210 readonly atmosphere: Atmosphere;221211 /** Box3D driver (null → kinematic rail rider, ?phys=0). */222212 private engine: PhysicsEngine | null = null;223- private readonly rails = new Map<224- number,225- {226- pts: number[][];227- tans: number[][];228- s: number[];229- mods: ({ speed?: number; grav?: number } | null)[];230- }231- >();213+ private readonly rails = new Map<number, Rail>();232214 private nearest: { seg: number; idx: number } = { seg: 0, idx: 0 };233215 deaths = 0;234216 /** Stall net: no rail progress for a while → respawn. Progress must
@@ -382,40 +364,13 @@ class Session {
382364 const length = curve.getLength();383365 if (this.engine) {384366 this.engine.addSegment(i, [385- ...channelProxies(data.path),367+ ...channelProxies(data.path, true, gapSegmentsOf(data)),386368 ...data.islands.flatMap((isle) => islandProxies(isle.placements)),387369 ]);388- // Dense rail (points/tangents/global s) for nearest-point progress389- // and the assist force — same sampling law as the headless tests.390- const rail = {391- pts: [] as number[][],392- tans: [] as number[][],393- s: [] as number[],394- mods: [] as ({ speed?: number; grav?: number } | null)[],395- };396- let acc = 0;397- for (const [lsIdx, ls] of data.path.segments.entries()) {398- const zone = data.windowMods[lsIdx];399- const base = TILE_MODS[data.pieceFamilies[lsIdx]] ?? null;400- // Planner zones outrank family character: a boost-chute hurls.401- const mod = zone === "boost" ? { speed: 1.9, grav: 1.2 } : base;402- for (const part of ls.curve) {403- const plen = segLength(part);404- const steps = Math.max(1, Math.ceil(plen / 0.5));405- for (let j = 0; j < steps; j++) {406- const a = segPoint(part, j / steps);407- const b = segPoint(part, Math.min(1, (j + 1) / steps));408- const d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];409- const l = Math.hypot(d[0], d[1], d[2]) || 1;410- rail.pts.push([a[0], a[1], a[2]]);411- rail.tans.push([d[0] / l, d[1] / l, d[2] / l]);412- rail.s.push(acc);413- rail.mods.push(mod);414- acc += l;415- }416- }417- }418- this.rails.set(i, rail);370+ // Dense rail (points/tangents/s/mods) for nearest-point progress and371+ // the assist force — the shared law (rail.ts), one truth with the372+ // headless tests.373+ this.rails.set(i, railForSegment(data));419374 }420375 const prev = this.sMeta.get(i - 1);421376 const startS = i === 0 ? 0 : (prev ? prev.start + prev.len : 0);
@@ -574,36 +529,16 @@ class Session {
574529 this.updateNearest(st.pos);575530 const rail = this.rails.get(this.nearest.seg);576531 if (!rail) return;577- const t = rail.tans[this.nearest.idx] as [number, number, number];578- let climbTy = t[1];579- let curveAhead = 0;580- for (let k = 1; k <= 8; k++) {581- const look = rail.tans[this.nearest.idx + k];582- const prev = rail.tans[this.nearest.idx + k - 1];583- if (!look || !prev) break;584- climbTy = Math.max(climbTy, look[1]);585- const dot = Math.min(586- 1,587- look[0] * prev[0] + look[1] * prev[1] + look[2] * prev[2],588- );589- curveAhead += Math.acos(Math.max(-1, dot));590- }591- const mod = rail.mods[this.nearest.idx];592- const f = assistForce(532+ // The complete per-step control law lives in rail.ts (shared with533+ // the headless tests); lookahead chains into the next segment.534+ const f = stationForce(535+ rail,536+ this.nearest.idx,593537 st.vel,594- t,595538 engine.mass,596- climbTy,597- curveAhead / (mod?.speed ?? 1),539+ this.rails.get(this.nearest.seg + 1) ?? null,598540 );599- if (f) {600- const sc = mod?.speed ?? 1;601- engine.force(f[0] * sc, f[1] * sc, f[2] * sc);602- }603- if (mod?.grav && mod.grav !== 1) {604- // Extra gravity while on the tile (chutes pull harder).605- engine.force(0, (mod.grav - 1) * -20 * engine.mass * -1, 0);606- }541+ if (f) engine.force(f[0], f[1], f[2]);607542 });608543 const st = engine.state();609544 this.marbleObj.position.set(st.pos[0], st.pos[1], st.pos[2]);
M src/client/physics/engine.ts +15 -8
@@ -90,34 +90,41 @@ export function assistForce(
9090 * arrivals into tight curves launch over the walls; brake to a safe9191 * entry speed instead (the driver-side speed envelope). */9292 curveAhead = 0,93+ /** Per-tile speed modifier (M11): scales the cruise target AND the94+ * assist authority — a boost zone actually accelerates the marble to95+ * launch speed (force-scaling alone can't pass the target check). */96+ speedScale = 1,9397 ): [number, number, number] | null {94- // Near-vertical rail (drop shafts): the fall is gravity's job — an95- // assist here just grinds the marble into the cup.96- if (Math.abs(tangent[1]) > 0.6) return null;98+ // Near-vertical rail (drop shafts, steep chute dives ty ≥ 0.77): the99+ // fall is gravity's job — an assist here just grinds the marble into100+ // the cup. Climbs top out at ty ≈ 0.63 (ramp-long-c mid-cubic), so 0.7101+ // keeps the climb carry alive through the WHOLE climb — at 0.6 it cut102+ // out mid-slope and marbles cycled at the base of 1.0-climbs.103+ if (Math.abs(tangent[1]) > 0.7) return null;97104 const vAlong = vel[0] * tangent[0] + vel[1] * tangent[1] +98105 vel[2] * tangent[2];99106 // Cruise/brake tuned to the proven survivable envelope. M11 NOTE: a100107 // global speed raise (4.2/10) launched marbles off curve seams and101108 // crest lips — dynamic speed needs CURVATURE-AWARE braking (the speed102109 // envelope) and banked/taller walls, not a bigger constant.103- const TARGET = 3.2;110+ const target = 3.2 * speedScale;104111 const MAX = 9;105- const safe = Math.max(TARGET, MAX - curveAhead * 5.5);112+ const safe = Math.max(target, MAX - curveAhead * 5.5);106113 if (vAlong > safe) {107114 const f = mass * Math.min(10, 3 + (vAlong - safe) * 2);108115 return [-tangent[0] * f, -tangent[1] * f, -tangent[2] * f];109116 }110117 const climbing = tangent[1] > 0.12 || climbTy > 0.12;111- if (climbing && vAlong < TARGET) {118+ if (climbing && vAlong < target) {112119 // Cancel the along-slope gravity component and push a little extra.113120 const a = -GRAVITY_Y * Math.max(tangent[1], climbTy) + 2.6;114121 const f = mass * a;115122 return [tangent[0] * f, tangent[1] * f, tangent[2] * f];116123 }117- if (vAlong < TARGET) {124+ if (vAlong < target) {118125 // Marble Blast responsiveness = authority (their angular accel ≈ 15119126 // linear); scaled to our slalom widths.120- const f = mass * 6;127+ const f = mass * 6 * speedScale;121128 return [tangent[0] * f, tangent[1] * f, tangent[2] * f];122129 }123130 return null;
A src/client/physics/rail.ts +167 -0
@@ -0,0 +1,167 @@
1+/**2+ * The shared rail law (M11) — ONE truth for the client driver, the headless3+ * survivability tests, and the phys-debug tools.4+ *5+ * A rail is a dense polyline over a segment's world-space path (points,6+ * tangents, cumulative s) with a per-station physics modifier resolved from7+ * the per-tile channel: piece families carry movement character (helix =8+ * banked spiral, ride it faster; chutes pull harder) and planner set-piece9+ * zones outrank families (a boost-chute hurls; a jump gap is hands-off10+ * ballistic flight).11+ *12+ * `stationForce` is the entire per-step control law: nearest-station13+ * lookahead (steepest climb ahead + curvature ahead), the assist force, and14+ * the modifier extras (gravity scaling), combined into one world-space15+ * force vector. Drivers differ only in how they find the nearest station.16+ */17+import type { LoopPath } from "../../shared/types.ts";18+import { segLength, segPoint } from "../../gen/geom.ts";19+import { assistForce, GRAVITY_Y } from "./engine.ts";20+21+/** Resolved per-station physics modifier. */22+export interface RailMod {23+ /** Scales the assist's target/safe speeds (and its authority). */24+ speed?: number;25+ /** Gravity multiplier while on the tile (chutes pull harder). */26+ grav?: number;27+ /** Ballistic flight (jump gaps): the assist is hands-off entirely. */28+ flight?: boolean;29+}30+31+export interface Rail {32+ pts: [number, number, number][];33+ tans: [number, number, number][];34+ /** Cumulative arc length, starting at 0 for this rail. */35+ s: number[];36+ mods: (RailMod | null)[];37+}38+39+/**40+ * Per-tile movement character by piece family (Marble Blast-inspired).41+ * The same channel carries icy/boost/bouncer surfaces and island gravity42+ * rotation later.43+ */44+const TILE_MODS: Record<string, RailMod> = {45+ helix: { speed: 1.35 },46+ slant: { grav: 1.2 },47+ ramp: { grav: 1.15 },48+};49+50+/** Planner zone kinds → rail modifiers. Zones outrank family character. */51+const ZONE_MODS: Record<string, RailMod> = {52+ boost: { speed: 1.9, grav: 1.2 },53+ jump: { flight: true },54+};55+56+/** The station spacing every driver samples at. */57+export const RAIL_STEP = 0.5;58+59+/** The segment fields the rail law needs (EndlessSegment satisfies it). */60+export interface RailSource {61+ path: LoopPath;62+ pieceFamilies: string[];63+ windowMods: (string | null)[];64+}65+66+/** Path-segment indices that are jump gaps — the collision proxies emit no67+ * channel there (absence of geometry IS the gap). */68+export function gapSegmentsOf(seg: RailSource): Set<number> {69+ const out = new Set<number>();70+ seg.windowMods.forEach((m, i) => {71+ if (m === "jump") out.add(i);72+ });73+ return out;74+}75+76+/** Dense rail for one segment (local s from 0). */77+export function railForSegment(seg: RailSource): Rail {78+ const rail: Rail = { pts: [], tans: [], s: [], mods: [] };79+ let acc = 0;80+ for (const [lsIdx, ls] of seg.path.segments.entries()) {81+ const zone = seg.windowMods[lsIdx];82+ const mod = (zone ? ZONE_MODS[zone] : undefined) ??83+ TILE_MODS[seg.pieceFamilies[lsIdx]] ?? null;84+ for (const part of ls.curve) {85+ const plen = segLength(part);86+ const steps = Math.max(1, Math.ceil(plen / RAIL_STEP));87+ for (let j = 0; j < steps; j++) {88+ const a = segPoint(part, j / steps);89+ const b = segPoint(part, Math.min(1, (j + 1) / steps));90+ const d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];91+ const l = Math.hypot(d[0], d[1], d[2]) || 1;92+ rail.pts.push([a[0], a[1], a[2]]);93+ rail.tans.push([d[0] / l, d[1] / l, d[2] / l]);94+ rail.s.push(acc);95+ rail.mods.push(mod);96+ acc += l;97+ }98+ }99+ }100+ return rail;101+}102+103+/** Chain per-segment rails into one (headless drivers). */104+export function concatRails(rails: readonly Rail[]): Rail {105+ const out: Rail = { pts: [], tans: [], s: [], mods: [] };106+ let base = 0;107+ for (const r of rails) {108+ out.pts.push(...r.pts);109+ out.tans.push(...r.tans);110+ for (const s of r.s) out.s.push(base + s);111+ out.mods.push(...r.mods);112+ if (r.s.length > 0) base += r.s[r.s.length - 1] + RAIL_STEP;113+ }114+ return out;115+}116+117+/** Stations of rail lookahead (≈4 world units at RAIL_STEP). */118+const LOOKAHEAD = 8;119+120+/**121+ * The complete per-step control law at a station: assist toward the local122+ * tangent (sized by the steepest climb ahead, braked by the curvature123+ * ahead, scaled by the station's modifier) plus modifier gravity extras.124+ * Returns one world-space force or null. `nextRail` chains lookahead125+ * across a segment boundary (client drivers keep per-segment rails).126+ */127+export function stationForce(128+ rail: Rail,129+ idx: number,130+ vel: readonly [number, number, number],131+ mass: number,132+ nextRail: Rail | null = null,133+): [number, number, number] | null {134+ const tanAt = (i: number): [number, number, number] | null =>135+ rail.tans[i] ??136+ (nextRail ? nextRail.tans[i - rail.tans.length] ?? null : null);137+ const t = rail.tans[idx];138+ if (!t) return null;139+ const mod = rail.mods[idx];140+ // Jump gaps: pure ballistics — no assist, no gravity meddling. The141+ // launch speed was priced at plan time; touching the marble mid-flight142+ // would change where it lands.143+ if (mod?.flight) return null;144+ let climbTy = t[1];145+ let curveAhead = 0;146+ for (let k = 1; k <= LOOKAHEAD; k++) {147+ const look = tanAt(idx + k);148+ const prev = tanAt(idx + k - 1);149+ if (!look || !prev) break;150+ climbTy = Math.max(climbTy, look[1]);151+ const dot = Math.min(152+ 1,153+ look[0] * prev[0] + look[1] * prev[1] + look[2] * prev[2],154+ );155+ curveAhead += Math.acos(Math.max(-1, dot));156+ }157+ const f = assistForce(vel, t, mass, climbTy, curveAhead, mod?.speed ?? 1) ??158+ [0, 0, 0];159+ const out: [number, number, number] = [f[0], f[1], f[2]];160+ // Extra pull ONLY on descending stations — grav mods mean "chutes pull161+ // harder", never "fight the climb carry" (a ramp tile is the same family162+ // going up; extra gravity there wedged marbles at climb bases).163+ if (mod?.grav && mod.grav !== 1 && t[1] < -0.05 && climbTy < 0.12) {164+ out[1] += (mod.grav - 1) * GRAVITY_Y * mass;165+ }166+ return out[0] === 0 && out[1] === 0 && out[2] === 0 ? null : out;167+}
M src/gen/endless/path.ts +58 -2
@@ -105,11 +105,15 @@ export interface IslandCrossing {
105105 margin: number;106106 }107107 108-/** Planner-placed physics zone (cell-indexed): the set-piece channel. */108+/** Planner-placed physics zone (cell-indexed): the set-piece channel.109+ * "boost" scales the assist's target speed (approaches, chutes); "jump"110+ * marks a gap cell — its realized piece is dropped from the placements and111+ * the collision proxies emit no channel there (ballistic flight, priced by112+ * trajectory math at plan time). */109113 export interface PlanMod {110114 start: number;111115 len: number;112- kind: "boost";116+ kind: "boost" | "jump";113117 }114118 115119 export interface SegmentPlan {
@@ -360,6 +364,7 @@ class SegmentBuilder {
360364 ["jog", 1.5],361365 ["slope", 3],362366 ["cornerDrop", 1.2],367+ ["jump", 0.8],363368 ] as const,364369 );365370 const attempts: (() => boolean)[] = [];
@@ -404,6 +409,8 @@ class SegmentBuilder {
404409 return this.trySlope(rng);405410 case "cornerDrop":406411 return this.tryCornerDrop(rng);412+ case "jump":413+ return this.tryJump();407414 }408415 for (const attempt of attempts) if (attempt()) return true;409416 return false;
@@ -543,6 +550,55 @@ class SegmentBuilder {
543550 return true;544551 }545552 553+ /**554+ * JUMP set-piece (M11): a real 1-cell channel gap. Layout is555+ * boost-approach (3 flat cells — the assist accelerates to launch556+ * speed) → gap cell (reserved slant-b DESCENDING 0.5; world.ts drops557+ * its placement so nothing renders and the proxies emit no channel —558+ * absence IS the gap) → 2 flat landing cells 0.5 lower.559+ *560+ * Trajectory, priced here at plan time: launch is horizontal at the561+ * boost target v ≈ 3.2·1.9·TRACK_SCALE-adjusted ≈ 6.1 world u/s; the562+ * 0.5-track drop (1.0 world) takes t = √(2·1.0/20) ≈ 0.316 s, covering563+ * v·t ≈ 1.9 world of the 2.0-world gap — the marble lands on the catch564+ * at the landing's leading edge (the proxies extend a pan under the lip565+ * so slow arrivals are caught too; hot arrivals ≤ the 9 u/s brake cap566+ * land within the first landing cell). The gap cell's claim spans down567+ * to the landing base — the flight band.568+ */569+ private tryJump(): boolean {570+ const APPROACH = 3;571+ const LANDING = 2;572+ // The gap window realizes as slant-b reversed (the only 1-cell 0.5573+ // descent that isn't a drop-stack); its centerline keeps the rail574+ // continuous through the flight.575+ if (576+ !this.registry.variants.some((v) =>577+ v.id === "slant-b" && v.reversed && v.moves.length === 1578+ )579+ ) return false;580+ const steps: Step[] = [581+ ...this.straightSteps(APPROACH),582+ {583+ move: "F",584+ band: [-0.5, CHANNEL_CLEARANCE] as const,585+ stepAfter: -0.5,586+ },587+ ...this.straightSteps(LANDING),588+ ];589+ const start = this.cells.length;590+ if (!this.commitSteps(steps)) return false;591+ this.mods.push({ start, len: APPROACH, kind: "boost" });592+ this.mods.push({ start: start + APPROACH, len: 1, kind: "jump" });593+ this.reservations.push({594+ start: start + APPROACH,595+ len: 1,596+ pieceIds: ["slant-b"],597+ reversed: true,598+ });599+ return true;600+ }601+546602 /** Last resort when boxed in: steep 1.5 chute straight down and out. */547603 private tryEscape(rng: Rng): boolean {548604 for (const delta of ["1.5", "1", "0.5"]) {
M src/gen/endless/proxies.ts +79 -21
@@ -99,18 +99,24 @@ interface Station {
9999 halfLen: number;100100 /** Vertical drop sample (no floor; cup + guide tube instead). */101101 vertical: boolean;102+ /** Jump-gap sample (M11): no channel at all — ballistic flight. */103+ gap: boolean;102104 dy: number;103105 }104106 105107 /** Channel slabs for one segment's world-space path. Two passes: sample106108 * every part into stations, then emit — drop shafts need LOOKAHEAD (the107- * guide tube opens toward the receiver's exit heading). */109+ * guide tube opens toward the receiver's exit heading). `gapSegments`110+ * are path-segment indices that are jump gaps (rail.ts gapSegmentsOf):111+ * they emit no channel, and the landing side gets a catch pan + funnel. */108112 export function channelProxies(109113 path: LoopPath,110114 useRibbon = true,115+ gapSegments?: ReadonlySet<number>,111116 ): CollisionProxy[] {112117 const stations: Station[] = [];113- for (const seg of path.segments) {118+ for (const [segIdx, seg] of path.segments.entries()) {119+ const gap = gapSegments?.has(segIdx) ?? false;114120 for (const part of seg.curve) {115121 const len = segLength(part);116122 // Curvature-aware sampling: arcs bound the per-slab turn angle;
@@ -135,13 +141,14 @@ export function channelProxies(
135141 const horiz = Math.hypot(dx, dz);136142 const total = Math.hypot(dx, dy, dz);137143 if (total < 1e-9) continue;138- const vertical = Math.abs(dy / total) > VERTICAL_LIMIT;144+ const vertical = !gap && Math.abs(dy / total) > VERTICAL_LIMIT;139145 stations.push({140146 p: [p[0], p[1], p[2]],141147 yaw: vertical ? 0 : Math.atan2(dx, dz),142148 pitch: vertical ? 0 : -Math.atan2(dy, horiz),143149 halfLen,144150 vertical,151+ gap,145152 dy,146153 });147154 }
@@ -162,30 +169,46 @@ export function channelProxies(
162169 // cross-order fix). Slabs remain available for A/B comparison.163170 const USE_RIBBON = useRibbon;164171 let strip: Station[] = [];165- let stripStartsAtShaft = false;166- const flushStrip = (endsAtShaft: boolean) => {167- if (strip.length < 2) {168- strip = [];169- return;170- }171- if (USE_RIBBON) {172- // Extend to the true strip ends (segment boundaries share ring173- // positions with the neighbor segment → seamless), but never toward174- // a drop shaft (that would overhang the mouth).175- out.push(ribbonOf(strip, !stripStartsAtShaft, !endsAtShaft));176- } else {177- strip.forEach((st, idx) =>178- emitSlabs(st, out, endsAtShaft && idx === strip.length - 1)179- );172+ /** Whether the CURRENT strip's start should extrapolate to its true173+ * port: yes at the path start, at segment boundaries, and after a jump174+ * gap (the landing catches at its very edge); never out of a shaft. */175+ let extendStart = true;176+ const flushStrip = (extendEnd: boolean, followingExtends: boolean) => {177+ if (strip.length >= 2) {178+ if (USE_RIBBON) {179+ // Extend to the true strip ends (segment boundaries share ring180+ // positions with the neighbor segment → seamless; jump lips run181+ // to the gap edge), but never toward a drop shaft (that would182+ // overhang the mouth).183+ out.push(ribbonOf(strip, extendStart, extendEnd));184+ } else {185+ strip.forEach((st, idx) =>186+ emitSlabs(st, out, !extendEnd && idx === strip.length - 1)187+ );188+ }180189 }181190 strip = [];182- stripStartsAtShaft = endsAtShaft;191+ extendStart = followingExtends;183192 };184193 for (let i = 0; i < stations.length; i++) {185194 const st = stations[i];186195 const [px, py, pz] = st.p;196+ if (st.gap) {197+ // JUMP gap: the lip ribbon runs to the gap edge, then NOTHING — the198+ // marble flies. The landing side gets a catch pan reaching back199+ // under the flight path plus funnel walls, so undershoots land and200+ // roll on instead of clipping the landing's leading face.201+ flushStrip(true, true);202+ let k = i;203+ while (k < stations.length && stations[k].gap) k++;204+ if (k < stations.length && !stations[k].vertical) {205+ emitCatch(stations[k], out);206+ }207+ i = k - 1;208+ continue;209+ }187210 if (st.vertical) {188- flushStrip(true);211+ flushStrip(false, false);189212 // One CUP PER CONTIGUOUS VERTICAL RUN (per-station emission stacked190213 // overlapping cups that wedged the marble — found the hard way).191214 // Frame: approach yaw; open side: receiver exit yaw.
@@ -245,10 +268,45 @@ export function channelProxies(
245268 }246269 strip.push(st);247270 }248- flushStrip(false);271+ flushStrip(true, true);249272 return out;250273 }251274 275+/** Catch hardware at a jump landing: a pan reaching back under the flight276+ * path (top a hair ABOVE the landing ribbon sheet — arriving below a277+ * one-sided mesh means catching its edge forever) plus funnel walls just278+ * outside the channel profile. Sized so any launch that cleared the lip279+ * at ≥ ~1 u/s lands on pan or ribbon; slower dribbles fall to respawn. */280+function emitCatch(landing: Station, out: CollisionProxy[]): void {281+ const fwdX = Math.sin(landing.yaw);282+ const fwdZ = Math.cos(landing.yaw);283+ const lx = Math.cos(landing.yaw);284+ const lz = -Math.sin(landing.yaw);285+ const floorY = landing.p[1];286+ const cx = landing.p[0] - fwdX * 1.0;287+ const cz = landing.p[2] - fwdZ * 1.0;288+ out.push({289+ kind: "slab",290+ center: [cx, floorY - FLOOR_HALF_T + 0.03, cz],291+ half: [1.0, FLOOR_HALF_T, 1.05],292+ yaw: landing.yaw,293+ pitch: 0,294+ });295+ for (const side of [-1, 1]) {296+ out.push({297+ kind: "slab",298+ center: [299+ cx + lx * 0.98 * side,300+ floorY + 0.35,301+ cz + lz * 0.98 * side,302+ ],303+ half: [0.09, 0.45, 1.05],304+ yaw: landing.yaw,305+ pitch: 0,306+ });307+ }308+}309+252310 /** Slab law (proven, M10): floor follows slope; walls are vertical. The253311 * floor slab approaching a drop shaft pulls back so it doesn't overhang254312 * the mouth. */
M src/gen/endless/world.ts +40 -13
@@ -293,14 +293,48 @@ export class EndlessWorld {
293293 )294294 );295295 296+ // Planner zone kind per window (mods are cell-indexed; windows and297+ // path segments are aligned 1:1).298+ const windowMods = windows.map((w) => {299+ for (const m of plan.mods) {300+ if (w.start < m.start + m.len && m.start < w.start + w.len) {301+ return m.kind;302+ }303+ }304+ return null;305+ });306+ // JUMP gaps: the reserved gap window realized a piece (the WFC needs307+ // the elevation step to be honest), but the set-piece is the ABSENCE308+ // of that piece — drop its placements so nothing renders in the gap309+ // and the collision proxies (which skip "jump" path segments) match310+ // what the eye sees. Placements are emitted in window order, so a311+ // window's span runs from its placementIndex to the next window's.312+ const dropped = new Set<number>();313+ const loopSegs = assembled.loop.segments;314+ for (let wi = 0; wi < loopSegs.length; wi++) {315+ if (windowMods[wi] !== "jump") continue;316+ const startIdx = loopSegs[wi].placementIndex;317+ if (startIdx < 0) continue;318+ let end = assembled.placements.length;319+ for (let wj = wi + 1; wj < loopSegs.length; wj++) {320+ if (loopSegs[wj].placementIndex >= 0) {321+ end = loopSegs[wj].placementIndex;322+ break;323+ }324+ }325+ for (let k = startIdx; k < end; k++) dropped.add(k);326+ }327+296328 // Slalom rule (M8): the generator plans in track cells; the WORLD sees297329 // track pieces at ×TRACK_SCALE (position and mesh scale) while islands298330 // and decor stay 1×. The centerline scales with the track.299- const placements = assembled.placements.map((p): Placement => ({300- ...p,301- pos: scaleVec(p.pos),302- scale: TRACK_SCALE,303- }));331+ const placements = assembled.placements332+ .filter((_, idx) => !dropped.has(idx))333+ .map((p): Placement => ({334+ ...p,335+ pos: scaleVec(p.pos),336+ scale: TRACK_SCALE,337+ }));304338 const path: LoopPath = {305339 segments: assembled.loop.segments.map((s) => {306340 const curve = s.curve.map(scaleSeg);
@@ -314,14 +348,7 @@ export class EndlessWorld {
314348 index: i,315349 plan,316350 pieceFamilies: windows.map((w) => w.def.family),317- windowMods: windows.map((w) => {318- for (const m of plan.mods) {319- if (w.start < m.start + m.len && m.start < w.start + w.len) {320- return m.kind;321- }322- }323- return null;324- }),351+ windowMods,325352 placements,326353 path,327354 islands,
M tests/physics_test.ts +59 -59
@@ -14,14 +14,18 @@ import { assert, assertEquals } from "@std/assert";
1414 import { EndlessWorld } from "../src/gen/endless/world.ts";1515 import { channelProxies, islandProxies } from "../src/gen/endless/proxies.ts";1616 import {17- assistForce,1817 type MarbleState,1918 PhysicsEngine,2019 type PhysicsModule,2120 } from "../src/client/physics/engine.ts";22-import { segLength, segPoint } from "../src/gen/geom.ts";21+import {22+ concatRails,23+ gapSegmentsOf,24+ type Rail,25+ railForSegment,26+ stationForce,27+} from "../src/client/physics/rail.ts";2328 import type { DecorCatalog } from "../src/gen/decor.ts";24-import type { LoopPath } from "../src/shared/types.ts";2529 2630 const catalog: DecorCatalog = {2731 packs: [
@@ -38,37 +42,6 @@ const factory =
3842 (await import(new URL("../static/dist/box3d.mjs", import.meta.url).href))3943 .default as () => Promise<PhysicsModule>;4044 41-/** Dense polyline over a path: points + tangents + cumulative s. */42-interface Rail {43- pts: [number, number, number][];44- tans: [number, number, number][];45- s: number[];46-}47-48-function railOf(paths: LoopPath[]): Rail {49- const rail: Rail = { pts: [], tans: [], s: [] };50- let acc = 0;51- for (const path of paths) {52- for (const seg of path.segments) {53- for (const part of seg.curve) {54- const len = segLength(part);55- const steps = Math.max(1, Math.ceil(len / 0.5));56- for (let i = 0; i < steps; i++) {57- const a = segPoint(part, i / steps);58- const b = segPoint(part, Math.min(1, (i + 1) / steps));59- const d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];60- const l = Math.hypot(d[0], d[1], d[2]) || 1;61- rail.pts.push([a[0], a[1], a[2]]);62- rail.tans.push([d[0] / l, d[1] / l, d[2] / l]);63- rail.s.push(acc);64- acc += l;65- }66- }67- }68- }69- return rail;70-}71-7245 interface RunResult {7346 reachedS: number;7447 minY: number;
@@ -81,8 +54,9 @@ function drive(
8154 segments: number,8255 simSeconds: number,8356 stopAtS = Infinity,57+ spawnAtS = 0,8458 ): RunResult {85- return driveWith(seed, segments, simSeconds, stopAtS);59+ return driveWith(seed, segments, simSeconds, stopAtS, spawnAtS);8660 }8761 8862 let module_: PhysicsModule | null = null;
@@ -92,30 +66,36 @@ function driveWith(
9266 segments: number,9367 simSeconds: number,9468 stopAtS = Infinity,69+ spawnAtS = 0,9570 ): RunResult {9671 const w = new EndlessWorld(seed, catalog);9772 const engine = new PhysicsEngine(module_!);98- const paths: LoopPath[] = [];73+ const rails: Rail[] = [];9974 let lowest = Infinity;10075 for (let i = 0; i < segments; i++) {10176 const seg = w.get(i);102- paths.push(seg.path);77+ rails.push(railForSegment(seg));10378 engine.addSegment(i, [104- ...channelProxies(seg.path),79+ ...channelProxies(seg.path, true, gapSegmentsOf(seg)),10580 ...seg.islands.flatMap((isle) => islandProxies(isle.placements)),10681 ]);10782 for (const c of seg.plan.cells) lowest = Math.min(lowest, c.baseY * 2);10883 }109- const rail = railOf(paths);110- const start = rail.pts[0];111- const t0 = rail.tans[0];84+ // ONE rail law with the client driver: rail.ts builds stations + mods.85+ const rail = concatRails(rails);86+ let spawnIdx = 0;87+ while (spawnIdx < rail.s.length - 1 && rail.s[spawnIdx] < spawnAtS) {88+ spawnIdx++;89+ }90+ const start = rail.pts[spawnIdx];91+ const t0 = rail.tans[spawnIdx];11292 engine.spawnMarble(11393 [start[0], start[1] + 0.4, start[2]],11494 [t0[0] * 2.2, 0, t0[2] * 2.2],11595 );11696 117- let nearest = 0;118- let reachedS = 0;97+ let nearest = spawnIdx;98+ let reachedS = rail.s[spawnIdx];11999 let minY = Infinity;120100 let fell = false;121101 let hash = 1469598103934665603n;
@@ -135,7 +115,7 @@ function driveWith(
135115 step++136116 ) {137117 engine.step(1 / 60, () => {138- // Assist toward the local tangent when slow (per fixed step).118+ // Nearest-station scan, then the shared per-step control law.139119 const p = st.pos;140120 let bestD = Infinity;141121 const lo = Math.max(0, nearest - 30);
@@ -149,21 +129,7 @@ function driveWith(
149129 nearest = i;150130 }151131 }152- const t = rail.tans[nearest];153- let climbTy = t[1];154- let curveAhead = 0;155- for (let k2 = 1; k2 <= 8; k2++) {156- const look = rail.tans[nearest + k2];157- const prev = rail.tans[nearest + k2 - 1];158- if (!look || !prev) break;159- climbTy = Math.max(climbTy, look[1]);160- const dot = Math.min(161- 1,162- look[0] * prev[0] + look[1] * prev[1] + look[2] * prev[2],163- );164- curveAhead += Math.acos(Math.max(-1, dot));165- }166- const f = assistForce(st.vel, t, engine.mass, climbTy, curveAhead);132+ const f = stationForce(rail, nearest, st.vel, engine.mass);167133 if (f) engine.force(f[0], f[1], f[2]);168134 });169135 st = engine.state();
@@ -213,6 +179,40 @@ Deno.test("physics survivability: assisted marble clears early segments", async
213179 );214180 });215181 182+Deno.test("scenario jump-gap: cold entry launches, flies, and is caught", async () => {183+ module_ = await factory();184+ // det-seed segment 0 plans a jump; locate its flight span on the shared185+ // rail, spawn the marble COLD (base speed 2.2) just before the boost186+ // approach, and require it to cross the gap and keep rolling. This is187+ // the envelope MINIMUM — the boost approach alone must build launch188+ // speed. (The envelope maximum is bounded by the assist brake at 9 u/s,189+ // which the catch pan + first landing cell absorb by construction; hot190+ // arrivals are exercised by the 7-seed survivability gate above.)191+ const w = new EndlessWorld("det-seed", catalog);192+ const rails: Rail[] = [];193+ for (let i = 0; i < 3; i++) rails.push(railForSegment(w.get(i)));194+ const rail = concatRails(rails);195+ const first = rail.mods.findIndex((m) => m?.flight);196+ assert(first >= 0, "det-seed early segments lost their jump");197+ let last = first;198+ while (rail.mods[last + 1]?.flight) last++;199+ const flightStart = rail.s[first];200+ const flightEnd = rail.s[last];201+ const r = drive(202+ "det-seed",203+ 4,204+ 30,205+ flightEnd + 10,206+ Math.max(0, flightStart - 14),207+ );208+ assert(!r.fell, "cold-entry marble fell at the jump");209+ assert(210+ r.reachedS >= flightEnd + 9,211+ `cold-entry marble stalled at s=${r.reachedS.toFixed(1)} ` +212+ `(flight ${flightStart.toFixed(1)}–${flightEnd.toFixed(1)})`,213+ );214+});215+216216 Deno.test("physics determinism: identical trajectory hash across runs", async () => {217217 module_ = await factory();218218 const a = drive("det-seed", 4, 45);
M tests/scenario_test.ts +65 -0
@@ -195,3 +195,68 @@ for (const s of STREAM_REGRESSIONS) {
195195 assertStreamInvariants(s.seed, s.through);196196 });197197 }198+199+// ---------------------------------------------------------------------------200+// 3. Set-piece rules — the JUMP gap (M11)201+// ---------------------------------------------------------------------------202+203+Deno.test("jump set-piece: every planned jump obeys the trajectory contract", () => {204+ // Rule-based, no time dimension: for every jump the planner commits, the205+ // layout MUST be 3 flat straight approach cells under a boost zone → one206+ // gap cell (reserved slant-b reversed — realize drops its placement) →207+ // ≥2 flat straight landing cells exactly 0.5 track lower. The trajectory208+ // math (launch ≈ 6.1 world u/s horizontal, 1.0-world drop in 0.316 s over209+ // the 2.0-world gap) is priced INTO this shape; a jump violating it is a210+ // marble thrown into the void.211+ const seeds = [212+ "cua-seed-1",213+ "det-seed",214+ "atmo-seed",215+ ...Array.from({ length: 12 }, (_, i) => `jump-scan-${i}`),216+ ];217+ let jumps = 0;218+ for (const seed of seeds) {219+ const w = new EndlessWorld(seed, catalog);220+ for (let i = 0; i < 5; i++) {221+ const seg = w.get(i);222+ const { cells, mods, reservations } = seg.plan;223+ for (const m of mods) {224+ if (m.kind !== "jump") continue;225+ jumps++;226+ const g = m.start;227+ assert(m.len === 1, `${seed} seg ${i}: jump zone must be one cell`);228+ const res = reservations.find((r) => r.start === g);229+ assert(230+ res !== undefined && res.len === 1 &&231+ res.pieceIds.length === 1 && res.pieceIds[0] === "slant-b" &&232+ res.reversed === true,233+ `${seed} seg ${i}: gap cell @${g} not reserved as slant-b(rev)`,234+ );235+ const lipY = cells[g].baseY;236+ for (let k = g - 3; k < g; k++) {237+ assert(238+ cells[k]?.move === "F" && cells[k].baseY === lipY,239+ `${seed} seg ${i}: approach cell ${k} not flat straight`,240+ );241+ }242+ assert(243+ mods.some((b) =>244+ b.kind === "boost" && b.start <= g - 3 && b.start + b.len >= g245+ ),246+ `${seed} seg ${i}: jump @${g} lacks its boost approach`,247+ );248+ assert(249+ cells[g].move === "F",250+ `${seed} seg ${i}: gap cell must be straight`,251+ );252+ for (let k = g + 1; k <= g + 2; k++) {253+ assert(254+ cells[k]?.move === "F" && cells[k].baseY === lipY - 0.5,255+ `${seed} seg ${i}: landing cell ${k} not flat at lip−0.5`,256+ );257+ }258+ }259+ }260+ }261+ assert(jumps > 0, "no jumps emerged across the scenario corpus");262+});
M tools/physics-debug/phys-deep.ts +84 -49
@@ -1,75 +1,110 @@
1-// Headless deep-drive: find where cua-seed-1 wedges at segment 4+.2-import { EndlessWorld } from "/Users/dami/Developer/marbles/src/gen/endless/world.ts";3-import { channelProxies, islandProxies } from "/Users/dami/Developer/marbles/src/gen/endless/proxies.ts";4-import { assistForce, PhysicsEngine, type PhysicsModule } from "/Users/dami/Developer/marbles/src/client/physics/engine.ts";5-import { segLength, segPoint } from "/Users/dami/Developer/marbles/src/gen/geom.ts";6-const catalog = { packs: [{ slug: "nature-kit", ids: JSON.parse(Deno.readTextFileSync("/Users/dami/Developer/marbles/assets/nature-kit.manifest.json")).pieces.map((p: { id: string }) => p.id) }] };7-const m = await ((await import("/Users/dami/Developer/marbles/static/dist/box3d.mjs")).default as () => Promise<PhysicsModule>)();1+// Headless deep-drive: run the real wasm marble over 8 segments with the2+// SHARED rail law (src/client/physics/rail.ts — one law, one truth with the3+// client and tests); prints FELL/WEDGED with rail context.4+// Usage: deno run --allow-read tools/physics-debug/phys-deep.ts <seed>5+import { EndlessWorld } from "../../src/gen/endless/world.ts";6+import {7+ channelProxies,8+ islandProxies,9+} from "../../src/gen/endless/proxies.ts";10+import {11+ PhysicsEngine,12+ type PhysicsModule,13+} from "../../src/client/physics/engine.ts";14+import {15+ concatRails,16+ gapSegmentsOf,17+ railForSegment,18+ stationForce,19+} from "../../src/client/physics/rail.ts";20+21+const catalog = {22+ packs: [{23+ slug: "nature-kit",24+ ids: JSON.parse(25+ Deno.readTextFileSync(26+ new URL("../../assets/nature-kit.manifest.json", import.meta.url),27+ ),28+ ).pieces.map((p: { id: string }) => p.id),29+ }],30+};31+const m = await ((await import(32+ new URL("../../static/dist/box3d.mjs", import.meta.url).href33+)).default as () => Promise<PhysicsModule>)();834 const w = new EndlessWorld(Deno.args[0] ?? "cua-seed-1", catalog);935 const engine = new PhysicsEngine(m);10-const rail: { p: number[]; t: number[]; s: number }[] = [];11-let acc = 0;36+const rails = [];1237 for (let i = 0; i < 8; i++) {1338 const seg = w.get(i);14- engine.addSegment(i, [...channelProxies(seg.path), ...seg.islands.flatMap((x) => islandProxies(x.placements))]);15- for (const part of seg.path.segments.flatMap((q) => q.curve)) {16- const len = segLength(part);17- const steps = Math.max(1, Math.ceil(len / 0.5));18- for (let j = 0; j < steps; j++) {19- const a = segPoint(part, j / steps);20- const b = segPoint(part, Math.min(1, (j + 1) / steps));21- const d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];22- const l = Math.hypot(...d) || 1;23- rail.push({ p: [...a], t: d.map((v) => v / l), s: acc });24- acc += l;25- }26- }39+ engine.addSegment(i, [40+ ...channelProxies(seg.path, true, gapSegmentsOf(seg)),41+ ...seg.islands.flatMap((x) => islandProxies(x.placements)),42+ ]);43+ rails.push(railForSegment(seg));2744 }28-engine.spawnMarble([rail[0].p[0], rail[0].p[1] + 0.4, rail[0].p[2]], [rail[0].t[0] * 2.2, 0, rail[0].t[2] * 2.2]);45+const rail = concatRails(rails);46+engine.spawnMarble(47+ [rail.pts[0][0], rail.pts[0][1] + 0.4, rail.pts[0][2]],48+ [rail.tans[0][0] * 2.2, 0, rail.tans[0][2] * 2.2],49+);2950 let nearest = 0;30-let lastS = 0, stall = 0;51+let lastS = 0, stall = 0, fell = false;3152 for (let step = 0; step < 240 * 60; step++) {3253 engine.step(1 / 60, () => {3354 const p = engine.state().pos;3455 let bd = Infinity;35- for (let i = Math.max(0, nearest - 25); i <= Math.min(rail.length - 1, nearest + 50); i++) {36- const r = rail[i].p;56+ for (57+ let i = Math.max(0, nearest - 25);58+ i <= Math.min(rail.pts.length - 1, nearest + 50);59+ i++60+ ) {61+ const r = rail.pts[i];3762 const d = (r[0] - p[0]) ** 2 + (r[1] - p[1]) ** 2 + (r[2] - p[2]) ** 2;38- if (d < bd) { bd = d; nearest = i; }63+ if (d < bd) {64+ bd = d;65+ nearest = i;66+ }3967 }4068 const st = engine.state();41- const t = rail[nearest].t as [number, number, number];42- let climbTy = t[1];43- let curveAhead = 0;44- for (let k2 = 1; k2 <= 8; k2++) {45- const look = rail[nearest + k2]?.t;46- const prev = rail[nearest + k2 - 1]?.t;47- if (!look || !prev) break;48- climbTy = Math.max(climbTy, look[1]);49- const dot = Math.min(1, look[0]*prev[0] + look[1]*prev[1] + look[2]*prev[2]);50- curveAhead += Math.acos(Math.max(-1, dot));51- }52- const f = assistForce(st.vel, t, engine.mass, climbTy, curveAhead);69+ const f = stationForce(rail, nearest, st.vel, engine.mass);5370 if (f) engine.force(f[0], f[1], f[2]);5471 });5572 {5673 const stq = engine.state();57- const g = globalThis as { fell?: boolean };58- if (!g.fell && stq.pos[1] < rail[nearest].p[1] - 6) {59- g.fell = true;74+ if (!fell && stq.pos[1] < rail.pts[nearest][1] - 6) {75+ fell = true;6076 console.log(61- `FELL at s=${rail[nearest].s.toFixed(1)} pos=(${stq.pos.map((v) => v.toFixed(2))}) ` +62- `rail=(${rail[nearest].p.map((v) => v.toFixed(2))}) tan=(${rail[nearest].t.map((v) => v.toFixed(2))}) ` +63- `vel=(${stq.vel.map((v) => v.toFixed(2))})`,77+ `FELL at s=${rail.s[nearest].toFixed(1)} pos=(${78+ stq.pos.map((v) => v.toFixed(2))79+ }) ` +80+ `rail=(${rail.pts[nearest].map((v) => v.toFixed(2))}) tan=(${81+ rail.tans[nearest].map((v) => v.toFixed(2))82+ }) ` +83+ `mod=${JSON.stringify(rail.mods[nearest])} vel=(${84+ stq.vel.map((v) => v.toFixed(2))85+ })`,6486 );6587 }6688 }67- const s = rail[nearest].s;68- if (s > lastS + 0.4) { lastS = s; stall = 0; } else stall++;89+ const s = rail.s[nearest];90+ if (s > lastS + 0.4) {91+ lastS = s;92+ stall = 0;93+ } else stall++;6994 if (stall > 15 * 60) {7095 const st = engine.state();71- console.log(`WEDGED s=${s.toFixed(1)} pos=(${st.pos.map((v)=>v.toFixed(4))}) rail=(${rail[nearest].p.map((v)=>v.toFixed(2))}) tan=(${rail[nearest].t.map((v)=>v.toFixed(2))})`);96+ console.log(97+ `WEDGED s=${s.toFixed(1)} pos=(${st.pos.map((v) => v.toFixed(4))}) ` +98+ `rail=(${rail.pts[nearest].map((v) => v.toFixed(2))}) tan=(${99+ rail.tans[nearest].map((v) => v.toFixed(2))100+ }) mod=${JSON.stringify(rail.mods[nearest])}`,101+ );72102 break;73103 }74104 }75-console.log("final s:", rail[nearest].s.toFixed(1), "/", acc.toFixed(1));105+console.log(106+ "final s:",107+ rail.s[nearest].toFixed(1),108+ "/",109+ rail.s[rail.s.length - 1].toFixed(1),110+);