clone

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

main
marbles / tests / track_test.ts
track_test.ts 24.3K
1import { assert, assertEquals } from "@std/assert";2import { generateTrack, validateTrack } from "../src/gen/generateTrack.ts";3import { generateSkeleton } from "../src/gen/topology.ts";4import { rngFamily } from "../src/gen/rng.ts";5import { genVersion } from "../src/gen/version.ts";67Deno.test("skeleton fuzz: 500 seeds → simple closed cycles (± crossings)", () => {8 for (let i = 0; i < 500; i++) {9 const skel = generateSkeleton(rngFamily(`fuzz-${i}`, "topology"));10 const n = skel.cells.length;11 assert(n >= 8, `seed fuzz-${i}: only ${n} cells`);12 // Simple except declared duplicates: crossings (pierce) are visited13 // twice, and a helix loop re-visits its first `under + 1` cells as the14 // ground-level underpass.15 const keys = new Set(skel.cells.map((c) => `${c.x},${c.z}`));16 const loopDups = skel.helixLoops.reduce(17 (acc, l) => acc + (l.id.includes("large") ? 4 : 3),18 0,19 );20 assertEquals(21 keys.size,22 n - skel.crossings.length - loopDups,23 `seed fuzz-${i}: self-intersecting beyond declared duplicates`,24 );25 for (const loop of skel.helixLoops) {26 assertEquals(loop.start, 0, `seed fuzz-${i}: loop not at ring start`);27 const dups = loop.id.includes("large") ? 4 : 3;28 for (let j = 0; j < dups; j++) {29 const first = skel.cells[loop.start + j];30 const again = skel.cells[loop.start + loop.len - dups + j];31 assertEquals(32 [first.x, first.z],33 [again.x, again.z],34 `seed fuzz-${i}: loop underpass cell ${j} not duplicated`,35 );36 }37 }38 for (const { a, b } of skel.crossings) {39 const ca = skel.cells[a];40 const cb = skel.cells[b];41 assertEquals(42 [ca.x, ca.z],43 [cb.x, cb.z],44 `seed fuzz-${i}: crossing pair cells differ`,45 );46 assertEquals(ca.move, "F", `seed fuzz-${i}: crossing visit a not F`);47 assertEquals(cb.move, "F", `seed fuzz-${i}: crossing visit b not F`);48 assert(49 (ca.dirIn + cb.dirIn) % 2 === 1,50 `seed fuzz-${i}: crossing visits not perpendicular`,51 );52 }53 // Closed + coherent: each cell's dirOut steps onto the next cell.54 const step = [[1, 0], [0, 1], [-1, 0], [0, -1]];55 for (let j = 0; j < n; j++) {56 const c = skel.cells[j];57 const nx = skel.cells[(j + 1) % n];58 const [dx, dz] = step[c.dirOut];59 assertEquals(60 [c.x + dx, c.z + dz],61 [nx.x, nx.z],62 `seed fuzz-${i} cell ${j}`,63 );64 assertEquals(nx.dirIn, c.dirOut);65 }66 // Turn balance: left − right = ±4 for a simple cycle; a helix loop's67 // spiral adds one extra full revolution in its own chirality (±4 more).68 const l = skel.cells.filter((c) => c.move === "L").length;69 const r = skel.cells.filter((c) => c.move === "R").length;70 const balances = skel.helixLoops.length > 0 ? [0, 4, 8] : [4];71 assert(72 balances.includes(Math.abs(l - r)),73 `seed fuzz-${i}: turn balance ${l}L/${r}R (loops ${skel.helixLoops.length})`,74 );75 }76});7778Deno.test("track fuzz: 200 seeds validate clean", () => {79 for (let i = 0; i < 200; i++) {80 const track = generateTrack(`track-${i}`, { packs: [] }, {81 skipDecor: true,82 });83 const errors = validateTrack(track);84 assertEquals(errors, [], `seed track-${i}`);85 }86});8788Deno.test("hill-interval constraint: crossing tracks host hills, pairs never split", () => {89 // M13 lifts the M3 "crossings stay flat" compromise: hills are admitted90 // on crossing tracks when the climb+descent pair sits on ONE side of91 // every crossing pair's ring interval. Rules: (a) pinned seeds keep a92 // crossing AND elevation (the lift emerged), (b) at every flat-crossing93 // cell, ALL loop-centerline passes agree on height — the pair never94 // splits. (Bridge decks differ by exactly 1.0 and are excluded via the95 // cross piece filter — flat crossings realize `cross`.)96 const seeds = ["xh-62", "xh-129", "xh-260", "xh-442"];97 let elevatedCrossings = 0;98 for (const seed of seeds) {99 const track = generateTrack(seed, { packs: [] }, { skipDecor: true });100 const crosses = track.placements.filter((p) => p.pieceId === "cross");101 assert(crosses.length > 0, `${seed} lost its crossing`);102 if (track.stats.maxDeckY > 0) elevatedCrossings++;103 for (const cross of crosses) {104 const ys: number[] = [];105 for (const seg of track.loop.segments) {106 for (const part of seg.curve) {107 for (let t = 0; t <= 8; t++) {108 const u = t / 8;109 // Cheap curve sampling: lines/cubics/arcs all expose enough110 // via their control data; use segment endpoints + midpoints111 // through the shared helper below.112 const pt = samplePart(part, u);113 const dx = pt[0] - cross.pos[0];114 const dz = pt[2] - cross.pos[2];115 if (dx * dx + dz * dz < 0.09) ys.push(pt[1]);116 }117 }118 }119 assert(ys.length > 0, `${seed}: no centerline passes at the crossing`);120 const min = Math.min(...ys);121 const max = Math.max(...ys);122 assert(123 max - min < 0.06,124 `${seed}: crossing passes split heights (${min}..${max})`,125 );126 }127 }128 assert(129 elevatedCrossings > 0,130 "no pinned crossing track is elevated — the lift regressed",131 );132});133134function samplePart(135 part: import("../src/shared/types.ts").CurveSeg,136 u: number,137): readonly [number, number, number] {138 if (part.kind === "line") {139 return [140 part.a[0] + (part.b[0] - part.a[0]) * u,141 part.a[1] + (part.b[1] - part.a[1]) * u,142 part.a[2] + (part.b[2] - part.a[2]) * u,143 ];144 }145 if (part.kind === "arc") {146 const a = part.a0 + (part.a1 - part.a0) * u;147 return [148 part.center[0] + part.radius * Math.cos(a),149 part.y0 + (part.y1 - part.y0) * u,150 part.center[2] + part.radius * Math.sin(a),151 ];152 }153 const [p0, p1, p2, p3] = part.p;154 const v = 1 - u;155 return [0, 1, 2].map((i) =>156 v * v * v * p0[i] + 3 * v * v * u * p1[i] + 3 * v * u * u * p2[i] +157 u * u * u * p3[i]158 ) as unknown as readonly [number, number, number];159}160161Deno.test("per-branch diamond supports: the alt branch carries its own columns", () => {162 // M13: an elevated diamond's support columns used to follow only the163 // MAIN branch centerline; the other branch deck hung unsupported.164 // Rule: for every elevated diamond window (loop segment with an alt165 // curve above ground), some support placement stands within a pier166 // span of the ALT centerline — and is not merely a main-lane pillar167 // (it sits distinctly closer to the alt lane than to the main one).168 const seeds = ["dia-loop-63", "dia-loop-125", "dia-loop-237", "dia-loop-402"];169 let elevatedDiamonds = 0;170 for (const seed of seeds) {171 const track = generateTrack(seed, { packs: [] }, { skipDecor: true });172 const supports = track.placements.filter((p) => p.role === "support");173 for (const seg of track.loop.segments) {174 if (!seg.alt) continue;175 const y0 = samplePart(seg.curve[0], 0)[1];176 if (y0 <= 0.26) continue; // ground diamonds need no columns177 elevatedDiamonds++;178 const altPts: (readonly [number, number, number])[] = [];179 const mainPts: (readonly [number, number, number])[] = [];180 for (const part of seg.alt.curve) {181 for (let t = 0; t <= 8; t++) altPts.push(samplePart(part, t / 8));182 }183 for (const part of seg.curve) {184 for (let t = 0; t <= 8; t++) mainPts.push(samplePart(part, t / 8));185 }186 const distTo = (187 pts: readonly (readonly [number, number, number])[],188 p: { pos: readonly number[] },189 ) =>190 Math.min(191 ...pts.map((q) => Math.hypot(q[0] - p.pos[0], q[2] - p.pos[2])),192 );193 const carried = supports.some((s) => {194 const da = distTo(altPts, s);195 return da < 1.3 && da < distTo(mainPts, s) - 0.3;196 });197 assert(198 carried,199 `${seed}: elevated diamond's alt branch has no dedicated support`,200 );201 }202 }203 assert(elevatedDiamonds > 0, "no elevated diamonds across the pinned seeds");204});205206Deno.test("tee-spur: split-double-sides stems from a junction, arms capped", () => {207 // M13: the tee splitter (no through port — was coverage-exempt) enters208 // as a spur terminal. Rules per tee: its stem points back at a `split`209 // junction host one cell behind the entry, and BOTH arm exits carry a210 // flush end cap opening toward the junction.211 const seeds = ["tee-84", "tee-227", "tee-236", "tee-250", "tee-275"];212 const STEP: readonly (readonly [number, number])[] = [213 [1, 0],214 [0, 1],215 [-1, 0],216 [0, -1],217 ];218 const rot = (r: number, x: number, z: number): [number, number] =>219 r === 0 ? [x, z] : r === 1 ? [z, -x] : r === 2 ? [-x, -z] : [-z, x];220 let tees = 0;221 for (const seed of seeds) {222 const track = generateTrack(seed, { packs: [] }, { skipDecor: true });223 for (const p of track.placements) {224 if (p.pieceId !== "split-double-sides") continue;225 tees++;226 const rT = p.rotation;227 const [mx, mz] = rot(rT, 0, 0.5);228 const s1: [number, number] = [p.pos[0] - mx, p.pos[2] - mz];229 const w = ((1 - rT + 4) % 4) as 0 | 1 | 2 | 3;230 const host: [number, number] = [231 s1[0] - STEP[w][0],232 s1[1] - STEP[w][1],233 ];234 assert(235 track.placements.some((q) =>236 q.pieceId === "split" &&237 Math.round(q.pos[0]) === host[0] &&238 Math.round(q.pos[2]) === host[1]239 ),240 `${seed}: tee stem has no split junction at (${host})`,241 );242 for (const ex of [2, -2]) {243 const [ox, oz] = rot(rT, ex, 1);244 const capCell = [s1[0] + ox, s1[1] + oz];245 assert(246 track.placements.some((q) =>247 (q.pieceId === "end-rounded" || q.pieceId === "end-square") &&248 Math.round(q.pos[0]) === capCell[0] &&249 Math.round(q.pos[2]) === capCell[1]250 ),251 `${seed}: tee arm at (${capCell}) is uncapped`,252 );253 }254 }255 }256 assert(tees > 0, "no tee-spurs across the pinned seeds");257});258259Deno.test("takeable spur: elevated split drains through an end-hole onto the lower line", () => {260 // M13 branch-taking beyond diamonds. Rules per guaranteed fall: the261 // hole placement is an end-hole exactly Δ1.0 above the fall landing;262 // the landing lies ON the main loop centerline (the drain reaches the263 // lower line); a split junction stands orthogonally adjacent at deck264 // height; and the spur's teardrop alt curve passes over the hole.265 // (take-168 re-pinned to wb-81 — it upgraded to the WIDE bridge form,266 // covered by its own test below.)267 const seeds = ["take-65", "take-105", "wb-81", "take-282", "take-308"];268 let spurs = 0;269 for (const seed of seeds) {270 const track = generateTrack(seed, { packs: [] }, { skipDecor: true });271 const guaranteed = (track.falls ?? []).filter((f) => f.guaranteed);272 assert(guaranteed.length > 0, `${seed} lost its takeable spur`);273 for (const f of guaranteed) {274 spurs++;275 assert(276 Math.abs(f.pos[1] - f.landPos[1] - 1) < 1e-9 &&277 f.pos[0] === f.landPos[0] && f.pos[2] === f.landPos[2],278 `${seed}: drain is not a vertical Δ1.0 drop`,279 );280 const holeY = f.pos[1] - 0.25;281 assert(282 track.placements.some((p) =>283 p.pieceId.startsWith("end-hole") &&284 Math.round(p.pos[0]) === f.pos[0] &&285 Math.round(p.pos[2]) === f.pos[2] &&286 Math.abs(p.pos[1] - holeY) < 1e-9287 ),288 `${seed}: no end-hole board at the drain`,289 );290 assert(291 track.placements.some((p) =>292 p.pieceId === "split" &&293 Math.abs(p.pos[1] - holeY) < 1e-9 &&294 Math.abs(Math.round(p.pos[0]) - f.pos[0]) +295 Math.abs(Math.round(p.pos[2]) - f.pos[2]) === 1296 ),297 `${seed}: no split junction beside the board`,298 );299 // Landing on the main line; hole on the alt (teardrop) variant.300 const passes = (301 curves:302 readonly (readonly import("../src/shared/types.ts").CurveSeg[])[],303 p: readonly number[],304 ): boolean =>305 curves.some((curve) =>306 curve.some((part) => {307 for (let t = 0; t <= 8; t++) {308 const q = samplePart(part, t / 8);309 if (310 Math.hypot(q[0] - p[0], q[1] - p[1], q[2] - p[2]) < 0.35311 ) return true;312 }313 return false;314 })315 );316 assert(317 passes(track.loop.segments.map((s) => s.curve), f.landPos),318 `${seed}: landing point is off the main centerline`,319 );320 assert(321 passes(322 track.loop.segments.filter((s) => s.alt).map((s) => s.alt!.curve),323 f.pos,324 ),325 `${seed}: no alt branch passes over the drain hole`,326 );327 }328 }329 assert(spurs > 0, "no takeable spurs across the pinned seeds");330});331332Deno.test("wide bridge: funnel-bracketed straight-wide-hole over the lower line", () => {333 // M13: straight-wide-hole realizes (exemption lifted) as the WIDE334 // takeable spur — funnel → wide-hole deck directly over the lower ring335 // channel → reversed funnel → cap, with falsework at the mouth and the336 // abutment. The drain is guaranteed (hole on the wide centerline).337 const seeds = ["wb-62", "wb-357", "wb-2036", "wb-2165"];338 let bridges = 0;339 for (const seed of seeds) {340 const track = generateTrack(seed, { packs: [] }, { skipDecor: true });341 const decks = track.placements.filter((p) =>342 p.pieceId === "straight-wide-hole"343 );344 assert(decks.length > 0, `${seed} lost its wide bridge`);345 for (const deck of decks) {346 bridges++;347 const y = deck.pos[1];348 const dx = Math.round(deck.pos[0]);349 const dz = Math.round(deck.pos[2]);350 // Guaranteed fall at the deck cell, Δ1.0.351 assert(352 (track.falls ?? []).some((f) =>353 f.guaranteed && f.pos[0] === dx && f.pos[2] === dz &&354 Math.abs(f.pos[1] - (y + 0.25)) < 1e-9 &&355 Math.abs(f.pos[1] - f.landPos[1] - 1) < 1e-9356 ),357 `${seed}: wide deck has no guaranteed drain`,358 );359 // Funnel brackets flush on both sides along the branch axis.360 const w = ((1 - deck.rotation + 4) % 4) as 0 | 1 | 2 | 3;361 const STEP: readonly (readonly [number, number])[] = [362 [1, 0],363 [0, 1],364 [-1, 0],365 [0, -1],366 ];367 const [sx, sz] = STEP[w];368 const funnels = track.placements.filter((p) => p.pieceId === "funnel");369 assert(370 funnels.some((p) =>371 Math.round(p.pos[0]) === dx - sx && Math.round(p.pos[2]) === dz - sz372 ) &&373 funnels.some((p) =>374 Math.round(p.pos[0]) === dx + sx &&375 Math.round(p.pos[2]) === dz + sz376 ),377 `${seed}: wide deck is not funnel-bracketed`,378 );379 // Cap at the far end; support falsework exists on the branch.380 assert(381 track.placements.some((p) =>382 (p.pieceId === "end-rounded" || p.pieceId === "end-square") &&383 Math.round(p.pos[0]) === dx + sx * 2 &&384 Math.round(p.pos[2]) === dz + sz * 2385 ),386 `${seed}: wide bridge is uncapped`,387 );388 assert(389 track.placements.some((p) =>390 p.role === "support" &&391 Math.round(p.pos[0]) === dx + sx * 2 &&392 Math.round(p.pos[2]) === dz + sz * 2393 ),394 `${seed}: wide bridge abutment is unsupported`,395 );396 }397 }398 assert(bridges > 0, "no wide bridges across the pinned seeds");399});400401Deno.test("scenery ring: four curve-wides close a freestanding roundabout", () => {402 // M13: the off-grid curve-wide family enters as a self-closed ring.403 // Rules: exactly four pieces of ONE variant, at ring-center offsets404 // (±h,±h) with the measured closing rotations (0,3,2,1 — every port405 // flush against its neighbor by construction), standing on ground clear406 // of the line.407 const seeds = ["ring-1", "ring-4", "ring-16", "ring-45"];408 const H: Record<string, number> = {409 "curve-wide": 0.85,410 "curve-wide-medium": 1.35,411 "curve-wide-large": 1.85,412 "curve-wide-solid-medium": 1.35,413 "curve-wide-solid-large": 1.85,414 };415 let rings = 0;416 let rotundas = 0;417 for (const seed of seeds) {418 const track = generateTrack(seed, { packs: [] }, { skipDecor: true });419 const pieces = track.placements.filter((p) =>420 p.pieceId.startsWith("curve-wide")421 );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 );428 rings++;429 const tiers = new Map<number, typeof pieces>();430 for (const p of pieces) {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)!;438 assert(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`,445 );446 }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 }472 }473 assert(rings > 0, "no scenery rings across the pinned seeds");474});475476Deno.test("determinism: same seed → deep-equal track", () => {477 const a = generateTrack("determinism-check");478 const b = generateTrack("determinism-check");479 assertEquals(a, b);480});481482Deno.test("different seeds diverge", () => {483 const a = generateTrack("seed-a", { packs: [] }, { skipDecor: true });484 const b = generateTrack("seed-b", { packs: [] }, { skipDecor: true });485 assert(486 JSON.stringify(a.placements) !== JSON.stringify(b.placements),487 "identical placements for different seeds",488 );489});490491Deno.test("golden snapshot: pinned integer-domain hash for fixed seeds", async () => {492 // Hash only the integer domain (piece ids, rounded positions, rotations) —493 // float formatting must not affect it. Regenerating these goldens is a494 // BREAKING change for every URL in the wild; do it knowingly.495 const goldens: Record<string, string> = {};496 // The computed generator version rides along, so the committed golden497 // file documents which fingerprint these hashes belong to.498 goldens["__version"] = genVersion();499 for (const seed of ["alpha", "zx9k2m4p1q", "dami"]) {500 const t = generateTrack(seed, { packs: [] }, { skipDecor: true });501 const canon = t.placements.map((p) =>502 `${p.pieceId}|${503 p.pos.map((v) => Math.round(v * 4)).join(",")504 }|${p.rotation}|${p.role}`505 ).join(";");506 const bytes = new TextEncoder().encode(canon);507 const digest = await crypto.subtle.digest("SHA-256", bytes);508 goldens[seed] = [...new Uint8Array(digest)].slice(0, 8)509 .map((b) => b.toString(16).padStart(2, "0")).join("");510 }511 const goldenFile = new URL("./golden_tracks.json", import.meta.url);512 let expected: Record<string, string>;513 try {514 expected = JSON.parse(await Deno.readTextFile(goldenFile));515 } catch {516 // First run: write the goldens (they get committed).517 await Deno.writeTextFile(518 goldenFile,519 JSON.stringify(goldens, null, 2) + "\n",520 );521 console.log("golden_tracks.json created — commit it");522 return;523 }524 assertEquals(goldens, expected);525});526527Deno.test("M2 vocabulary emerges: bends, s-curves, waves, bumps, wide runs", () => {528 const seen = new Set<string>();529 for (let i = 0; i < 250 && seen.size < 5; i++) {530 const t = generateTrack(`m2-cov-${i}`, { packs: [] }, { skipDecor: true });531 for (const p of t.placements) {532 if (/^bend/.test(p.pieceId)) seen.add("bend");533 if (/^s-curve/.test(p.pieceId)) seen.add("s-curve");534 if (/^wave/.test(p.pieceId)) seen.add("wave");535 if (/^bump/.test(p.pieceId)) seen.add("bump");536 if (/funnel|straight-wide/.test(p.pieceId)) seen.add("wide");537 }538 }539 assertEquals(540 [...seen].sort(),541 ["bend", "bump", "s-curve", "wave", "wide"],542 );543});544545Deno.test("wide channel is always funnel-bracketed in ring order", () => {546 let checked = 0;547 for (let i = 0; i < 120; i++) {548 const t = generateTrack(`wide-cov-${i}`, { packs: [] }, {549 skipDecor: true,550 });551 // placementIndex −1 = a crossing's noMesh second visit (cross piece).552 const ids = t.loop.segments.map((s) =>553 s.placementIndex >= 0 ? t.placements[s.placementIndex].pieceId : "cross"554 );555 for (let j = 0; j < ids.length; j++) {556 if (ids[j] !== "straight-wide") continue;557 const prev = ids[(j - 1 + ids.length) % ids.length];558 const next = ids[(j + 1) % ids.length];559 assert(560 /funnel|straight-wide/.test(prev) && /funnel|straight-wide/.test(next),561 `wide piece with narrow neighbor (seed wide-cov-${i}: ${prev} → straight-wide → ${next})`,562 );563 checked++;564 }565 }566 assert(checked > 0, "no wide runs generated in 120 seeds");567});568569Deno.test("stats report emergent variety across seeds", () => {570 const themes = new Set<string>();571 const heights = new Set<number>();572 for (let i = 0; i < 40; i++) {573 const t = generateTrack(`variety-${i}`, { packs: [] }, { skipDecor: true });574 themes.add(t.stats.theme);575 heights.add(t.stats.maxDeckY);576 }577 assert(themes.size >= 3, `only themes: ${[...themes]}`);578 assert(heights.size >= 2, `no elevation variety`);579});580581// ---------------------------------------------------------------------------582// M19c loop set-piece breadth: pinned exemplars.583// ---------------------------------------------------------------------------584585Deno.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});595596Deno.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});620621Deno.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});