Finite-mode set-piece breadth, one batched genVersion shift (g491fc4c7a685 → g555803244402), endless goldens' segment hashes byte-identical (only the embedded __version moved — the supports gate held): - features.ts: a SECOND split-merge diamond on another run (own child stream, 0.5) — two lane-choice events per lap. The chicane pair from the design was ruled out by MEASUREMENT (mutated rings top out at ~9-cell straight runs; the pair needs 11) and adapted honestly. - stubs.ts: STUB GARDEN — 2-3 clustered sidings sharing one cap style plus a trackside fan facing the ring beyond the middle cap; with the M19a loop wind backport the garden literally breathes on passing marbles. Appended child stream. - scenery.ts: TWO-TIER ROTUNDA — measured: open and solid curve-wide forms are both exactly 0.5 tall with identical footprints, so a solid ring is a plinth the open ring stacks on flush. Appended child draw. - supports.ts: planStack gains an extended pattern pool (three-band taper four→two→single, pedestal) GATED to loop-mode call sites (assemble !open, takeable falsework) — the endless viaduct hash space is untouched and the endless golden test is the tripwire. Both new patterns obey the containment law (narrower always on wider). - Tests: scenery-ring law extended to rotunda tiers; pinned scenarios loop-scan-981 / loop-scan-20 / loop-scan-33. golden_tracks.json regenerated once. 202 green, check clean.
- commit
- 2657265edb30939aae7e5cfd81bc7205072f4dbf
- parent
- eb4271346ab5c2112dfc25c36041e7611c8fb6b9
- author
- Dami <dami.etoile@gmail.com>
| M | progress.md | 3 | + |
| M | src/ | 3 | + |
| M | src/ | 43 | ++++++++++ |
| M | src/ | 16 | ++++- |
| M | src/ | 74 | ++++++++++++++++++ |
| M | src/ | 32 | +++++++- |
| M | src/ | 4 | + |
| M | tests/ | 2 | +- |
| M | tests/ | 2 | +- |
| M | tests/ | 125 | +++++++++++++++++++++++++----- |
@@ -106,5 +106,8 @@ cross composite, quantitative metrics CI).
106106 regenerate endless goldens with `deno run --allow-read --allow-write107107 tools/pin_endless.ts`. Canon note: golden placements hash wind108108 pulse/swirl since M19a.109+- Loop named scenarios (M19c): loop-scan-981 twin diamonds,110+ loop-scan-20 stub garden, loop-scan-33 rotunda (track_test.ts).111+ genVersion shifted once for the M19c batch (computed, not hand-bumped).109112 - `deno task check` is fully clean now — keep it that way; `deno task110113 test` remains the gate.@@ -157,5 +157,48 @@ export function planFeatures(
157157 }158158 }159159 }160+ // DOUBLE DIAMOND (M19c): a SECOND split–merge event on another run —161+ // two lane choices per lap. (A back-to-back chicane pair was ruled out162+ // by measurement: mutated rings top out at ~9-cell straight runs and163+ // the pair needs 11.) Own child stream — label-derived, so nothing164+ // above moves; eligible leftover geometry is scarce, which keeps a165+ // twin-diamond lap an event.166+ const ddRng = rng.child("diamond2");167+ if (ddRng.chance(0.5)) {168+ const claimedIdx = new Set<number>();169+ for (const r of out) {170+ for (let j = 0; j < r.len; j++) claimedIdx.add(r.start + j);171+ }172+ const overlaps = (start: number, total: number): boolean => {173+ for (let j = 0; j < total; j++) {174+ if (claimedIdx.has(start + j)) return true;175+ }176+ return false;177+ };178+ placing:179+ for (const run of ddRng.shuffle(runs)) {180+ const maxN = Math.min(3, run.len - 4);181+ for (let N = maxN; N >= 1; N--) {182+ const total = 4 + N;183+ const offsets = ddRng.shuffle(184+ Array.from({ length: run.len - total + 1 }, (_, o) => o),185+ );186+ for (const o of offsets) {187+ const start = run.start + o;188+ if (overlaps(start, total)) continue;189+ const flanks = flanksClear(start, total);190+ if (!flanks) continue;191+ out.push({192+ start,193+ len: total,194+ pieceIds: [`diamond-${N}`],195+ reversed: false,196+ });197+ for (const k of flanks) claimedFlanks.add(k);198+ break placing;199+ }200+ }201+ }202+ }160203 return out;161204 }@@ -113,13 +113,27 @@ export function planSceneryRing(
113113 const rx = gx + 0.5;114114 const rz = gz + 0.5;115115 const solid = variant.solidId !== undefined && rng.chance(0.5);116+ // TWO-TIER ROTUNDA (M19c): measured — open and solid curve-wide117+ // forms are BOTH exactly 0.5 tall with identical footprints, so a118+ // solid ring is a plinth an open ring stacks on flush (self-mating119+ // in plan, flush in elevation; no falsework needed). Appended child120+ // draw: the solid coin above stays byte-identical.121+ const rotunda = solid && rng.child("rotunda").chance(0.4);116122 const id = solid ? variant.solidId! : variant.id;117123 const placements: Placement[] = CORNERS.map(([sx, sz, r]) => ({118124 pieceId: id,119125 pos: [rx + sx * variant.h, 0, rz + sz * variant.h],120126 rotation: r as Placement["rotation"],121- role: "decor",127+ role: "decor" as const,122128 }));129+ if (rotunda) {130+ placements.push(...CORNERS.map(([sx, sz, r]): Placement => ({131+ pieceId: variant.id,132+ pos: [rx + sx * variant.h, 0.5, rz + sz * variant.h],133+ rotation: r as Placement["rotation"],134+ role: "decor",135+ })));136+ }123137 return { placements, cells: cellsFor(rx, rz)! };124138 }125139 return empty;@@ -239,5 +239,79 @@ export function planStubs(
239239 // An unused branch cell stays free for decor when the spur is short.240240 placed++;241241 }242+243+ // STUB GARDEN (M19c): a clustered ensemble — 2-3 sidings on nearby244+ // hosts sharing ONE cap style, with a trackside fan beyond the middle245+ // stub's cap facing back at the ring (M19a loop wind: its capsule246+ // brushes the main channel, so the garden literally breathes on247+ // passing marbles). Own child stream, appended after the singles pass.248+ const gRng = rng.child("garden");249+ if (gRng.chance(0.35)) {250+ const fresh = candidates.filter((c) =>251+ !usedHosts.has(c.placement) &&252+ c.junction === "split" &&253+ !c.branch.some(([x, z]) => taken.has(key(x, z)))254+ );255+ // Cluster: seed candidate + others whose hosts are within 3 cells.256+ for (const seedCand of gRng.shuffle(fresh)) {257+ const [sx, sz] = [258+ Math.round(seedCand.placement.pos[0]),259+ Math.round(seedCand.placement.pos[2]),260+ ];261+ const cluster = [seedCand];262+ for (const c of fresh) {263+ if (c === seedCand || cluster.length >= 3) continue;264+ if (usedHosts.has(c.placement) || cluster.some((m) => m.placement === c.placement)) continue;265+ const dx = Math.abs(Math.round(c.placement.pos[0]) - sx);266+ const dz = Math.abs(Math.round(c.placement.pos[2]) - sz);267+ if (Math.max(dx, dz) <= 3) cluster.push(c);268+ }269+ if (cluster.length < 2) continue;270+ const cap = gRng.pick(["end-rounded", "end-square"]);271+ let ok = 0;272+ for (const cand of cluster) {273+ const [bx, bz] = cand.branch[0];274+ if (taken.has(key(bx, bz))) continue;275+ cand.placement.pieceId = "split";276+ if (cand.mirrored) cand.placement.mirrored = true;277+ usedHosts.add(cand.placement);278+ plan.placements.push({279+ pieceId: cap,280+ pos: [bx, 0, bz],281+ rotation: ((1 - cand.dir + 4) % 4) as Dir,282+ role: "track",283+ });284+ taken.add(key(bx, bz));285+ plan.cells.push(key(bx, bz));286+ ok++;287+ }288+ if (ok >= 2) {289+ // The garden fan: one cell past the middle stub's cap, blades290+ // facing the ring (rotation maps local +z toward −dir).291+ const mid = cluster[Math.floor(cluster.length / 2)];292+ const [fx, fz] = DIR_STEP[mid.dir];293+ const [bx, bz] = mid.branch[0];294+ const fanCell: [number, number] = [bx + fx, bz + fz];295+ if (!taken.has(key(...fanCell))) {296+ const facing = ((mid.dir + 2) % 4) as Dir;297+ plan.placements.push({298+ pieceId: gRng.pick([299+ "fan-standing-two",300+ "fan-standing-four",301+ "fan-two",302+ "fan-four",303+ ]),304+ pos: [fanCell[0], 0, fanCell[1]],305+ rotation: ((1 - facing + 4) % 4) as Dir,306+ role: "track",307+ spin: { axis: [0, 0, 1], radPerSec: 4 + gRng.float() * 3 },308+ });309+ taken.add(key(...fanCell));310+ plan.cells.push(key(...fanCell));311+ }312+ }313+ break;314+ }315+ }242316 return plan;243317 }@@ -66,14 +66,39 @@ function stackHash(x: number, z: number, base: number): () => number {
6666 };6767 }6868 69-/** Shaft plan: the kind occupying each 0.5 slot, bottom → top. */69+/** Shaft plan: the kind occupying each 0.5 slot, bottom → top.70+ *71+ * `extended` (M19c) admits two more patterns — LOOP MODE ONLY: the flag72+ * is passed by finite-mode call sites (assemble !open, takeable). The73+ * endless viaduct/spur stacks share this hash space, so an ungated74+ * pattern change would shift endless goldens from a loop milestone; the75+ * endless golden test is the tripwire. Both patterns obey the76+ * containment law (narrower always ON wider — four never stands on the77+ * thin two frame). */7078 export function planStack(7179 bias: SupportKind,7280 slots: number,7381 draw: () => number,82+ extended = false,7483 ): SupportKind[] {7584 if (slots <= 0) return [];7685 const r = draw();86+ if (extended && slots >= 4 && r < 0.18) {87+ // Three-band taper: four → two → single, wide plinth to needle.88+ const a = Math.max(1, Math.round(slots * (0.25 + draw() * 0.2)));89+ const b = Math.max(1, Math.round((slots - a) * (0.4 + draw() * 0.2)));90+ return [91+ ...Array<SupportKind>(a).fill("four"),92+ ...Array<SupportKind>(Math.min(b, slots - a - 1)).fill("two"),93+ ...Array<SupportKind>(Math.max(1, slots - a - Math.min(b, slots - a - 1)))94+ .fill("single"),95+ ];96+ }97+ if (extended && slots >= 2 && r >= 0.18 && r < 0.34) {98+ // Pedestal: one wide four course, then a slender column.99+ const upper: SupportKind = draw() < 0.5 ? "two" : "single";100+ return ["four", ...Array<SupportKind>(slots - 1).fill(upper)];101+ }77102 // Tapered tower: a wide `four` base run carrying a slender upper run.78103 if (slots >= 3 && r < 0.3) {79104 const upper: SupportKind = bias === "four"@@ -115,11 +140,14 @@ export function emitSupportStack(
115140 dir: Dir,116141 cradle: boolean,117142 baseAt = 0,143+ /** M19c extended pattern pool — loop-mode call sites only (see144+ * planStack: the endless golden test is the gate's tripwire). */145+ extended = false,118146 ) {119147 const rotation = ((1 - dir + 4) % 4) as Dir;120148 const draw = stackHash(x, z, baseAt);121149 const slots = Math.max(0, Math.round((height - (cradle ? 0.5 : 0)) / 0.5));122- const plan = planStack(bias, slots, draw);150+ const plan = planStack(bias, slots, draw, extended);123151 const piece = (kind: SupportKind, segment: string, y: number) => {124152 placements.push({125153 pieceId: `${prefix}-${kind}-${segment}`,@@ -211,6 +211,8 @@ export function planTakeableSpurs(
211211 y,212212 w,213213 false,214+ 0,215+ true, // loop-only falsework: M19c pattern pool admitted214216 );215217 emitSupportStack(216218 spurPlacements,@@ -221,6 +223,8 @@ export function planTakeableSpurs(
221223 y,222224 w,223225 true,226+ 0,227+ true,224228 );225229 for (let n = 1; n <= 4; n++) cells.push(key(...at(n)));226230 tip = at(3);@@ -413,36 +413,62 @@ Deno.test("scenery ring: four curve-wides close a freestanding roundabout", () =
413413 "curve-wide-solid-large": 1.85,414414 };415415 let rings = 0;416+ let rotundas = 0;416417 for (const seed of seeds) {417418 const track = generateTrack(seed, { packs: [] }, { skipDecor: true });418419 const pieces = track.placements.filter((p) =>419420 p.pieceId.startsWith("curve-wide")420421 );421- assert(pieces.length === 4, `${seed}: expected 4 ring pieces`);422+ // M19c: a solid ring may carry an OPEN ring stacked flush on top (the423+ // two-tier rotunda — both forms measured exactly 0.5 tall).424+ assert(425+ pieces.length === 4 || pieces.length === 8,426+ `${seed}: expected 4 or 8 ring pieces, got ${pieces.length}`,427+ );422428 rings++;423- const id = pieces[0].pieceId;424- assert(pieces.every((p) => p.pieceId === id), `${seed}: mixed variants`);425- const h = H[id];426- assert(h !== undefined, `${seed}: unknown variant ${id}`);427- const rx = pieces.reduce((a, p) => a + p.pos[0], 0) / 4;428- const rz = pieces.reduce((a, p) => a + p.pos[2], 0) / 4;429- const want = new Map([430- [`${1},${1}`, 0],431- [`${-1},${1}`, 3],432- [`${-1},${-1}`, 2],433- [`${1},${-1}`, 1],434- ]);429+ const tiers = new Map<number, typeof pieces>();435430 for (const p of pieces) {436- assert(p.pos[1] === 0, `${seed}: ring piece off the ground`);437- const sx = Math.round((p.pos[0] - rx) / h);438- const sz = Math.round((p.pos[2] - rz) / h);431+ tiers.set(p.pos[1], [...(tiers.get(p.pos[1]) ?? []), p]);432+ }433+ if (pieces.length === 8) {434+ rotundas++;435+ assertEquals([...tiers.keys()].sort(), [0, 0.5], `${seed}: tier ys`);436+ const lower = tiers.get(0)!;437+ const upper = tiers.get(0.5)!;439438 assert(440- Math.abs(p.pos[0] - (rx + sx * h)) < 1e-9 &&441- Math.abs(p.pos[2] - (rz + sz * h)) < 1e-9 &&442- want.get(`${sx},${sz}`) === p.rotation,443- `${seed}: piece at (${p.pos}) breaks the closing pattern`,439+ lower.every((p) => p.pieceId.includes("solid")),440+ `${seed}: rotunda base must be the solid form`,441+ );442+ assert(443+ upper.every((p) => !p.pieceId.includes("solid")),444+ `${seed}: rotunda top must be the open form`,444445 );445446 }447+ for (const tier of tiers.values()) {448+ assertEquals(tier.length, 4, `${seed}: tier is not a closed ring`);449+ const id = tier[0].pieceId;450+ assert(tier.every((p) => p.pieceId === id), `${seed}: mixed variants`);451+ const h = H[id];452+ assert(h !== undefined, `${seed}: unknown variant ${id}`);453+ const rx = tier.reduce((a, p) => a + p.pos[0], 0) / 4;454+ const rz = tier.reduce((a, p) => a + p.pos[2], 0) / 4;455+ const want = new Map([456+ [`${1},${1}`, 0],457+ [`${-1},${1}`, 3],458+ [`${-1},${-1}`, 2],459+ [`${1},${-1}`, 1],460+ ]);461+ for (const p of tier) {462+ const sx = Math.round((p.pos[0] - rx) / h);463+ const sz = Math.round((p.pos[2] - rz) / h);464+ assert(465+ Math.abs(p.pos[0] - (rx + sx * h)) < 1e-9 &&466+ Math.abs(p.pos[2] - (rz + sz * h)) < 1e-9 &&467+ want.get(`${sx},${sz}`) === p.rotation,468+ `${seed}: piece at (${p.pos}) breaks the closing pattern`,469+ );470+ }471+ }446472 }447473 assert(rings > 0, "no scenery rings across the pinned seeds");448474 });@@ -551,3 +577,62 @@ Deno.test("stats report emergent variety across seeds", () => {
551577 assert(themes.size >= 3, `only themes: ${[...themes]}`);552578 assert(heights.size >= 2, `no elevation variety`);553579 });580+581+// ---------------------------------------------------------------------------582+// M19c loop set-piece breadth: pinned exemplars.583+// ---------------------------------------------------------------------------584+585+Deno.test("twin diamonds (loop-scan-981): two lane-choice events on one lap", () => {586+ const track = generateTrack("loop-scan-981", { packs: [] }, {587+ skipDecor: true,588+ });589+ const diamonds = track.loop.segments.filter((s) => s.alt && !s.alt.spur);590+ assert(591+ diamonds.length >= 2,592+ `expected two diamond windows, got ${diamonds.length}`,593+ );594+});595+596+Deno.test("stub garden (loop-scan-20): clustered same-cap sidings + a garden fan", () => {597+ const track = generateTrack("loop-scan-20", { packs: [] }, {598+ skipDecor: true,599+ });600+ const caps = track.placements.filter((p) =>601+ /^end-(rounded|square)$/.test(p.pieceId)602+ );603+ const fans = track.placements.filter((p) => p.pieceId.startsWith("fan-"));604+ assert(caps.length >= 2, `garden lost its sidings (${caps.length} caps)`);605+ assert(fans.length >= 1, "garden lost its fan");606+ // Cluster law: at least two caps share an id AND stand within 5 cells.607+ let clustered = false;608+ for (const a of caps) {609+ for (const b of caps) {610+ if (a === b || a.pieceId !== b.pieceId) continue;611+ if (612+ Math.abs(a.pos[0] - b.pos[0]) <= 5 && Math.abs(a.pos[2] - b.pos[2]) <= 5613+ ) clustered = true;614+ }615+ }616+ assert(clustered, "garden caps are not a same-style cluster");617+ // The fan spins (loop wind derives a capsule from it — M19a backport).618+ assert(fans.some((f) => f.spin), "garden fan lost its spin");619+});620+621+Deno.test("rotunda (loop-scan-33): a solid ring carries a flush open ring", () => {622+ const track = generateTrack("loop-scan-33", { packs: [] }, {623+ skipDecor: true,624+ });625+ const upper = track.placements.filter((p) =>626+ p.pieceId.startsWith("curve-wide") && p.pos[1] === 0.5627+ );628+ const lower = track.placements.filter((p) =>629+ p.pieceId.startsWith("curve-wide") && p.pos[1] === 0630+ );631+ assertEquals(upper.length, 4, "rotunda upper ring incomplete");632+ assertEquals(lower.length, 4, "rotunda base ring incomplete");633+ assert(634+ lower.every((p) => p.pieceId.includes("solid")) &&635+ upper.every((p) => !p.pieceId.includes("solid")),636+ "rotunda tiers must be solid base + open top",637+ );638+});