clone

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

M8: slalom proportion - track x2, world 1x (endless)

Dami committed bcae1f2
The generator plans in track cells; the world renders track pieces at
TRACK_SCALE=2 (position + mesh scale) with the centerline scaled to
match, while the marble, island blocks, and decor keep world scale 1.
Channel ~= 3.5 marble diameters - the slalom the physics era needs.

Islands now generate on the world grid: one track cell = 2x2 world cells
(worldCellsOf), so mass columns, cap tiles, and the island decor field
all gain resolution; Island.topY/placements are world-space. Canopy
airspace re-derived in track units. Endless-only: loop mode and its
goldens untouched. Follow cam rides higher and farther back
(CameraOptions) so slalom-scale pieces don't swallow it.
commit
bcae1f2fc6dde9881007fe0f00843631c869bf3c
parent
7fc0c8c868b50fb37e43ac2b53400dc1111cb65c
author
Dami <dami.etoile@gmail.com>
9 files changed, +268 -109
Mprogress.md24+++-
Msrc/client/camera.ts18++-
Msrc/client/endless.ts17++-
Msrc/gen/endless/islands.ts174+++++++++++++++++-------------
Msrc/gen/endless/path.ts8+-
Asrc/gen/endless/scale.ts64+++++++++++
Msrc/gen/endless/world.ts32++++-
Mtests/endless_test.ts16++-
Mtests/scenario_test.ts24+++-
M progress.md +17 -7
@@ -542,13 +542,23 @@ named regression; (c) survivability scaffolding that M10+ physics scenarios plug
542542 into (deterministic sim of a fragment at min/typical/max entry speed → must543543 complete). WFC rules help here — the piece vocabulary IS the enumeration space.544544 545-**M8 — Slalom proportion (world rescale).** Track pieces scale up (proposal: ×2)546-in world space; marble, islands, decor stay 1×. One track cell = 2×2 island547-cells (mass builder maps columns), decor bands rescale, elevation quantum548-doubles in world units. PROPOSAL: endless-only at first — loop mode keeps 1× so549-finite seeds/goldens survive; revisit at M13 freeze. Slalom channel ≈ 3.5–4×550-marble diameter = falling off is rarer under physics, and the marble reads as a551-marble, not a boulder. Validation: geometry rule tests at the new scale (band545+**M8 — Slalom proportion (world rescale).** [DONE. TRACK_SCALE=2546+(src/gen/endless/scale.ts): the generator plans in TRACK CELLS; world.ts maps547+track placements ×2 (pos + mesh scale via Placement.scale) and scales the548+centerline; islands/decor stay 1× — buildIsland now emits on the WORLD grid (one549+track cell = 2×2 world cells via worldCellsOf; mass columns, caps, and the decor550+field all per world cell — finer variation for free). Island.topY is now WORLD551+units; plan/crossings stay track units. CANOPY_HEIGHT 1.8→1.0 track units (trees552+are 1.8 WORLD). Endless-only — loop mode untouched, goldens intact. Follow cam553+rides higher/farther (trail 7, height 3.1 via CameraOptions) or the ×2 pieces554+swallow it. Channel ≈ 3.5 marble diameters — the slalom. 63 tests green (island555+anchorage assertions now world-aware).] Track pieces scale up (proposal: ×2) in556+world space; marble, islands, decor stay 1×. One track cell = 2×2 island cells557+(mass builder maps columns), decor bands rescale, elevation quantum doubles in558+world units. PROPOSAL: endless-only at first — loop mode keeps 1× so finite559+seeds/goldens survive; revisit at M13 freeze. Slalom channel ≈ 3.5–4× marble560+diameter = falling off is rarer under physics, and the marble reads as a marble,561+not a boulder. Validation: geometry rule tests at the new scale (band552562 clearances, island column mapping, port continuity), screenshot pass. OPEN553563 DECISIONS: exact scale factor (2 vs 2.5), whether speeds/gravity constants554564 rescale.
M src/client/camera.ts +14 -4
M src/client/endless.ts +14 -3
@@ -20,6 +20,7 @@ import { instantiatePlacements, type Spinner } from "./track.ts";
2020 import { buildOpenCurve } from "./centerline.ts";2121 import { type Cameras, type CamMode, createCameras } from "./camera.ts";2222 import { EndlessWorld, KEEP_SEGMENTS } from "../gen/endless/world.ts";23+import { TRACK_SCALE } from "../gen/endless/scale.ts";2324 import { ExitAnim, SpawnAnim } from "./streamAnim.ts";2425 import { buildIslandInstanced, type InstancedBuild } from "./islandRender.ts";2526 import { Atmosphere, drawZone } from "./atmosphere.ts";
@@ -76,7 +77,9 @@ export function bootEndless(
7677 stars.position.copy(ctx.camera.position);7778 });7879 79- const cams = createCameras(ctx, null, null);80+ // Slalom-scale pieces would swallow the default follow cam — ride81+ // higher and farther back. (Camera collision is a known polish item.)82+ const cams = createCameras(ctx, null, null, { trail: 7, height: 3.1 });8083 const setCamLabel = (mode: CamMode) => {8184 if (ui.camBtn) {8285 ui.camBtn.textContent = mode === "orbit" ? "follow cam" : "orbit cam";
@@ -341,7 +344,11 @@ class Session {
341344 i,342345 new SpawnAnim(343346 seg.group,344- new THREE.Vector3(entry.x, entry.y, entry.z),347+ new THREE.Vector3(348+ entry.x * TRACK_SCALE,349+ entry.y * TRACK_SCALE,350+ entry.z * TRACK_SCALE,351+ ),345352 new THREE.Vector3(step[0], 0, step[1]),346353 ),347354 );
@@ -361,7 +368,11 @@ class Session {
361368 group: seg.group,362369 anim: new ExitAnim(363370 seg.group,364- new THREE.Vector3(entry.x, entry.y, entry.z),371+ new THREE.Vector3(372+ entry.x * TRACK_SCALE,373+ entry.y * TRACK_SCALE,374+ entry.z * TRACK_SCALE,375+ ),365376 new THREE.Vector3(step[0], 0, step[1]),366377 ),367378 disposables: seg.disposables,
M src/gen/endless/islands.ts +100 -74
@@ -32,6 +32,7 @@ import {
3232 import type { StyleField } from "../style.ts";3333 import { Occupancy } from "./occupancy.ts";3434 import type { IslandCrossing } from "./path.ts";35+import { TRACK_SCALE, worldCellsOf } from "./scale.ts";3536 3637 export interface BiomeSpec {3738 name: string;
@@ -102,9 +103,12 @@ export interface Island {
102103 id: number;103104 ref: string;104105 biome: string;106+ /** Top surface height in WORLD units (track topY × TRACK_SCALE). */105107 topY: number;108+ /** Placements in WORLD coordinates (island blocks/decor are 1× scale —109+ * the slalom rule: track ×2, world 1×). */106110 placements: Placement[];107- /** Top-surface mass cells ("x,z"), the run included. */111+ /** Top-surface mass cells (world-grid "x,z"), the run patch included. */108112 cells: string[];109113 }110114
@@ -120,91 +124,104 @@ export function buildIsland(
120124 catalog: DecorCatalog,121125 ): Island {122126 const { id, topY, margin } = crossing;127+ const worldTop = topY * TRACK_SCALE;123128 const ref = `isle/${id}`;124129 const biome = rng.weighted(BIOMES.map((b) => [b, b.weight] as const));125130 const placements: Placement[] = [];126131 127- const runSet = new Set(runCells.map(([x, z]) => key(x, z)));132+ // The run's WORLD patch: each track cell covers a 2×2 set of world cells133+ // (slalom rule — the channel swath the mass must carry).134+ const runWorld = new Set<string>();135+ for (const [cx, cz] of runCells) {136+ for (const [wx, wz] of worldCellsOf(cx, cz)) runWorld.add(key(wx, wz));137+ }138+ const runWorldList = [...runWorld].map((k) => k.split(",").map(Number));128139 const chebToRun = (x: number, z: number): number => {129140 let best = Infinity;130- for (const [rx, rz] of runCells) {141+ for (const [rx, rz] of runWorldList) {131142 best = Math.min(best, Math.max(Math.abs(x - rx), Math.abs(z - rz)));132143 }133144 return best;134145 };135146 136147 // ---------------------------------------------------------------------137- // Mass columns: run columns always; ring columns thin with distance.138- // Depth shallows with distance → stepped inverted-ziggurat underside.139- // All decisions per-cell-keyed (order-independent).148+ // Mass columns on the WORLD grid: run columns always; ring columns thin149+ // and shallow with distance → stepped inverted-ziggurat underside. All150+ // decisions per-world-cell-keyed (order-independent).140151 // ---------------------------------------------------------------------141- const RING_KEEP = [1, 0.9, 0.55, 0.25]; // by Chebyshev distance152+ const keepFor = (dist: number): number =>153+ dist <= 1 ? 1 : Math.max(0.2, 1.05 - dist * 0.16);142154 const topCells: string[] = [];143155 for (const claim of occ.claimsOf(ref)) {144156 const { x, z, y0, y1 } = claim;145157 if (Math.abs(y1 - topY) > 1e-9) continue; // not a mass claim (paranoia)146- const cellRng = rng.child(`cell/${x}/${z}`);147- const dist = chebToRun(x, z);148- const keep = RING_KEEP[Math.min(dist, RING_KEEP.length - 1)];149- if (!runSet.has(key(x, z)) && !cellRng.chance(keep)) continue;150-151- const avail = topY - y0;152- if (avail < 0.999) continue;153- // Depth target: deepest under the run, shallowing outward, jittered.154- const target = Math.max(155- 1,156- (margin + 1.5 - dist) * (0.8 + cellRng.float() * 0.7),157- );158- const depth = quantize(Math.min(avail, target));158+ const availWorld = (topY - y0) * TRACK_SCALE;159+ if (availWorld < 0.999) continue;160+ for (const [wx, wz] of worldCellsOf(x, z)) {161+ const cellRng = rng.child(`cell/${wx}/${wz}`);162+ const dist = chebToRun(wx, wz);163+ const onRun = runWorld.has(key(wx, wz));164+ if (!onRun && !cellRng.chance(keepFor(dist))) continue;159165 160- // Stack blocks top-down: full blocks, then a half/quarter shim so the161- // underside lands exactly on the claimed grid.162- let remaining = depth;163- let yTop = topY;164- while (remaining >= 1 - 1e-9) {165- placements.push(166- block(`cliff_block_${biome.material}`, x, yTop - 1, z, cellRng),166+ // Depth target: deepest under the run, shallowing outward, jittered.167+ const target = Math.max(168+ 1,169+ (margin * TRACK_SCALE + 2.5 - dist) * (0.5 + cellRng.float() * 0.45),167170 );168- yTop -= 1;169- remaining -= 1;170- }171- if (remaining >= 0.5 - 1e-9) {172- placements.push(173- block(`cliff_blockHalf_${biome.material}`, x, yTop - 0.5, z, cellRng),174- );175- yTop -= 0.5;176- remaining -= 0.5;177- }178- if (remaining >= 0.25 - 1e-9) {179- placements.push(180- block(181- `cliff_blockQuarter_${biome.material}`,182- x,183- yTop - 0.25,184- z,185- cellRng,186- ),187- );188- }189- topCells.push(key(x, z));171+ const depth = quantize(Math.min(availWorld, target));172+173+ // Stack blocks top-down: full blocks, then a half/quarter shim so174+ // the underside lands exactly on the claimed grid.175+ let remaining = depth;176+ let yTop = worldTop;177+ while (remaining >= 1 - 1e-9) {178+ placements.push(179+ block(`cliff_block_${biome.material}`, wx, yTop - 1, wz, cellRng),180+ );181+ yTop -= 1;182+ remaining -= 1;183+ }184+ if (remaining >= 0.5 - 1e-9) {185+ placements.push(186+ block(187+ `cliff_blockHalf_${biome.material}`,188+ wx,189+ yTop - 0.5,190+ wz,191+ cellRng,192+ ),193+ );194+ yTop -= 0.5;195+ remaining -= 0.5;196+ }197+ if (remaining >= 0.25 - 1e-9) {198+ placements.push(199+ block(200+ `cliff_blockQuarter_${biome.material}`,201+ wx,202+ yTop - 0.25,203+ wz,204+ cellRng,205+ ),206+ );207+ }208+ topCells.push(key(wx, wz));190209 191- // Caps: biome surface tile on non-track tops.192- if (193- !runSet.has(key(x, z)) && biome.capId &&194- cellRng.chance(biome.capChance)195- ) {196- placements.push({197- pieceId: `${NATURE}:${biome.capId}`,198- pos: [x, topY, z],199- rotation: cellRng.int(0, 4) as Placement["rotation"],200- role: "decor",201- });210+ // Caps: biome surface tile on non-track tops.211+ if (!onRun && biome.capId && cellRng.chance(biome.capChance)) {212+ placements.push({213+ pieceId: `${NATURE}:${biome.capId}`,214+ pos: [wx, worldTop, wz],215+ rotation: cellRng.int(0, 4) as Placement["rotation"],216+ role: "decor",217+ });218+ }202219 }203220 }204221 205222 // ---------------------------------------------------------------------206- // Decor field on the top surface: the M2a API with the run as "the207- // track", filtered to actual mass tops, lifted to topY.223+ // Decor field on the top surface (world grid): the M2a API with the run224+ // patch as "the track", filtered to actual mass tops, lifted to the top.208225 // ---------------------------------------------------------------------209226 if (biome.setting && catalog.packs.length > 0) {210227 const setting = settingByName(biome.setting, catalog);
@@ -213,26 +230,35 @@ export function buildIsland(
213230 rng.child("decor"),214231 setting,215232 { ...style, decorDensity: biome.decorDensity },216- regionAround(runSet, margin),217- cellSetOracle(runSet),233+ regionAround(runWorld, margin * TRACK_SCALE),234+ cellSetOracle(runWorld),218235 catalog,219236 );220237 for (const p of decor) {221- const cx = Math.round(p.pos[0]);222- const cz = Math.round(p.pos[2]);223- if (!topSet.has(key(cx, cz)) || runSet.has(key(cx, cz))) continue;224- // Vegetation needs the canopy airspace claim (made at plan time) —225- // cells whose air an older line owns stay bare rather than growing a226- // tree through someone's channel.238+ const cx = Math.round(p.pos[0] - 0.5) + 0.5;239+ const cz = Math.round(p.pos[2] - 0.5) + 0.5;240+ if (!topSet.has(key(cx, cz)) || runWorld.has(key(cx, cz))) continue;241+ // Vegetation needs the canopy airspace claim (made at plan time in242+ // TRACK cells) — cells whose air an older line owns stay bare rather243+ // than growing a tree through someone's channel.244+ const tx = Math.round(cx / TRACK_SCALE);245+ const tz = Math.round(cz / TRACK_SCALE);227246 const ownsAir = occ228- .at(cx, cz)229- .some((b) => b.ref === ref && b.y1 > topY + 1);247+ .at(tx, tz)248+ .some((b) => b.ref === ref && b.y1 > topY + 0.5);230249 if (!ownsAir) continue;231- placements.push({ ...p, pos: [p.pos[0], topY, p.pos[2]] });250+ placements.push({ ...p, pos: [p.pos[0], worldTop, p.pos[2]] });232251 }233252 }234253 235- return { id, ref, biome: biome.name, topY, placements, cells: topCells };254+ return {255+ id,256+ ref,257+ biome: biome.name,258+ topY: worldTop,259+ placements,260+ cells: topCells,261+ };236262 }237263 238264 function quantize(depth: number): number {
M src/gen/endless/path.ts +5 -3
A src/gen/endless/scale.ts +64 -0
@@ -0,0 +1,64 @@
1+/**2+ * M8 — slalom proportion. The TRACK is bigger than the world.3+ *4+ * The generator plans in TRACK CELLS (the abstract grid all the planning5+ * math lives in). At render scale, one track cell spans TRACK_SCALE world6+ * units: track placements are positioned ×2 and their meshes scaled ×2,7+ * while the marble, island blocks, and decor keep world scale 1 — the8+ * channel becomes a slalom ≈ 3.5 marble diameters wide, so a physics9+ * marble (M10) has real room before it can fall off.10+ *11+ * ENDLESS-ONLY: the finite loop keeps 1:1 (its seeds/goldens survive).12+ * One track cell maps to a 2×2 patch of world-grid island cells; island13+ * masses and their decor are generated directly on the world grid.14+ */15+import type { CurveSeg, Vec3 } from "../../shared/types.ts";16+17+export const TRACK_SCALE = 2;18+19+export function scaleVec(v: Vec3): Vec3 {20+ return [v[0] * TRACK_SCALE, v[1] * TRACK_SCALE, v[2] * TRACK_SCALE];21+}22+23+/** Uniformly scale a centerline segment into world units. */24+export function scaleSeg(seg: CurveSeg): CurveSeg {25+ switch (seg.kind) {26+ case "line":27+ return { kind: "line", a: scaleVec(seg.a), b: scaleVec(seg.b) };28+ case "arc":29+ return {30+ kind: "arc",31+ center: scaleVec(seg.center),32+ radius: seg.radius * TRACK_SCALE,33+ a0: seg.a0,34+ a1: seg.a1,35+ y0: seg.y0 * TRACK_SCALE,36+ y1: seg.y1 * TRACK_SCALE,37+ };38+ case "cubic":39+ return {40+ kind: "cubic",41+ p: [42+ scaleVec(seg.p[0]),43+ scaleVec(seg.p[1]),44+ scaleVec(seg.p[2]),45+ scaleVec(seg.p[3]),46+ ],47+ };48+ }49+}50+51+/** The four world-grid cell centers covered by one track cell. */52+export function worldCellsOf(53+ cx: number,54+ cz: number,55+): readonly (readonly [number, number])[] {56+ const wx = cx * TRACK_SCALE;57+ const wz = cz * TRACK_SCALE;58+ return [59+ [wx - 0.5, wz - 0.5],60+ [wx + 0.5, wz - 0.5],61+ [wx - 0.5, wz + 0.5],62+ [wx + 0.5, wz + 0.5],63+ ];64+}
M src/gen/endless/world.ts +26 -6
@@ -26,6 +26,7 @@ import { assemble } from "../assemble.ts";
2626 import type { DecorCatalog } from "../decor.ts";2727 import { applyXform, windowXform } from "../geom.ts";2828 import { CHANNEL_CLEARANCE, Occupancy, OccupancyTx } from "./occupancy.ts";29+import { scaleSeg, scaleVec, TRACK_SCALE } from "./scale.ts";2930 import {3031 hasFlatContinuation,3132 type HeadState,
@@ -38,11 +39,13 @@ import { buildIsland, type Island } from "./islands.ts";
3839 3940 export interface EndlessSegment {4041 index: number;42+ /** Planning data in TRACK CELLS (the generator's abstract grid). */4143 plan: SegmentPlan;42- /** Track placements only — island placements live on the islands. */44+ /** Track placements in WORLD coordinates (×TRACK_SCALE, meshes scaled —45+ * the slalom rule; islands/decor stay 1× on the world grid). */4346 placements: Placement[];44- /** Open path through this segment, entry → exit (LoopPath shape, but45- * nothing wraps — the "loop" never closes). */47+ /** Open WORLD-space path through this segment, entry → exit (LoopPath48+ * shape, but nothing wraps — the "loop" never closes). */4649 path: LoopPath;4750 islands: Island[];4851 /** Cumulative arc length of the line at this segment's entry. */
@@ -280,15 +283,32 @@ export class EndlessWorld {
280283 )281284 );282285 286+ // Slalom rule (M8): the generator plans in track cells; the WORLD sees287+ // track pieces at ×TRACK_SCALE (position and mesh scale) while islands288+ // and decor stay 1×. The centerline scales with the track.289+ const placements = assembled.placements.map((p): Placement => ({290+ ...p,291+ pos: scaleVec(p.pos),292+ scale: TRACK_SCALE,293+ }));294+ const path: LoopPath = {295+ segments: assembled.loop.segments.map((s) => {296+ const curve = s.curve.map(scaleSeg);297+ const length = s.length * TRACK_SCALE;298+ return { ...s, curve, length };299+ }),300+ totalLength: assembled.loop.totalLength * TRACK_SCALE,301+ };302+283303 this.segments.push({284304 index: i,285305 plan,286- placements: assembled.placements,287- path: assembled.loop,306+ placements,307+ path,288308 islands,289309 startS: this.nextStartS,290310 });291- this.nextStartS += assembled.loop.totalLength;311+ this.nextStartS += path.totalLength;292312 this.cullBefore(i - KEEP_SEGMENTS);293313 }294314 }
M tests/endless_test.ts +11 -5
@@ -8,6 +8,7 @@
88 */99 import { assert, assertEquals } from "@std/assert";1010 import { EndlessWorld, KEEP_SEGMENTS } from "../src/gen/endless/world.ts";11+import { TRACK_SCALE } from "../src/gen/endless/scale.ts";1112 import { segPoint } from "../src/gen/geom.ts";1213 import type { DecorCatalog } from "../src/gen/decor.ts";1314
@@ -109,26 +110,31 @@ Deno.test("endless: 20-seed fuzz — continuity, clearance, anchors, memory", ()
109110 channels.set(k, ys);110111 }111112 112- // -- islands: every anchored run cell has mass directly beneath113+ // -- islands: every anchored run cell has mass directly beneath its114+ // WORLD patch (islands live on the 1× world grid; track cells are115+ // ×TRACK_SCALE — the slalom rule).113116 for (const crossing of seg.plan.crossings) {114117 const isle = seg.islands.find((x) => x.id === crossing.id)!;118+ const worldTop = crossing.topY * TRACK_SCALE;115119 for (let j = 0; j < crossing.len; j++) {116120 const c = seg.plan.cells[crossing.start + j];117121 assertEquals(c.baseY, crossing.topY, "anchored cell at island top");118122 const supported = isle.placements.some((p) =>119- p.role === "support" && p.pos[0] === c.x && p.pos[2] === c.z &&120- p.pos[1] < crossing.topY123+ p.role === "support" &&124+ Math.abs(p.pos[0] - c.x * TRACK_SCALE) <= 0.51 &&125+ Math.abs(p.pos[2] - c.z * TRACK_SCALE) <= 0.51 &&126+ p.pos[1] < worldTop121127 );122128 assert(123129 supported,124130 `seed ${n} island ${crossing.id}: no mass under (${c.x},${c.z})`,125131 );126132 }127- // Island decor/mass never intrudes into the channel band of the run.133+ // Island mass never intrudes above the surface the track rides.128134 for (const p of isle.placements) {129135 if (p.role !== "support") continue;130136 assert(131- p.pos[1] <= crossing.topY - 0.24,137+ p.pos[1] <= worldTop - 0.24,132138 `mass block top above island surface at ${p.pos}`,133139 );134140 }
M tests/scenario_test.ts +17 -7
@@ -29,6 +29,7 @@
2929 import { assert } from "@std/assert";3030 import { buildRegistry } from "../src/gen/registry.ts";3131 import { EndlessWorld, KEEP_SEGMENTS } from "../src/gen/endless/world.ts";32+import { TRACK_SCALE } from "../src/gen/endless/scale.ts";3233 import type { DecorCatalog } from "../src/gen/decor.ts";3334 3435 const catalog: DecorCatalog = {
@@ -93,7 +94,9 @@ Deno.test("transition matrix: every variant starts and ends on the port lattice"
9394 if (dx > 1e-6 || dz > 1e-6 || dy > 1e-6) {9495 bad.push(9596 `${v.id}${v.reversed ? "(rev)" : ""}: centerline starts at ` +96- `(${start[0]}, ${start[1]}, ${start[2]}), expected (0, ${v.floorIn}, -0.5)`,97+ `(${start[0]}, ${start[1]}, ${98+ start[2]99+ }), expected (0, ${v.floorIn}, -0.5)`,97100 );98101 }99102 }
@@ -121,25 +124,29 @@ const STREAM_REGRESSIONS: StreamScenario[] = [
121124 name: "island-run-self-intersection",122125 seed: "endless-fuzz-2",123126 through: 16,124- why: "turny island run looped onto itself within one probe (probe now checks pending cells)",127+ why:128+ "turny island run looped onto itself within one probe (probe now checks pending cells)",125129 },126130 {127131 name: "wfc-extras-strand",128132 seed: "fuzz-1",129133 through: 16,130- why: "footprint-extra claims landed after the exit guard (realization now runs inside the planning tx)",134+ why:135+ "footprint-extra claims landed after the exit guard (realization now runs inside the planning tx)",131136 },132137 {133138 name: "canopy-cul-de-sac",134139 seed: "fuzz-5",135140 through: 14,136- why: "canopy claims sealed a corridor deeper than the flat check (deep exit check + corridor exemption)",141+ why:142+ "canopy claims sealed a corridor deeper than the flat check (deep exit check + corridor exemption)",137143 },138144 {139145 name: "planning-trap-entry",140146 seed: "deep-50",141147 through: 10,142- why: "trapped entry pose unfixable by retries (successor-probe gate exists because of this seed)",148+ why:149+ "trapped entry pose unfixable by retries (successor-probe gate exists because of this seed)",143150 },144151 ];145152
@@ -164,12 +171,15 @@ function assertStreamInvariants(seed: string, through: number): void {
164171 }165172 for (const crossing of seg.plan.crossings) {166173 const isle = seg.islands.find((x) => x.id === crossing.id)!;174+ const worldTop = crossing.topY * TRACK_SCALE;167175 for (let j = 0; j < crossing.len; j++) {168176 const c = seg.plan.cells[crossing.start + j];169177 assert(170178 isle.placements.some((p) =>171- p.role === "support" && p.pos[0] === c.x && p.pos[2] === c.z &&172- p.pos[1] < crossing.topY179+ p.role === "support" &&180+ Math.abs(p.pos[0] - c.x * TRACK_SCALE) <= 0.51 &&181+ Math.abs(p.pos[2] - c.z * TRACK_SCALE) <= 0.51 &&182+ p.pos[1] < worldTop173183 ),174184 `${seed} seg ${i}: island ${crossing.id} missing mass under run`,175185 );