1/**2 * Void path planner — the endless line.3 *4 * Grows an open path segment-by-segment through empty 3D space. No ground, no5 * closure: elevation is unbounded (with a gentle net-down bias so gravity6 * reads), and the only constraint is the shared Occupancy map — the line may7 * cross over, under, and flush alongside its own past, but bands never8 * overlap and it never merges into an old channel.9 *10 * Islands are ANCHORS, not scenery: every `anchorEvery` cells the planner11 * owes the line a landing — a flat run whose cells will carry a cliff-block12 * mass built underneath (islands.ts). The planner claims the island's mass13 * envelope immediately so later clauses (and later segments) route around or14 * under it, and the island builder then works entirely inside its own claim.15 *16 * RNG discipline: one family child per segment (`segment/${i}`), clause17 * children keyed by the cell offset they start at — label-derived, never18 * order-dependent across systems.19 *20 * Elevation bookkeeping mirrors elevation.ts exactly: a slope window's whole21 * delta steps at its start index (interior cells already carry the exit22 * base), which is what realize/assemble expect of reserved windows.23 */24import type { Dir } from "../../shared/types.ts";25import type { Rng } from "../rng.ts";26import type { PieceRegistry } from "../registry.ts";27import {28 CLIMB_VOCAB,29 DESCENT_VOCAB,30 type Reservation,31 type SlopeOption,32 TURN_DESCENTS,33} from "../elevation.ts";34import {35 applyXform,36 DIR_STEP,37 moveLabel,38 opposite,39 turnLeft,40 turnRight,41 windowXform,42} from "../geom.ts";43import { HELIX_LOOP_SPECS } from "../topology.ts";44import { CHANNEL_CLEARANCE, type OccView } from "./occupancy.ts";4546/** Planner dead-end — the caller retries with a fresh rng label on a clean47 * occupancy transaction. Deterministic per seed (dead ends are, too). */48export class PlannerStuck extends Error {}4950/**51 * The pocket guard: from `pose`, some flat continuation of `depth` cells52 * must exist (every clause's first cell needs its flat band, so a head with53 * no flat continuation is one clause away from stuck). `alsoBlocked`54 * carries cells a pending-but-uncommitted clause would claim. The world55 * generator reuses this after realization: WFC footprint extras land after56 * planning, and a segment only commits if its exit corridor survived them.57 */58export function hasFlatContinuation(59 occ: OccView,60 pose: HeadPose,61 depth: number,62 alsoBlocked: ReadonlySet<string> = new Set(),63): boolean {64 if (65 alsoBlocked.has(key(pose.x, pose.z)) ||66 !occ.isFree(pose.x, pose.z, pose.y, pose.y + CHANNEL_CLEARANCE)67 ) return false;68 if (depth <= 1) return true;69 for (const dirOut of [pose.dir, turnLeft(pose.dir), turnRight(pose.dir)]) {70 const next: HeadPose = {71 x: pose.x + DIR_STEP[dirOut][0],72 z: pose.z + DIR_STEP[dirOut][1],73 y: pose.y,74 dir: dirOut,75 };76 if (hasFlatContinuation(occ, next, depth - 1, alsoBlocked)) return true;77 }78 return false;79}8081export interface OpenCell {82 x: number;83 z: number;84 dirIn: Dir;85 dirOut: Dir;86 move: "F" | "L" | "R";87 /** Entry base height (world y — may be any multiple of 0.25). */88 baseY: number;89}9091/** Pose entering a cell: the cell's coordinates and the heading into it. */92export interface HeadPose {93 x: number;94 z: number;95 y: number;96 dir: Dir;97}9899export interface IslandCrossing {100 /** World-unique island id (rng family `island/${id}`, claim ref `isle/${id}`). */101 id: number;102 /** Local index range of cells riding the island top. */103 start: number;104 len: number;105 /** Island surface height at the run's ENTRY (sloped runs terrace from106 * here — see `profile`). */107 topY: number;108 /** Terrace top per run cell (floor base — a slope window's cells share109 * its LOW base; the ramp bridges). Flat runs: all topY. */110 profile: number[];111 /** Blob margin (Chebyshev rings around the run) the mass may fill. */112 margin: number;113 /** Raised viaduct (USER: pillars vary the island↔track connection):114 * the deck sits this far BELOW the track and kit pier stacks carry115 * the channel across (assemble emits them on the planner's deck116 * record). Absent = the classic flush terrace. */117 raised?: number;118 /** Extra channel cells beside the run (a diamond's branch columns) —119 * the island builder treats them as track: full mass, no caps, no120 * decor. */121 branchCells?: readonly (readonly [number, number])[];122 /** Wide-run flank columns (the wide profile bulges into them) — track123 * patch like branch columns: full mass, no caps, no decor. */124 wideFlanks?: readonly (readonly [number, number])[];125 /** Furniture ledges beside flat straight run cells, claimed at plan126 * time; furnished post-realize (stub siding or fan) when the host127 * cell realizes as a plain straight. `host` is the absolute cell128 * index; `side` is L/R of the flow. */129 sideCells?: readonly {130 cell: readonly [number, number];131 host: number;132 side: "L" | "R";133 }[];134 /** Envelope cells that received a MASS claim, with their terrace tops135 * — the planner's own deck record. Assemble consults it for solid136 * support; a third mirror of the terrace rule would risk drift. */137 deckCells?: readonly (readonly [number, number, number])[];138}139140/** Planner-placed physics zone (cell-indexed): the set-piece channel.141 * "boost" scales the assist's target speed (approaches, chutes); "jump"142 * marks a gap cell — its realized piece is dropped from the placements and143 * the collision proxies emit no channel there (ballistic flight, priced by144 * trajectory math at plan time); "propeller" is the 2-cell gap variant145 * flown under 0.35 gravity (Marble Blast helicopter); "shock" overrides146 * the marble restitution at hard landings (chute exits). */147export interface PlanMod {148 start: number;149 len: number;150 kind:151 | "boost"152 | "jump"153 | "propeller"154 | "shock"155 | "icy"156 | "gravity"157 | "spur"158 | "reorient"159 | "braid";160 /** Reorient only: signed roll (radians) about the run's travel axis —161 * everything downstream presents rotated (wall/ceiling riding). */162 angle?: number;163}164165/** Takeable spur drop (M13's loop-mode takeable.ts wired into the void):166 * a 1-cell straight host whose side cell carries an end-hole diving167 * board, with the MAIN line hooking down to pass back DIRECTLY UNDER168 * that cell at Δ exactly 1.0 (the bridge law). world.ts mutates the host169 * placement straight → split, adds the board, and hangs the one-way alt170 * centerline (out over the hole) on the host window. */171export interface SpurDrop {172 /** Cell index of the split host (its window is pinned to 1 cell). */173 host: number;174 /** Cell index where the main line passes back under the branch cell —175 * the receiver the falling marble lands on (same coords as the branch176 * cell, baseY exactly host − 1.0). */177 receiver: number;178 /** World dir from the host toward the branch cell. */179 dir: Dir;180 /** The diving-board piece on the branch cell. */181 cap: "end-hole-rounded" | "end-hole-square";182}183184/** Braided path (M17): the line forks into two genuine lanes that wander185 * apart and merge back `len` cells later. The MAIN lane is `len` straight186 * cells of the segment walk; cell `start` is the FORK host and cell187 * `start + len - 1` the MERGE host (both pinned 1-cell straights —188 * world.ts mutates them straight → split, spur-style). The ALT lane is a189 * real OpenCell sub-walk beside the main lane (offset 1..breadth on190 * `side`), NOT part of plan.cells — it realizes as its own sub-skeleton191 * and hangs on the fork window's LoopSegment.alt. */192export interface Braid {193 /** Cell index of the fork host. */194 start: number;195 /** Main-lane length in cells (merge host = start + len − 1). */196 len: number;197 /** World dir from the main lane toward the alt side. */198 side: Dir;199 /** The alt lane as a real adjacent-cell walk, fork branch cell →200 * merge branch cell, flat at the fork's baseY. */201 altCells: OpenCell[];202 /** Lane character (M19b): surface contrast between the lanes — TAGS203 * ONLY (rail-station modifiers + pipe recolor). The tag-only law:204 * laneMods never pin windows, never join plan.mods (realize's exclude205 * rules must not see them), never alter geometry. Both lanes exclude206 * tags within 2 cells of fork and merge (the junction-lip law in207 * friction form — an icy patch at a merge mouth parks a slow rider on208 * the lip). */209 laneMods?: { main?: "icy" | "boost"; alt?: "icy" | "boost" };210 /** Wanderer character (M19b): the alt lane leaned into the far211 * offsets this draw — always compensated by an alt boost tag so the212 * long way around is never a strict loser. */213 wanderer?: boolean;214 /** OPEN fork (V3, user directive): this braid runs to the SEGMENT215 * BOUNDARY without merging — the alt lane exits forward beside the216 * main lane and the SUCCESSOR segment must plan the continuation217 * (HeadState.openBraid; the successor-probe gate is what makes218 * leaving it open safe: an open fork only commits when its resolution219 * actually plans). No merge host, no merge mutation this segment. */220 open?: boolean;221 /** Continuation (V3): this braid RESOLVES a fork left open by the222 * previous segment — the alt lane enters at the boundary (no fork223 * host) and merges at `start + len − 1`. */224 cont?: boolean;225}226227/** An alt lane crossing a segment boundary (V3). Rides HeadState. */228export interface OpenBraidState {229 /** World dir from the main lane toward the alt side. */230 side: Dir;231 /** Alt-lane offset at the boundary (1..3). */232 offset: number;233 /** Main-lane cells covered since the fork (the resolution cap counts234 * against this — "up to a point"). */235 cells: number;236 laneMods?: Braid["laneMods"];237 wanderer?: boolean;238}239240/** Hard cap on a fork's total main-lane span (fork → merge), cells.241 * "Up to a point": a continuation may STAY OPEN once (M23 braid242 * networks — the alt lane rides a whole middle segment), so the longest243 * legal chain is fork segment + one full open segment + merge segment. */244export const OPEN_BRAID_CAP = 60;245/** A continuation may re-emit the open state at this chance when the246 * cap still affords the full middle segment plus a minimal merge. */247const STAY_OPEN_CHANCE = 0.35;248249/** M21 — climate → hazard appetite (clause-weight multipliers by the250 * zone's BASE mood). Legibility rule: each mood leans at most 2-3251 * clauses, hard enough to FEEL across a 5-segment zone. */252export const MOOD_CLAUSE_BIAS: Record<string, Record<string, number>> = {253 snowdrift: { icy: 2.5, sprint: 0.7 },254 goldhour: { jump: 1.6, sprint: 1.4, icy: 0.5 },255 ember: { sprint: 1.5, cornerDrop: 1.3 },256 abyss: { gravity: 1.8, icy: 1.3 },257 midnight: { braid: 1.5, spurDrop: 1.4 },258 violet: { helixDive: 1.7, reorient: 1.3 },259};260261export interface SegmentPlan {262 index: number;263 cells: OpenCell[];264 /** Movement set-piece zones (M11): e.g. boost runs into big chutes. */265 mods: PlanMod[];266 /** Takeable spur drops (local host/receiver cell indices). */267 spurs: SpurDrop[];268 /** Braided paths (fork/merge host cell indices + the alt sub-walk). */269 braids: Braid[];270 /** Slope/set-piece windows (local indices) — realize commits them as-is. */271 reservations: Reservation[];272 crossings: IslandCrossing[];273 entry: HeadPose;274 exit: HeadPose;275}276277/** Carried head-of-line state between segments (pose + anchor cadence). */278export interface HeadState {279 pose: HeadPose;280 /** Cells travelled since the line last left an island. */281 sinceAnchor: number;282 /** Island due when sinceAnchor reaches this. */283 anchorEvery: number;284 /** Monotone island counter — ids must be world-unique. */285 islandCount: number;286 /** A fork still unresolved at this boundary (V3) — the next segment287 * MUST open with its continuation and merge within the cap. */288 openBraid?: OpenBraidState;289}290291/** The line spawns anchored: segment 0 opens with its home island due. */292export function initialHeadState(): HeadState {293 return {294 pose: { x: 0, z: 0, y: 0, dir: 0 },295 sinceAnchor: 0,296 anchorEvery: 0,297 islandCount: 0,298 };299}300301/** Segment length floor — clauses run until at least this many cells. */302const SEGMENT_TARGET = 26;303/** Deepest mass an island envelope reserves below its top. */304export const ISLAND_MAX_DEPTH = 3.25;305/** Airspace above island surface cells reserved for vegetation, in TRACK306 * units — tall decor only grows where this claim succeeded. Nature trees307 * run to ~1.8 WORLD units = 0.9 track units at the slalom scale; 1.0308 * leaves margin. */309export const CANOPY_HEIGHT = 1.0;310/** Void-flight cells between islands (drawn per departure). */311const ANCHOR_EVERY_MIN = 14;312const ANCHOR_EVERY_MAX = 38;313314const key = (x: number, z: number) => `${x},${z}`;315316interface Step {317 move: "F" | "L" | "R";318 /** Band this cell claims, relative to its entry base: [lo, hi]. */319 band: readonly [number, number];320 /** Entry base delta applied AFTER this cell (step-at-start convention:321 * a window's whole delta lands after its first cell). */322 stepAfter?: number;323}324325export function planSegment(326 rng: Rng,327 index: number,328 state: HeadState,329 occ: OccView,330 registry: PieceRegistry,331 braidBias = 1,332 /** Merged clause-weight bias (M21 climate × M24 temperament) — the333 * caller (world.ts) composes it; {} = neutral (finite-mode callers,334 * tests). */335 clauseBias: Record<string, number> = {},336): { plan: SegmentPlan; next: HeadState } {337 return new SegmentBuilder(338 rng,339 index,340 state,341 occ,342 registry,343 braidBias,344 clauseBias,345 ).build();346}347348class SegmentBuilder {349 private cells: OpenCell[] = [];350 private reservations: Reservation[] = [];351 private mods: PlanMod[] = [];352 private spurs: SpurDrop[] = [];353 private braids: Braid[] = [];354 private crossings: IslandCrossing[] = [];355 private pose: HeadPose;356 private sinceAnchor: number;357 private anchorEvery: number;358 private islandCount: number;359 private readonly ref: string;360 private readonly entry: HeadPose;361362 /** Fork left open by the PREVIOUS segment (V3) — resolved first. */363 private readonly openBraidIn?: OpenBraidState;364 /** Fork THIS segment leaves open at its boundary (V3). */365 private openBraidOut?: OpenBraidState;366367 constructor(368 private readonly rng: Rng,369 private readonly index: number,370 state: HeadState,371 private readonly occ: OccView,372 private readonly registry: PieceRegistry,373 /** Per-seed breadth knob (1..3, drawn once by the world from374 * rngFamily(seed, "braid-breadth")) — some seeds are tangly, some375 * clean. Caps how far a braid's alt lane may wander out. */376 private readonly braidBias: number = 1,377 /** Merged clause bias (climate × temperament). */378 private readonly clauseBias: Record<string, number> = {},379 ) {380 this.pose = { ...state.pose };381 this.sinceAnchor = state.sinceAnchor;382 this.anchorEvery = state.anchorEvery;383 this.islandCount = state.islandCount;384 this.ref = `seg/${index}`;385 this.entry = { ...state.pose };386 this.openBraidIn = state.openBraid;387 }388389 build(): { plan: SegmentPlan; next: HeadState } {390 // V3: a fork left open at the previous boundary resolves FIRST — the391 // continuation is not a clause the planner may skip. Failure is a392 // PlannerStuck (the retry/probe machinery re-attempts; the fork only393 // committed because a successor probe planned this very394 // continuation, so a viable label exists).395 if (this.openBraidIn) {396 if (!this.tryBraidContinuation(this.rng.child("braid-cont"))) {397 throw new PlannerStuck(398 `segment ${this.index}: open braid continuation failed ` +399 `(offset ${this.openBraidIn.offset}, ` +400 `${this.openBraidIn.cells} cells since fork)`,401 );402 }403 }404 while (this.cells.length < SEGMENT_TARGET) {405 const at = this.cells.length;406 const clauseRng = this.rng.child(`clause/${at}`);407 if (this.sinceAnchor >= this.anchorEvery) {408 if (this.tryIsland(clauseRng)) continue;409 // No room for an island here — fly on and retry next clause.410 if (this.tryVoidClause(clauseRng.child("fallback"))) continue;411 } else if (this.tryVoidClause(clauseRng)) {412 continue;413 }414 // Fully stuck: escape with a steep descent, the void below is the415 // least-travelled direction (net drift is downward).416 if (!this.tryEscape(clauseRng.child("escape"))) {417 throw new PlannerStuck(418 `segment ${this.index}, cell ${at}, pose ${419 JSON.stringify(this.pose)420 }`,421 );422 }423 }424 // Boundary margin: end the segment inside a straight so the WFC seam425 // between segments always sits mid-run. Keep flying normal clauses426 // until a 2-cell straight fits. An OPEN braid (V3) already ran its427 // straight main lane flush to the boundary — its tail IS the margin,428 // and appending more cells would strand the alt lane short of the429 // seam.430 let guard = 0;431 while (!this.openBraidOut && !this.commitSteps(this.straightSteps(2))) {432 if (guard++ > 40) {433 throw new PlannerStuck(`closing segment ${this.index}`);434 }435 const rng = this.rng.child(`boundary/${this.cells.length}`);436 if (this.tryVoidClause(rng)) continue;437 if (!this.tryEscape(rng.child("escape"))) {438 throw new PlannerStuck(`closing segment ${this.index}`);439 }440 }441442 // Reorient window veto: the twist's presentation field is a pure443 // axial blend across an infinite slab (frame.ts twistParam) — any444 // OTHER geometry of this segment inside the open window would445 // present mid-roll: continuous within the segment, but the boundary446 // cells then carry a partial roll against the neighbor segment's447 // exact entry/exit frame and the world tears at the seam (a marble448 // fell through one). The whole attempt is discarded; the caller449 // retries under a fresh label.450 if (this.twistWindow) {451 const { px, pz, dx, dz, start, len } = this.twistWindow;452 const bad = (x: number, z: number): void => {453 const a = (x - px) * dx + (z - pz) * dz;454 if (a > 0.01 && a < len - 0.01) {455 throw new PlannerStuck(456 `segment ${this.index}: geometry at (${x},${z}) inside the ` +457 `reorient window`,458 );459 }460 };461 for (const [i, c] of this.cells.entries()) {462 if (i >= start && i < start + len) continue;463 bad(c.x, c.z);464 }465 for (const b of this.braids) {466 for (const c of b.altCells) bad(c.x, c.z);467 }468 for (const s of this.spurs) {469 const c = this.cells[s.host];470 const [wx, wz] = DIR_STEP[s.dir];471 bad(c.x + wx, c.z + wz);472 }473 for (const cr of this.crossings) {474 for (const [x, z] of cr.deckCells ?? []) bad(x, z);475 for (const [x, z] of cr.branchCells ?? []) bad(x, z);476 for (const [x, z] of cr.wideFlanks ?? []) bad(x, z);477 for (const sc of cr.sideCells ?? []) bad(sc.cell[0], sc.cell[1]);478 }479 }480481 const plan: SegmentPlan = {482 index: this.index,483 cells: this.cells,484 mods: this.mods,485 spurs: this.spurs,486 braids: this.braids,487 reservations: this.reservations,488 crossings: this.crossings,489 entry: this.entry,490 exit: { ...this.pose },491 };492 return {493 plan,494 next: {495 pose: { ...this.pose },496 sinceAnchor: this.sinceAnchor,497 anchorEvery: this.anchorEvery,498 islandCount: this.islandCount,499 ...(this.openBraidOut ? { openBraid: this.openBraidOut } : {}),500 },501 };502 }503504 // -------------------------------------------------------------------------505 // Clause machinery506 // -------------------------------------------------------------------------507508 /** Walk `steps` from the current pose; returns claimed cells + exit pose,509 * or null when any band collides. Commits nothing. */510 private probe(511 steps: readonly Step[],512 ):513 | {514 cells: { x: number; z: number; y: number }[];515 exit: HeadPose;516 }517 | null {518 let { x, z, y, dir } = this.pose;519 const cells: { x: number; z: number; y: number }[] = [];520 // Bands this walk would claim — a clause must not intersect ITSELF521 // either (a turny island run can loop back before anything commits).522 const pending = new Map<string, [number, number][]>();523 for (const s of steps) {524 const dirOut = s.move === "F"525 ? dir526 : s.move === "L"527 ? turnLeft(dir)528 : turnRight(dir);529 const y0 = y + s.band[0];530 const y1 = y + s.band[1];531 if (!this.occ.isFree(x, z, y0, y1)) return null;532 const k = key(x, z);533 for (const [p0, p1] of pending.get(k) ?? []) {534 if (y0 < p1 && p0 < y1) return null;535 }536 const bands = pending.get(k) ?? [];537 bands.push([y0, y1]);538 pending.set(k, bands);539 cells.push({ x, z, y });540 y += s.stepAfter ?? 0;541 dir = dirOut;542 x += DIR_STEP[dirOut][0];543 z += DIR_STEP[dirOut][1];544 }545 return { cells, exit: { x, z, y, dir } };546 }547548 /** Probe + pocket guard + append cells + claim bands + advance the pose. */549 private commitSteps(steps: readonly Step[]): boolean {550 const probed = this.probe(steps);551 if (!probed) return false;552 const pending = new Set(probed.cells.map((p) => key(p.x, p.z)));553 if (!hasFlatContinuation(this.occ, probed.exit, 3, pending)) return false;554 for (let i = 0; i < steps.length; i++) {555 const s = steps[i];556 const p = probed.cells[i];557 const dirIn = this.pose.dir;558 const dirOut = s.move === "F"559 ? dirIn560 : s.move === "L"561 ? turnLeft(dirIn)562 : turnRight(dirIn);563 this.cells.push({564 x: p.x,565 z: p.z,566 dirIn,567 dirOut,568 move: s.move,569 baseY: p.y,570 });571 this.occ.claim(p.x, p.z, p.y + s.band[0], p.y + s.band[1], this.ref);572 this.pose = {573 x: p.x + DIR_STEP[dirOut][0],574 z: p.z + DIR_STEP[dirOut][1],575 y: p.y + (s.stepAfter ?? 0),576 dir: dirOut,577 };578 this.sinceAnchor++;579 }580 return true;581 }582583 private straightSteps(len: number): Step[] {584 return Array.from({ length: len }, () => ({585 move: "F" as const,586 band: [0, CHANNEL_CLEARANCE] as const,587 }));588 }589590 // -------------------------------------------------------------------------591 // Void-flight clauses592 // -------------------------------------------------------------------------593594 private tryVoidClause(rng: Rng): boolean {595 // M21+M24: climate × temperament lean the hazard appetite — one596 // weighted draw either way (stream shape identical, outcomes lean).597 // Snowdrift zones grow icy patches; a treacherous SEED grows them598 // everywhere.599 const bias = this.clauseBias;600 const kind = rng.weighted(601 ([602 ["straight", 4],603 ["turn", 2.5],604 ["jog", 1.5],605 ["slope", 3],606 ["cornerDrop", 1.2],607 ["jump", 0.8],608 ["sprint", 0.9],609 ["helixDive", 0.5],610 ["icy", 0.7],611 ["gravity", 0.5],612 ["spurDrop", 0.35],613 ["reorient", 0.45],614 ["braid", 0.5],615 ] as const).map(([k, w]) => [k, w * (bias[k] ?? 1)] as const),616 );617 const attempts: (() => boolean)[] = [];618 switch (kind) {619 case "straight":620 attempts.push(() =>621 this.commitSteps(this.straightSteps(rng.int(2, 6)))622 );623 // Inch forward: a 1-cell step navigates any corridor the pocket624 // guard has promised, one clause at a time.625 attempts.push(() => this.commitSteps(this.straightSteps(1)));626 break;627 case "turn": {628 const first = rng.chance(0.5) ? "L" : "R";629 for (const m of first === "L" ? ["L", "R"] : ["R", "L"]) {630 attempts.push(() =>631 this.commitSteps([632 { move: m as "L" | "R", band: [0, CHANNEL_CLEARANCE] },633 ])634 );635 }636 break;637 }638 case "jog": {639 // "FRL"/"FLR" jogs — the staircase signature the short s-curve640 // pieces (and wiggle composites) collapse onto.641 const first = rng.chance(0.5) ? ["F", "R", "L"] : ["F", "L", "R"];642 const alt = first[1] === "R" ? ["F", "L", "R"] : ["F", "R", "L"];643 for (const moves of [first, alt]) {644 attempts.push(() =>645 this.commitSteps(646 moves.map((m) => ({647 move: m as Step["move"],648 band: [0, CHANNEL_CLEARANCE] as const,649 })),650 )651 );652 }653 break;654 }655 case "slope":656 return this.trySlope(rng);657 case "cornerDrop":658 return this.tryCornerDrop(rng);659 case "jump":660 return this.tryJump(rng);661 case "sprint":662 return this.trySprint(rng);663 case "helixDive":664 return this.tryHelixDive(rng);665 case "icy":666 return this.tryIcy(rng);667 case "gravity":668 return this.tryGravity(rng);669 case "spurDrop":670 return this.trySpurDrop(rng);671 case "reorient":672 return this.tryReorient(rng);673 case "braid":674 return this.tryBraid(rng);675 }676 for (const attempt of attempts) if (attempt()) return true;677 return false;678 }679680 /** Straight-run slope from the shared vocab. Descents dominate (the void681 * drift is downward); climbs keep the rhythm honest. */682 private trySlope(rng: Rng): boolean {683 const climb = rng.chance(0.18);684 const vocab = climb ? CLIMB_VOCAB : DESCENT_VOCAB;685 const delta = rng.weighted(686 (climb687 ? [["0.5", 4], ["1", 2]]688 : [["0.5", 3], ["1", 2.5], ["1.5", 2]]) as readonly (readonly [689 string,690 number,691 ])[],692 );693 const options = vocab[delta];694 const option: SlopeOption = rng.pick(options);695 return this.commitSlope(option, climb);696 }697698 private commitSlope(option: SlopeOption, climb: boolean): boolean {699 // Build the step list part-by-part; each part is its own reservation700 // (multi-part options chain, descents traverse parts in reverse —701 // mirroring elevation.ts reserve()).702 const parts = climb ? option.parts : option.parts.slice().reverse();703 const steps: Step[] = [];704 for (const part of parts) {705 const signed = climb ? part.delta : -part.delta;706 for (let j = 0; j < part.len; j++) {707 // Every cell of the part claims the part's FULL envelope. The708 // whole delta rides on cell 0's stepAfter, so later cells sit at709 // the part's EXIT y — their band must reach BACK across the delta710 // (the piece descends/climbs continuously through them). The old711 // j>0 band repeated cell 0's, leaving the geometry's upper half712 // unclaimed on descents: apfuzz-81's slant-long-d walls topped out713 // 1.0 above their claim and barricaded the channel crossing above.714 const lo = j === 0 ? Math.min(0, signed) : Math.min(0, -signed);715 const hi = (j === 0 ? Math.max(0, signed) : Math.max(0, -signed)) +716 CHANNEL_CLEARANCE;717 steps.push({718 move: "F",719 band: [lo, hi],720 ...(j === 0 ? { stepAfter: signed } : {}),721 });722 }723 }724 const start = this.cells.length;725 if (!this.commitSteps(steps)) return false;726 // BOOST-CHUTE set-piece: big descents get a boost zone from two cells727 // ahead of the lip through the chute — the spectacle reads as the728 // track hurling the marble (and the exit momentum carries what729 // follows).730 const totalDrop = parts.reduce((a2, p2) => a2 + p2.delta, 0);731 if (!climb && totalDrop >= 1.0) {732 this.mods.push({733 start: Math.max(0, start - 2),734 len: start - Math.max(0, start - 2) + option.totalLen,735 kind: "boost",736 });737 }738 // SHOCK-ABSORBER zone (M11): the two cells after a big chute are the739 // impact valley — the marble arrives with vy piled on; killing the740 // bounce there keeps the landing readable (Marble Blast shock741 // absorber before a hard drop). Cells may not be planned yet; the742 // mod is cell-indexed, so the windows that eventually cover them743 // pick it up.744 if (!climb && totalDrop >= 1.5) {745 this.mods.push({746 start: start + option.totalLen,747 len: 2,748 kind: "shock",749 });750 }751 let at = start;752 for (const part of parts) {753 this.reservations.push({754 start: at,755 len: part.len,756 pieceIds: part.ids,757 reversed: !climb && !part.asIs,758 });759 at += part.len;760 }761 return true;762 }763764 /** Turn-window set piece: helix quarters/halves and bare-corner765 * drop-stacks from TURN_DESCENTS — the void's corner descents. */766 private tryCornerDrop(rng: Rng): boolean {767 const pattern = rng.weighted(768 [769 ["L", 1.5],770 ["R", 1.5],771 ["FLF", 3],772 ["FRF", 3],773 ["FFLFF", 1],774 ["FFRFF", 1],775 ["FLFFLF", 0.8],776 ["FRFFRF", 0.8],777 ] as const,778 );779 const spec = TURN_DESCENTS[pattern];780 const def = this.registry.variants.find(781 (v) => v.id === spec.ids[0] && !v.reversed,782 );783 if (!def) return false;784785 const steps: Step[] = pattern.split("").map((m, j) => ({786 move: m as Step["move"],787 band: [-spec.delta, CHANNEL_CLEARANCE] as const,788 ...(j === 0 ? { stepAfter: -spec.delta } : {}),789 }));790791 // Footprint extras (arc interiors) must be free too — reservations are792 // committed unchecked by realize, so the planner is the gatekeeper.793 const probed = this.probe(steps);794 if (!probed) return false;795 const first = probed.cells[0];796 const xf = windowXform(first.x, first.z, this.pose.dir, 0);797 const windowSet = new Set(probed.cells.map((p) => key(p.x, p.z)));798 const extras: [number, number][] = [];799 for (const [fx, fz] of def.footprint) {800 const w = applyXform(xf, [fx, 0, fz]);801 const wx = Math.round(w[0]);802 const wz = Math.round(w[2]);803 if (windowSet.has(key(wx, wz))) continue;804 if (805 !this.occ.isFree(806 wx,807 wz,808 first.y - spec.delta,809 first.y + CHANNEL_CLEARANCE,810 )811 ) return false;812 extras.push([wx, wz]);813 }814815 const start = this.cells.length;816 if (!this.commitSteps(steps)) return false;817 for (const [wx, wz] of extras) {818 this.occ.claim(819 wx,820 wz,821 first.y - spec.delta,822 first.y + CHANNEL_CLEARANCE,823 this.ref,824 );825 }826 this.reservations.push({827 start,828 len: pattern.length,829 pieceIds: spec.ids,830 reversed: false,831 });832 return true;833 }834835 /**836 * SPRINT set-piece (M11): a long straight boost zone on the flat — the837 * marble accelerates to the boost target and tears down the run. The838 * zone stops 2 cells short of the clause end so the brake has runway839 * before whatever follows. These are ALSO the only cells where WFC may840 * free-collapse BUMP windows (4 flat cells fully inside a boost zone):841 * at sprint speed a bump crest is a small launch, and the ribbon that842 * follows the bump profile is its own catch.843 */844 private trySprint(rng: Rng): boolean {845 const len = rng.int(6, 8);846 const start = this.cells.length;847 if (!this.commitSteps(this.straightSteps(len))) return false;848 this.mods.push({ start, len: len - 2, kind: "boost" });849 return true;850 }851852 /**853 * JUMP set-piece (M11): a real 1-cell channel gap. Layout is854 * boost-approach (3 flat cells — the assist accelerates to launch855 * speed) → gap cell (reserved slant-b DESCENDING 0.5; world.ts drops856 * its placement so nothing renders and the proxies emit no channel —857 * absence IS the gap) → 2 flat landing cells 0.5 lower.858 *859 * Trajectory, priced here at plan time: launch is horizontal at the860 * boost target v ≈ 3.2·1.9·TRACK_SCALE-adjusted ≈ 6.1 world u/s; the861 * 0.5-track drop (1.0 world) takes t = √(2·1.0/20) ≈ 0.316 s, covering862 * v·t ≈ 1.9 world of the 2.0-world gap — the marble lands on the catch863 * at the landing's leading edge (the proxies extend a pan under the lip864 * so slow arrivals are caught too; hot arrivals ≤ the 9 u/s brake cap865 * land within the first landing cell). The gap cell's claim spans down866 * to the landing base — the flight band.867 */868 private tryJump(rng: Rng): boolean {869 const APPROACH = 3;870 const LANDING = 2;871 // PROPELLER variant (2-cell gap flown under 0.35 gravity — same872 // launch speed, longer floatier arc): drop 1.0 world takes873 // √(2/7) ≈ 0.535 s, covering ≈ 3.3 of the 4.0-world gap at the boost874 // target; the catch pan absorbs the shortfall, the brake cap (9 u/s875 // → 4.8 world) stays on the landing cells.876 const propeller = rng.chance(0.4);877 const gapLen = propeller ? 2 : 1;878 const gapIds = propeller ? ["ramp-long-b", "slant-long-b"] : ["slant-b"];879 // The gap window realizes as a reversed 0.5-descent (dropped from the880 // placements later); its centerline keeps the rail continuous through881 // the flight.882 if (883 !this.registry.variants.some((v) =>884 gapIds.includes(v.id) && v.reversed && v.moves.length === gapLen885 )886 ) return false;887 const steps: Step[] = [888 ...this.straightSteps(APPROACH),889 // Gap cells mirror commitSlope's band shape: every cell claims down890 // to the landing base (the flight band), delta steps after cell 0.891 ...Array.from({ length: gapLen }, (_, j) => ({892 move: "F" as const,893 band: [-0.5, CHANNEL_CLEARANCE] as const,894 ...(j === 0 ? { stepAfter: -0.5 } : {}),895 })),896 ...this.straightSteps(LANDING),897 ];898 const start = this.cells.length;899 if (!this.commitSteps(steps)) return false;900 this.mods.push({ start, len: APPROACH, kind: "boost" });901 this.mods.push({902 start: start + APPROACH,903 len: gapLen,904 kind: propeller ? "propeller" : "jump",905 });906 this.reservations.push({907 start: start + APPROACH,908 len: gapLen,909 pieceIds: gapIds,910 reversed: true,911 });912 return true;913 }914915 /**916 * FULL-HELIX SPIRAL DIVE (promoted backlog): the loop-mode ensemble917 * dropped into the void — a 360° spiral block whose entry and exit share918 * one mid-face port edge; the line dives 2.0 and passes back UNDER its919 * own approach (the underpass straights). The window's moves REVISIT its920 * first cells, which the generic step machinery can't express (per-visit921 * bands at one cell would self-collide) — so this clause is the922 * duplicate-cell-tolerant path: the ensemble claims each footprint cell923 * ONCE over the whole block depth [−2, clearance] (the spiral occupies924 * its full column — nothing may thread through the block), and925 * this.cells simply carries the revisited coordinates twice at their two926 * bases. Realize matches the reserved def by moves (same as loop mode);927 * the mesh provides both levels, and the centerline's spiral arc drops928 * linearly in angle. One per segment — it's a set-piece, not a texture.929 */930 private hadHelixDive = false;931 private tryHelixDive(rng: Rng): boolean {932 if (this.hadHelixDive) return false;933 const large = rng.chance(0.35);934 const turn = rng.chance(0.5) ? "R" : "L";935 const spec = HELIX_LOOP_SPECS.find(936 (s) => s.large === large && s.turn === turn,937 );938 if (!spec) return false;939 const def = this.registry.variants.find(940 (v) => v.id === spec.id && !v.reversed,941 );942 if (!def) return false;943944 const { x, z, y, dir } = this.pose;945 const xf = windowXform(x, z, dir, 0);946 const block: [number, number][] = [];947 const seen = new Set<string>();948 for (const [fx, fz] of def.footprint) {949 const w = applyXform(xf, [fx, 0, fz]);950 const wx = Math.round(w[0]);951 const wz = Math.round(w[2]);952 const k = key(wx, wz);953 if (seen.has(k)) continue;954 seen.add(k);955 if (!this.occ.isFree(wx, wz, y - 2, y + CHANNEL_CLEARANCE)) return false;956 block.push([wx, wz]);957 }958959 // Walk the ensemble's moves for cell bookkeeping and the exit pose.960 // Interior cells carry the exit base (step-at-start: the whole −2961 // lands after the first cell) — exactly what realize expects of a962 // reserved window.963 let cx = x;964 let cz = z;965 let d = dir;966 const walked: OpenCell[] = [];967 for (let j = 0; j < spec.moves.length; j++) {968 const m = spec.moves[j] as "F" | "L" | "R";969 const dirIn = d;970 const dirOut = m === "F" ? d : m === "L" ? turnLeft(d) : turnRight(d);971 walked.push({972 x: cx,973 z: cz,974 dirIn,975 dirOut,976 move: m,977 baseY: j === 0 ? y : y - 2,978 });979 d = dirOut;980 cx += DIR_STEP[dirOut][0];981 cz += DIR_STEP[dirOut][1];982 }983 const exit: HeadPose = { x: cx, z: cz, y: y - 2, dir: d };984 if (!hasFlatContinuation(this.occ, exit, 3, seen)) return false;985986 const start = this.cells.length;987 for (const [wx, wz] of block) {988 this.occ.claim(wx, wz, y - 2, y + CHANNEL_CLEARANCE, this.ref);989 }990 this.cells.push(...walked);991 this.sinceAnchor += spec.moves.length;992 this.pose = exit;993 this.reservations.push({994 start,995 len: spec.moves.length,996 pieceIds: [spec.id],997 reversed: false,998 });999 this.hadHelixDive = true;1000 return true;1001 }10021003 /**1004 * ICY RUN (M13): a flat straight patch of low-grip channel — the assist1005 * backs off (rail.ts scales its authority down) so the marble carries1006 * whatever momentum it arrived with and slides wide through whatever1007 * follows. The zone READS via the client's ice-pale pipe recolor.1008 */1009 private tryIcy(rng: Rng): boolean {1010 const len = rng.int(4, 7);1011 const start = this.cells.length;1012 if (!this.commitSteps(this.straightSteps(len))) return false;1013 this.mods.push({ start, len, kind: "icy" });1014 return true;1015 }10161017 /**1018 * GRAVITY-ROTATION RUN (M13): a flat straight patch where the WORLD's1019 * gravity vector tilts forward along the run (shim gravityDir) — the1020 * marble surges down a run that is geometrically flat, and the follow1021 * cam leans with it. Sprint-style layout: the zone stops 2 cells short1022 * of the clause end, so gravity has snapped back and the assist has1023 * brake runway before whatever follows. READS via the client's1024 * violet-charged pipe recolor.1025 */1026 private tryGravity(rng: Rng): boolean {1027 const len = rng.int(6, 8);1028 const start = this.cells.length;1029 if (!this.commitSteps(this.straightSteps(len))) return false;1030 this.mods.push({ start, len: len - 2, kind: "gravity" });1031 return true;1032 }10331034 /**1035 * TAKEABLE SPUR DROP (M13's loop-mode machinery wired into the void):1036 * a straight host cell whose side cell carries an end-hole diving1037 * board, with the MAIN line hooking down 1.0 and passing back DIRECTLY1038 * UNDER that side cell — the receiver at Δ exactly 1.0 below the deck1039 * (the bridge law: two stacked channels just clear). A marble riding1040 * the branch (respawn variety alternates onto it) rolls out over the1041 * hole, falls through, and lands ON ITS OWN LINE (hole semantics, USER1042 * RULE: every fall lands on track below — enforced by construction).1043 * The continuation IS the main line, so the head walks on normally.1044 *1045 * Move pattern (branch left shown; right mirrors), deck level y0:1046 *1047 * B ← A4 host H straight, then a 0.5 descent, two turns1048 * ↑ ↑ around the block at y0−0.5, a second 0.51049 * → H → A2 → A3 descent into B (= the branch cell, at y0−1),1050 * (B above H+left) and a flat runway cell out.1051 *1052 * One per segment — punctuation, not texture.1053 */1054 private hadSpur = false;1055 private trySpurDrop(rng: Rng): boolean {1056 if (this.hadSpur) return false;1057 // The two descents realize as reversed slant-b (the jump clause's1058 // own availability check).1059 if (1060 !this.registry.variants.some((v) =>1061 v.id === "slant-b" && v.reversed && v.moves.length === 11062 )1063 ) return false;1064 const sides: ("L" | "R")[] = rng.chance(0.5) ? ["L", "R"] : ["R", "L"];1065 for (const side of sides) {1066 if (this.trySpurSide(rng.child(`side/${side}`), side)) return true;1067 }1068 return false;1069 }10701071 private trySpurSide(rng: Rng, side: "L" | "R"): boolean {1072 const steps: Step[] = [1073 // Host (the future split junction).1074 { move: "F", band: [0, CHANNEL_CLEARANCE] },1075 // First 0.5 descent (band shape mirrors commitSlope/tryJump).1076 { move: "F", band: [-0.5, CHANNEL_CLEARANCE], stepAfter: -0.5 },1077 // Two turns around the block at y0−0.5.1078 { move: side, band: [0, CHANNEL_CLEARANCE] },1079 { move: side, band: [0, CHANNEL_CLEARANCE] },1080 // Second 0.5 descent, arriving under the branch cell.1081 { move: "F", band: [-0.5, CHANNEL_CLEARANCE], stepAfter: -0.5 },1082 // The receiver: the branch cell's column at deck − 1.0.1083 { move: "F", band: [0, CHANNEL_CLEARANCE] },1084 // Flat runway so the landing rolls on.1085 { move: "F", band: [0, CHANNEL_CLEARANCE] },1086 ];1087 const probed = this.probe(steps);1088 if (!probed) return false;1089 const host = probed.cells[0];1090 const w = side === "L" ? turnLeft(this.pose.dir) : turnRight(this.pose.dir);1091 const [wx, wz] = DIR_STEP[w];1092 const bx = host.x + wx;1093 const bz = host.z + wz;1094 const rec = probed.cells[5];1095 // The hook must come back exactly under the branch cell at Δ 1.0 —1096 // true by construction; keep the law loud if the pattern drifts.1097 if (rec.x !== bx || rec.z !== bz || rec.y !== host.y - 1) return false;1098 // The branch deck band is claimed manually (probe only walks the1099 // line); it stacks EXACTLY on the receiver's channel claim below.1100 if (!this.occ.isFree(bx, bz, host.y, host.y + CHANNEL_CLEARANCE)) {1101 return false;1102 }1103 const start = this.cells.length;1104 if (!this.commitSteps(steps)) return false;1105 this.occ.claim(bx, bz, host.y, host.y + CHANNEL_CLEARANCE, this.ref);1106 // Pin the structural windows: the host must realize as a plain1107 // 1-cell straight (its placement mutates straight → split in1108 // world.ts), the descents as slant-b, and the receiver as a bare1109 // open channel — no arch/wave may realize over the landing.1110 this.reservations.push(1111 { start, len: 1, pieceIds: ["straight"], reversed: false },1112 { start: start + 1, len: 1, pieceIds: ["slant-b"], reversed: true },1113 { start: start + 4, len: 1, pieceIds: ["slant-b"], reversed: true },1114 { start: start + 5, len: 1, pieceIds: ["straight"], reversed: false },1115 );1116 // The spur zone marks the host window: rail centering keeps1117 // main-line marbles out of the branch mouth and branch riders on1118 // the board (proxies key the composite off the alt marker instead).1119 this.mods.push({ start, len: 1, kind: "spur" });1120 this.spurs.push({1121 host: start,1122 receiver: start + 5,1123 dir: w,1124 cap: rng.chance(0.5) ? "end-hole-rounded" : "end-hole-square",1125 });1126 this.hadSpur = true;1127 return true;1128 }11291130 /**1131 * BRAIDED PATH (M17, USER FEATURE): the line forks into TWO genuine1132 * lanes that wander apart and merge back L cells later — parts of the1133 * stream read as a winding tangle, and per-racer lane choice makes the1134 * pack visibly split and rejoin. V1 scope: flat (one y), void-only1135 * (this is a void clause), one braid per segment, MAIN lane straight,1136 * ALT lane wandering beside it.1137 *1138 * Layout: L = 8..14 straight main cells; cell 0 is the FORK host and1139 * cell L−1 the MERGE host, both pinned to 1-cell straight windows1140 * (world.ts mutates them straight → split, spur-style). The alt lane1141 * is a REAL OpenCell walk beside the main lane at lateral offset1142 * o ∈ [1, B] (B from the per-seed breadth knob): F cells while the1143 * offset holds, turn PAIRS to shift a lane (each offset change spends1144 * two walk cells — diagonal adjacency is impossible by construction).1145 * The walk enters from the fork's branch mouth (heading sideways,1146 * turning into travel) and leaves turning back INTO the merge's1147 * branch mouth aligned with the arriving lane. Alt cells claim their1148 * own channel bands; they are a SUB-skeleton on the Braid record, not1149 * part of plan.cells.1150 */1151 private tryBraid(rng: Rng): boolean {1152 // M23 braid networks: up to TWO braids per segment (an open fork is1153 // necessarily the last — it runs flush to the boundary).1154 if (this.braids.length >= 2 || this.openBraidOut) return false;1155 // Flat approach runway: a fork at the crest of a climb receives its1156 // marbles at near-zero speed and they park on the junction lip1157 // (grav-scan-21's alt rider stalled against the deck edge at the top1158 // of a 1.0 slant). The 2 cells behind the fork must be plain flat1159 // cells at the braid's level — reserved windows (slopes, set-pieces)1160 // and level changes veto the clause. Segment starts are safe by1161 // construction (the boundary margin is a flat 2-cell straight).1162 const at = this.cells.length;1163 for (let k = Math.max(0, at - 2); k < at; k++) {1164 if (this.cells[k].baseY !== this.pose.y) return false;1165 }1166 if (1167 this.reservations.some((r) => r.start < at && r.start + r.len > at - 2)1168 ) return false;1169 const L = rng.int(8, 15);1170 // Per-braid breadth from the per-seed bias: bias 1 → always lane 11171 // (a clean parallel siding); bias 3 → up to three lanes out.1172 const B = Math.min(3, 1 + Math.floor(this.braidBias * rng.float()));1173 const sides: ("L" | "R")[] = rng.chance(0.5) ? ["L", "R"] : ["R", "L"];1174 for (const side of sides) {1175 if (this.tryBraidSide(rng.child(`side/${side}`), side, L, B)) {1176 return true;1177 }1178 }1179 return false;1180 }11811182 private tryBraidSide(1183 rng: Rng,1184 side: "L" | "R",1185 L: number,1186 B: number,1187 ): boolean {1188 const d = this.pose.dir;1189 const y0 = this.pose.y;1190 const [dx, dz] = DIR_STEP[d];1191 const w = side === "L" ? turnLeft(d) : turnRight(d);1192 const [wx, wz] = DIR_STEP[w];1193 // OPEN FORK (V3, user directive): sometimes the fork does NOT1194 // resolve this segment — the braid runs flush to the boundary and1195 // the successor plans the merge (bounded by OPEN_BRAID_CAP). Only1196 // when the run to the boundary is a sane braid length; the drawn L1197 // is overridden by that distance.1198 const toBoundary = SEGMENT_TARGET - this.cells.length;1199 const open = toBoundary >= 6 && toBoundary <= 18 &&1200 rng.child("open").chance(0.3);1201 if (open) L = toBoundary;1202 const steps = this.straightSteps(L);1203 const probed = this.probe(steps);1204 if (!probed) return false;1205 const fork = probed.cells[0];12061207 // Wanderer character (M19b): drawn from its own child so non-braid1208 // streams never move. Breadth stays capped at B — the wanderer only1209 // LEANS the same profile range outward, so its claims are never1210 // wider than V1 could produce (no new twist-window veto pressure).1211 const wanderer = rng.child("character").chance(0.35);1212 // Offset profile per main column j = 1..L−2: starts and ends at 11213 // (the fork/merge branch mouths), wanders in [1, B], |Δ| ≤ 1, with1214 // the return-to-1 budget enforced ahead of time. pick() and1215 // weighted() both consume exactly ONE draw, so non-wanderer braids1216 // stay byte-identical — the wanderer bias is THE M19b reshuffle.1217 const pRng = rng.child("profile");1218 const o = new Array<number>(L).fill(1);1219 const wanderEnd = open ? L - 1 : L - 3;1220 for (let j = 2; j <= wanderEnd; j++) {1221 // An open braid never owes the return-to-1 budget this segment —1222 // the continuation walks it home.1223 const maxHere = open ? B : Math.min(B, 1 + (L - 2 - j));1224 const choices: number[] = [];1225 for (const step of [-1, 0, 1]) {1226 const v = o[j - 1] + step;1227 if (v >= 1 && v <= maxHere) choices.push(v);1228 }1229 o[j] = wanderer1230 ? pRng.weighted(choices.map((c) => [c, c * c] as const))1231 : pRng.pick(choices);1232 }12331234 // The alt walk (see the clause comment for the move law).1235 const cellAt = (j: number, off: number): readonly [number, number] => [1236 fork.x + dx * j + wx * off,1237 fork.z + dz * j + wz * off,1238 ];1239 const altCells: OpenCell[] = [];1240 const emit = (j: number, off: number, dirIn: Dir, dirOut: Dir): void => {1241 const [x, z] = cellAt(j, off);1242 altCells.push({1243 x,1244 z,1245 dirIn,1246 dirOut,1247 move: moveLabel(dirIn, dirOut),1248 baseY: y0,1249 });1250 };1251 // Branch cell beside the fork: enter sideways, turn into travel.1252 emit(0, 1, w, d);1253 const lastJ = open ? L - 1 : L - 2;1254 for (let j = 1; j <= lastJ; j++) {1255 const next = j === lastJ ? (open ? o[j] : 1) : o[j + 1];1256 if (next === o[j]) {1257 emit(j, o[j], d, d);1258 } else if (next > o[j]) {1259 emit(j, o[j], d, w);1260 emit(j, next, w, d);1261 } else {1262 emit(j, o[j], d, opposite(w));1263 emit(j, next, opposite(w), d);1264 }1265 }1266 // Branch cell beside the merge: turn back into the branch mouth.1267 // (An open braid has no merge this segment — its last alt cell1268 // exits FORWARD across the boundary.)1269 if (!open) emit(L - 1, 1, d, opposite(w));12701271 // Alt claims must be free (disjoint from the main lane by offset ≥ 11272 // construction), and the exit corridor must survive BOTH lanes.1273 for (const c of altCells) {1274 if (!this.occ.isFree(c.x, c.z, y0, y0 + CHANNEL_CLEARANCE)) {1275 return false;1276 }1277 }1278 const pending = new Set([1279 ...probed.cells.map((p) => key(p.x, p.z)),1280 ...altCells.map((c) => key(c.x, c.z)),1281 ]);1282 if (!hasFlatContinuation(this.occ, probed.exit, 3, pending)) return false;1283 if (open) {1284 // BOTH lanes cross the boundary: the alt lane's forward corridor1285 // must be plannable too (the successor probe then truly plans it).1286 // The corridor pose is the NEXT cell PAST the boundary — the1287 // hasFlatContinuation contract (its own first cell must be free).1288 const lastAlt = altCells[altCells.length - 1];1289 const altExit: HeadPose = {1290 x: lastAlt.x + dx,1291 z: lastAlt.z + dz,1292 y: y0,1293 dir: d,1294 };1295 if (!hasFlatContinuation(this.occ, altExit, 3, pending)) return false;1296 }12971298 const start = this.cells.length;1299 if (!this.commitSteps(steps)) return false;1300 for (const c of altCells) {1301 this.occ.claim(c.x, c.z, y0, y0 + CHANNEL_CLEARANCE, this.ref);1302 }1303 // Pin fork (+ merge when it resolves here) to plain 1-cell straights1304 // (their placements mutate straight → split in world.ts; the1305 // junction composites assume a bare straight window). The braid zone1306 // marks the junction hosts: rail centering keeps main-lane marbles1307 // out of the open branch mouths.1308 this.reservations.push(1309 { start, len: 1, pieceIds: ["straight"], reversed: false },1310 );1311 this.mods.push({ start, len: 1, kind: "braid" });1312 if (!open) {1313 this.reservations.push(1314 {1315 start: start + L - 1,1316 len: 1,1317 pieceIds: ["straight"],1318 reversed: false,1319 },1320 );1321 this.mods.push({ start: start + L - 1, len: 1, kind: "braid" });1322 }1323 // Lane mods (M19b): a surface CONTRAST so lane choice matters — an1324 // icy lane against a boost lane. Own child stream; tags only (the1325 // Braid.laneMods law). A wanderer alt always carries the boost1326 // compensation, whatever the contrast draw said.1327 const lRng = rng.child("lanes");1328 const laneRoll = lRng.float();1329 let laneMods: Braid["laneMods"] = laneRoll < 0.51330 ? undefined1331 : laneRoll < 0.751332 ? { alt: "icy", main: "boost" }1333 : { alt: "boost", main: "icy" };1334 if (wanderer) {1335 // Compensation trumps the contrast draw; a boost-vs-boost pair is1336 // no contrast at all, so the main tag drops in that case.1337 laneMods = {1338 alt: "boost",1339 ...(laneMods?.main && laneMods.main !== "boost"1340 ? { main: laneMods.main }1341 : {}),1342 };1343 }1344 this.braids.push({1345 start,1346 len: L,1347 side: w,1348 altCells,1349 ...(laneMods ? { laneMods } : {}),1350 ...(wanderer ? { wanderer: true } : {}),1351 ...(open ? { open: true } : {}),1352 });1353 if (open) {1354 this.openBraidOut = {1355 side: w,1356 offset: o[L - 1],1357 cells: L,1358 ...(laneMods ? { laneMods } : {}),1359 ...(wanderer ? { wanderer: true } : {}),1360 };1361 }1362 return true;1363 }13641365 /** BRAID CONTINUATION (V3): resolve the fork the previous segment left1366 * open. Runs FIRST — the alt lane enters at the boundary at the1367 * carried offset, wanders home, and merges at a pinned straight within1368 * the cap. No fork host this segment; the merge is the V1 merge. */1369 private tryBraidContinuation(rng: Rng): boolean {1370 const ob = this.openBraidIn!;1371 const d = this.pose.dir;1372 const y0 = this.pose.y;1373 const [dx, dz] = DIR_STEP[d];1374 const w = ob.side;1375 const [wx, wz] = DIR_STEP[w];1376 const remaining = OPEN_BRAID_CAP - ob.cells;1377 // M23: STAY OPEN — the alt lane rides this whole segment and the1378 // NEXT segment merges. Only when the cap affords the full middle1379 // segment plus a minimal merge leg downstream.1380 if (1381 remaining >= SEGMENT_TARGET + 6 &&1382 rng.child("stay-open").chance(STAY_OPEN_CHANCE) &&1383 this.planContinuation(1384 rng.child("open-fit"),1385 SEGMENT_TARGET,1386 d,1387 y0,1388 w,1389 [dx, dz],1390 [wx, wz],1391 ob,1392 true,1393 )1394 ) {1395 return true;1396 }1397 const minL = Math.max(6, ob.offset + 4);1398 const maxL = Math.max(minL, Math.min(13, remaining));1399 // Longest first — a failed probe steps the merge closer (bounded1400 // retry inside one attempt, deterministic).1401 const want = rng.int(minL, maxL + 1);1402 for (let L2 = want; L2 >= minL; L2--) {1403 if (this.planContinuation(rng.child(`fit/${L2}`), L2, d, y0, w, [1404 dx,1405 dz,1406 ], [wx, wz], ob)) {1407 return true;1408 }1409 }1410 return false;1411 }14121413 private planContinuation(1414 rng: Rng,1415 L2: number,1416 d: Dir,1417 y0: number,1418 w: Dir,1419 [dx, dz]: readonly [number, number],1420 [wx, wz]: readonly [number, number],1421 ob: OpenBraidState,1422 /** M23: leave the chain open at THIS boundary too (no merge). */1423 stayOpen = false,1424 ): boolean {1425 const steps = this.straightSteps(L2);1426 const probed = this.probe(steps);1427 if (!probed) return false;1428 const first = probed.cells[0];14291430 // Offset profile: o[0] is the carried boundary offset. A merging1431 // continuation wanders home under the return budget (o[L2−2] = 11432 // for the merge mouth); a stay-open one keeps wandering to the far1433 // boundary.1434 const pRng = rng.child("profile");1435 const o = new Array<number>(L2).fill(1);1436 o[0] = ob.offset;1437 const wanderEnd = stayOpen ? L2 - 1 : L2 - 3;1438 for (let j = 1; j <= wanderEnd; j++) {1439 const maxHere = stayOpen ? 3 : Math.min(3, 1 + (L2 - 2 - j));1440 const choices: number[] = [];1441 for (const step of [-1, 0, 1]) {1442 const v = o[j - 1] + step;1443 if (v >= 1 && v <= maxHere) choices.push(v);1444 }1445 if (choices.length === 0) return false;1446 o[j] = ob.wanderer1447 ? pRng.weighted(choices.map((c) => [c, c * c] as const))1448 : pRng.pick(choices);1449 }1450 if (!stayOpen) {1451 o[L2 - 2] = 1;1452 // |Δ| ≤ 1 must hold into the forced return cell too.1453 if (L2 >= 3 && Math.abs(o[L2 - 3] - 1) > 1) return false;1454 }14551456 const cellAt = (j: number, off: number): readonly [number, number] => [1457 first.x + dx * j + wx * off,1458 first.z + dz * j + wz * off,1459 ];1460 const altCells: OpenCell[] = [];1461 const emit = (j: number, off: number, dirIn: Dir, dirOut: Dir): void => {1462 const [x, z] = cellAt(j, off);1463 altCells.push({1464 x,1465 z,1466 dirIn,1467 dirOut,1468 move: moveLabel(dirIn, dirOut),1469 baseY: y0,1470 });1471 };1472 // The boundary cell continues FORWARD at the carried offset — no1473 // fork branch cell (the fork lives segments back).1474 const lastJ = stayOpen ? L2 - 1 : L2 - 2;1475 for (let j = 0; j <= lastJ; j++) {1476 const next = j === lastJ ? (stayOpen ? o[j] : 1) : o[j + 1];1477 if (next === o[j]) {1478 emit(j, o[j], d, d);1479 } else if (next > o[j]) {1480 emit(j, o[j], d, w);1481 emit(j, next, w, d);1482 } else {1483 emit(j, o[j], d, opposite(w));1484 emit(j, next, opposite(w), d);1485 }1486 }1487 // Branch cell beside the merge: turn back into the branch mouth.1488 if (!stayOpen) emit(L2 - 1, 1, d, opposite(w));14891490 for (const c of altCells) {1491 if (!this.occ.isFree(c.x, c.z, y0, y0 + CHANNEL_CLEARANCE)) {1492 return false;1493 }1494 }1495 const pending = new Set([1496 ...probed.cells.map((p) => key(p.x, p.z)),1497 ...altCells.map((c) => key(c.x, c.z)),1498 ]);1499 if (!hasFlatContinuation(this.occ, probed.exit, 3, pending)) return false;15001501 const start = this.cells.length;1502 if (!this.commitSteps(steps)) return false;1503 for (const c of altCells) {1504 this.occ.claim(c.x, c.z, y0, y0 + CHANNEL_CLEARANCE, this.ref);1505 }1506 if (!stayOpen) {1507 this.reservations.push(1508 {1509 start: start + L2 - 1,1510 len: 1,1511 pieceIds: ["straight"],1512 reversed: false,1513 },1514 );1515 this.mods.push({ start: start + L2 - 1, len: 1, kind: "braid" });1516 }1517 this.braids.push({1518 start,1519 len: L2,1520 side: w,1521 altCells,1522 cont: true,1523 ...(stayOpen ? { open: true } : {}),1524 ...(ob.laneMods ? { laneMods: ob.laneMods } : {}),1525 ...(ob.wanderer ? { wanderer: true } : {}),1526 });1527 if (stayOpen) {1528 this.openBraidOut = {1529 side: w,1530 offset: o[L2 - 1],1531 cells: ob.cells + L2,1532 ...(ob.laneMods ? { laneMods: ob.laneMods } : {}),1533 ...(ob.wanderer ? { wanderer: true } : {}),1534 };1535 }1536 return true;1537 }15381539 /**1540 * GRAVITY REORIENTATION RUN (M16 — the real gravity feature, USER1541 * HIGHLIGHT): a straight void run where the whole stream ROLLS about1542 * its travel axis by ±90° or 180° — every island, channel and marble1543 * downstream presents rotated in world space (wall/ceiling riding).1544 * Plan space never knows: generation, occupancy and the control laws1545 * stay +y-up; world.ts composes the presentation frame and the client1546 * conjugates marble state through it. Here we only walk the straight1547 * run with a FAT claim band (the rolling channel sweeps its walls1548 * around the centerline) and mark the window. The kit pieces over the1549 * run are dropped (world.ts) — the twist renders as the collision1550 * ribbon itself, corkscrewing between the two frames.1551 */1552 private hadReorient = false;1553 /** Committed reorient window (track units): entry-port pivot + axis +1554 * cell range. build() vetoes any OTHER geometry of this segment that1555 * projects axially into the open window — the presentation field is a1556 * pure axial blend (frame.ts twistParam), so anything else inside the1557 * slab would present mid-roll and tear a seam at the segment1558 * boundaries (grav-scan-13's post-twist spur hook did exactly that). */1559 private twistWindow:1560 | {1561 px: number;1562 pz: number;1563 dx: number;1564 dz: number;1565 start: number;1566 len: number;1567 }1568 | null = null;1569 private tryReorient(rng: Rng): boolean {1570 // One per segment (the presentation frame carries a single twist);1571 // hero spawn runway: never open the very first segment mid-roll.1572 if (this.hadReorient) return false;1573 if (this.index === 0 && this.cells.length < 6) return false;1574 const angle = rng.weighted(1575 [1576 [Math.PI / 2, 2],1577 [-Math.PI / 2, 2],1578 [Math.PI, 1],1579 ] as const,1580 );1581 // Roll rate stays gentle: 90° over 8 cells, 180° over 12.1582 const len = Math.abs(angle) > Math.PI / 2 + 1e-6 ? 12 : 8;1583 const steps: Step[] = Array.from({ length: len }, () => ({1584 move: "F" as const,1585 // The rotating cross-section sweeps wall tips (±0.46 track) above1586 // AND below the floor line — claim the swept drum, not just the1587 // channel band.1588 band: [-0.75, CHANNEL_CLEARANCE] as const,1589 }));1590 const probed = this.probe(steps);1591 if (!probed) return false;1592 // FLANK COLUMNS: the roll's swept drum reaches laterally past the1593 // cell (wall tips ~±0.72 track mid-roll), and the frame law's roll1594 // blend owns the run's corridor (frame.ts TWIST_LAT_MAX) — claim1595 // both flanks of every run cell so no other line rides beside the1596 // roll. A later clause doubling back through a flank projected1597 // axially INTO the twist window and its channel presented mid-roll,1598 // tearing a world seam a marble fell through (grav-scan-13).1599 const flanks: [number, number][] = [];1600 for (const p of probed.cells) {1601 for (const side of [turnLeft(this.pose.dir), turnRight(this.pose.dir)]) {1602 const [sx, sz] = DIR_STEP[side];1603 const fx = p.x + sx;1604 const fz = p.z + sz;1605 if (!this.occ.isFree(fx, fz, p.y - 0.75, p.y + CHANNEL_CLEARANCE)) {1606 return false;1607 }1608 flanks.push([fx, fz]);1609 }1610 }1611 const pendingBlock = new Set([1612 ...probed.cells.map((p) => key(p.x, p.z)),1613 ...flanks.map(([fx, fz]) => key(fx, fz)),1614 ]);1615 if (!hasFlatContinuation(this.occ, probed.exit, 3, pendingBlock)) {1616 return false;1617 }1618 const dir = this.pose.dir;1619 const [ddx, ddz] = DIR_STEP[dir];1620 const start = this.cells.length;1621 if (!this.commitSteps(steps)) return false;1622 const y0 = probed.cells[0].y;1623 for (const [fx, fz] of flanks) {1624 this.occ.claim(fx, fz, y0 - 0.75, y0 + CHANNEL_CLEARANCE, this.ref);1625 }1626 this.twistWindow = {1627 px: probed.cells[0].x - ddx / 2,1628 pz: probed.cells[0].z - ddz / 2,1629 dx: ddx,1630 dz: ddz,1631 start,1632 len,1633 };1634 this.mods.push({ start, len, kind: "reorient", angle });1635 // Pin every run cell to a bare straight: the twist ribbon assumes1636 // the centerline — no wave/arch/funnel may realize under the roll.1637 for (let j = 0; j < len; j++) {1638 this.reservations.push({1639 start: start + j,1640 len: 1,1641 pieceIds: ["straight"],1642 reversed: false,1643 });1644 }1645 this.hadReorient = true;1646 return true;1647 }16481649 /** Last resort when boxed in: steep 1.5 chute straight down and out. */1650 private tryEscape(rng: Rng): boolean {1651 for (const delta of ["1.5", "1", "0.5"]) {1652 for (const option of DESCENT_VOCAB[delta]) {1653 if (this.commitSlope(option, false)) return true;1654 }1655 }1656 // Try turning first, then dive.1657 for (const m of rng.chance(0.5) ? ["L", "R"] : ["R", "L"]) {1658 if (1659 this.commitSteps([1660 { move: m as "L" | "R", band: [0, CHANNEL_CLEARANCE] },1661 ])1662 ) {1663 return true;1664 }1665 }1666 // Inch forward along whatever corridor the guard last promised.1667 return this.commitSteps(this.straightSteps(1));1668 }16691670 // -------------------------------------------------------------------------1671 // Island anchor clause1672 // -------------------------------------------------------------------------16731674 private tryIsland(rng: Rng): boolean {1675 const runLen = rng.int(5, 11);1676 const margin = runLen >= 8 ? rng.int(2, 4) : rng.int(1, 3);16771678 // RAISED VIADUCT crossing (USER: pillars vary the island↔track1679 // connection): the deck sits 0.5/1.0 BELOW the track and kit pier1680 // stacks carry the channel across (assemble's structural rule —1681 // abutments + piers, standing on the deck). A viaduct island is its1682 // own flavor: no slope terraces, no diamond/wide/ledge furniture —1683 // the stilts are the feature.1684 const raised = rng.child("raised").chance(0.3)1685 ? (rng.child("raised/delta").chance(0.5) ? 0.5 : 1)1686 : 0;16871688 // The run itself: mostly straight, an occasional bend so the mass1689 // isn't always a bar. First/last cells stay straight (clean shoreline1690 // ports). Mass needs at least one block below every run cell.1691 //1692 // ISLAND VERTICALITY (M12): longer runs may embed ONE slope window1693 // (0.5 or 1.0, climb or descent, from the shared vocab — terrace1694 // step ≤ 0.5/cell by construction) with ≥2 flat cells on each side.1695 // The mass terraces under the profile: every run cell is anchored at1696 // ITS height, and the envelope follows (nearest-run-cell tops).1697 interface RunSlope {1698 at: number;1699 option: SlopeOption;1700 climb: boolean;1701 }1702 let slope: RunSlope | null = null;1703 if (raised === 0 && runLen >= 7 && rng.child("slope").chance(0.55)) {1704 const sRng = rng.child("slope/pick");1705 const climb = sRng.chance(0.45);1706 const delta = runLen >= 8 && sRng.chance(0.4) ? "1" : "0.5";1707 const vocab = climb ? CLIMB_VOCAB : DESCENT_VOCAB;1708 const options = vocab[delta].filter((o) =>1709 o.totalLen <= runLen - 4 &&1710 // Islands terrace at ≤0.5 per cell — no chute caps, no1711 // drop-stacks (asIs single-cell falls) inside a shoreline run.1712 o.parts.every((p) => p.delta / p.len <= 0.5 + 1e-9 && !p.asIs)1713 );1714 if (options.length > 0) {1715 const option = sRng.pick(options);1716 // The cubic-spillover cell (one past a climb / one before a1717 // descent lip) shares the part low base, so the slope must sit1718 // one cell further from the shoreline on that side to keep the1719 // ≥2 flat shoreline cells contract.1720 const lo = climb ? 2 : 3;1721 const hi = runLen - option.totalLen - (climb ? 3 : 1);1722 if (hi >= lo) {1723 slope = { at: sRng.int(lo, hi), option, climb };1724 }1725 }1726 }1727 const slopeCells = new Map<number, Step>();1728 if (slope) {1729 const parts = slope.climb1730 ? slope.option.parts1731 : slope.option.parts.slice().reverse();1732 let j = slope.at;1733 for (const part of parts) {1734 const signed = slope.climb ? part.delta : -part.delta;1735 for (let k = 0; k < part.len; k++) {1736 // Full part envelope per cell (same fix as commitSlope): cells1737 // after the stepAfter sit at the part's exit y, so their band1738 // reaches BACK across the delta. Mass headroom −1 preserved.1739 const lo = (k === 0 ? Math.min(0, signed) : Math.min(0, -signed)) -1740 1;1741 const hi = (k === 0 ? Math.max(0, signed) : Math.max(0, -signed)) +1742 CHANNEL_CLEARANCE;1743 slopeCells.set(j++, {1744 move: "F",1745 band: [lo, hi],1746 ...(k === 0 ? { stepAfter: signed } : {}),1747 });1748 }1749 }1750 }1751 const steps: Step[] = [];1752 for (let j = 0; j < runLen; j++) {1753 const fromSlope = slopeCells.get(j);1754 if (fromSlope) {1755 steps.push(fromSlope);1756 continue;1757 }1758 const edge = j === 0 || j === runLen - 1;1759 const move = !edge && rng.chance(0.28)1760 ? (rng.chance(0.5) ? "L" : "R")1761 : "F";1762 steps.push({ move, band: [-1, CHANNEL_CLEARANCE] });1763 }1764 const probed = this.probe(steps);1765 if (!probed) return false;17661767 const id = this.islandCount;1768 const topY = this.pose.y;1769 const start = this.cells.length;1770 if (!this.commitSteps(steps)) return false;1771 const runCells = probed.cells.map((p) => [p.x, p.z] as const);17721773 // Slope reservations mirror commitSlope (parts in traversal order);1774 // big island descents get their boost zone like any chute.1775 if (slope) {1776 const parts = slope.climb1777 ? slope.option.parts1778 : slope.option.parts.slice().reverse();1779 let at = start + slope.at;1780 for (const part of parts) {1781 this.reservations.push({1782 start: at,1783 len: part.len,1784 pieceIds: part.ids,1785 reversed: !slope.climb && !part.asIs,1786 });1787 at += part.len;1788 }1789 const totalDrop = slope.option.parts.reduce((a, p) => a + p.delta, 0);1790 if (!slope.climb && totalDrop >= 1.0) {1791 const bStart = Math.max(start, start + slope.at - 2);1792 this.mods.push({1793 start: bStart,1794 len: start + slope.at + slope.option.totalLen - bStart,1795 kind: "boost",1796 });1797 }1798 }17991800 // Terrace profile: flat cells anchor at their OWN base; each slope1801 // PART's cells share that part's low base (the ramp sits on the low1802 // terrace and bridges up — exactly how finite-mode ramps ground).1803 const baseYs = runCells.map((_, j) => this.cells[start + j].baseY);1804 const partLow = new Map<number, number>();1805 if (slope) {1806 const parts = slope.climb1807 ? slope.option.parts1808 : slope.option.parts.slice().reverse();1809 let at = slope.at;1810 let lastLow = 0;1811 for (const part of parts) {1812 const entryBase = baseYs[at];1813 const low = slope.climb ? entryBase : entryBase - part.delta;1814 for (let k = 0; k < part.len; k++) partLow.set(at + k, low);1815 lastLow = low;1816 at += part.len;1817 }1818 // Cubic spillover (apfuzz-58 wedge): the realized climb/chute1819 // centerline is a smooth cubic that is still below the upper1820 // terrace in the first cell PAST a climb (and already dipping in1821 // the cell BEFORE a descent's lip). Those cells share the adjacent1822 // part's low base too, or the mass face pokes up through the1823 // still-sloping channel floor and walls the run.1824 if (slope.climb) {1825 if (at < runLen && !partLow.has(at)) partLow.set(at, lastLow);1826 } else if (slope.at > 0 && !partLow.has(slope.at - 1)) {1827 partLow.set(slope.at - 1, partLow.get(slope.at)!);1828 }1829 }1830 // Raised viaduct: the whole surface (run + envelope terraces) sits1831 // `raised` below the walk — the run cells' [-1, clearance] bands1832 // already cover the pillar gap.1833 const profile = baseYs.map((y, j) => (partLow.get(j) ?? y) - raised);18341835 // Split–merge diamond on the island run (M11 branch respawns): find1836 // a straight stretch of ≥5 cells, check + claim both flank columns at1837 // deck level, and reserve the composite diamond window. The branch1838 // columns become part of the island's track patch (full mass beneath,1839 // no caps, no decor; canopy skips them because their band is claimed1840 // before the envelope loop below runs).1841 // Maximal flat straight stretches of the run (local index, length) —1842 // shared by the diamond, wide-run, and furniture pickers below.1843 const stretches: [number, number][] = [];1844 {1845 let j = 0;1846 while (j < runLen) {1847 if (steps[j].move !== "F") {1848 j++;1849 continue;1850 }1851 let l = 0;1852 while (j + l < runLen && steps[j + l].move === "F") l++;1853 if (l >= 5) stretches.push([j, l]);1854 j += l;1855 }1856 }1857 /** True when [start, start+len) (absolute cell indices) overlaps any1858 * committed reservation. */1859 const overlapsReservation = (s0: number, len: number): boolean =>1860 this.reservations.some((r) => r.start < s0 + len && s0 < r.start + r.len);18611862 const branchCells: (readonly [number, number])[] = [];1863 if (raised === 0 && rng.child("diamond").chance(0.5)) {1864 const dRng = rng.child("diamond/pick");1865 placing:1866 for (const [s0, l] of dRng.shuffle(stretches)) {1867 for (let N = Math.min(3, l - 4); N >= 1; N--) {1868 const total = 4 + N;1869 const offsets = dRng.shuffle(1870 Array.from({ length: l - total + 1 }, (_, o) => o),1871 );1872 for (const o of offsets) {1873 const j0 = s0 + o;1874 // Flat + unreserved only: a diamond can't straddle a slope1875 // window (its deck is one level by construction).1876 if (1877 this.reservations.some((r) =>1878 r.start < start + j0 + total && start + j0 < r.start + r.len1879 )1880 ) continue;1881 const flatY = this.cells[start + j0].baseY;1882 // Neighbors must share the level too, or the window's edge1883 // terrace (min-of-neighbors) drops below the deck and the1884 // split-double corner floats.1885 if (1886 (this.cells[start + j0 - 1]?.baseY ?? flatY) !== flatY ||1887 (this.cells[start + j0 + total]?.baseY ?? flatY) !== flatY1888 ) continue;1889 const flanks: (readonly [number, number])[] = [];1890 let ok = true;1891 for (let k = 0; k < total && ok; k++) {1892 const cell = this.cells[start + j0 + k];1893 if (cell.baseY !== flatY) {1894 ok = false;1895 break;1896 }1897 for (1898 const side of [turnLeft(cell.dirIn), turnRight(cell.dirIn)]1899 ) {1900 const [dx, dz] = DIR_STEP[side];1901 const fx = cell.x + dx;1902 const fz = cell.z + dz;1903 if (1904 runCells.some(([rx, rz]) => rx === fx && rz === fz) ||1905 !this.occ.isFree(fx, fz, flatY, flatY + CHANNEL_CLEARANCE)1906 ) {1907 ok = false;1908 break;1909 }1910 flanks.push([fx, fz]);1911 }1912 }1913 if (!ok) continue;1914 for (const [fx, fz] of flanks) {1915 this.occ.claim(1916 fx,1917 fz,1918 flatY,1919 flatY + CHANNEL_CLEARANCE,1920 this.ref,1921 );1922 }1923 branchCells.push(...flanks);1924 this.reservations.push({1925 start: start + j0,1926 len: total,1927 pieceIds: [`diamond-${N}`],1928 reversed: false,1929 });1930 break placing;1931 }1932 }1933 }1934 }19351936 // WIDE RUN (ground vocabulary — an island top IS local ground): a1937 // funnel-bracketed wide channel on a flat straight stretch, exactly1938 // the loop-mode feature shape (funnel → wide×1..3 → funnel reversed).1939 // Both flank columns of every window cell must be free (the wide1940 // profile bulges 0.35 into them); they're claimed as channel band and1941 // become track patch (full mass, no caps/decor) like branch columns.1942 const wideFlanks: (readonly [number, number])[] = [];1943 if (raised === 0 && rng.child("wide").chance(0.4)) {1944 const wRng = rng.child("wide/pick");1945 placingWide:1946 for (const [s0, l] of wRng.shuffle(stretches)) {1947 for (let mid = Math.min(3, l - 2); mid >= 1; mid--) {1948 const total = mid + 2;1949 const offsets = wRng.shuffle(1950 Array.from({ length: l - total + 1 }, (_, o) => o),1951 );1952 for (const o of offsets) {1953 const j0 = s0 + o;1954 if (overlapsReservation(start + j0, total)) continue;1955 const flatY = this.cells[start + j0].baseY;1956 let ok = true;1957 const flanks: (readonly [number, number])[] = [];1958 for (let k = 0; k < total && ok; k++) {1959 const cell = this.cells[start + j0 + k];1960 if (cell.baseY !== flatY || cell.baseY !== profile[j0 + k]) {1961 ok = false;1962 break;1963 }1964 for (1965 const side of [turnLeft(cell.dirIn), turnRight(cell.dirIn)]1966 ) {1967 const [dx, dz] = DIR_STEP[side];1968 const fx = cell.x + dx;1969 const fz = cell.z + dz;1970 if (1971 runCells.some(([rx, rz]) => rx === fx && rz === fz) ||1972 !this.occ.isFree(fx, fz, flatY, flatY + CHANNEL_CLEARANCE)1973 ) {1974 ok = false;1975 break;1976 }1977 flanks.push([fx, fz]);1978 }1979 }1980 if (!ok) continue;1981 for (const [fx, fz] of flanks) {1982 this.occ.claim(1983 fx,1984 fz,1985 flatY,1986 flatY + CHANNEL_CLEARANCE,1987 this.ref,1988 );1989 }1990 wideFlanks.push(...flanks);1991 this.reservations.push({1992 start: start + j0,1993 len: 1,1994 pieceIds: ["funnel"],1995 reversed: false,1996 });1997 for (let k = 0; k < mid; k++) {1998 this.reservations.push({1999 start: start + j0 + 1 + k,2000 len: 1,2001 pieceIds: ["straight-wide"],2002 reversed: false,2003 });2004 }2005 this.reservations.push({2006 start: start + j0 + 1 + mid,2007 len: 1,2008 pieceIds: ["funnel"],2009 reversed: true,2010 });2011 break placingWide;2012 }2013 }2014 }2015 }20162017 // FURNITURE LEDGES: 1–3 single cells flanking flat straight run cells2018 // — claimed now (mass beneath, no canopy), furnished after realize2019 // (stub siding or fan) if their host realizes as a plain straight.2020 const sideCells: {2021 cell: readonly [number, number];2022 host: number;2023 side: "L" | "R";2024 }[] = [];2025 if (raised === 0 && rng.child("furniture").chance(0.55)) {2026 const fRng = rng.child("furniture/pick");2027 const candidates: typeof sideCells = [];2028 for (let j = 1; j < runLen - 1; j++) {2029 const cell = this.cells[start + j];2030 if (2031 cell.move !== "F" || cell.baseY !== profile[j] ||2032 overlapsReservation(start + j, 1)2033 ) continue;2034 for (2035 const [side, dir] of [2036 ["L", turnLeft(cell.dirIn)],2037 ["R", turnRight(cell.dirIn)],2038 ] as const2039 ) {2040 const [dx, dz] = DIR_STEP[dir];2041 const fx = cell.x + dx;2042 const fz = cell.z + dz;2043 if (2044 runCells.some(([rx, rz]) => rx === fx && rz === fz) ||2045 !this.occ.isFree(2046 fx,2047 fz,2048 cell.baseY,2049 cell.baseY + CHANNEL_CLEARANCE,2050 )2051 ) continue;2052 candidates.push({ cell: [fx, fz], host: start + j, side });2053 }2054 }2055 const want = fRng.int(1, 4);2056 const usedHosts = new Set<number>();2057 const usedCells = new Set<string>();2058 for (const cand of fRng.shuffle(candidates)) {2059 if (sideCells.length >= want) break;2060 if (usedHosts.has(cand.host)) continue;2061 if (usedCells.has(key(cand.cell[0], cand.cell[1]))) continue;2062 this.occ.claim(2063 cand.cell[0],2064 cand.cell[1],2065 this.cells[cand.host].baseY,2066 this.cells[cand.host].baseY + CHANNEL_CLEARANCE,2067 this.ref,2068 );2069 usedHosts.add(cand.host);2070 usedCells.add(key(cand.cell[0], cand.cell[1]));2071 sideCells.push(cand);2072 }2073 }20742075 // Claim the mass envelope now — blob rings around the run, each cell2076 // reserving as much depth below topY as older claims allow. The island2077 // builder works strictly inside `isle/${id}` claims.2078 const isleRef = `isle/${id}`;2079 const envelope = new Map<string, [number, number]>();2080 for (const [cx, cz] of runCells) {2081 for (let dx = -margin; dx <= margin; dx++) {2082 for (let dz = -margin; dz <= margin; dz++) {2083 envelope.set(key(cx + dx, cz + dz), [cx + dx, cz + dz]);2084 }2085 }2086 }2087 // Approach/departure corridors stay canopy-free: the line must be able2088 // to arrive and leave at surface height, and the promise the run's2089 // pocket guard made about the exit must survive the envelope claims.2090 // (Bonus: a natural clearing where the track crosses the shoreline.)2091 const corridor = new Set<string>();2092 {2093 const exit = this.pose; // commitSteps already advanced past the run2094 const entry = this.cells[start];2095 for (let k = 0; k <= margin + 2; k++) {2096 corridor.add(2097 key(2098 exit.x + DIR_STEP[exit.dir][0] * k,2099 exit.z + DIR_STEP[exit.dir][1] * k,2100 ),2101 );2102 corridor.add(2103 key(2104 entry.x - DIR_STEP[entry.dirIn][0] * k,2105 entry.z - DIR_STEP[entry.dirIn][1] * k,2106 ),2107 );2108 }2109 }2110 // Terrace tops for the envelope: each cell takes the floor base of2111 // its NEAREST run cell (Chebyshev; ties pick the lower top — a low2112 // terrace beside the channel can never intrude into it). Flat runs2113 // reduce to the old constant-top behavior exactly.2114 const terraceTop = (cx: number, cz: number): number => {2115 let best = Infinity;2116 let top = topY;2117 for (let j = 0; j < runCells.length; j++) {2118 const [rx, rz] = runCells[j];2119 const d = Math.max(Math.abs(cx - rx), Math.abs(cz - rz));2120 if (d < best || (d === best && profile[j] < top)) {2121 best = d;2122 top = profile[j];2123 }2124 }2125 return top;2126 };2127 // Own-segment bands are transparent ONLY on the run's own cells (and2128 // a diamond's branch columns) — their channel claims carry the −12129 // mass-headroom band the envelope must fill. Everywhere else a2130 // same-segment channel (the island's own approach corridor!) is as2131 // solid as any older line: skipping it let terraced mass plow2132 // through the corridor (det-seed jammed a marble in it).2133 const ownCells = new Set([2134 ...runCells.map(([x, z]) => key(x, z)),2135 ...branchCells.map(([x, z]) => key(x, z)),2136 ]);2137 const deckCells: (readonly [number, number, number])[] = [];2138 for (const [cx, cz] of envelope.values()) {2139 const cellTop = terraceTop(cx, cz);2140 // Deepest free band under the top: stop at the first older claim.2141 let floor = cellTop - ISLAND_MAX_DEPTH;2142 for (const b of this.occ.at(cx, cz)) {2143 if (b.ref === isleRef) continue;2144 if (b.ref === this.ref && ownCells.has(key(cx, cz))) continue;2145 if (b.y1 <= cellTop + 1e-9 && b.y1 > floor) floor = b.y1;2146 // A band overlapping [floor, cellTop) from above (old track2147 // between floor and top) — mass can't reach the top here; skip.2148 if (b.y0 < cellTop - 1e-9 && b.y1 > cellTop - 1e-9) {2149 floor = cellTop;2150 }2151 }2152 if (cellTop - floor < 0.999) continue; // no room for even one block2153 this.occ.claim(cx, cz, floor, cellTop, isleRef);2154 deckCells.push([cx, cz, cellTop]);2155 // Canopy airspace: non-run surface cells may grow tall decor (trees,2156 // banners); claim the air above them so no future line threads2157 // through a crown. Cells whose airspace is already spoken for simply2158 // stay vegetation-free (the builder checks for this claim).2159 if (2160 !runCells.some(([rx, rz]) => rx === cx && rz === cz) &&2161 !corridor.has(key(cx, cz)) &&2162 this.occ.isFree(cx, cz, cellTop, cellTop + CANOPY_HEIGHT)2163 ) {2164 this.occ.claim(cx, cz, cellTop, cellTop + CANOPY_HEIGHT, isleRef);2165 }2166 }21672168 this.crossings.push({2169 id,2170 start,2171 len: runLen,2172 topY,2173 profile,2174 margin,2175 ...(raised > 0 ? { raised } : {}),2176 ...(branchCells.length > 0 ? { branchCells } : {}),2177 ...(wideFlanks.length > 0 ? { wideFlanks } : {}),2178 ...(sideCells.length > 0 ? { sideCells } : {}),2179 ...(deckCells.length > 0 ? { deckCells } : {}),2180 });2181 this.islandCount++;2182 this.sinceAnchor = 0;2183 this.anchorEvery = rng.int(ANCHOR_EVERY_MIN, ANCHOR_EVERY_MAX);2184 return true;2185 }2186}