1/**2 * Elevation system: plans a closed height profile over the skeleton ring3 * (Σ deltas = 0 by construction: hills alternate climb → descent and always4 * return to ground). Climbs/descents are reserved as concrete window slots5 * with restricted piece domains; all reserved pieces are 1-cell-wide, so6 * reservations can never collide with anything.7 *8 * All planned base heights are multiples of 0.5 — the support-stack grid.9 */10import type { Rng } from "./rng.ts";11import type { Skeleton } from "./topology.ts";12import type { PieceRegistry } from "./registry.ts";13import { applyXform, windowXform } from "./geom.ts";1415/** A reserved window: realization must place one of `pieceIds` here. */16export interface Reservation {17 start: number;18 len: number;19 /** Candidate piece ids (forward defs); style picks among them. */20 pieceIds: readonly string[];21 /** True → use the reversed variant (descent). */22 reversed: boolean;23}2425export interface ElevationPlan {26 /** Base height (piece origin y) at each ring cell's entry. */27 baseY: number[];28 reservations: Reservation[];29 maxBaseY: number;30}3132interface Run {33 start: number;34 len: number;35}3637/**38 * One contiguous stretch of a climb/descent. `ids` are interchangeable39 * alternatives (same length and delta); multi-part options chain parts40 * back-to-back so every intermediate base stays on the 0.5 support grid.41 */42export interface SlopePart {43 ids: string[];44 len: number;45 delta: number;46 /** The FORWARD def already descends (drop-stacks, helixes) — reserve it47 * unreversed even inside a descent. */48 asIs?: boolean;49}5051export interface SlopeOption {52 totalLen: number;53 parts: SlopePart[];54}5556const slope = (...parts: SlopePart[]): SlopeOption => ({57 totalLen: parts.reduce((a, p) => a + p.len, 0),58 parts,59});6061/**62 * Climbs cap at 0.5 rise per cell — a marble visibly ascending anything63 * steeper reads as broken. Taller climbs chain gentle parts instead.64 */65export const CLIMB_VOCAB: Record<string, SlopeOption[]> = {66 "0.5": [67 slope({ ids: ["slant-b"], len: 1, delta: 0.5 }),68 slope({ ids: ["ramp-long-b", "slant-long-b"], len: 2, delta: 0.5 }),69 slope(70 { ids: ["ramp-start-b"], len: 1, delta: 0.25 },71 { ids: ["ramp-end-b"], len: 1, delta: 0.25 },72 ),73 ],74 "1": [75 slope({ ids: ["ramp-long-c", "slant-long-c"], len: 2, delta: 1.0 }),76 slope(77 { ids: ["ramp-start-c"], len: 1, delta: 0.5 },78 { ids: ["ramp-end-c"], len: 1, delta: 0.5 },79 ),80 ],81 "1.5": [82 slope(83 { ids: ["ramp-start-c"], len: 1, delta: 0.5 },84 { ids: ["ramp-end-c"], len: 1, delta: 0.5 },85 { ids: ["slant-b"], len: 1, delta: 0.5 },86 ),87 slope(88 { ids: ["ramp-long-b", "slant-long-b"], len: 2, delta: 0.5 },89 { ids: ["ramp-long-c", "slant-long-c"], len: 2, delta: 1.0 },90 ),91 ],92 // Only the full-helix ensemble asks for 2.0 (its mandatory climb).93 "2": [94 slope(95 { ids: ["ramp-long-c", "slant-long-c"], len: 2, delta: 1.0 },96 { ids: ["ramp-long-c", "slant-long-c"], len: 2, delta: 1.0 },97 ),98 slope(99 { ids: ["ramp-start-c"], len: 1, delta: 0.5 },100 { ids: ["ramp-end-c"], len: 1, delta: 0.5 },101 { ids: ["ramp-long-c", "slant-long-c"], len: 2, delta: 1.0 },102 ),103 ],104};105106/** Descents also allow steeper chute drops (0.75 per cell) and drop-stacks107 * (end-hole over a receiver end: the marble falls through the hole — the108 * channel network disconnects there but the loop closes through the fall). */109export const DESCENT_VOCAB: Record<string, SlopeOption[]> = {110 "0.5": [111 ...CLIMB_VOCAB["0.5"],112 slope({113 ids: ["drop-stack-rounded-f", "drop-stack-square-f"],114 len: 1,115 delta: 0.5,116 asIs: true,117 }),118 ],119 "1": CLIMB_VOCAB["1"],120 "1.5": [121 ...CLIMB_VOCAB["1.5"],122 slope({ ids: ["slant-long-d", "ramp-long-d"], len: 2, delta: 1.5 }),123 slope({ ids: ["slant-long-d", "ramp-long-d"], len: 2, delta: 1.5 }),124 // Chute pair: 0.75/cell — exactly the descent steepness cap.125 slope(126 { ids: ["ramp-start-d"], len: 1, delta: 0.75 },127 { ids: ["ramp-end-d"], len: 1, delta: 0.75 },128 ),129 ],130};131132/**133 * Corner drops: a hill leg may ride a helix at a turn window instead of a134 * ramp on a straight run. Forward helix defs are descents (deckDelta < 0);135 * a climb through the same window pattern is the REVERSED variant of the136 * opposite-chirality helix (reversing swaps L↔R in the moves). The window137 * always covers the hill's full delta, so the helix's integral base sits on138 * the ground at its low end (hills start/end at base 0).139 */140export const TURN_DESCENTS: Record<string, { delta: number; ids: string[] }> = {141 // Bare corners can host a drop-stack: end-hole over a receiver end whose142 // port exits through the turn. Guaranteed fall; len²-weighting keeps real143 // helix windows preferred when both exist.144 L: { delta: 0.5, ids: ["drop-stack-rounded-l", "drop-stack-square-l"] },145 R: { delta: 0.5, ids: ["drop-stack-rounded-r", "drop-stack-square-r"] },146 FLF: { delta: 0.5, ids: ["helix-quarter-left"] },147 FRF: { delta: 0.5, ids: ["helix-quarter-right"] },148 FFLFF: { delta: 0.5, ids: ["helix-large-quarter-left"] },149 FFRFF: { delta: 0.5, ids: ["helix-large-quarter-right"] },150 FLFFLF: { delta: 1.0, ids: ["helix-half-left"] },151 FRFFRF: { delta: 1.0, ids: ["helix-half-right"] },152 FFLFFFFLFF: { delta: 1.0, ids: ["helix-large-half-left"] },153 FFRFFFFRFF: { delta: 1.0, ids: ["helix-large-half-right"] },154};155const TURN_CLIMBS: Record<string, { delta: number; ids: string[] }> = {156 FLF: { delta: 0.5, ids: ["helix-quarter-right"] },157 FRF: { delta: 0.5, ids: ["helix-quarter-left"] },158 FFLFF: { delta: 0.5, ids: ["helix-large-quarter-right"] },159 FFRFF: { delta: 0.5, ids: ["helix-large-quarter-left"] },160 FLFFLF: { delta: 1.0, ids: ["helix-half-right"] },161 FRFFRF: { delta: 1.0, ids: ["helix-half-left"] },162 FFLFFFFLFF: { delta: 1.0, ids: ["helix-large-half-right"] },163 FFRFFFFRFF: { delta: 1.0, ids: ["helix-large-half-left"] },164};165166/** How often the track's corner set-piece (a helix window) is attempted. */167const CORNER_DESCENT_AFFINITY = 0.5;168const CORNER_CLIMB_AFFINITY = 0.3;169170interface TurnWindow {171 start: number;172 len: number;173 pattern: string;174 delta: number;175}176177/**178 * All ring windows whose move pattern matches a helix def, unblocked, and179 * whose footprint extras don't collide with the rest of the ring (the world180 * cells a helix covers are the same for the descent and the climb variant,181 * so the forward def's footprint is checked once).182 */183function findTurnWindows(184 skeleton: Skeleton,185 blocked: ReadonlySet<number>,186 registry: PieceRegistry,187): TurnWindow[] {188 const n = skeleton.cells.length;189 const moves = skeleton.cells.map((c) => c.move).join("");190 const ringCells = new Set(skeleton.cells.map((c) => `${c.x},${c.z}`));191 const out: TurnWindow[] = [];192 for (const [pattern, spec] of Object.entries(TURN_DESCENTS)) {193 const def = registry.variants.find(194 (v) => v.id === spec.ids[0] && !v.reversed,195 );196 if (!def) continue;197 for (let start = 0; start < n; start++) {198 let ok = true;199 const windowSet = new Set<string>();200 for (let j = 0; j < pattern.length; j++) {201 const idx = (start + j) % n;202 if (blocked.has(idx) || moves[idx] !== pattern[j]) {203 ok = false;204 break;205 }206 const c = skeleton.cells[idx];207 windowSet.add(`${c.x},${c.z}`);208 }209 if (!ok) continue;210 const cell = skeleton.cells[start];211 const xf = windowXform(cell.x, cell.z, cell.dirIn, 0);212 for (const [fx, fz] of def.footprint) {213 const w = applyXform(xf, [fx, 0, fz]);214 const k = `${Math.round(w[0])},${Math.round(w[2])}`;215 if (!windowSet.has(k) && ringCells.has(k)) {216 ok = false;217 break;218 }219 }220 if (ok) {221 out.push({ start, len: pattern.length, pattern, delta: spec.delta });222 }223 }224 }225 return out.sort((a, b) => a.start - b.start || b.len - a.len);226}227228export function planElevation(229 rng: Rng,230 skeleton: Skeleton,231 registry: PieceRegistry,232 /** Ring indices claimed by the feature planner — hills avoid them. */233 blocked: ReadonlySet<number> = new Set(),234): ElevationPlan {235 const n = skeleton.cells.length;236 const deltas = new Array<number>(n).fill(0);237 const reservations: Reservation[] = [];238239 // A full-helix ensemble owns its whole window; its cells never host other240 // slopes, and its mandatory 2.0 climb is reserved before any hills.241 const fullBlocked = new Set(blocked);242 for (const loop of skeleton.helixLoops) {243 for (let j = 0; j < loop.len; j++) fullBlocked.add(loop.start + j);244 }245246 // Maximal straight unblocked runs (skeleton is normalized to start at a247 // turn, so runs never wrap the seam).248 const runs: Run[] = [];249 let i = 0;250 while (i < n) {251 if (skeleton.cells[i].move !== "F" || fullBlocked.has(i)) {252 i++;253 continue;254 }255 let len = 0;256 while (257 i + len < n && skeleton.cells[i + len].move === "F" &&258 !fullBlocked.has(i + len)259 ) len++;260 runs.push({ start: i, len });261 i += len;262 }263264 // Full-helix ensemble: reserve the spiral window (drop 2.0) and its265 // mandatory climb; the loop is then THE hill of the track.266 const helixLoop = skeleton.helixLoops[0];267 if (helixLoop) {268 reservations.push({269 start: helixLoop.start,270 len: helixLoop.len,271 pieceIds: [helixLoop.id],272 reversed: false,273 });274 deltas[helixLoop.start] -= 2;275 const climbRuns = runs.filter((r) => r.len >= 4);276 if (climbRuns.length === 0) {277 // The mutation pre-checked for a ≥4 run; features can't take it278 // (generateTrack skips wide runs on helix-loop tracks).279 throw new Error("helix loop without a climb run");280 }281 const longest = climbRuns.reduce((a, b) => (b.len > a.len ? b : a));282 reserve(rng.child("helix-climb"), longest, 2.0, false);283 }284285 // Bridge pierce: the hairpin climbs +1.0 on its columns, passes OVER the286 // crossed run, and descends on the far column. The pierce splice fixes287 // the layout (columns at indices 1..k and k+1..2k, crossings at d and288 // 2k+1−d), so the climb sits below the near crossing and the descent289 // below the far one — both upper visits land at exactly 1.0.290 const bridge = skeleton.pierce?.bridge ? skeleton.pierce : null;291 if (bridge) {292 reserve(293 rng.child("bridge-climb"),294 { start: 1, len: bridge.d - 1 },295 1.0,296 false,297 );298 reserve(299 rng.child("bridge-descent"),300 { start: 2 * bridge.k - bridge.d + 2, len: bridge.d - 1 },301 1.0,302 true,303 );304 }305306 // Corner drop, planned BEFORE hills grab their straight runs: the big307 // helix windows straddle several runs, so picking runs first starved them308 // (helix-large-half never appeared). One corner set-piece per track:309 // a length²-weighted window (big spirals win when present) reserved as310 // the descent, paired with a straight climb — or a second, disjoint311 // corner window as a spiral climb. Ring order is free: the baseY profile312 // is normalized to min 0 after integration, so descent-before-climb313 // simply means the elevated stretch wraps the seam.314 // Helix-loop tracks stay flat elsewhere (the spiral IS the hill). Grade315 // crossings no longer force a flat track (M13 lifts the M3 compromise):316 // plain hills are admitted under the HILL-INTERVAL CONSTRAINT below;317 // corner set-pieces stay off crossing tracks (multi-window ensembles318 // and the pierce splice both pin ring geometry the constraint can't319 // reason about).320 const flatOnly = Boolean(helixLoop);321 const crossingPairs = skeleton.crossings;322 const corner = rng.child("corner");323 const usedRuns = new Set<Run>();324 if (325 !flatOnly && crossingPairs.length === 0 &&326 corner.chance(CORNER_DESCENT_AFFINITY)327 ) {328 const turnWindows = findTurnWindows(skeleton, fullBlocked, registry);329 if (turnWindows.length > 0) {330 const pickTurn = (opts: TurnWindow[]): TurnWindow =>331 corner.weighted(opts.map((w) => [w, w.len * w.len] as const));332 const reserveTurn = (w: TurnWindow, descent: boolean) => {333 const spec = (descent ? TURN_DESCENTS : TURN_CLIMBS)[w.pattern];334 reservations.push({335 start: w.start,336 len: w.len,337 pieceIds: spec.ids,338 reversed: !descent,339 });340 spreadDelta(w.start, w.len, descent ? -w.delta : w.delta);341 };342 const descent = pickTurn(turnWindows);343 const windowIdx = new Set<number>();344 for (let j = 0; j < descent.len; j++) {345 windowIdx.add((descent.start + j) % n);346 }347 const overlapsWindow = (r: Run): boolean => {348 for (let j = 0; j < r.len; j++) {349 if (windowIdx.has(r.start + j)) return true;350 }351 return false;352 };353 const disjoint = (w: TurnWindow): boolean => {354 for (let j = 0; j < w.len; j++) {355 if (windowIdx.has((w.start + j) % n)) return false;356 }357 return true;358 };359 const climbWindows = turnWindows.filter((w) =>360 w !== descent && w.delta === descent.delta && disjoint(w) &&361 // Drop-stack windows (bare L/R) have no climb form — you can't362 // fall upward through a hole.363 TURN_CLIMBS[w.pattern] !== undefined364 );365 const climbRuns = runs.filter((r) =>366 !overlapsWindow(r) &&367 CLIMB_VOCAB[String(descent.delta)].some((o) => o.totalLen <= r.len)368 );369 if (climbWindows.length > 0 && corner.chance(CORNER_CLIMB_AFFINITY)) {370 reserveTurn(pickTurn(climbWindows), false);371 reserveTurn(descent, true);372 } else if (climbRuns.length > 0) {373 const run = corner.pick(climbRuns);374 reserve(rng.child("climb/corner"), run, descent.delta, false);375 reserveTurn(descent, true);376 usedRuns.add(run);377 }378 // No climb available → the corner drop is silently skipped.379 }380 }381382 const cornerIdx = new Set<number>();383 for (const r of reservations) {384 for (let j = 0; j < r.len; j++) cornerIdx.add((r.start + j) % n);385 }386 // HILL-INTERVAL CONSTRAINT (M13, lifts the M3 "crossings stay flat"387 // compromise): both visits of a crossing pair must agree on their height388 // RELATIONSHIP — baseY[b] − baseY[a] is the sum of window deltas in the389 // ring interval (a, b], so a hill is safe iff its climb and descent land390 // on the SAME SIDE of every pair's interval (net contribution zero).391 // Runs touching a crossing cell are excluded outright (the cross piece392 // is one flat 4-way — a slope window may not cover it), and any run393 // free of the crossing cells lies entirely on one side of each interval.394 const crossingCells = new Set<number>(395 crossingPairs.flatMap((c) => [c.a, c.b]),396 );397 const eligible = runs.filter((r) => {398 if (usedRuns.has(r)) return false;399 for (let j = 0; j < r.len; j++) {400 if (cornerIdx.has(r.start + j)) return false;401 if (crossingCells.has((r.start + j) % n)) return false;402 }403 return r.len >= 2;404 });405 const sameSide = (r1: Run, r2: Run): boolean =>406 crossingPairs.every(({ a, b }) => {407 const s1 = r1.start > a && r1.start <= b;408 const s2 = r2.start > a && r2.start <= b;409 return s1 === s2;410 });411 const maxHills = Math.min(3, Math.floor(eligible.length / 2));412 // At least one hill whenever the ring can host one — flat tracks still413 // occur (small rings), but stay the exception. Helix-loop tracks: the414 // spiral IS the hill.415 const hills = maxHills > 0 && !flatOnly ? rng.int(1, maxHills + 1) : 0;416417 if (hills > 0) {418 const chosen = rng.shuffle(eligible).slice(0, hills * 2)419 .sort((a, b) => a.start - b.start);420 for (let h = 0; h < hills; h++) {421 const climbRun = chosen[h * 2];422 const descentRun = chosen[h * 2 + 1];423 // A pair split by a crossing interval would tilt the pair's height424 // relationship — skip that hill (the pairing is seeded; another425 // seed pairs differently).426 if (!sameSide(climbRun, descentRun)) continue;427 // Only deltas both runs can physically host (0.5 always fits).428 const choices = ([[0.5, 3], [1.0, 2], [1.5, 2]] as const).filter(([d]) =>429 CLIMB_VOCAB[String(d)].some((o) => o.totalLen <= climbRun.len) &&430 DESCENT_VOCAB[String(d)].some((o) => o.totalLen <= descentRun.len)431 );432 const delta = rng.weighted(choices);433 reserve(rng.child(`climb/${h}`), climbRun, delta, false);434 reserve(rng.child(`descent/${h}`), descentRun, delta, true);435 }436 }437438 function reserve(439 r: Rng,440 run: Run,441 delta: number,442 reversed: boolean,443 ): { start: number; len: number } {444 const vocab = reversed ? DESCENT_VOCAB : CLIMB_VOCAB;445 const usable = vocab[String(delta)].filter((o) => o.totalLen <= run.len);446 const option = r.pick(usable);447 const start = run.start + r.int(0, run.len - option.totalLen + 1);448 // Descents traverse the part sequence in reverse order.449 const parts = reversed ? option.parts.slice().reverse() : option.parts;450 let at = start;451 for (const part of parts) {452 reservations.push({453 start: at,454 len: part.len,455 pieceIds: part.ids,456 reversed: reversed && !part.asIs,457 });458 spreadDelta(at, part.len, reversed ? -part.delta : part.delta);459 at += part.len;460 }461 return { start, len: option.totalLen };462 }463464 function spreadDelta(start: number, _len: number, delta: number) {465 // The whole window's delta lands at its start index; baseY integration466 // below treats a window as one step.467 deltas[start] += delta;468 }469470 // Integrate deltas around the ring: baseY[i] is the entry base of cell i.471 const baseY = new Array<number>(n).fill(0);472 let hgt = 0;473 for (let idx = 0; idx < n; idx++) {474 baseY[idx] = hgt;475 hgt += deltas[idx];476 }477 if (Math.abs(hgt) > 1e-9) {478 throw new Error(`elevation did not close: residual ${hgt}`);479 }480 // Ground the profile: the integration constant is free on a closed loop,481 // so shift the lowest point to base 0. Hills built climb-before-descent482 // already sit at 0; the helix loop's drop-then-climb (in index order)483 // relies on this.484 const minY = Math.min(...baseY);485 if (minY !== 0) {486 for (let idx = 0; idx < n; idx++) baseY[idx] -= minY;487 }488489 return { baseY, reservations, maxBaseY: Math.max(...baseY) };490}