clone

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

main
marbles / tests / spur_test.ts
spur_test.ts 11.0K
1/**2 * Takeable spur drops in endless mode (M13's loop-mode machinery wired3 * into the void as respawn variety).4 *5 * The contract, per committed spur (rule-based, no time dimension):6 * a 1-cell straight host window whose placement mutated straight → split,7 * an end-hole diving board on the branch cell, a ONE-WAY alt centerline8 * out over the hole (flagged spur), and the MAIN line hooking down to9 * pass back DIRECTLY UNDER the branch cell at Δ exactly 1.0 track units10 * (the bridge law) — so the guaranteed fall lands ON TRACK BELOW (hole11 * semantics, USER RULE), and the head keeps walking (the continuation IS12 * the main line). The collision proxies must leave the hole OPEN (no13 * floor at deck level over the hole cell) with a catch pan below.14 */15import { assert, assertEquals } from "@std/assert";16import { EndlessWorld } from "../src/gen/endless/world.ts";17import { TRACK_SCALE } from "../src/gen/endless/scale.ts";18import { channelProxies, wideScalesOf } from "../src/gen/endless/proxies.ts";19import { gapSegmentsOf, railForSegment } from "../src/client/physics/rail.ts";20import { goldenCatalog, segmentHash } from "../tools/pin_endless.ts";21import { DIR_STEP, turnRight } from "../src/gen/geom.ts";22import { segPoint } from "../src/gen/geom.ts";23import type { DecorCatalog } from "../src/gen/decor.ts";2425// The catalog the client streams with (all decor packs — M18a).26const catalog: DecorCatalog = goldenCatalog();2728/** Scan corpus (same style as the jump/gravity scenario seeds). Pinned29 * exemplars: grav-scan-3 spurs at segment 0, grav-scan-22 at segment 130 * (re-pinned after the braid clause reshuffled the streams; before that31 * the reorient clause moved them) — re-scan with a fresh label family if32 * a reshuffle moves them (tools-free: the corpus loop below prints33 * nothing, just widen). */34const SEEDS = [35 "alpha",36 "dami",37 "cua-seed-1",38 ...Array.from({ length: 12 }, (_, i) => `spur-scan-${i}`),39];40const SEGS = 12;4142Deno.test("spur drop: pinned seeds exhibit a spur early", () => {43 const scan12 = new EndlessWorld("grav-scan-3", catalog);44 assert(45 scan12.get(0).plan.spurs.length > 0,46 "seed grav-scan-3 lost its segment-0 spur — re-scan for an exemplar",47 );48 const scan4 = new EndlessWorld("grav-scan-22", catalog);49 assert(50 scan4.get(1).plan.spurs.length > 0,51 "seed grav-scan-22 lost its segment-1 spur — re-scan (physics_test " +52 "pins the same seed)",53 );54});5556Deno.test("spur drop: every spur obeys the bridge-law contract", () => {57 let spurs = 0;58 for (const seed of SEEDS) {59 const w = new EndlessWorld(seed, catalog);60 for (let i = 0; i < SEGS; i++) {61 const seg = w.get(i);62 assert(63 seg.plan.spurs.length <= 1,64 `${seed} seg ${i}: ${seg.plan.spurs.length} spurs (set-piece, not texture)`,65 );66 for (const spur of seg.plan.spurs) {67 spurs++;68 const { cells } = seg.plan;69 const host = cells[spur.host];70 const rec = cells[spur.receiver];71 const [wx, wz] = DIR_STEP[spur.dir];72 const bx = host.x + wx;73 const bz = host.z + wz;7475 // The host is a straight cell; its window is pinned to 1 cell of76 // family "straight" and carries the spur zone.77 assert(host.move === "F", `${seed} seg ${i}: spur host not straight`);78 const wi = seg.windowRanges.findIndex((r) =>79 r.start === spur.host && r.len === 180 );81 assert(wi >= 0, `${seed} seg ${i}: spur host window not 1 cell`);82 assertEquals(83 seg.pieceFamilies[wi],84 "straight",85 `${seed} seg ${i}: spur host window family`,86 );87 assertEquals(88 seg.windowMods[wi],89 "spur",90 `${seed} seg ${i}: spur host window zone`,91 );9293 // The bridge law: the receiver is the branch cell's column at94 // Δ exactly 1.0 below the deck, and the line continues past it.95 assertEquals(96 [rec.x, rec.z],97 [bx, bz],98 `${seed} seg ${i}: receiver not under the branch cell`,99 );100 assert(101 Math.abs(rec.baseY - (host.baseY - 1)) < 1e-9,102 `${seed} seg ${i}: receiver at Δ ${host.baseY - rec.baseY}, not 1.0`,103 );104 assert(105 cells.length > spur.receiver + 1,106 `${seed} seg ${i}: no runway after the receiver`,107 );108 // Main-line continuity through the hook (adjacent cells walk).109 for (let k = spur.host; k < spur.receiver + 1; k++) {110 const [dx, dz] = DIR_STEP[cells[k].dirOut];111 assertEquals(112 [cells[k].x + dx, cells[k].z + dz],113 [cells[k + 1].x, cells[k + 1].z],114 `${seed} seg ${i}: main line breaks at cell ${k}`,115 );116 }117118 // The host placement mutated into the split junction (mirrored119 // for a right-hand branch), and the branch cell carries its120 // end-hole diving board at deck level.121 const split = seg.placements.find((p) =>122 p.pieceId === "split" &&123 Math.round(p.pos[0] / TRACK_SCALE) === host.x &&124 Math.round(p.pos[2] / TRACK_SCALE) === host.z &&125 Math.abs(p.pos[1] / TRACK_SCALE - host.baseY) < 1e-6126 );127 assert(split, `${seed} seg ${i}: spur host did not mutate to split`);128 assertEquals(129 Boolean(split.mirrored),130 spur.dir === turnRight(host.dirIn),131 `${seed} seg ${i}: split mirroring wrong for branch side`,132 );133 const board = seg.placements.find((p) =>134 p.pieceId === spur.cap &&135 Math.round(p.pos[0] / TRACK_SCALE) === bx &&136 Math.round(p.pos[2] / TRACK_SCALE) === bz &&137 Math.abs(p.pos[1] / TRACK_SCALE - host.baseY) < 1e-6138 );139 assert(board, `${seed} seg ${i}: no end-hole board on the branch`);140 assertEquals(141 board.rotation,142 (1 - spur.dir + 4) % 4,143 `${seed} seg ${i}: board rotation must open toward the junction`,144 );145146 // The alt centerline: present on the host window, spur-flagged,147 // one-way — out over the hole, then the DIVE down the shaft148 // (the fall is part of the line: the wishdir look target must149 // lead over the lip), ending at receiver floor level.150 const ls = seg.path.segments[wi];151 assert(152 ls.alt !== undefined && ls.alt.spur === true,153 `${seed} seg ${i}: spur window lost its alt centerline`,154 );155 const floorWorld = (host.baseY + 0.25) * TRACK_SCALE;156 const dive = ls.alt.curve.find((part) => {157 const a = segPoint(part, 0);158 const b = segPoint(part, 1);159 return Math.hypot(a[0] - b[0], a[2] - b[2]) < 1e-6 &&160 Math.abs(a[1] - floorWorld) < 1e-6 &&161 Math.abs(b[1] - (floorWorld - TRACK_SCALE)) < 1e-6 &&162 Math.hypot(a[0] - bx * TRACK_SCALE, a[2] - bz * TRACK_SCALE) <163 0.3;164 });165 assert(166 dive !== undefined,167 `${seed} seg ${i}: alt centerline has no shaft dive at the hole`,168 );169 const tip = segPoint(ls.alt.curve[ls.alt.curve.length - 1], 1);170 assert(171 Math.abs(tip[1] - (floorWorld - TRACK_SCALE)) < 1e-6 &&172 Math.hypot(tip[0] - bx * TRACK_SCALE, tip[2] - bz * TRACK_SCALE) <173 TRACK_SCALE,174 `${seed} seg ${i}: alt tip not on the receiver below`,175 );176177 // The descents realize as the reserved slant-b pair.178 for (const at of [spur.host + 1, spur.host + 4]) {179 assert(180 seg.plan.reservations.some((r) =>181 r.start === at && r.len === 1 && r.reversed &&182 r.pieceIds.includes("slant-b")183 ),184 `${seed} seg ${i}: hook descent @${at} not reserved`,185 );186 }187188 // PROXIES — the fall must be real physics: no floor at deck189 // level over the hole (the branch cell center), a diving-board190 // floor before it, and a catch pan a hair above the receiver.191 const proxies = channelProxies(192 seg.path,193 true,194 gapSegmentsOf(seg),195 wideScalesOf(seg),196 );197 const holeX = bx * TRACK_SCALE;198 const holeZ = bz * TRACK_SCALE;199 const recFloor = floorWorld - TRACK_SCALE;200 const coveredBySlab = (201 x: number,202 y: number,203 z: number,204 yTol: number,205 ): boolean =>206 proxies.some((p) => {207 if (p.kind !== "slab" || Math.abs(p.pitch) > 0.05) return false;208 const top = p.center[1] + p.half[1];209 if (Math.abs(top - y) > yTol) return false;210 // Rotate into the slab's yaw frame.211 const dx = x - p.center[0];212 const dz = z - p.center[2];213 const lx = dx * Math.cos(p.yaw) - dz * Math.sin(p.yaw);214 const lz = dx * Math.sin(p.yaw) + dz * Math.cos(p.yaw);215 return Math.abs(lx) <= p.half[0] && Math.abs(lz) <= p.half[2];216 });217 // Hole open: no flat slab top near deck floor level anywhere in218 // the hole's opening, and no ribbon triangle either.219 assert(220 !coveredBySlab(holeX, floorWorld, holeZ, 0.3),221 `${seed} seg ${i}: a slab floors over the hole`,222 );223 const meshOverHole = proxies.some((p) => {224 if (p.kind !== "mesh") return false;225 for (let v = 0; v < p.vertices.length; v += 3) {226 if (227 Math.abs(p.vertices[v + 1] - floorWorld) < 0.3 &&228 Math.hypot(p.vertices[v] - holeX, p.vertices[v + 2] - holeZ) <229 0.45230 ) return true;231 }232 return false;233 });234 assert(235 !meshOverHole,236 `${seed} seg ${i}: a ribbon sheet floors over the hole`,237 );238 // Diving board present between junction and hole.239 const midX = (host.x * TRACK_SCALE + holeX) / 2;240 const midZ = (host.z * TRACK_SCALE + holeZ) / 2;241 assert(242 coveredBySlab(midX, floorWorld + 0.02, midZ, 0.1),243 `${seed} seg ${i}: no diving-board floor before the hole`,244 );245 // Catch pan just above the receiver floor, under the hole.246 assert(247 coveredBySlab(holeX, recFloor + 0.03, holeZ, 0.1),248 `${seed} seg ${i}: no catch pan above the receiver`,249 );250 // The receiver rail passes directly below: riding the ALT rail,251 // some later station sits under the hole at the receiver floor.252 const rail = railForSegment(seg, true);253 const under = rail.pts.some((p) =>254 Math.abs(p[1] - recFloor) < 0.15 &&255 Math.hypot(p[0] - holeX, p[2] - holeZ) < 0.6256 );257 assert(258 under,259 `${seed} seg ${i}: no receiver rail station under the hole`,260 );261 }262 }263 }264 assert(spurs >= 8, `too few spurs across the corpus (${spurs})`);265});266267Deno.test("spur drop: deterministic per seed", () => {268 const a = new EndlessWorld("alpha", catalog);269 const b = new EndlessWorld("alpha", catalog);270 for (let i = 0; i < 3; i++) {271 assertEquals(segmentHash(a.get(i)), segmentHash(b.get(i)), `segment ${i}`);272 }273 assertEquals(274 a.get(0).plan.spurs,275 b.get(0).plan.spurs,276 "spur plans diverged",277 );278});