An island top IS local ground. Wide runs: funnel-bracketed reservations on flat island stretches with claimed flank columns; proxies scale the ribbon per window (wideScalesOf). Furniture: planner ledge cells beside straight run cells, furnished post-realize as stub sidings (host straight→split) or channel-facing fans. Solids return in open mode where a window rides its terrace (localGroundY + deckAt from the planner's mass record; ring-wrap ports clamp on open paths). Tunnels needed nothing. Scenario 'island ground vocabulary' + wide-run physics ride; 89 tests green.
- commit
- 772438fe9338b58a3f1444ccb4f4f6f5d2fcf057
- parent
- 0b332b0d3795a36971d616ee7eecb9b5c28c061e
- author
- Dami <dami.etoile@gmail.com>
| M | progress.md | 26 | ++++ |
| M | src/ | 13 | ++- |
| M | src/ | 44 | +++++++- |
| A | src/ | 106 | ++++++++++++++++++ |
| M | src/ | 11 | ++ |
| M | src/ | 177 | +++++++++++++++++++++++++++++- |
| M | src/ | 46 | +++++++- |
| M | src/ | 33 | ++++++ |
| M | tests/ | 30 | +++++- |
| M | tests/ | 180 | ++++++++++++++++++++++++++++++ |
| M | tools/ | 3 | +- |
@@ -812,6 +812,32 @@ zones (icy needs a visible surface treatment first — zones must READ).
812812 813813 **DONE this round (post-M12, user-directed):**814814 815+- **GROUND VOCABULARY ON ISLANDS** (promoted backlog: "an island top IS816+ local ground"). Tunnels needed nothing (already free-collapse in817+ endless — the banner rule's arched cells). WIDE RUNS: tryIsland plans818+ the loop-mode feature shape (funnel → straight-wide×1..3 → funnel819+ reversed) on flat straight island stretches; BOTH flank columns820+ claimed and recorded (crossing.wideFlanks → track patch: full mass,821+ no caps/decor); collision proxies gained per-window lateral scaling822+ (Station.wScale, wideScalesOf from realized families — funnels lerp823+ 1→2.43 across their window). FURNITURE: tryIsland claims 1–3 ledge824+ cells beside flat straight run cells (crossing.sideCells); after825+ realize, endless/furniture.ts furnishes ledges whose host realized as826+ a plain straight — stub sidings (host straight→split in place, the827+ loop stubs.ts move; ledge gets a no-hole end cap) or fans (blades828+ face the channel, spin + kinematic boost). SOLIDS: AssembleOptions829+ gained localGroundY (window grounded on its terrace → solids return830+ in open mode via style.solidAt(0)) and deckAt (the planner's own831+ mass-claim record, crossing.deckCells — solid neighbor support may832+ lean on the island deck; NO third mirror of the terrace rule);833+ solidSupported's ring-wrap ports clamp in open mode. Emergence over834+ 12 seeds × 10 segs: 22 wide windows, 31 stub junctions, 27 fans, 84835+ solids. New scenario "island ground vocabulary" pins the rules836+ (wide = grounded + bracketed + flanked, ledges furnished per host837+ kind, fans only on ledges, solids near island ground) + physics838+ scenario rides wide-scan-0's wide run. 89 tests green; browser: wide839+ deck reads on screen, 0 deaths.840+815841 - **HUD DISTANCE MILESTONES + PHOTO KEY** (promoted backlog): milestone816842 toast (src/client/hud.ts — pure milestoneFor: landmarks 100/250/500/817843 750/1000 then every 500; MilestoneToast self-owned DOM, fades, CUA@@ -35,7 +35,11 @@ import {
3535 stationForce,3636 stationRestitution,3737 } from "./physics/rail.ts";38-import { channelProxies, islandProxies } from "../gen/endless/proxies.ts";38+import {39+ channelProxies,40+ islandProxies,41+ wideScalesOf,42+} from "../gen/endless/proxies.ts";3943 import { buildIslandInstanced, type InstancedBuild } from "./islandRender.ts";4044 import { Atmosphere, drawZone } from "./atmosphere.ts";4145 import type { DecorCatalog } from "../gen/decor.ts";@@ -442,7 +446,12 @@ class Session {
442446 const length = curve.getLength();443447 if (this.engine) {444448 this.engine.addSegment(i, [445- ...channelProxies(data.path, true, gapSegmentsOf(data)),449+ ...channelProxies(450+ data.path,451+ true,452+ gapSegmentsOf(data),453+ wideScalesOf(data),454+ ),446455 ...data.islands.flatMap((isle) => islandProxies(isle.placements)),447456 ]);448457 // Dense rail (points/tangents/s/mods) for nearest-point progress and@@ -39,6 +39,19 @@ export interface AssembleOptions {
3939 * by solid support checks. Default (closed loop) is unaffected.4040 */4141 open?: boolean;42+ /**43+ * Endless ground vocabulary: local ground height at a cell index — an44+ * island run cell riding its own terrace (null off-island or bridging45+ * above it). When every cell of a window is grounded, solid piece46+ * forms come back in open mode: an island top IS local ground.47+ */48+ localGroundY?: (cellIdx: number) => number | null;49+ /**50+ * Endless: island deck height at a world cell ("x,z" key) — the51+ * planner's mass-claim record. Extends the solid neighbor check52+ * beyond ring cells (the island deck abutting a shoreline turn).53+ */54+ deckAt?: (cellKey: string) => number | undefined;4255 }4356 4457 const key = (x: number, z: number) => `${x},${z}`;@@ -92,15 +105,25 @@ export function assemble(
92105 own.add(key(cx, cz));93106 cells.push([cx, cz]);94107 }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)]);108+ // Open paths have no seam: clamp the port cells instead of wrapping109+ // (wrapped indices would name bogus cells at both ends).110+ const prevIdx = opts.open ? win.start - 1 : (win.start - 1 + n) % n;111+ const nextIdx = opts.open112+ ? win.start + win.def.moves.length113+ : (win.start + win.def.moves.length) % n;114+ const ports = new Set<string>();115+ for (const idx of [prevIdx, nextIdx]) {116+ const c = skeleton.cells[idx];117+ if (c) ports.add(key(c.x, c.z));118+ }98119 const baseY = elevation.baseY[win.start % n];99120 for (const [cx, cz] of cells) {100121 for (const [dx, dz] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {101122 const k = key(cx + dx, cz + dz);102123 if (own.has(k) || ports.has(k)) continue;103124 if (cellHeight.get(k) === baseY) return true;125+ // Island deck abutting the footprint counts too (endless).126+ if (opts.deckAt?.(k) === baseY) return true;104127 }105128 }106129 return false;@@ -134,8 +157,19 @@ export function assemble(
134157 const xf: Xform = windowXform(cell.x, cell.z, cell.dirIn, baseY);135158 const def = win.def;136159 137- const useSolid = !opts.open && def.solidId !== undefined &&138- style.solidAt(baseY) &&160+ // Solids are ground-only (user rule). Closed loops: world ground at161+ // baseY <= 0. Open paths: an island top IS local ground — the window162+ // must sit entirely on its terrace (localGroundY matches every cell).163+ const grounded = opts.open164+ ? opts.localGroundY !== undefined &&165+ Array.from(166+ { length: def.moves.length },167+ (_, j) => win.start + j,168+ ).every((idx) =>169+ opts.localGroundY!(idx) === elevation.baseY[idx]170+ ) && style.solidAt(0)171+ : style.solidAt(baseY);172+ const useSolid = grounded && def.solidId !== undefined &&139173 (!def.solidExtends || solidSupported(win, xf));140174 // −1 for noMesh windows: their loop segment has no placement of its own.141175 const placementIndex = def.noMesh ? -1 : placements.length;@@ -0,0 +1,106 @@
1+/**2+ * Island furniture (ground vocabulary — an island top IS local ground):3+ * stub sidings and boost fans on the ledge cells the planner claimed4+ * beside island runs (IslandCrossing.sideCells).5+ *6+ * The planner claimed the ledge band and the island builder put full mass7+ * beneath it; this pass runs AFTER realize/assemble because a ledge is8+ * only furnishable when its host cell realized as a plain straight (a9+ * tunnel arch or a wave has no side port). Stub hosts mutate in place10+ * (straight → split, the loop-mode stubs.ts move: the junction's main11+ * channel IS the host's channel; a right-hand branch renders the native12+ * left-branching mesh mirrored); fans stand in the ledge cell with the13+ * blade side facing the channel.14+ */15+import type { Dir, Placement } from "../../shared/types.ts";16+import type { Rng } from "../rng.ts";17+import type { RealizedWindow } from "../wfc.ts";18+import { turnLeft, turnRight } from "../geom.ts";19+import type { SegmentPlan } from "./path.ts";20+21+/** No hole caps on islands: a drain spur over solid mass reads wrong. */22+const CAPS = ["end-rounded", "end-square"];23+const FANS = [24+ "fan-standing-two",25+ "fan-standing-four",26+ "fan-two",27+ "fan-four",28+ "fan-simple-two",29+ "fan-simple-four",30+];31+const FAN_BOOST = 6;32+33+export interface FurnishedPiece {34+ placement: Placement;35+ /** Absolute plan-cell index of the host (for chapter stamping). */36+ host: number;37+}38+39+export function planIslandFurniture(40+ rng: Rng,41+ plan: SegmentPlan,42+ windows: readonly RealizedWindow[],43+ /** Assembled placements in TRACK coords — stub hosts are MUTATED44+ * (straight → split) exactly like loop-mode stubs. */45+ assembled: Placement[],46+): FurnishedPiece[] {47+ const out: FurnishedPiece[] = [];48+ const windowAt = (idx: number): RealizedWindow | undefined =>49+ windows.find((w) => idx >= w.start && idx < w.start + w.len);50+51+ for (const crossing of plan.crossings) {52+ for (const sc of crossing.sideCells ?? []) {53+ const host = plan.cells[sc.host];54+ const win = windowAt(sc.host);55+ // Plain straight hosts only — the family carries a bare centered56+ // channel (the banner rule's own test); anything else has no57+ // usable side port.58+ if (!win || win.def.family !== "straight") continue;59+ const hp = assembled.find((p) =>60+ p.role === "track" && p.pieceId === "straight" &&61+ Math.round(p.pos[0]) === host.x &&62+ Math.round(p.pos[2]) === host.z &&63+ Math.abs(p.pos[1] - host.baseY) < 1e-664+ );65+ if (!hp) continue;66+67+ const r = rng.child(`isle/${crossing.id}/side/${sc.cell[0]},${sc.cell[1]}`);68+ const away: Dir = sc.side === "L"69+ ? turnLeft(host.dirIn)70+ : turnRight(host.dirIn);71+ const [cx, cz] = sc.cell;72+73+ if (r.chance(0.55)) {74+ // STUB SIDING: host becomes the 3-way split; the ledge gets a75+ // dead-end cap opening back toward the junction.76+ hp.pieceId = "split";77+ if (sc.side === "R") hp.mirrored = true;78+ out.push({79+ placement: {80+ pieceId: r.pick(CAPS),81+ pos: [cx, host.baseY, cz],82+ rotation: ((1 - away + 4) % 4) as Dir,83+ role: "track",84+ },85+ host: sc.host,86+ });87+ } else {88+ // FAN: blades face the channel, spinning; boost is kinematic-89+ // mode flavor (the endless assist law has its own zones).90+ const facing = ((away + 2) % 4) as Dir;91+ out.push({92+ placement: {93+ pieceId: r.pick(FANS),94+ pos: [cx, host.baseY, cz],95+ rotation: ((1 - facing + 4) % 4) as Dir,96+ role: "track",97+ spin: { axis: [0, 0, 1], radPerSec: 4 + r.float() * 3 },98+ boost: FAN_BOOST,99+ },100+ host: sc.host,101+ });102+ }103+ }104+ }105+ return out;106+}@@ -157,6 +157,17 @@ export function buildIsland(
157157 for (const [cx, cz] of crossing.branchCells ?? []) {158158 for (const [wx, wz] of worldCellsOf(cx, cz)) runWorld.add(key(wx, wz));159159 }160+ // Wide-run flanks and furniture ledges are track patch too: full mass161+ // beneath, no caps (a cap tile would z-fight the channel/cap pieces162+ // standing there), no decor.163+ for (const [cx, cz] of crossing.wideFlanks ?? []) {164+ for (const [wx, wz] of worldCellsOf(cx, cz)) runWorld.add(key(wx, wz));165+ }166+ for (const { cell } of crossing.sideCells ?? []) {167+ for (const [wx, wz] of worldCellsOf(cell[0], cell[1])) {168+ runWorld.add(key(wx, wz));169+ }170+ }160171 const runWorldList = [...runWorld].map((k) => k.split(",").map(Number));161172 const chebToRun = (x: number, z: number): number => {162173 let best = Infinity;@@ -112,6 +112,22 @@ export interface IslandCrossing {
112112 * the island builder treats them as track: full mass, no caps, no113113 * decor. */114114 branchCells?: readonly (readonly [number, number])[];115+ /** Wide-run flank columns (the wide profile bulges into them) — track116+ * patch like branch columns: full mass, no caps, no decor. */117+ wideFlanks?: readonly (readonly [number, number])[];118+ /** Furniture ledges beside flat straight run cells, claimed at plan119+ * time; furnished post-realize (stub siding or fan) when the host120+ * cell realizes as a plain straight. `host` is the absolute cell121+ * index; `side` is L/R of the flow. */122+ sideCells?: readonly {123+ cell: readonly [number, number];124+ host: number;125+ side: "L" | "R";126+ }[];127+ /** Envelope cells that received a MASS claim, with their terrace tops128+ * — the planner's own deck record. Assemble consults it for solid129+ * support; a third mirror of the terrace rule would risk drift. */130+ deckCells?: readonly (readonly [number, number, number])[];115131 }116132 117133 /** Planner-placed physics zone (cell-indexed): the set-piece channel.@@ -905,10 +921,10 @@ class SegmentBuilder {
905921 // columns become part of the island's track patch (full mass beneath,906922 // no caps, no decor; canopy skips them because their band is claimed907923 // before the envelope loop below runs).908- const branchCells: (readonly [number, number])[] = [];909- if (rng.child("diamond").chance(0.5)) {910- const dRng = rng.child("diamond/pick");911- const stretches: [number, number][] = [];924+ // Maximal flat straight stretches of the run (local index, length) —925+ // shared by the diamond, wide-run, and furniture pickers below.926+ const stretches: [number, number][] = [];927+ {912928 let j = 0;913929 while (j < runLen) {914930 if (steps[j].move !== "F") {@@ -920,6 +936,17 @@ class SegmentBuilder {
920936 if (l >= 5) stretches.push([j, l]);921937 j += l;922938 }939+ }940+ /** True when [start, start+len) (absolute cell indices) overlaps any941+ * committed reservation. */942+ const overlapsReservation = (s0: number, len: number): boolean =>943+ this.reservations.some((r) =>944+ r.start < s0 + len && s0 < r.start + r.len945+ );946+947+ const branchCells: (readonly [number, number])[] = [];948+ if (rng.child("diamond").chance(0.5)) {949+ const dRng = rng.child("diamond/pick");923950 placing:924951 for (const [s0, l] of dRng.shuffle(stretches)) {925952 for (let N = Math.min(3, l - 4); N >= 1; N--) {@@ -991,6 +1018,143 @@ class SegmentBuilder {
9911018 }9921019 }9931020 1021+ // WIDE RUN (ground vocabulary — an island top IS local ground): a1022+ // funnel-bracketed wide channel on a flat straight stretch, exactly1023+ // the loop-mode feature shape (funnel → wide×1..3 → funnel reversed).1024+ // Both flank columns of every window cell must be free (the wide1025+ // profile bulges 0.35 into them); they're claimed as channel band and1026+ // become track patch (full mass, no caps/decor) like branch columns.1027+ const wideFlanks: (readonly [number, number])[] = [];1028+ if (rng.child("wide").chance(0.4)) {1029+ const wRng = rng.child("wide/pick");1030+ placingWide:1031+ for (const [s0, l] of wRng.shuffle(stretches)) {1032+ for (let mid = Math.min(3, l - 2); mid >= 1; mid--) {1033+ const total = mid + 2;1034+ const offsets = wRng.shuffle(1035+ Array.from({ length: l - total + 1 }, (_, o) => o),1036+ );1037+ for (const o of offsets) {1038+ const j0 = s0 + o;1039+ if (overlapsReservation(start + j0, total)) continue;1040+ const flatY = this.cells[start + j0].baseY;1041+ let ok = true;1042+ const flanks: (readonly [number, number])[] = [];1043+ for (let k = 0; k < total && ok; k++) {1044+ const cell = this.cells[start + j0 + k];1045+ if (cell.baseY !== flatY || cell.baseY !== profile[j0 + k]) {1046+ ok = false;1047+ break;1048+ }1049+ for (1050+ const side of [turnLeft(cell.dirIn), turnRight(cell.dirIn)]1051+ ) {1052+ const [dx, dz] = DIR_STEP[side];1053+ const fx = cell.x + dx;1054+ const fz = cell.z + dz;1055+ if (1056+ runCells.some(([rx, rz]) => rx === fx && rz === fz) ||1057+ !this.occ.isFree(fx, fz, flatY, flatY + CHANNEL_CLEARANCE)1058+ ) {1059+ ok = false;1060+ break;1061+ }1062+ flanks.push([fx, fz]);1063+ }1064+ }1065+ if (!ok) continue;1066+ for (const [fx, fz] of flanks) {1067+ this.occ.claim(1068+ fx,1069+ fz,1070+ flatY,1071+ flatY + CHANNEL_CLEARANCE,1072+ this.ref,1073+ );1074+ }1075+ wideFlanks.push(...flanks);1076+ this.reservations.push({1077+ start: start + j0,1078+ len: 1,1079+ pieceIds: ["funnel"],1080+ reversed: false,1081+ });1082+ for (let k = 0; k < mid; k++) {1083+ this.reservations.push({1084+ start: start + j0 + 1 + k,1085+ len: 1,1086+ pieceIds: ["straight-wide"],1087+ reversed: false,1088+ });1089+ }1090+ this.reservations.push({1091+ start: start + j0 + 1 + mid,1092+ len: 1,1093+ pieceIds: ["funnel"],1094+ reversed: true,1095+ });1096+ break placingWide;1097+ }1098+ }1099+ }1100+ }1101+1102+ // FURNITURE LEDGES: 1–3 single cells flanking flat straight run cells1103+ // — claimed now (mass beneath, no canopy), furnished after realize1104+ // (stub siding or fan) if their host realizes as a plain straight.1105+ const sideCells: {1106+ cell: readonly [number, number];1107+ host: number;1108+ side: "L" | "R";1109+ }[] = [];1110+ if (rng.child("furniture").chance(0.55)) {1111+ const fRng = rng.child("furniture/pick");1112+ const candidates: typeof sideCells = [];1113+ for (let j = 1; j < runLen - 1; j++) {1114+ const cell = this.cells[start + j];1115+ if (1116+ cell.move !== "F" || cell.baseY !== profile[j] ||1117+ overlapsReservation(start + j, 1)1118+ ) continue;1119+ for (const [side, dir] of [1120+ ["L", turnLeft(cell.dirIn)],1121+ ["R", turnRight(cell.dirIn)],1122+ ] as const) {1123+ const [dx, dz] = DIR_STEP[dir];1124+ const fx = cell.x + dx;1125+ const fz = cell.z + dz;1126+ if (1127+ runCells.some(([rx, rz]) => rx === fx && rz === fz) ||1128+ !this.occ.isFree(1129+ fx,1130+ fz,1131+ cell.baseY,1132+ cell.baseY + CHANNEL_CLEARANCE,1133+ )1134+ ) continue;1135+ candidates.push({ cell: [fx, fz], host: start + j, side });1136+ }1137+ }1138+ const want = fRng.int(1, 4);1139+ const usedHosts = new Set<number>();1140+ const usedCells = new Set<string>();1141+ for (const cand of fRng.shuffle(candidates)) {1142+ if (sideCells.length >= want) break;1143+ if (usedHosts.has(cand.host)) continue;1144+ if (usedCells.has(key(cand.cell[0], cand.cell[1]))) continue;1145+ this.occ.claim(1146+ cand.cell[0],1147+ cand.cell[1],1148+ this.cells[cand.host].baseY,1149+ this.cells[cand.host].baseY + CHANNEL_CLEARANCE,1150+ this.ref,1151+ );1152+ usedHosts.add(cand.host);1153+ usedCells.add(key(cand.cell[0], cand.cell[1]));1154+ sideCells.push(cand);1155+ }1156+ }1157+9941158 // Claim the mass envelope now — blob rings around the run, each cell9951159 // reserving as much depth below topY as older claims allow. The island9961160 // builder works strictly inside `isle/${id}` claims.@@ -1053,6 +1217,7 @@ class SegmentBuilder {
10531217 ...runCells.map(([x, z]) => key(x, z)),10541218 ...branchCells.map(([x, z]) => key(x, z)),10551219 ]);1220+ const deckCells: (readonly [number, number, number])[] = [];10561221 for (const [cx, cz] of envelope.values()) {10571222 const cellTop = terraceTop(cx, cz);10581223 // Deepest free band under the top: stop at the first older claim.@@ -1069,6 +1234,7 @@ class SegmentBuilder {
10691234 }10701235 if (cellTop - floor < 0.999) continue; // no room for even one block10711236 this.occ.claim(cx, cz, floor, cellTop, isleRef);1237+ deckCells.push([cx, cz, cellTop]);10721238 // Canopy airspace: non-run surface cells may grow tall decor (trees,10731239 // banners); claim the air above them so no future line threads10741240 // through a crown. Cells whose airspace is already spoken for simply@@ -1090,6 +1256,9 @@ class SegmentBuilder {
10901256 profile,10911257 margin,10921258 ...(branchCells.length > 0 ? { branchCells } : {}),1259+ ...(wideFlanks.length > 0 ? { wideFlanks } : {}),1260+ ...(sideCells.length > 0 ? { sideCells } : {}),1261+ ...(deckCells.length > 0 ? { deckCells } : {}),10931262 });10941263 this.islandCount++;10951264 this.sinceAnchor = 0;@@ -105,6 +105,31 @@ interface Station {
105105 * (two crossing one-sided branch meshes destabilized the solver). */106106 diamond: boolean;107107 dy: number;108+ /** Lateral profile scale (wide runs: the kit's 1.7-unit channel vs the109+ * 0.7 default; funnels lerp across their window). */110+ wScale: number;111+}112+113+/** Wide channel lateral scale: kit wide walls ±0.85 track vs ±0.35. */114+const WIDE_SCALE = 0.85 / 0.35;115+116+/** Per-path-segment lateral width range [entry, exit] for wide runs —117+ * derived from realized window families (wide windows are always118+ * funnel-bracketed by construction; a funnel tapers toward its wide119+ * neighbor). Undefined when the segment has no wide vocabulary. */120+export function wideScalesOf(121+ seg: { pieceFamilies: string[] },122+): Map<number, readonly [number, number]> | undefined {123+ const fam = seg.pieceFamilies;124+ const out = new Map<number, readonly [number, number]>();125+ for (let i = 0; i < fam.length; i++) {126+ if (fam[i] === "wide") out.set(i, [WIDE_SCALE, WIDE_SCALE]);127+ else if (fam[i] === "funnel") {128+ if (fam[i + 1] === "wide") out.set(i, [1, WIDE_SCALE]);129+ else if (fam[i - 1] === "wide") out.set(i, [WIDE_SCALE, 1]);130+ }131+ }132+ return out.size > 0 ? out : undefined;108133 }109134 110135 /** Channel slabs for one segment's world-space path. Two passes: sample@@ -116,13 +141,19 @@ export function channelProxies(
116141 path: LoopPath,117142 useRibbon = true,118143 gapSegments?: ReadonlySet<number>,144+ wideScales?: ReadonlyMap<number, readonly [number, number]>,119145 ): CollisionProxy[] {120146 const sampleParts = (121147 parts: LoopPath["segments"][number]["curve"],122148 gap: boolean,123149 into: Station[],124150 diamond = false,151+ wRange: readonly [number, number] | null = null,125152 ): void => {153+ const windowLen = wRange154+ ? parts.reduce((acc, p) => acc + segLength(p), 0) || 1155+ : 1;156+ let travelled = 0;126157 for (const part of parts) {127158 const len = segLength(part);128159 // Curvature-aware sampling: arcs bound the per-slab turn angle;@@ -148,6 +179,14 @@ export function channelProxies(
148179 const total = Math.hypot(dx, dy, dz);149180 if (total < 1e-9) continue;150181 const vertical = !gap && Math.abs(dy / total) > VERTICAL_LIMIT;182+ const wScale = wRange183+ ? wRange[0] +184+ (wRange[1] - wRange[0]) *185+ Math.min(186+ 1,187+ (travelled + len * ((i + 0.5) / steps)) / windowLen,188+ )189+ : 1;151190 into.push({152191 p: [p[0], p[1], p[2]],153192 yaw: vertical ? 0 : Math.atan2(dx, dz),@@ -157,8 +196,10 @@ export function channelProxies(
157196 gap,158197 diamond,159198 dy,199+ wScale,160200 });161201 }202+ travelled += len;162203 }163204 };164205 const stations: Station[] = [];@@ -168,6 +209,7 @@ export function channelProxies(
168209 gapSegments?.has(segIdx) ?? false,169210 stations,170211 seg.alt !== undefined,212+ wideScales?.get(segIdx) ?? null,171213 );172214 }173215 // Vertical stations inherit the approach yaw; their exit heading is the@@ -553,9 +595,9 @@ function ribbonOf(
553595 // Catch-mouth flare: lateral scale ramps 1.35 → 1 over the first554596 // 2 world units (heights untouched — the floor band widens and the555597 // gutter walls converge downstream).556- const latScale = flareStart598+ const latScale = (flareStart557599 ? 1 + 0.35 * Math.max(0, 1 - arc / 2.0)558- : 1;600+ : 1) * st.wScale;559601 // Frame from the true tangent: right = up × t, upV = t × right.560602 const ty = Math.sin(-st.pitch);561603 const th = Math.cos(st.pitch);@@ -36,6 +36,7 @@ import {
3636 type SegmentPlan,3737 } from "./path.ts";3838 import { buildIsland, type Island } from "./islands.ts";39+import { planIslandFurniture } from "./furniture.ts";3940 4041 export interface EndlessSegment {4142 index: number;@@ -317,9 +318,37 @@ export class EndlessWorld {
317318 return ch;318319 };319320 this.state = next;321+ // Ground vocabulary (an island top IS local ground): solids come back322+ // where a window sits on its terrace, and the solid neighbor check may323+ // lean on the island deck (the planner's own mass-claim record).324+ const deckMap = new Map<string, number>();325+ for (const c of plan.crossings) {326+ for (const [dx, dz, top] of c.deckCells ?? []) {327+ deckMap.set(`${dx},${dz}`, top);328+ }329+ }330+ const localGroundY = (idx: number): number | null => {331+ for (const c of plan.crossings) {332+ if (idx >= c.start && idx < c.start + c.len) {333+ const base = plan.cells[idx].baseY;334+ return base === c.profile[idx - c.start] ? base : null;335+ }336+ }337+ return null;338+ };320339 const assembled = assemble(skeleton, elevation, this.style, windows, {321340 open: true,341+ localGroundY,342+ deckAt: (k) => deckMap.get(k),322343 });344+ // Island furniture: stub sidings / fans on the planner's ledge cells345+ // (mutates stub hosts straight → split in place).346+ const furniture = planIslandFurniture(347+ rngFamily(this.seed, `furniture/${i}`),348+ plan,349+ windows,350+ assembled.placements,351+ );323352 324353 // Islands anchored in this segment.325354 const islands = plan.crossings.map((crossing) =>@@ -459,6 +488,10 @@ export class EndlessWorld {
459488 ...assembled.placements460489 .map((p, idx): Placement => ({ ...p, chapter: chapterOf[idx] }))461490 .filter((_, idx) => !dropped.has(idx)),491+ ...furniture.map(({ placement, host }): Placement => ({492+ ...placement,493+ chapter: cellChapter(host),494+ })),462495 ...pads,463496 ].map((p): Placement => ({464497 ...p,@@ -12,7 +12,11 @@
1212 */1313 import { assert, assertEquals } from "@std/assert";1414 import { EndlessWorld } from "../src/gen/endless/world.ts";15-import { channelProxies, islandProxies } from "../src/gen/endless/proxies.ts";15+import {16+ channelProxies,17+ islandProxies,18+ wideScalesOf,19+} from "../src/gen/endless/proxies.ts";1620 import {1721 type MarbleState,1822 PhysicsEngine,@@ -79,7 +83,7 @@ function driveWith(
7983 const seg = w.get(i);8084 rails.push(railForSegment(seg, useAlt));8185 engine.addSegment(i, [82- ...channelProxies(seg.path, true, gapSegmentsOf(seg)),86+ ...channelProxies(seg.path, true, gapSegmentsOf(seg), wideScalesOf(seg)),8387 ...seg.islands.flatMap((isle) => islandProxies(isle.placements)),8488 ]);8589 for (const c of seg.plan.cells) lowest = Math.min(lowest, c.baseY * 2);@@ -282,3 +286,25 @@ Deno.test("scenario helix-dive: marble rides the full spiral and the underpass",
282286 })`,283287 );284288 });289+290+Deno.test("scenario wide-run: marble rides funnel → wide channel → funnel", async () => {291+ module_ = await factory();292+ // wide-scan-0 segment 0 hosts an island wide run (funnel-bracketed by293+ // construction). The proxies widen the ribbon over those windows294+ // (wideScalesOf) — the marble must enter the funnel, cross the wide295+ // deck, and be gathered back to the narrow channel.296+ const w = new EndlessWorld("wide-scan-0", catalog);297+ const seg = w.get(0);298+ const wi = seg.pieceFamilies.indexOf("wide");299+ assert(wi >= 0, "wide-scan-0 seg 0 lost its wide run");300+ let endS = 0;301+ for (let k = 0; k <= wi + 1; k++) endS += seg.path.segments[k].length;302+ const r = drive("wide-scan-0", 3, 60, endS + 8);303+ assert(!r.fell, "marble fell in the wide run");304+ assert(305+ r.reachedS >= endS + 7,306+ `marble stalled at s=${r.reachedS.toFixed(1)} (wide run ends ${307+ endS.toFixed(1)308+ })`,309+ );310+});@@ -686,3 +686,183 @@ Deno.test("helix dive: full-helix ensembles in the void obey the window contract
686686 }687687 assert(seen >= 4, `too few full helixes across the corpus (${seen})`);688688 });689+690+// ---------------------------------------------------------------------------691+// Ground vocabulary on islands (promoted backlog): an island top IS local692+// ground — wide runs, stub sidings, fans, and solid piece forms come back693+// where the window rides its terrace. (Tunnels were never gated.)694+// ---------------------------------------------------------------------------695+696+Deno.test("island ground vocabulary: wide runs, furniture, solids obey the rules", () => {697+ const CAP_IDS = new Set(["end-rounded", "end-square"]);698+ const SOLID_PREFIXES = [699+ "corner-solid",700+ "curve-solid",701+ "slant-solid",702+ "ramp-long-solid",703+ "ramp-start-solid",704+ "ramp-end-solid",705+ "bend-solid",706+ "wave-solid",707+ "bump-solid",708+ ];709+ let wides = 0;710+ let furnished = 0;711+ let solids = 0;712+ for (let n = 0; n < 10; n++) {713+ const seed = `vocab-${n}`;714+ const w = new EndlessWorld(seed, catalog);715+ for (let i = 0; i < 8; i++) {716+ const seg = w.get(i);717+ const { crossings } = seg.plan;718+ const inCrossing = (idx: number) =>719+ crossings.find((c) => idx >= c.start && idx < c.start + c.len);720+ const grounded = (idx: number) => {721+ const c = inCrossing(idx);722+ return c !== undefined &&723+ seg.plan.cells[idx].baseY === c.profile[idx - c.start];724+ };725+726+ // WIDE RUNS: island-only, flat on the terrace, funnel-bracketed,727+ // flanks recorded as track patch with mass beneath.728+ for (let wi = 0; wi < seg.pieceFamilies.length; wi++) {729+ if (seg.pieceFamilies[wi] !== "wide") continue;730+ wides++;731+ const range = seg.windowRanges[wi];732+ for (let j = range.start; j < range.start + range.len; j++) {733+ assert(grounded(j), `${seed} seg ${i}: wide cell ${j} off ground`);734+ }735+ assert(736+ seg.pieceFamilies[wi - 1] === "funnel" ||737+ seg.pieceFamilies[wi - 1] === "wide",738+ `${seed} seg ${i}: wide window ${wi} not funnel-bracketed before`,739+ );740+ assert(741+ seg.pieceFamilies[wi + 1] === "funnel" ||742+ seg.pieceFamilies[wi + 1] === "wide",743+ `${seed} seg ${i}: wide window ${wi} not funnel-bracketed after`,744+ );745+ const c = inCrossing(range.start)!;746+ assert(747+ (c.wideFlanks ?? []).length > 0,748+ `${seed} seg ${i}: wide run without recorded flanks`,749+ );750+ }751+752+ // FURNITURE: every cap/fan placement stands on a planner side cell;753+ // every mutated split host is a recorded furniture host.754+ const sideSet = new Set(755+ crossings.flatMap((c) =>756+ (c.sideCells ?? []).map((sc) => `${sc.cell[0]},${sc.cell[1]}`)757+ ),758+ );759+ const hostSet = new Set(760+ crossings.flatMap((c) => (c.sideCells ?? []).map((sc) => sc.host)),761+ );762+ // Every ledge whose host realized as a plain straight must carry763+ // exactly one furnishing (cap or fan); a split host pairs with a764+ // cap (the siding), a straight host with a fan.765+ const placementAt = (766+ cx: number,767+ cz: number,768+ cy: number,769+ ids: (id: string) => boolean,770+ ) =>771+ seg.placements.find((p) =>772+ p.role === "track" && ids(p.pieceId) &&773+ Math.round(p.pos[0] / TRACK_SCALE) === cx &&774+ Math.round(p.pos[2] / TRACK_SCALE) === cz &&775+ // Same level — an old drop-stack receiver in the same column776+ // must not alias as this ledge's furnishing.777+ Math.abs(p.pos[1] / TRACK_SCALE - cy) < 0.3778+ );779+ const familyAt = (idx: number): string | null => {780+ for (let wi = 0; wi < seg.windowRanges.length; wi++) {781+ const r = seg.windowRanges[wi];782+ if (idx >= r.start && idx < r.start + r.len) {783+ return seg.pieceFamilies[wi];784+ }785+ }786+ return null;787+ };788+ for (const c of crossings) {789+ for (const sc of c.sideCells ?? []) {790+ // Furnished only when the host window realized as a PLAIN791+ // straight (a tunnel's mesh is "straight" + an arch overlay —792+ // no side port there).793+ if (familyAt(sc.host) !== "straight") continue;794+ const host = seg.plan.cells[sc.host];795+ const hostPiece = placementAt(796+ host.x,797+ host.z,798+ host.baseY,799+ (id) => id === "straight" || id === "split",800+ );801+ if (!hostPiece) continue; // host realized as something else802+ const furnishing = placementAt(803+ sc.cell[0],804+ sc.cell[1],805+ host.baseY,806+ (id) => CAP_IDS.has(id) || id.startsWith("fan-"),807+ );808+ assert(809+ furnishing,810+ `${seed} seg ${i}: bare ledge at ${sc.cell} (host ${hostPiece.pieceId})`,811+ );812+ furnished++;813+ if (hostPiece.pieceId === "split") {814+ assert(815+ CAP_IDS.has(furnishing.pieceId),816+ `${seed} seg ${i}: split host with non-cap ${furnishing.pieceId}`,817+ );818+ } else {819+ assert(820+ furnishing.pieceId.startsWith("fan-"),821+ `${seed} seg ${i}: straight host with non-fan ${furnishing.pieceId}`,822+ );823+ }824+ }825+ }826+ for (const p of seg.placements) {827+ if (p.role !== "track") continue;828+ const cellKey = `${Math.round(p.pos[0] / TRACK_SCALE)},${829+ Math.round(p.pos[2] / TRACK_SCALE)830+ }`;831+ // Fans exist ONLY as ledge furniture in endless.832+ if (p.pieceId.startsWith("fan-")) {833+ assert(834+ sideSet.has(cellKey),835+ `${seed} seg ${i}: fan off its ledge (${cellKey})`,836+ );837+ }838+ if (p.pieceId === "split") {839+ const hostCell = [...hostSet].some((h) => {840+ const c = seg.plan.cells[h];841+ return `${c.x},${c.z}` === cellKey;842+ });843+ assert(844+ hostCell,845+ `${seed} seg ${i}: split junction at ${cellKey} has no ledge`,846+ );847+ }848+ // SOLIDS: only near grounded island cells (an island top IS849+ // local ground; the void grows no solids).850+ if (SOLID_PREFIXES.some((s) => p.pieceId.startsWith(s))) {851+ solids++;852+ const near = seg.plan.cells.some((c, idx) =>853+ grounded(idx) &&854+ Math.abs(c.x - p.pos[0] / TRACK_SCALE) <= 3 &&855+ Math.abs(c.z - p.pos[2] / TRACK_SCALE) <= 3856+ );857+ assert(858+ near,859+ `${seed} seg ${i}: solid ${p.pieceId} far from island ground`,860+ );861+ }862+ }863+ }864+ }865+ assert(wides > 0, "no wide runs emerged across the corpus");866+ assert(furnished > 0, "no furniture emerged across the corpus");867+ assert(solids > 0, "no solids emerged across the corpus");868+});@@ -5,6 +5,7 @@
55 import { EndlessWorld } from "../../src/gen/endless/world.ts";66 import {77 channelProxies,8+ wideScalesOf,89 islandProxies,910 } from "../../src/gen/endless/proxies.ts";1011 import {@@ -38,7 +39,7 @@ const rails = [];
3839 for (let i = 0; i < 8; i++) {3940 const seg = w.get(i);4041 engine.addSegment(i, [41- ...channelProxies(seg.path, true, gapSegmentsOf(seg)),42+ ...channelProxies(seg.path, true, gapSegmentsOf(seg), wideScalesOf(seg)),4243 ...seg.islands.flatMap((x) => islandProxies(x.placements)),4344 ]);4445 rails.push(railForSegment(seg));