1/**2 * EndlessWorld — the streaming generator behind endless mode.3 *4 * One instance owns the seed, the shared 3D occupancy, and the head-of-line5 * state, and generates segments strictly in order (segment i+1 grows from6 * segment i's exit pose — the continuity is the point; determinism per seed7 * is total because every random decision is label-derived from8 * `segment/${i}` / `island/${k}` families).9 *10 * Pipeline per segment: planSegment (void clauses + island anchors, claims11 * occupancy) → realize (open-path WFC over the same piece vocabulary, world12 * occupancy vetoing footprint extras) → assemble (open: no supports, no13 * goal, no solids) → buildIsland per crossing. The client asks for segments14 * ahead of the marble and culls behind it; culling releases every claim the15 * segment and its islands made, so memory tracks the streaming window, not16 * the distance travelled.17 */18import type {19 CurveSeg,20 Dir,21 LoopPath,22 Placement,23 Vec3,24} from "../../shared/types.ts";25import { rngFamily } from "../rng.ts";26import { buildRegistry, type PieceRegistry } from "../registry.ts";27import { pickStyle, type StyleField } from "../style.ts";28import type { ElevationPlan } from "../elevation.ts";29import type { Skeleton } from "../topology.ts";30import { realize } from "../wfc.ts";31import { assemble } from "../assemble.ts";32import type { DecorCatalog } from "../decor.ts";33import {34 applyXform,35 DIR_STEP,36 segLength,37 segPoint,38 turnLeft,39 turnRight,40 windowXform,41} from "../geom.ts";42import {43 type Affine,44 AFFINE_IDENTITY,45 affineApply,46 composeTwist,47 isIdentityAffine,48 quatRotate,49 type SegmentFrame,50} from "../../shared/frame.ts";51import { CHANNEL_CLEARANCE, Occupancy, OccupancyTx } from "./occupancy.ts";52import { scaleSeg, scaleVec, TRACK_SCALE } from "./scale.ts";53import {54 hasFlatContinuation,55 type HeadState,56 initialHeadState,57 PlannerStuck,58 planSegment,59 type SegmentPlan,60} from "./path.ts";61import { buildIsland, type Island } from "./islands.ts";62import { planIslandFurniture } from "./furniture.ts";63import { planVoidDecor } from "./voidDecor.ts";64import { segmentClimate } from "../climate.ts";65import { MOOD_CLAUSE_BIAS } from "./path.ts";6667/** M21 climate × M24 temperament, one multiplicative record. */68function composeClauseBias(69 mood: string,70 temperament: { clauseBias: Record<string, number> },71): Record<string, number> {72 const a = MOOD_CLAUSE_BIAS[mood] ?? {};73 const b = temperament.clauseBias;74 const out: Record<string, number> = { ...a };75 for (const [k, v] of Object.entries(b)) out[k] = (out[k] ?? 1) * v;76 return out;77}7879export interface EndlessSegment {80 index: number;81 /** Planning data in TRACK CELLS (the generator's abstract grid). */82 plan: SegmentPlan;83 /** Piece family per path segment (aligned with path.segments) — the84 * per-tile physics modifier channel (M11): the client maps families to85 * speed/gravity behavior at the rail-station level. */86 pieceFamilies: string[];87 /** Cell range per window (aligned with pieceFamilies/path.segments) —88 * lets tests and tools map plan cells to realized windows. */89 windowRanges: { start: number; len: number }[];90 /** Planner set-piece zone kind per path segment (or null). */91 windowMods: (string | null)[];92 /** Track placements in WORLD coordinates (×TRACK_SCALE, meshes scaled —93 * the slalom rule; islands/decor stay 1× on the world grid). */94 placements: Placement[];95 /** Open WORLD-space path through this segment, entry → exit (LoopPath96 * shape, but nothing wraps — the "loop" never closes). */97 path: LoopPath;98 islands: Island[];99 /** Braid alt LANE in WORLD coordinates (M17): the sub-assembled open100 * path over the braid's alt cells, branch port → branch port. The101 * client (and the drive harness) append plain channelProxies over it —102 * the alt lane is an ordinary channel; only the fork/merge junctions103 * need composite treatment (proxies.ts). Absent = no braid here. */104 altPath?: LoopPath;105 /** Floating void decor (M18b), WORLD coordinates — kept OUT of106 * `placements` so the proxy path structurally cannot see it (never107 * collides, never claims occupancy). */108 voidDecor: Placement[];109 /** Braid V3: this segment RESOLVES a fork opened in the named earlier110 * segment — the client keys per-racer lane draws on the FORK segment111 * so a rider's lane survives the boundary. */112 braidContOf?: number;113 /** Cumulative arc length of the line at this segment's entry. */114 startS: number;115 /** Presentation frame (M16 gravity reorientation): how this segment's116 * plan-space data maps into world space. Omitted = identity (no117 * reorient history). All plan/path/placement data above stays118 * plan-space +y-up regardless. */119 frame?: SegmentFrame;120}121122const key = (x: number, z: number) => `${x},${z}`;123124/** Live window: how many trailing segments keep their data + claims. The125 * client's render window (ahead + behind) must fit inside this. */126export const KEEP_SEGMENTS = 10;127128/** Successor-probe labels: a segment commits only if its successor plans129 * under at least one of these; the successor retries them last. */130const PROBE_LABELS = ["probe", "probe/1", "probe/2", "probe/3"];131132export class EndlessWorld {133 readonly seed: string;134 readonly style: StyleField;135 private readonly registry: PieceRegistry;136 private readonly catalog: DecorCatalog;137 private readonly occ = new Occupancy();138 private state: HeadState = initialHeadState();139 private segments: (EndlessSegment | null)[] = [];140 /** First index not yet culled (segments before it are tombstones). */141 private firstLive = 0;142 /** Cumulative arc length at the head — survives culling. */143 private nextStartS = 0;144 /** Cumulative presentation frame (M16) — reorient runs compose onto145 * it; as seed-pure as nextStartS (generation is strictly forward). */146 private frame: Affine = AFFINE_IDENTITY;147 /** Per-seed braid breadth knob (M17): 1..3, drawn once — some seeds148 * braid clean parallel sidings, some tangle three lanes out. The149 * planner draws each braid's actual breadth under this cap. */150 private readonly braidBias: number;151152 constructor(seed: string, catalog: DecorCatalog = { packs: [] }) {153 this.seed = seed;154 this.catalog = catalog;155 this.registry = buildRegistry();156 // Same style family as finite mode: one seed, one theme, both modes.157 this.style = pickStyle(rngFamily(seed, "style"));158 this.braidBias = 1 + rngFamily(seed, "braid-breadth").int(0, 3);159 }160161 /** Segment by index — generates forward as needed (never backward). */162 get(i: number): EndlessSegment {163 if (i < this.firstLive) {164 throw new Error(165 `segment ${i} already culled (firstLive ${this.firstLive})`,166 );167 }168 while (this.segments.length <= i) this.generateNext();169 return this.segments[i]!;170 }171172 /** Number of segments generated so far. */173 get generatedCount(): number {174 return this.segments.length;175 }176177 /** Occupancy cell count — bounded-memory checks in tests. */178 get occupancySize(): number {179 return this.occ.size;180 }181182 /**183 * Culling is GENERATION-DRIVEN, never client-driven: when segment i is184 * generated, segment i − KEEP_SEGMENTS is released (occupancy claims of185 * its track and islands — an island's anchorage IS its birth segment) and186 * its data dropped. Pinning cull timing to generation keeps the stream a187 * pure function of the seed; if it followed the realtime marble instead,188 * whether an old claim still existed when a segment was planned would189 * depend on frame timing. The client sizes its render window under190 * KEEP_SEGMENTS and disposes scene groups on its own schedule — that's191 * rendering, not world state.192 */193 private cullBefore(index: number): void {194 for (195 let i = this.firstLive;196 i < Math.min(index, this.segments.length);197 i++198 ) {199 const seg = this.segments[i];200 if (!seg) continue;201 this.occ.release(`seg/${i}`);202 for (const isle of seg.islands) this.occ.release(isle.ref);203 this.segments[i] = null;204 }205 this.firstLive = Math.max(this.firstLive, index);206 }207208 /** True when segment `index` plans under at least one probe label, on a209 * throwaway transaction stacked over `base`. */210 private successorPlans(211 index: number,212 state: HeadState,213 base: OccupancyTx,214 ): boolean {215 const rng = rngFamily(this.seed, `segment/${index}`);216 for (const label of PROBE_LABELS) {217 try {218 planSegment(219 rng.child(label),220 index,221 state,222 new OccupancyTx(base),223 this.registry,224 this.braidBias,225 composeClauseBias(226 segmentClimate(this.seed, index).mood,227 this.style.temperament,228 ),229 );230 return true;231 } catch (err) {232 if (!(err instanceof PlannerStuck)) throw err;233 }234 }235 return false;236 }237238 private generateNext(): void {239 const i = this.segments.length;240 const segRng = rngFamily(this.seed, `segment/${i}`);241 // Transactional generation: plan AND realize inside one occupancy242 // transaction. WFC footprint extras (bend arc interiors, wave flanks…)243 // are world matter that only exists after realization — a segment244 // commits only if the exit corridor survives them; otherwise the whole245 // attempt is discarded and replanned under a fresh rng label. Dead ends246 // and retries are both deterministic per seed.247 let result:248 | {249 plan: ReturnType<typeof planSegment>["plan"];250 next: ReturnType<typeof planSegment>["next"];251 windows: ReturnType<typeof realize>;252 /** Braid alt-lane windows (M17, per braid since M19b) —253 * realized inside the same transaction; empty when the plan254 * carries no braid. */255 altWindowsOf: ReturnType<typeof realize>[];256 skeleton: Skeleton;257 elevation: ElevationPlan;258 tx: OccupancyTx;259 }260 | null = null;261 // Attempt order: the plain label, fresh retries, then the probe label262 // set — our predecessor proved at least one of those plans from our263 // exact entry state, so they're the near-guaranteed fallbacks.264 const labels = [265 "",266 "retry/1",267 "retry/2",268 "retry/3",269 "retry/4",270 "retry/5",271 ...PROBE_LABELS,272 ];273 for (const label of labels) {274 if (result) break;275 const tx = new OccupancyTx(this.occ);276 const rng = label === "" ? segRng : segRng.child(label);277 let planned;278 try {279 planned = planSegment(280 rng,281 i,282 this.state,283 tx,284 this.registry,285 this.braidBias,286 // M21+M24: the zone's base climate and the seed's temperament287 // lean the clause draw together.288 composeClauseBias(289 segmentClimate(this.seed, i).mood,290 this.style.temperament,291 ),292 );293 } catch (err) {294 if (err instanceof PlannerStuck) continue;295 throw err;296 }297 const { plan, next } = planned;298 const skeleton: Skeleton = {299 cells: plan.cells,300 crossings: [],301 helixLoops: [],302 };303 const elevation: ElevationPlan = {304 baseY: plan.cells.map((c) => c.baseY),305 reservations: plan.reservations,306 maxBaseY: Math.max(...plan.cells.map((c) => c.baseY)),307 };308 // Jump/propeller trajectory cells: approach (3 before the gap)309 // through landing (2 after) assume a STRAIGHT channel — a wave310 // window free-collapsing over them swings the channel laterally311 // and the ballistic marble misses the catch (found the hard way:312 // a wave landing edge-bounced the marble back into the void).313 const noLateral: [number, number][] = plan.mods314 .filter((m) => m.kind === "jump" || m.kind === "propeller")315 .map((m) => [m.start - 3, m.start + m.len + 2]);316 // Braid spans (M17) are flat runway: the kit's waves AND short317 // s-curves DIP the channel vertically, and a dip against a318 // junction port parks a slow marble on the crest at the deck lip319 // (grav-scan-21's alt rider stalled exactly there — first on a320 // wave, then on the s-curve the collapse picked instead). ±2 cells321 // of margin keeps the approach/exit flat as well. Kept separate322 // from the jump ranges so the braid rule can cover both families323 // without touching the priced jump-approach behavior.324 const braidFlat: [number, number][] = plan.braids.map(325 (b) => [b.start - 2, b.start + b.len + 2],326 );327 // Bump lips: bumps re-admit ONLY fully inside a single boost zone328 // (sprint runs are the only zones with ≥4 free flat cells) — the329 // crest launch is priced by the zone's speed, and elsewhere the330 // assisted marble lacks crest momentum.331 const boostRanges: [number, number][] = plan.mods332 .filter((m) => m.kind === "boost")333 .map((m) => [m.start, m.start + m.len]);334 const windows = realize(335 rng.child("wfc"),336 skeleton,337 elevation,338 this.style,339 this.registry,340 [],341 {342 open: true,343 extraFree: (x, z, baseY) =>344 tx.isFree(x, z, baseY, baseY + CHANNEL_CLEARANCE),345 // Physics era: bumps demand crest momentum — allowed only when346 // the window sits fully inside one boost zone (sprint runs),347 // and only the LIP tiers: a/b rise 0.5/1.0 world and the crest348 // curvature stays below the launch threshold at the boost349 // target (κ ≈ 0.375·R_world vs v²κ = g at 0.54). Tier c/d are350 // 2–3 world tall — genuine launch ramps that threw the marble351 // over the walls.352 exclude: (def, start) =>353 (def.family === "bump" &&354 (!(def.id === "bump-a" || def.id === "bump-b") ||355 !boostRanges.some(([a, b]) =>356 start >= a && start + def.moves.length <= b357 ))) ||358 (def.family === "wave" && noLateral.some(([a, b]) =>359 start < b && a < start + def.moves.length360 )) ||361 ((def.family === "wave" || def.family === "s-curve") &&362 braidFlat.some(([a, b]) =>363 start < b && a < start + def.moves.length364 )),365 },366 );367 // Claim the extras into the transaction, then confirm the exit368 // corridor is still alive.369 for (const win of windows) {370 const cell = plan.cells[win.start];371 const xf = windowXform(cell.x, cell.z, cell.dirIn, 0);372 const windowSet = new Set<string>();373 for (let j = 0; j < win.len; j++) {374 const c = plan.cells[win.start + j];375 windowSet.add(key(c.x, c.z));376 }377 const baseY = cell.baseY;378 for (const [fx, fz] of win.def.footprint) {379 const w = applyXform(xf, [fx, 0, fz]);380 const wx = Math.round(w[0]);381 const wz = Math.round(w[2]);382 if (windowSet.has(key(wx, wz))) continue;383 // Reserved set-pieces' extras were claimed by the planner384 // already — not free there means ours, skip the duplicate.385 if (tx.isFree(wx, wz, baseY, baseY + CHANNEL_CLEARANCE)) {386 tx.claim(wx, wz, baseY, baseY + CHANNEL_CLEARANCE, `seg/${i}`);387 }388 }389 }390 // BRAID alt lane (M17): realize the alt sub-walk inside the SAME391 // transaction (its footprint extras are world matter too — they392 // must be claimed before the exit-corridor checks and the393 // successor probe run). No boost zones exist on the alt lane, so394 // bumps are excluded outright (the main realize's momentum rule).395 // Generalized over plan.braids (M19b): the planner still emits at396 // most one, but the realize is a loop — flipping that cap later is397 // a planner-side weight change with its own pin cycle. Braid 0398 // keeps the bare "braid-wfc" child label so existing seeds draw399 // byte-identically.400 const altWindowsOf: ReturnType<typeof realize>[] = [];401 let altWedged = false;402 for (const [bi, braid] of plan.braids.entries()) {403 const altSkeleton: Skeleton = {404 cells: braid.altCells,405 crossings: [],406 helixLoops: [],407 };408 const altElevation: ElevationPlan = {409 baseY: braid.altCells.map((c) => c.baseY),410 reservations: [],411 maxBaseY: Math.max(...braid.altCells.map((c) => c.baseY)),412 };413 let altWindows: ReturnType<typeof realize>;414 try {415 altWindows = realize(416 bi === 0 ? rng.child("braid-wfc") : rng.child(`braid-wfc/${bi}`),417 altSkeleton,418 altElevation,419 this.style,420 this.registry,421 [],422 {423 open: true,424 extraFree: (x, z, baseY) =>425 tx.isFree(x, z, baseY, baseY + CHANNEL_CLEARANCE),426 // No bumps (they demand crest momentum and the alt lane427 // has no boost zones) and no waves/s-curves (vertical dips428 // — the alt rider corners slowly, and a dip beside the429 // junction mouths parks it; V1 alt lanes are flat-floored).430 exclude: (def) =>431 def.family === "bump" || def.family === "wave" ||432 def.family === "s-curve",433 },434 );435 } catch (_err) {436 // A wedged alt collapse discards the whole attempt — same437 // contract as a planner dead end, deterministic per seed.438 altWedged = true;439 break;440 }441 altWindowsOf.push(altWindows);442 for (const win of altWindows) {443 const cell = braid.altCells[win.start];444 const xf = windowXform(cell.x, cell.z, cell.dirIn, 0);445 const windowSet = new Set<string>();446 for (let j = 0; j < win.len; j++) {447 const c = braid.altCells[win.start + j];448 windowSet.add(key(c.x, c.z));449 }450 const baseY = cell.baseY;451 for (const [fx, fz] of win.def.footprint) {452 const w = applyXform(xf, [fx, 0, fz]);453 const wx = Math.round(w[0]);454 const wz = Math.round(w[2]);455 if (windowSet.has(key(wx, wz))) continue;456 if (tx.isFree(wx, wz, baseY, baseY + CHANNEL_CLEARANCE)) {457 tx.claim(wx, wz, baseY, baseY + CHANNEL_CLEARANCE, `seg/${i}`);458 }459 }460 }461 }462 if (altWedged) continue;463 // Exit viability, two stages. First the cheap one: a flat corridor464 // must survive this attempt's claims (path, islands, canopy, WFC465 // extras). Then the real one: PLAN THE SUCCESSOR as probes on a466 // stacked transaction — no fixed-depth corridor check can rule out467 // every cul-de-sac, but "segment i+1 actually plans" can. Probes use468 // a fixed label set so the successor's own last-resort attempts469 // replay exactly what was validated here; the set (rather than one470 // label) keeps a single unlucky hypothetical route from vetoing a471 // good attempt.472 if (!hasFlatContinuation(tx, plan.exit, 6)) continue;473 if (!this.successorPlans(i + 1, next, tx)) continue;474 result = { plan, next, windows, altWindowsOf, skeleton, elevation, tx };475 }476 if (!result) {477 throw new Error(478 `endless planner exhausted retries at segment ${i} (seed ${this.seed})`,479 );480 }481 result.tx.commit();482 const { plan, next, windows, altWindowsOf, skeleton, elevation } = result;483 // Pipe-color chapters (user rule: the pipe color is PER ISLAND): a484 // cell belongs to the most recent island's chapter — islands opened485 // in this segment take over from their run start; earlier cells486 // carry the chapter the line entered with.487 const entryChapter = this.state.islandCount - 1;488 const cellChapter = (idx: number): number => {489 let ch = entryChapter;490 for (const c of plan.crossings) {491 if (c.start <= idx && c.id > ch) ch = c.id;492 }493 return ch;494 };495 this.state = next;496 // Ground vocabulary (an island top IS local ground): solids come back497 // where a window sits on its terrace, and the solid neighbor check may498 // lean on the island deck (the planner's own mass-claim record).499 const deckMap = new Map<string, number>();500 for (const c of plan.crossings) {501 for (const [dx, dz, top] of c.deckCells ?? []) {502 deckMap.set(`${dx},${dz}`, top);503 }504 }505 const localGroundY = (idx: number): number | null => {506 for (const c of plan.crossings) {507 if (idx >= c.start && idx < c.start + c.len) {508 const base = plan.cells[idx].baseY;509 return base === c.profile[idx - c.start] ? base : null;510 }511 }512 return null;513 };514 const assembled = assemble(skeleton, elevation, this.style, windows, {515 open: true,516 localGroundY,517 deckAt: (k) => deckMap.get(k),518 });519 // Island furniture: stub sidings / fans on the planner's ledge cells520 // (mutates stub hosts straight → split in place).521 const furniture = planIslandFurniture(522 rngFamily(this.seed, `furniture/${i}`),523 plan,524 windows,525 assembled.placements,526 // M21: the zone's base weather leans the wind vocabulary.527 segmentClimate(this.seed, i).weather,528 );529530 // TAKEABLE SPUR DROPS (loop-mode takeable.ts wired into the void):531 // the planner pinned the host to a 1-cell straight window; here the532 // host placement mutates straight → split (the junction's main533 // channel IS the host's channel; a right-hand branch renders the534 // native mesh mirrored — the takeable.ts move), the branch cell gets535 // its end-hole diving board, and the host window's loop segment536 // gains a ONE-WAY alt centerline out over the hole, flagged `spur`537 // so the collision proxies emit the branch deck with the hole OPEN:538 // a marble riding the branch genuinely falls through and lands on539 // the segment's own receiver channel Δ exactly 1.0 below.540 const spurPieces: { placement: Placement; host: number }[] = [];541 for (const spur of plan.spurs) {542 const wi = windows.findIndex((w) => w.start === spur.host && w.len === 1);543 if (wi < 0) continue; // reserved by the planner — always found544 const ls = assembled.loop.segments[wi];545 const hp = ls.placementIndex >= 0546 ? assembled.placements[ls.placementIndex]547 : undefined;548 if (!hp || hp.pieceId !== "straight") continue;549 const cell = plan.cells[spur.host];550 hp.pieceId = "split";551 if (spur.dir === turnRight(cell.dirIn)) hp.mirrored = true;552 const [wx, wz] = DIR_STEP[spur.dir];553 const bx = cell.x + wx;554 const bz = cell.z + wz;555 spurPieces.push({556 placement: {557 pieceId: spur.cap,558 pos: [bx, cell.baseY, bz],559 rotation: ((1 - spur.dir + 4) % 4) as Dir,560 role: "track",561 },562 host: spur.host,563 });564 // The alt: entry port → rounded pull-off at the junction → out to565 // the hole center → STRAIGHT DOWN the shaft. No return leg (loop566 // mode's teardrop): the fall is guaranteed. The dive is part of567 // the line ON PURPOSE — the wishdir autopilot steers toward a look568 // station ahead, and without shaft stations the look target569 // crosses back to the main exit port and PARKS the marble a570 // length short of the lip (found the hard way on spur-scan-7).571 // railForSegment then skips the main hook after the dive, so the572 // stations resume at the receiver window directly below.573 const floor = cell.baseY + 0.25;574 const [fx, fz] = DIR_STEP[cell.dirIn];575 const cx = cell.x;576 const cz = cell.z;577 const curve: CurveSeg[] = [578 {579 kind: "line",580 a: [cx - fx / 2, floor, cz - fz / 2],581 b: [cx - fx * 0.1, floor, cz - fz * 0.1],582 },583 {584 kind: "cubic",585 p: [586 [cx - fx * 0.1, floor, cz - fz * 0.1],587 [cx, floor, cz],588 [cx + wx * 0.18, floor, cz + wz * 0.18],589 [cx + wx * 0.35, floor, cz + wz * 0.35],590 ] as [Vec3, Vec3, Vec3, Vec3],591 },592 {593 kind: "line",594 a: [cx + wx * 0.35, floor, cz + wz * 0.35],595 b: [bx, floor, bz],596 },597 {598 kind: "line",599 a: [bx, floor, bz],600 b: [bx, floor - 1, bz],601 },602 ];603 ls.alt = {604 curve,605 length: curve.reduce((acc, s) => acc + segLength(s), 0),606 spur: true,607 };608 }609610 // BRAIDED PATHS (M17): the planner pinned fork + merge to 1-cell611 // straight windows. Here (a) both host placements mutate straight →612 // split — the fork branches toward the alt side (right-hand branch613 // renders mirrored, the takeable.ts move); the merge is the SAME614 // split z-flipped in its local frame (rotation + 2 with the mirror615 // flag inverted — R(r+2)·M_x ≡ R(r)·M_z), so its branch mouth faces616 // the arriving alt lane and the branch curve sweeps INTO downstream;617 // (b) the alt sub-walk assembles into its own open path (altPath —618 // plain channel proxies hang on it, client + drive harness); (c) the619 // fork window's LoopSegment gains the full alt chain: pull-off620 // through the fork junction, the whole alt lane, pull-in through the621 // merge junction — railForSegment(seg, true) rides the braid's other622 // lane and skips the main windows underneath it (resuming at the623 // window that starts at the merge exit port).624 const braidPieces: { placement: Placement; host: number }[] = [];625 const braidAltLoops: LoopPath[] = [];626 for (const [bi, braid] of plan.braids.entries()) {627 const altWindows = altWindowsOf[bi];628 if (!altWindows) break; // realized with the plan — always present629 const forkIdx = braid.start;630 const mergeIdx = braid.start + braid.len - 1;631 // V3: an OPEN braid has no merge this segment; a CONT braid has no632 // fork (it resolves the previous segment's open fork). The host633 // ls for the alt chain is the fork window, or — for a cont — the634 // FIRST window (the chain enters at the segment boundary).635 const wi = braid.cont636 ? 0637 : windows.findIndex((w) => w.start === forkIdx && w.len === 1);638 const wj = braid.open639 ? -1640 : windows.findIndex((w) => w.start === mergeIdx && w.len === 1);641 if (wi < 0 || (!braid.open && wj < 0)) continue; // planner-reserved642 const lsFork = assembled.loop.segments[wi];643 if (!braid.cont) {644 const hpF = lsFork.placementIndex >= 0645 ? assembled.placements[lsFork.placementIndex]646 : undefined;647 if (!hpF || hpF.pieceId !== "straight") continue;648 const fork = plan.cells[forkIdx];649 hpF.pieceId = "split";650 if (braid.side === turnRight(fork.dirIn)) hpF.mirrored = true;651 }652 if (!braid.open) {653 const lsMerge = assembled.loop.segments[wj];654 const hpM = lsMerge.placementIndex >= 0655 ? assembled.placements[lsMerge.placementIndex]656 : undefined;657 if (!hpM || hpM.pieceId !== "straight") continue;658 const merge = plan.cells[mergeIdx];659 hpM.pieceId = "split";660 hpM.rotation = ((hpM.rotation + 2) % 4) as Dir;661 if (braid.side === turnLeft(merge.dirIn)) hpM.mirrored = true;662 lsMerge.braidMerge = true;663 }664665 const altSkeleton: Skeleton = {666 cells: braid.altCells,667 crossings: [],668 helixLoops: [],669 };670 const altElevation: ElevationPlan = {671 baseY: braid.altCells.map((c) => c.baseY),672 reservations: [],673 maxBaseY: Math.max(...braid.altCells.map((c) => c.baseY)),674 };675 const altAssembled = assemble(676 altSkeleton,677 altElevation,678 this.style,679 altWindows,680 { open: true },681 );682 // Alt-lane zone tags (M19b): a tagged alt window is one that lies683 // wholly ≥2 chain cells from BOTH junction mouths (the lip law).684 const chainN = braid.altCells.length;685 const altZoneOfWindow = (686 win: { start: number; len: number },687 ): "icy" | "boost" | null => {688 const lm = braid.laneMods?.alt;689 if (!lm) return null;690 // Lip margins exist only at ends that ARE junctions (V3): an691 // open braid's far end and a cont braid's near end are segment692 // boundaries, not mouths.693 const nearOk = braid.cont ? true : win.start >= 2;694 const farOk = braid.open ? true : win.start + win.len <= chainN - 2;695 return nearOk && farOk ? lm : null;696 };697 // Tagged windows recolor their placements too (zones must READ).698 const altSegs = altAssembled.loop.segments;699 const altZone = new Array<string | null>(700 altAssembled.placements.length,701 ).fill(null);702 for (let k = 0; k < altSegs.length; k++) {703 const startIdx = altSegs[k].placementIndex;704 if (startIdx < 0) continue;705 const z = altZoneOfWindow(altWindows[k]);706 if (!z) continue;707 let end = altAssembled.placements.length;708 for (let k2 = k + 1; k2 < altSegs.length; k2++) {709 if (altSegs[k2].placementIndex >= 0) {710 end = altSegs[k2].placementIndex;711 break;712 }713 }714 for (let p = startIdx; p < end; p++) altZone[p] = z;715 }716 for (const [pi, p] of altAssembled.placements.entries()) {717 braidPieces.push({718 placement: altZone[pi] ? { ...p, zone: altZone[pi]! } : p,719 host: forkIdx,720 });721 }722 braidAltLoops.push(altAssembled.loop);723724 // The alt chain (V3 cases): a CLOSED braid runs fork entry port →725 // junction pull-off → alt lane → merge pull-in → merge exit port.726 // An OPEN braid ends with the alt lane at the boundary (no tail);727 // a CONT chain starts with the alt lane at the boundary (no head).728 // Boundary ports are exact, so consecutive segments' chains meet729 // the way main-channel windows do.730 const forkCell = plan.cells[forkIdx];731 const mergeCell = plan.cells[mergeIdx];732 const floor = forkCell.baseY + 0.25;733 const [fx, fz] = DIR_STEP[forkCell.dirIn];734 const [wx, wz] = DIR_STEP[braid.side];735 const head: CurveSeg[] = braid.cont ? [] : [736 {737 kind: "line",738 a: [forkCell.x - fx / 2, floor, forkCell.z - fz / 2],739 b: [forkCell.x - fx * 0.1, floor, forkCell.z - fz * 0.1],740 },741 {742 kind: "cubic",743 p: [744 [forkCell.x - fx * 0.1, floor, forkCell.z - fz * 0.1],745 [forkCell.x, floor, forkCell.z],746 [forkCell.x + wx * 0.28, floor, forkCell.z + wz * 0.28],747 [forkCell.x + wx * 0.5, floor, forkCell.z + wz * 0.5],748 ] as [Vec3, Vec3, Vec3, Vec3],749 },750 ];751 const tail: CurveSeg[] = braid.open ? [] : [752 {753 kind: "cubic",754 p: [755 [mergeCell.x + wx * 0.5, floor, mergeCell.z + wz * 0.5],756 [mergeCell.x + wx * 0.28, floor, mergeCell.z + wz * 0.28],757 [mergeCell.x, floor, mergeCell.z],758 [mergeCell.x + fx * 0.1, floor, mergeCell.z + fz * 0.1],759 ] as [Vec3, Vec3, Vec3, Vec3],760 },761 {762 kind: "line",763 a: [mergeCell.x + fx * 0.1, floor, mergeCell.z + fz * 0.1],764 b: [mergeCell.x + fx / 2, floor, mergeCell.z + fz / 2],765 },766 ];767 const curve: CurveSeg[] = [768 ...head,769 ...altAssembled.loop.segments.flatMap((s) => s.curve),770 ...tail,771 ];772 // Per-part zone tags aligned with the chain curve: junction parts773 // untagged at whichever ends exist.774 const zones: (string | null)[] = head.map(() => null);775 for (let k = 0; k < altSegs.length; k++) {776 const z = altZoneOfWindow(altWindows[k]);777 for (let c = 0; c < altSegs[k].curve.length; c++) zones.push(z);778 }779 for (const _ of tail) zones.push(null);780 lsFork.alt = {781 curve,782 length: curve.reduce((acc, s) => acc + segLength(s), 0),783 braid: true,784 ...(braid.cont ? { contBraid: true } : {}),785 ...(braid.open ? { openBraid: true } : {}),786 ...(zones.some((z) => z !== null) ? { zones } : {}),787 };788 }789790 // Islands anchored in this segment.791 const islands = plan.crossings.map((crossing) =>792 buildIsland(793 rngFamily(this.seed, `island/${crossing.id}`),794 crossing,795 plan.cells796 .slice(crossing.start, crossing.start + crossing.len)797 .map((c) => [c.x, c.z] as const),798 this.occ,799 this.style,800 this.catalog,801 )802 );803804 // Planner zone kind per window (mods are cell-indexed; windows and805 // path segments are aligned 1:1). Overlaps resolve by PRIORITY, not806 // plan order: gap kinds are structural (they drop geometry), boost807 // must win over a preceding chute's shock zone bleeding into a jump808 // approach, shock is cosmetic damping.809 const MOD_PRIORITY: readonly string[] = [810 "jump",811 "propeller",812 "reorient",813 "spur",814 "braid",815 "boost",816 "gravity",817 "icy",818 "shock",819 ];820 const windowMods = windows.map((w) => {821 let best: string | null = null;822 let bestRank: number = MOD_PRIORITY.length;823 for (const m of plan.mods) {824 if (w.start >= m.start + m.len || m.start >= w.start + w.len) {825 continue;826 }827 const rank = MOD_PRIORITY.indexOf(m.kind);828 if (rank < bestRank) {829 bestRank = rank;830 best = m.kind;831 }832 }833 return best;834 });835 // M19b lane mods (TAG-ONLY law): stamp the braid interior's main-836 // lane windows with the drawn surface — never via plan.mods837 // (realize's exclude rules must not see lane tags: geometry is838 // sacred) and never within 2 cells of fork or merge (the junction-839 // lip law in friction form).840 for (const braid of plan.braids) {841 const main = braid.laneMods?.main;842 if (!main) continue;843 // V3: lip margins only at real junction ends (see altZoneOfWindow).844 const lo = braid.cont ? braid.start : braid.start + 2;845 const hi = braid.open846 ? braid.start + braid.len - 1847 : braid.start + braid.len - 3;848 for (let wi = 0; wi < windows.length; wi++) {849 const w = windows[wi];850 if (851 windowMods[wi] === null && w.start >= lo &&852 w.start + w.len - 1 <= hi853 ) {854 windowMods[wi] = main;855 }856 }857 }858 // JUMP gaps: the reserved gap window realized a piece (the WFC needs859 // the elevation step to be honest), but the set-piece is the ABSENCE860 // of that piece — drop its placements so nothing renders in the gap861 // and the collision proxies (which skip "jump" path segments) match862 // what the eye sees. Placements are emitted in window order, so a863 // window's span runs from its placementIndex to the next window's.864 const dropped = new Set<number>();865 const chapterOf = new Array<number>(assembled.placements.length).fill(866 entryChapter,867 );868 const zoneOf = new Array<string | null>(assembled.placements.length).fill(869 null,870 );871 const loopSegs = assembled.loop.segments;872 for (let wi = 0; wi < loopSegs.length; wi++) {873 const startIdx = loopSegs[wi].placementIndex;874 if (startIdx < 0) continue;875 let end = assembled.placements.length;876 for (let wj = wi + 1; wj < loopSegs.length; wj++) {877 if (loopSegs[wj].placementIndex >= 0) {878 end = loopSegs[wj].placementIndex;879 break;880 }881 }882 const ch = cellChapter(windows[wi].start);883 for (let k = startIdx; k < end; k++) {884 chapterOf[k] = ch;885 if (886 windowMods[wi] === "jump" || windowMods[wi] === "propeller" ||887 windowMods[wi] === "reorient"888 ) {889 // Reorient runs drop their kit pieces too — the corkscrewing890 // collision ribbon is rendered directly (visual == collision).891 dropped.add(k);892 }893 if (894 windowMods[wi] === "icy" || windowMods[wi] === "gravity" ||895 windowMods[wi] === "boost"896 ) {897 // Boost joined in M19b (lane contrast must READ) — sprint898 // zones recolor with it: one coherent presentation.899 zoneOf[k] = windowMods[wi];900 }901 }902 }903904 // Zone pad visuals (M11): set-pieces must READ on screen. Banners905 // stand on the channel rails (the goal-gate measurement — Kenney906 // designed the posts to sit on them), so they work even in the void:907 // banner-high marks a jump lip, banner-double-high a propeller lip,908 // a plain banner the entry of a standalone boost zone. Rule-derived,909 // no RNG. A banner goes ONLY on a plain straight window (USER RULE):910 // never on curved-channel cells (waves/wiggles are F-moves but the911 // channel swings away from the posts) and never under a tunnel arch912 // (the banner would thread the arch mesh) — family "straight" is913 // exactly the windows with a bare centered channel.914 const windowFamilyAt = (idx: number): string | null => {915 for (const w of windows) {916 if (idx >= w.start && idx < w.start + w.len) return w.def.family;917 }918 return null;919 };920 const gapStarts = new Set(921 plan.mods922 .filter((m) => m.kind === "jump" || m.kind === "propeller")923 .map((m) => m.start),924 );925 const pads: Placement[] = [];926 for (const m of plan.mods) {927 if (m.kind === "jump" || m.kind === "propeller") {928 const lip = plan.cells[m.start - 1];929 if (930 lip && lip.move === "F" &&931 windowFamilyAt(m.start - 1) === "straight"932 ) {933 pads.push({934 pieceId: m.kind === "propeller"935 ? "banner-double-high"936 : "banner-high",937 pos: [lip.x, lip.baseY + 0.5, lip.z],938 rotation: ((1 - lip.dirIn + 4) % 4) as Dir,939 role: "track",940 chapter: cellChapter(m.start - 1),941 });942 }943 } else if (m.kind === "boost" || m.kind === "gravity") {944 // The lip banner already announces a jump's approach zone.945 if (gapStarts.has(m.start + m.len)) continue;946 for (947 let idx = m.start;948 idx < m.start + m.len && idx < plan.cells.length;949 idx++950 ) {951 const c = plan.cells[idx];952 if (c.move !== "F" || windowFamilyAt(idx) !== "straight") continue;953 pads.push({954 pieceId: "banner",955 pos: [c.x, c.baseY + 0.5, c.z],956 rotation: ((1 - c.dirIn + 4) % 4) as Dir,957 role: "track",958 chapter: cellChapter(idx),959 });960 break;961 }962 }963 }964965 // Slalom rule (M8): the generator plans in track cells; the WORLD sees966 // track pieces at ×TRACK_SCALE (position and mesh scale) while islands967 // and decor stay 1×. The centerline scales with the track.968 // Filtering jump/reorient meshes changes array indices. Remap every969 // surviving path window to the FINAL placement array so diagnostic970 // provenance (and the LoopPath contract) cannot accidentally name a971 // later support/decor placement after an earlier gap was removed.972 const placementRemap = new Map<number, number>();973 const mainPlacements: Placement[] = [];974 for (const [idx, p] of assembled.placements.entries()) {975 if (dropped.has(idx)) continue;976 placementRemap.set(idx, mainPlacements.length);977 mainPlacements.push({978 ...p,979 chapter: chapterOf[idx],980 ...(zoneOf[idx] ? { zone: zoneOf[idx]! } : {}),981 });982 }983 const placements = [984 ...mainPlacements,985 ...furniture.map(({ placement, host }): Placement => ({986 ...placement,987 chapter: cellChapter(host),988 })),989 ...spurPieces.map(({ placement, host }): Placement => ({990 ...placement,991 chapter: cellChapter(host),992 })),993 ...braidPieces.map(({ placement, host }): Placement => ({994 ...placement,995 chapter: cellChapter(host),996 })),997 ...pads,998 ].map((p): Placement => ({999 ...p,1000 pos: scaleVec(p.pos),1001 scale: TRACK_SCALE,1002 }));1003 const path: LoopPath = {1004 segments: assembled.loop.segments.map((s) => {1005 const curve = s.curve.map(scaleSeg);1006 const length = s.length * TRACK_SCALE;1007 // Diamond/spur/braid branches: the alt centerline scales with1008 // the track too (the markers ride along for the proxies).1009 const alt = s.alt1010 ? {1011 curve: s.alt.curve.map(scaleSeg),1012 length: s.alt.length * TRACK_SCALE,1013 ...(s.alt.spur ? { spur: true } : {}),1014 ...(s.alt.braid ? { braid: true } : {}),1015 ...(s.alt.zones ? { zones: s.alt.zones } : {}),1016 ...(s.alt.contBraid ? { contBraid: true } : {}),1017 ...(s.alt.openBraid ? { openBraid: true } : {}),1018 }1019 : undefined;1020 return {1021 ...s,1022 placementIndex: s.placementIndex >= 01023 ? placementRemap.get(s.placementIndex) ?? -11024 : -1,1025 curve,1026 length,1027 ...(alt ? { alt } : {}),1028 // Floor-pierce centers scale with the track (LoopSegment law).1029 ...(s.holes ? { holes: s.holes.map(scaleVec) } : {}),1030 };1031 }),1032 totalLength: assembled.loop.totalLength * TRACK_SCALE,1033 };1034 // Braid alt LANE, world-scaled — the client (and drive harness)1035 // append plain channel proxies over it (placementIndex −1: its1036 // meshes ride in `placements` already, chapter-stamped).1037 // M23: EVERY braid lane's loop rides altPath (two braids per segment1038 // concatenate — channel ribbons per window, spans disjoint).1039 const altPath: LoopPath | undefined = braidAltLoops.length > 01040 ? {1041 segments: braidAltLoops.flatMap((lp) =>1042 lp.segments.map((s) => ({1043 placementIndex: -1,1044 curve: s.curve.map(scaleSeg),1045 length: s.length * TRACK_SCALE,1046 }))1047 ),1048 totalLength: braidAltLoops.reduce((a, lp) => a + lp.totalLength, 0) *1049 TRACK_SCALE,1050 }1051 : undefined;10521053 // Gravity reorientation (M16): a reorient run ROLLS everything1054 // downstream about its travel axis. The cumulative presentation1055 // frame is composed here — culling never rewinds generation, so the1056 // running frame is as seed-pure as nextStartS. Plan data stays1057 // +y-up; only the stamped frame record tells the client how this1058 // segment PRESENTS.1059 const entryFrame = this.frame;1060 let frame: SegmentFrame | undefined;1061 const reorient = plan.mods.find((m) => m.kind === "reorient");1062 if (reorient && reorient.angle !== undefined) {1063 const from = windows.findIndex((w) => w.start === reorient.start);1064 let to = from;1065 while (1066 to + 1 < windows.length &&1067 windows[to + 1].start < reorient.start + reorient.len1068 ) to++;1069 if (from < 0) {1070 throw new Error(1071 `reorient window missing at segment ${i} (seed ${this.seed})`,1072 );1073 }1074 let len = 0;1075 for (let k = from; k <= to; k++) len += path.segments[k].length;1076 const first = path.segments[from].curve[0];1077 const p0 = segPoint(first, 0);1078 const cell = plan.cells[reorient.start];1079 const [dx, dz] = DIR_STEP[cell.dirIn];1080 const axis: [number, number, number] = [dx, 0, dz];1081 const pivot: [number, number, number] = [p0[0], p0[1], p0[2]];1082 const exit = composeTwist(1083 entryFrame,1084 quatRotate(entryFrame.q, axis),1085 reorient.angle,1086 affineApply(entryFrame, pivot),1087 );1088 frame = {1089 entry: entryFrame,1090 exit,1091 twist: { angle: reorient.angle, axis, pivot, len, from, to },1092 };1093 this.frame = exit;1094 } else if (!isIdentityAffine(entryFrame)) {1095 frame = { entry: entryFrame, exit: entryFrame };1096 }10971098 this.segments.push({1099 index: i,1100 plan,1101 pieceFamilies: windows.map((w) => w.def.family),1102 windowRanges: windows.map((w) => ({ start: w.start, len: w.len })),1103 windowMods,1104 placements,1105 path,1106 ...(altPath ? { altPath } : {}),1107 islands,1108 // Void decor last (M18b): its own labeled family, consuming1109 // nothing from any stream above — and fed the finished segment.1110 voidDecor: planVoidDecor(this.seed, i, path, altPath, islands, this.occ),1111 // V3: a continuation's fork is always the immediately previous1112 // segment (open forks resolve at the very next boundary; the CAP1113 // bounds the total span, not the segment count).1114 ...(plan.braids.some((b) => b.cont) ? { braidContOf: i - 1 } : {}),1115 startS: this.nextStartS,1116 ...(frame ? { frame } : {}),1117 });1118 this.nextStartS += path.totalLength;1119 this.cullBefore(i - KEEP_SEGMENTS);1120 }1121}