1/**2 * Topology system: generates a simple closed rectilinear cycle on the grid.3 * Loop closure and self-avoidance are invariants of the construction —4 * realization can never break them.5 *6 * Algorithm: seeded rectangle perimeter, then K mutations drawn from three7 * families, each preserving simplicity:8 * - bump: shift a straight run's interior sideways by one cell (1-cell9 * clearance margin around the detour, checked against the whole path).10 * - U-extrude: grow a peninsula off a straight segment — out k, across,11 * back k. The two columns may be mutually adjacent (m=2 hairpins host the12 * `bend` U-turn pieces; wider ones host bend-medium/large windows).13 * - staircase: shift a run's interior two cells sideways via diagonal14 * zig-zag transitions, whose LRLR signatures host the s-curve pieces.15 */16import type { Dir } from "../shared/types.ts";17import type { Rng } from "./rng.ts";18import {19 applyXform,20 DIR_STEP,21 moveLabel,22 opposite,23 turnLeft,24 turnRight,25 windowXform,26} from "./geom.ts";2728export interface SkeletonCell {29 x: number;30 z: number;31 dirIn: Dir;32 dirOut: Dir;33 move: "F" | "L" | "R";34}3536export interface Skeleton {37 cells: SkeletonCell[];38 /**39 * Ring-index pairs that share one grid cell — designated grade crossings40 * (the pierce mutation). Both visits travel straight and perpendicular to41 * each other at the same base height; realization puts the 4-way `cross`42 * piece there. Every other cell is visited exactly once.43 */44 crossings: { a: number; b: number }[];45 /**46 * Full-helix ensembles (the helix-loop mutation, at most one): the window47 * [start, start+len) traces the spiral block's perimeter and re-visits its48 * first cells as the ground-level underpass. Elevation MUST reserve the49 * named def here (drop 2.0) plus a 2.0 climb elsewhere — the duplicated50 * cells only compose as that one piece.51 */52 helixLoops: { start: number; len: number; id: string }[];53 /**54 * Pierce layout, when the ring was pierced (its splice lands at index 0:55 * anchor turn, column A cells at 1..k, column B at k+1..2k, the far56 * anchor at 2k+1; crossings' upper/column visits are indices d and57 * 2k+1−d). `bridge` = the hairpin passes OVER the crossed run at +1.058 * (elevation reserves the climb/descent on the columns) instead of a59 * flat grade crossing; upper deck cells never take support pillars (the60 * lower channel owns the ground).61 */62 pierce?: { d: number; k: number; bridge: boolean };63}6465type P = readonly [number, number];66const key = (x: number, z: number) => `${x},${z}`;6768function dirBetween(a: P, b: P): Dir {69 const dx = b[0] - a[0];70 const dz = b[1] - a[1];71 if (dx === 1 && dz === 0) return 0;72 if (dx === 0 && dz === 1) return 1;73 if (dx === -1 && dz === 0) return 2;74 if (dx === 0 && dz === -1) return 3;75 throw new Error(`cells not adjacent: ${a} → ${b}`);76}7778export function generateSkeleton(rng: Rng): Skeleton {79 // Seed rectangle perimeter, clockwise from origin.80 const w = rng.int(5, 10);81 const h = rng.int(4, 8);82 const ring: P[] = [];83 for (let x = 0; x < w - 1; x++) ring.push([x, 0]);84 for (let z = 0; z < h - 1; z++) ring.push([w - 1, z]);85 for (let x = w - 1; x > 0; x--) ring.push([x, h - 1]);86 for (let z = h - 1; z > 0; z--) ring.push([0, z]);87 // Half the tracks run the other way around. Without this every loop is88 // turn-left-dominant and the left-handed helix ensembles (which need89 // right-turn anchors with open ground) essentially never fit.90 if (rng.chance(0.5)) ring.reverse();9192 const cells = new Set(ring.map(([x, z]) => key(x, z)));9394 const mutations = rng.int(4, 16);95 let applied = 0;96 const mutRng = rng.child("mutations");97 for (98 let attempt = 0;99 attempt < mutations * 10 && applied < mutations;100 attempt++101 ) {102 const kind = mutRng.weighted(103 [["bump", 6], ["u-extrude", 2.5], ["staircase", 1.5]] as const,104 );105 const ok = kind === "bump"106 ? tryBump(ring, cells, mutRng)107 : kind === "u-extrude"108 ? tryUExtrude(ring, cells, mutRng)109 : tryStaircase(ring, cells, mutRng);110 if (ok) applied++;111 }112113 // Duplicate-cell mutations run LAST (and mutually exclusive): the other114 // mutations' clearance logic assumes cell keys are unique on the path115 // (their `removed` bookkeeping would mis-handle a twice-visited cell).116 const pierceRng = rng.child("pierce");117 let pierce: { d: number; k: number; bridge: boolean } | null = null;118 if (pierceRng.chance(0.55)) {119 for (let attempt = 0; attempt < 120 && !pierce; attempt++) {120 pierce = tryPierce(ring, cells, pierceRng);121 }122 }123 const loopRng = rng.child("helix-loop");124 let loop: { len: number; id: string } | null = null;125 if (!pierce && loopRng.chance(0.45)) {126 loop = tryHelixLoop(ring, cells, loopRng);127 }128129 // Assemble cells with in/out dirs.130 const n = ring.length;131 const out: SkeletonCell[] = ring.map((c, i) => {132 const prev = ring[(i - 1 + n) % n];133 const next = ring[(i + 1) % n];134 const dirIn = dirBetween(prev, c);135 const dirOut = dirBetween(c, next);136 return { x: c[0], z: c[1], dirIn, dirOut, move: moveLabel(dirIn, dirOut) };137 });138139 // Normalize: rotate so index 0 is a turn (straight runs never wrap the140 // seam, which keeps run-finding in the elevation system simple). A helix141 // loop's splice puts its window at ring index 0, and its first cell is a142 // turn, so normalization is the identity there and the declared start143 // stays 0.144 const firstTurn = out.findIndex((c) => c.move !== "F");145 const norm = out.slice(firstTurn).concat(out.slice(0, firstTurn));146147 // Crossings = cells appearing twice (pierce). A helix loop's duplicates148 // live inside its own declared window and are NOT crossings.149 const seen = new Map<string, number>();150 const crossings: { a: number; b: number }[] = [];151 const loopLen = loop?.len ?? 0;152 norm.forEach((c, i) => {153 if (i < loopLen) return;154 const k = key(c.x, c.z);155 const first = seen.get(k);156 if (first !== undefined) crossings.push({ a: first, b: i });157 else seen.set(k, i);158 });159 return {160 cells: norm,161 crossings,162 helixLoops: loop ? [{ start: 0, len: loop.len, id: loop.id }] : [],163 ...(pierce ? { pierce } : {}),164 };165}166167/**168 * Try one bump mutation: pick a straight run s..e (inclusive, same travel169 * dir), shift its interior one cell to a side. Path p→s→…→e→q becomes170 * p→s→(s+n)→…→(e+n)→e→q. Succeeds only if the detour cells are free and171 * keep 1-cell clearance from the rest of the path.172 */173function tryBump(ring: P[], cells: Set<string>, rng: Rng): boolean {174 const n = ring.length;175 const i = rng.int(0, n);176 const runLen = rng.int(2, 5); // cells s..e inclusive177 // Collect run: consecutive cells all stepping the same dir.178 const idx = (j: number) => (i + j + n) % n;179 const dir = dirBetween(ring[idx(0)], ring[idx(1)]);180 for (let j = 1; j < runLen; j++) {181 if (dirBetween(ring[idx(j - 1)], ring[idx(j)]) !== dir) return false;182 }183 // p and q must continue the path (any dir), just fetch them.184 const s = ring[idx(0)];185 const e = ring[idx(runLen - 1)];186 const p = ring[idx(-1)];187 const q = ring[idx(runLen)];188189 const normal = rng.chance(0.5) ? turnLeft(dir) : turnRight(dir);190 const [nx, nz] = DIR_STEP[normal];191192 const detour: P[] = [];193 for (let j = 0; j < runLen; j++) {194 const c = ring[idx(j)];195 detour.push([c[0] + nx, c[1] + nz]);196 }197198 // Interior cells (strictly between s and e) leave the path.199 const removed = new Set<string>();200 for (let j = 1; j < runLen - 1; j++) {201 removed.add(key(ring[idx(j)][0], ring[idx(j)][1]));202 }203204 // Allowed path-neighbors of detour cells: run endpoints, their path205 // neighbors (diagonal contact at the new corners), and the detour itself.206 const allowed = new Set([207 key(s[0], s[1]),208 key(e[0], e[1]),209 key(p[0], p[1]),210 key(q[0], q[1]),211 ...detour.map(([x, z]) => key(x, z)),212 ]);213214 for (const [x, z] of detour) {215 if (cells.has(key(x, z)) && !removed.has(key(x, z))) return false;216 for (let dx = -1; dx <= 1; dx++) {217 for (let dz = -1; dz <= 1; dz++) {218 if (dx === 0 && dz === 0) continue;219 const k = key(x + dx, z + dz);220 if (cells.has(k) && !removed.has(k) && !allowed.has(k)) return false;221 }222 }223 }224225 // Apply: splice the ring. `rest` holds q..p inclusive (n − runLen cells);226 // the interior cells between s and e drop out of the path.227 const rest: P[] = [];228 for (let j = runLen; j < n; j++) rest.push(ring[idx(j)]);229 const newRing = [s, ...detour, e, ...rest];230 if (newRing.length !== n + 2) return false; // sanity231232 ring.length = 0;233 ring.push(...newRing);234 cells.clear();235 for (const [x, z] of ring) cells.add(key(x, z));236 return true;237}238239/**240 * Validity core shared by the splice mutations: every `fresh` cell must be241 * off-path, and its 8-neighborhood must contain no path cell outside242 * `allowed` (cells in `removed` are leaving the path and don't count).243 */244function replaceOk(245 cells: Set<string>,246 fresh: readonly P[],247 removed: ReadonlySet<string>,248 allowed: ReadonlySet<string>,249): boolean {250 for (const [x, z] of fresh) {251 if (cells.has(key(x, z)) && !removed.has(key(x, z))) return false;252 for (let dx = -1; dx <= 1; dx++) {253 for (let dz = -1; dz <= 1; dz++) {254 if (dx === 0 && dz === 0) continue;255 const k = key(x + dx, z + dz);256 if (cells.has(k) && !removed.has(k) && !allowed.has(k)) return false;257 }258 }259 }260 return true;261}262263/** Replace ring cells idx(i)..idx(i+m−1) with `sub` (same endpoints). */264function applySplice(265 ring: P[],266 cells: Set<string>,267 i: number,268 m: number,269 sub: readonly P[],270) {271 const n = ring.length;272 const idx = (j: number) => (i + j + n) % n;273 const rest: P[] = [];274 for (let j = m; j < n; j++) rest.push(ring[idx(j)]);275 ring.length = 0;276 ring.push(...sub, ...rest);277 cells.clear();278 for (const [x, z] of ring) cells.add(key(x, z));279}280281/**282 * U-extrude: grow a peninsula off a straight segment of m cells — up k,283 * across, back down. The two columns ARE allowed to touch each other284 * (channels in adjacent cells clear each other: walls reach ±0.35).285 * m=2 tops become an L,L / R,R hairpin (the `bend` piece's window);286 * m=4/k≥2 host bend-medium ("FLFFLF"), m=6/k=3 host bend-large.287 */288function tryUExtrude(ring: P[], cells: Set<string>, rng: Rng): boolean {289 const n = ring.length;290 const i = rng.int(0, n);291 const idx = (j: number) => (i + j + n) % n;292 const m = rng.weighted([[2, 5], [4, 2.5], [6, 1.5]] as const);293 // Deep columns (k=5) host the large s-curve windows on their shoulders.294 const k = m === 2295 ? rng.int(1, 4)296 : m === 4297 ? rng.weighted([[2, 4], [3, 3], [4, 1], [5, 1.5]] as const)298 : rng.weighted([[3, 3], [4, 1], [5, 1.5]] as const);299 if (m + 2 > n) return false;300 const dir = dirBetween(ring[idx(0)], ring[idx(1)]);301 for (let j = 1; j < m; j++) {302 if (dirBetween(ring[idx(j - 1)], ring[idx(j)]) !== dir) return false;303 }304 const normal = rng.chance(0.5) ? turnLeft(dir) : turnRight(dir);305 const s = ring[idx(0)];306 const e = ring[idx(m - 1)];307 const p = ring[idx(-1)];308 const q = ring[idx(m)];309 // The anchors become turn cells — reject reversals.310 if (dirBetween(p, s) === opposite(normal)) return false;311 if (dirBetween(e, q) === normal) return false;312313 const [nx, nz] = DIR_STEP[normal];314 const at = (c: P, j: number): P => [c[0] + nx * j, c[1] + nz * j];315 const colA: P[] = [];316 const colB: P[] = [];317 for (let j = 1; j <= k; j++) {318 colA.push(at(s, j));319 colB.push(at(e, j));320 }321 const top: P[] = [];322 for (let t = 1; t <= m - 2; t++) top.push(at(ring[idx(t)], k));323324 const removed = new Set<string>();325 for (let t = 1; t <= m - 2; t++) {326 removed.add(key(ring[idx(t)][0], ring[idx(t)][1]));327 }328 const fresh = [...colA, ...top, ...colB];329 const allowed = new Set([330 key(p[0], p[1]),331 key(s[0], s[1]),332 key(e[0], e[1]),333 key(q[0], q[1]),334 ...fresh.map(([x, z]) => key(x, z)),335 ]);336 if (!replaceOk(cells, fresh, removed, allowed)) return false;337338 const sub: P[] = [s, ...colA, ...top, ...colB.slice().reverse(), e];339 applySplice(ring, cells, i, m, sub);340 return true;341}342343/**344 * Pierce: a hairpin peninsula (m=2 U-extrude) pushed THROUGH a perpendicular345 * straight run — the two column channels cross the run, making two adjacent346 * twice-visited cells (ring order visits each once along the run and once347 * along the column). Two forms:348 * - flat (grade crossing): the 4-way `cross` piece, everything at base 0.349 * - bridge (d ≥ 3): the hairpin climbs +1.0 on its columns and passes OVER350 * the run; the deck piece above may be straight-hole — the marble's351 * chance to fall onto the channel below (user rule: holes require track352 * below).353 * The splice lands at ring index 0 (the anchor is a turn, so seam354 * normalization is the identity): columns at indices 1..k and k+1..2k,355 * upper crossing visits at d and 2k+1−d.356 */357function tryPierce(358 ring: P[],359 cells: Set<string>,360 rng: Rng,361): { d: number; k: number; bridge: boolean } | null {362 const n = ring.length;363 const i = rng.int(0, n);364 const idx = (j: number) => (i + j + n) % n;365 const s = ring[idx(0)];366 const e = ring[idx(1)];367 let dir: Dir;368 try {369 dir = dirBetween(s, e);370 } catch {371 return null;372 }373 const p = ring[idx(-1)];374 const q = ring[idx(2)];375 const normal = rng.chance(0.5) ? turnLeft(dir) : turnRight(dir);376 // The anchors become turn cells — reject reversals. The NEAR anchor must377 // genuinely turn (approach not already along the normal): the splice puts378 // it at ring index 0, and seam normalization is only the identity when379 // index 0 is a turn — the bridge layout indices depend on that.380 if (dirBetween(p, s) === opposite(normal)) return null;381 if (dirBetween(p, s) === normal) return null;382 if (dirBetween(e, q) === normal) return null;383384 const [nx, nz] = DIR_STEP[normal];385 const at = (c: P, j: number): P => [c[0] + nx * j, c[1] + nz * j];386387 // Scan outward for the first row where BOTH columns hit path cells — the388 // candidate crossed run. Rows before it must be completely free.389 let d = -1;390 for (let j = 1; j <= 6; j++) {391 const hitA = cells.has(key(...at(s, j)));392 const hitB = cells.has(key(...at(e, j)));393 if (hitA !== hitB) return null; // grazing one column only — bail394 if (hitA) {395 d = j;396 break;397 }398 }399 if (d < 2) return null; // no run found in reach, or hugging the segment400401 const cA = at(s, d);402 const cB = at(e, d);403 // Both crossing cells must be interior of one straight run perpendicular404 // to the columns: their ring neighbors continue along ±dir.405 const runOk = (c: P, otherKey: string): boolean => {406 const along1 = key(c[0] + DIR_STEP[dir][0], c[1] + DIR_STEP[dir][1]);407 const along2 = key(c[0] - DIR_STEP[dir][0], c[1] - DIR_STEP[dir][1]);408 // One side continues to the partner crossing cell, the other must be a409 // path cell of the same run; the cell ahead (row d+1 handled by fresh410 // checks) must NOT be path.411 return (along1 === otherKey || cells.has(along1)) &&412 (along2 === otherKey || cells.has(along2));413 };414 if (!runOk(cA, key(...cB)) || !runOk(cB, key(...cA))) return null;415 // The crossing cells must currently be STRAIGHT visits of that run: find416 // them in the ring and check their in/out steps are along ±dir... their417 // ring neighbors must be exactly the along-run cells (no turns there).418 const straightVisit = (c: P): boolean => {419 for (let j = 0; j < n; j++) {420 if (ring[j][0] === c[0] && ring[j][1] === c[1]) {421 const prev = ring[(j - 1 + n) % n];422 const next = ring[(j + 1) % n];423 const din = dirBetween(prev, c);424 const dout = dirBetween(c, next);425 return din === dout && (din === dir || din === opposite(dir));426 }427 }428 return false;429 };430 if (!straightVisit(cA) || !straightVisit(cB)) return null;431432 // Column length: at least one row beyond the crossing, at most a few.433 const k = d + 1 + rng.int(0, 3);434435 const colA: P[] = [];436 const colB: P[] = [];437 for (let j = 1; j <= k; j++) {438 colA.push(at(s, j));439 colB.push(at(e, j));440 }441 // Fresh = every column cell except the crossings themselves.442 const fresh = [...colA, ...colB].filter(([x, z]) =>443 !(x === cA[0] && z === cA[1]) && !(x === cB[0] && z === cB[1])444 );445 const [tx, tz] = DIR_STEP[dir];446 const allowed = new Set([447 key(p[0], p[1]),448 key(s[0], s[1]),449 key(e[0], e[1]),450 key(q[0], q[1]),451 key(cA[0], cA[1]),452 key(cB[0], cB[1]),453 // The crossed run continues past the crossings on both sides; the454 // column cells touch those continuations diagonally.455 key(cA[0] - tx, cA[1] - tz),456 key(cB[0] + tx, cB[1] + tz),457 ...[...colA, ...colB].map(([x, z]) => key(x, z)),458 ]);459 if (!replaceOk(cells, fresh, new Set(), allowed)) return null;460461 const sub: P[] = [s, ...colA, ...colB.slice().reverse(), e];462 applySplice(ring, cells, i, 2, sub);463 // Bridge needs room on the columns for the 1.0 climb/descent below the464 // crossing row (2 cells each side → d ≥ 3); prefer it when possible.465 const bridge = d >= 3 && rng.chance(0.7);466 return { d, k, bridge };467}468469/**470 * Full-helix ensemble windows, keyed by the entry turn's chirality. Moves471 * MUST equal the matching registry defs' moves (pinned by a test): approach472 * turn, ccw/cw perimeter of the spiral block, then back through the first473 * cells as the ground-level underpass.474 */475export const HELIX_LOOP_SPECS: {476 large: boolean;477 turn: "L" | "R";478 id: string;479 moves: string;480}[] = [481 { large: false, turn: "R", id: "helix-left", moves: "RFLFFLFFLFFLFFF" },482 { large: false, turn: "L", id: "helix-right", moves: "LFRFFRFFRFFRFFF" },483 {484 large: true,485 turn: "R",486 id: "helix-large-left",487 moves: "RFFLFFFFLFFFFLFFFFLFFFFF",488 },489 {490 large: true,491 turn: "L",492 id: "helix-large-right",493 moves: "LFFRFFFFRFFFFRFFFFRFFFFF",494 },495];496497/** Walk a moves string from (0,0) heading +z; returns the cell sequence. */498function walkMoves(moves: string): P[] {499 const out: P[] = [];500 let cx = 0, cz = 0;501 let dir: Dir = 1;502 for (const m of moves) {503 dir = m === "F" ? dir : m === "L" ? turnLeft(dir) : turnRight(dir);504 out.push([cx, cz]);505 cx += DIR_STEP[dir][0];506 cz += DIR_STEP[dir][1];507 }508 return out;509}510511/**512 * Helix loop: replace a turn cell K (plus the following `under` straight513 * cells) with a full-helix ensemble window — the spiral block's perimeter514 * walk that re-visits K's row as the ground-level underpass. Duplicated515 * cells are confined to the window; realization is forced to the one516 * full-helix def by the elevation planner. The splice lands the window at517 * ring index 0 (K is a turn, so seam normalization keeps it there).518 *519 * All valid (anchor, spec) sites are enumerated and the pick is weighted520 * toward large blocks and the ring's MINORITY turn chirality (the seed521 * rectangle is turn-left-dominant, so left-handed helixes would otherwise522 * almost never appear — their R anchors mostly come from cluttered bump523 * detours).524 */525function tryHelixLoop(526 ring: P[],527 cells: Set<string>,528 rng: Rng,529): { len: number; id: string } | null {530 const n = ring.length;531532 interface Site {533 newRing: P[];534 spec: (typeof HELIX_LOOP_SPECS)[number];535 }536 const validate = (537 i: number,538 spec: (typeof HELIX_LOOP_SPECS)[number],539 ): Site | null => {540 const idx = (j: number) => (i + j + n) % n;541 const K = ring[idx(0)];542 const P0 = ring[idx(-1)];543 const dirIn = dirBetween(P0, K);544 const dirOut = dirBetween(K, ring[idx(1)]);545 if (moveLabel(dirIn, dirOut) !== spec.turn) return null;546 const under = spec.large ? 3 : 2;547 if (n <= under + 2) return null;548 // The `under` cells after K must continue straight along dirOut, and549 // the exit edge after the last one must too.550 for (let j = 1; j <= under; j++) {551 if (dirBetween(ring[idx(j)], ring[idx(j + 1)]) !== dirOut) return null;552 }553 const Q = ring[idx(under + 1)];554555 // Map the ensemble walk into the world anchored at K.556 const xf = windowXform(K[0], K[1], dirIn, 0);557 const toWorld = ([cx, cz]: P): P => {558 const w = applyXform(xf, [cx, 0, cz]);559 return [Math.round(w[0]), Math.round(w[2])];560 };561 const walk = walkMoves(spec.moves).map(toWorld);562 // Footprint = the walk's bounding box (perimeter + spiral column).563 const xs = walk.map((c) => c[0]);564 const zs = walk.map((c) => c[1]);565 const footprint: P[] = [];566 for (let x = Math.min(...xs); x <= Math.max(...xs); x++) {567 for (let z = Math.min(...zs); z <= Math.max(...zs); z++) {568 footprint.push([x, z]);569 }570 }571 const replaced = new Set<string>();572 replaced.add(key(K[0], K[1]));573 for (let j = 1; j <= under; j++) {574 replaced.add(key(ring[idx(j)][0], ring[idx(j)][1]));575 }576 const fresh = footprint.filter(([x, z]) => !replaced.has(key(x, z)));577 const allowed = new Set([578 key(P0[0], P0[1]),579 key(Q[0], Q[1]),580 ...replaced,581 ...footprint.map(([x, z]) => key(x, z)),582 ]);583 if (!replaceOk(cells, fresh, new Set(), allowed)) return null;584585 // Splice with the window at index 0.586 const rest: P[] = [];587 for (let j = under + 1; j < n; j++) rest.push(ring[idx(j)]);588 const newRing = [...walk, ...rest];589590 // The 2.0 climb needs a straight run of ≥4 cells outside the window.591 let runLen = 0;592 for (let j = spec.moves.length; j < newRing.length; j++) {593 const prev = newRing[j - 1];594 const cur = newRing[j];595 const next = newRing[(j + 1) % newRing.length];596 const straight = dirBetween(prev, cur) === dirBetween(cur, next);597 runLen = straight ? runLen + 1 : 0;598 if (runLen >= 4) return { newRing, spec };599 }600 return null;601 };602603 // Minority chirality on this ring, for the rarity weighting.604 let l = 0, r = 0;605 for (let j = 0; j < n; j++) {606 const m = moveLabel(607 dirBetween(ring[(j - 1 + n) % n], ring[j]),608 dirBetween(ring[j], ring[(j + 1) % n]),609 );610 if (m === "L") l++;611 else if (m === "R") r++;612 }613 const minority = l >= r ? "R" : "L";614615 const sites: Site[] = [];616 for (let i = 0; i < n; i++) {617 for (const spec of HELIX_LOOP_SPECS) {618 const site = validate(i, spec);619 if (site) sites.push(site);620 }621 }622 if (sites.length === 0) return null;623 const pick = rng.weighted(624 sites.map((s) =>625 [626 s,627 (s.spec.large ? 3 : 1) * (s.spec.turn === minority ? 3 : 1),628 ] as const629 ),630 );631632 ring.length = 0;633 ring.push(...pick.newRing);634 cells.clear();635 for (const [x, z] of ring) cells.add(key(x, z));636 return { len: pick.spec.moves.length, id: pick.spec.id };637}638639/**640 * Staircase: shift a run's interior TWO cells sideways with diagonal641 * zig-zag transitions. The transitions read L,R,L,R (and mirrored on the642 * way back), which is exactly the s-curve pieces' window signature.643 */644function tryStaircase(ring: P[], cells: Set<string>, rng: Rng): boolean {645 const n = ring.length;646 const i = rng.int(0, n);647 const idx = (j: number) => (i + j + n) % n;648 const span = rng.int(5, 9);649 if (span + 2 > n) return false;650 const dir = dirBetween(ring[idx(0)], ring[idx(1)]);651 for (let j = 1; j < span; j++) {652 if (dirBetween(ring[idx(j - 1)], ring[idx(j)]) !== dir) return false;653 }654 const normal = rng.chance(0.5) ? turnLeft(dir) : turnRight(dir);655 const s = ring[idx(0)];656 const e = ring[idx(span - 1)];657 const prev = ring[idx(-1)];658 const next = ring[idx(span)];659 if (dirBetween(prev, s) === opposite(normal)) return false;660 if (dirBetween(e, next) === normal) return false;661662 const [nx, nz] = DIR_STEP[normal];663 const at = (c: P, j: number): P => [c[0] + nx * j, c[1] + nz * j];664 const c = (t: number) => ring[idx(t)];665666 const sub: P[] = [s, at(s, 1), at(c(1), 1), at(c(1), 2)];667 for (let t = 2; t <= span - 2; t++) sub.push(at(c(t), 2));668 sub.push(at(c(span - 2), 1), at(e, 1), e);669670 const removed = new Set<string>();671 for (let t = 1; t <= span - 2; t++) {672 removed.add(key(c(t)[0], c(t)[1]));673 }674 const fresh = sub.slice(1, -1);675 const allowed = new Set([676 key(prev[0], prev[1]),677 key(s[0], s[1]),678 key(e[0], e[1]),679 key(next[0], next[1]),680 ...fresh.map(([x, z]) => key(x, z)),681 ]);682 if (!replaceOk(cells, fresh, removed, allowed)) return false;683684 applySplice(ring, cells, i, span, sub);685 return true;686}