clone

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

Banner rule (user): banners only on plain straight windows - never curved channels or tunnel-arched cells

Dami committed 3c23b46
commit
3c23b4607a9ca3f1d9c5dc8c53ebedfeb13de4ee
parent
78184df9ecfeacd4892686f3cbbb2b7474f84926
author
Dami <dami.etoile@gmail.com>
2 files changed, +73 -13
Msrc/gen/endless/world.ts26++++++++++---
Mtests/scenario_test.ts60+++++++++++++++++++++++++++----
M src/gen/endless/world.ts +20 -6
@@ -45,6 +45,9 @@ export interface EndlessSegment {
4545 * per-tile physics modifier channel (M11): the client maps families to4646 * speed/gravity behavior at the rail-station level. */4747 pieceFamilies: string[];48+ /** Cell range per window (aligned with pieceFamilies/path.segments) —49+ * lets tests and tools map plan cells to realized windows. */50+ windowRanges: { start: number; len: number }[];4851 /** Planner set-piece zone kind per path segment (or null). */4952 windowMods: (string | null)[];5053 /** Track placements in WORLD coordinates (×TRACK_SCALE, meshes scaled —
@@ -375,10 +378,17 @@ export class EndlessWorld {
375378 // designed the posts to sit on them), so they work even in the void:376379 // banner-high marks a jump lip, banner-double-high a propeller lip,377380 // a plain banner the entry of a standalone boost zone. Rule-derived,378- // no RNG. Cells inside reservations are skipped (a banner through a379- // ramp mesh clips).380- const isReserved = (idx: number) =>381- plan.reservations.some((r) => idx >= r.start && idx < r.start + r.len);381+ // no RNG. A banner goes ONLY on a plain straight window (USER RULE):382+ // never on curved-channel cells (waves/wiggles are F-moves but the383+ // channel swings away from the posts) and never under a tunnel arch384+ // (the banner would thread the arch mesh) — family "straight" is385+ // exactly the windows with a bare centered channel.386+ const windowFamilyAt = (idx: number): string | null => {387+ for (const w of windows) {388+ if (idx >= w.start && idx < w.start + w.len) return w.def.family;389+ }390+ return null;391+ };382392 const gapStarts = new Set(383393 plan.mods384394 .filter((m) => m.kind === "jump" || m.kind === "propeller")
@@ -388,7 +398,10 @@ export class EndlessWorld {
388398 for (const m of plan.mods) {389399 if (m.kind === "jump" || m.kind === "propeller") {390400 const lip = plan.cells[m.start - 1];391- if (lip && lip.move === "F" && !isReserved(m.start - 1)) {401+ if (402+ lip && lip.move === "F" &&403+ windowFamilyAt(m.start - 1) === "straight"404+ ) {392405 pads.push({393406 pieceId: m.kind === "propeller"394407 ? "banner-double-high"
@@ -407,7 +420,7 @@ export class EndlessWorld {
407420 idx++408421 ) {409422 const c = plan.cells[idx];410- if (c.move !== "F" || isReserved(idx)) continue;423+ if (c.move !== "F" || windowFamilyAt(idx) !== "straight") continue;411424 pads.push({412425 pieceId: "banner",413426 pos: [c.x, c.baseY + 0.5, c.z],
@@ -450,6 +463,7 @@ export class EndlessWorld {
450463 index: i,451464 plan,452465 pieceFamilies: windows.map((w) => w.def.family),466+ windowRanges: windows.map((w) => ({ start: w.start, len: w.len })),453467 windowMods,454468 placements,455469 path,
M tests/scenario_test.ts +53 -7
@@ -273,16 +273,26 @@ Deno.test("jump set-piece: every planned jump obeys the trajectory contract", ()
273273 `${seed} seg ${i}: landing cell ${k} not flat at lip−0.5`,274274 );275275 }276- // Pad visual: the lip carries its banner so the set-piece reads.276+ // Pad visual: the lip carries its banner so the set-piece reads —277+ // but ONLY when the lip realized as a plain straight window (USER278+ // RULE: no banners on curved-channel pieces, none under tunnel279+ // arches).277280 const lip = cells[g - 1];281+ const lipWindow = seg.windowRanges.findIndex((w) =>282+ g - 1 >= w.start && g - 1 < w.start + w.len283+ );284+ const lipIsPlain = lipWindow >= 0 &&285+ seg.pieceFamilies[lipWindow] === "straight";278286 const bannerId = propeller ? "banner-double-high" : "banner-high";287+ const hasBanner = seg.placements.some((p) =>288+ p.pieceId === bannerId &&289+ Math.abs(p.pos[0] - lip.x * TRACK_SCALE) < 0.01 &&290+ Math.abs(p.pos[2] - lip.z * TRACK_SCALE) < 0.01291+ );279292 assert(280- seg.placements.some((p) =>281- p.pieceId === bannerId &&282- Math.abs(p.pos[0] - lip.x * TRACK_SCALE) < 0.01 &&283- Math.abs(p.pos[2] - lip.z * TRACK_SCALE) < 0.01284- ),285- `${seed} seg ${i}: ${m.kind} @${g} missing its lip banner`,293+ hasBanner === lipIsPlain,294+ `${seed} seg ${i}: ${m.kind} @${g} banner=${hasBanner} but lip ` +295+ `window is ${seg.pieceFamilies[lipWindow] ?? "none"}`,286296 );287297 }288298 // Shock rule: every ≥1.5 chute descent has its impact-valley zone.
@@ -342,6 +352,42 @@ Deno.test("bump lips: bumps realize ONLY fully inside a boost zone", () => {
342352 assert(bumps > 0, "no bump lips emerged across the corpus");343353 });344354 355+Deno.test("zone banners: only on plain straight windows (never curves/tunnels)", () => {356+ // USER RULE: flags/banners cannot go on curved pieces (waves/wiggles357+ // swing the channel away from the posts) or share a cell with a tunnel358+ // arch. Every banner placement must map to a family-"straight" window.359+ const seeds = [360+ "det-seed",361+ "atmo-seed",362+ ...Array.from({ length: 10 }, (_, i) => `jump-scan-${i}`),363+ ];364+ let banners = 0;365+ for (const seed of seeds) {366+ const w = new EndlessWorld(seed, catalog);367+ for (let i = 0; i < 5; i++) {368+ const seg = w.get(i);369+ for (const p of seg.placements) {370+ if (!p.pieceId.startsWith("banner")) continue;371+ banners++;372+ const cx = Math.round(p.pos[0] / TRACK_SCALE);373+ const cz = Math.round(p.pos[2] / TRACK_SCALE);374+ const idx = seg.plan.cells.findIndex((c) => c.x === cx && c.z === cz);375+ assert(idx >= 0, `${seed} seg ${i}: banner off the line at ${p.pos}`);376+ const wi = seg.windowRanges.findIndex((r) =>377+ idx >= r.start && idx < r.start + r.len378+ );379+ assert(380+ wi >= 0 && seg.pieceFamilies[wi] === "straight",381+ `${seed} seg ${i}: banner on a ${382+ seg.pieceFamilies[wi] ?? "?"383+ } window at cell (${cx},${cz})`,384+ );385+ }386+ }387+ }388+ assert(banners > 0, "no banners emerged across the corpus");389+});390+345391 Deno.test("island verticality: sloped runs terrace, every cell anchored at its height", () => {346392 // M12: island runs may embed ONE slope window. Rules: terrace steps are347393 // bounded by the slope vocab (≤0.5 per cell along the profile); every