clone

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

M13: wide bridges — takeable spur upgrades to funnel→straight-wide-hole→funnel→cap over the lower line; straight-wide-hole exemption lifted

Dami committed 798eec3
commit
798eec3aa8be1e0108882bddbcf0f73dc478965f
parent
dccb5451ad1d584cde21327feea74ef772efa81d
author
Dami <dami.etoile@gmail.com>
5 files changed, +196 -36
Msrc/gen/assemble.ts2+-
Msrc/gen/generateTrack.ts2+
Msrc/gen/takeable.ts153++++++++++++++++++++++++------
Mtests/track_test.ts73++++++++++++++-
Mtools/coverage.ts2-
M src/gen/assemble.ts +1 -1
M src/gen/generateTrack.ts +2 -0
M src/gen/takeable.ts +121 -32
@@ -24,10 +24,14 @@ import type {
2424 } from "../shared/types.ts";2525 import type { Skeleton } from "./topology.ts";2626 import type { ElevationPlan } from "./elevation.ts";27+import type { StyleField } from "./style.ts";2728 import type { Rng } from "./rng.ts";2829 import { DIR_STEP, segLength } from "./geom.ts";30+import { emitSupportStack } from "./assemble.ts";2931 3032 const SPUR_CHANCE = 0.6;33+/** Prefer the wide bridge form when its footprint fits (it is far rarer). */34+const WIDE_PREFERENCE = 0.85;3135 3236 export interface TakeablePlan {3337 /** New branch placements (the host straight is mutated in place). */
@@ -40,10 +44,13 @@ export function planTakeableSpurs(
4044 rng: Rng,4145 skeleton: Skeleton,4246 elevation: ElevationPlan,47+ style: StyleField,4348 /** All assembled placements — the chosen host mutates straight → split. */4449 placements: Placement[],4550 /** Loop segments (assemble output) — the host window gains `alt`. */4651 segments: LoopSegment[],52+ /** Full track footprint so far (2D cells) — wide decks must not clip. */53+ occupied: ReadonlySet<string>,4754 ): TakeablePlan {4855 const empty: TakeablePlan = { placements: [], falls: [], cells: [] };4956 if (!rng.chance(SPUR_CHANCE)) return empty;
@@ -74,7 +81,19 @@ export function planTakeableSpurs(
7481 w: Dir; // branch direction (toward the hole cell)7582 flow: Dir;7683 hole: [number, number];84+ /** Wide bridge form: funnel → straight-wide-hole → funnel → cap. */85+ wide: boolean;7786 }87+ /** A lower-line cell suitable for the drain hole at deck height y. */88+ const drainable = (k: string, y: number): boolean =>89+ cellHeight.get(k) === y - 1 && !arched.has(k) && !goalCells.has(k) &&90+ (visits.get(k) ?? 0) <= 1;91+ /** Free air at deck height (not track, ground open for a pillar). */92+ const air = (k: string): boolean =>93+ !occupied.has(k) && cellHeight.get(k) === undefined;94+ /** Wide-deck flank: open air, or the lower line a full course below. */95+ const flankOk = (k: string, y: number): boolean =>96+ air(k) || (cellHeight.get(k) !== undefined && cellHeight.get(k)! <= y - 1);7897 const candidates: Candidate[] = [];7998 for (const seg of segments) {8099 if (seg.placementIndex < 0 || seg.alt) continue;
@@ -94,28 +113,42 @@ export function planTakeableSpurs(
94113 for (const side of [1, 3] as const) {95114 const w = ((flow + side) % 4) as Dir;96115 const [dx, dz] = DIR_STEP[w];97- const hole: [number, number] = [cx + dx, cz + dz];98- const hkey = key(...hole);99- // Alignment: the hole cell must BE a lower ring cell at exactly100- // Δ1.0 below the deck — the drain lands on the lower line. No101- // arches (the marble would fall through a tunnel roof), no goal102- // gate (the banner straddles that cell), no twice-visited cells.103- if (cellHeight.get(hkey) !== y - 1) continue;116+ const [fx, fz] = DIR_STEP[flow];117+ const at = (n: number): [number, number] => [cx + dx * n, cz + dz * n];118+ // NARROW: the hole cell is the adjacent lower ring cell.119+ if (drainable(key(...at(1)), y)) {120+ candidates.push({ seg, host, w, flow, hole: at(1), wide: false });121+ }122+ // WIDE BRIDGE (M13): funnel@1 → straight-wide-hole@2 (over the123+ // lower line) → reversed funnel@3 → cap@4. The deck cells need124+ // open air with pillar ground; every deck cell's lateral flanks125+ // (the 0.35 wide bulge) need air or the lower line a full course126+ // below.104127 if (105- arched.has(hkey) || goalCells.has(hkey) ||106- (visits.get(hkey) ?? 0) > 1107- ) continue;108- candidates.push({ seg, host, w, flow, hole });128+ drainable(key(...at(2)), y) &&129+ air(key(...at(1))) && air(key(...at(3))) && air(key(...at(4))) &&130+ [1, 2, 3].every((n) => {131+ const [bx, bz] = at(n);132+ return flankOk(key(bx + fx, bz + fz), y) &&133+ flankOk(key(bx - fx, bz - fz), y);134+ })135+ ) {136+ candidates.push({ seg, host, w, flow, hole: at(2), wide: true });137+ }109138 }110139 }111140 if (candidates.length === 0) return empty;112141 113- // One spur per track — a set-piece, not a texture.114- const cand = rng.pick(115- candidates.sort((a, b) =>116- a.hole[0] - b.hole[0] || a.hole[1] - b.hole[1] || a.w - b.w117- ),142+ // One spur per track — a set-piece, not a texture. Wide bridges are far143+ // rarer geometry; prefer one when it fits.144+ const sorted = candidates.sort((a, b) =>145+ a.hole[0] - b.hole[0] || a.hole[1] - b.hole[1] || a.w - b.w ||146+ Number(a.wide) - Number(b.wide)118147 );148+ const wides = sorted.filter((c) => c.wide);149+ const cand = wides.length > 0 && rng.chance(WIDE_PREFERENCE)150+ ? rng.pick(wides)151+ : rng.pick(sorted);119152 const { host, w, flow, hole } = cand;120153 const y = host.pos[1];121154 const floor = y + 0.25;
@@ -129,32 +162,88 @@ export function planTakeableSpurs(
129162 host.pieceId = "split";130163 if ((flow + 3) % 4 === w) host.mirrored = true;131164 132- const capRotation = ((1 - w + 4) % 4) as Dir;133- const spurPlacements: Placement[] = [{134- pieceId: rng.chance(0.5) ? "end-hole-rounded" : "end-hole-square",135- pos: [hole[0], y, hole[1]],136- rotation: capRotation,137- role: "track",138- }];165+ const rW = ((1 - w + 4) % 4) as Dir;166+ const spurPlacements: Placement[] = [];167+ const cells: string[] = [];168+ /** Turnaround cell for the teardrop (the last deck cell ridden out). */169+ let tip: [number, number];139170 140- // The teardrop alt: entry port → junction → out over the hole → back →141- // exit port. The marble never rides the return leg — the fall at the142- // hole is guaranteed — but the variant stays a closed, well-formed loop.171+ if (cand.wide) {172+ const at = (n: number): [number, number] => [173+ cx + wx * n,174+ cz + wz * n,175+ ];176+ spurPlacements.push(177+ {178+ pieceId: "funnel",179+ pos: [at(1)[0], y, at(1)[1]],180+ rotation: rW,181+ role: "track",182+ },183+ {184+ pieceId: "straight-wide-hole",185+ pos: [at(2)[0], y, at(2)[1]],186+ rotation: rW,187+ role: "track",188+ },189+ {190+ pieceId: "funnel",191+ pos: [at(3)[0], y, at(3)[1]],192+ rotation: ((rW + 2) % 4) as Dir,193+ role: "track",194+ },195+ {196+ pieceId: rng.chance(0.5) ? "end-rounded" : "end-square",197+ pos: [at(4)[0], y, at(4)[1]],198+ rotation: rW,199+ role: "track",200+ },201+ );202+ // Falsework mirroring the assemble greedy: a plain column under the203+ // funnel mouth, a cradle abutment under the cap.204+ const supportPrefix = style.supportColor ? "support-color" : "support";205+ const supportId = (segment: string) =>206+ `${supportPrefix}-${style.supportKind}-${segment}`;207+ emitSupportStack(spurPlacements, supportId, at(1)[0], at(1)[1], y, w, false);208+ emitSupportStack(spurPlacements, supportId, at(4)[0], at(4)[1], y, w, true);209+ for (let n = 1; n <= 4; n++) cells.push(key(...at(n)));210+ tip = at(3);211+ } else {212+ spurPlacements.push({213+ pieceId: rng.chance(0.5) ? "end-hole-rounded" : "end-hole-square",214+ pos: [hole[0], y, hole[1]],215+ rotation: rW,216+ role: "track",217+ });218+ cells.push(key(...hole));219+ tip = hole;220+ }221+222+ // The teardrop alt: entry port → junction → out over the hole to the223+ // tip → back → exit port. The marble never rides the return leg — the224+ // fall at the hole is guaranteed — but the variant stays a closed,225+ // well-formed loop.143226 const pts: Vec3[] = [144227 [cx - fx / 2, floor, cz - fz / 2],145228 [cx, floor, cz],146229 [cx + wx * 0.35 - fx * 0.12, floor, cz + wz * 0.35 - fz * 0.12],230+ ...(cand.wide231+ ? [[hole[0] - fx * 0.15, floor, hole[1] - fz * 0.15] as Vec3]232+ : []),147233 [148- hole[0] - wx * 0.15 - fx * 0.18,234+ tip[0] - wx * 0.15 - fx * 0.18,149235 floor,150- hole[1] - wz * 0.15 - fz * 0.18,236+ tip[1] - wz * 0.15 - fz * 0.18,151237 ],152- [hole[0] + wx * 0.22, floor, hole[1] + wz * 0.22],238+ [tip[0] + wx * 0.22, floor, tip[1] + wz * 0.22],153239 [154- hole[0] - wx * 0.15 + fx * 0.18,240+ tip[0] - wx * 0.15 + fx * 0.18,155241 floor,156- hole[1] - wz * 0.15 + fz * 0.18,242+ tip[1] - wz * 0.15 + fz * 0.18,157243 ],244+ ...(cand.wide245+ ? [[hole[0] + fx * 0.18, floor, hole[1] + fz * 0.18] as Vec3]246+ : []),158247 [cx + wx * 0.35 + fx * 0.12, floor, cz + wz * 0.35 + fz * 0.12],159248 [cx, floor, cz],160249 [cx + fx / 2, floor, cz + fz / 2],
@@ -175,6 +264,6 @@ export function planTakeableSpurs(
175264 landPos: [hole[0], floor - 1, hole[1]],176265 guaranteed: true,177266 }],178- cells: [key(...hole)],267+ cells,179268 };180269 }
M tests/track_test.ts +72 -1
@@ -262,7 +262,9 @@ Deno.test("takeable spur: elevated split drains through an end-hole onto the low
262262 // the landing lies ON the main loop centerline (the drain reaches the263263 // lower line); a split junction stands orthogonally adjacent at deck264264 // height; and the spur's teardrop alt curve passes over the hole.265- const seeds = ["take-65", "take-105", "take-168", "take-282", "take-308"];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"];266268 let spurs = 0;267269 for (const seed of seeds) {268270 const track = generateTrack(seed, { packs: [] }, { skipDecor: true });
@@ -326,6 +328,75 @@ Deno.test("takeable spur: elevated split drains through an end-hole onto the low
326328 assert(spurs > 0, "no takeable spurs across the pinned seeds");327329 });328330 331+Deno.test("wide bridge: funnel-bracketed straight-wide-hole over the lower line", () => {332+ // M13: straight-wide-hole realizes (exemption lifted) as the WIDE333+ // takeable spur — funnel → wide-hole deck directly over the lower ring334+ // channel → reversed funnel → cap, with falsework at the mouth and the335+ // abutment. The drain is guaranteed (hole on the wide centerline).336+ const seeds = ["wb-62", "wb-357", "wb-2036", "wb-2165"];337+ let bridges = 0;338+ for (const seed of seeds) {339+ const track = generateTrack(seed, { packs: [] }, { skipDecor: true });340+ const decks = track.placements.filter((p) =>341+ p.pieceId === "straight-wide-hole"342+ );343+ assert(decks.length > 0, `${seed} lost its wide bridge`);344+ for (const deck of decks) {345+ bridges++;346+ const y = deck.pos[1];347+ const dx = Math.round(deck.pos[0]);348+ const dz = Math.round(deck.pos[2]);349+ // Guaranteed fall at the deck cell, Δ1.0.350+ assert(351+ (track.falls ?? []).some((f) =>352+ f.guaranteed && f.pos[0] === dx && f.pos[2] === dz &&353+ Math.abs(f.pos[1] - (y + 0.25)) < 1e-9 &&354+ Math.abs(f.pos[1] - f.landPos[1] - 1) < 1e-9355+ ),356+ `${seed}: wide deck has no guaranteed drain`,357+ );358+ // Funnel brackets flush on both sides along the branch axis.359+ const w = ((1 - deck.rotation + 4) % 4) as 0 | 1 | 2 | 3;360+ const STEP: readonly (readonly [number, number])[] = [361+ [1, 0],362+ [0, 1],363+ [-1, 0],364+ [0, -1],365+ ];366+ const [sx, sz] = STEP[w];367+ const funnels = track.placements.filter((p) => p.pieceId === "funnel");368+ assert(369+ funnels.some((p) =>370+ Math.round(p.pos[0]) === dx - sx && Math.round(p.pos[2]) === dz - sz371+ ) &&372+ funnels.some((p) =>373+ Math.round(p.pos[0]) === dx + sx &&374+ Math.round(p.pos[2]) === dz + sz375+ ),376+ `${seed}: wide deck is not funnel-bracketed`,377+ );378+ // Cap at the far end; support falsework exists on the branch.379+ assert(380+ track.placements.some((p) =>381+ (p.pieceId === "end-rounded" || p.pieceId === "end-square") &&382+ Math.round(p.pos[0]) === dx + sx * 2 &&383+ Math.round(p.pos[2]) === dz + sz * 2384+ ),385+ `${seed}: wide bridge is uncapped`,386+ );387+ assert(388+ track.placements.some((p) =>389+ p.role === "support" &&390+ Math.round(p.pos[0]) === dx + sx * 2 &&391+ Math.round(p.pos[2]) === dz + sz * 2392+ ),393+ `${seed}: wide bridge abutment is unsupported`,394+ );395+ }396+ }397+ assert(bridges > 0, "no wide bridges across the pinned seeds");398+});399+329400 Deno.test("determinism: same seed → deep-equal track", () => {330401 const a = generateTrack("determinism-check");331402 const b = generateTrack("determinism-check");
M tools/coverage.ts +0 -2