- commit
- e94897cf8356cfab0aeab3b8e3ca50b23fc12be3
- parent
- 16d0130224295d01b4961f5ef830b5bacfffaedd
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 17 | ++++- |
| M | src/ | 12 | ++- |
| M | src/ | 6 | +- |
| M | src/ | 80 | ++++++++++++++++++++- |
| M | src/ | 119 | +++++++++++++++++++++++++++++- |
| M | src/ | 9 | ++- |
| M | tests/ | 29 | +++++++- |
| M | tests/ | 72 | ++++++++++++++++++ |
@@ -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];@@ -84,15 +84,21 @@ export function gapSegmentsOf(seg: RailSource): Set<number> {
8484 return out;8585 }8686 87-/** Dense rail for one segment (local s from 0). */88-export function railForSegment(seg: RailSource): Rail {87+/** True if any window in the segment carries a diamond's other branch. */88+export function hasAltBranch(seg: RailSource): boolean {89+ return seg.path.segments.some((ls) => ls.alt !== undefined);90+}91+92+/** Dense rail for one segment (local s from 0). `useAlt` rides every93+ * diamond's OTHER branch — the respawn-variety rail. */94+export function railForSegment(seg: RailSource, useAlt = false): Rail {8995 const rail: Rail = { pts: [], tans: [], s: [], mods: [] };9096 let acc = 0;9197 for (const [lsIdx, ls] of seg.path.segments.entries()) {9298 const zone = seg.windowMods[lsIdx];9399 const mod = (zone ? ZONE_MODS[zone] : undefined) ??94100 TILE_MODS[seg.pieceFamilies[lsIdx]] ?? null;95- for (const part of ls.curve) {101+ for (const part of (useAlt && ls.alt ? ls.alt.curve : ls.curve)) {96102 const plen = segLength(part);97103 const steps = Math.max(1, Math.ceil(plen / RAIL_STEP));98104 for (let j = 0; j < steps; j++) {@@ -130,11 +130,15 @@ export function buildIsland(
130130 const placements: Placement[] = [];131131 132132 // The run's WORLD patch: each track cell covers a 2×2 set of world cells133- // (slalom rule — the channel swath the mass must carry).133+ // (slalom rule — the channel swath the mass must carry). A diamond's134+ // branch columns are track too: full mass beneath, no caps, no decor.134135 const runWorld = new Set<string>();135136 for (const [cx, cz] of runCells) {136137 for (const [wx, wz] of worldCellsOf(cx, cz)) runWorld.add(key(wx, wz));137138 }139+ for (const [cx, cz] of crossing.branchCells ?? []) {140+ for (const [wx, wz] of worldCellsOf(cx, cz)) runWorld.add(key(wx, wz));141+ }138142 const runWorldList = [...runWorld].map((k) => k.split(",").map(Number));139143 const chebToRun = (x: number, z: number): number => {140144 let best = Infinity;@@ -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);@@ -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@@ -384,7 +384,14 @@ export class EndlessWorld {
384384 segments: assembled.loop.segments.map((s) => {385385 const curve = s.curve.map(scaleSeg);386386 const length = s.length * TRACK_SCALE;387- return { ...s, curve, length };387+ // Diamond branches: the alt centerline scales with the track too.388+ const alt = s.alt389+ ? {390+ curve: s.alt.curve.map(scaleSeg),391+ length: s.alt.length * TRACK_SCALE,392+ }393+ : undefined;394+ return { ...s, curve, length, ...(alt ? { alt } : {}) };388395 }),389396 totalLength: assembled.loop.totalLength * TRACK_SCALE,390397 };@@ -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);@@ -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+});