clone

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

main
marbles / tests / endless_test.ts
endless_test.ts 14.4K
1/**2 * Endless mode (M5) invariants: the void line + island anchors.3 *4 * The stream must be a pure function of the seed (culling is generation-5 * driven, so client timing can't perturb it), continuous across segment6 * boundaries, physically honest (no two channels overlap a cell's band),7 * and bounded in memory.8 */9import { assert, assertEquals } from "@std/assert";10import { EndlessWorld, KEEP_SEGMENTS } from "../src/gen/endless/world.ts";11import { BIOMES } from "../src/gen/endless/islands.ts";12import { TRACK_SCALE } from "../src/gen/endless/scale.ts";13import { segPoint } from "../src/gen/geom.ts";14import type { DecorCatalog } from "../src/gen/decor.ts";15import { goldenCatalog } from "../tools/pin_endless.ts";16import { segmentClimate } from "../src/gen/climate.ts";1718// The catalog the client streams with (all decor packs — M18a).19const catalog: DecorCatalog = goldenCatalog();2021Deno.test("endless: same seed → identical stream", () => {22 const a = new EndlessWorld("det-seed", catalog);23 const b = new EndlessWorld("det-seed", catalog);24 for (let i = 0; i < 8; i++) {25 assertEquals(a.get(i).placements, b.get(i).placements, `segment ${i}`);26 assertEquals(27 a.get(i).islands.map((isle) => isle.placements),28 b.get(i).islands.map((isle) => isle.placements),29 `islands of segment ${i}`,30 );31 }32});3334Deno.test("endless: different seeds diverge", () => {35 const a = new EndlessWorld("div-a", catalog);36 const b = new EndlessWorld("div-b", catalog);37 const cellsA = a.get(1).plan.cells.map((c) => `${c.x},${c.z},${c.baseY}`);38 const cellsB = b.get(1).plan.cells.map((c) => `${c.x},${c.z},${c.baseY}`);39 assert(JSON.stringify(cellsA) !== JSON.stringify(cellsB));40});4142Deno.test("endless: path placement indices survive dropped gap meshes", () => {43 const world = new EndlessWorld("rail-sample-008", catalog);44 for (let i = 0; i < 24; i++) {45 const seg = world.get(i);46 for (const [window, pathSeg] of seg.path.segments.entries()) {47 if (pathSeg.placementIndex < 0) continue;48 const placement = seg.placements[pathSeg.placementIndex];49 assert(50 placement !== undefined && placement.role === "track",51 `segment ${i} window ${window} resolved to ${52 placement?.pieceId ?? "nothing"53 } (${placement?.role ?? "missing"})`,54 );55 }56 }57});5859Deno.test("endless: access pattern cannot perturb the stream", () => {60 // Eager: generate 12 up front. Lazy: one at a time (auto-cull runs at61 // different moments relative to reads). Streams must match exactly.62 const eager = new EndlessWorld("access-seed", catalog);63 eager.get(11);64 const lazy = new EndlessWorld("access-seed", catalog);65 for (let i = 4; i < 12; i++) {66 assertEquals(lazy.get(i).placements, eager.get(i).placements);67 }68});6970Deno.test("endless: 20-seed fuzz — continuity, clearance, anchors, memory", () => {71 for (let n = 0; n < 20; n++) {72 const w = new EndlessWorld(`endless-fuzz-${n}`, catalog);73 const SEGS = 24;74 // Channel entries per cell, tagged with their segment: the clearance75 // guarantee holds among CO-LIVE segments (within KEEP_SEGMENTS). The76 // line may legally reoccupy long-culled space — that geometry was77 // despawned well behind the render window, so nothing visible overlaps.78 const channels = new Map<79 string,80 { y: number; seg: number; idx: number }[]81 >();8283 let prevEnd: readonly [number, number, number] | null = null;84 let prevExit: { x: number; z: number } | null = null;85 for (let i = 0; i < SEGS; i++) {86 const seg = w.get(i);8788 // -- port continuity across the boundary (centerline + cells)89 const first = seg.path.segments[0].curve[0];90 const start = segPoint(first, 0);91 if (prevEnd) {92 const horiz = Math.hypot(start[0] - prevEnd[0], start[2] - prevEnd[2]);93 const vert = Math.abs(start[1] - prevEnd[1]);94 assert(95 horiz < 1e-6 && vert < 0.06,96 `seed ${n} seg ${i}: boundary gap horiz ${horiz} vert ${vert}`,97 );98 }99 if (prevExit) {100 const c0 = seg.plan.cells[0];101 assertEquals(102 [c0.x, c0.z],103 [prevExit.x, prevExit.z],104 `seed ${n} seg ${i}: entry cell != promised exit cell`,105 );106 }107 const lastSeg = seg.path.segments[seg.path.segments.length - 1];108 const lastCurve = lastSeg.curve[lastSeg.curve.length - 1];109 prevEnd = segPoint(lastCurve, 1);110 prevExit = { x: seg.plan.exit.x, z: seg.plan.exit.z };111112 // -- vertical clearance: same cell + co-live → entries ≥ ~1 apart.113 // Exemption: cells inside ONE reserved full-helix ensemble revisit114 // their own coordinates by construction (the underpass rides back115 // through the approach cells; the composite mesh provides both116 // levels), so within-window pairs don't count as collisions.117 const helixSpans = seg.plan.reservations118 .filter((r) => /^helix-(large-)?(left|right)$/.test(r.pieceIds[0]))119 .map((r) => [r.start, r.start + r.len] as const);120 const spanOf = (idx: number) =>121 helixSpans.findIndex(([a, b]) => idx >= a && idx < b);122 for (const [ci, c] of seg.plan.cells.entries()) {123 const k = `${c.x},${c.z}`;124 const ys = channels.get(k) ?? [];125 for (const e of ys) {126 if (e.seg <= i - KEEP_SEGMENTS) continue; // culled — legal reuse127 if (128 e.seg === i && spanOf(ci) >= 0 && spanOf(e.idx) === spanOf(ci)129 ) continue; // same full-helix ensemble130 assert(131 Math.abs(e.y - c.baseY) >= 0.98,132 `seed ${n} seg ${i}: channels ${e.y} (seg ${e.seg}) and ` +133 `${c.baseY} collide at ${k}`,134 );135 }136 ys.push({ y: c.baseY, seg: i, idx: ci });137 channels.set(k, ys);138 }139140 // -- islands: every anchored run cell has mass directly beneath its141 // WORLD patch (islands live on the 1× world grid; track cells are142 // ×TRACK_SCALE — the slalom rule).143 for (const crossing of seg.plan.crossings) {144 const isle = seg.islands.find((x) => x.id === crossing.id)!;145 for (let j = 0; j < crossing.len; j++) {146 const c = seg.plan.cells[crossing.start + j];147 const cellTop = crossing.profile[j];148 // M12 verticality: a run cell rides its own terrace — at the149 // terrace top exactly, or bridging up to one slope-part delta150 // above (a 1.0 ramp-long window grounds on its low side).151 assert(152 c.baseY >= cellTop - 1e-9 && c.baseY <= cellTop + 1.0 + 1e-9,153 `seed ${n} island ${crossing.id}: cell ${j} base ${c.baseY} ` +154 `off its terrace ${cellTop}`,155 );156 const cellTopWorld = cellTop * TRACK_SCALE;157 const supported = isle.placements.some((p) =>158 p.role === "support" &&159 Math.abs(p.pos[0] - c.x * TRACK_SCALE) <= 0.51 &&160 Math.abs(p.pos[2] - c.z * TRACK_SCALE) <= 0.51 &&161 p.pos[1] < cellTopWorld162 );163 assert(164 supported,165 `seed ${n} island ${crossing.id}: no mass under (${c.x},${c.z})`,166 );167 }168 // Island mass never intrudes above the highest terrace.169 const maxTopWorld = Math.max(...crossing.profile) * TRACK_SCALE;170 for (const p of isle.placements) {171 if (p.role !== "support") continue;172 assert(173 p.pos[1] <= maxTopWorld - 0.24,174 `mass block top above island surface at ${p.pos}`,175 );176 }177 }178 }179180 // -- memory bounded by the live window181 assert(182 w.occupancySize < 6000,183 `seed ${n}: occupancy grew unbounded (${w.occupancySize})`,184 );185 }186});187188Deno.test("endless: net drift is downward and unbounded-ish", () => {189 const w = new EndlessWorld("drift-seed", catalog);190 const y0 = w.get(0).plan.entry.y;191 const y20 = w.get(20).plan.exit.y;192 assert(y20 < y0 - 5, `expected clear net descent, got ${y0} → ${y20}`);193});194195Deno.test("endless: islands appear regularly with biome variety", () => {196 const biomes = new Set<string>();197 let islands = 0;198 for (let n = 0; n < 6; n++) {199 const w = new EndlessWorld(`biome-${n}`, catalog);200 for (let i = 0; i < 12; i++) {201 for (const isle of w.get(i).islands) {202 islands++;203 biomes.add(isle.biome);204 }205 }206 }207 assert(islands >= 20, `expected islands as regular anchors, got ${islands}`);208 assert(biomes.size >= 3, `expected biome variety, got ${[...biomes]}`);209});210211// ---------------------------------------------------------------------------212// M18a themed biomes: structural scan.213// ---------------------------------------------------------------------------214215Deno.test("themed biomes: every island placement resolves, caps stay 1x1", () => {216 const cat = catalog;217 const known = new Map(cat.packs.map((p) => [p.slug, new Set(p.ids)]));218 const manifests = new Map(219 [220 "nature-kit",221 "holiday-kit",222 "graveyard-kit",223 "pirate-kit",224 "survival-kit",225 "platformer-kit",226 ]227 .map((slug) => [228 slug,229 JSON.parse(Deno.readTextFileSync(230 new URL(`../assets/${slug}.manifest.json`, import.meta.url),231 )),232 ]),233 );234 const resolve = (pieceId: string): boolean => {235 const i = pieceId.indexOf(":");236 if (i < 0) return true; // marble-kit ids aren't the catalog's concern237 const [slug, id] = [pieceId.slice(0, i), pieceId.slice(i + 1)];238 return known.get(slug)?.has(id) ?? false;239 };240 const seen = new Set<string>();241 for (let n = 0; n < 40; n++) {242 const w = new EndlessWorld(`biome-${n}`, cat);243 for (let i = 0; i < 8; i++) {244 for (const isle of w.get(i).islands) {245 seen.add(isle.biome);246 for (const p of isle.placements) {247 assert(248 resolve(p.pieceId),249 `biome-${n} seg ${i} (${isle.biome}): unresolvable ${p.pieceId}`,250 );251 }252 }253 }254 }255 // The themed rows must actually emerge across the corpus.256 for (const b of ["winter", "spooky", "tropical", "autumn"]) {257 assert(seen.has(b), `biome ${b} never emerged across the corpus`);258 }259 // Cap footprint law: every biome's capId (if any) is a clean 1×1260 // bottom-center tile per its committed manifest.261 for (const biome of BIOMES) {262 if (!biome.capId) continue;263 const i = biome.capId.indexOf(":");264 const m = manifests.get(biome.capId.slice(0, i));265 assert(m, `${biome.name}: no manifest for ${biome.capId}`);266 const piece = m.pieces.find((p: { id: string }) =>267 p.id === biome.capId!.slice(i + 1)268 );269 assert(piece, `${biome.name}: cap ${biome.capId} missing from manifest`);270 const dx = piece.bboxMax[0] - piece.bboxMin[0];271 const dz = piece.bboxMax[2] - piece.bboxMin[2];272 assert(273 dx <= 1.05 && dz <= 1.05,274 `${biome.name}: cap ${biome.capId} is ${dx}x${dz}, not ~1x1`,275 );276 const cx = (piece.bboxMax[0] + piece.bboxMin[0]) / 2;277 const cz = (piece.bboxMax[2] + piece.bboxMin[2]) / 2;278 assert(279 Math.abs(cx) <= 0.05 && Math.abs(cz) <= 0.05,280 `${biome.name}: cap ${biome.capId} not bottom-center`,281 );282 }283});284285// ---------------------------------------------------------------------------286// M21 — weather becomes physical.287// ---------------------------------------------------------------------------288289Deno.test("climate: the planner leans with the zone's base mood", () => {290 // Corpus law: snowdrift zones grow measurably more icy windows than291 // goldhour zones; goldhour zones jump more. The climate read is the292 // BASE draw — the same seed-pure function the sky presents.293 const per = new Map<string, { icy: number; jump: number; segs: number }>();294 for (let n = 0; n < 40; n++) {295 const w = new EndlessWorld(`climate-${n}`, catalog);296 for (let i = 0; i < 8; i++) {297 const { mood } = segmentClimate(`climate-${n}`, i);298 const seg = w.get(i);299 const e = per.get(mood) ?? { icy: 0, jump: 0, segs: 0 };300 e.segs++;301 for (const m of seg.plan.mods) {302 if (m.kind === "icy") e.icy++;303 if (m.kind === "jump" || m.kind === "propeller") e.jump++;304 }305 per.set(mood, e);306 }307 }308 const rate = (m: string, k: "icy" | "jump"): number => {309 const e = per.get(m);310 return e && e.segs > 10 ? e[k] / e.segs : NaN;311 };312 const icySnow = rate("snowdrift", "icy");313 const icyGold = rate("goldhour", "icy");314 const jumpGold = rate("goldhour", "jump");315 const jumpSnow = rate("snowdrift", "jump");316 assert(317 Number.isNaN(icySnow) === false && Number.isNaN(icyGold) === false,318 "corpus too small for climate comparison",319 );320 assert(321 icySnow > icyGold * 1.5,322 `snowdrift icy rate ${icySnow.toFixed(3)} not clearly above goldhour ${323 icyGold.toFixed(3)324 }`,325 );326 assert(327 jumpGold > jumpSnow,328 `goldhour jump rate ${jumpGold.toFixed(3)} not above snowdrift ${329 jumpSnow.toFixed(3)330 }`,331 );332});333334// ---------------------------------------------------------------------------335// M24 — seed personality.336// ---------------------------------------------------------------------------337338Deno.test("temperament: seeds develop measurable identities", () => {339 // Group seeds by their drawn temperament and compare hazard rates:340 // treacherous seeds must run icier+heavier than serene ones across a341 // corpus, and every temperament must actually emerge.342 const byTemp = new Map<string, { icy: number; segs: number }>();343 for (let n = 0; n < 60; n++) {344 const seed = `temper-${n}`;345 const w = new EndlessWorld(seed, catalog);346 const t = w.style.temperament.name;347 const e = byTemp.get(t) ?? { icy: 0, segs: 0 };348 for (let i = 0; i < 6; i++) {349 e.segs++;350 for (const m of w.get(i).plan.mods) {351 if (m.kind === "icy" || m.kind === "gravity") e.icy++;352 }353 }354 byTemp.set(t, e);355 }356 assert(byTemp.size >= 4, `only ${byTemp.size} temperaments emerged`);357 const treacherous = byTemp.get("treacherous");358 const serene = byTemp.get("serene");359 if (treacherous && serene && treacherous.segs > 20 && serene.segs > 20) {360 assert(361 treacherous.icy / treacherous.segs > serene.icy / serene.segs,362 `treacherous hazard rate ${363 (treacherous.icy / treacherous.segs).toFixed(3)364 } not above serene ${(serene.icy / serene.segs).toFixed(3)}`,365 );366 }367 // The style stream stays appended-only: the temperament draw must not368 // move any pre-M24 style field (the theme of a known seed is pinned).369 const w = new EndlessWorld("det-seed", catalog);370 assert(371 typeof w.style.temperament.name === "string" &&372 w.style.theme.length > 0,373 );374});