clone

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

M2-M5: full kit vocabulary, hole semantics, and endless void mode

Dami committed a8ea283
M2-M4 + hole semantics (previous rounds, uncommitted): bends, s-curves,
waves, wide runs, all 12 helixes, grade crossings, bridge pierces, split
sidings, diamonds, boost fans, drop-stacks, coverage tooling (122 present /
40 exempt / 0 missing), computed genVersion, inspect mode.

M5 (this round, redesigned with user): endless mode is a VOID LINE with
ISLAND ANCHORS - no ground plane, no tiles. Open path with unbounded
net-down elevation weaving over/under/around its own past via a cell->y-band
occupancy map; islands are cliff-block voxel masses the line must anchor to,
with biome families and island-top decor; generation-driven culling keeps
the stream a pure function of the seed; transactional planning with
successor-probe lookahead (0 failures / 400 seeds x 80 segments).
Client streams segments/islands with a starfield void scene (?endless=1).

57 tests green; finite pipeline byte-identical (genVersion ga265f1373eed).
commit
a8ea2834188e7509aa9b1a76486ee62df26baa2d
parent
58f95459ae90bb4918ae47d2bcb833c9f201f79e
author
Dami <dami.etoile@gmail.com>
35 files changed, +6762 -317
Mprogress.md599++++++++++++++--
Msrc/client/camera.ts38+-
Msrc/client/centerline.ts32+-
Msrc/client/cua.ts4+-
Asrc/client/endless.ts443++++++++++++
Asrc/client/inspect.ts214++++++
Msrc/client/main.ts55+-
Msrc/client/marble.ts105+++-
Msrc/client/track.ts101++-
Msrc/gen/assemble.ts235+++++-
Msrc/gen/decor.ts184++++-
Msrc/gen/elevation.ts316++++++++-
Asrc/gen/endless/islands.ts245+++++++
Asrc/gen/endless/occupancy.ts155++++
Asrc/gen/endless/path.ts638+++++++++++++++++
Asrc/gen/endless/world.ts294++++++++
Asrc/gen/fans.ts93++
Asrc/gen/features.ts161++++
Msrc/gen/generateTrack.ts97++-
Msrc/gen/registry.ts1130+++++++++++++++++++++++++++++-
Asrc/gen/stubs.ts192+++++
Msrc/gen/style.ts58+-
Msrc/gen/topology.ts553++++++++++++++-
Asrc/gen/version.ts72++
Msrc/gen/wfc.ts128+++-
Msrc/shared/types.ts19+
Mstatic/index.html100++-
Atests/decor_test.ts108+++
Atests/endless_test.ts167++++
Mtests/golden_tracks.json7+-
Mtests/registry_test.ts37+
Mtests/track_test.ts99++-
Atools/coverage.ts142++++
Atools/measure_floor_path.ts71++
Atools/measure_ports.ts187+++++
M progress.md +541 -58
@@ -1,67 +1,550 @@
11 # progress22 3-Original prompt: Greenfield Deno webapp using Kenney Marble Kit assets to generate emergent, infinitely-looping marble tracks; URL fragment = seed (browser history); seed splits into RNG families across systems; WFC as realization layer; other Kenney packs as decor+setting layer. Current task: CUA test of the running app at http://localhost:8000 (seed navigation, camera toggle, history back/forward, gallery/debug modes).3+Original prompt: Greenfield Deno webapp using Kenney Marble Kit assets to4+generate emergent, infinitely-looping marble tracks; URL fragment = seed5+(browser history); seed splits into RNG families across systems; WFC as6+realization layer; other Kenney packs as decor+setting layer. Current task: M57+polish / M6 (M5 endless mode core landed this round).48 5-## State (2026-08-08)9+## State (2026-08-08, round 2)610 7-- Milestone 1 complete: shell, asset pipeline (marble-kit + nature-kit packed GLBs), generation core (rng families → topology/elevation/style/setting/decor → path-domain WFC), client renderer with spline-follow marble, orbit/follow cams, hash-seed history flow. 42/42 deno tests green.8-- CUA hooks added in `src/client/cua.ts`, wired from `main.ts` (both modes):9- - `window.render_game_to_text()` → `{coords, hash, mode, seed, camMode, marble{s,speed,pos}, stats{...}}` (gallery mode: piece counts + missing).10- - `window.advanceTime(ms)` → deterministic 60 Hz stepping of the frame hooks + render.11- - Dev keys: `b` → history.back(), `a` → history.forward() (test clients can only send mapped keys; `c` toggles camera but isn't in the client's key map — camera toggle is exercised via `#cam-mode` click).11+- Milestone 1 complete: shell, asset pipeline, generation core, client renderer,12+ CUA hooks. See git history for details.13+- **Milestone 2 complete**: bends, s-curves, waves, bumps, wide channel, two new14+ themes.15+- **M2a complete**: decor is a world field (region+oracle API, ambient band).16+- **M3 complete**: all 12 helixes, corner drops/climbs, grade crossings17+ (pierce), tunnel runs, hole spurs.18+- **M4 complete**: split sidings + turn junctions, split–merge diamonds with19+ per-lap branch alternation, boost fans, ramp b/d tiers, track handedness, full20+ kit coverage (122 present / 40 exempt / 0 missing @ 20k seeds).21+- **Hole semantics complete** (user design, see its section): drop-stacks22+ (guaranteed falls, non-connected loop) + bridge pierces with straight-hole23+ chance-falls onto the channel below.24+- **M5 endless mode complete (this round — REDESIGNED with the user, see its25+ section)**: the void line — an unbounded open path through empty 3D space,26+ anchored by generative floating islands, streamed segment-by-segment.27+ `?endless=1` + the same `#seed` hash; finite loop mode untouched.28+- 57 deno tests green (51 finite + 6 endless); goldens UNCHANGED — the finite29+ pipeline is byte-identical. CURRENT genVersion: ga265f1373eed.1230 13-## CUA test plan31+## M2a systems1432 15-1. idle @ #alpha ×3 iterations → marble `s` strictly increases, no console errors.16-2. click `#cam-mode` then idle → `camMode: "follow"`.17-3. click `#new-track` then idle → seed/hash change from alpha, track regenerates.18-4. click `#new-track`, idle, key `b` → seed returns to `alpha` (history back).19-5. same + key `a` → seed returns to minted seed (history forward).20-6. `?gallery=1` → mode gallery, 162+329 pieces, 0 missing.21-7. `?debug=1#alpha` → renders with centerline overlay, no errors.33+- **placeDecor is inverted** (decor.ts): caller supplies a `CellRegion` +34+ `TrackOracle` (`(x,z) → {dist, dir}`); the function realizes the field for35+ exactly that region. `regionAround(cells, margin)` + `cellSetOracle(cells)`36+ are the single-track backers; M5 passes chunk regions + a chunk-backed oracle.37+ Per-cell RNG children (`cell/${x}/${z}`) unchanged.38+- **ORACLE_HORIZON = 6**: oracles must see exactly this far (bands stop at 5.5).39+ `dist` saturates to Infinity beyond it — that's the ambient band. Chunked40+ backers need neighbor context of exactly this radius.41+- **Ambient band** beyond 5.5 out to the region edge: reuses the far-band rules42+ thinned ×0.3 (extra per-cell chance draw), fans excluded (a lone spinning fan43+ in the void reads as debris). Single-track margin 5 → 13.44+- **Forward-compat compromise (M5 list)**: ambient density is FLAT (0.3×), not45+ distance-graded — the oracle saturates at horizon 6 so there's no distance to46+ grade by. If a graded falloff is ever wanted, widen the oracle contract, don't47+ special-case the field.48+- tests/decor_test.ts pins the tiling invariant (quadrant regions == single49+ region, exact), ambient existence, and track clearance.50+- Avg ~143 decor pieces/track at margin 13 (was ~margin-5 ring). Draw cost fine51+ today; instancing is M5's job.52+- Ground plane: client-side, track bbox + 90/side — already covers the wider53+ field; M5 replaces it with streamed ground.54+55+## M2 systems56+57+- **Geometry (measured from GLBs this round)**: bends are pure semicircles with58+ ZERO lead-in (diameter on the port face; lanes 1/3/5 apart, r 0.5/1.5/2.5);59+ s-curves are two tangent 90° arcs of the curve/curve-large radii (lane60+ momentarily perpendicular to travel mid-piece); waves are purely lateral,61+ bumps purely vertical, both smootherstep-eased trough→crest→trough over 462+ cells (ports at trough); wave ports sit at lane −A of the mesh frame (mesh63+ shifts +A to grid-align); funnel taper is exactly linear over 1 cell;64+ funnel-long = straight+funnel+straight-wide fused; straight-wide channel is65+ 1.7 wide centered on the cell (0.35 bulge into each flank cell).66+- **curve-wide is OFF-GRID** (ports on ±0.85/±1.35/±1.85 bbox faces, lane circle67+ non-concentric with the corner, oblique at ports) — cannot mate with the68+ 1-unit port lattice; DEFERRED to a later milestone as a set-piece macro.69+- **Topology mutations** (topology.ts): alongside bump —70+ - U-extrude(m∈{2,4,6}, k≤5): peninsulas whose two columns MAY touch (adjacent71+ parallel channels clear each other; walls at ±0.35). m=2 tops are LL/RR72+ hairpins (bend windows); m=4/6 host bend-medium ("FLFFLF") and bend-large73+ ("FFLFFFFLFF"); k=3/5 shoulders host s-curve windows ("FLFFRF" /74+ "FFLFFFFRFF" dog-legs).75+ - staircase: two-cell lane shift via LRLR diagonals (hosts chained short76+ s-curve jogs "FRL"/"FLR").77+- **Registry infra**: mirrorDef() (x-flip; L↔R, arc θ→π−θ, meshRot→−meshRot,78+ placement.mirrored renders scale(−1,1,1); mirrored def ids keep meshId of the79+ real GLB node); overlays now have absolute entry-frame offsets + optional80+ rot/mirrored (composite pieces: wiggle-a/b = short-jog + mirrored jog,81+ lane-shift-b = two chained b jogs); widthIn/widthOut ports; reservedOnly (wide82+ pieces never free-collapse); columnSupports (off-center channels get83+ centerline-snapped plain columns, never cradles).84+- **Feature planner** (features.ts, rng family "features"): plans wide runs85+ funnel→straight-wide×n→funnel(reversed) on straight runs, checking lateral86+ margins at plan time. Runs BEFORE elevation (ring flat at plan time);87+ elevation avoids feature cells (`blocked` param). Order matters: elevation88+ used to eat all long runs — 54% of skeletons had no eligible wide run.89+- **WFC**: reservations merged from elevation+features (modulo seam-safe),90+ reserved defs' footprint extras go into occupancy, width continuity is a hard91+ post-collapse assertion (funnel bracketing guarantees it by construction —92+ tested).93+- **Supports are structural now** (assemble.ts): per contiguous elevated94+ stretch, pillar at each end (abutments) + piers every SUPPORT_SPAN=2.4 units,95+ global SUPPORT_CLEARANCE=1.0 dedupe (hairpin arms share pillars).96+ Deterministic greedy in ring order, no RNG. (User: "you wouldn't put 3097+ pillars to cover a U curve".)98+- **Themes**: serpentine (bends/s-curves/waves hot), boulevard (wide runs,99+ wideAffinity 0.9, stately large curves). wideAffinity on StyleField drives the100+ feature planner.101+- **Solid-turn rule** (user feedback, via inspect mode): corner-solid /102+ curve-solid(-large) fill their full footprint squares beyond the pipe (open103+ forms are contour-cut: corner's outer corner is rounded away, curve is an104+ annulus). `solidExtends` on those defs; assemble picks the solid ONLY when105+ some orthogonal footprint-neighbor is a same-height RING cell (ports excluded;106+ footprint extras like funnel flanks are void, not deck, and never count as107+ support). Otherwise the pipe-only open form is placed. Turn solids still108+ appear in ~48% of tracks (hairpins/plazas).109+- **Inspect mode** (client): tilde toggles; click placements to select110+ (drag-suppressed raycast), red box highlights, panel lists111+ `#i piece @ (cell) yH rR [role]`; buttons: copy debug info (portable JSON112+ {seed, version, url, tiles[]}), clear selection, exit mode. This exists113+ because stale browser bundles caused three rounds of confusion.114+- **Computed version** (src/gen/version.ts): genVersion() hashes canonical115+ placements of 4 fixed probe seeds (decor included, synthetic catalog) with116+ cyrb128 — NO manual bumps; any behavior change changes the string, refactors117+ that preserve output don't. Shown in HUD/reports; also embedded in118+ tests/golden_tracks.json as "__version" so the committed goldens are119+ self-documenting. ~12ms on first call, cached.120+- **Goal gate** (user idea): one `banner-high` per track straddles the channel121+ as the lap line — post feet at ±0.425 = exactly the rail-top centers122+ (measured; Kenney designed banners to sit on the rails), cloth underside +0.75123+ clears the marble by 0.6. Placed deterministically on the first plain straight124+ window; `goal: true` on the Placement makes the renderer clone+tint its125+ materials dark charcoal (0x30303a) so it reads as the finish marker against126+ any palette (cloned materials disposed with the build). Proper checkered127+ retexture: candidate for M6 polish.128+- **Plate-tile rule** (user feedback, two rounds): the s-curve-short meshes are129+ square slabs with the channel carved diagonally — their triangular platform130+ wings look wrong jutting into bare grass. `plateSides` on a def lists the131+ lateral wing cells; candidateOk requires the connected deck to lie along AT132+ LEAST ONE wing (user-specified threshold — port-edge neighbors don't count,133+ they touch every piece by construction) AND at the SAME base height (the WFC134+ occupancy set is a cell→baseY map now; a channel a level below a wing doesn't135+ visually carry it). Flagged: short-c singles, wiggle-b, lane-shift-b (a-tier136+ wings are sub-0.35 slivers, unflagged). Effect: short-c presence 550→364 of137+ 600 tracks, always with a wing against neighboring deck. Standard corners stay138+ the default in open terrain.139+140+## M3 systems (complete)141+142+- **Helix geometry (measured this round)**: every helix is its flat cousin's arc143+ with the floor descending LINEARLY IN ANGLE — quarters = curve / curve-large144+ quarter arcs (FLF / FFLFF windows, drop 0.5), halves = bend-medium /145+ bend-large semicircles (FLFFLF / FFLFFFFLFF, lanes 3/5 apart, drop 1.0).146+ Ports/radii/footprints bit-identical to the flat pieces; helix walls are147+ floor+0.25 (not +0.5); mesh grounded on its EXIT side so meshOffset.y = −drop.148+ Native right GLBs are exact mirrors (mirrorDef + meshId cleared, s-curve149+ pattern).150+- **FULL helixes (all 4) enter and exit at the SAME cell edge** (e.g. small:151+ port plane x=0 at z=1.5 of the ±2 bbox, both ports heading −x, bases 2.0 and152+ 0.0, 360° at r=1.5/2.5 dropping 2.0). SOLVED as one reserved ensemble window153+ (see below) — no general leveled-occupancy model was needed.154+- **Full-helix ensembles**: registry defs helix-left/right (15-cell window,155+ moves "RFLFFLFFLFFLFFF") and helix-large-left/right (24 cells,156+ "RFFLFFFFLFFFFLFFFFLFFFFF"): approach corner overlay (mirrored corner = R157+ turn) at the top + 360° perimeter-walk spiral + ground-level straight overlays158+ under the first spiral cells (the underpass). The window's moves REVISIT its159+ own first cells (approach cell + underpass); footprint = the whole N×N block.160+ meshOffset [0.5, −2, R], meshRot 2 (ports mid-face). deckDelta −2. Topology's161+ helix-loop mutation (topology.ts, HELIX_LOOP_SPECS — moves strings pinned to162+ the defs by a registry test) replaces a turn cell + `under` following163+ straights with the walk, applied LAST, mutually exclusive with pierce, and164+ only if a ≥4-cell straight run survives for the mandatory climb. The window165+ lands at ring index 0 (normalization is identity because its first cell is a166+ turn). Skeleton.helixLoops declares it; elevation reserves the def + a 2.0167+ climb (CLIMB_VOCAB["2"], longest eligible run) and skips other hills; features168+ (wide runs) are skipped on loop tracks so the climb keeps its run. Elevation169+ now NORMALIZES baseY by −min (the integration constant is free on a closed170+ loop), so drop-before-climb ring order is fine. ~5% of tracks. [Chirality note171+ SUPERSEDED: M4's 50/50 ring reversal + site enumeration balanced left/right.]172+ The spiral coil is freestanding (the GLB has no central column and no mid-coil173+ support points — Kenney authored it as a self-standing slide; flagged for user174+ feedback).175+- **Grade crossings (cross piece + pierce mutation)**: `cross` measured as a176+ flat 4-way (straight's port signature on all four faces — same height, NOT two177+ levels). Topology "pierce": an m=2 hairpin pushed THROUGH a perpendicular178+ straight run (d ≥ 2 rows out, k ≥ d+1), creating two adjacent twice-visited179+ cells; Skeleton.crossings lists index pairs (a, b). Realization pre-reserves180+ both visits: `cross` renders the mesh, `cross-over` (noMesh flag on PieceDef)181+ contributes centerline/occupancy only; its LoopSegment carries placementIndex182+ −1. Crossing tracks stay FLAT (M3 compromise: keeping the ring at base 0 is183+ the only rule that can't split a pair's heights; a hill-interval constraint184+ could lift it). Wide runs treat crossing indices as blocked. Stubs can't185+ attach beside a crossing (its neighbors are ring cells). ~4-5% of tracks186+ (pierce chance 0.55, geometry-limited).187+- **Corner drops** (elevation.ts): forward helix defs are descents (deckDelta<0,188+ never free-collapse); TURN_DESCENTS/TURN_CLIMBS map window patterns → helix189+ ids (a climb through pattern P is the REVERSED variant of the190+ opposite-chirality helix). findTurnWindows() scans the ring move string,191+ checks footprint extras against ring cells at plan time. planElevation takes192+ the registry now (footprint checks). [SUPERSEDED by the M4 restructure:193+ affinity is 0.5, ONE pre-claimed corner set-piece per track, and the194+ ring-order/underground-throw invariant is GONE — baseY is min-normalized, see195+ "Corner-drop planner restructured" under M4.]196+- Helix emergence: 38% of tracks (quarters dominate; halves/large rare197+ set-pieces tied to peninsula windows). 0 failures / 600 strict seeds.198+- **Tunnel runs**: tunnel-run-2/3 defs (composite straight+arch per cell,199+ weights 2.5/1.5). Placements carry MESH ids ("straight" + "tunnel" overlays) —200+ count arches, not def ids, when measuring emergence.201+- **Hole spurs** [SUPERSEDED: M4 replaced wall-butt spurs with real split202+ junctions — see "Split sidings"]. Kept measurement: a straight's block side is203+ a flat 1×0.5 face at the cell edge, so branch ports sit flush against it.204+- **Decor banners moved near → mid band**: posts at ±0.425 + jitter ±0.3 can205+ reach across into an adjacent track cell from the near band (caught by206+ validate at ~1/1000 seeds).207+- M3 final state: 51 tests green, 1500-seed strict fuzz (decor on) clean.208+ genVersion after M3: g9d8e4c534f18. Goldens regenerated.209+- M3 emergence (1000 seeds): quarter/half helixes 33%, full helixes 6%,210+ crossings 6%, tunnel runs (2+ arches) 50%, stubs 42%.211+212+## M4 systems (complete)213+214+- **Split sidings** (stubs.ts rewritten, same "stubs" rng family): the M3215+ wall-butt spurs are REPLACED by real 3-way junctions — a realized plain216+ `straight` placement at ground level is mutated into `split` (same cell, same217+ rotation: the split's main channel IS the straight; measured ports z−/z+/x−,218+ so the native branch goes LEFT of flow; right branches set `mirrored`). Branch219+ = optional straight-hole + end cap (end-hole-rounded/square or220+ end-rounded/square), 1–2 cells, flush. Skips tunnel-arched cells and the goal221+ straight. ALSO turn junctions: split-left is a drop-in for the curve MESH222+ (+branch port on the outer flank at mesh (1.0, 0.5), branch cell (1.5, 0.5));223+ split-large-left ditto for curve-large (branch cell (2, 1)); solid hosts swap224+ to split-solid-*. Left/right ids alternate 50/50 — the right GLB renders225+ mirrored to the same shape (s-curve convention). 36% of tracks.226+- Split geometry (measured): split-left/right = 2×2 Y (curve's r1.5 arc +227+ opposite-side branch exit); split-large = 3×3 curve-large analog; split-double228+ = 3×2 center entry → two +z exits at x±1; split-double-sides = 3×2 center229+ entry → exits BOTH x-sides (NO through port — unusable on the ring, exempt).230+- **Split–merge diamonds** (`diamond-1/2/3` composite reserved defs + feature231+ planner): split-double + N branch straights per side + a 180°-rotated232+ split-double merge, one composite window (moves F^(4+N), footprint 3 columns).233+ PieceDef.altCenterline carries the second branch; LoopSegment.alt flows to the234+ client; buildCenterline fits TWO CatmullRom variants and the marble swaps at235+ lap wrap (verified: laps cross at z±1 alternately). Branch jogs are236+ z-linear-third cubics (lane-shift law). Planner: exhaustive (N, offset) search237+ per shuffled run, both flanks must be free; ~2% of tracks (rare set-piece;238+ boulevard hosts more).239+- **Fans as track elements** (fans.ts, rng family "fans", 30% of tracks): 1–2240+ fans flush beside ground straights, blades facing the channel (all six fan241+ GLBs), spin + `Placement.boost` (6 u/s²); the client marble accelerates while242+ within 1.2 units (POSITION-based, so diamond branches both work). Verified:243+ speed 2.85→4.53 passing a fan. Fans removed from the decor table.244+- **Ramp b/d pair tiers** authored (ramp-start/end-b: 0.25 each — mid-base 0.25245+ is fine, only delta-window starts sit there; ramp-start/end-d: 0.75 each = the246+ chute cap, DESCENT_VOCAB["1.5"] only).247+- **Track handedness**: the seed rectangle ring is REVERSED 50/50 — without it248+ every loop is turn-left-dominant and left-handed helix ensembles never fit249+ (their R anchors only arose in cluttered bump detours). This symmetrized all250+ chirality coverage.251+- **Corner-drop planner restructured** (elevation.ts): the corner set-piece252+ window is picked BEFORE hills grab straight runs (large helix windows straddle253+ several runs and were starved — helix-large-half never appeared). One254+ length²-weighted corner descent per track (affinity 0.5) + straight or spiral255+ climb; hills use the remaining runs; ring order is free (baseY normalized to256+ min 0 after integration — the old climb-before-descent invariant is obsolete).257+- **Full-helix site enumeration** (topology.ts): all valid (anchor, spec) sites258+ enumerated, pick weighted ×3 large, ×3 minority chirality.259+- **Coverage dashboard** (tools/coverage.ts,260+ `deno run --allow-read261+ tools/coverage.ts [N]`): 20k seeds → **122 present,262+ 40 exempt, 0 missing**. Exemption categories (curated in the tool): curve-wide263+ family (off-grid), marble variants, tier-a verticals (0.125 off-grid),264+ slant-c/d (steeper than caps), top-double cradles (0.4 saddle pitch matches265+ nothing), split-double-sides (no through port), straight-wide-hole (hole on266+ the marble's path), solids of always-elevated pieces (solids are ground-only267+ by user rule). bend-solid-large / s-curve-*-large flicker at ~1–3/10k (present268+ at 20k).269+- **Funnel drops: deliberately SKIPPED** — the kit has no bowl/cone piece;270+ "funnel" is the width taper (covered by wide runs).271+- M4 final state: 51 tests green, 1500-seed strict fuzz (decor on) clean,272+ goldens regenerated. genVersion after M4: gc6f70b6760f9.273+274+## M5 systems (complete) — endless mode is a VOID LINE with ISLAND ANCHORS275+276+USER REDESIGN (this round, supersedes the old "chunk streaming" sketch below in277+the historical handoff): endless generation is NOT a tile system and has NO278+ground plane. The old plan (disjoint chunk footprints on a ground plane) was279+rejected explicitly: "infinite generation should not be independent footprints…280+it is not a tile system." Design conversation settled on:281+282+- **The world is a void** (space, starfield, no ground). Removing the ground283+ decoupled elevation entirely — the line dives forever (net-down drift, the284+ honest physics for a marble) and weaves over/under/around its own past.285+- **Islands are anchors, not scenery**: "An island is a thing a track has to286+ anchor to. When the island is no longer required as an anchor it can be287+ culled." The path planner owes the line a landing every `anchorEvery` cells;288+ the island builder erects a cliff-block voxel mass UNDER the landed run.289+- **Island families are the new settings layer**: biome table (forest / meadow /290+ rocky / barren today; lava/snow/city are future rows pointing at future291+ packs). Decor demoted to a per-island field on the island top (same M2a292+ region+oracle API, run cells = "the track", lifted to topY).293+- **Cross + graze only**: the line may bridge over, pierce under, and run flush294+ beside earlier track, but never merges into an old channel.295+- Destructible decor: dormant (nothing can collide with decor in the void);296+ revisit when something can.297+298+### Architecture (src/gen/endless/)299+300+- **occupancy.ts**: cell (x,z) → occupied y-bands `{y0, y1, ref}`. A channel301+ claims [baseY, baseY+0.99] (walls + marble headroom — stacked channels at the302+ kit's Δ1.0 just clear). Refs (`seg/${i}`, `isle/${k}`) release wholesale on303+ cull. `OccupancyTx` = write overlay for transactional planning (stackable —304+ probes run on a second layer).305+- **path.ts**: clause-based planner. Void clauses: straight runs, turns, FRL/FLR306+ jogs (s-curve food), slope windows straight from elevation.ts's307+ CLIMB/DESCENT_VOCAB (exported now), corner drops from TURN_DESCENTS (helix308+ quarters/halves, bare-corner drop-stacks). Island clause: flat run 5–11 cells309+ (occasional bends), mass envelope + CANOPY claims immediately. Elevation310+ mirrors elevation.ts exactly: a window's whole delta steps at its start index.311+ 18% of slopes are climbs; the rest descend — that's the drift.312+- **Pocket guard**: every clause commit requires a 3-deep flat continuation from313+ its exit (claim-aware, incl. the pending clause's own cells). `probe()` also314+ checks self-intersection within a single clause (a turny island run can loop315+ onto itself before anything commits).316+- **world.ts**: `EndlessWorld.get(i)` generates strictly in order. Per segment,317+ ONE TRANSACTION wraps: plan → realize (open WFC) → claim WFC footprint extras318+ → deep exit checks. Attempt labels: plain, retry/1..5, then PROBE_LABELS.319+ **Successor-probe lookahead**: an attempt only commits if segment i+1 actually320+ PLANS under ≥1 of 4 fixed probe labels on a stacked tx — no fixed-depth321+ corridor check can rule out every cul-de-sac, but "the successor plans" can;322+ the successor replays those exact labels as its last-resort attempts. Result:323+ 0 failures / 400 seeds × 80 segments (~1.5ms/segment). Earlier iterations for324+ the record: naive → snake-traps itself; +guard → post-planning claims (WFC325+ extras, canopy) broke guard promises; +tx retries → trapped ENTRY poses326+ unfixable; +single-probe gate → WORSE (a no-retry gate rejects good attempts,327+ failures compound); +probe SET → clean.328+- **islands.ts**: biome table (weights, cliff material rock/stone, cap tile,329+ setting row, decor density). Mass = cliff_block/blockHalf/blockQuarter columns330+ (1×1×1 / 0.5 / 0.25 — the nature kit cliff family is EXACTLY our grid,331+ bottom-center origins), run columns always, ring columns thin+shallow with332+ Chebyshev distance (stepped inverted-ziggurat underside). Depth stops at older333+ claims → islands wrap around old track (a line under an island = recessed334+ floor). Caps (ground_grass) + placeDecor filtered to mass tops. Vegetation335+ only on cells whose CANOPY airspace claim ([topY, topY+1.8]) succeeded — no336+ tree ever threads a future channel; approach/departure corridors are337+ canopy-exempt (a natural shoreline clearing).338+- **Culling is generation-driven** (KEEP_SEGMENTS=10): generating segment i339+ releases segment i−10 and its islands. NEVER client/marble-driven — that would340+ make the stream depend on frame timing. The client render window341+ (BEHIND=2..AHEAD=3) sits strictly inside, so everything visible is co-live in342+ the occupancy and nothing on screen can overlap. The line MAY reoccupy343+ long-culled space (legal: that geometry is despawned far behind the render344+ window; the void heals).345+- **wfc.ts/assemble.ts open mode** (opt-in `RealizeOptions`/ `AssembleOptions`,346+ closed path byte-identical — genVersion unchanged): no window wrap, no seam347+ width check, `extraFree` world-occupancy veto for footprint extras; assemble348+ skips supports/goal banner/solids (ground concepts). Style: same "style"349+ family per seed — one theme both modes.350+351+### Client (src/client/endless.ts)352+353+- `?endless=1` (+ `#seed`), HUD `#mode-toggle` navigates between modes.354+- Session per seed: segments built into per-index groups (track + island355+ placements together — islands cull with their birth segment), open CatmullRom356+ per segment (`buildOpenCurve`), global arc length chained via per-index357+ `sMeta`. Marble = same physics constants as loop mode, global s, no laps.358+ Camera: `retargetPath(sampler, proxy)` follow mode (loop mode's closed-curve359+ follow untouched); follow is the default in endless.360+- Void dressing: bg/fog 0x070b16, camera-pinned starfield Points361+ (sizeAttenuation off, fog off). No ground. `instantiatePlacements` was362+ extracted from track.ts and shared.363+- CUA state: `mode: "endless"`, marble {s, speed, pos, segment}, stream {built,364+ generated, islandsSeen, occupancyCells, theme}.365+366+### Verified (CUA harness)367+368+- Spawn on home island, grass-capped cliff mass with trees, line dives into the369+ void, islands stream past, HUD stats update; loop mode regression clean370+ (`#loop-check`: 346 pieces, 0 missing).371+- 6-sim-minute browser soak (scratch `soak.mjs`: one `advanceTime(60000)` call372+ per minute — the per-frame harness loop is too slow for soaks): 914u, 31373+ segments, 31 islands, render window slid [3,8]→[29,34], occupancy bounded374+ 625–847 cells, depth −61, ZERO console errors. Tunnel arches emerge in void375+ flight (free WFC), the marble dives through them.376+- Deep generator fuzz: 0 failures / 400 seeds × 80 segments, ~1.5ms/segment.377+378+## Hole semantics (user design, post-M4)379+380+USER RULE: hole pieces represent the POTENTIAL to fall through — guaranteed for381+end-hole, "same as a split" for straight-hole — and a fall must land ON TRACK382+BELOW (a WFC-level requirement, satisfied by construction: holes are only383+realized inside compositions that carry their own lower track). Plain `end`384+pieces are supported so the channel network may be NON-CONNECTED — the lap385+closes through the fall.386+387+- **Drop-stacks** (`drop-stack-{rounded,square}-{f,l,r}` defs): an end-hole388+ stacked FLUSH on a receiver `end` piece (0.5 grid, nothing floats). One cell,389+ deckDelta −0.5; the CENTERLINE ITSELF dives vertically through the hole into390+ the cup and exits the receiver's port (overlay rot 2/1/3 for F/L/R) — no391+ client mechanic needed, the gravity model makes the marble plunge (verified:392+ speed spike, clean landing). Placed by DESCENT_VOCAB["0.5"] (SlopePart.asIs —393+ the FORWARD def descends, don't reverse it) and by TURN_DESCENTS "L"/"R" (bare394+ corners; no TURN_CLIMBS form — guarded, you can't fall upward). ~24% of395+ tracks.396+- **Bridge pierces**: pierce with d ≥ 3 upgrades to a bridge (chance 0.7): the397+ hairpin climbs +1.0 on its columns (elevation reserves climb at indices [1,398+ d−1], descent at [2k−d+2, 2k] — the pierce splice lands at ring index 0, and399+ the NEAR ANCHOR MUST BE A GENUINE TURN or seam normalization rotates the400+ indices; approach-along-normal anchors are rejected — this was a real401+ 1%-of-seeds loop-gap bug), crosses OVER the run, descends. Upper deck =402+ `straight-hole` (60% per crossing, rng family "falls") or plain straight;403+ lower visit reserved as plain straight (keeps waves/tunnels from growing under404+ the deck). Track.falls carries {pos, landPos} per hole (same (x,z), Δy 1.0).405+- **Client chance-fall** (marble.ts): falls mapped to arc positions per curve406+ variant (dense closest-point sampling; y separates the two lines);407+ deterministic per (lap, hole) hash takes the fall ~50% of passes — an eased408+ 0.35s vertical drop, then s jumps to the landing and the marble continues on409+ the lower channel (verified in telemetry and screenshots).410+- Guards: no support pillars and no goal banner at twice-visited cells (the411+ ground belongs to the lower channel); stubs/fans skip stacked cells.412+- Emergence (1500 seeds): bridges 1.5%, flat crossings 0.9%, drop-stacks 24%.413+ Coverage: straight-wide-hole stays exempt (no wide line ever has track below —414+ a wide bridge is a future macro); end-rounded/end-square now ALSO appear as415+ drop-stack receivers.416+- NEXT (not built): takeable spur branches (split siding → end-hole = guaranteed417+ drain onto a lower line where alignment exists) — needs branch-taking beyond418+ diamonds; noted for M5/M6.419+- Final state: 51 tests green, 1200-seed strict fuzz (decor on) clean, goldens420+ regenerated. genVersion: ga265f1373eed.421+422+## Emergence snapshot (current, post-hole-semantics)423+424+Registry: 119 variants. Per-track presence: helixes ~50% (quarters dominate;425+halves/larges rare set-pieces), drop-stacks 24%, split sidings 36%, boost fans426+30%, tunnel runs 50%, wide runs ~14%, diamonds ~2%, bridges 1.5%, flat crossings427+~1%, full helixes ~5%. bend 95%, s-curve-short 92% (M2-era, still428+representative). Kit coverage: 122 present / 40 exempt / 0 missing @ 20k429+(tools/coverage.ts).22430 23431 ## Notes / gotchas24432 25-- Test client script: `$CODEX_HOME`-relative paths don't exist here; actual skill lives at `/Users/dami/.claude/skills/develop-web-game/`. Playwright resolved from npx cache via symlinked node_modules in the scratchpad `cua/` run dir (script copied there as `client.mjs`, Node 26 auto-ESM).26-- WebGL works in headless chromium with the script's swiftshader flags.27-- `render_game_to_text` rounds floats to 2dp to keep payloads small.28-29-## Results (all pass, 0 console errors across 7 runs)30-31-| # | scenario | evidence |32-| --- | --- | --- |33-| 1 | idle @ #alpha ×3 | marble s: 2.76 → 5.52 → 7.74 (loop 20.6u), speed at 2.2 base |34-| 2 | click #cam-mode | camMode "follow", marble still advancing (s 3.5 → 5.81) |35-| 3 | click #new-track | seed alpha → 6hqofws6y9, hash updated, new track (66 → 85 pieces) |36-| 4 | new-track, then key b | seed back to "alpha", regenerated deterministically |37-| 5 | new-track, b, then key a | seed forward to minted 2pnk545e7k (sweeping/rocky, maxY 0.5) |38-| 6 | ?gallery=1 | mode gallery, 162 marble + 329 nature pieces, 0 missing |39-| 7 | ?debug=1#alpha | renders + marble advances, no errors |40-41-Artifacts: scratchpad `cua/out/{idle,cam,newtrack,back,fwd,gallery,debug}/` — shot-N.png,42-state-N.json per iteration; no errors-N.json anywhere. Screenshots visually inspected43-(follow cam behind channel; fwd run shows distinct rocky-theme world).44-45-## Visual-bug round (user reports, all fixed + verified)46-47-- **Tunnel is an arch SHELL (top half only)**, not standalone track — Kenney intends it48- seated on a straight with feet on the wall tops (see Sample.png). Registry now models49- it as `meshId: "straight"` + overlay `{id: "tunnel", offset: [0, 0.5, 0]}`. Also50- removed the 0.05 marble dip (the "floor 0.2" was the shell lip, not a channel).51-- **Fan blades**: GLTFLoader UNIQUIFIES duplicate node names — six pieces each have a52- child authored "fan" → loads as fan, fan_1, fan_2… Match `/^fan(_\d+)?$/`, spin only53- that child, never the housing.54-- **Supports under turns**: cradle tops only fit straight channel; turn-cell pillars are55- plain columns snapped to the nearest centerline point (so they always touch deck) via56- `nearestOnCurve` in assemble.ts. Straight cells keep cradle stacks at cell centers.57-- Climbs capped at 0.5 rise/cell (CLIMB_VOCAB); steep d-tier chutes are descent-only.58-- Goldens regenerated multiple times this round (pre-release; documented as breaking).59-60-## TODOs / suggestions for next agent61-62-- M2 coverage: bends (U-turns!), s-curves (short tiers jog 0.25/0.5/1.0 — only c grid-aligned),63- wide pieces + funnel transitions, waves/bumps with profiled centerlines.64-- Marble physics feel: speed is slope-modulated but constant-ish; consider per-piece friction.65-- CUA harness: `advanceTime` steps hooks on top of the live rAF loop (fine for monotonic66- checks; pause the real loop during stepping if exact frame counts ever matter).67-- Repo is still not `git init`-ed.433+- Reversed-def dedup in buildRegistry must compare widthIn/widthOut too, or the434+ reversed (wide→narrow) funnel silently vanishes.435+- Window cells need NOT all touch the channel — bend-large/s-curve-large use436+ rectangular dog-leg windows whose corner cells are only wall-clipped; the ring437+ path is an abstraction, the marble follows the centerline.438+- Adjacent-parallel channels (hairpin columns) are physically fine: walls reach439+ ±0.35, gap 0.3. The topology clearance rule is relaxed ONLY for a mutation's440+ own column pair.441+- Goldens (tests/golden_tracks.json) regenerated this round — still442+ pre-release/breaking-allowed. M6 freezes them.443+- Elevated diamonds (a diamond on a plateau between climb and descent) get444+ support columns only along the LEFT branch centerline (assemble follows the445+ window's main centerline); the right branch and slab edges are unsupported —446+ rare compound case, fix with per-branch column candidates (M6 polish).447+- Twice-visited cells store ONE baseY in the WFC occupancy map (last write wins)448+ — plate-wing/solid-turn equality checks near crossings/bridges are449+ cosmetic-only approximations.450+- Reservation planners must not overlap: features exclude crossing indices,451+ helix-loop tracks skip wide runs entirely, and turn windows are pre-claimed452+ before hills. Adding a new planner? Thread its cells through the same453+ blocking, or WFC will double-cover a ring index and tear the loop.454+- MEASUREMENT SCRIPTS live in tools/ now:455+ `deno run --allow-read456+ tools/measure_ports.ts <piece-ids…>` (per-bbox-face457+ port signatures: floor marks at lat ±0.1 y=+0.25, walls ±0.32, slab edges) and458+ `tools/measure_floor_path.ts <piece> [cx cz]` (upward-facing floor ribbon with459+ polar coords — how the helix spirals were decoded). Measure before authoring;460+ this project never guesses geometry.461+- CUA harness recipe (rebuilt this round): `npm install playwright` in a scratch462+ dir, copy the develop-web-game skill's client script next to it,463+ `deno task build`, serve on a port, then464+ `node client.mjs --url http://localhost:PORT/#seed --click-selector465+ "#cam-mode" --actions-json '{"steps":[{"buttons":[],"frames":40}]}'466+ --iterations N`467+ — screenshots + render_game_to_text land in output/web-game/. Follow-cam468+ sweeps + state JSON (marble pos/speed per iteration) are how falls, boosts,469+ and lap alternation were verified.470+471+## HANDOFF PROMPT — next agent: M5 polish + M6472+473+You are continuing the marbles generator. M1–M4, hole semantics, and M5 (endless474+mode — the void line with island anchors, REDESIGNED with the user this round;475+read the M5 section carefully, the old chunk-tile sketch is dead) are complete.476+Before anything: `deno task test` (57 green), `genVersion()` (ga265f1373eed —477+unchanged by M5, the finite pipeline is byte-identical), and skim "M5 systems".478+479+### M5 polish backlog (endless mode is playable but young)480+481+- **Void-flight variety**: full-helix ensembles as spiral dives (the defs482+ revisit their own window cells — the open-path planner needs a483+ duplicate-cell-tolerant clause), tunnel runs on islands, stubs/fans on island484+ runs (they need "ground" — an island top IS local ground), wide runs on long485+ island runs, solids on islands (style.solidAt with island context).486+- **Island shaping**: slope/diagonal shoulder trim (cliff_blockSlope*,487+ cliff_topDiagonal), cave mouths where old track pierces a mass488+ (cliff_blockCave_*), waterfall pieces off island edges, statues/tents/ bridges489+ as island landmarks, biome-tinted cliff materials.490+- **Performance**: InstancedMesh for island blocks + decor (each island is491+ 50–250 clones today; fine at the current window, wasteful). Segment builds are492+ ~5–15ms on the main thread — consider building one frame early or in idle time493+ if hitches show.494+- **Marble feel in the void**: drop-stack falls work (centerline dives); long495+ steep chutes can pin speed at MAX — maybe raise MAX_SPEED for endless or add496+ banking lean (M6 item) which matters more at speed.497+- **Endless extras**: distance milestones in the HUD, a "photo mode", per-biome498+ fog/lighting tint shifts, destructible decor revival (needs a collider — e.g.499+ a future powerup marble that jumps the channel).500+501+### M6 backlog (collected across rounds)502+503+Seed freeze (goldens become permanent — INCLUDING an endless-stream golden: pin504+segment hashes for fixed seeds); marble banking lean; checkered goal-gate505+texture; ambient audio; curve-wide family as an off-grid scenery macro; tee-spur506+macro for split-double-sides; takeable spur branches (split siding → end-hole507+drain onto a lower line); wide bridges; hill-interval constraint so508+flat-crossing tracks can have hills; per-branch support columns for elevated509+diamonds; distance-graded ambient decor (widen the oracle contract).510+511+### Standing design principles (endless edition)512+513+- RNG is label/position-derived, NEVER sequential-draw-dependent. Endless514+ families: `segment/${i}` (+ children `clause/${cellOffset}`, `wfc`,515+ retry/probe labels), `island/${k}`, per-cell decor children. A retry label is516+ deterministic BECAUSE the dead end it recovers from is deterministic.517+- The endless stream must remain a PURE FUNCTION OF THE SEED. Culling is518+ generation-driven (KEEP_SEGMENTS); never let client/frame timing decide when519+ occupancy is released. The client render window must stay strictly inside the520+ keep window.521+- All planning writes go through OccupancyTx transactions; nothing touches the522+ base map until a segment fully validates (plan + realize + extras + successor523+ probes). If you add a new post-planning claim source, it MUST happen inside524+ the transaction, before the exit checks — two bugs this round (WFC extras,525+ island canopy) came from claims landing after a guard's promise.526+- Composition honesty carries into the void: bands never overlap among co-live527+ segments, islands never intrude into channel bands, vegetation only where the528+ island owns the airspace, no supports/solids without ground.529+- The finite loop pipeline is shared machinery: open-mode behavior is opt-in530+ flags with the closed path byte-identical. Any change that moves531+ `genVersion()` regenerates goldens (documented-breaking until M6 freeze).532+533+### Working with the user (read this)534+535+- Debug reports come from INSPECT MODE (tilde key → click tiles → copy JSON536+ {seed, version, tiles[]}). Ask for that; do not interpret screenshots by eye —537+ it went wrong three times before the tool existed.538+- FIRST check the report's `version` against a fresh `genVersion()` — a mismatch539+ means their browser has a stale bundle (hard refresh), not a bug.540+- The version is COMPUTED from generator output (src/gen/version.ts); never add541+ manual version bumps. Goldens embed it as "__version".542+- Goldens (tests/golden_tracks.json) may be regenerated freely until the M6 seed543+ freeze — every behavior change does it: rm the file, run tests/track_test.ts544+ with --allow-write, commit the diff.545+- The user cares hard about tile composition honesty: nothing juts, nothing546+ floats, plates/solids only where connected. When adding pieces, check what the547+ mesh's base actually covers (measure, don't assume) and wire the same548+ contextual rules.549+- Marble feel (banking lean), checkered goal-gate texture, ambient audio: M6,550+ don't drift into them.
M src/client/camera.ts +31 -7
@@ -20,6 +20,15 @@ export interface Cameras {
2020 curve: THREE.Curve<THREE.Vector3> | null,2121 marble: Marble | null,2222 ): void;23+ /**24+ * Endless mode: follow via an arc-length sampler instead of one closed25+ * curve (the path grows without bound). `getPointAtS` writes the world26+ * point at global s into `out` and returns false outside the built window.27+ */28+ retargetPath(29+ getPointAtS: ((s: number, out: THREE.Vector3) => boolean) | null,30+ marble: Marble | null,31+ ): void;2332 /** Re-aim orbit at a new bounding box. */2433 frame(box: THREE.Box3): void;2534 onModeChange?: (mode: CamMode) => void;
@@ -49,6 +58,7 @@ export function createCameras(
4958 let mode: CamMode = "orbit";5059 let activeCurve = curve;5160 let activeMarble = marble;61+ let pathSampler: ((s: number, out: THREE.Vector3) => boolean) | null = null;5262 /** Last box passed to `frame`, replayed when we return to orbit. */5363 let framedBox: THREE.Box3 | null = null;5464
@@ -85,6 +95,14 @@ export function createCameras(
8595 retarget(nextCurve, nextMarble) {8696 activeCurve = nextCurve;8797 activeMarble = nextMarble;98+ pathSampler = null;99+ lookInit = false;100+ },101+102+ retargetPath(getPointAtS, nextMarble) {103+ pathSampler = getPointAtS;104+ activeCurve = null;105+ activeMarble = nextMarble;88106 lookInit = false;89107 },90108
@@ -115,13 +133,19 @@ export function createCameras(
115133 controls.update();116134 return;117135 }118- if (!activeCurve || !activeMarble) return;119- const total = activeCurve.getLength();120- if (total <= 0) return;121-122- const behind = ((activeMarble.s - TRAIL) % total + total) % total;123- activeCurve.getPointAt(behind / total, desired);124- desired.y += HEIGHT;136+ if (!activeMarble) return;137+ if (pathSampler) {138+ if (!pathSampler(activeMarble.s - TRAIL, desired)) return;139+ desired.y += HEIGHT;140+ } else if (activeCurve) {141+ const total = activeCurve.getLength();142+ if (total <= 0) return;143+ const behind = ((activeMarble.s - TRAIL) % total + total) % total;144+ activeCurve.getPointAt(behind / total, desired);145+ desired.y += HEIGHT;146+ } else {147+ return;148+ }125149 126150 lookTarget.copy(activeMarble.obj.position);127151 lookTarget.y += LOOK_UP;
M src/client/centerline.ts +30 -2
@@ -15,11 +15,13 @@ const EPS = 1e-4;
1515 1616 export interface Centerline {1717 curve: THREE.CatmullRomCurve3;18+ /** Second lap variant taking every diamond's OTHER branch (if any). */19+ altCurve?: THREE.CatmullRomCurve3;1820 points: THREE.Vector3[];1921 debugLine(): THREE.Line;2022 }2123 22-export function buildCenterline(loop: LoopPath): Centerline {24+function buildVariant(loop: LoopPath, useAlt: boolean): THREE.Vector3[] {2325 const points: THREE.Vector3[] = [];2426 2527 const push = (x: number, y: number, z: number) => {
@@ -32,7 +34,8 @@ export function buildCenterline(loop: LoopPath): Centerline {
3234 };3335 3436 for (const seg of loop.segments) {35- for (const part of seg.curve) {37+ const curve = useAlt && seg.alt ? seg.alt.curve : seg.curve;38+ for (const part of curve) {3639 const len = segLength(part);3740 const steps = Math.max(1, Math.ceil(len / SAMPLE_SPACING));3841 // i from 0 keeps joints exact; `push` drops the duplicate at each seam.
@@ -55,13 +58,38 @@ export function buildCenterline(loop: LoopPath): Centerline {
5558 points.push(points[points.length - 1].clone().addScalar(EPS));5659 }5760 }61+ return points;62+}5863 64+function fit(points: THREE.Vector3[]): THREE.CatmullRomCurve3 {5965 const curve = new THREE.CatmullRomCurve3(points, true, "centripetal", 0.5);6066 curve.arcLengthDivisions = points.length * 8;6167 curve.updateArcLengths();68+ return curve;69+}70+71+/**72+ * Open-path variant (endless mode): same dense sampling + centripetal73+ * Catmull-Rom refit, but the curve does not close — segment boundaries meet74+ * at straight ports, so chained per-segment curves stay visually C1.75+ */76+export function buildOpenCurve(path: LoopPath): THREE.CatmullRomCurve3 {77+ const points = buildVariant(path, false);78+ const curve = new THREE.CatmullRomCurve3(points, false, "centripetal", 0.5);79+ curve.arcLengthDivisions = points.length * 8;80+ curve.updateArcLengths();81+ return curve;82+}83+84+export function buildCenterline(loop: LoopPath): Centerline {85+ const points = buildVariant(loop, false);86+ const curve = fit(points);87+ const hasAlt = loop.segments.some((s) => s.alt);88+ const altCurve = hasAlt ? fit(buildVariant(loop, true)) : undefined;6289 6390 return {6491 curve,92+ altCurve,6593 points,6694 debugLine(): THREE.Line {6795 const geom = new THREE.BufferGeometry().setFromPoints([
M src/client/cua.ts +2 -2
A src/client/endless.ts +443 -0
@@ -0,0 +1,443 @@
1+/**2+ * Endless mode client: the void line, streamed.3+ *4+ * One EndlessWorld per seed generates segments ahead of the marble; this5+ * module builds their render groups (track pieces + island masses/decor in6+ * one group per segment), chains their open centerline curves into a global7+ * arc-length line, and disposes groups behind the marble. The render window8+ * (BEHIND..AHEAD) sits strictly inside the generator's KEEP_SEGMENTS, so9+ * everything visible is co-live in the occupancy — nothing on screen can10+ * overlap.11+ *12+ * Scene dressing: deep-space background + fog, a camera-following starfield.13+ * No ground plane — islands are the only land.14+ */15+import * as THREE from "three";16+import { fitShadowsTo, type SceneCtx } from "./scene.ts";17+import type { Assets } from "./assets.ts";18+import { initSeed, newSeed } from "./seed.ts";19+import { instantiatePlacements, type Spinner } from "./track.ts";20+import { buildOpenCurve } from "./centerline.ts";21+import { type Cameras, type CamMode, createCameras } from "./camera.ts";22+import { EndlessWorld, KEEP_SEGMENTS } from "../gen/endless/world.ts";23+import type { DecorCatalog } from "../gen/decor.ts";24+import { genVersion } from "../gen/version.ts";25+import { installCua } from "./cua.ts";26+27+/** Segments built ahead of / kept behind the marble's segment. */28+const AHEAD = 3;29+const BEHIND = 2;30+31+// Marble feel — same constants as the finite loop (marble.ts).32+const MARBLE_PIECE = "marble-center-high";33+const BASE_SPEED = 2.2;34+const MIN_SPEED = 1.2;35+const MAX_SPEED = 7;36+const GRAVITY = 6;37+const DRAG = 0.9;38+39+interface BuiltSegment {40+ index: number;41+ group: THREE.Group;42+ curve: THREE.CatmullRomCurve3;43+ startS: number;44+ length: number;45+ spinners: Spinner[];46+ islands: number;47+}48+49+interface UiRefs {50+ seedEl: HTMLElement | null;51+ statsEl: HTMLElement | null;52+ newTrackBtn: HTMLButtonElement | null;53+ camBtn: HTMLButtonElement | null;54+}55+56+export function bootEndless(57+ ctx: SceneCtx,58+ assets: Assets,59+ catalog: DecorCatalog,60+ ui: UiRefs,61+): void {62+ // The void: deep-space backdrop, tight fog, stars pinned to the camera.63+ const SPACE = 0x070b16;64+ ctx.scene.background = new THREE.Color(SPACE);65+ ctx.scene.fog = new THREE.Fog(SPACE, 32, 115);66+ const stars = makeStarfield();67+ ctx.scene.add(stars);68+ ctx.onFrame.add(() => {69+ stars.position.copy(ctx.camera.position);70+ });71+72+ const cams = createCameras(ctx, null, null);73+ const setCamLabel = (mode: CamMode) => {74+ if (ui.camBtn) {75+ ui.camBtn.textContent = mode === "orbit" ? "follow cam" : "orbit cam";76+ }77+ };78+ cams.onModeChange = setCamLabel;79+80+ let session: Session | null = null;81+82+ ctx.onFrame.add((dt) => {83+ session?.update(dt);84+ cams.update(dt);85+ });86+87+ initSeed((seed) => {88+ session?.dispose();89+ try {90+ session = new Session(seed, ctx, assets, catalog, cams, ui);91+ } catch (err) {92+ console.error(err);93+ if (ui.statsEl) {94+ ui.statsEl.textContent = `error: ${95+ err instanceof Error ? err.message : String(err)96+ }`;97+ }98+ return;99+ }100+ // Endless is a journey — follow by default.101+ cams.setMode("follow");102+ setCamLabel(cams.mode);103+ });104+105+ ui.newTrackBtn?.addEventListener("click", () => newSeed());106+ ui.camBtn?.addEventListener("click", () => cams.toggle());107+ ui.seedEl?.addEventListener("click", () => {108+ navigator.clipboard?.writeText(location.href).catch(() => {});109+ });110+111+ installCua(ctx, () => ({112+ mode: "endless",113+ seed: session?.seed ?? "",114+ version: genVersion(),115+ camMode: cams.mode,116+ marble: session117+ ? {118+ s: Number(session.marbleS.toFixed(2)),119+ speed: Number(session.speed.toFixed(2)),120+ pos: session.marbleObj.position.toArray().map((v: number) =>121+ Number(v.toFixed(2))122+ ),123+ segment: session.currentIndex,124+ }125+ : null,126+ stream: session127+ ? {128+ built: session.builtRange(),129+ generated: session.world.generatedCount,130+ islandsSeen: session.islandsSeen,131+ occupancyCells: session.world.occupancySize,132+ theme: session.world.style.theme,133+ }134+ : null,135+ }));136+}137+138+class Session {139+ readonly world: EndlessWorld;140+ readonly seed: string;141+ marbleS = 0;142+ speed = BASE_SPEED;143+ islandsSeen = 0;144+ currentIndex = 0;145+ readonly marbleObj: THREE.Group;146+147+ private readonly root = new THREE.Group();148+ private readonly built = new Map<number, BuiltSegment>();149+ /** Cumulative curve arc lengths, kept for every index ever built. */150+ private readonly sMeta = new Map<number, { start: number; len: number }>();151+ private readonly pivot: THREE.Group;152+ private radius = 0.2;153+ private statsClock = 0;154+155+ private readonly point = new THREE.Vector3();156+ private readonly tangent = new THREE.Vector3();157+ private readonly axis = new THREE.Vector3();158+ private readonly spin = new THREE.Quaternion();159+ private readonly up = new THREE.Vector3(0, 1, 0);160+161+ constructor(162+ seed: string,163+ private readonly ctx: SceneCtx,164+ private readonly assets: Assets,165+ catalog: DecorCatalog,166+ private readonly cams: Cameras,167+ private readonly ui: UiRefs,168+ ) {169+ this.seed = seed;170+ this.world = new EndlessWorld(seed, catalog);171+ assets.applyPalette(this.world.style.paletteIndex);172+ this.root.name = `endless:${seed}`;173+ ctx.scene.add(this.root);174+175+ // Marble (same grounded-mesh-in-pivot construction as the loop mode).176+ this.marbleObj = new THREE.Group();177+ this.marbleObj.name = "marble";178+ this.pivot = new THREE.Group();179+ this.marbleObj.add(this.pivot);180+ const mesh = assets.getPiece(MARBLE_PIECE);181+ if (mesh) {182+ const box = new THREE.Box3().setFromObject(mesh);183+ const center = box.getCenter(new THREE.Vector3());184+ const size = box.getSize(new THREE.Vector3());185+ this.radius = Math.max(size.x, size.y, size.z) / 2 || 0.2;186+ this.pivot.position.copy(center);187+ mesh.position.sub(center);188+ this.pivot.add(mesh);189+ }190+ ctx.scene.add(this.marbleObj);191+192+ this.ensure();193+ const first = this.built.get(0)!;194+ first.curve.getPointAt(0, this.point);195+ this.marbleObj.position.copy(this.point);196+197+ this.cameraProxy = {198+ obj: this.marbleObj,199+ s: this.marbleS,200+ speed: this.speed,201+ radius: this.radius,202+ update: () => {},203+ };204+ this.cams.retargetPath(205+ (s, out) => this.samplePoint(s, out),206+ this.cameraProxy,207+ );208+ if (this.ui.seedEl) this.ui.seedEl.textContent = seed;209+ this.refreshStats();210+ }211+212+ builtRange(): [number, number] {213+ const keys = [...this.built.keys()];214+ return [Math.min(...keys), Math.max(...keys)];215+ }216+217+ /** World point at global arc length s (clamped to the built window). */218+ samplePoint(s: number, out: THREE.Vector3): boolean {219+ const seg = this.segmentAt(s);220+ if (!seg) return false;221+ const u = Math.min(222+ 1,223+ Math.max(0, (s - seg.startS) / seg.length),224+ );225+ seg.curve.getPointAt(u, out);226+ return true;227+ }228+229+ private segmentAt(s: number): BuiltSegment | null {230+ let best: BuiltSegment | null = null;231+ for (const seg of this.built.values()) {232+ if (s >= seg.startS - 1e-9 && s <= seg.startS + seg.length + 1e-9) {233+ return seg;234+ }235+ // Clamp: remember the nearest built segment for out-of-window asks.236+ if (!best || Math.abs(s - seg.startS) < Math.abs(s - best.startS)) {237+ best = seg;238+ }239+ }240+ return best;241+ }242+243+ private build(i: number): BuiltSegment {244+ const data = this.world.get(i);245+ const group = new THREE.Group();246+ group.name = `segment:${i}`;247+ const spinners: Spinner[] = [];248+ const missing = new Set<string>();249+ instantiatePlacements(250+ data.placements,251+ this.assets,252+ group,253+ spinners,254+ missing,255+ );256+ for (const isle of data.islands) {257+ instantiatePlacements(258+ isle.placements,259+ this.assets,260+ group,261+ spinners,262+ missing,263+ );264+ }265+ for (const id of missing) {266+ console.warn(`[endless] unknown pieceId "${id}" — skipped`);267+ }268+ this.root.add(group);269+270+ const curve = buildOpenCurve(data.path);271+ const length = curve.getLength();272+ const prev = this.sMeta.get(i - 1);273+ const startS = i === 0 ? 0 : (prev ? prev.start + prev.len : 0);274+ if (i > 0 && !prev) {275+ throw new Error(`segment ${i} built before its predecessor`);276+ }277+ this.sMeta.set(i, { start: startS, len: length });278+279+ const seg: BuiltSegment = {280+ index: i,281+ group,282+ curve,283+ startS,284+ length,285+ spinners,286+ islands: data.islands.length,287+ };288+ this.built.set(i, seg);289+ this.islandsSeen += data.islands.length;290+ return seg;291+ }292+293+ private disposeSegment(seg: BuiltSegment): void {294+ this.root.remove(seg.group);295+ // Piece clones share kit resources; nothing per-segment to dispose.296+ seg.group.clear();297+ this.built.delete(seg.index);298+ }299+300+ /** Keep the render window [cur−BEHIND, cur+AHEAD] built. */301+ private ensure(): void {302+ const cur = this.currentIndex;303+ const lo = Math.max(0, cur - BEHIND);304+ const hi = cur + AHEAD;305+ if (hi - lo + 2 > KEEP_SEGMENTS) {306+ throw new Error("render window exceeds generator keep window");307+ }308+ let added = false;309+ for (let i = lo; i <= hi; i++) {310+ if (!this.built.has(i)) {311+ this.build(i);312+ added = true;313+ }314+ }315+ for (const seg of [...this.built.values()]) {316+ if (seg.index < lo || seg.index > hi) this.disposeSegment(seg);317+ }318+ if (added) {319+ const box = new THREE.Box3().setFromObject(this.root);320+ if (!box.isEmpty()) {321+ fitShadowsTo(this.ctx.sun, box);322+ this.cams.frame(box);323+ }324+ }325+ }326+327+ update(dt: number): void {328+ if (dt <= 0) return;329+ const seg = this.segmentAt(this.marbleS);330+ if (!seg) return;331+332+ const u = Math.min(333+ 1,334+ Math.max(0, (this.marbleS - seg.startS) / seg.length),335+ );336+ seg.curve.getTangentAt(u, this.tangent);337+338+ // Gravity-flavoured speed, same law as the loop marble. The void line339+ // trends downhill, so the marble keeps pace without fans.340+ this.speed +=341+ (-this.tangent.y * GRAVITY - DRAG * (this.speed - BASE_SPEED)) *342+ dt;343+ this.speed = Math.min(MAX_SPEED, Math.max(MIN_SPEED, this.speed));344+ this.marbleS += this.speed * dt;345+346+ const next = this.segmentAt(this.marbleS);347+ if (next && next.index !== this.currentIndex) {348+ this.currentIndex = next.index;349+ }350+ this.ensure();351+352+ if (!this.samplePoint(this.marbleS, this.point)) return;353+ this.marbleObj.position.copy(this.point);354+355+ // Roll about the horizontal axis perpendicular to travel.356+ this.axis.copy(this.up).cross(this.tangent);357+ if (this.axis.lengthSq() > 1e-8) {358+ this.axis.normalize();359+ this.spin.setFromAxisAngle(this.axis, (this.speed * dt) / this.radius);360+ this.pivot.quaternion.premultiply(this.spin);361+ }362+363+ for (const built of this.built.values()) {364+ for (const sp of built.spinners) {365+ sp.obj.rotateOnAxis(sp.axis, sp.radPerSec * dt);366+ }367+ }368+369+ // The follow camera reads marble.s off the proxy we handed it.370+ this.syncCameraProxy();371+372+ this.statsClock += dt;373+ if (this.statsClock > 0.5) {374+ this.statsClock = 0;375+ this.refreshStats();376+ }377+ }378+379+ private cameraProxy:380+ | {381+ obj: THREE.Object3D;382+ s: number;383+ speed: number;384+ radius: number;385+ update: (dt: number) => void;386+ }387+ | null = null;388+389+ private syncCameraProxy(): void {390+ if (!this.cameraProxy) return;391+ this.cameraProxy.s = this.marbleS;392+ this.cameraProxy.speed = this.speed;393+ }394+395+ dispose(): void {396+ for (const seg of [...this.built.values()]) this.disposeSegment(seg);397+ this.ctx.scene.remove(this.root);398+ this.ctx.scene.remove(this.marbleObj);399+ this.cams.retargetPath(null, null);400+ }401+402+ private refreshStats(): void {403+ if (!this.ui.statsEl) return;404+ const y = this.marbleObj.position.y;405+ this.ui.statsEl.textContent = `endless • ${this.world.style.theme} • ` +406+ `${this.marbleS.toFixed(0)}u travelled • depth ${y.toFixed(1)} • ` +407+ `${this.islandsSeen} islands • seg ${this.currentIndex} • ` +408+ `${genVersion()}`;409+ }410+}411+412+function makeStarfield(): THREE.Points {413+ const N = 1600;414+ const positions = new Float32Array(N * 3);415+ for (let i = 0; i < N; i++) {416+ // Uniform directions, radii pushed outward so stars sit past the fog.417+ const v = new THREE.Vector3(418+ Math.random() * 2 - 1,419+ Math.random() * 2 - 1,420+ Math.random() * 2 - 1,421+ );422+ if (v.lengthSq() < 1e-4) v.set(0, 1, 0);423+ v.normalize().multiplyScalar(130 + Math.random() * 160);424+ positions[i * 3] = v.x;425+ positions[i * 3 + 1] = v.y;426+ positions[i * 3 + 2] = v.z;427+ }428+ const geom = new THREE.BufferGeometry();429+ geom.setAttribute("position", new THREE.BufferAttribute(positions, 3));430+ const mat = new THREE.PointsMaterial({431+ color: 0xc7d5ff,432+ size: 1.6,433+ sizeAttenuation: false,434+ transparent: true,435+ opacity: 0.85,436+ depthWrite: false,437+ fog: false,438+ });439+ const points = new THREE.Points(geom, mat);440+ points.name = "starfield";441+ points.renderOrder = -1;442+ return points;443+}
A src/client/inspect.ts +214 -0
@@ -0,0 +1,214 @@
1+/**2+ * Inspect mode: tile-level debugging. Toggle with the tilde/backquote key;3+ * click placements to select them (raycast, drag-suppressed so orbiting4+ * still works); copy a portable JSON report of5+ * "these tiles on this seed on this version".6+ */7+import * as THREE from "three";8+import type { SceneCtx } from "./scene.ts";9+import type { Placement, Track } from "../shared/types.ts";10+import { genVersion } from "../gen/version.ts";11+12+export interface InspectSource {13+ seed: string;14+ track: Track | null;15+ /** Scene group whose children carry userData.placementIndex. */16+ group: THREE.Group | null;17+}18+19+export interface Inspector {20+ /** Drop all selections/highlights (called on regenerate). */21+ reset(): void;22+ readonly active: boolean;23+ /** Selected placement indices, for CUA state dumps. */24+ selection(): number[];25+}26+27+export function installInspect(28+ ctx: SceneCtx,29+ source: () => InspectSource,30+): Inspector {31+ let active = false;32+ const selected = new Map<number, THREE.Box3Helper>();33+34+ // --- DOM ------------------------------------------------------------------35+ const panel = document.createElement("div");36+ panel.id = "inspect";37+ panel.style.cssText =38+ "position:fixed;top:12px;right:12px;z-index:20;display:none;" +39+ "flex-direction:column;gap:8px;align-items:flex-end;" +40+ "font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:#dce6f5;";41+ const buttons = document.createElement("div");42+ buttons.style.cssText = "display:flex;gap:8px;";43+ const mkBtn = (label: string, onClick: () => void): HTMLButtonElement => {44+ const b = document.createElement("button");45+ b.textContent = label;46+ b.style.cssText =47+ "background:rgba(14,20,32,.72);color:#dce6f5;cursor:pointer;" +48+ "border:1px solid rgba(220,230,245,.25);border-radius:6px;" +49+ "padding:6px 10px;font:inherit;backdrop-filter:blur(6px);";50+ b.addEventListener("click", onClick);51+ buttons.appendChild(b);52+ return b;53+ };54+ const list = document.createElement("div");55+ list.style.cssText =56+ "background:rgba(14,20,32,.72);border:1px solid rgba(220,230,245,.25);" +57+ "border-radius:6px;padding:8px 10px;white-space:pre;text-align:left;" +58+ "max-height:50vh;overflow:auto;backdrop-filter:blur(6px);";59+ const copyBtn = mkBtn("copy debug info", () => copyReport());60+ mkBtn("clear selection", () => clearSelection());61+ mkBtn("exit mode", () => setActive(false));62+ panel.appendChild(buttons);63+ panel.appendChild(list);64+ document.body.appendChild(panel);65+66+ function describe(index: number, p: Placement): string {67+ const cell = `${Math.round(p.pos[0])},${Math.round(p.pos[2])}`;68+ return `#${index} ${p.pieceId} @ (${cell}) y${p.pos[1]} r${p.rotation}` +69+ `${p.mirrored ? " mirrored" : ""} [${p.role}]`;70+ }71+72+ function renderList(): void {73+ const { seed } = source();74+ const lines = [...selected.keys()].sort((a, b) => a - b).map((i) => {75+ const p = source().track?.placements[i];76+ return p ? describe(i, p) : `#${i} ?`;77+ });78+ list.textContent = `inspect • ${seed} • ${genVersion()}\n` +79+ (lines.length ? lines.join("\n") : "click tiles to select");80+ }81+82+ function report(): string {83+ const { seed, track } = source();84+ return JSON.stringify(85+ {86+ seed,87+ version: genVersion(),88+ url: location.href,89+ tiles: [...selected.keys()].sort((a, b) => a - b).map((i) => {90+ const p = track?.placements[i];91+ return p92+ ? {93+ i,94+ piece: p.pieceId,95+ cell: [Math.round(p.pos[0]), Math.round(p.pos[2])],96+ pos: p.pos.map((v) => Number(v.toFixed(2))),97+ rot: p.rotation,98+ ...(p.mirrored ? { mirrored: true } : {}),99+ role: p.role,100+ }101+ : { i };102+ }),103+ },104+ null,105+ 1,106+ );107+ }108+109+ function copyReport(): void {110+ const text = report();111+ console.log("[inspect]", text);112+ const showInline = () => {113+ // Clipboard unavailable — render the JSON in the panel (user-select:114+ // all) so it can be copied by hand.115+ list.textContent = text;116+ (list.style as CSSStyleDeclaration).userSelect = "all";117+ copyBtn.textContent = "select & copy below";118+ setTimeout(() => (copyBtn.textContent = "copy debug info"), 2000);119+ };120+ if (!navigator.clipboard) {121+ showInline();122+ return;123+ }124+ navigator.clipboard.writeText(text).then(() => {125+ copyBtn.textContent = "copied!";126+ setTimeout(() => (copyBtn.textContent = "copy debug info"), 1200);127+ }).catch(showInline);128+ }129+130+ // --- selection ------------------------------------------------------------131+ function drop(index: number, helper: THREE.Box3Helper): void {132+ ctx.scene.remove(helper);133+ helper.geometry.dispose();134+ (helper.material as THREE.Material).dispose();135+ selected.delete(index);136+ }137+138+ function clearSelection(): void {139+ for (const [i, helper] of [...selected]) drop(i, helper);140+ renderList();141+ }142+143+ function toggle(index: number, obj: THREE.Object3D): void {144+ const cur = selected.get(index);145+ if (cur) {146+ drop(index, cur);147+ } else {148+ const box = new THREE.Box3().setFromObject(obj);149+ const helper = new THREE.Box3Helper(box, new THREE.Color(0xff4757));150+ helper.name = `inspect:${index}`;151+ ctx.scene.add(helper);152+ selected.set(index, helper);153+ }154+ renderList();155+ }156+157+ // --- picking (drag-suppressed click) ---------------------------------------158+ const ray = new THREE.Raycaster();159+ const ndc = new THREE.Vector2();160+ let downAt: [number, number] | null = null;161+162+ ctx.renderer.domElement.addEventListener("pointerdown", (e: PointerEvent) => {163+ if (active) downAt = [e.clientX, e.clientY];164+ });165+ ctx.renderer.domElement.addEventListener("pointerup", (e: PointerEvent) => {166+ if (!active || !downAt) return;167+ const moved = Math.hypot(e.clientX - downAt[0], e.clientY - downAt[1]);168+ downAt = null;169+ if (moved > 5) return; // orbit drag, not a pick170+171+ const rect = ctx.renderer.domElement.getBoundingClientRect();172+ ndc.set(173+ ((e.clientX - rect.left) / rect.width) * 2 - 1,174+ -((e.clientY - rect.top) / rect.height) * 2 + 1,175+ );176+ ray.setFromCamera(ndc, ctx.camera);177+ const { group } = source();178+ if (!group) return;179+ for (const hit of ray.intersectObject(group, true)) {180+ let node: THREE.Object3D | null = hit.object;181+ if (node.name === "ground") continue;182+ while (node && node.userData.placementIndex === undefined) {183+ node = node.parent;184+ }185+ if (node) {186+ toggle(node.userData.placementIndex as number, node);187+ return;188+ }189+ }190+ });191+192+ // --- mode toggle ------------------------------------------------------------193+ function setActive(on: boolean): void {194+ active = on;195+ panel.style.display = on ? "flex" : "none";196+ if (on) renderList();197+ }198+199+ addEventListener("keydown", (e) => {200+ if (e.code === "Backquote") setActive(!active);201+ });202+203+ return {204+ reset() {205+ clearSelection();206+ },207+ get active() {208+ return active;209+ },210+ selection() {211+ return [...selected.keys()].sort((a, b) => a - b);212+ },213+ };214+}
M src/client/main.ts +52 -3
@@ -17,11 +17,15 @@ import { type CamMode, createCameras } from "./camera.ts";
1717 import { generateTrack } from "../gen/generateTrack.ts";1818 import type { DecorCatalog } from "../gen/decor.ts";1919 import type { Track } from "../shared/types.ts";20+import { genVersion } from "../gen/version.ts";2021 import { installCua } from "./cua.ts";22+import { installInspect } from "./inspect.ts";23+import { bootEndless } from "./endless.ts";2124 2225 const params = new URLSearchParams(location.search);2326 const GALLERY = params.get("gallery") === "1";2427 const DEBUG = params.get("debug") === "1";28+const ENDLESS = params.get("endless") === "1";2529 2630 const container = document.getElementById("app")!;2731 const seedEl = document.getElementById("seed");
@@ -30,6 +34,18 @@ const newTrackBtn = document.getElementById("new-track") as
3034 | HTMLButtonElement3135 | null;3236 const camBtn = document.getElementById("cam-mode") as HTMLButtonElement | null;37+const modeBtn = document.getElementById("mode-toggle") as38+ | HTMLButtonElement39+ | null;40+41+// Mode switch is a real navigation (search param changes, hash seed kept).42+if (modeBtn) {43+ modeBtn.textContent = ENDLESS ? "loop mode" : "endless mode";44+ modeBtn.addEventListener("click", () => {45+ const search = ENDLESS ? "" : "?endless=1";46+ location.href = location.pathname + search + location.hash;47+ });48+}3349 3450 const ctx = createScene(container);3551
@@ -166,6 +182,11 @@ function boot(assets: Assets): void {
166182 let lastSeed = "";167183 168184 const cams = createCameras(ctx, null, null);185+ const inspector = installInspect(ctx, () => ({186+ seed: lastSeed,187+ track: lastTrack,188+ group: built?.group ?? null,189+ }));169190 const setCamLabel = (mode: CamMode) => {170191 if (camBtn) {171192 camBtn.textContent = mode === "orbit" ? "follow cam" : "orbit cam";
@@ -193,6 +214,7 @@ function boot(assets: Assets): void {
193214 function regenerate(seed: string): void {194215 const t0 = performance.now();195216 217+ inspector.reset();196218 built?.dispose();197219 built = null;198220 if (marble) {
@@ -219,7 +241,21 @@ function boot(assets: Assets): void {
219241 lastSeed = seed;220242 built = buildTrack(track, assets, ctx.scene);221243 centerline = buildCenterline(track.loop);222- marble = createMarble(assets, centerline.curve);244+ marble = createMarble(245+ assets,246+ centerline.curve,247+ centerline.altCurve,248+ track.placements249+ .filter((p) => p.boost)250+ .map((p) => ({251+ pos: p.pos as [number, number, number],252+ strength: p.boost!,253+ })),254+ (track.falls ?? []).map((f) => ({255+ pos: f.pos as [number, number, number],256+ landPos: f.landPos as [number, number, number],257+ })),258+ );223259 ctx.scene.add(marble.obj);224260 225261 if (DEBUG) {
@@ -237,13 +273,17 @@ function boot(assets: Assets): void {
237273 `${s.theme} • ${s.setting} • ${s.pieceCount} pieces • ` +238274 `${track.loop.totalLength.toFixed(0)}u • maxY ${s.maxDeckY}` +239275 (built.missing.length ? ` • ${built.missing.length} missing ids` : "") +240- ` • ${(performance.now() - t0).toFixed(0)}ms`,276+ ` • ${277+ (performance.now() - t0).toFixed(0)278+ }ms • ${genVersion()} • \` inspect`,241279 );242280 }243281 244282 installCua(ctx, () => ({245283 mode: "track",246284 seed: lastSeed,285+ version: genVersion(),286+ inspect: inspector.active ? inspector.selection() : undefined,247287 camMode: cams.mode,248288 marble: marble249289 ? {
@@ -283,5 +323,14 @@ function boot(assets: Assets): void {
283323 284324 loadAssets().then((assets) => {285325 if (GALLERY) buildGallery(assets);286- else boot(assets);326+ else if (ENDLESS) {327+ bootEndless(ctx, assets, {328+ packs: [{ slug: NATURE_PACK, ids: assets.natureIds }],329+ }, {330+ seedEl,331+ statsEl,332+ newTrackBtn,333+ camBtn,334+ });335+ } else boot(assets);287336 }).catch(fail);
M src/client/marble.ts +101 -4
@@ -28,6 +28,16 @@ export interface Marble {
2828 export function createMarble(2929 assets: Assets,3030 curve: THREE.Curve<THREE.Vector3>,31+ /** Alternate-lap variant (a diamond's other branch); laps alternate. */32+ altCurve?: THREE.Curve<THREE.Vector3>,33+ /** Trackside fan boosts: accelerate while the marble is within range. */34+ boosts: { pos: [number, number, number]; strength: number }[] = [],35+ /** Bridge holes: the marble may drop through onto the channel below —36+ * deterministic per lap, so replays of a seed are identical. */37+ fallPoints: {38+ pos: [number, number, number];39+ landPos: [number, number, number];40+ }[] = [],3141 ): Marble {3242 const obj = new THREE.Group();3343 obj.name = "marble";
@@ -64,7 +74,50 @@ export function createMarble(
6474 pivot.add(fallback);6575 }6676 67- const total = curve.getLength();77+ // Laps alternate between the two curve variants (diamond branches).78+ const variants = altCurve ? [curve, altCurve] : [curve];79+ let lap = 0;80+ const active = () => variants[lap % variants.length];81+82+ // Map each bridge hole to arc positions on every variant: the hole s83+ // (where the drop can trigger) and the landing s on the channel below.84+ // Dense uniform sampling is fine — both points are ≥0.5 apart in y from85+ // any other part of the loop passing that cell.86+ const closestS = (87+ c: THREE.Curve<THREE.Vector3>,88+ p: [number, number, number],89+ ): number => {90+ const total = c.getLength();91+ const probe = new THREE.Vector3();92+ let best = 0;93+ let bestD = Infinity;94+ const steps = 600;95+ for (let i = 0; i < steps; i++) {96+ c.getPointAt(i / steps, probe);97+ const d = probe.distanceToSquared(new THREE.Vector3(...p));98+ if (d < bestD) {99+ bestD = d;100+ best = (i / steps) * total;101+ }102+ }103+ return best;104+ };105+ const falls = variants.map((c) =>106+ fallPoints.map((f) => ({107+ holeS: closestS(c, f.pos),108+ landS: closestS(c, f.landPos),109+ dropY: f.pos[1] - f.landPos[1],110+ }))111+ );112+ // Falling state: eased vertical drop from the hole to the landing.113+ let falling:114+ | { t: number; from: THREE.Vector3; toS: number; drop: number }115+ | null = null;116+ const FALL_TIME = 0.35;117+ /** Deterministic per (lap, hole): drops on roughly half the passes. */118+ const takesFall = (lapN: number, holeI: number): boolean =>119+ ((lapN * 2654435761 + holeI * 40503 + 12345) >>> 0) % 100 < 50;120+68121 const up = new THREE.Vector3(0, 1, 0);69122 const point = new THREE.Vector3();70123 const tangent = new THREE.Vector3();
@@ -77,18 +130,62 @@ export function createMarble(
77130 speed: BASE_SPEED,78131 radius,79132 update(dt: number) {133+ const total = active().getLength();80134 if (total <= 0 || dt <= 0) return;81135 136+ // Mid-fall: eased vertical drop, then resume on the lower channel.137+ if (falling) {138+ falling.t += dt / FALL_TIME;139+ if (falling.t >= 1) {140+ marble.s = falling.toS;141+ falling = null;142+ } else {143+ const k = falling.t * falling.t; // accelerate downward144+ obj.position.set(145+ falling.from.x,146+ falling.from.y - k * falling.drop,147+ falling.from.z,148+ );149+ return;150+ }151+ }152+82153 const u = ((marble.s % total) + total) % total / total;83- curve.getTangentAt(u, tangent);154+ active().getTangentAt(u, tangent);84155 85156 // Gravity-flavoured: downhill accelerates, uphill and drag bleed speed.86157 marble.speed +=87158 (-tangent.y * GRAVITY - DRAG * (marble.speed - BASE_SPEED)) * dt;159+ // Trackside fans shove the marble along while it's in front of them.160+ for (const b of boosts) {161+ const dx = obj.position.x - b.pos[0];162+ const dz = obj.position.z - b.pos[2];163+ if (dx * dx + dz * dz < 1.2 * 1.2) marble.speed += b.strength * dt;164+ }88165 marble.speed = Math.min(MAX_SPEED, Math.max(MIN_SPEED, marble.speed));89166 90- marble.s = (marble.s + marble.speed * dt) % total;91- curve.getPointAt(marble.s / total, point);167+ const prevS = marble.s;168+ const next = marble.s + marble.speed * dt;169+ if (next >= total) lap++;170+ marble.s = next % total;171+172+ // Bridge holes: on a taking lap, crossing the hole s starts the drop.173+ for (const [i, f] of falls[lap % variants.length].entries()) {174+ const crossed = prevS < f.holeS && next >= f.holeS;175+ if (crossed && takesFall(lap, i)) {176+ active().getPointAt(f.holeS / total, point);177+ falling = {178+ t: 0,179+ from: point.clone(),180+ toS: f.landS,181+ drop: f.dropY,182+ };183+ obj.position.copy(point);184+ return;185+ }186+ }187+188+ active().getPointAt(marble.s / total, point);92189 obj.position.copy(point);93190 94191 // Roll: spin about the horizontal axis perpendicular to travel.
M src/client/track.ts +73 -28
@@ -7,7 +7,7 @@
77 * - `scale` is uniform and decor-only; `mirrored` flips x.88 */99 import * as THREE from "three";10-import type { Track } from "../shared/types.ts";10+import type { Placement, Track } from "../shared/types.ts";1111 import type { Assets } from "./assets.ts";1212 1313 export interface Spinner {
@@ -16,41 +16,50 @@ export interface Spinner {
1616 radPerSec: number;1717 }1818 19-export interface BuiltTrack {20- group: THREE.Group;21- spinners: Spinner[];22- /** World bounds of the placed pieces (excludes the ground plane). */23- box: THREE.Box3;24- missing: string[];25- dispose(): void;26-}27-28-/** Ground overshoot, in grid units — wide enough for the fog to eat its edge. */29-const GROUND_MARGIN = 90;30-31-export function buildTrack(32- track: Track,19+/**20+ * Instantiate placements into `group` (shared by finite tracks and endless21+ * segments). Returns materials cloned for goal tinting — the caller owns22+ * their disposal; piece clones share the loaded kits' resources.23+ */24+export function instantiatePlacements(25+ placements: readonly Placement[],3326 assets: Assets,34- scene: THREE.Scene,35-): BuiltTrack {36- assets.applyPalette(track.stats.paletteIndex);37-38- const group = new THREE.Group();39- group.name = `track:${track.seed}`;40- const spinners: Spinner[] = [];41- const missing = new Set<string>();42-43- for (const p of track.placements) {27+ group: THREE.Group,28+ spinners: Spinner[],29+ missing: Set<string>,30+): THREE.Material[] {31+ const goalMaterials: THREE.Material[] = [];32+ for (const [index, p] of placements.entries()) {4433 const obj = assets.getPiece(p.pieceId);4534 if (!obj) {4635 missing.add(p.pieceId);4736 continue;4837 }38+ // Inspect mode resolves picked meshes back to their placement.39+ obj.userData.placementIndex = index;4940 obj.position.set(p.pos[0], p.pos[1], p.pos[2]);5041 obj.rotation.y = p.rotation * Math.PI / 2;5142 const s = p.scale ?? 1;5243 obj.scale.setScalar(s);5344 if (p.mirrored) obj.scale.x = -s;45+ if (p.goal) {46+ // Lap-line gate: dark charcoal restyle so it reads as the finish47+ // marker against any palette.48+ obj.traverse((child: THREE.Object3D) => {49+ const mesh = child as THREE.Mesh;50+ if (!mesh.isMesh) return;51+ const mats = Array.isArray(mesh.material)52+ ? mesh.material53+ : [mesh.material];54+ const tinted = mats.map((m: THREE.Material) => {55+ const c = (m as THREE.MeshStandardMaterial).clone();56+ c.color.set(0x30303a);57+ goalMaterials.push(c);58+ return c;59+ });60+ mesh.material = Array.isArray(mesh.material) ? tinted : tinted[0];61+ });62+ }5463 group.add(obj);5564 5665 if (p.spin) {
@@ -70,6 +79,41 @@ export function buildTrack(
7079 });7180 }7281 }82+ return goalMaterials;83+}84+85+export interface BuiltTrack {86+ group: THREE.Group;87+ spinners: Spinner[];88+ /** World bounds of the placed pieces (excludes the ground plane). */89+ box: THREE.Box3;90+ missing: string[];91+ dispose(): void;92+}93+94+/** Ground overshoot, in grid units — wide enough for the fog to eat its edge. */95+const GROUND_MARGIN = 90;96+97+export function buildTrack(98+ track: Track,99+ assets: Assets,100+ scene: THREE.Scene,101+): BuiltTrack {102+ assets.applyPalette(track.stats.paletteIndex);103+104+ const group = new THREE.Group();105+ group.name = `track:${track.seed}`;106+ const spinners: Spinner[] = [];107+ const missing = new Set<string>();108+ // Goal-gate placements get cloned, tinted materials (the kit shares one109+ // colormap material — mutating it would restyle every piece).110+ const goalMaterials = instantiatePlacements(111+ track.placements,112+ assets,113+ group,114+ spinners,115+ missing,116+ );73117 74118 for (const id of missing) {75119 console.warn(`[track] unknown pieceId "${id}" — skipped`);
@@ -108,11 +152,12 @@ export function buildTrack(
108152 missing: [...missing],109153 dispose() {110154 scene.remove(group);111- // Only the ground's geometry/material are owned by this build; piece112- // clones share the loaded kit's resources, so disposing them would break113- // every future build.155+ // Only the ground's geometry/material and the goal gate's cloned156+ // materials are owned by this build; piece clones share the loaded157+ // kit's resources, so disposing them would break every future build.114158 ground.geometry.dispose();115159 (ground.material as THREE.Material).dispose();160+ for (const m of goalMaterials) m.dispose();116161 group.clear();117162 },118163 };
M src/gen/assemble.ts +195 -40
@@ -31,22 +31,102 @@ export interface AssembledTrack {
3131 maxDeckY: number;3232 }3333 34+export interface AssembleOptions {35+ /**36+ * Open path in the void (endless mode): no ground exists, so support37+ * stacks, the goal banner, and solid ground-variants are all skipped —38+ * those are ground concepts. Also disables the ring-wrap adjacency used39+ * by solid support checks. Default (closed loop) is unaffected.40+ */41+ open?: boolean;42+}43+3444 const key = (x: number, z: number) => `${x},${z}`;3545 46+/**47+ * Supports mimic real structural falsework rather than one-pillar-per-cell:48+ * every contiguous elevated span gets a pillar at each end (abutments) and49+ * periodic piers whenever the deck has run SUPPORT_SPAN since the last one.50+ * A global clearance keeps pillars from doubling up where spans run close51+ * together (hairpin columns, U-turn arms) — a nearby pillar visually carries52+ * both decks.53+ */54+const SUPPORT_SPAN = 2.4;55+const SUPPORT_CLEARANCE = 1.0;56+3657 export function assemble(3758 skeleton: Skeleton,3859 elevation: ElevationPlan,3960 style: StyleField,4061 windows: RealizedWindow[],62+ opts: AssembleOptions = {},4163 ): AssembledTrack {4264 const placements: Placement[] = [];4365 const segments: LoopSegment[] = [];4466 const trackCells = new Set<string>();4567 let totalLength = 0;68+ const n = skeleton.cells.length;69+70+ // Deck base height per RING cell — used to decide whether a solid71+ // variant's extended surface actually abuts same-height deck. Ring cells72+ // only: footprint extras (funnel flanks, bulge margins) are mostly void,73+ // not deck, and must not count as support.74+ const cellHeight = new Map<string, number>();75+ skeleton.cells.forEach((c, i) =>76+ cellHeight.set(key(c.x, c.z), elevation.baseY[i])77+ );78+79+ /**80+ * A solid turn block reads as intended only when its extended surface81+ * connects: some orthogonal neighbor of the footprint (not the two82+ * ring-port cells, which touch every piece by construction) carries track83+ * at the same base height.84+ */85+ const solidSupported = (win: RealizedWindow, xf: Xform): boolean => {86+ const own = new Set<string>();87+ const cells: [number, number][] = [];88+ for (const [fx, fz] of win.def.footprint) {89+ const w = applyXform(xf, [fx, 0, fz]);90+ const cx = Math.round(w[0]);91+ const cz = Math.round(w[2]);92+ own.add(key(cx, cz));93+ cells.push([cx, cz]);94+ }95+ const prev = skeleton.cells[(win.start - 1 + n) % n];96+ const next = skeleton.cells[(win.start + win.def.moves.length) % n];97+ const ports = new Set([key(prev.x, prev.z), key(next.x, next.z)]);98+ const baseY = elevation.baseY[win.start % n];99+ for (const [cx, cz] of cells) {100+ for (const [dx, dz] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {101+ const k = key(cx + dx, cz + dz);102+ if (own.has(k) || ports.has(k)) continue;103+ if (cellHeight.get(k) === baseY) return true;104+ }105+ }106+ return false;107+ };46108 47109 const supportPrefix = style.supportColor ? "support-color" : "support";48110 const supportId = (segment: string) =>49111 `${supportPrefix}-${style.supportKind}-${segment}`;112+ const supportCandidates: {113+ x: number;114+ z: number;115+ height: number;116+ dir: Dir;117+ cradle: boolean;118+ stretch: number;119+ }[] = [];120+ let stretch = 0;121+122+ // Twice-visited cells (crossings, bridge decks): the ground there belongs123+ // to the lower channel — a pillar would stand in it. Piers land on the124+ // neighboring deck cells instead. The goal banner also skips them.125+ const crossingCells = new Set(126+ skeleton.crossings.map(({ a }) =>127+ key(skeleton.cells[a].x, skeleton.cells[a].z)128+ ),129+ );50130 51131 for (const win of windows) {52132 const cell = skeleton.cells[win.start];
@@ -54,24 +134,31 @@ export function assemble(
54134 const xf: Xform = windowXform(cell.x, cell.z, cell.dirIn, baseY);55135 const def = win.def;56136 57- const useSolid = def.solidId !== undefined && style.solidAt(baseY);58- const placementIndex = placements.length;137+ const useSolid = !opts.open && def.solidId !== undefined &&138+ style.solidAt(baseY) &&139+ (!def.solidExtends || solidSupported(win, xf));140+ // −1 for noMesh windows: their loop segment has no placement of its own.141+ const placementIndex = def.noMesh ? -1 : placements.length;59142 const rotation = ((xf.r + def.meshRot) % 4) as Dir;60- placements.push({61- pieceId: useSolid ? def.solidId! : (def.meshId ?? def.id),62- pos: applyXform(xf, def.meshOffset),63- rotation,64- role: "track",65- });143+ // noMesh windows (a crossing's second visit) contribute centerline and144+ // occupancy only — the shared cell's mesh comes from the first visit.145+ if (!def.noMesh) {146+ placements.push({147+ pieceId: useSolid ? def.solidId! : (def.meshId ?? def.id),148+ pos: applyXform(xf, def.meshOffset),149+ rotation,150+ ...(def.mirrored ? { mirrored: true } : {}),151+ role: "track",152+ });153+ }66154 for (const overlay of def.overlays ?? []) {155+ // Overlay offsets are absolute entry-frame positions of the overlay156+ // mesh origin (not relative to the base mesh).67157 placements.push({68158 pieceId: overlay.id,69- pos: applyXform(xf, [70- def.meshOffset[0] + overlay.offset[0],71- def.meshOffset[1] + overlay.offset[1],72- def.meshOffset[2] + overlay.offset[2],73- ]),74- rotation,159+ pos: applyXform(xf, overlay.offset),160+ rotation: ((xf.r + (overlay.rot ?? 0)) % 4) as Dir,161+ ...(overlay.mirrored ? { mirrored: true } : {}),75162 role: "track",76163 });77164 }
@@ -85,43 +172,111 @@ export function assemble(
85172 // Centerline segments in world space.86173 const curve: CurveSeg[] = def.centerline.map((s) => xformSeg(xf, s));87174 const length = curve.reduce((acc, s) => acc + segLength(s), 0);88- segments.push({ placementIndex, curve, length });175+ const alt = def.altCenterline176+ ? (() => {177+ const altCurve = def.altCenterline!.map((s) => xformSeg(xf, s));178+ return {179+ curve: altCurve,180+ length: altCurve.reduce((acc, s) => acc + segLength(s), 0),181+ };182+ })()183+ : undefined;184+ segments.push({ placementIndex, curve, length, ...(alt ? { alt } : {}) });89185 totalLength += length;90186 91187 // Supports: flat elevated pieces get a stack per channel cell; climbs get92188 // one stack at whichever end is elevated (entry for descents, exit-side93- // handled by the neighboring flat window).94- if (baseY > 0 && def.deckDelta === 0) {189+ // handled by the neighboring flat window). Void mode: nothing to stand190+ // on — no supports at any height.191+ if (!opts.open && baseY > 0 && def.deckDelta === 0) {95192 const cells = windowCells(def);96193 for (let j = 0; j < cells.length; j++) {97194 const c = skeleton.cells[(win.start + j) % skeleton.cells.length];98- if (c.move === "F") {99- emitSupportStack(100- placements,101- supportId,102- c.x,103- c.z,104- baseY,105- c.dirIn,106- true,107- );195+ if (crossingCells.has(key(c.x, c.z))) continue;196+ if (c.move === "F" && !def.columnSupports) {197+ supportCandidates.push({198+ x: c.x,199+ z: c.z,200+ height: baseY,201+ dir: c.dirIn,202+ cradle: true,203+ stretch,204+ });108205 } else {109- // Turn cells: a pillar is only allowed where it actually touches110- // deck, so anchor it under the channel by snapping to the nearest111- // centerline point. Plain column — the cradle can't align to a112- // diagonal tangent.206+ // Turn cells and off-center channels (waves, bends, wide): a207+ // pillar is only allowed where it actually touches deck, so anchor208+ // it under the channel by snapping to the nearest centerline209+ // point. Plain column — the cradle can't align to a diagonal210+ // tangent or displaced lane.113211 const p = nearestOnCurve(curve, c.x, c.z);114- emitSupportStack(115- placements,116- supportId,117- p[0],118- p[2],119- baseY,120- c.dirIn,121- false,122- );212+ supportCandidates.push({213+ x: p[0],214+ z: p[2],215+ height: baseY,216+ dir: c.dirIn,217+ cradle: false,218+ stretch,219+ });123220 }124221 }222+ } else {223+ // Ground-level or climbing window: closes any elevated stretch.224+ stretch++;225+ }226+ }227+228+ // Goal gate: a banner straddling the channel marks the lap line — its229+ // post feet land exactly on the rail tops (post centers ±0.425 = wall-top230+ // centers, measured; cloth underside at +0.75 clears the marble by 0.6).231+ // First plain straight window in ring order keeps it deterministic.232+ // Endless mode has no lap line (segments hold no ceremony).233+ for (const win of opts.open ? [] : windows) {234+ if (win.def.id !== "straight") continue;235+ const cell = skeleton.cells[win.start % n];236+ if (crossingCells.has(key(cell.x, cell.z))) continue;237+ placements.push({238+ pieceId: "banner-high",239+ pos: [cell.x, elevation.baseY[win.start % n] + 0.5, cell.z],240+ rotation: ((1 - cell.dirIn + 4) % 4) as Dir,241+ role: "track",242+ goal: true,243+ });244+ break;245+ }246+247+ // Structural density rule (deterministic, no RNG): per elevated stretch,248+ // keep the two ends plus piers at SUPPORT_SPAN intervals, subject to the249+ // global clearance.250+ const kept: { x: number; z: number }[] = [];251+ const nearKept = (x: number, z: number, d: number) =>252+ kept.some((k) => Math.hypot(k.x - x, k.z - z) < d);253+ const byStretch = new Map<number, typeof supportCandidates>();254+ for (const cand of supportCandidates) {255+ const list = byStretch.get(cand.stretch) ?? [];256+ list.push(cand);257+ byStretch.set(cand.stretch, list);258+ }259+ for (const group of byStretch.values()) {260+ let last: { x: number; z: number } | null = null;261+ for (let i = 0; i < group.length; i++) {262+ const cand = group[i];263+ const isEnd = i === 0 || i === group.length - 1;264+ const gap = last265+ ? Math.hypot(cand.x - last.x, cand.z - last.z)266+ : Infinity;267+ if (!isEnd && gap < SUPPORT_SPAN) continue;268+ if (nearKept(cand.x, cand.z, SUPPORT_CLEARANCE)) continue;269+ kept.push({ x: cand.x, z: cand.z });270+ last = cand;271+ emitSupportStack(272+ placements,273+ supportId,274+ cand.x,275+ cand.z,276+ cand.height,277+ cand.dir,278+ cand.cradle,279+ );125280 }126281 }127282
M src/gen/decor.ts +145 -39
@@ -5,8 +5,13 @@
55 * table (piece-name patterns × distance bands). Data-driven: new packs are a66 * manifest + a rule table away.77 *8- * Decor: realizes the setting's rules around the assembled track. Purely9- * additive — candidates colliding with track/support cells are dropped.8+ * Decor: a WORLD FIELD. The caller supplies a cell region and a track-distance9+ * oracle; placeDecor realizes the field for exactly that region. Every cell's10+ * outcome is keyed by its own RNG child (`cell/${x}/${z}`) and the oracle's11+ * answer — never by iteration order or region bounds — so any tiling of12+ * regions produces the same world. Single-track mode passes bounds+margin and13+ * an oracle over its own cell set; M5's chunk streamer passes chunk regions14+ * and an oracle backed by "this chunk + neighbors".1015 */1116 import type { Placement, Vec3 } from "../shared/types.ts";1217 import type { Rng } from "./rng.ts";
@@ -17,7 +22,7 @@ export interface DecorCatalog {
1722 packs: { slug: string; ids: readonly string[] }[];1823 }1924 20-type Band = "near" | "mid" | "far";25+type Band = "near" | "mid" | "far" | "ambient";2126 2227 interface DecorGroup {2328 pattern: RegExp;
@@ -99,6 +104,23 @@ const NATURE_SETTINGS: Omit<Setting, "packSlug">[] = [
99104 },100105 ];101106 107+/**108+ * Setting by name — island biomes (endless mode) borrow the nature rule109+ * tables directly instead of drawing a setting per world.110+ */111+export function settingByName(name: string, catalog: DecorCatalog): Setting {112+ const nature = catalog.packs.find((p) => p.slug === "nature-kit");113+ const spec = NATURE_SETTINGS.find((s) => s.name === name);114+ if (!nature || !spec) return { name: "bare", packSlug: null, groups: [] };115+ return {116+ name,117+ packSlug: "nature-kit",118+ groups: spec.groups.filter((g) =>119+ nature.ids.some((id) => g.pattern.test(id))120+ ),121+ };122+}123+102124 export function pickSetting(rng: Rng, catalog: DecorCatalog): Setting {103125 const nature = catalog.packs.find((p) => p.slug === "nature-kit");104126 if (!nature) {
@@ -123,71 +145,147 @@ const MARBLE_DECOR: {
123145 { id: "tree-tall", weight: 2, band: "mid" },124146 { id: "tree-large", weight: 1, band: "far" },125147 { id: "tree-tall-large", weight: 1, band: "far" },126- { id: "banner", weight: 1.5, band: "near" },127- { id: "banner-high", weight: 1, band: "near" },128- { id: "fan-standing-two", weight: 0.7, band: "near", spin: true },129- { id: "fan-standing-four", weight: 0.7, band: "near", spin: true },148+ // Banners are WIDE (posts at ±0.425, plus placement jitter ±0.3): in the149+ // near band they can reach across into an adjacent track cell (caught by150+ // validate at 1/1000 seeds). Mid band keeps them clear.151+ { id: "banner", weight: 1, band: "mid" },152+ { id: "banner-high", weight: 0.7, band: "mid" },153+ { id: "banner-double", weight: 0.5, band: "mid" },154+ { id: "banner-double-high", weight: 0.4, band: "mid" },155+ { id: "banner-hanging", weight: 0.5, band: "mid" },156+ { id: "banner-hanging-high", weight: 0.4, band: "mid" },157+ { id: "banner-hanging-double", weight: 0.3, band: "mid" },158+ { id: "banner-hanging-double-high", weight: 0.3, band: "mid" },159+ // Fans are TRACK ELEMENTS now (fans.ts, M4): deliberately placed against160+ // the channel with a marble boost — no longer sprinkled as decor.130161 ];131162 132-export function placeDecor(133- rng: Rng,134- setting: Setting,135- style: StyleField,136- trackCells: ReadonlySet<string>,137- catalog: DecorCatalog,138-): Placement[] {139- const placements: Placement[] = [];140- const nature = setting.packSlug141- ? catalog.packs.find((p) => p.slug === setting.packSlug)142- : undefined;163+/** Inclusive cell bounds the decor field is realized for. */164+export interface CellRegion {165+ minX: number;166+ maxX: number;167+ minZ: number;168+ maxZ: number;169+}170+171+/**172+ * What the field knows about the track at a cell: distance to the nearest173+ * track cell and the grid direction toward it (as a piece rotation, so facade174+ * pieces like fans can face the track). `dist` is Infinity beyond175+ * ORACLE_HORIZON — those cells are the ambient band.176+ */177+export interface TrackProbe {178+ dist: number;179+ dir: Placement["rotation"];180+}181+182+/**183+ * Cell → nearest-track info. A chunked caller (M5) backs this with184+ * "this chunk + neighbors"; single-track mode wraps its one cell set.185+ * Implementations must answer from bounded local context only.186+ */187+export type TrackOracle = (x: number, z: number) => TrackProbe;188+189+/**190+ * How far (in cells) an oracle must see: the banded rules stop at 5.5, so any191+ * track farther than this cannot change a cell's outcome. Chunked backers192+ * need neighbor context of exactly this radius.193+ */194+export const ORACLE_HORIZON = 6;143195 144- // Region: track bounds + margin.196+/** Track bounds + margin — the single-track region. */197+export function regionAround(198+ cells: ReadonlySet<string>,199+ margin: number,200+): CellRegion {145201 let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;146- for (const cell of trackCells) {202+ for (const cell of cells) {147203 const [x, z] = cell.split(",").map(Number);148204 minX = Math.min(minX, x);149205 maxX = Math.max(maxX, x);150206 minZ = Math.min(minZ, z);151207 maxZ = Math.max(maxZ, z);152208 }153- const margin = 5;209+ return {210+ minX: minX - margin,211+ maxX: maxX + margin,212+ minZ: minZ - margin,213+ maxZ: maxZ + margin,214+ };215+}154216 155- // Distance to nearest track cell (scan radius 4 — bands only need ≤ 4).156- const distToTrack = (x: number, z: number): number => {217+/** Oracle over a plain cell set (single-track mode). */218+export function cellSetOracle(cells: ReadonlySet<string>): TrackOracle {219+ return (x, z) => {157220 let best = Infinity;158- for (let dx = -4; dx <= 4; dx++) {159- for (let dz = -4; dz <= 4; dz++) {160- if (trackCells.has(`${x + dx},${z + dz}`)) {161- best = Math.min(best, Math.hypot(dx, dz));221+ let bx = 0, bz = 1;222+ for (let dx = -ORACLE_HORIZON; dx <= ORACLE_HORIZON; dx++) {223+ for (let dz = -ORACLE_HORIZON; dz <= ORACLE_HORIZON; dz++) {224+ if (cells.has(`${x + dx},${z + dz}`)) {225+ const d = Math.hypot(dx, dz);226+ if (d < best) {227+ best = d;228+ bx = dx;229+ bz = dz;230+ }162231 }163232 }164233 }165- return best;234+ // Dominant axis → Dir (0=+x, 1=+z, 2=−x, 3=−z).235+ const wantDir = Math.abs(bx) >= Math.abs(bz)236+ ? (bx >= 0 ? 0 : 2)237+ : (bz >= 0 ? 1 : 3);238+ // Rotation r maps local +z (dir 1) to world (1 − r + 4) % 4.239+ return {240+ dist: best,241+ dir: ((1 - wantDir + 4) % 4) as Placement["rotation"],242+ };166243 };244+}245+246+/** Ambient band thins the field out toward the fog, not a hard cliff. */247+const AMBIENT_DENSITY = 0.3;248+249+export function placeDecor(250+ rng: Rng,251+ setting: Setting,252+ style: StyleField,253+ region: CellRegion,254+ oracle: TrackOracle,255+ catalog: DecorCatalog,256+): Placement[] {257+ const placements: Placement[] = [];258+ const nature = setting.packSlug259+ ? catalog.packs.find((p) => p.slug === setting.packSlug)260+ : undefined;167261 168262 const bandOf = (d: number): Band | null => {169- if (d < 1) return null; // too close — would clip the track263+ if (d < 1) return null; // on/too close to the track — would clip it170264 if (d <= 2) return "near";171265 if (d <= 3.5) return "mid";172266 if (d <= 5.5) return "far";173- return null;267+ return "ambient"; // sparse continuation out to the region edge174268 };175269 176270 const idsFor = (group: DecorGroup): string[] =>177271 nature ? nature.ids.filter((id) => group.pattern.test(id)) : [];178272 179- for (let x = minX - margin; x <= maxX + margin; x++) {180- for (let z = minZ - margin; z <= maxZ + margin; z++) {181- if (trackCells.has(`${x},${z}`)) continue;273+ for (let x = region.minX; x <= region.maxX; x++) {274+ for (let z = region.minZ; z <= region.maxZ; z++) {182275 const cellRng = rng.child(`cell/${x}/${z}`);183276 if (!cellRng.chance(style.decorDensity * 0.45)) continue;184- const band = bandOf(distToTrack(x, z));277+ const probe = oracle(x, z);278+ const band = bandOf(probe.dist);185279 if (band === null) continue;280+ // The ambient band reuses the far-band rules, thinned: horizon fill of281+ // the same trees/rocks/cliffs, sparse enough that the fog does the rest.282+ const ruleBand: Band = band === "ambient" ? "far" : band;283+ if (band === "ambient" && !cellRng.chance(AMBIENT_DENSITY)) continue;186284 187285 // Weighted union of setting groups and marble-kit decor for this band.188286 const options: (readonly [() => Placement | null, number])[] = [];189287 for (const group of setting.groups) {190- if (group.band !== band) continue;288+ if (group.band !== ruleBand) continue;191289 const ids = idsFor(group);192290 if (ids.length === 0) continue;193291 options.push(
@@ -199,10 +297,13 @@ export function placeDecor(
199297 );200298 }201299 for (const d of MARBLE_DECOR) {202- if (d.band !== band) continue;300+ if (d.band !== ruleBand) continue;301+ // Fans stay banded: a fan spinning alone in the ambient void reads302+ // as debris, not scenery.303+ if (band === "ambient" && d.spin) continue;203304 options.push(204305 [205- () => makeMarblePlacement(cellRng, d, x, z),306+ () => makeMarblePlacement(cellRng, d, x, z, probe.dir),206307 d.weight * 0.5,207308 ] as const,208309 );
@@ -245,11 +346,16 @@ function makeMarblePlacement(
245346 d: { id: string; spin?: boolean },246347 x: number,247348 z: number,349+ faceTrack: Placement["rotation"],248350 ): Placement {351+ const pos = jitteredPos(rng, x, z);352+ // Spinning pieces (fans) face the track — their local +z is the blade353+ // side, and the housing back reads as a blank deck-colored cube.354+ const rotation = d.spin ? faceTrack : rng.int(0, 4) as Placement["rotation"];249355 return {250356 pieceId: d.id,251- pos: jitteredPos(rng, x, z),252- rotation: rng.int(0, 4) as Placement["rotation"],357+ pos,358+ rotation,253359 role: "decor",254360 spin: d.spin255361 ? { axis: [0, 0, 1], radPerSec: 1.5 + rng.float() * 2.5 }
M src/gen/elevation.ts +300 -16
@@ -9,6 +9,8 @@
99 */1010 import type { Rng } from "./rng.ts";1111 import type { Skeleton } from "./topology.ts";12+import type { PieceRegistry } from "./registry.ts";13+import { applyXform, windowXform } from "./geom.ts";1214 1315 /** A reserved window: realization must place one of `pieceIds` here. */1416 export interface Reservation {
@@ -37,13 +39,16 @@ interface Run {
3739 * alternatives (same length and delta); multi-part options chain parts3840 * back-to-back so every intermediate base stays on the 0.5 support grid.3941 */40-interface SlopePart {42+export interface SlopePart {4143 ids: string[];4244 len: number;4345 delta: number;46+ /** The FORWARD def already descends (drop-stacks, helixes) — reserve it47+ * unreversed even inside a descent. */48+ asIs?: boolean;4449 }4550 46-interface SlopeOption {51+export interface SlopeOption {4752 totalLen: number;4853 parts: SlopePart[];4954 }
@@ -57,10 +62,14 @@ const slope = (...parts: SlopePart[]): SlopeOption => ({
5762 * Climbs cap at 0.5 rise per cell — a marble visibly ascending anything5863 * steeper reads as broken. Taller climbs chain gentle parts instead.5964 */60-const CLIMB_VOCAB: Record<string, SlopeOption[]> = {65+export const CLIMB_VOCAB: Record<string, SlopeOption[]> = {6166 "0.5": [6267 slope({ ids: ["slant-b"], len: 1, delta: 0.5 }),6368 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+ ),6473 ],6574 "1": [6675 slope({ ids: ["ramp-long-c", "slant-long-c"], len: 2, delta: 1.0 }),
@@ -80,44 +89,305 @@ const CLIMB_VOCAB: Record<string, SlopeOption[]> = {
8089 { ids: ["ramp-long-c", "slant-long-c"], len: 2, delta: 1.0 },8190 ),8291 ],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+ ],83104 };84105 85-/** Descents also allow steeper chute drops (0.75 per cell). */86-const DESCENT_VOCAB: Record<string, SlopeOption[]> = {87- "0.5": CLIMB_VOCAB["0.5"],106+/** 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). */109+export 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+ ],88119 "1": CLIMB_VOCAB["1"],89120 "1.5": [90121 ...CLIMB_VOCAB["1.5"],91122 slope({ ids: ["slant-long-d", "ramp-long-d"], len: 2, delta: 1.5 }),92123 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+ ),93129 ],94130 };95131 96-export function planElevation(rng: Rng, skeleton: Skeleton): ElevationPlan {132+/**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+ */140+export 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+};155+const 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+};165+166+/** How often the track's corner set-piece (a helix window) is attempted. */167+const CORNER_DESCENT_AFFINITY = 0.5;168+const CORNER_CLIMB_AFFINITY = 0.3;169+170+interface TurnWindow {171+ start: number;172+ len: number;173+ pattern: string;174+ delta: number;175+}176+177+/**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+ */183+function 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+}227+228+export 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 {97235 const n = skeleton.cells.length;98236 const deltas = new Array<number>(n).fill(0);99237 const reservations: Reservation[] = [];100238 101- // Maximal straight runs (skeleton is normalized to start at a turn, so102- // runs never wrap the seam).239+ // 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+ }245+246+ // Maximal straight unblocked runs (skeleton is normalized to start at a247+ // turn, so runs never wrap the seam).103248 const runs: Run[] = [];104249 let i = 0;105250 while (i < n) {106- if (skeleton.cells[i].move !== "F") {251+ if (skeleton.cells[i].move !== "F" || fullBlocked.has(i)) {107252 i++;108253 continue;109254 }110255 let len = 0;111- while (i + len < n && skeleton.cells[i + len].move === "F") len++;256+ while (257+ i + len < n && skeleton.cells[i + len].move === "F" &&258+ !fullBlocked.has(i + len)259+ ) len++;112260 runs.push({ start: i, len });113261 i += len;114262 }115263 116- const eligible = runs.filter((r) => r.len >= 2);264+ // 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+ }284+285+ // 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+ }305+306+ // 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+ const flatOnly = skeleton.crossings.length > 0 || Boolean(helixLoop);315+ const corner = rng.child("corner");316+ const usedRuns = new Set<Run>();317+ if (!flatOnly && corner.chance(CORNER_DESCENT_AFFINITY)) {318+ const turnWindows = findTurnWindows(skeleton, fullBlocked, registry);319+ if (turnWindows.length > 0) {320+ const pickTurn = (opts: TurnWindow[]): TurnWindow =>321+ corner.weighted(opts.map((w) => [w, w.len * w.len] as const));322+ const reserveTurn = (w: TurnWindow, descent: boolean) => {323+ const spec = (descent ? TURN_DESCENTS : TURN_CLIMBS)[w.pattern];324+ reservations.push({325+ start: w.start,326+ len: w.len,327+ pieceIds: spec.ids,328+ reversed: !descent,329+ });330+ spreadDelta(w.start, w.len, descent ? -w.delta : w.delta);331+ };332+ const descent = pickTurn(turnWindows);333+ const windowIdx = new Set<number>();334+ for (let j = 0; j < descent.len; j++) {335+ windowIdx.add((descent.start + j) % n);336+ }337+ const overlapsWindow = (r: Run): boolean => {338+ for (let j = 0; j < r.len; j++) {339+ if (windowIdx.has(r.start + j)) return true;340+ }341+ return false;342+ };343+ const disjoint = (w: TurnWindow): boolean => {344+ for (let j = 0; j < w.len; j++) {345+ if (windowIdx.has((w.start + j) % n)) return false;346+ }347+ return true;348+ };349+ const climbWindows = turnWindows.filter((w) =>350+ w !== descent && w.delta === descent.delta && disjoint(w) &&351+ // Drop-stack windows (bare L/R) have no climb form — you can't352+ // fall upward through a hole.353+ TURN_CLIMBS[w.pattern] !== undefined354+ );355+ const climbRuns = runs.filter((r) =>356+ !overlapsWindow(r) &&357+ CLIMB_VOCAB[String(descent.delta)].some((o) => o.totalLen <= r.len)358+ );359+ if (climbWindows.length > 0 && corner.chance(CORNER_CLIMB_AFFINITY)) {360+ reserveTurn(pickTurn(climbWindows), false);361+ reserveTurn(descent, true);362+ } else if (climbRuns.length > 0) {363+ const run = corner.pick(climbRuns);364+ reserve(rng.child("climb/corner"), run, descent.delta, false);365+ reserveTurn(descent, true);366+ usedRuns.add(run);367+ }368+ // No climb available → the corner drop is silently skipped.369+ }370+ }371+372+ const cornerIdx = new Set<number>();373+ for (const r of reservations) {374+ for (let j = 0; j < r.len; j++) cornerIdx.add((r.start + j) % n);375+ }376+ const eligible = runs.filter((r) => {377+ if (usedRuns.has(r)) return false;378+ for (let j = 0; j < r.len; j++) {379+ if (cornerIdx.has(r.start + j)) return false;380+ }381+ return r.len >= 2;382+ });117383 const maxHills = Math.min(3, Math.floor(eligible.length / 2));118384 // At least one hill whenever the ring can host one — flat tracks still119- // occur (small rings), but stay the exception.120- const hills = maxHills > 0 ? rng.int(1, maxHills + 1) : 0;385+ // occur (small rings), but stay the exception. Tracks with grade386+ // crossings stay flat: both visits of a crossing cell must agree on387+ // height, and keeping the whole ring at base 0 is the only rule that388+ // can't separate a pair (M3 compromise — a hill-interval constraint389+ // could lift this later). Helix-loop tracks: the spiral IS the hill.390+ const hills = maxHills > 0 && !flatOnly ? rng.int(1, maxHills + 1) : 0;121391 122392 if (hills > 0) {123393 const chosen = rng.shuffle(eligible).slice(0, hills * 2)
@@ -136,7 +406,12 @@ export function planElevation(rng: Rng, skeleton: Skeleton): ElevationPlan {
136406 }137407 }138408 139- function reserve(r: Rng, run: Run, delta: number, reversed: boolean) {409+ function reserve(410+ r: Rng,411+ run: Run,412+ delta: number,413+ reversed: boolean,414+ ): { start: number; len: number } {140415 const vocab = reversed ? DESCENT_VOCAB : CLIMB_VOCAB;141416 const usable = vocab[String(delta)].filter((o) => o.totalLen <= run.len);142417 const option = r.pick(usable);
@@ -149,11 +424,12 @@ export function planElevation(rng: Rng, skeleton: Skeleton): ElevationPlan {
149424 start: at,150425 len: part.len,151426 pieceIds: part.ids,152- reversed,427+ reversed: reversed && !part.asIs,153428 });154429 spreadDelta(at, part.len, reversed ? -part.delta : part.delta);155430 at += part.len;156431 }432+ return { start, len: option.totalLen };157433 }158434 159435 function spreadDelta(start: number, _len: number, delta: number) {
@@ -172,6 +448,14 @@ export function planElevation(rng: Rng, skeleton: Skeleton): ElevationPlan {
172448 if (Math.abs(hgt) > 1e-9) {173449 throw new Error(`elevation did not close: residual ${hgt}`);174450 }451+ // Ground the profile: the integration constant is free on a closed loop,452+ // so shift the lowest point to base 0. Hills built climb-before-descent453+ // already sit at 0; the helix loop's drop-then-climb (in index order)454+ // relies on this.455+ const minY = Math.min(...baseY);456+ if (minY !== 0) {457+ for (let idx = 0; idx < n; idx++) baseY[idx] -= minY;458+ }175459 176460 return { baseY, reservations, maxBaseY: Math.max(...baseY) };177461 }
A src/gen/endless/islands.ts +245 -0
@@ -0,0 +1,245 @@
1+/**2+ * Island builder — the anchors of the endless line.3+ *4+ * The path planner has already committed an anchored run and claimed a mass5+ * envelope (`isle/${id}` bands: per-cell free depth below the top surface,6+ * pre-shrunk around older track that crosses under). This builder realizes7+ * that claim as a cliff-block voxel mass with a biome on top:8+ *9+ * - mass: nature-kit cliff_block family (1×1×1 blocks + 0.5/0.25 slabs) on10+ * our exact grid; columns under the run always exist, blob rings around it11+ * thin out and shallow out with distance (stepped underside taper — the12+ * floating-island silhouette).13+ * - biome: data-driven family table (material, cap tiles, decor rules,14+ * density). v1 rows draw on the nature kit; lava/snow/city are future rows15+ * pointing at future packs.16+ * - decor: the M2a field API over the island's top — region = island bbox,17+ * oracle = the anchored run's cells, placements filtered to real mass tops18+ * and lifted to topY. Per-cell RNG children keep it order-independent.19+ *20+ * Every placement belongs to the island's ref; culling an island releases21+ * its claim and its render group together.22+ */23+import type { Placement } from "../../shared/types.ts";24+import type { Rng } from "../rng.ts";25+import {26+ cellSetOracle,27+ type DecorCatalog,28+ placeDecor,29+ regionAround,30+ settingByName,31+} from "../decor.ts";32+import type { StyleField } from "../style.ts";33+import { Occupancy } from "./occupancy.ts";34+import type { IslandCrossing } from "./path.ts";35+36+export interface BiomeSpec {37+ name: string;38+ weight: number;39+ /** Cliff family material suffix. */40+ material: "rock" | "stone";41+ /** Cap tile on non-track top cells (nature-kit id), or bare block tops. */42+ capId: string | null;43+ capChance: number;44+ /** NATURE_SETTINGS row the top decor borrows, or null for barren tops. */45+ setting: string | null;46+ decorDensity: number;47+}48+49+/** Island families. Emergent variety comes from (biome × size × run shape ×50+ * depth noise); new packs slot in as new rows. */51+export const BIOMES: readonly BiomeSpec[] = [52+ {53+ name: "forest",54+ weight: 3,55+ material: "stone",56+ capId: "ground_grass",57+ capChance: 0.9,58+ setting: "forest",59+ decorDensity: 0.65,60+ },61+ {62+ name: "meadow",63+ weight: 2.5,64+ material: "stone",65+ capId: "ground_grass",66+ capChance: 0.95,67+ setting: "meadow",68+ decorDensity: 0.55,69+ },70+ {71+ name: "rocky",72+ weight: 2.5,73+ material: "rock",74+ capId: null,75+ capChance: 0,76+ setting: "rocky",77+ decorDensity: 0.4,78+ },79+ {80+ name: "barren",81+ weight: 1.5,82+ material: "rock",83+ capId: null,84+ capChance: 0,85+ setting: "rocky",86+ decorDensity: 0.15,87+ },88+];89+90+export interface Island {91+ id: number;92+ ref: string;93+ biome: string;94+ topY: number;95+ placements: Placement[];96+ /** Top-surface mass cells ("x,z"), the run included. */97+ cells: string[];98+}99+100+const NATURE = "nature-kit";101+const key = (x: number, z: number) => `${x},${z}`;102+103+export function buildIsland(104+ rng: Rng,105+ crossing: IslandCrossing,106+ runCells: readonly (readonly [number, number])[],107+ occ: Occupancy,108+ style: StyleField,109+ catalog: DecorCatalog,110+): Island {111+ const { id, topY, margin } = crossing;112+ const ref = `isle/${id}`;113+ const biome = rng.weighted(BIOMES.map((b) => [b, b.weight] as const));114+ const placements: Placement[] = [];115+116+ const runSet = new Set(runCells.map(([x, z]) => key(x, z)));117+ const chebToRun = (x: number, z: number): number => {118+ let best = Infinity;119+ for (const [rx, rz] of runCells) {120+ best = Math.min(best, Math.max(Math.abs(x - rx), Math.abs(z - rz)));121+ }122+ return best;123+ };124+125+ // ---------------------------------------------------------------------126+ // Mass columns: run columns always; ring columns thin with distance.127+ // Depth shallows with distance → stepped inverted-ziggurat underside.128+ // All decisions per-cell-keyed (order-independent).129+ // ---------------------------------------------------------------------130+ const RING_KEEP = [1, 0.9, 0.55, 0.25]; // by Chebyshev distance131+ const topCells: string[] = [];132+ for (const claim of occ.claimsOf(ref)) {133+ const { x, z, y0, y1 } = claim;134+ if (Math.abs(y1 - topY) > 1e-9) continue; // not a mass claim (paranoia)135+ const cellRng = rng.child(`cell/${x}/${z}`);136+ const dist = chebToRun(x, z);137+ const keep = RING_KEEP[Math.min(dist, RING_KEEP.length - 1)];138+ if (!runSet.has(key(x, z)) && !cellRng.chance(keep)) continue;139+140+ const avail = topY - y0;141+ if (avail < 0.999) continue;142+ // Depth target: deepest under the run, shallowing outward, jittered.143+ const target = Math.max(144+ 1,145+ (margin + 1.5 - dist) * (0.8 + cellRng.float() * 0.7),146+ );147+ const depth = quantize(Math.min(avail, target));148+149+ // Stack blocks top-down: full blocks, then a half/quarter shim so the150+ // underside lands exactly on the claimed grid.151+ let remaining = depth;152+ let yTop = topY;153+ while (remaining >= 1 - 1e-9) {154+ placements.push(155+ block(`cliff_block_${biome.material}`, x, yTop - 1, z, cellRng),156+ );157+ yTop -= 1;158+ remaining -= 1;159+ }160+ if (remaining >= 0.5 - 1e-9) {161+ placements.push(162+ block(`cliff_blockHalf_${biome.material}`, x, yTop - 0.5, z, cellRng),163+ );164+ yTop -= 0.5;165+ remaining -= 0.5;166+ }167+ if (remaining >= 0.25 - 1e-9) {168+ placements.push(169+ block(170+ `cliff_blockQuarter_${biome.material}`,171+ x,172+ yTop - 0.25,173+ z,174+ cellRng,175+ ),176+ );177+ }178+ topCells.push(key(x, z));179+180+ // Caps: biome surface tile on non-track tops.181+ if (182+ !runSet.has(key(x, z)) && biome.capId &&183+ cellRng.chance(biome.capChance)184+ ) {185+ placements.push({186+ pieceId: `${NATURE}:${biome.capId}`,187+ pos: [x, topY, z],188+ rotation: cellRng.int(0, 4) as Placement["rotation"],189+ role: "decor",190+ });191+ }192+ }193+194+ // ---------------------------------------------------------------------195+ // Decor field on the top surface: the M2a API with the run as "the196+ // track", filtered to actual mass tops, lifted to topY.197+ // ---------------------------------------------------------------------198+ if (biome.setting && catalog.packs.length > 0) {199+ const setting = settingByName(biome.setting, catalog);200+ const topSet = new Set(topCells);201+ const decor = placeDecor(202+ rng.child("decor"),203+ setting,204+ { ...style, decorDensity: biome.decorDensity },205+ regionAround(runSet, margin),206+ cellSetOracle(runSet),207+ catalog,208+ );209+ for (const p of decor) {210+ const cx = Math.round(p.pos[0]);211+ const cz = Math.round(p.pos[2]);212+ if (!topSet.has(key(cx, cz)) || runSet.has(key(cx, cz))) continue;213+ // Vegetation needs the canopy airspace claim (made at plan time) —214+ // cells whose air an older line owns stay bare rather than growing a215+ // tree through someone's channel.216+ const ownsAir = occ217+ .at(cx, cz)218+ .some((b) => b.ref === ref && b.y1 > topY + 1);219+ if (!ownsAir) continue;220+ placements.push({ ...p, pos: [p.pos[0], topY, p.pos[2]] });221+ }222+ }223+224+ return { id, ref, biome: biome.name, topY, placements, cells: topCells };225+}226+227+function quantize(depth: number): number {228+ return Math.max(1, Math.round(depth * 4) / 4);229+}230+231+function block(232+ pieceId: string,233+ x: number,234+ y: number,235+ z: number,236+ rng: Rng,237+): Placement {238+ return {239+ pieceId: `${NATURE}:${pieceId}`,240+ pos: [x, y, z],241+ // Random facing — the cliff faces are textured on all sides.242+ rotation: rng.int(0, 4) as Placement["rotation"],243+ role: "support",244+ };245+}
A src/gen/endless/occupancy.ts +155 -0
@@ -0,0 +1,155 @@
1+/**2+ * 3D occupancy for the endless void: cell (x, z) → occupied vertical bands.3+ *4+ * The void has no ground, so nothing is "the" height of a cell — a column may5+ * carry a channel at y −3.25, an island slab from −1 to 0, and an older6+ * channel at +1.5, all legal as long as bands don't overlap. New track claims7+ * a band of CHANNEL_CLEARANCE above its base (walls + marble headroom);8+ * islands claim their mass. Crossing over/under/around earlier track is9+ * exactly "my band fits between yours".10+ *11+ * Bands carry a `ref` (e.g. "seg/12", "isle/3") so a disposed segment or12+ * culled island can release everything it claimed — memory stays bounded by13+ * the streaming window, never by how far the line has travelled.14+ */15+16+export interface Band {17+ y0: number;18+ y1: number;19+ ref: string;20+}21+22+/** Vertical claim of a channel above its base: walls plus marble headroom.23+ * Two stacked channels at Δy = 1.0 (the kit's bridge separation) just clear:24+ * [y, y+0.99] vs [y+1, y+1.99]. */25+export const CHANNEL_CLEARANCE = 0.99;26+27+const key = (x: number, z: number) => `${x},${z}`;28+29+/** What a planner needs of the occupancy — satisfied by both the real map30+ * and a transaction overlay. */31+export interface OccView {32+ isFree(x: number, z: number, y0: number, y1: number): boolean;33+ at(x: number, z: number): readonly Band[];34+ claim(x: number, z: number, y0: number, y1: number, ref: string): void;35+}36+37+export class Occupancy implements OccView {38+ private cells = new Map<string, Band[]>();39+ /** ref → cell keys it claimed (for O(claims) release). */40+ private byRef = new Map<string, string[]>();41+42+ /** True when [y0, y1] overlaps no existing band at (x, z). */43+ isFree(x: number, z: number, y0: number, y1: number): boolean {44+ const bands = this.cells.get(key(x, z));45+ if (!bands) return true;46+ for (const b of bands) {47+ if (y0 < b.y1 && b.y0 < y1) return false;48+ }49+ return true;50+ }51+52+ /** Bands at a cell (empty array if none). */53+ at(x: number, z: number): readonly Band[] {54+ return this.cells.get(key(x, z)) ?? [];55+ }56+57+ claim(x: number, z: number, y0: number, y1: number, ref: string): void {58+ const k = key(x, z);59+ const bands = this.cells.get(k) ?? [];60+ bands.push({ y0, y1, ref });61+ this.cells.set(k, bands);62+ const keys = this.byRef.get(ref) ?? [];63+ keys.push(k);64+ this.byRef.set(ref, keys);65+ }66+67+ /** Claim a channel band: base y plus walls + marble headroom. */68+ claimChannel(x: number, z: number, baseY: number, ref: string): void {69+ this.claim(x, z, baseY, baseY + CHANNEL_CLEARANCE, ref);70+ }71+72+ /** Release every band claimed under `ref`. */73+ release(ref: string): void {74+ const keys = this.byRef.get(ref);75+ if (!keys) return;76+ this.byRef.delete(ref);77+ for (const k of keys) {78+ const bands = this.cells.get(k);79+ if (!bands) continue;80+ const kept = bands.filter((b) => b.ref !== ref);81+ if (kept.length === 0) this.cells.delete(k);82+ else this.cells.set(k, kept);83+ }84+ }85+86+ /** Every band claimed under `ref`, with its cell coordinates. */87+ claimsOf(ref: string): { x: number; z: number; y0: number; y1: number }[] {88+ const keys = this.byRef.get(ref) ?? [];89+ const out: { x: number; z: number; y0: number; y1: number }[] = [];90+ for (const k of keys) {91+ const [x, z] = k.split(",").map(Number);92+ for (const b of this.cells.get(k) ?? []) {93+ if (b.ref === ref) out.push({ x, z, y0: b.y0, y1: b.y1 });94+ }95+ }96+ return out;97+ }98+99+ /** Claimed cell count — bounded-memory sanity in tests. */100+ get size(): number {101+ return this.cells.size;102+ }103+}104+105+/**106+ * Write overlay over a base Occupancy: planning a segment claims here; a107+ * stuck plan is discarded wholesale (nothing touched the base), a finished108+ * plan commits its claims through. Cheap — O(claims of one segment).109+ */110+export class OccupancyTx implements OccView {111+ private added: {112+ x: number;113+ z: number;114+ y0: number;115+ y1: number;116+ ref: string;117+ }[] = [];118+ private byCell = new Map<string, Band[]>();119+120+ /** Base may itself be a transaction — probe plans stack a second layer. */121+ constructor(private readonly base: OccView) {}122+123+ isFree(x: number, z: number, y0: number, y1: number): boolean {124+ if (!this.base.isFree(x, z, y0, y1)) return false;125+ const bands = this.byCell.get(key(x, z));126+ if (!bands) return true;127+ for (const b of bands) {128+ if (y0 < b.y1 && b.y0 < y1) return false;129+ }130+ return true;131+ }132+133+ at(x: number, z: number): readonly Band[] {134+ const mine = this.byCell.get(key(x, z));135+ const base = this.base.at(x, z);136+ return mine ? [...base, ...mine] : base;137+ }138+139+ claim(x: number, z: number, y0: number, y1: number, ref: string): void {140+ this.added.push({ x, z, y0, y1, ref });141+ const k = key(x, z);142+ const bands = this.byCell.get(k) ?? [];143+ bands.push({ y0, y1, ref });144+ this.byCell.set(k, bands);145+ }146+147+ /** Flush every overlay claim into the base map, refs preserved. */148+ commit(): void {149+ for (const c of this.added) {150+ this.base.claim(c.x, c.z, c.y0, c.y1, c.ref);151+ }152+ this.added = [];153+ this.byCell.clear();154+ }155+}
A src/gen/endless/path.ts +638 -0
@@ -0,0 +1,638 @@
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+ */24+import type { Dir } from "../../shared/types.ts";25+import type { Rng } from "../rng.ts";26+import type { PieceRegistry } from "../registry.ts";27+import {28+ CLIMB_VOCAB,29+ DESCENT_VOCAB,30+ type Reservation,31+ type SlopeOption,32+ TURN_DESCENTS,33+} from "../elevation.ts";34+import {35+ applyXform,36+ DIR_STEP,37+ turnLeft,38+ turnRight,39+ windowXform,40+} from "../geom.ts";41+import { CHANNEL_CLEARANCE, type OccView } from "./occupancy.ts";42+43+/** Planner dead-end — the caller retries with a fresh rng label on a clean44+ * occupancy transaction. Deterministic per seed (dead ends are, too). */45+export class PlannerStuck extends Error {}46+47+/**48+ * The pocket guard: from `pose`, some flat continuation of `depth` cells49+ * must exist (every clause's first cell needs its flat band, so a head with50+ * no flat continuation is one clause away from stuck). `alsoBlocked`51+ * carries cells a pending-but-uncommitted clause would claim. The world52+ * generator reuses this after realization: WFC footprint extras land after53+ * planning, and a segment only commits if its exit corridor survived them.54+ */55+export function hasFlatContinuation(56+ occ: OccView,57+ pose: HeadPose,58+ depth: number,59+ alsoBlocked: ReadonlySet<string> = new Set(),60+): boolean {61+ if (62+ alsoBlocked.has(key(pose.x, pose.z)) ||63+ !occ.isFree(pose.x, pose.z, pose.y, pose.y + CHANNEL_CLEARANCE)64+ ) return false;65+ if (depth <= 1) return true;66+ for (const dirOut of [pose.dir, turnLeft(pose.dir), turnRight(pose.dir)]) {67+ const next: HeadPose = {68+ x: pose.x + DIR_STEP[dirOut][0],69+ z: pose.z + DIR_STEP[dirOut][1],70+ y: pose.y,71+ dir: dirOut,72+ };73+ if (hasFlatContinuation(occ, next, depth - 1, alsoBlocked)) return true;74+ }75+ return false;76+}77+78+export interface OpenCell {79+ x: number;80+ z: number;81+ dirIn: Dir;82+ dirOut: Dir;83+ move: "F" | "L" | "R";84+ /** Entry base height (world y — may be any multiple of 0.25). */85+ baseY: number;86+}87+88+/** Pose entering a cell: the cell's coordinates and the heading into it. */89+export interface HeadPose {90+ x: number;91+ z: number;92+ y: number;93+ dir: Dir;94+}95+96+export interface IslandCrossing {97+ /** World-unique island id (rng family `island/${id}`, claim ref `isle/${id}`). */98+ id: number;99+ /** Local index range of cells riding the island top. */100+ start: number;101+ len: number;102+ /** Island top surface height == baseY of the anchored cells. */103+ topY: number;104+ /** Blob margin (Chebyshev rings around the run) the mass may fill. */105+ margin: number;106+}107+108+export interface SegmentPlan {109+ index: number;110+ cells: OpenCell[];111+ /** Slope/set-piece windows (local indices) — realize commits them as-is. */112+ reservations: Reservation[];113+ crossings: IslandCrossing[];114+ entry: HeadPose;115+ exit: HeadPose;116+}117+118+/** Carried head-of-line state between segments (pose + anchor cadence). */119+export interface HeadState {120+ pose: HeadPose;121+ /** Cells travelled since the line last left an island. */122+ sinceAnchor: number;123+ /** Island due when sinceAnchor reaches this. */124+ anchorEvery: number;125+ /** Monotone island counter — ids must be world-unique. */126+ islandCount: number;127+}128+129+/** The line spawns anchored: segment 0 opens with its home island due. */130+export function initialHeadState(): HeadState {131+ return {132+ pose: { x: 0, z: 0, y: 0, dir: 0 },133+ sinceAnchor: 0,134+ anchorEvery: 0,135+ islandCount: 0,136+ };137+}138+139+/** Segment length floor — clauses run until at least this many cells. */140+const SEGMENT_TARGET = 26;141+/** Deepest mass an island envelope reserves below its top. */142+export const ISLAND_MAX_DEPTH = 3.25;143+/** Airspace above island surface cells reserved for vegetation — tall decor144+ * only grows where this claim succeeded (nature trees run to ~1.8). */145+export const CANOPY_HEIGHT = 1.8;146+/** Void-flight cells between islands (drawn per departure). */147+const ANCHOR_EVERY_MIN = 14;148+const ANCHOR_EVERY_MAX = 38;149+150+const key = (x: number, z: number) => `${x},${z}`;151+152+interface Step {153+ move: "F" | "L" | "R";154+ /** Band this cell claims, relative to its entry base: [lo, hi]. */155+ band: readonly [number, number];156+ /** Entry base delta applied AFTER this cell (step-at-start convention:157+ * a window's whole delta lands after its first cell). */158+ stepAfter?: number;159+}160+161+export function planSegment(162+ rng: Rng,163+ index: number,164+ state: HeadState,165+ occ: OccView,166+ registry: PieceRegistry,167+): { plan: SegmentPlan; next: HeadState } {168+ return new SegmentBuilder(rng, index, state, occ, registry).build();169+}170+171+class SegmentBuilder {172+ private cells: OpenCell[] = [];173+ private reservations: Reservation[] = [];174+ private crossings: IslandCrossing[] = [];175+ private pose: HeadPose;176+ private sinceAnchor: number;177+ private anchorEvery: number;178+ private islandCount: number;179+ private readonly ref: string;180+ private readonly entry: HeadPose;181+182+ constructor(183+ private readonly rng: Rng,184+ private readonly index: number,185+ state: HeadState,186+ private readonly occ: OccView,187+ private readonly registry: PieceRegistry,188+ ) {189+ this.pose = { ...state.pose };190+ this.sinceAnchor = state.sinceAnchor;191+ this.anchorEvery = state.anchorEvery;192+ this.islandCount = state.islandCount;193+ this.ref = `seg/${index}`;194+ this.entry = { ...state.pose };195+ }196+197+ build(): { plan: SegmentPlan; next: HeadState } {198+ while (this.cells.length < SEGMENT_TARGET) {199+ const at = this.cells.length;200+ const clauseRng = this.rng.child(`clause/${at}`);201+ if (this.sinceAnchor >= this.anchorEvery) {202+ if (this.tryIsland(clauseRng)) continue;203+ // No room for an island here — fly on and retry next clause.204+ if (this.tryVoidClause(clauseRng.child("fallback"))) continue;205+ } else if (this.tryVoidClause(clauseRng)) {206+ continue;207+ }208+ // Fully stuck: escape with a steep descent, the void below is the209+ // least-travelled direction (net drift is downward).210+ if (!this.tryEscape(clauseRng.child("escape"))) {211+ throw new PlannerStuck(212+ `segment ${this.index}, cell ${at}, pose ${213+ JSON.stringify(this.pose)214+ }`,215+ );216+ }217+ }218+ // Boundary margin: end the segment inside a straight so the WFC seam219+ // between segments always sits mid-run. Keep flying normal clauses220+ // until a 2-cell straight fits.221+ let guard = 0;222+ while (!this.commitSteps(this.straightSteps(2))) {223+ if (guard++ > 40) {224+ throw new PlannerStuck(`closing segment ${this.index}`);225+ }226+ const rng = this.rng.child(`boundary/${this.cells.length}`);227+ if (this.tryVoidClause(rng)) continue;228+ if (!this.tryEscape(rng.child("escape"))) {229+ throw new PlannerStuck(`closing segment ${this.index}`);230+ }231+ }232+233+ const plan: SegmentPlan = {234+ index: this.index,235+ cells: this.cells,236+ reservations: this.reservations,237+ crossings: this.crossings,238+ entry: this.entry,239+ exit: { ...this.pose },240+ };241+ return {242+ plan,243+ next: {244+ pose: { ...this.pose },245+ sinceAnchor: this.sinceAnchor,246+ anchorEvery: this.anchorEvery,247+ islandCount: this.islandCount,248+ },249+ };250+ }251+252+ // -------------------------------------------------------------------------253+ // Clause machinery254+ // -------------------------------------------------------------------------255+256+ /** Walk `steps` from the current pose; returns claimed cells + exit pose,257+ * or null when any band collides. Commits nothing. */258+ private probe(259+ steps: readonly Step[],260+ ):261+ | {262+ cells: { x: number; z: number; y: number }[];263+ exit: HeadPose;264+ }265+ | null {266+ let { x, z, y, dir } = this.pose;267+ const cells: { x: number; z: number; y: number }[] = [];268+ // Bands this walk would claim — a clause must not intersect ITSELF269+ // either (a turny island run can loop back before anything commits).270+ const pending = new Map<string, [number, number][]>();271+ for (const s of steps) {272+ const dirOut = s.move === "F"273+ ? dir274+ : s.move === "L"275+ ? turnLeft(dir)276+ : turnRight(dir);277+ const y0 = y + s.band[0];278+ const y1 = y + s.band[1];279+ if (!this.occ.isFree(x, z, y0, y1)) return null;280+ const k = key(x, z);281+ for (const [p0, p1] of pending.get(k) ?? []) {282+ if (y0 < p1 && p0 < y1) return null;283+ }284+ const bands = pending.get(k) ?? [];285+ bands.push([y0, y1]);286+ pending.set(k, bands);287+ cells.push({ x, z, y });288+ y += s.stepAfter ?? 0;289+ dir = dirOut;290+ x += DIR_STEP[dirOut][0];291+ z += DIR_STEP[dirOut][1];292+ }293+ return { cells, exit: { x, z, y, dir } };294+ }295+296+ /** Probe + pocket guard + append cells + claim bands + advance the pose. */297+ private commitSteps(steps: readonly Step[]): boolean {298+ const probed = this.probe(steps);299+ if (!probed) return false;300+ const pending = new Set(probed.cells.map((p) => key(p.x, p.z)));301+ if (!hasFlatContinuation(this.occ, probed.exit, 3, pending)) return false;302+ for (let i = 0; i < steps.length; i++) {303+ const s = steps[i];304+ const p = probed.cells[i];305+ const dirIn = this.pose.dir;306+ const dirOut = s.move === "F"307+ ? dirIn308+ : s.move === "L"309+ ? turnLeft(dirIn)310+ : turnRight(dirIn);311+ this.cells.push({312+ x: p.x,313+ z: p.z,314+ dirIn,315+ dirOut,316+ move: s.move,317+ baseY: p.y,318+ });319+ this.occ.claim(p.x, p.z, p.y + s.band[0], p.y + s.band[1], this.ref);320+ this.pose = {321+ x: p.x + DIR_STEP[dirOut][0],322+ z: p.z + DIR_STEP[dirOut][1],323+ y: p.y + (s.stepAfter ?? 0),324+ dir: dirOut,325+ };326+ this.sinceAnchor++;327+ }328+ return true;329+ }330+331+ private straightSteps(len: number): Step[] {332+ return Array.from({ length: len }, () => ({333+ move: "F" as const,334+ band: [0, CHANNEL_CLEARANCE] as const,335+ }));336+ }337+338+ // -------------------------------------------------------------------------339+ // Void-flight clauses340+ // -------------------------------------------------------------------------341+342+ private tryVoidClause(rng: Rng): boolean {343+ const kind = rng.weighted(344+ [345+ ["straight", 4],346+ ["turn", 2.5],347+ ["jog", 1.5],348+ ["slope", 3],349+ ["cornerDrop", 1.2],350+ ] as const,351+ );352+ const attempts: (() => boolean)[] = [];353+ switch (kind) {354+ case "straight":355+ attempts.push(() =>356+ this.commitSteps(this.straightSteps(rng.int(2, 6)))357+ );358+ // Inch forward: a 1-cell step navigates any corridor the pocket359+ // guard has promised, one clause at a time.360+ attempts.push(() => this.commitSteps(this.straightSteps(1)));361+ break;362+ case "turn": {363+ const first = rng.chance(0.5) ? "L" : "R";364+ for (const m of first === "L" ? ["L", "R"] : ["R", "L"]) {365+ attempts.push(() =>366+ this.commitSteps([367+ { move: m as "L" | "R", band: [0, CHANNEL_CLEARANCE] },368+ ])369+ );370+ }371+ break;372+ }373+ case "jog": {374+ // "FRL"/"FLR" jogs — the staircase signature the short s-curve375+ // pieces (and wiggle composites) collapse onto.376+ const first = rng.chance(0.5) ? ["F", "R", "L"] : ["F", "L", "R"];377+ const alt = first[1] === "R" ? ["F", "L", "R"] : ["F", "R", "L"];378+ for (const moves of [first, alt]) {379+ attempts.push(() =>380+ this.commitSteps(381+ moves.map((m) => ({382+ move: m as Step["move"],383+ band: [0, CHANNEL_CLEARANCE] as const,384+ })),385+ )386+ );387+ }388+ break;389+ }390+ case "slope":391+ return this.trySlope(rng);392+ case "cornerDrop":393+ return this.tryCornerDrop(rng);394+ }395+ for (const attempt of attempts) if (attempt()) return true;396+ return false;397+ }398+399+ /** Straight-run slope from the shared vocab. Descents dominate (the void400+ * drift is downward); climbs keep the rhythm honest. */401+ private trySlope(rng: Rng): boolean {402+ const climb = rng.chance(0.18);403+ const vocab = climb ? CLIMB_VOCAB : DESCENT_VOCAB;404+ const delta = rng.weighted(405+ (climb406+ ? [["0.5", 4], ["1", 2]]407+ : [["0.5", 3], ["1", 2.5], ["1.5", 2]]) as readonly (readonly [408+ string,409+ number,410+ ])[],411+ );412+ const options = vocab[delta];413+ const option: SlopeOption = rng.pick(options);414+ return this.commitSlope(option, climb);415+ }416+417+ private commitSlope(option: SlopeOption, climb: boolean): boolean {418+ // Build the step list part-by-part; each part is its own reservation419+ // (multi-part options chain, descents traverse parts in reverse —420+ // mirroring elevation.ts reserve()).421+ const parts = climb ? option.parts : option.parts.slice().reverse();422+ const steps: Step[] = [];423+ for (const part of parts) {424+ const signed = climb ? part.delta : -part.delta;425+ for (let j = 0; j < part.len; j++) {426+ const lo = climb ? 0 : signed;427+ const hi = (climb ? signed : 0) + CHANNEL_CLEARANCE;428+ steps.push({429+ move: "F",430+ band: [lo, hi],431+ ...(j === 0 ? { stepAfter: signed } : {}),432+ });433+ }434+ }435+ const start = this.cells.length;436+ if (!this.commitSteps(steps)) return false;437+ let at = start;438+ for (const part of parts) {439+ this.reservations.push({440+ start: at,441+ len: part.len,442+ pieceIds: part.ids,443+ reversed: !climb && !part.asIs,444+ });445+ at += part.len;446+ }447+ return true;448+ }449+450+ /** Turn-window set piece: helix quarters/halves and bare-corner451+ * drop-stacks from TURN_DESCENTS — the void's corner descents. */452+ private tryCornerDrop(rng: Rng): boolean {453+ const pattern = rng.weighted(454+ [455+ ["L", 1.5],456+ ["R", 1.5],457+ ["FLF", 3],458+ ["FRF", 3],459+ ["FFLFF", 1],460+ ["FFRFF", 1],461+ ["FLFFLF", 0.8],462+ ["FRFFRF", 0.8],463+ ] as const,464+ );465+ const spec = TURN_DESCENTS[pattern];466+ const def = this.registry.variants.find(467+ (v) => v.id === spec.ids[0] && !v.reversed,468+ );469+ if (!def) return false;470+471+ const steps: Step[] = pattern.split("").map((m, j) => ({472+ move: m as Step["move"],473+ band: [-spec.delta, CHANNEL_CLEARANCE] as const,474+ ...(j === 0 ? { stepAfter: -spec.delta } : {}),475+ }));476+477+ // Footprint extras (arc interiors) must be free too — reservations are478+ // committed unchecked by realize, so the planner is the gatekeeper.479+ const probed = this.probe(steps);480+ if (!probed) return false;481+ const first = probed.cells[0];482+ const xf = windowXform(first.x, first.z, this.pose.dir, 0);483+ const windowSet = new Set(probed.cells.map((p) => key(p.x, p.z)));484+ const extras: [number, number][] = [];485+ for (const [fx, fz] of def.footprint) {486+ const w = applyXform(xf, [fx, 0, fz]);487+ const wx = Math.round(w[0]);488+ const wz = Math.round(w[2]);489+ if (windowSet.has(key(wx, wz))) continue;490+ if (491+ !this.occ.isFree(492+ wx,493+ wz,494+ first.y - spec.delta,495+ first.y + CHANNEL_CLEARANCE,496+ )497+ ) return false;498+ extras.push([wx, wz]);499+ }500+501+ const start = this.cells.length;502+ if (!this.commitSteps(steps)) return false;503+ for (const [wx, wz] of extras) {504+ this.occ.claim(505+ wx,506+ wz,507+ first.y - spec.delta,508+ first.y + CHANNEL_CLEARANCE,509+ this.ref,510+ );511+ }512+ this.reservations.push({513+ start,514+ len: pattern.length,515+ pieceIds: spec.ids,516+ reversed: false,517+ });518+ return true;519+ }520+521+ /** Last resort when boxed in: steep 1.5 chute straight down and out. */522+ private tryEscape(rng: Rng): boolean {523+ for (const delta of ["1.5", "1", "0.5"]) {524+ for (const option of DESCENT_VOCAB[delta]) {525+ if (this.commitSlope(option, false)) return true;526+ }527+ }528+ // Try turning first, then dive.529+ for (const m of rng.chance(0.5) ? ["L", "R"] : ["R", "L"]) {530+ if (531+ this.commitSteps([532+ { move: m as "L" | "R", band: [0, CHANNEL_CLEARANCE] },533+ ])534+ ) {535+ return true;536+ }537+ }538+ // Inch forward along whatever corridor the guard last promised.539+ return this.commitSteps(this.straightSteps(1));540+ }541+542+ // -------------------------------------------------------------------------543+ // Island anchor clause544+ // -------------------------------------------------------------------------545+546+ private tryIsland(rng: Rng): boolean {547+ const runLen = rng.int(5, 11);548+ const margin = runLen >= 8 ? rng.int(2, 4) : rng.int(1, 3);549+550+ // The run itself: flat, mostly straight, an occasional bend so the mass551+ // isn't always a bar. First/last cells stay straight (clean shoreline552+ // ports). Mass needs at least one block below every run cell.553+ const steps: Step[] = [];554+ for (let j = 0; j < runLen; j++) {555+ const edge = j === 0 || j === runLen - 1;556+ const move = !edge && rng.chance(0.28)557+ ? (rng.chance(0.5) ? "L" : "R")558+ : "F";559+ steps.push({ move, band: [-1, CHANNEL_CLEARANCE] });560+ }561+ const probed = this.probe(steps);562+ if (!probed) return false;563+564+ const id = this.islandCount;565+ const topY = this.pose.y;566+ const start = this.cells.length;567+ if (!this.commitSteps(steps)) return false;568+ const runCells = probed.cells.map((p) => [p.x, p.z] as const);569+570+ // Claim the mass envelope now — blob rings around the run, each cell571+ // reserving as much depth below topY as older claims allow. The island572+ // builder works strictly inside `isle/${id}` claims.573+ const isleRef = `isle/${id}`;574+ const envelope = new Map<string, [number, number]>();575+ for (const [cx, cz] of runCells) {576+ for (let dx = -margin; dx <= margin; dx++) {577+ for (let dz = -margin; dz <= margin; dz++) {578+ envelope.set(key(cx + dx, cz + dz), [cx + dx, cz + dz]);579+ }580+ }581+ }582+ // Approach/departure corridors stay canopy-free: the line must be able583+ // to arrive and leave at surface height, and the promise the run's584+ // pocket guard made about the exit must survive the envelope claims.585+ // (Bonus: a natural clearing where the track crosses the shoreline.)586+ const corridor = new Set<string>();587+ {588+ const exit = this.pose; // commitSteps already advanced past the run589+ const entry = this.cells[start];590+ for (let k = 0; k <= margin + 2; k++) {591+ corridor.add(592+ key(593+ exit.x + DIR_STEP[exit.dir][0] * k,594+ exit.z + DIR_STEP[exit.dir][1] * k,595+ ),596+ );597+ corridor.add(598+ key(599+ entry.x - DIR_STEP[entry.dirIn][0] * k,600+ entry.z - DIR_STEP[entry.dirIn][1] * k,601+ ),602+ );603+ }604+ }605+ for (const [cx, cz] of envelope.values()) {606+ // Deepest free band under the top: stop at the first older claim.607+ let floor = topY - ISLAND_MAX_DEPTH;608+ for (const b of this.occ.at(cx, cz)) {609+ if (b.ref === this.ref || b.ref === isleRef) continue;610+ if (b.y1 <= topY + 1e-9 && b.y1 > floor) floor = b.y1;611+ // A band overlapping [floor, topY) from above (old track between612+ // floor and top) — mass can't reach the top here; skip the cell.613+ if (b.y0 < topY - 1e-9 && b.y1 > topY - 1e-9) {614+ floor = topY;615+ }616+ }617+ if (topY - floor < 0.999) continue; // no room for even one block618+ this.occ.claim(cx, cz, floor, topY, isleRef);619+ // Canopy airspace: non-run surface cells may grow tall decor (trees,620+ // banners); claim the air above them so no future line threads621+ // through a crown. Cells whose airspace is already spoken for simply622+ // stay vegetation-free (the builder checks for this claim).623+ if (624+ !runCells.some(([rx, rz]) => rx === cx && rz === cz) &&625+ !corridor.has(key(cx, cz)) &&626+ this.occ.isFree(cx, cz, topY, topY + CANOPY_HEIGHT)627+ ) {628+ this.occ.claim(cx, cz, topY, topY + CANOPY_HEIGHT, isleRef);629+ }630+ }631+632+ this.crossings.push({ id, start, len: runLen, topY, margin });633+ this.islandCount++;634+ this.sinceAnchor = 0;635+ this.anchorEvery = rng.int(ANCHOR_EVERY_MIN, ANCHOR_EVERY_MAX);636+ return true;637+ }638+}
A src/gen/endless/world.ts +294 -0
@@ -0,0 +1,294 @@
1+/**2+ * EndlessWorld — the streaming generator behind endless mode.3+ *4+ * One instance owns the seed, the shared 3D occupancy, and the head-of-line5+ * state, and generates segments strictly in order (segment i+1 grows from6+ * segment i's exit pose — the continuity is the point; determinism per seed7+ * is total because every random decision is label-derived from8+ * `segment/${i}` / `island/${k}` families).9+ *10+ * Pipeline per segment: planSegment (void clauses + island anchors, claims11+ * occupancy) → realize (open-path WFC over the same piece vocabulary, world12+ * occupancy vetoing footprint extras) → assemble (open: no supports, no13+ * goal, no solids) → buildIsland per crossing. The client asks for segments14+ * ahead of the marble and culls behind it; culling releases every claim the15+ * segment and its islands made, so memory tracks the streaming window, not16+ * the distance travelled.17+ */18+import type { LoopPath, Placement } from "../../shared/types.ts";19+import { rngFamily } from "../rng.ts";20+import { buildRegistry, type PieceRegistry } from "../registry.ts";21+import { pickStyle, type StyleField } from "../style.ts";22+import type { ElevationPlan } from "../elevation.ts";23+import type { Skeleton } from "../topology.ts";24+import { realize } from "../wfc.ts";25+import { assemble } from "../assemble.ts";26+import type { DecorCatalog } from "../decor.ts";27+import { applyXform, windowXform } from "../geom.ts";28+import { CHANNEL_CLEARANCE, Occupancy, OccupancyTx } from "./occupancy.ts";29+import {30+ hasFlatContinuation,31+ type HeadState,32+ initialHeadState,33+ PlannerStuck,34+ planSegment,35+ type SegmentPlan,36+} from "./path.ts";37+import { buildIsland, type Island } from "./islands.ts";38+39+export interface EndlessSegment {40+ index: number;41+ plan: SegmentPlan;42+ /** Track placements only — island placements live on the islands. */43+ placements: Placement[];44+ /** Open path through this segment, entry → exit (LoopPath shape, but45+ * nothing wraps — the "loop" never closes). */46+ path: LoopPath;47+ islands: Island[];48+ /** Cumulative arc length of the line at this segment's entry. */49+ startS: number;50+}51+52+const key = (x: number, z: number) => `${x},${z}`;53+54+/** Live window: how many trailing segments keep their data + claims. The55+ * client's render window (ahead + behind) must fit inside this. */56+export const KEEP_SEGMENTS = 10;57+58+/** Successor-probe labels: a segment commits only if its successor plans59+ * under at least one of these; the successor retries them last. */60+const PROBE_LABELS = ["probe", "probe/1", "probe/2", "probe/3"];61+62+export class EndlessWorld {63+ readonly seed: string;64+ readonly style: StyleField;65+ private readonly registry: PieceRegistry;66+ private readonly catalog: DecorCatalog;67+ private readonly occ = new Occupancy();68+ private state: HeadState = initialHeadState();69+ private segments: (EndlessSegment | null)[] = [];70+ /** First index not yet culled (segments before it are tombstones). */71+ private firstLive = 0;72+ /** Cumulative arc length at the head — survives culling. */73+ private nextStartS = 0;74+75+ constructor(seed: string, catalog: DecorCatalog = { packs: [] }) {76+ this.seed = seed;77+ this.catalog = catalog;78+ this.registry = buildRegistry();79+ // Same style family as finite mode: one seed, one theme, both modes.80+ this.style = pickStyle(rngFamily(seed, "style"));81+ }82+83+ /** Segment by index — generates forward as needed (never backward). */84+ get(i: number): EndlessSegment {85+ if (i < this.firstLive) {86+ throw new Error(87+ `segment ${i} already culled (firstLive ${this.firstLive})`,88+ );89+ }90+ while (this.segments.length <= i) this.generateNext();91+ return this.segments[i]!;92+ }93+94+ /** Number of segments generated so far. */95+ get generatedCount(): number {96+ return this.segments.length;97+ }98+99+ /** Occupancy cell count — bounded-memory checks in tests. */100+ get occupancySize(): number {101+ return this.occ.size;102+ }103+104+ /**105+ * Culling is GENERATION-DRIVEN, never client-driven: when segment i is106+ * generated, segment i − KEEP_SEGMENTS is released (occupancy claims of107+ * its track and islands — an island's anchorage IS its birth segment) and108+ * its data dropped. Pinning cull timing to generation keeps the stream a109+ * pure function of the seed; if it followed the realtime marble instead,110+ * whether an old claim still existed when a segment was planned would111+ * depend on frame timing. The client sizes its render window under112+ * KEEP_SEGMENTS and disposes scene groups on its own schedule — that's113+ * rendering, not world state.114+ */115+ private cullBefore(index: number): void {116+ for (117+ let i = this.firstLive;118+ i < Math.min(index, this.segments.length);119+ i++120+ ) {121+ const seg = this.segments[i];122+ if (!seg) continue;123+ this.occ.release(`seg/${i}`);124+ for (const isle of seg.islands) this.occ.release(isle.ref);125+ this.segments[i] = null;126+ }127+ this.firstLive = Math.max(this.firstLive, index);128+ }129+130+ /** True when segment `index` plans under at least one probe label, on a131+ * throwaway transaction stacked over `base`. */132+ private successorPlans(133+ index: number,134+ state: HeadState,135+ base: OccupancyTx,136+ ): boolean {137+ const rng = rngFamily(this.seed, `segment/${index}`);138+ for (const label of PROBE_LABELS) {139+ try {140+ planSegment(141+ rng.child(label),142+ index,143+ state,144+ new OccupancyTx(base),145+ this.registry,146+ );147+ return true;148+ } catch (err) {149+ if (!(err instanceof PlannerStuck)) throw err;150+ }151+ }152+ return false;153+ }154+155+ private generateNext(): void {156+ const i = this.segments.length;157+ const segRng = rngFamily(this.seed, `segment/${i}`);158+ // Transactional generation: plan AND realize inside one occupancy159+ // transaction. WFC footprint extras (bend arc interiors, wave flanks…)160+ // are world matter that only exists after realization — a segment161+ // commits only if the exit corridor survives them; otherwise the whole162+ // attempt is discarded and replanned under a fresh rng label. Dead ends163+ // and retries are both deterministic per seed.164+ let result:165+ | {166+ plan: ReturnType<typeof planSegment>["plan"];167+ next: ReturnType<typeof planSegment>["next"];168+ windows: ReturnType<typeof realize>;169+ skeleton: Skeleton;170+ elevation: ElevationPlan;171+ tx: OccupancyTx;172+ }173+ | null = null;174+ // Attempt order: the plain label, fresh retries, then the probe label175+ // set — our predecessor proved at least one of those plans from our176+ // exact entry state, so they're the near-guaranteed fallbacks.177+ const labels = [178+ "",179+ "retry/1",180+ "retry/2",181+ "retry/3",182+ "retry/4",183+ "retry/5",184+ ...PROBE_LABELS,185+ ];186+ for (const label of labels) {187+ if (result) break;188+ const tx = new OccupancyTx(this.occ);189+ const rng = label === "" ? segRng : segRng.child(label);190+ let planned;191+ try {192+ planned = planSegment(rng, i, this.state, tx, this.registry);193+ } catch (err) {194+ if (err instanceof PlannerStuck) continue;195+ throw err;196+ }197+ const { plan, next } = planned;198+ const skeleton: Skeleton = {199+ cells: plan.cells,200+ crossings: [],201+ helixLoops: [],202+ };203+ const elevation: ElevationPlan = {204+ baseY: plan.cells.map((c) => c.baseY),205+ reservations: plan.reservations,206+ maxBaseY: Math.max(...plan.cells.map((c) => c.baseY)),207+ };208+ const windows = realize(209+ rng.child("wfc"),210+ skeleton,211+ elevation,212+ this.style,213+ this.registry,214+ [],215+ {216+ open: true,217+ extraFree: (x, z, baseY) =>218+ tx.isFree(x, z, baseY, baseY + CHANNEL_CLEARANCE),219+ },220+ );221+ // Claim the extras into the transaction, then confirm the exit222+ // corridor is still alive.223+ for (const win of windows) {224+ const cell = plan.cells[win.start];225+ const xf = windowXform(cell.x, cell.z, cell.dirIn, 0);226+ const windowSet = new Set<string>();227+ for (let j = 0; j < win.len; j++) {228+ const c = plan.cells[win.start + j];229+ windowSet.add(key(c.x, c.z));230+ }231+ const baseY = cell.baseY;232+ for (const [fx, fz] of win.def.footprint) {233+ const w = applyXform(xf, [fx, 0, fz]);234+ const wx = Math.round(w[0]);235+ const wz = Math.round(w[2]);236+ if (windowSet.has(key(wx, wz))) continue;237+ // Reserved set-pieces' extras were claimed by the planner238+ // already — not free there means ours, skip the duplicate.239+ if (tx.isFree(wx, wz, baseY, baseY + CHANNEL_CLEARANCE)) {240+ tx.claim(wx, wz, baseY, baseY + CHANNEL_CLEARANCE, `seg/${i}`);241+ }242+ }243+ }244+ // Exit viability, two stages. First the cheap one: a flat corridor245+ // must survive this attempt's claims (path, islands, canopy, WFC246+ // extras). Then the real one: PLAN THE SUCCESSOR as probes on a247+ // stacked transaction — no fixed-depth corridor check can rule out248+ // every cul-de-sac, but "segment i+1 actually plans" can. Probes use249+ // a fixed label set so the successor's own last-resort attempts250+ // replay exactly what was validated here; the set (rather than one251+ // label) keeps a single unlucky hypothetical route from vetoing a252+ // good attempt.253+ if (!hasFlatContinuation(tx, plan.exit, 6)) continue;254+ if (!this.successorPlans(i + 1, next, tx)) continue;255+ result = { plan, next, windows, skeleton, elevation, tx };256+ }257+ if (!result) {258+ throw new Error(259+ `endless planner exhausted retries at segment ${i} (seed ${this.seed})`,260+ );261+ }262+ result.tx.commit();263+ const { plan, next, windows, skeleton, elevation } = result;264+ this.state = next;265+ const assembled = assemble(skeleton, elevation, this.style, windows, {266+ open: true,267+ });268+269+ // Islands anchored in this segment.270+ const islands = plan.crossings.map((crossing) =>271+ buildIsland(272+ rngFamily(this.seed, `island/${crossing.id}`),273+ crossing,274+ plan.cells275+ .slice(crossing.start, crossing.start + crossing.len)276+ .map((c) => [c.x, c.z] as const),277+ this.occ,278+ this.style,279+ this.catalog,280+ )281+ );282+283+ this.segments.push({284+ index: i,285+ plan,286+ placements: assembled.placements,287+ path: assembled.loop,288+ islands,289+ startS: this.nextStartS,290+ });291+ this.nextStartS += assembled.loop.totalLength;292+ this.cullBefore(i - KEEP_SEGMENTS);293+ }294+}
A src/gen/fans.ts +93 -0
@@ -0,0 +1,93 @@
1+/**2+ * Trackside fans as TRACK ELEMENTS (M4): placed deliberately against a3+ * ground-level straight, blades facing the channel, with a marble boost.4+ * (They lived in the decor mid band before — flush against the track their5+ * palette-tinted housings read as deck protrusions; deliberate placement +6+ * orientation + the boost make them read as machinery instead.)7+ */8+import type { Dir, Placement } from "../shared/types.ts";9+import type { Rng } from "./rng.ts";10+import { DIR_STEP } from "./geom.ts";11+12+const FAN_CHANCE = 0.35;13+const BOOST = 6; // marble acceleration (units/s²) while in range14+15+export interface FanPlan {16+ placements: Placement[];17+ cells: string[];18+}19+20+export function planFans(21+ rng: Rng,22+ /** All placements so far (straights located, cells avoided). */23+ placements: readonly Placement[],24+ occupied: ReadonlySet<string>,25+): FanPlan {26+ const empty: FanPlan = { placements: [], cells: [] };27+ if (!rng.chance(FAN_CHANCE)) return empty;28+29+ const key = (x: number, z: number) => `${x},${z}`;30+ const arched = new Set(31+ placements32+ .filter((p) => p.pieceId === "tunnel")33+ .map((p) => key(Math.round(p.pos[0]), Math.round(p.pos[2]))),34+ );35+ // Bridge decks stack over channels — a fan under a bridge clips the deck.36+ const cellCounts = new Map<string, number>();37+ for (const p of placements) {38+ if (p.role !== "track") continue;39+ const k = key(Math.round(p.pos[0]), Math.round(p.pos[2]));40+ cellCounts.set(k, (cellCounts.get(k) ?? 0) + 1);41+ }42+43+ interface Candidate {44+ cell: [number, number];45+ /** Direction from the fan cell toward the track. */46+ facing: Dir;47+ }48+ const taken = new Set<string>(occupied);49+ const candidates: Candidate[] = [];50+ for (const p of placements) {51+ if (p.pieceId !== "straight" || p.role !== "track") continue;52+ if (p.pos[1] !== 0) continue;53+ const cx = Math.round(p.pos[0]);54+ const cz = Math.round(p.pos[2]);55+ if (arched.has(key(cx, cz))) continue;56+ if ((cellCounts.get(key(cx, cz)) ?? 0) > 1) continue;57+ const flow = ((1 - p.rotation + 4) % 4) as Dir;58+ for (const side of [1, 3] as const) {59+ const w = ((flow + side) % 4) as Dir;60+ const [dx, dz] = DIR_STEP[w];61+ const s1: [number, number] = [cx + dx, cz + dz];62+ if (taken.has(key(...s1))) continue;63+ candidates.push({ cell: s1, facing: ((w + 2) % 4) as Dir });64+ }65+ }66+ if (candidates.length === 0) return empty;67+68+ const count = rng.int(1, 3);69+ const plan: FanPlan = { placements: [], cells: [] };70+ for (const cand of rng.shuffle(candidates)) {71+ if (plan.placements.length >= count) break;72+ if (taken.has(key(...cand.cell))) continue;73+ plan.placements.push({74+ pieceId: rng.pick([75+ "fan-standing-two",76+ "fan-standing-four",77+ "fan-two",78+ "fan-four",79+ "fan-simple-two",80+ "fan-simple-four",81+ ]),82+ pos: [cand.cell[0], 0, cand.cell[1]],83+ // Local +z (the blade side) faces the track.84+ rotation: ((1 - cand.facing + 4) % 4) as Dir,85+ role: "track",86+ spin: { axis: [0, 0, 1], radPerSec: 4 + rng.float() * 3 },87+ boost: BOOST,88+ });89+ taken.add(key(...cand.cell));90+ plan.cells.push(key(...cand.cell));91+ }92+ return plan;93+}
A src/gen/features.ts +161 -0
@@ -0,0 +1,161 @@
1+/**2+ * Feature planner: reserves ring intervals for set-piece runs that free3+ * collapse can't guarantee — currently wide-channel runs (funnel in, wide4+ * straights through, funnel out). Wide pieces bulge 0.35 into the flanking5+ * cells, so both lateral neighbors of every cell in a run must be track-free;6+ * that check happens here, at plan time, because reservations bypass the7+ * WFC's candidate vetting. Width continuity is guaranteed by construction8+ * (runs are always funnel-bracketed); realize() asserts it as an invariant.9+ */10+import type { Rng } from "./rng.ts";11+import type { Skeleton } from "./topology.ts";12+import type { Reservation } from "./elevation.ts";13+import type { StyleField } from "./style.ts";14+import { DIR_STEP, turnLeft, turnRight } from "./geom.ts";15+16+/**17+ * Runs BEFORE the elevation planner (the ring is all-flat at plan time);18+ * elevation then avoids feature-reserved cells, which keeps every feature19+ * window at constant base height by construction.20+ */21+export function planFeatures(22+ rng: Rng,23+ skeleton: Skeleton,24+ style: StyleField,25+): Reservation[] {26+ const out: Reservation[] = [];27+ const n = skeleton.cells.length;28+29+ // Grade-crossing cells are spoken for (the cross piece owns them; a wide30+ // channel through a crossing has no piece).31+ const crossingIdx = new Set<number>();32+ for (const { a, b } of skeleton.crossings) {33+ crossingIdx.add(a);34+ crossingIdx.add(b);35+ }36+37+ const wideRng = rng.child("wide");38+ const want = wideRng.chance(style.wideAffinity)39+ ? (wideRng.chance(style.wideAffinity * 0.5) ? 2 : 1)40+ : 0;41+42+ const ringCells = new Set(skeleton.cells.map((c) => `${c.x},${c.z}`));43+ const claimedFlanks = new Set<string>();44+45+ // Candidate intervals: maximal straight runs. The skeleton is normalized46+ // to start at a turn, so runs never wrap the seam.47+ const runs: { start: number; len: number }[] = [];48+ let i = 0;49+ while (i < n) {50+ if (skeleton.cells[i].move !== "F" || crossingIdx.has(i)) {51+ i++;52+ continue;53+ }54+ let len = 0;55+ while (56+ i + len < n && skeleton.cells[i + len].move === "F" &&57+ !crossingIdx.has(i + len)58+ ) len++;59+ if (len >= 3) runs.push({ start: i, len });60+ i += len;61+ }62+63+ /** Both flank cells of every interval cell must be track-free and not64+ * claimed by another feature. Returns the flank keys, or null. */65+ const flanksClear = (start: number, total: number): string[] | null => {66+ const flanks: string[] = [];67+ for (let j = 0; j < total; j++) {68+ const c = skeleton.cells[start + j];69+ for (const side of [turnLeft(c.dirIn), turnRight(c.dirIn)]) {70+ const [dx, dz] = DIR_STEP[side];71+ const k = `${c.x + dx},${c.z + dz}`;72+ if (ringCells.has(k) || claimedFlanks.has(k)) return null;73+ flanks.push(k);74+ }75+ }76+ return flanks;77+ };78+79+ let placed = 0;80+ for (const run of wideRng.shuffle(runs)) {81+ if (placed >= want) break;82+ // funnel + 1..4 wide straights + funnel; long runs sometimes upgrade a83+ // transition to funnel-long (straight + taper + wide fused, 3 cells).84+ const entryLen = run.len >= 6 && wideRng.chance(0.35) ? 3 : 1;85+ const exitLen = run.len >= entryLen + 5 && wideRng.chance(0.35) ? 3 : 1;86+ const maxMid = Math.min(run.len - entryLen - exitLen, 4);87+ if (maxMid < 1) continue;88+ const mid = wideRng.int(1, maxMid + 1);89+ const total = entryLen + mid + exitLen;90+ const start = run.start + wideRng.int(0, run.len - total + 1);91+92+ const flanks = flanksClear(start, total);93+ if (!flanks) continue;94+95+ out.push({96+ start,97+ len: entryLen,98+ pieceIds: [entryLen === 3 ? "funnel-long" : "funnel"],99+ reversed: false,100+ });101+ for (let j = 0; j < mid; j++) {102+ out.push({103+ start: start + entryLen + j,104+ len: 1,105+ pieceIds: ["straight-wide"],106+ reversed: false,107+ });108+ }109+ out.push({110+ start: start + entryLen + mid,111+ len: exitLen,112+ pieceIds: [exitLen === 3 ? "funnel-long" : "funnel"],113+ reversed: true,114+ });115+ for (const k of flanks) claimedFlanks.add(k);116+ placed++;117+ }118+119+ // Split–merge diamond: one per track at most, on a straight run with both120+ // flanks free for the whole span (the branches run one lane out each side).121+ const diamondRng = rng.child("diamond");122+ if (diamondRng.chance(0.4)) {123+ const claimedIdx = new Set<number>();124+ for (const r of out) {125+ for (let j = 0; j < r.len; j++) claimedIdx.add(r.start + j);126+ }127+ const overlaps = (start: number, total: number): boolean => {128+ for (let j = 0; j < total; j++) {129+ if (claimedIdx.has(start + j)) return true;130+ }131+ return false;132+ };133+ // Exhaustive within each run (eligible geometry is scarce): prefer the134+ // longest branch that fits, sliding the window across the run.135+ placing:136+ for (const run of diamondRng.shuffle(runs)) {137+ const maxN = Math.min(3, run.len - 4);138+ for (let N = maxN; N >= 1; N--) {139+ const total = 4 + N;140+ const offsets = diamondRng.shuffle(141+ Array.from({ length: run.len - total + 1 }, (_, o) => o),142+ );143+ for (const o of offsets) {144+ const start = run.start + o;145+ if (overlaps(start, total)) continue;146+ const flanks = flanksClear(start, total);147+ if (!flanks) continue;148+ out.push({149+ start,150+ len: total,151+ pieceIds: [`diamond-${N}`],152+ reversed: false,153+ });154+ for (const k of flanks) claimedFlanks.add(k);155+ break placing;156+ }157+ }158+ }159+ }160+ return out;161+}
M src/gen/generateTrack.ts +93 -4
@@ -5,15 +5,24 @@
55 * Each subsystem draws only from its own labeled RNG family, so adding66 * systems later cannot perturb existing seeds' outputs.77 */8-import type { Track } from "../shared/types.ts";8+import type { Track, Vec3 } from "../shared/types.ts";99 import { rngFamily } from "./rng.ts";1010 import { buildRegistry } from "./registry.ts";1111 import { generateSkeleton } from "./topology.ts";1212 import { planElevation } from "./elevation.ts";1313 import { pickStyle } from "./style.ts";14+import { planFeatures } from "./features.ts";1415 import { realize } from "./wfc.ts";1516 import { assemble } from "./assemble.ts";16-import { type DecorCatalog, pickSetting, placeDecor } from "./decor.ts";17+import { planStubs } from "./stubs.ts";18+import { planFans } from "./fans.ts";19+import {20+ cellSetOracle,21+ type DecorCatalog,22+ pickSetting,23+ placeDecor,24+ regionAround,25+} from "./decor.ts";1726 import { validateTrack } from "./validate.ts";1827 1928 export interface GenerateOptions {
@@ -25,32 +34,111 @@ export interface GenerateOptions {
2534 2635 const registry = buildRegistry();2736 37+/** Single-track ambient margin — wide enough that the field fades into fog38+ * instead of ending at a visible decor ring. */39+const DECOR_MARGIN = 13;40+2841 export function generateTrack(2942 seed: string,3043 catalog: DecorCatalog = { packs: [] },3144 opts: GenerateOptions = {},3245 ): Track {3346 const skeleton = generateSkeleton(rngFamily(seed, "topology"));34- const elevation = planElevation(rngFamily(seed, "elevation"), skeleton);3547 const style = pickStyle(rngFamily(seed, "style"));48+ // Features plan first (the ring is flat at plan time); elevation then49+ // steers its hills around the feature-reserved cells. Helix-loop tracks50+ // skip wide runs: the mandatory 2.0 climb must not lose its straight run51+ // to the feature planner.52+ const features = skeleton.helixLoops.length > 053+ ? []54+ : planFeatures(rngFamily(seed, "features"), skeleton, style);55+ const blocked = new Set<number>();56+ for (const r of features) {57+ for (let j = 0; j < r.len; j++) {58+ blocked.add((r.start + j) % skeleton.cells.length);59+ }60+ }61+ const elevation = planElevation(62+ rngFamily(seed, "elevation"),63+ skeleton,64+ registry,65+ blocked,66+ );67+ // Crossings are pre-reserved. Flat pierce: the 4-way cross mesh at the68+ // first visit + the noMesh phantom at the second. Bridge pierce: the69+ // upper visit (always the column, index-wise first) is the deck —70+ // sometimes with a pierced floor, the marble's chance-fall onto the71+ // channel below — and the lower visit is a plain straight.72+ const bridge = skeleton.pierce?.bridge ?? false;73+ const fallsRng = rngFamily(seed, "falls");74+ const falls: { pos: Vec3; landPos: Vec3 }[] = [];75+ const crossReservations = skeleton.crossings.flatMap(({ a, b }, i) => {76+ if (!bridge) {77+ return [78+ { start: a, len: 1, pieceIds: ["cross"], reversed: false },79+ { start: b, len: 1, pieceIds: ["cross-over"], reversed: false },80+ ];81+ }82+ const hole = fallsRng.child(`hole/${i}`).chance(0.6);83+ if (hole) {84+ const c = skeleton.cells[a];85+ falls.push({86+ pos: [c.x, 1.25, c.z],87+ landPos: [c.x, 0.25, c.z],88+ });89+ }90+ return [91+ {92+ start: a,93+ len: 1,94+ pieceIds: [hole ? "straight-hole" : "straight"],95+ reversed: false,96+ },97+ { start: b, len: 1, pieceIds: ["straight"], reversed: false },98+ ];99+ });36100 const windows = realize(37101 rngFamily(seed, "wfc"),38102 skeleton,39103 elevation,40104 style,41105 registry,106+ [...features, ...crossReservations],42107 );43108 const assembled = assemble(skeleton, elevation, style, windows);44109 45110 const placements = [...assembled.placements];111+112+ // Dead-end sidings: real split junctions in the ring with short branches.113+ const stubs = planStubs(114+ rngFamily(seed, "stubs"),115+ placements,116+ assembled.trackCells,117+ );118+ placements.push(...stubs.placements);119+ for (const cell of stubs.cells) assembled.trackCells.add(cell);120+121+ // Trackside fans: deliberate machinery with a marble boost (M4).122+ const fans = planFans(123+ rngFamily(seed, "fans"),124+ placements,125+ assembled.trackCells,126+ );127+ placements.push(...fans.placements);128+ for (const cell of fans.cells) assembled.trackCells.add(cell);46129 const setting = pickSetting(rngFamily(seed, "setting"), catalog);47130 if (!opts.skipDecor) {131+ // Decor is a world field: single-track mode realizes it for the track's132+ // bounds plus an ambient margin (the fog eats the region edge), with an133+ // oracle over the one ring's cells. M5 swaps in chunk regions + a134+ // chunk-backed oracle without touching placeDecor.48135 placements.push(49136 ...placeDecor(50137 rngFamily(seed, "decor"),51138 setting,52139 style,53- assembled.trackCells,140+ regionAround(assembled.trackCells, DECOR_MARGIN),141+ cellSetOracle(assembled.trackCells),54142 catalog,55143 ),56144 );
@@ -60,6 +148,7 @@ export function generateTrack(
60148 seed,61149 placements,62150 loop: assembled.loop,151+ ...(falls.length > 0 ? { falls } : {}),63152 stats: {64153 theme: style.theme,65154 paletteIndex: style.paletteIndex,
M src/gen/registry.ts +1096 -34
@@ -14,7 +14,6 @@ import {
1414 opposite,1515 reverseSeg,1616 rotP,17- rotVec3,1817 segPoint,1918 turnLeft,2019 turnRight,
@@ -29,13 +28,23 @@ export interface PortPose {
2928 baseY: number;3029 }3130 31+/** Secondary mesh of a composite piece, posed in entry frame. */32+export interface Overlay {33+ id: string;34+ offset: Vec3;35+ /** Extra quarter-turns on top of the placement rotation. */36+ rot?: Dir;37+ /** Render this mesh x-flipped (independent of the def's own flag). */38+ mirrored?: boolean;39+}40+3241 export interface PieceDef {3342 /** Piece identity (style weights, reservations key on this). */3443 id: string;3544 /** GLB mesh to place, when it differs from `id` (e.g. tunnel = straight). */3645 meshId?: string;3746 /** Extra meshes stacked on the base mesh, offsets in entry frame. */38- overlays?: readonly { id: string; offset: Vec3 }[];47+ overlays?: readonly Overlay[];3948 family: string;4049 /** Skeleton moves consumed, e.g. "F", "L", "FLF", "FFLFF". */4150 moves: string;
@@ -54,6 +63,12 @@ export interface PieceDef {
5463 supportCells: readonly (readonly [number, number])[];5564 /** Centerline at channel-floor height, entry port → exit port. */5665 centerline: readonly CurveSeg[];66+ /**67+ * Alternative path through the piece (a split–merge diamond's other68+ * branch). Same entry/exit ports as `centerline`; the marble alternates69+ * per lap.70+ */71+ altCenterline?: readonly CurveSeg[];5772 /** Occupied y band above local base (for collision). */5873 heightSpan: readonly [number, number];5974 /** GLB id of the closed-underside variant, if the kit has one. */
@@ -61,6 +76,41 @@ export interface PieceDef {
6176 baseWeight: number;6277 /** True when this def was derived by reversing another. */6378 reversed?: boolean;79+ /** Render the base mesh x-flipped (defs derived via mirrorDef). */80+ mirrored?: boolean;81+ /** Channel width at the entry/exit port (wide = 1.7-cell channel). */82+ widthIn?: "narrow" | "wide";83+ widthOut?: "narrow" | "wide";84+ /** Only placeable via reservations (feature planner), never free WFC. */85+ reservedOnly?: boolean;86+ /**87+ * Emit no mesh placement — the piece's geometry is provided by another88+ * window sharing its cell (the second visit of a grade crossing). The89+ * centerline still contributes to the loop.90+ */91+ noMesh?: boolean;92+ /**93+ * Channel doesn't hug cell-center lanes on its F cells (waves, bends,94+ * s-curves, wide pieces) — elevated supports use plain columns snapped to95+ * the centerline instead of cell-center cradle stacks.96+ */97+ columnSupports?: boolean;98+ /**99+ * Plate tiles (short s-curve jogs): the mesh is a square slab with the100+ * channel carved diagonally through, leaving triangular platform wings.101+ * These cells (entry frame, OUTSIDE the footprint) must carry track for102+ * the wings to read as embedded deck — the WFC skips the candidate when103+ * any wing cell is bare grass. Standard corners stay the default.104+ */105+ plateSides?: readonly (readonly [number, number])[];106+ /**107+ * The solid variant fills the full footprint square(s) beyond the pipe108+ * (corner/curve blocks), unlike open forms whose outline is contour-cut.109+ * Assembly uses the solid ONLY when that extended surface abuts other110+ * same-height deck; otherwise the pipe-only open form is placed (user111+ * rule: no solid block corners jutting where nothing touches them).112+ */113+ solidExtends?: boolean;64114 }65115 66116 const F = 0.25; // standard channel floor above base
@@ -130,6 +180,136 @@ function buildDefs(): PieceDef[] {
130180 baseWeight: 1.5,131181 });132182 183+ // straight-hole: a straight whose floor is pierced — on a bridge deck the184+ // marble has a CHANCE to fall through onto the channel below (user rule:185+ // holes require track below, guaranteed here because only bridge-crossing186+ // reservations place it). The centerline is the roll-over path; the fall187+ // is the client's per-lap deviation.188+ defs.push({189+ id: "straight-hole",190+ family: "straight",191+ moves: "F",192+ deckDelta: 0,193+ floorIn: F,194+ floorOut: F,195+ meshOffset: [0, 0, 0],196+ meshRot: 0,197+ footprint: [[0, 0]],198+ supportCells: [[0, 0]],199+ centerline: [line([0, F, -0.5], [0, F, 0.5])],200+ heightSpan: [0, W],201+ reservedOnly: true,202+ baseWeight: 1,203+ });204+205+ // Tunnel runs: contiguous covered stretches (the M1 singleton reads as a206+ // lone gate; a run reads as an actual tunnel). Composite of one straight +207+ // arch per cell — same measured seating as the singleton.208+ for (const len of [2, 3]) {209+ const cells: [number, number][] = [];210+ const overlays: Overlay[] = [{ id: "tunnel", offset: [0, W, 0] }];211+ for (let j = 0; j < len; j++) {212+ cells.push([0, j]);213+ if (j > 0) {214+ overlays.push({ id: "straight", offset: [0, 0, j] });215+ overlays.push({ id: "tunnel", offset: [0, W, j] });216+ }217+ }218+ defs.push({219+ id: `tunnel-run-${len}`,220+ meshId: "straight",221+ overlays,222+ family: "tunnel",223+ moves: "F".repeat(len),224+ deckDelta: 0,225+ floorIn: F,226+ floorOut: F,227+ meshOffset: [0, 0, 0],228+ meshRot: 0,229+ footprint: cells,230+ supportCells: cells,231+ centerline: [line([0, F, -0.5], [0, F, len - 0.5])],232+ heightSpan: [0, W + 0.6],233+ baseWeight: len === 2 ? 2.5 : 1.5,234+ });235+ }236+237+ // Drop-stacks (user design): an end-hole stacked FLUSH on a plain end238+ // piece (0.5 grid — nothing floats). The marble enters the upper port,239+ // falls through the hole into the cup below, and exits the receiver's240+ // port — a guaranteed vertical transfer. The channel network is241+ // deliberately non-connected here; the LOOP closes through the fall (the242+ // centerline carries a vertical segment, and the gravity model makes the243+ // marble properly plunge). One cell, deckDelta −0.5; the exit port may244+ // continue straight or turn (the cup is round): F/L/R variants, rounded245+ // and square flavors. Reservation-only, placed by the descent vocabulary246+ // and the corner-drop planner.247+ for (const flavor of ["rounded", "square"] as const) {248+ for (249+ const [turn, moves, exitRot] of [250+ ["f", "F", 2],251+ ["l", "L", 1],252+ ["r", "R", 3],253+ ] as const254+ ) {255+ const exit = turn === "f"256+ ? [0, 0.5]257+ : turn === "l"258+ ? [-0.5, 0]259+ : [0.5, 0];260+ defs.push({261+ id: `drop-stack-${flavor}-${turn}`,262+ meshId: `end-hole-${flavor}`,263+ overlays: [264+ // Receiver cup at ground; its port (local −z) faces the exit.265+ { id: `end-${flavor}`, offset: [0, -0.5, 0], rot: exitRot },266+ ],267+ family: "drop",268+ moves,269+ deckDelta: -0.5,270+ floorIn: F,271+ floorOut: F,272+ meshOffset: [0, 0, 0],273+ meshRot: 0,274+ footprint: [[0, 0]],275+ supportCells: [[0, 0]],276+ centerline: [277+ line([0, F, -0.5], [0, F, 0]),278+ line([0, F, 0], [0, F - 0.5, 0]),279+ line([0, F - 0.5, 0], [exit[0], F - 0.5, exit[1]]),280+ ],281+ heightSpan: [-0.5, W],282+ reservedOnly: true,283+ baseWeight: 2,284+ });285+ }286+ }287+288+ // Grade crossing: 4-way symmetric block (measured: the straight's port289+ // signature on all four faces, floor 0.25). The ring visits its cell twice290+ // (perpendicular, same height, pierce mutation); the first visit places291+ // the mesh, the second is the noMesh phantom so the loop centerline still292+ // chains. Reservation-only — free WFC never crosses itself.293+ for (const phantom of [false, true]) {294+ defs.push({295+ id: phantom ? "cross-over" : "cross",296+ ...(phantom ? { meshId: "cross", noMesh: true } : {}),297+ family: "cross",298+ moves: "F",299+ deckDelta: 0,300+ floorIn: F,301+ floorOut: F,302+ meshOffset: [0, 0, 0],303+ meshRot: 0,304+ footprint: [[0, 0]],305+ supportCells: [[0, 0]],306+ centerline: [line([0, F, -0.5], [0, F, 0.5])],307+ heightSpan: [0, W],308+ reservedOnly: true,309+ baseWeight: 1,310+ });311+ }312+133313 // --- turns ---------------------------------------------------------------134314 // corner: 1×1, quarter arc r=0.5, turns south→west (L).135315 defs.push({
@@ -146,6 +326,7 @@ function buildDefs(): PieceDef[] {
146326 centerline: [leftArc(0.5, F, F)],147327 heightSpan: [0, W],148328 solidId: "corner-solid",329+ solidExtends: true,149330 baseWeight: 6,150331 });151332
@@ -164,6 +345,7 @@ function buildDefs(): PieceDef[] {
164345 centerline: [leftArc(1.5, F, F)],165346 heightSpan: [0, W],166347 solidId: "curve-solid",348+ solidExtends: true,167349 baseWeight: 5,168350 });169351
@@ -192,6 +374,7 @@ function buildDefs(): PieceDef[] {
192374 centerline: [leftArc(2.5, F, F)],193375 heightSpan: [0, W],194376 solidId: "curve-solid-large",377+ solidExtends: true,195378 baseWeight: 3,196379 });197380
@@ -258,49 +441,845 @@ function buildDefs(): PieceDef[] {
258441 });259442 }260443 261- // ramp-start-c + ramp-end-c pair: each rises 0.5 over one cell, eased.262- // Modeled as a single 2-cell composite is unnecessary — each is a valid263- // 1-cell piece; the elevation system reserves them as a pair so the264- // mid-climb base (+0.5) stays on the support grid.444+ // ramp-start + ramp-end pairs: each rises the tier delta over one cell,445+ // eased. Modeled as a single 2-cell composite is unnecessary — each is a446+ // valid 1-cell piece; the elevation system reserves them as pairs so the447+ // mid-climb base stays on the vocabulary's grid. Tier deltas measured:448+ // b 0.25, c 0.5, d 0.75 (a = 0.125 is off every usable grid — skipped).449+ for (450+ const [tier, d, weight] of [["b", 0.25, 2], ["c", 0.5, 3], ["d", 0.75, 2]]451+ .map((t) => t as [string, number, number])452+ ) {453+ defs.push({454+ id: `ramp-start-${tier}`,455+ family: "ramp",456+ moves: "F",457+ deckDelta: d,458+ floorIn: F,459+ floorOut: F,460+ meshOffset: [0, 0, 0],461+ meshRot: 0,462+ footprint: [[0, 0]],463+ supportCells: [[0, 0]],464+ centerline: [465+ cubic(466+ [0, F, -0.5],467+ [0, F, -0.1],468+ [0, F + d * 0.56, 0.28],469+ [0, F + d, 0.5],470+ ),471+ ],472+ heightSpan: [0, W + d],473+ solidId: `ramp-start-solid-${tier}`,474+ baseWeight: weight,475+ });476+ defs.push({477+ id: `ramp-end-${tier}`,478+ family: "ramp",479+ moves: "F",480+ deckDelta: d,481+ floorIn: F,482+ floorOut: F,483+ meshOffset: [0, 0, 0],484+ meshRot: 0,485+ footprint: [[0, 0]],486+ supportCells: [[0, 0]],487+ centerline: [488+ cubic(489+ [0, F, -0.5],490+ [0, F + d * 0.44, -0.28],491+ [0, F + d, 0.1],492+ [0, F + d, 0.5],493+ ),494+ ],495+ heightSpan: [0, W + d],496+ solidId: `ramp-end-solid-${tier}`,497+ baseWeight: weight,498+ });499+ }500+501+ // --- bends (U-turns) -----------------------------------------------------502+ // Measured: both ports on the authored −x face at cell centers, and the503+ // semicircle's diameter lies exactly ON the port face (zero lead-in), arc504+ // center at (bboxMin.x, 0), radii 0.5/1.5/2.5, floor 0.25 throughout.505+ // meshRot 3 turns the authored +x entry into the entry frame's +z.265506 defs.push({266- id: "ramp-start-c",267- family: "ramp",268- moves: "F",269- deckDelta: 0.5,507+ id: "bend",508+ family: "bend",509+ moves: "LL",510+ deckDelta: 0,270511 floorIn: F,271512 floorOut: F,272- meshOffset: [0, 0, 0],513+ meshOffset: [-0.5, 0, 0],514+ meshRot: 3,515+ footprint: [[0, 0], [-1, 0]],516+ supportCells: [[0, 0], [-1, 0]],517+ centerline: [518+ {519+ kind: "arc",520+ center: [-0.5, 0, -0.5],521+ radius: 0.5,522+ a0: 0,523+ a1: Math.PI,524+ y0: F,525+ y1: F,526+ },527+ ],528+ heightSpan: [0, W],529+ baseWeight: 14,530+ });531+ defs.push({532+ id: "bend-medium",533+ family: "bend",534+ moves: "FLFFLF",535+ deckDelta: 0,536+ floorIn: F,537+ floorOut: F,538+ meshOffset: [-1.5, 0, 0.5],539+ meshRot: 3,540+ footprint: [541+ [0, 0],542+ [0, 1],543+ [-1, 1],544+ [-2, 1],545+ [-3, 1],546+ [-3, 0],547+ [-1, 0],548+ [-2, 0],549+ ],550+ supportCells: [[0, 0], [0, 1], [-1, 1], [-2, 1], [-3, 1], [-3, 0]],551+ centerline: [552+ {553+ kind: "arc",554+ center: [-1.5, 0, -0.5],555+ radius: 1.5,556+ a0: 0,557+ a1: Math.PI,558+ y0: F,559+ y1: F,560+ },561+ ],562+ heightSpan: [0, W],563+ solidId: "bend-solid-medium",564+ columnSupports: true,565+ baseWeight: 16,566+ });567+ {568+ const footprint: [number, number][] = [];569+ for (let x = -5; x <= 0; x++) {570+ for (let z = 0; z <= 2; z++) footprint.push([x, z]);571+ }572+ defs.push({573+ id: "bend-large",574+ family: "bend",575+ moves: "FFLFFFFLFF",576+ deckDelta: 0,577+ floorIn: F,578+ floorOut: F,579+ meshOffset: [-2.5, 0, 1.0],580+ meshRot: 3,581+ footprint,582+ supportCells: [583+ [0, 0],584+ [0, 1],585+ [0, 2],586+ [-1, 2],587+ [-2, 2],588+ [-3, 2],589+ [-4, 2],590+ [-5, 2],591+ [-5, 1],592+ [-5, 0],593+ ],594+ centerline: [595+ {596+ kind: "arc",597+ center: [-2.5, 0, -0.5],598+ radius: 2.5,599+ a0: 0,600+ a1: Math.PI,601+ y0: F,602+ y1: F,603+ },604+ ],605+ heightSpan: [0, W],606+ solidId: "bend-solid-large",607+ columnSupports: true,608+ baseWeight: 16,609+ });610+ }611+612+ // --- helixes (descending turns) ------------------------------------------613+ // Measured: each helix is exactly its flat cousin's arc — curve /614+ // curve-large for quarters, bend-medium / bend-large semicircles for615+ // halves (same ports, radii, footprints) — with the channel floor616+ // descending LINEARLY IN ANGLE. Quarters drop 0.5, halves drop 1.0; the617+ // mesh is grounded on its exit side, so meshOffset.y = −drop puts the618+ // entry base at the entry frame's 0. Forward defs are DESCENTS619+ // (deckDelta < 0 keeps them out of free WFC collapse); the elevation620+ // planner reserves them at turn windows ("corner drops"). Reversed621+ // variants are gentle spiral climbs (~0.21/cell). Native right-handed622+ // GLBs are exact mirrors — same convention as s-curves.623+ const helixQuarterLeft: PieceDef = {624+ id: "helix-quarter-left",625+ family: "helix",626+ moves: "FLF",627+ deckDelta: -0.5,628+ floorIn: F,629+ floorOut: F,630+ meshOffset: [-0.5, -0.5, 0.5],273631 meshRot: 0,274- footprint: [[0, 0]],275- supportCells: [[0, 0]],632+ footprint: [[0, 0], [0, 1], [-1, 1], [-1, 0]],633+ supportCells: [[0, 0], [0, 1], [-1, 1]],634+ centerline: [leftArc(1.5, F, F - 0.5)],635+ heightSpan: [-0.5, W],636+ columnSupports: true,637+ baseWeight: 8,638+ };639+ defs.push(helixQuarterLeft);640+ defs.push({641+ ...mirrorDef(helixQuarterLeft, "helix-quarter-right"),642+ meshId: undefined,643+ mirrored: false,644+ });645+ const helixLargeQuarterLeft: PieceDef = {646+ id: "helix-large-quarter-left",647+ family: "helix",648+ moves: "FFLFF",649+ deckDelta: -0.5,650+ floorIn: F,651+ floorOut: F,652+ meshOffset: [-1, -0.5, 1],653+ meshRot: 0,654+ footprint: [655+ [0, 0],656+ [0, 1],657+ [0, 2],658+ [-1, 0],659+ [-1, 1],660+ [-1, 2],661+ [-2, 0],662+ [-2, 1],663+ [-2, 2],664+ ],665+ supportCells: [[0, 0], [0, 1], [-1, 1], [-1, 2], [-2, 2]],666+ centerline: [leftArc(2.5, F, F - 0.5)],667+ heightSpan: [-0.5, W],668+ columnSupports: true,669+ baseWeight: 6,670+ };671+ defs.push(helixLargeQuarterLeft);672+ defs.push({673+ ...mirrorDef(helixLargeQuarterLeft, "helix-large-quarter-right"),674+ meshId: undefined,675+ mirrored: false,676+ });677+ const helixHalfLeft: PieceDef = {678+ id: "helix-half-left",679+ family: "helix",680+ moves: "FLFFLF",681+ deckDelta: -1.0,682+ floorIn: F,683+ floorOut: F,684+ meshOffset: [-1.5, -1.0, 0.5],685+ meshRot: 3,686+ footprint: [687+ [0, 0],688+ [0, 1],689+ [-1, 1],690+ [-2, 1],691+ [-3, 1],692+ [-3, 0],693+ [-1, 0],694+ [-2, 0],695+ ],696+ supportCells: [[0, 0], [0, 1], [-1, 1], [-2, 1], [-3, 1], [-3, 0]],697+ centerline: [698+ {699+ kind: "arc",700+ center: [-1.5, 0, -0.5],701+ radius: 1.5,702+ a0: 0,703+ a1: Math.PI,704+ y0: F,705+ y1: F - 1.0,706+ },707+ ],708+ heightSpan: [-1.0, W],709+ columnSupports: true,710+ baseWeight: 8,711+ };712+ defs.push(helixHalfLeft);713+ defs.push({714+ ...mirrorDef(helixHalfLeft, "helix-half-right"),715+ meshId: undefined,716+ mirrored: false,717+ });718+ {719+ const footprint: [number, number][] = [];720+ for (let x = -5; x <= 0; x++) {721+ for (let z = 0; z <= 2; z++) footprint.push([x, z]);722+ }723+ const helixLargeHalfLeft: PieceDef = {724+ id: "helix-large-half-left",725+ family: "helix",726+ moves: "FFLFFFFLFF",727+ deckDelta: -1.0,728+ floorIn: F,729+ floorOut: F,730+ meshOffset: [-2.5, -1.0, 1.0],731+ meshRot: 3,732+ footprint,733+ supportCells: [734+ [0, 0],735+ [0, 1],736+ [0, 2],737+ [-1, 2],738+ [-2, 2],739+ [-3, 2],740+ [-4, 2],741+ [-5, 2],742+ [-5, 1],743+ [-5, 0],744+ ],745+ centerline: [746+ {747+ kind: "arc",748+ center: [-2.5, 0, -0.5],749+ radius: 2.5,750+ a0: 0,751+ a1: Math.PI,752+ y0: F,753+ y1: F - 1.0,754+ },755+ ],756+ heightSpan: [-1.0, W],757+ columnSupports: true,758+ baseWeight: 6,759+ };760+ defs.push(helixLargeHalfLeft);761+ defs.push({762+ ...mirrorDef(helixLargeHalfLeft, "helix-large-half-right"),763+ meshId: undefined,764+ mirrored: false,765+ });766+ }767+768+ // Split–merge diamonds: split-double fans the channel into two parallel769+ // branches one lane out each side (measured: center entry, exits at x±1770+ // heading +z over a 3×2 block); N straights per branch; a 180°-rotated771+ // split-double merges them back. One composite reserved def per N — the772+ // feature planner places them on straight runs with both flanks free.773+ // centerline = LEFT branch, altCenterline = RIGHT; the client alternates774+ // per lap. Branch jogs are z-linear-third cubics (the lane-shift law).775+ for (const N of [1, 2, 3]) {776+ const len = 4 + N;777+ const footprint: [number, number][] = [];778+ for (let dx = -1; dx <= 1; dx++) {779+ for (let j = 0; j < len; j++) footprint.push([dx, j]);780+ }781+ const overlays: Overlay[] = [782+ { id: "split-double", offset: [0, 0, 0.5] },783+ { id: "split-double", offset: [0, 0, N + 2.5], rot: 2 },784+ ];785+ for (let j = 0; j < N; j++) {786+ overlays.push({ id: "straight", offset: [-1, 0, 2 + j] });787+ overlays.push({ id: "straight", offset: [1, 0, 2 + j] });788+ }789+ const branch = (side: -1 | 1): CurveSeg[] => [790+ cubic(791+ [0, F, -0.5],792+ [0, F, 2 / 3 - 0.5],793+ [side, F, 4 / 3 - 0.5],794+ [side, F, 1.5],795+ ),796+ line([side, F, 1.5], [side, F, N + 1.5]),797+ cubic(798+ [side, F, N + 1.5],799+ [side, F, N + 1.5 + 2 / 3],800+ [0, F, N + 1.5 + 4 / 3],801+ [0, F, N + 3.5],802+ ),803+ ];804+ defs.push({805+ id: `diamond-${N}`,806+ meshId: "split-double",807+ overlays: overlays.slice(1),808+ family: "split",809+ moves: "F".repeat(len),810+ deckDelta: 0,811+ floorIn: F,812+ floorOut: F,813+ meshOffset: [0, 0, 0.5],814+ meshRot: 0,815+ footprint,816+ supportCells: footprint.filter(([dx]) => dx === 0),817+ centerline: branch(-1),818+ altCenterline: branch(1),819+ heightSpan: [0, W],820+ columnSupports: true,821+ reservedOnly: true,822+ baseWeight: 4,823+ });824+ }825+826+ // Full helixes: a 360° spiral (drop 2.0, linear in angle) whose entry and827+ // exit share ONE port edge at the block's mid-face — the ring passes under828+ // the spiral's own start. One reserved ensemble window covers it all:829+ // approach corner (top level, corner overlay), the perimeter spiral, and830+ // the underpass straights (ground level, straight overlays) back out831+ // through the first spiral cells. The window's moves REVISIT its first832+ // cells (the topology helix-loop mutation makes the matching ring), so833+ // footprints dedup and the mesh provides both levels. Mesh grounded at the834+ // exit side: meshOffset.y = −2. Only the elevation planner reserves these835+ // (with a mandatory 2.0 climb elsewhere on the ring).836+ for (const large of [false, true]) {837+ const R = large ? 2.5 : 1.5; // spiral lane radius838+ const N = large ? 6 : 4; // block is N×N cells839+ const side = N - 1;840+ const under = N / 2; // underpass length in cells (2 small, 3 large)841+ // Walk: A(0,0) R turn, ccw around the block perimeter (F…L per side),842+ // then back through A and the first cells (the underpass), exiting +x.843+ let moves = "R";844+ moves += "F".repeat(under - 1) + "L"; // A → along +x to the corner845+ for (let s = 0; s < 3; s++) moves += "F".repeat(side - 1) + "L";846+ moves += "F".repeat(side - 1 - under); // return leg, short of A847+ moves += "F"; // through A again848+ moves += "F".repeat(under); // underpass B, C(, D)849+ // Trace cells for footprint/supports.850+ const walk: [number, number][] = [];851+ let cx = 0, cz = 0, dir = 1;852+ for (const m of moves) {853+ dir = m === "F" ? dir : m === "L" ? (dir + 1) % 4 : (dir + 3) % 4;854+ walk.push([cx, cz]);855+ cx += [1, 0, -1, 0][dir];856+ cz += [0, 1, 0, -1][dir];857+ }858+ const uniq = new Map<string, [number, number]>();859+ for (const c of walk) uniq.set(`${c[0]},${c[1]}`, c);860+ // Interior cells (the helix's central column) complete the footprint.861+ const xs = [...uniq.values()].map((c) => c[0]);862+ const zs = [...uniq.values()].map((c) => c[1]);863+ const [x0, x1] = [Math.min(...xs), Math.max(...xs)];864+ const [z0, z1] = [Math.min(...zs), Math.max(...zs)];865+ const footprint: [number, number][] = [];866+ for (let fx = x0; fx <= x1; fx++) {867+ for (let fz = z0; fz <= z1; fz++) footprint.push([fx, fz]);868+ }869+ const overlays: Overlay[] = [870+ // Approach turn at the top — mirrored corner = R turn.871+ { id: "corner", offset: [0, 0, 0], mirrored: true },872+ ];873+ for (let j = 1; j <= under; j++) {874+ overlays.push({ id: "straight", offset: [j, -2, 0], rot: 1 });875+ }876+ const def: PieceDef = {877+ id: large ? "helix-large-left" : "helix-left",878+ overlays,879+ family: "helix",880+ moves,881+ deckDelta: -2,882+ floorIn: F,883+ floorOut: F,884+ meshOffset: [0.5, -2, R],885+ meshRot: 2,886+ footprint,887+ supportCells: [...uniq.values()],888+ centerline: [889+ // Approach corner: R quarter, r 0.5, flat at the top level.890+ {891+ kind: "arc",892+ center: [0.5, 0, -0.5],893+ radius: 0.5,894+ a0: Math.PI,895+ a1: Math.PI / 2,896+ y0: F,897+ y1: F,898+ },899+ // The spiral: full ccw turn, floor linear in angle (measured).900+ {901+ kind: "arc",902+ center: [0.5, 0, R],903+ radius: R,904+ a0: -Math.PI / 2,905+ a1: Math.PI * 1.5,906+ y0: F,907+ y1: F - 2,908+ },909+ line([0.5, F - 2, 0], [under + 0.5, F - 2, 0]),910+ ],911+ heightSpan: [-2, W],912+ columnSupports: true,913+ reservedOnly: true,914+ baseWeight: 4,915+ };916+ defs.push(def);917+ defs.push({918+ ...mirrorDef(def, large ? "helix-large-right" : "helix-right"),919+ meshId: undefined,920+ mirrored: false,921+ });922+ }923+924+ // --- s-curves ------------------------------------------------------------925+ // Measured: two mirrored tangent-continuous 90° arcs of the curve /926+ // curve-large lane radii; the lane heading is momentarily perpendicular to927+ // the travel axis at the inflection. Point-symmetric, so the reversed928+ // variant collapses onto the forward def. Kenney's "left" jogs +x in the929+ // entry frame — our "R" chirality; the native right meshes are exact930+ // mirrors, so their defs are derived via mirrorDef with the flip cleared.931+ const sCurveLeft: PieceDef = {932+ id: "s-curve-left",933+ family: "s-curve",934+ moves: "FRFFLF",935+ deckDelta: 0,936+ floorIn: F,937+ floorOut: F,938+ meshOffset: [1.5, 0, 1.0],939+ meshRot: 0,940+ footprint: [941+ [0, 0],942+ [0, 1],943+ [1, 0],944+ [1, 1],945+ [2, 1],946+ [2, 2],947+ [3, 1],948+ [3, 2],949+ ],950+ supportCells: [[0, 0], [0, 1], [1, 1], [2, 1], [3, 1], [3, 2]],951+ centerline: [952+ {953+ kind: "arc",954+ center: [1.5, 0, -0.5],955+ radius: 1.5,956+ a0: Math.PI,957+ a1: Math.PI / 2,958+ y0: F,959+ y1: F,960+ },961+ {962+ kind: "arc",963+ center: [1.5, 0, 2.5],964+ radius: 1.5,965+ a0: -Math.PI / 2,966+ a1: 0,967+ y0: F,968+ y1: F,969+ },970+ ],971+ heightSpan: [0, W],972+ columnSupports: true,973+ baseWeight: 12,974+ };975+ defs.push(sCurveLeft);976+ defs.push({977+ ...mirrorDef(sCurveLeft, "s-curve-right"),978+ meshId: undefined,979+ mirrored: false,980+ });981+ const sCurveLeftLarge: PieceDef = {982+ id: "s-curve-left-large",983+ family: "s-curve",984+ moves: "FFRFFFFLFF",985+ deckDelta: 0,986+ floorIn: F,987+ floorOut: F,988+ meshOffset: [2.5, 0, 2.0],989+ meshRot: 0,990+ footprint: [991+ [0, 0],992+ [0, 1],993+ [0, 2],994+ [1, 0],995+ [1, 1],996+ [1, 2],997+ [2, 1],998+ [2, 2],999+ [3, 2],1000+ [3, 3],1001+ [4, 2],1002+ [4, 3],1003+ [4, 4],1004+ [5, 2],1005+ [5, 3],1006+ [5, 4],1007+ ],1008+ supportCells: [1009+ [0, 0],1010+ [0, 1],1011+ [0, 2],1012+ [1, 2],1013+ [2, 2],1014+ [3, 2],1015+ [4, 2],1016+ [5, 2],1017+ [5, 3],1018+ [5, 4],1019+ ],2761020 centerline: [277- cubic([0, F, -0.5], [0, F, -0.1], [0, F + 0.28, 0.28], [0, F + 0.5, 0.5]),1021+ {1022+ kind: "arc",1023+ center: [2.5, 0, -0.5],1024+ radius: 2.5,1025+ a0: Math.PI,1026+ a1: Math.PI / 2,1027+ y0: F,1028+ y1: F,1029+ },1030+ {1031+ kind: "arc",1032+ center: [2.5, 0, 4.5],1033+ radius: 2.5,1034+ a0: -Math.PI / 2,1035+ a1: 0,1036+ y0: F,1037+ y1: F,1038+ },2781039 ],279- heightSpan: [0, W + 0.5],280- solidId: "ramp-start-solid-c",1040+ heightSpan: [0, W],1041+ columnSupports: true,1042+ baseWeight: 14,1043+ };1044+ defs.push(sCurveLeftLarge);1045+ defs.push({1046+ ...mirrorDef(sCurveLeftLarge, "s-curve-right-large"),1047+ meshId: undefined,1048+ mirrored: false,1049+ });1050+1051+ // s-curve-short tiers: smootherstep jogs of 0.25 / 0.5 / 1.0 over two1052+ // cells. Only tier c is grid-aligned on its own; a/b enter as paired1053+ // combos (wiggle = jog out + mirrored jog back; lane-shift = two chained1054+ // b jogs). Cubics use z-linear control thirds ⇒ x(z) = smoothstep — close1055+ // enough to the measured smootherstep at these amplitudes.1056+ const jogSeg = (1057+ x0: number,1058+ x1: number,1059+ z0: number,1060+ z1: number,1061+ ): CurveSeg =>1062+ cubic(1063+ [x0, F, z0],1064+ [x0, F, z0 + (z1 - z0) / 3],1065+ [x1, F, z1 - (z1 - z0) / 3],1066+ [x1, F, z1],1067+ );1068+ const shortC: PieceDef = {1069+ id: "s-curve-short-left-c",1070+ family: "s-curve",1071+ moves: "FRL",1072+ deckDelta: 0,1073+ floorIn: F,1074+ floorOut: F,1075+ meshOffset: [0.5, 0, 0.5],1076+ meshRot: 0,1077+ footprint: [[0, 0], [0, 1], [1, 0], [1, 1]],1078+ supportCells: [[0, 0], [0, 1], [1, 1]],1079+ // Platform wings: left of the entry lane, right of the exit lane.1080+ plateSides: [[-1, 0], [2, 1]],1081+ centerline: [jogSeg(0, 1, -0.5, 1.5)],1082+ heightSpan: [0, W],1083+ columnSupports: true,1084+ baseWeight: 5,1085+ };1086+ defs.push(shortC);1087+ defs.push(mirrorDef(shortC, "s-curve-short-right-c"));1088+1089+ for (const [tier, amp] of [["a", 0.125], ["b", 0.25]] as const) {1090+ const wiggle: PieceDef = {1091+ id: `wiggle-${tier}`,1092+ family: "s-curve",1093+ meshId: `s-curve-short-left-${tier}`,1094+ moves: "FFFF",1095+ deckDelta: 0,1096+ floorIn: F,1097+ floorOut: F,1098+ meshOffset: [amp, 0, 0.5],1099+ meshRot: 0,1100+ overlays: [1101+ {1102+ id: `s-curve-short-left-${tier}`,1103+ offset: [amp, 0, 2.5],1104+ mirrored: true,1105+ },1106+ ],1107+ footprint: [[0, 0], [0, 1], [0, 2], [0, 3], [1, 1], [1, 2]],1108+ supportCells: [[0, 0], [0, 1], [0, 2], [0, 3]],1109+ // b-tier plates are wide enough to read as platforms; a-tier wings1110+ // are sub-0.35 slivers and pass as chamfers.1111+ ...(tier === "b" ? { plateSides: [[-1, 0], [-1, 3]] as const } : {}),1112+ centerline: [1113+ jogSeg(0, 2 * amp, -0.5, 1.5),1114+ jogSeg(2 * amp, 0, 1.5, 3.5),1115+ ],1116+ heightSpan: [0, W],1117+ columnSupports: true,1118+ baseWeight: 2.5,1119+ };1120+ defs.push(wiggle);1121+ defs.push(mirrorDef(wiggle, `wiggle-${tier}-mirror`));1122+ }1123+1124+ const laneShiftB: PieceDef = {1125+ id: "lane-shift-b",1126+ family: "s-curve",1127+ meshId: "s-curve-short-left-b",1128+ moves: "FRLFF",1129+ deckDelta: 0,1130+ floorIn: F,1131+ floorOut: F,1132+ meshOffset: [0.25, 0, 0.5],1133+ meshRot: 0,1134+ overlays: [{ id: "s-curve-short-left-b", offset: [0.75, 0, 2.5] }],1135+ footprint: [[0, 0], [0, 1], [0, 2], [1, 1], [1, 2], [1, 3]],1136+ supportCells: [[0, 0], [0, 1], [1, 1], [1, 2], [1, 3]],1137+ plateSides: [[-1, 0], [2, 3]],1138+ centerline: [jogSeg(0, 0.5, -0.5, 1.5), jogSeg(0.5, 1, 1.5, 3.5)],1139+ heightSpan: [0, W],1140+ columnSupports: true,2811141 baseWeight: 3,1142+ };1143+ defs.push(laneShiftB);1144+ defs.push(mirrorDef(laneShiftB, "lane-shift-b-mirror"));1145+1146+ // --- waves (lateral) and bumps (vertical) --------------------------------1147+ // Measured: waves are purely lateral, bumps purely vertical, both1148+ // smootherstep-eased trough→crest→trough over 4 cells with the peak at1149+ // mid-piece. Wave ports sit at lane −A of the mesh frame, so the mesh1150+ // shifts +A to put the port lanes on the cell centerline (crest at +2A).1151+ for (1152+ const [tier, amp, w] of [1153+ ["a", 0.125, 3],1154+ ["b", 0.25, 3.2],1155+ ["c", 0.5, 3.5],1156+ ] as const1157+ ) {1158+ const wave: PieceDef = {1159+ id: `wave-${tier}`,1160+ family: "wave",1161+ moves: "FFFF",1162+ deckDelta: 0,1163+ floorIn: F,1164+ floorOut: F,1165+ meshOffset: [amp, 0, 1.5],1166+ meshRot: 0,1167+ footprint: [[0, 0], [0, 1], [0, 2], [0, 3], [1, 1], [1, 2]],1168+ supportCells: [[0, 0], [0, 1], [0, 2], [0, 3]],1169+ centerline: [1170+ jogSeg(0, 2 * amp, -0.5, 1.5),1171+ jogSeg(2 * amp, 0, 1.5, 3.5),1172+ ],1173+ heightSpan: [0, W],1174+ solidId: `wave-solid-${tier}`,1175+ columnSupports: true,1176+ baseWeight: w,1177+ };1178+ defs.push(wave);1179+ defs.push(mirrorDef(wave, `wave-${tier}-mirror`));1180+ }1181+1182+ const bumpSeg = (y0: number, y1: number, z0: number, z1: number): CurveSeg =>1183+ cubic(1184+ [0, y0, z0],1185+ [0, y0, z0 + (z1 - z0) / 3],1186+ [0, y1, z1 - (z1 - z0) / 3],1187+ [0, y1, z1],1188+ );1189+ for (1190+ const [tier, rise, w] of [1191+ ["a", 0.25, 4],1192+ ["b", 0.5, 3.5],1193+ ["c", 1.0, 2],1194+ ["d", 1.5, 1],1195+ ] as const1196+ ) {1197+ defs.push({1198+ id: `bump-${tier}`,1199+ family: "bump",1200+ moves: "FFFF",1201+ deckDelta: 0,1202+ floorIn: F,1203+ floorOut: F,1204+ meshOffset: [0, 0, 1.5],1205+ meshRot: 0,1206+ footprint: [[0, 0], [0, 1], [0, 2], [0, 3]],1207+ supportCells: [[0, 0], [0, 1], [0, 2], [0, 3]],1208+ centerline: [1209+ bumpSeg(F, F + rise, -0.5, 1.5),1210+ bumpSeg(F + rise, F, 1.5, 3.5),1211+ ],1212+ heightSpan: [0, W + rise],1213+ solidId: `bump-solid-${tier}`,1214+ baseWeight: w,1215+ });1216+ }1217+1218+ // --- wide channel --------------------------------------------------------1219+ // straight-wide: 1.7-wide channel centered on the cell, bulging 0.35 into1220+ // each flanking cell (hence the 3-cell footprint). funnel: exactly linear1221+ // narrow→wide taper across one cell; funnel-long = straight + funnel +1222+ // straight-wide fused. All reservedOnly: the feature planner emits them as1223+ // funnel-bracketed runs, which is what makes width continuity a1224+ // by-construction invariant. (curve-wide measured off-grid — its ports1225+ // sit on ±0.85 bbox faces and cannot mate with the 1-unit port lattice;1226+ // deferred to a later milestone as a set-piece macro.)1227+ defs.push({1228+ id: "straight-wide",1229+ family: "wide",1230+ moves: "F",1231+ deckDelta: 0,1232+ floorIn: F,1233+ floorOut: F,1234+ widthIn: "wide",1235+ widthOut: "wide",1236+ meshOffset: [0, 0, 0],1237+ meshRot: 0,1238+ footprint: [[0, 0], [1, 0], [-1, 0]],1239+ supportCells: [[0, 0]],1240+ centerline: [line([0, F, -0.5], [0, F, 0.5])],1241+ heightSpan: [0, W],1242+ reservedOnly: true,1243+ columnSupports: true,1244+ baseWeight: 1,2821245 });2831246 defs.push({284- id: "ramp-end-c",285- family: "ramp",1247+ id: "funnel",1248+ family: "funnel",2861249 moves: "F",287- deckDelta: 0.5,1250+ deckDelta: 0,2881251 floorIn: F,2891252 floorOut: F,1253+ widthIn: "narrow",1254+ widthOut: "wide",2901255 meshOffset: [0, 0, 0],2911256 meshRot: 0,292- footprint: [[0, 0]],1257+ footprint: [[0, 0], [1, 0], [-1, 0]],2931258 supportCells: [[0, 0]],294- centerline: [295- cubic([0, F, -0.5], [0, F + 0.22, -0.28], [0, F + 0.5, 0.1], [296- 0,297- F + 0.5,298- 0.5,299- ]),300- ],301- heightSpan: [0, W + 0.5],302- solidId: "ramp-end-solid-c",303- baseWeight: 3,1259+ centerline: [line([0, F, -0.5], [0, F, 0.5])],1260+ heightSpan: [0, W],1261+ reservedOnly: true,1262+ columnSupports: true,1263+ baseWeight: 1,1264+ });1265+ defs.push({1266+ id: "funnel-long",1267+ family: "funnel",1268+ moves: "FFF",1269+ deckDelta: 0,1270+ floorIn: F,1271+ floorOut: F,1272+ widthIn: "narrow",1273+ widthOut: "wide",1274+ meshOffset: [0, 0, 1],1275+ meshRot: 0,1276+ footprint: [[0, 0], [0, 1], [0, 2], [1, 1], [-1, 1], [1, 2], [-1, 2]],1277+ supportCells: [[0, 0], [0, 1], [0, 2]],1278+ centerline: [line([0, F, -0.5], [0, F, 2.5])],1279+ heightSpan: [0, W],1280+ reservedOnly: true,1281+ columnSupports: true,1282+ baseWeight: 1,3041283 });3051284 3061285 return defs;
@@ -358,19 +1337,80 @@ export function reverseDef(def: PieceDef): PieceDef {
3581337 deckDelta: -def.deckDelta,3591338 floorIn: def.floorOut,3601339 floorOut: def.floorIn,1340+ widthIn: def.widthOut,1341+ widthOut: def.widthIn,3611342 meshOffset: shift(def.meshOffset),3621343 overlays: def.overlays?.map((o) => ({363- id: o.id,364- offset: rotVec3(o.offset, rho),1344+ ...o,1345+ offset: shift(o.offset),1346+ rot: (((o.rot ?? 0) + rho) % 4) as Dir,3651347 })),3661348 meshRot: ((def.meshRot + rho) % 4) as Dir,3671349 footprint: def.footprint.map(shiftCell),1350+ plateSides: def.plateSides?.map(shiftCell),3681351 supportCells: def.supportCells.map(shiftCell).reverse(),3691352 centerline: def.centerline.map(shiftSeg).reverse(),1353+ altCenterline: def.altCenterline?.map(shiftSeg).reverse(),3701354 reversed: !def.reversed,3711355 };3721356 }3731357 1358+/**1359+ * Mirror a def about the entry-frame x=0 plane (entry port maps to itself,1360+ * L↔R swap). The mesh renders with scale(−1,1,1): world = T·R(r)·Mirror, so1361+ * the local pre-rotation flips sign (M·R(θ) = R(−θ)·M).1362+ */1363+export function mirrorDef(def: PieceDef, id: string): PieceDef {1364+ const mp = (v: Vec3): Vec3 => [-v[0], v[1], v[2]];1365+ const mCell = (c: readonly [number, number]): [number, number] => [1366+ -c[0],1367+ c[1],1368+ ];1369+ const mSeg = (seg: CurveSeg): CurveSeg => {1370+ switch (seg.kind) {1371+ case "line":1372+ return { kind: "line", a: mp(seg.a), b: mp(seg.b) };1373+ case "cubic":1374+ return {1375+ kind: "cubic",1376+ p: [mp(seg.p[0]), mp(seg.p[1]), mp(seg.p[2]), mp(seg.p[3])],1377+ };1378+ case "arc":1379+ // x → −x maps angle θ → π − θ (cos flips, sin preserved) and1380+ // reverses the sweep direction, which is exactly the mirrored turn.1381+ return {1382+ ...seg,1383+ center: mp(seg.center),1384+ a0: Math.PI - seg.a0,1385+ a1: Math.PI - seg.a1,1386+ };1387+ }1388+ };1389+ return {1390+ ...def,1391+ id,1392+ // The mirrored def renders the ORIGINAL mesh flipped — its own id is not1393+ // a GLB node.1394+ meshId: def.meshId ?? def.id,1395+ moves: def.moves.split("")1396+ .map((m) => m === "L" ? "R" : m === "R" ? "L" : "F").join(""),1397+ meshOffset: mp(def.meshOffset),1398+ meshRot: ((4 - def.meshRot) % 4) as Dir,1399+ mirrored: !def.mirrored,1400+ overlays: def.overlays?.map((o) => ({1401+ ...o,1402+ offset: mp(o.offset),1403+ rot: ((4 - (o.rot ?? 0)) % 4) as Dir,1404+ mirrored: !o.mirrored,1405+ })),1406+ footprint: def.footprint.map(mCell),1407+ supportCells: def.supportCells.map(mCell),1408+ plateSides: def.plateSides?.map(mCell),1409+ centerline: def.centerline.map(mSeg),1410+ altCenterline: def.altCenterline?.map(mSeg),1411+ };1412+}1413+3741414 /** Walk a def's moves from the entry cell; returns per-cell [cell, dirIn]. */3751415 export function windowCells(3761416 def: PieceDef,
@@ -419,8 +1459,12 @@ export function buildRegistry(): PieceRegistry {
4191459 const variants: PieceDef[] = [...fwd];4201460 for (const def of fwd) {4211461 const rev = reverseDef(def);422- // Skip reversed defs identical to forward (symmetric straights).423- if (rev.moves === def.moves && rev.deckDelta === def.deckDelta) continue;1462+ // Skip reversed defs identical to forward (symmetric straights) — but a1463+ // width-asymmetric piece (funnel) reversed is a genuinely new variant.1464+ if (1465+ rev.moves === def.moves && rev.deckDelta === def.deckDelta &&1466+ rev.widthIn === def.widthIn && rev.widthOut === def.widthOut1467+ ) continue;4241468 variants.push(rev);4251469 }4261470 const byMoves = new Map<string, PieceDef[]>();
@@ -486,6 +1530,24 @@ export function validateRegistry(reg: PieceRegistry): string[] {
4861530 break;4871531 }4881532 }1533+ // Alt branch (diamonds): same ports as the main centerline.1534+ if (def.altCenterline) {1535+ const aStart = segPoint(def.altCenterline[0], 0);1536+ const aEnd = segPoint(def.altCenterline[def.altCenterline.length - 1], 1);1537+ for (let i = 0; i < 3; i++) {1538+ if (Math.abs(aStart[i] - expStart[i]) > 1e-6) {1539+ errors.push(`${tag}: altCenterline start ${aStart} != ${expStart}`);1540+ break;1541+ }1542+ }1543+ for (let i = 0; i < 3; i++) {1544+ if (Math.abs(aEnd[i] - expEnd[i]) > 1e-6) {1545+ errors.push(`${tag}: altCenterline end ${aEnd} != ${expEnd}`);1546+ break;1547+ }1548+ }1549+ }1550+4891551 // Centerline segment chaining (C0).4901552 for (let s = 1; s < def.centerline.length; s++) {4911553 const a = segPoint(def.centerline[s - 1], 1);
A src/gen/stubs.ts +192 -0
@@ -0,0 +1,192 @@
1+/**2+ * Dead-end sidings: a real 3-way `split` piece IN the ring (its main channel3+ * is the straight the window realized; the third port opens sideways), with4+ * a short branch — straight-hole, then an end cap — dead-ending beside the5+ * track. The marble keeps to the main line (the loop centerline is6+ * unchanged); the branch is scenery with honest junction geometry. Branch7+ * marbles would roll in and drop through the hole — a drain spur.8+ *9+ * The native split branches LEFT of the flow; a right-hand branch renders10+ * the same mesh mirrored.11+ */12+import type { Dir, Placement } from "../shared/types.ts";13+import type { Rng } from "./rng.ts";14+import { DIR_STEP } from "./geom.ts";15+16+const STUB_CHANCE = 0.45;17+18+const CAPS = [19+ "end-hole-rounded",20+ "end-hole-square",21+ "end-rounded",22+ "end-square",23+];24+25+export interface StubPlan {26+ /** New branch placements (the chosen straights are mutated in place). */27+ placements: Placement[];28+ /** Cells the branches occupy (world "x,z" keys) — merge into trackCells. */29+ cells: string[];30+}31+32+export function planStubs(33+ rng: Rng,34+ /** All assembled placements — chosen `straight`s become `split`s. */35+ placements: Placement[],36+ /** Full track footprint incl. supports (assembled). */37+ occupied: ReadonlySet<string>,38+): StubPlan {39+ const empty: StubPlan = { placements: [], cells: [] };40+ if (!rng.chance(STUB_CHANCE)) return empty;41+42+ const key = (x: number, z: number) => `${x},${z}`;43+ // Cells that carry a tunnel arch — a junction under a tunnel reads wrong.44+ const arched = new Set(45+ placements46+ .filter((p) => p.pieceId === "tunnel")47+ .map((p) => key(Math.round(p.pos[0]), Math.round(p.pos[2]))),48+ );49+ // The goal gate straddles its straight — keep the lap line clean.50+ const goalCells = new Set(51+ placements52+ .filter((p) => p.goal)53+ .map((p) => key(Math.round(p.pos[0]), Math.round(p.pos[2]))),54+ );55+ // Cells carrying stacked track (bridge decks over channels): a junction56+ // in the lower channel would sit under the bridge — skip.57+ const cellCounts = new Map<string, number>();58+ for (const p of placements) {59+ if (p.role !== "track") continue;60+ const k = key(Math.round(p.pos[0]), Math.round(p.pos[2]));61+ cellCounts.set(k, (cellCounts.get(k) ?? 0) + 1);62+ }63+ const stacked = (k: string) => (cellCounts.get(k) ?? 0) > 1;64+65+ /** Rotate a mesh-local offset by the placement rotation (r quarters). */66+ const rot = (r: Dir, x: number, z: number): [number, number] =>67+ r === 0 ? [x, z] : r === 1 ? [z, -x] : r === 2 ? [-x, -z] : [-z, x];68+69+ interface Candidate {70+ placement: Placement;71+ /** What the host placement becomes (junction id; mirrored optional). */72+ junction: string;73+ mirrored?: boolean;74+ branch: [number, number][]; // cells outward from the ring75+ dir: Dir; // branch direction (away from the ring)76+ }77+ const taken = new Set<string>(occupied);78+ const candidates: Candidate[] = [];79+ for (const p of placements) {80+ if (p.role !== "track" || p.pos[1] !== 0) continue;81+82+ if (p.pieceId === "straight") {83+ const cx = Math.round(p.pos[0]);84+ const cz = Math.round(p.pos[2]);85+ if (86+ arched.has(key(cx, cz)) || goalCells.has(key(cx, cz)) ||87+ stacked(key(cx, cz))88+ ) continue;89+ // Placement rotation r maps entry-frame +z to the flow direction.90+ const flow = ((1 - p.rotation + 4) % 4) as Dir;91+ for (const side of [1, 3] as const) {92+ const w = ((flow + side) % 4) as Dir;93+ const [dx, dz] = DIR_STEP[w];94+ const s1: [number, number] = [cx + dx, cz + dz];95+ const s2: [number, number] = [cx + dx * 2, cz + dz * 2];96+ if (taken.has(key(...s1)) || taken.has(key(...s2))) continue;97+ candidates.push({98+ placement: p,99+ // side 1 = turnLeft(flow) — the native split branches left; a100+ // right branch renders the same mesh mirrored.101+ junction: "split",102+ mirrored: side === 3,103+ branch: [s1, s2],104+ dir: w,105+ });106+ }107+ }108+109+ // Turn junctions: split-left is the curve mesh plus a branch port on110+ // the outer flank (measured — drop-in replacement); split-right is its111+ // native mirror, so rendering it mirrored gives the same shape while112+ // covering the right-handed id. Same for the large family.113+ const turn = p.pieceId === "curve" || p.pieceId === "curve-solid"114+ ? { large: false, solid: p.pieceId === "curve-solid" }115+ : p.pieceId === "curve-large" || p.pieceId === "curve-solid-large"116+ ? { large: true, solid: p.pieceId === "curve-solid-large" }117+ : null;118+ if (turn && !p.mirrored) {119+ // Branch continues along mesh +x from the outer flank cell.120+ const cell0 = turn.large ? [2, 1] : [1.5, 0.5];121+ const w = ((0 - p.rotation + 4) % 4) as Dir;122+ const b: [number, number][] = [];123+ let ok = true;124+ for (let j = 0; j < 2; j++) {125+ const [ox, oz] = rot(p.rotation, cell0[0] + j, cell0[1]);126+ const bx = Math.round(p.pos[0] + ox);127+ const bz = Math.round(p.pos[2] + oz);128+ if (taken.has(key(bx, bz))) ok = false;129+ b.push([bx, bz]);130+ }131+ if (ok) {132+ candidates.push({133+ placement: p,134+ junction: "", // resolved at placement time (left/right alternation)135+ branch: b,136+ dir: w,137+ });138+ }139+ }140+ }141+ if (candidates.length === 0) return empty;142+143+ const count = rng.int(1, 3);144+ let placed = 0;145+ const usedHosts = new Set<Placement>();146+ const plan: StubPlan = { placements: [], cells: [] };147+ for (const cand of rng.shuffle(candidates)) {148+ if (placed >= count) break;149+ if (usedHosts.has(cand.placement)) continue;150+ if (cand.branch.some(([x, z]) => taken.has(key(x, z)))) continue;151+152+ // The host becomes the junction (same cell, same rotation — the153+ // junction's main channel is the host's channel).154+ if (cand.junction === "split") {155+ cand.placement.pieceId = "split";156+ if (cand.mirrored) cand.placement.mirrored = true;157+ } else {158+ const large = cand.placement.pieceId.includes("large");159+ const solid = cand.placement.pieceId.includes("solid");160+ // Alternate ids: the right-handed GLB mirrored renders the same161+ // left-handed shape (s-curve convention), keeping both ids in play.162+ const right = rng.chance(0.5);163+ cand.placement.pieceId = [164+ "split",165+ ...(large ? ["large"] : []),166+ ...(solid ? ["solid"] : []),167+ right ? "right" : "left",168+ ].join("-");169+ if (right) cand.placement.mirrored = true;170+ }171+ usedHosts.add(cand.placement);172+173+ const len = rng.chance(0.6) ? 2 : 1;174+ const rotation = ((1 - cand.dir + 4) % 4) as Dir;175+ const cap = rng.pick(CAPS);176+ const pieces = len === 2 ? ["straight-hole", cap] : [cap];177+ for (let j = 0; j < len; j++) {178+ const [x, z] = cand.branch[j];179+ plan.placements.push({180+ pieceId: pieces[j],181+ pos: [x, 0, z],182+ rotation,183+ role: "track",184+ });185+ taken.add(key(x, z));186+ plan.cells.push(key(x, z));187+ }188+ // An unused branch cell stays free for decor when the spur is short.189+ placed++;190+ }191+ return plan;192+}
M src/gen/style.ts +55 -3
@@ -17,6 +17,8 @@ export interface StyleField {
1717 supportColor: boolean;1818 /** 0..1 — decor placement probability scale. */1919 decorDensity: number;20+ /** 0..1 — the feature planner's appetite for wide-channel runs. */21+ wideAffinity: number;2022 }2123 2224 interface ThemeSpec {
@@ -25,6 +27,7 @@ interface ThemeSpec {
2527 bias: Record<string, number>; // by def id or family2628 solidGround: number; // chance ground-level pieces go solid2729 decorDensity: number;30+ wideAffinity: number;2831 }2932 3033 const THEMES: ThemeSpec[] = [
@@ -34,27 +37,75 @@ const THEMES: ThemeSpec[] = [
3437 bias: {},3538 solidGround: 0.55,3639 decorDensity: 0.5,40+ wideAffinity: 0.3,3741 },3842 {3943 name: "sweeping",4044 weight: 3,41- bias: { "curve-large": 5, curve: 2, corner: 0.25, tunnel: 0.5 },45+ bias: {46+ "curve-large": 5,47+ curve: 2,48+ "s-curve": 2,49+ corner: 0.25,50+ tunnel: 0.5,51+ },4252 solidGround: 0.4,4353 decorDensity: 0.45,54+ wideAffinity: 0.35,4455 },4556 {4657 name: "tight",4758 weight: 3,48- bias: { corner: 3.5, curve: 0.6, "curve-large": 0.05, straight: 0.8 },59+ bias: {60+ corner: 3.5,61+ bend: 3,62+ curve: 0.6,63+ "curve-large": 0.05,64+ straight: 0.8,65+ },4966 solidGround: 0.5,5067 decorDensity: 0.55,68+ wideAffinity: 0.05,5169 },5270 {5371 name: "canyon",5472 weight: 2,55- bias: { tunnel: 5, slant: 1.6, ramp: 1.6 },73+ bias: { tunnel: 5, slant: 1.6, ramp: 1.6, bump: 2 },5674 solidGround: 0.95,5775 decorDensity: 0.8,76+ wideAffinity: 0.1,77+ },78+ // M2 themes: exploit the flow vocabulary.79+ {80+ name: "serpentine",81+ weight: 3,82+ bias: {83+ bend: 6,84+ "s-curve": 5,85+ wave: 4,86+ bump: 2.5,87+ corner: 0.7,88+ straight: 0.7,89+ "curve-large": 0.4,90+ },91+ solidGround: 0.45,92+ decorDensity: 0.5,93+ wideAffinity: 0.15,94+ },95+ {96+ name: "boulevard",97+ weight: 2.5,98+ bias: {99+ "curve-large": 3,100+ curve: 1.5,101+ straight: 1.4,102+ wave: 0.5,103+ corner: 0.3,104+ tunnel: 0.3,105+ },106+ solidGround: 0.6,107+ decorDensity: 0.65,108+ wideAffinity: 0.9,58109 },59110 ];60111
@@ -79,6 +130,7 @@ export function pickStyle(rng: Rng): StyleField {
79130 supportKind,80131 supportColor,81132 decorDensity: spec.decorDensity,133+ wideAffinity: spec.wideAffinity,82134 solidAt(baseY: number): boolean {83135 // Elevated track reads better open (supports visible); ground track84136 // follows the theme's solidness.
M src/gen/topology.ts +545 -8
@@ -3,14 +3,27 @@
33 * Loop closure and self-avoidance are invariants of the construction —44 * realization can never break them.55 *6- * Algorithm: seeded rectangle perimeter, then K "bump" mutations. A bump7- * takes a straight run of the cycle and shifts its interior sideways by one8- * cell, preserving simplicity and a 1-cell clearance margin around the9- * detour (checked against the whole path).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.1015 */1116 import type { Dir } from "../shared/types.ts";1217 import type { Rng } from "./rng.ts";13-import { DIR_STEP, moveLabel, turnLeft, turnRight } from "./geom.ts";18+import {19+ applyXform,20+ DIR_STEP,21+ moveLabel,22+ opposite,23+ turnLeft,24+ turnRight,25+ windowXform,26+} from "./geom.ts";1427 1528 export interface SkeletonCell {1629 x: number;
@@ -22,6 +35,31 @@ export interface SkeletonCell {
2235 2336 export interface Skeleton {2437 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 };2563 }2664 2765 type P = readonly [number, number];
@@ -46,6 +84,10 @@ export function generateSkeleton(rng: Rng): Skeleton {
4684 for (let z = 0; z < h - 1; z++) ring.push([w - 1, z]);4785 for (let x = w - 1; x > 0; x--) ring.push([x, h - 1]);4886 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();4991 5092 const cells = new Set(ring.map(([x, z]) => key(x, z)));5193
@@ -57,7 +99,31 @@ export function generateSkeleton(rng: Rng): Skeleton {
5799 attempt < mutations * 10 && applied < mutations;58100 attempt++59101 ) {60- if (tryBump(ring, cells, mutRng)) applied++;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+ }112+113+ // 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);61127 }62128 63129 // Assemble cells with in/out dirs.
@@ -71,9 +137,31 @@ export function generateSkeleton(rng: Rng): Skeleton {
71137 });72138 73139 // Normalize: rotate so index 0 is a turn (straight runs never wrap the74- // seam, which keeps run-finding in the elevation system simple).140+ // 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.75144 const firstTurn = out.findIndex((c) => c.move !== "F");76- return { cells: out.slice(firstTurn).concat(out.slice(0, firstTurn)) };145+ const norm = out.slice(firstTurn).concat(out.slice(0, firstTurn));146+147+ // 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+ };77165 }78166 79167 /**
@@ -147,3 +235,452 @@ function tryBump(ring: P[], cells: Set<string>, rng: Rng): boolean {
147235 for (const [x, z] of ring) cells.add(key(x, z));148236 return true;149237 }238+239+/**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+ */244+function 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+}262+263+/** Replace ring cells idx(i)..idx(i+m−1) with `sub` (same endpoints). */264+function 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+}280+281+/**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+ */288+function 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;312+313+ 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));323+324+ 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;337+338+ const sub: P[] = [s, ...colA, ...top, ...colB.slice().reverse(), e];339+ applySplice(ring, cells, i, m, sub);340+ return true;341+}342+343+/**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+ */357+function 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;383+384+ const [nx, nz] = DIR_STEP[normal];385+ const at = (c: P, j: number): P => [c[0] + nx * j, c[1] + nz * j];386+387+ // 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 segment400+401+ 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;431+432+ // Column length: at least one row beyond the crossing, at most a few.433+ const k = d + 1 + rng.int(0, 3);434+435+ 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;460+461+ 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+}468+469+/**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+ */475+export 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+];496+497+/** Walk a moves string from (0,0) heading +z; returns the cell sequence. */498+function 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+}510+511+/**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+ */525+function tryHelixLoop(526+ ring: P[],527+ cells: Set<string>,528+ rng: Rng,529+): { len: number; id: string } | null {530+ const n = ring.length;531+532+ 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)];554+555+ // 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;584+585+ // 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];589+590+ // 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+ };602+603+ // 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";614+615+ 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+ );631+632+ 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+}638+639+/**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+ */644+function 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;661+662+ 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)];665+666+ 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);669+670+ 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;683+684+ applySplice(ring, cells, i, span, sub);685+ return true;686+}
A src/gen/version.ts +72 -0

Not shown: this page already shows 6000 lines of diff.

M src/gen/wfc.ts +100 -28

Not shown: this page already shows 6000 lines of diff.

M src/shared/types.ts +19 -0

Not shown: this page already shows 6000 lines of diff.

M static/index.html +67 -33

Not shown: this page already shows 6000 lines of diff.

A tests/decor_test.ts +108 -0

Not shown: this page already shows 6000 lines of diff.

A tests/endless_test.ts +167 -0

Not shown: this page already shows 6000 lines of diff.

M tests/golden_tracks.json +4 -3

Not shown: this page already shows 6000 lines of diff.

M tests/registry_test.ts +37 -0

Not shown: this page already shows 6000 lines of diff.

M tests/track_test.ts +94 -5

Not shown: this page already shows 6000 lines of diff.

A tools/coverage.ts +142 -0

Not shown: this page already shows 6000 lines of diff.

A tools/measure_floor_path.ts +71 -0

Not shown: this page already shows 6000 lines of diff.

A tools/measure_ports.ts +187 -0

Not shown: this page already shows 6000 lines of diff.