clone

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

M11: branch respawns - island diamonds in endless (flank claims, alt rail, split wedge slab composite, respawn rides the other branch)

Dami committed e94897c
commit
e94897cf8356cfab0aeab3b8e3ca50b23fc12be3
parent
16d0130224295d01b4961f5ef830b5bacfffaedd
author
Dami <dami.etoile@gmail.com>
8 files changed, +330 -14
Msrc/client/endless.ts17++++-
Msrc/client/physics/rail.ts12++-
Msrc/gen/endless/islands.ts6+-
Msrc/gen/endless/path.ts80++++++++++++++++++++-
Msrc/gen/endless/proxies.ts119+++++++++++++++++++++++++++++-
Msrc/gen/endless/world.ts9++-
Mtests/physics_test.ts29+++++++-
Mtests/scenario_test.ts72++++++++++++++++++
M src/client/endless.ts +16 -1
@@ -25,6 +25,7 @@ import { ExitAnim, SpawnAnim } from "./streamAnim.ts";
2525 import { PhysicsEngine, type PhysicsModule } from "./physics/engine.ts";2626 import {2727 gapSegmentsOf,28+ hasAltBranch,2829 type Rail,2930 railForSegment,3031 stationForce,
@@ -212,6 +213,9 @@ class Session {
212213 /** Box3D driver (null → kinematic rail rider, ?phys=0). */213214 private engine: PhysicsEngine | null = null;214215 private readonly rails = new Map<number, Rail>();216+ /** Active diamond branch per segment — respawns alternate it, so a217+ * fall's retry may ride the OTHER branch (respawn variety). */218+ private readonly branchOf = new Map<number, "main" | "alt">();215219 private nearest: { seg: number; idx: number } = { seg: 0, idx: 0 };216220 deaths = 0;217221 /** Stall net: no rail progress for a while → respawn. Progress must
@@ -371,7 +375,10 @@ class Session {
371375 // Dense rail (points/tangents/s/mods) for nearest-point progress and372376 // the assist force — the shared law (rail.ts), one truth with the373377 // headless tests.374- this.rails.set(i, railForSegment(data));378+ this.rails.set(379+ i,380+ railForSegment(data, this.branchOf.get(i) === "alt"),381+ );375382 }376383 const prev = this.sMeta.get(i - 1);377384 const startS = i === 0 ? 0 : (prev ? prev.start + prev.len : 0);
@@ -640,6 +647,14 @@ class Session {
640647 this.lastRespawnSeg = cp.index;641648 this.respawnsHere = 0;642649 }650+ // Respawn variety (M11): if the checkpoint segment carries a diamond,651+ // the retry rides the OTHER branch — the fall becomes a reveal.652+ const data = this.world.get(cp.index);653+ if (hasAltBranch(data)) {654+ const next = this.branchOf.get(cp.index) === "alt" ? "main" : "alt";655+ this.branchOf.set(cp.index, next);656+ this.rails.set(cp.index, railForSegment(data, next === "alt"));657+ }643658 const rail = this.rails.get(cp.index);644659 if (!rail) return;645660 const t = rail.tans[0];
M src/client/physics/rail.ts +9 -3
M src/gen/endless/islands.ts +5 -1
M src/gen/endless/path.ts +79 -1
@@ -103,6 +103,10 @@ export interface IslandCrossing {
103103 topY: number;104104 /** Blob margin (Chebyshev rings around the run) the mass may fill. */105105 margin: number;106+ /** Extra channel cells beside the run (a diamond's branch columns) —107+ * the island builder treats them as track: full mass, no caps, no108+ * decor. */109+ branchCells?: readonly (readonly [number, number])[];106110 }107111 108112 /** Planner-placed physics zone (cell-indexed): the set-piece channel.
@@ -697,6 +701,73 @@ class SegmentBuilder {
697701 if (!this.commitSteps(steps)) return false;698702 const runCells = probed.cells.map((p) => [p.x, p.z] as const);699703 704+ // Split–merge diamond on the island run (M11 branch respawns): find705+ // a straight stretch of ≥5 cells, check + claim both flank columns at706+ // deck level, and reserve the composite diamond window. The branch707+ // columns become part of the island's track patch (full mass beneath,708+ // no caps, no decor; canopy skips them because their band is claimed709+ // before the envelope loop below runs).710+ const branchCells: (readonly [number, number])[] = [];711+ if (rng.child("diamond").chance(0.5)) {712+ const dRng = rng.child("diamond/pick");713+ const stretches: [number, number][] = [];714+ let j = 0;715+ while (j < runLen) {716+ if (steps[j].move !== "F") {717+ j++;718+ continue;719+ }720+ let l = 0;721+ while (j + l < runLen && steps[j + l].move === "F") l++;722+ if (l >= 5) stretches.push([j, l]);723+ j += l;724+ }725+ placing:726+ for (const [s0, l] of dRng.shuffle(stretches)) {727+ for (let N = Math.min(3, l - 4); N >= 1; N--) {728+ const total = 4 + N;729+ const offsets = dRng.shuffle(730+ Array.from({ length: l - total + 1 }, (_, o) => o),731+ );732+ for (const o of offsets) {733+ const j0 = s0 + o;734+ const flanks: (readonly [number, number])[] = [];735+ let ok = true;736+ for (let k = 0; k < total && ok; k++) {737+ const cell = this.cells[start + j0 + k];738+ for (739+ const side of [turnLeft(cell.dirIn), turnRight(cell.dirIn)]740+ ) {741+ const [dx, dz] = DIR_STEP[side];742+ const fx = cell.x + dx;743+ const fz = cell.z + dz;744+ if (745+ runCells.some(([rx, rz]) => rx === fx && rz === fz) ||746+ !this.occ.isFree(fx, fz, topY, topY + CHANNEL_CLEARANCE)747+ ) {748+ ok = false;749+ break;750+ }751+ flanks.push([fx, fz]);752+ }753+ }754+ if (!ok) continue;755+ for (const [fx, fz] of flanks) {756+ this.occ.claim(fx, fz, topY, topY + CHANNEL_CLEARANCE, this.ref);757+ }758+ branchCells.push(...flanks);759+ this.reservations.push({760+ start: start + j0,761+ len: total,762+ pieceIds: [`diamond-${N}`],763+ reversed: false,764+ });765+ break placing;766+ }767+ }768+ }769+ }770+700771 // Claim the mass envelope now — blob rings around the run, each cell701772 // reserving as much depth below topY as older claims allow. The island702773 // builder works strictly inside `isle/${id}` claims.
@@ -759,7 +830,14 @@ class SegmentBuilder {
759830 }760831 }761832 762- this.crossings.push({ id, start, len: runLen, topY, margin });833+ this.crossings.push({834+ id,835+ start,836+ len: runLen,837+ topY,838+ margin,839+ ...(branchCells.length > 0 ? { branchCells } : {}),840+ });763841 this.islandCount++;764842 this.sinceAnchor = 0;765843 this.anchorEvery = rng.int(ANCHOR_EVERY_MIN, ANCHOR_EVERY_MAX);
M src/gen/endless/proxies.ts +114 -5
@@ -101,6 +101,9 @@ interface Station {
101101 vertical: boolean;102102 /** Jump-gap sample (M11): no channel at all — ballistic flight. */103103 gap: boolean;104+ /** Diamond-window sample (M11): the slab composite replaces the ribbon105+ * (two crossing one-sided branch meshes destabilized the solver). */106+ diamond: boolean;104107 dy: number;105108 }106109
@@ -114,10 +117,13 @@ export function channelProxies(
114117 useRibbon = true,115118 gapSegments?: ReadonlySet<number>,116119 ): CollisionProxy[] {117- const stations: Station[] = [];118- for (const [segIdx, seg] of path.segments.entries()) {119- const gap = gapSegments?.has(segIdx) ?? false;120- for (const part of seg.curve) {120+ const sampleParts = (121+ parts: LoopPath["segments"][number]["curve"],122+ gap: boolean,123+ into: Station[],124+ diamond = false,125+ ): void => {126+ for (const part of parts) {121127 const len = segLength(part);122128 // Curvature-aware sampling: arcs bound the per-slab turn angle;123129 // cubics (jogs) sample finely; lines stay coarse.
@@ -142,17 +148,27 @@ export function channelProxies(
142148 const total = Math.hypot(dx, dy, dz);143149 if (total < 1e-9) continue;144150 const vertical = !gap && Math.abs(dy / total) > VERTICAL_LIMIT;145- stations.push({151+ into.push({146152 p: [p[0], p[1], p[2]],147153 yaw: vertical ? 0 : Math.atan2(dx, dz),148154 pitch: vertical ? 0 : -Math.atan2(dy, horiz),149155 halfLen,150156 vertical,151157 gap,158+ diamond,152159 dy,153160 });154161 }155162 }163+ };164+ const stations: Station[] = [];165+ for (const [segIdx, seg] of path.segments.entries()) {166+ sampleParts(167+ seg.curve,168+ gapSegments?.has(segIdx) ?? false,169+ stations,170+ seg.alt !== undefined,171+ );156172 }157173 // Vertical stations inherit the approach yaw; their exit heading is the158174 // next non-vertical station's yaw (the receiver's port).
@@ -201,6 +217,16 @@ export function channelProxies(
201217 for (let i = 0; i < stations.length; i++) {202218 const st = stations[i];203219 const [px, py, pz] = st.p;220+ if (st.diamond) {221+ // Diamond window: the ribbon stops at both ports; the slab222+ // composite (emitted below, from the segment's own geometry)223+ // carries the marble across.224+ flushStrip(true, true);225+ let k = i;226+ while (k < stations.length && stations[k].diamond) k++;227+ i = k - 1;228+ continue;229+ }204230 if (st.gap) {205231 // JUMP gap: the lip ribbon runs to the gap edge, then NOTHING — the206232 // marble flies. The landing side gets a catch pan reaching back
@@ -277,9 +303,92 @@ export function channelProxies(
277303 strip.push(st);278304 }279305 flushStrip(true, true);306+307+ // Diamond windows (M11 branch respawns): one slab composite per window308+ // — a wide flat deck, outer flare/straight walls, and a center wedge309+ // divider, mirroring the real split-double geometry. (Two overlapping310+ // one-sided branch RIBBONS were tried first: their surfaces cross at311+ // the split and the wedged marble destabilized the solver.)312+ for (const seg of path.segments) {313+ if (seg.alt) emitDiamond(seg, out);314+ }280315 return out;281316 }282317 318+/** Slab composite for a split–merge diamond window. World geometry (the319+ * window is straight and flat): branches at lateral ±2, channel half320+ * width 0.92, diverge/merge cubics 4 world long each, N straights321+ * between. Two-sided box slabs — crossings and overlaps are harmless. */322+function emitDiamond(323+ seg: LoopPath["segments"][number],324+ out: CollisionProxy[],325+): void {326+ const entry = segPoint(seg.curve[0], 0);327+ const last = seg.curve[seg.curve.length - 1];328+ const exit = segPoint(last, 1);329+ const dx = exit[0] - entry[0];330+ const dz = exit[2] - entry[2];331+ const L = Math.hypot(dx, dz);332+ if (L < 1e-6) return;333+ const yaw = Math.atan2(dx, dz);334+ const fwd: [number, number] = [dx / L, dz / L];335+ const lat: [number, number] = [fwd[1], -fwd[0]];336+ const floorY = entry[1];337+ const at = (338+ along: number,339+ side: number,340+ ): [number, number, number] => [341+ entry[0] + fwd[0] * along + lat[0] * side,342+ floorY,343+ entry[2] + fwd[1] * along + lat[1] * side,344+ ];345+ const wall = (346+ a: readonly [number, number],347+ b: readonly [number, number],348+ ): void => {349+ // Wall slab from (along, side) a → b at deck level.350+ const ax = entry[0] + fwd[0] * a[0] + lat[0] * a[1];351+ const az = entry[2] + fwd[1] * a[0] + lat[1] * a[1];352+ const bx = entry[0] + fwd[0] * b[0] + lat[0] * b[1];353+ const bz = entry[2] + fwd[1] * b[0] + lat[1] * b[1];354+ const wx = bx - ax;355+ const wz = bz - az;356+ const wlen = Math.hypot(wx, wz);357+ out.push({358+ kind: "slab",359+ center: [(ax + bx) / 2, floorY + 0.35, (az + bz) / 2],360+ half: [0.09, 0.4, wlen / 2],361+ yaw: Math.atan2(wx, wz),362+ pitch: 0,363+ });364+ };365+366+ const C = 4.0; // diverge/merge cubic length (2 track cells)367+ const B = 2.0; // branch lateral offset368+ const HW = 0.95; // channel half width at the walls369+ // Deck: the full 3-column footprint, top a hair above the neighboring370+ // ribbon sheets so entry/exit never catch a mesh edge.371+ const mid = at(L / 2, 0);372+ out.push({373+ kind: "slab",374+ center: [mid[0], floorY - FLOOR_HALF_T + 0.02, mid[2]],375+ half: [B + HW, FLOOR_HALF_T, L / 2 + 0.05],376+ yaw,377+ pitch: 0,378+ });379+ for (const side of [-1, 1]) {380+ // Outer walls: entry flare, branch straight, merge flare.381+ wall([0, side * HW], [C, side * (B + HW)]);382+ wall([C, side * (B + HW)], [L - C, side * (B + HW)]);383+ wall([L - C, side * (B + HW)], [L, side * HW]);384+ // Center wedge: tip where the inner channel edges cross (≈ half the385+ // cubic), widening to the island between the branch straights.386+ wall([C / 2, 0], [C, side * (B - HW)]);387+ wall([C, side * (B - HW)], [L - C, side * (B - HW)]);388+ wall([L - C, side * (B - HW)], [L - C / 2, 0]);389+ }390+}391+283392 /** Catch pan at a jump landing, reaching back under the flight path. Its284393 * top sits a hair ABOVE the landing ribbon sheet (arriving below a285394 * one-sided mesh means catching its edge forever). Lateral catching is
M src/gen/endless/world.ts +8 -1
M tests/physics_test.ts +27 -2
@@ -56,8 +56,9 @@ function drive(
5656 simSeconds: number,5757 stopAtS = Infinity,5858 spawnAtS = 0,59+ useAlt = false,5960 ): RunResult {60- return driveWith(seed, segments, simSeconds, stopAtS, spawnAtS);61+ return driveWith(seed, segments, simSeconds, stopAtS, spawnAtS, useAlt);6162 }6263 6364 let module_: PhysicsModule | null = null;
@@ -68,6 +69,7 @@ function driveWith(
6869 simSeconds: number,6970 stopAtS = Infinity,7071 spawnAtS = 0,72+ useAlt = false,7173 ): RunResult {7274 const w = new EndlessWorld(seed, catalog);7375 const engine = new PhysicsEngine(module_!);
@@ -75,7 +77,7 @@ function driveWith(
7577 let lowest = Infinity;7678 for (let i = 0; i < segments; i++) {7779 const seg = w.get(i);78- rails.push(railForSegment(seg));80+ rails.push(railForSegment(seg, useAlt));7981 engine.addSegment(i, [8082 ...channelProxies(seg.path, true, gapSegmentsOf(seg)),8183 ...seg.islands.flatMap((isle) => islandProxies(isle.placements)),
@@ -222,6 +224,29 @@ Deno.test("scenario jump-gap: cold entry launches, flies, and is caught (both ki
222224 }223225 });224226 227+Deno.test("scenario diamond-branch: both branches carry the marble through", async () => {228+ module_ = await factory();229+ // cua-seed-1 segment 1 hosts an island diamond. Drive through segment 2230+ // twice — once on the main (left) branch rail, once on the alt (right)231+ // — the marble must clear the split, branch, and merge on BOTH. This is232+ // the branch-respawn contract: after a fall, the retry may ride the233+ // other branch, so both must be survivable.234+ const w = new EndlessWorld("cua-seed-1", catalog);235+ const hasDiamond = [0, 1, 2].some((i) =>236+ w.get(i).plan.reservations.some((r) => r.pieceIds[0]?.startsWith("diamond"))237+ );238+ assert(hasDiamond, "cua-seed-1 early segments lost their diamond");239+ const goal = w.get(3).startS;240+ for (const useAlt of [false, true]) {241+ const r = drive("cua-seed-1", 5, 90, goal, 0, useAlt);242+ assert(243+ !r.fell && r.reachedS >= goal,244+ `${useAlt ? "alt" : "main"} branch failed: fell=${r.fell} ` +245+ `reached=${r.reachedS.toFixed(1)}/${goal.toFixed(1)}`,246+ );247+ }248+});249+225250 Deno.test("physics determinism: identical trajectory hash across runs", async () => {226251 module_ = await factory();227252 const a = drive("det-seed", 4, 45);
M tests/scenario_test.ts +72 -0
@@ -330,3 +330,75 @@ Deno.test("bump lips: bumps realize ONLY fully inside a boost zone", () => {
330330 }331331 assert(bumps > 0, "no bump lips emerged across the corpus");332332 });333+334+Deno.test("island diamonds: branches live on island track with claimed flanks", () => {335+ // Branch-respawn contract (rule-based): every diamond reservation sits336+ // fully inside an island run's straight cells, its crossing records the337+ // branch columns (2 per window cell), and every branch cell is part of338+ // the island's mass patch (checked via the island builder's placements:339+ // mass directly under each branch cell, no decor on it).340+ const seeds = [341+ "cua-seed-1",342+ "drift-seed",343+ ...Array.from({ length: 12 }, (_, i) => `jump-scan-${i}`),344+ ];345+ let diamonds = 0;346+ for (const seed of seeds) {347+ const w = new EndlessWorld(seed, catalog);348+ for (let i = 0; i < 5; i++) {349+ const seg = w.get(i);350+ for (const r of seg.plan.reservations) {351+ if (!r.pieceIds[0]?.startsWith("diamond")) continue;352+ diamonds++;353+ const crossing = seg.plan.crossings.find((c) =>354+ r.start >= c.start && r.start + r.len <= c.start + c.len355+ );356+ assert(357+ crossing !== undefined,358+ `${seed} seg ${i}: diamond @${r.start} not inside an island run`,359+ );360+ for (let j = 0; j < r.len; j++) {361+ assert(362+ seg.plan.cells[r.start + j].move === "F",363+ `${seed} seg ${i}: diamond cell ${r.start + j} not straight`,364+ );365+ }366+ assert(367+ crossing.branchCells !== undefined &&368+ crossing.branchCells.length >= r.len * 2,369+ `${seed} seg ${i}: diamond @${r.start} missing branch columns`,370+ );371+ // Mass under every branch cell, and no decor placed on it.372+ const isle = seg.islands.find((x) => x.id === crossing.id)!;373+ const worldTop = crossing.topY * TRACK_SCALE;374+ for (const [bx, bz] of crossing.branchCells) {375+ assert(376+ isle.placements.some((p) =>377+ p.role === "support" &&378+ Math.abs(p.pos[0] - bx * TRACK_SCALE) <= 0.51 &&379+ Math.abs(p.pos[2] - bz * TRACK_SCALE) <= 0.51 &&380+ p.pos[1] < worldTop381+ ),382+ `${seed} seg ${i}: no mass under branch cell (${bx},${bz})`,383+ );384+ for (const p of isle.placements) {385+ if (p.role !== "decor") continue;386+ const inCell = Math.abs(p.pos[0] - bx * TRACK_SCALE) < 1 &&387+ Math.abs(p.pos[2] - bz * TRACK_SCALE) < 1;388+ assert(389+ !inCell,390+ `${seed} seg ${i}: decor on branch cell (${bx},${bz})`,391+ );392+ }393+ }394+ // The path window carries the alt centerline through to the395+ // client (scaled): the loop segment for this window has alt.396+ assert(397+ seg.path.segments.some((ls) => ls.alt !== undefined),398+ `${seed} seg ${i}: diamond window lost its alt centerline`,399+ );400+ }401+ }402+ }403+ assert(diamonds > 0, "no island diamonds emerged across the corpus");404+});