clone

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

BRAIDED PATHS (M17): the line forks, wanders, and merges back

Dami committed 7bf56ca
USER: branching was single stubs — now the stream braids. A tryBraid
clause (void rotation, weight 0.5) forks the line at a split host: the
main lane runs straight through; the ALT lane is a REAL wandering walk
beside it (offset profile, |do|<=1 per step) that re-enters at a merge
host L=8..14 cells later. Both lanes are genuine channels: the alt lane
realizes through the same WFC, gets its own centerline chain on the
fork window (the diamond alt machinery, flagged braid), its own
channelProxies ribbon, and its own rail.

- BREADTH KNOB (user): per-seed bias rngFamily(seed, 'braid-breadth')
  in 1..3 caps each braid's wander — some seeds run clean, some tangle.
- PER-PEER PRNG lane choice (user): braidLaneAlt draws from each
  racer's own label-derived stream rngFamily(seed, 'lane/idx/seg') —
  independent per peer like pilot personalities; the pack visibly
  splits ~50/50 at forks and rejoins at merges. Physical recovery:
  a shoved marble adopts whichever lane's rail is >0.6u nearer.
  Riders/promotion/respawns are lane-aware; the respawn branch flip
  exempts braid segments (that toggle stays for diamonds/spurs).
- Junction proxies: one flow-symmetric composite for fork AND merge
  (spur junction minus the hole) — deck, far wall, mouth stubs, side
  walls out to the branch port where the alt ribbon takes over. The
  real wedge hazards were ADJACENT vertical-dip pieces: braid spans
  exclude wave/s-curve families near junctions and forks veto non-flat
  approaches.
- Merge law derived: merge = the split mesh z-flipped == rotation+2
  with mirror inverted (asserted in tests). LoopSegment gains
  alt.braid + braidMerge flags.
- Latent M16 fix exposed by the reshuffle: geometry could project
  axially into a reorient window and present mid-roll (world seam a
  marble fell through). Twist windows now claim their flanks and
  world.ts rejects any non-run geometry inside the drum.
- Headless: both lanes of a pinned braid drive clean end to end;
  36-seed invariant scan (fork/merge laws, walk continuity, breadth
  bounds, C0 alt chain, junction proxies); lane-law unit tests
  (determinism, per-racer independence, 30-70 splits). 32-drive stress
  corpus across 16 braid seeds all clean.
- Streams reshuffled: goldens regenerated; scenario pins re-scanned
  (jump-scan-25/grav-scan-33 reorients, jump-scan-12 spur, helix-scan-6
  propeller, grav-scan-25 jump, grav-scan-9 diamond, grav-scan-6
  helix). genVersion unchanged (finite untouched).
162 tests, check clean. Verified in-browser on #marbles: pack rides
through the framed fork, alt lane wanders and rejoins.
commit
7bf56ca6bae407c3ee91af1be0de550cbc41be9b
parent
d255a424051d6c4e8b7561cb7a3a8233a7ba43af
author
Dami <dami.etoile@gmail.com>
14 files changed, +1441 -122
Msrc/client/endless.ts137+++++++++-
Msrc/client/physics/autopilot.ts15+
Msrc/client/physics/rail.ts31++
Msrc/gen/endless/path.ts288+++++++++++++++++++-
Msrc/gen/endless/proxies.ts139+++++++++-
Msrc/gen/endless/world.ts243++++++++++++++++-
Msrc/shared/frame.ts8+-
Msrc/shared/types.ts19+-
Atests/braid_test.ts441++++++++++++++++++++++++++++++
Mtests/golden_endless.json92+++---
Mtests/physics_test.ts81++++--
Mtests/reorient_test.ts21+-
Mtests/scenario_test.ts33+-
Mtests/spur_test.ts15+-
M src/client/endless.ts +129 -8
@@ -34,6 +34,7 @@ import {
3434 attachFrames,3535 gapSegmentsOf,3636 hasAltBranch,37+ hasBraid,3738 type Rail,3839 railForSegment,3940 railFrameAt,
@@ -47,6 +48,7 @@ import {
4748 autopilotForce,4849 type BoostState,4950 boostStep,51+ braidLaneAlt,5052 FULL_TANK,5153 type Neighbor,5254 type Pilot,
@@ -379,6 +381,14 @@ interface Racer {
379381 pivot: THREE.Object3D | null;380382 materials: THREE.Material[];381383 nearest: { seg: number; idx: number };384+ /** Braid lane (M17): when `laneBraid` names the racer's CURRENT braid385+ * segment and `laneAlt` is set, its rail is that segment's alt rail —386+ * the pack visibly splits and rejoins. Choice is per-racer determinism387+ * (braidLaneAlt — each racer's own label-derived stream); collisions388+ * that physically shove a marble onto the other lane flip it there389+ * (decide()'s recovery law). */390+ laneAlt: boolean;391+ laneBraid: number;382392 s: number;383393 speed: number;384394 simT: number;
@@ -491,6 +501,13 @@ class Session {
491501 private readonly instGlow = new THREE.Color();492502 private readonly glowBlue = new THREE.Color(0x66aaff);493503 private readonly rails = new Map<number, Rail>();504+ /** Braid alt-lane rails (M17): built alongside `rails` for every505+ * segment carrying an alt branch; per-racer lane choice picks between506+ * them on braid segments (railFor). */507+ private readonly altRails = new Map<number, Rail>();508+ /** Built segment indices hosting a braid — the only segments where509+ * per-racer lane choice engages (diamonds/spurs keep branchOf). */510+ private readonly braidSegs = new Set<number>();494511 /** Fan wind volumes, binned per segment (M16) — fans push marbles. */495512 private readonly fanField = new FanField();496513 /** Scratch for the per-decision wind force (allocation-free). */
@@ -694,6 +711,8 @@ class Session {
694711 pivot: this.pivot,695712 materials: heroMaterials,696713 nearest: { seg: 0, idx: 0 },714+ laneAlt: false,715+ laneBraid: -1,697716 s: 0,698717 speed: 0,699718 simT: 0,
@@ -850,6 +869,8 @@ class Session {
850869 pivot,851870 materials,852871 nearest: { seg: 0, idx: at },872+ laneAlt: false,873+ laneBraid: -1,853874 s: rail.s[at],854875 speed: 0,855876 simT: 0,
@@ -1140,6 +1161,9 @@ class Session {
11401161 gapSegmentsOf(data),11411162 wideScalesOf(data),11421163 ),1164+ // Braid alt LANE (M17): a plain channel between the junction1165+ // composites — its ribbon meets their open mouths at the ports.1166+ ...(data.altPath ? channelProxies(data.altPath, true) : []),11431167 ...data.islands.flatMap((isle) => islandProxies(isle.placements)),11441168 ...bumperProxies(data.placements),11451169 ];
@@ -1153,6 +1177,14 @@ class Session {
11531177 const rail = railForSegment(data, this.branchOf.get(i) === "alt");11541178 if (fr) attachFrames(rail, fr);11551179 this.rails.set(i, rail);1180+ // Both rails live for alt-carrying segments (M17): per-racer lane1181+ // choice needs the other lane resident, not rebuilt on demand.1182+ if (hasAltBranch(data)) {1183+ const altRail = railForSegment(data, true);1184+ if (fr) attachFrames(altRail, fr);1185+ this.altRails.set(i, altRail);1186+ if (hasBraid(data)) this.braidSegs.add(i);1187+ }11561188 // Fan wind capsules (M16): derived from the fan placements —11571189 // deterministic, no rng. Ledge fans face the channel, so their11581190 // capsules cross it (lateral gusts); wind-hinted fans carry their
@@ -1309,6 +1341,8 @@ class Session {
13091341 this.spawning.delete(seg.index);13101342 this.engine?.removeSegment(seg.index);13111343 this.rails.delete(seg.index);1344+ this.altRails.delete(seg.index);1345+ this.braidSegs.delete(seg.index);13121346 this.fanField.removeSegment(seg.index);13131347 // Sink away rather than pop out; the group leaves the scene when the13141348 // exit wave finishes. (Occupancy claims release much later — the
@@ -1525,7 +1559,7 @@ class Session {
15251559 const engine = this.engine;15261560 if (!engine) return;15271561 const seg = this.segmentAt(r.s);1528- const rail = seg ? this.rails.get(seg.index) : undefined;1562+ const rail = seg ? this.railFor(r, seg.index) : undefined;15291563 if (!seg || !rail) return;15301564 const local = r.s - seg.startS;15311565 let idx = 0;
@@ -1568,7 +1602,7 @@ class Session {
15681602 r.s += r.speed * dt;15691603 const seg = this.segmentAt(r.s);15701604 if (!seg) return;1571- const rail = this.rails.get(seg.index);1605+ const rail = this.railFor(r, seg.index);15721606 if (!rail) return;15731607 const local = r.s - seg.startS;15741608 let idx = r.nearest.seg === seg.index ? r.nearest.idx : 0;
@@ -1599,13 +1633,14 @@ class Session {
15991633 return;16001634 }16011635 this.updateNearestFor(r, r.pos);1602- const rail = this.rails.get(r.nearest.seg);1636+ this.braidLaneFor(r);1637+ const rail = this.railFor(r, r.nearest.seg);16031638 if (!rail) {16041639 r.cachedF = null;16051640 return;16061641 }16071642 engine.setRestitution(r.idx, stationRestitution(rail, r.nearest.idx));1608- const nextRail = this.rails.get(r.nearest.seg + 1) ?? null;1643+ const nextRail = this.railFor(r, r.nearest.seg + 1) ?? null;16091644 // M16 conjugation: on a reoriented segment the control laws run in16101645 // PLAN space — marble state maps in through the station frame, the16111646 // decided force maps back out. The autopilot never sees the wall.
@@ -1863,8 +1898,10 @@ class Session {
18631898 }18641899 }18651900 const seg = this.built.get(r.nearest.seg);1866- const rail = this.rails.get(r.nearest.seg);1901+ const rail = this.railFor(r, r.nearest.seg);18671902 if (seg && rail) {1903+ // Alt-lane s runs on the alt rail's own arc length (≈ main ±1904+ // wander) — leaderboard tolerance is fine (brief rule).18681905 r.s = Math.max(r.s, seg.startS + rail.s[r.nearest.idx]);18691906 }18701907
@@ -2000,7 +2037,7 @@ class Session {
20002037 // rail (mid-cull, mid-respawn) keeps the last known up — a one-frame20012038 // flip to world-up read as "snap to a default angle, float back"20022039 // (user report).2003- const fRail = this.rails.get(followed.nearest.seg);2040+ const fRail = this.railFor(followed, followed.nearest.seg);20042041 if (fRail) {20052042 const fFrame = railFrameAt(fRail, followed.nearest.idx);20062043 if (fFrame) frameUp(fFrame, this.camUpTarget);
@@ -2120,6 +2157,10 @@ class Session {
21202157 const h = (((r.idx * 2654435761) >>> 0) % 1024) / 1024;21212158 const heroS = this.racers[0]?.s ?? this.marbleS;21222159 const at = Math.max(lo, Math.min(hi, heroS + (h - 0.7) * span));2160+ // Fresh lane state: the respawn seats on the MAIN rail; if it lands2161+ // inside a braid, the next decide re-draws the (deterministic) lane.2162+ r.laneAlt = false;2163+ r.laneBraid = -1;21232164 const seg = this.segmentAt(at);21242165 const rail = seg ? this.rails.get(seg.index) : undefined;21252166 if (!seg || !rail) return;
@@ -2256,6 +2297,82 @@ class Session {
22562297 return { obj, pivot, materials, baseEmissive };22572298 }22582299 2300+ /** The rail a racer rides in a segment: the alt lane on its chosen2301+ * braid segment, the main (branchOf-resolved) rail everywhere else.2302+ * r.nearest.idx always indexes into THIS rail — every dereference of a2303+ * racer's station must come through here. */2304+ private railFor(r: Racer, segIdx: number): Rail | undefined {2305+ if (r.laneAlt && r.laneBraid === segIdx) {2306+ return this.altRails.get(segIdx) ?? this.rails.get(segIdx);2307+ }2308+ return this.rails.get(segIdx);2309+ }2310+2311+ /** Braid lane law (M17), run each decision: entering a braid segment2312+ * draws the racer's own deterministic lane (braidLaneAlt — per-racer2313+ * label-derived stream, hero included); and while inside one, a marble2314+ * that collisions have physically shoved onto the OTHER lane re-picks2315+ * it (the other rail's nearest station clearly closer) — the rail2316+ * follows the marble, never fights it. */2317+ private braidLaneFor(r: Racer): void {2318+ const segIdx = r.nearest.seg;2319+ if (!this.braidSegs.has(segIdx)) return;2320+ if (r.laneBraid !== segIdx) {2321+ r.laneBraid = segIdx;2322+ const want = braidLaneAlt(this.seed, r.idx, segIdx);2323+ if (want !== r.laneAlt) {2324+ r.laneAlt = want;2325+ // Re-anchor on the lane rail (pre-fork stations coincide, so the2326+ // carried index is a valid scan anchor).2327+ this.updateNearestFor(r, r.pos);2328+ }2329+ }2330+ // Recovery: compare against the other lane near the same local s.2331+ const cur = this.railFor(r, segIdx);2332+ const other = r.laneAlt2333+ ? this.rails.get(segIdx)2334+ : this.altRails.get(segIdx);2335+ if (!cur || !other || other === cur) return;2336+ const cp = (cur.worldPts ?? cur.pts)[r.nearest.idx];2337+ if (!cp) return;2338+ const dCur = Math.hypot(2339+ cp[0] - r.pos[0],2340+ cp[1] - r.pos[1],2341+ cp[2] - r.pos[2],2342+ );2343+ const sLocal = cur.s[r.nearest.idx];2344+ let oi = 0;2345+ let hi = other.s.length;2346+ while (oi < hi) {2347+ const mid = (oi + hi) >> 1;2348+ if (other.s[mid] < sLocal) oi = mid + 1;2349+ else hi = mid;2350+ }2351+ const pts = other.worldPts ?? other.pts;2352+ let bestIdx = Math.min(oi, pts.length - 1);2353+ let bestD = Infinity;2354+ for (2355+ let i2 = Math.max(0, oi - 24);2356+ i2 <= Math.min(pts.length - 1, oi + 24);2357+ i2++2358+ ) {2359+ const p = pts[i2];2360+ const d = Math.hypot(2361+ p[0] - r.pos[0],2362+ p[1] - r.pos[1],2363+ p[2] - r.pos[2],2364+ );2365+ if (d < bestD) {2366+ bestD = d;2367+ bestIdx = i2;2368+ }2369+ }2370+ if (bestD + 0.6 < dCur) {2371+ r.laneAlt = !r.laneAlt;2372+ r.nearest = { seg: segIdx, idx: bestIdx };2373+ }2374+ }2375+22592376 /** Nearest rail station to a racer, advancing across segments. */22602377 private updateNearestFor(22612378 r: Racer,
@@ -2270,7 +2387,7 @@ class Session {
22702387 let bestIdx = idx;22712388 let bestD = Infinity;22722389 const consider = (sIdx: number, from: number, to: number) => {2273- const rail = this.rails.get(sIdx);2390+ const rail = this.railFor(r, sIdx);22742391 if (!rail) return;22752392 // Reoriented rails compare in PRESENTED world space (worldPts is22762393 // the frame-applied cache); identity rails' pts ARE world.
@@ -2327,8 +2444,10 @@ class Session {
23272444 }23282445 // Respawn variety (M11): if the checkpoint segment carries a diamond,23292446 // the retry rides the OTHER branch — the fall becomes a reveal.2447+ // Braid segments are exempt (M17): their lanes are PER-RACER choices2448+ // (braidLaneFor), and flipping the shared rail would fight them.23302449 const data = this.world.get(cp.index);2331- if (hasAltBranch(data)) {2450+ if (hasAltBranch(data) && !hasBraid(data)) {23322451 const next = this.branchOf.get(cp.index) === "alt" ? "main" : "alt";23332452 this.branchOf.set(cp.index, next);23342453 const swapped = railForSegment(data, next === "alt");
@@ -2356,6 +2475,8 @@ class Session {
23562475 const hero = this.racers[0];23572476 if (hero) {23582477 hero.nearest = { seg: cp.index, idx: 0 };2478+ hero.laneAlt = false;2479+ hero.laneBraid = -1;23592480 hero.s = cp.startS;23602481 hero.lastProgressS = cp.startS;23612482 hero.stallClock = 0;
M src/client/physics/autopilot.ts +15 -0
M src/client/physics/rail.ts +31 -0
@@ -181,6 +181,11 @@ const ZONE_MODS: Record<string, RailMod> = {
181181 // branch mouth (the composite opens one wall there) and branch riders182182 // square on the diving board.183183 spur: { center: true },184+ // Braid fork/merge junctions (M17): same law — main-lane marbles stay185+ // out of the open branch mouths, alt riders hold their pull-off line.186+ // (The fork window's mod rides the WHOLE alt chain for alt riders —187+ // centering keeps them on the wandering lane.)188+ braid: { center: true },184189 // Reorient run (M16): the channel corkscrews around the centerline —185190 // plan-space centering (rotated to world by the frame) holds the186191 // marble on the line while the floor rolls under it.
@@ -212,6 +217,13 @@ export function hasAltBranch(seg: RailSource): boolean {
212217 return seg.path.segments.some((ls) => ls.alt !== undefined);213218 }214219 220+/** True if the segment hosts a braid (M17). Braid segments use PER-RACER221+ * lane choice — the client's global respawn branch-flip (branchOf) must222+ * ignore them (that machinery stays for diamonds/spurs). */223+export function hasBraid(seg: RailSource): boolean {224+ return seg.path.segments.some((ls) => ls.alt?.braid === true);225+}226+215227 /** Dense rail for one segment (local s from 0). `useAlt` rides every216228 * diamond's OTHER branch — the respawn-variety rail. A takeable SPUR's217229 * alt ends diving through the hole; the stations then SKIP the main
@@ -224,6 +236,10 @@ export function railForSegment(seg: RailSource, useAlt = false): Rail {
224236 let acc = 0;225237 /** Set after a spur dive: skip windows until one passes this point. */226238 let resumeNear: readonly [number, number, number] | null = null;239+ /** Set after a braid alt chain (which runs through to the MERGE exit240+ * port): skip the main windows underneath the braid until the window241+ * that STARTS at that port — ports are exact, so the match is tight. */242+ let resumeAtStart: readonly [number, number, number] | null = null;227243 const approaches = (228244 curve: readonly LoopPath["segments"][number]["curve"][number][],229245 p: readonly [number, number, number],
@@ -235,6 +251,17 @@ export function railForSegment(seg: RailSource, useAlt = false): Rail {
235251 })236252 );237253 for (const [lsIdx, ls] of seg.path.segments.entries()) {254+ if (resumeAtStart) {255+ const p0 = segPoint(ls.curve[0], 0);256+ if (257+ Math.hypot(258+ p0[0] - resumeAtStart[0],259+ p0[1] - resumeAtStart[1],260+ p0[2] - resumeAtStart[2],261+ ) > 0.3262+ ) continue;263+ resumeAtStart = null;264+ }238265 if (resumeNear) {239266 if (!approaches(ls.curve, resumeNear)) continue;240267 resumeNear = null;
@@ -262,6 +289,10 @@ export function railForSegment(seg: RailSource, useAlt = false): Rail {
262289 const lastPart = ls.alt!.curve[ls.alt!.curve.length - 1];263290 const tip = segPoint(lastPart, 1);264291 resumeNear = [tip[0], tip[1], tip[2]];292+ } else if (alt && ls.alt!.braid) {293+ const lastPart = ls.alt!.curve[ls.alt!.curve.length - 1];294+ const tip = segPoint(lastPart, 1);295+ resumeAtStart = [tip[0], tip[1], tip[2]];265296 }266297 }267298 return rail;
M src/gen/endless/path.ts +286 -2
@@ -34,6 +34,8 @@ import {
3434 import {3535 applyXform,3636 DIR_STEP,37+ moveLabel,38+ opposite,3739 turnLeft,3840 turnRight,3941 windowXform,
@@ -153,7 +155,8 @@ export interface PlanMod {
153155 | "icy"154156 | "gravity"155157 | "spur"156- | "reorient";158+ | "reorient"159+ | "braid";157160 /** Reorient only: signed roll (radians) about the run's travel axis —158161 * everything downstream presents rotated (wall/ceiling riding). */159162 angle?: number;
@@ -178,6 +181,26 @@ export interface SpurDrop {
178181 cap: "end-hole-rounded" | "end-hole-square";179182 }180183 184+/** Braided path (M17): the line forks into two genuine lanes that wander185+ * apart and merge back `len` cells later. The MAIN lane is `len` straight186+ * cells of the segment walk; cell `start` is the FORK host and cell187+ * `start + len - 1` the MERGE host (both pinned 1-cell straights —188+ * world.ts mutates them straight → split, spur-style). The ALT lane is a189+ * real OpenCell sub-walk beside the main lane (offset 1..breadth on190+ * `side`), NOT part of plan.cells — it realizes as its own sub-skeleton191+ * and hangs on the fork window's LoopSegment.alt. */192+export interface Braid {193+ /** Cell index of the fork host. */194+ start: number;195+ /** Main-lane length in cells (merge host = start + len − 1). */196+ len: number;197+ /** World dir from the main lane toward the alt side. */198+ side: Dir;199+ /** The alt lane as a real adjacent-cell walk, fork branch cell →200+ * merge branch cell, flat at the fork's baseY. */201+ altCells: OpenCell[];202+}203+181204 export interface SegmentPlan {182205 index: number;183206 cells: OpenCell[];
@@ -185,6 +208,8 @@ export interface SegmentPlan {
185208 mods: PlanMod[];186209 /** Takeable spur drops (local host/receiver cell indices). */187210 spurs: SpurDrop[];211+ /** Braided paths (fork/merge host cell indices + the alt sub-walk). */212+ braids: Braid[];188213 /** Slope/set-piece windows (local indices) — realize commits them as-is. */189214 reservations: Reservation[];190215 crossings: IslandCrossing[];
@@ -243,8 +268,10 @@ export function planSegment(
243268 state: HeadState,244269 occ: OccView,245270 registry: PieceRegistry,271+ braidBias = 1,246272 ): { plan: SegmentPlan; next: HeadState } {247- return new SegmentBuilder(rng, index, state, occ, registry).build();273+ return new SegmentBuilder(rng, index, state, occ, registry, braidBias)274+ .build();248275 }249276 250277 class SegmentBuilder {
@@ -252,6 +279,7 @@ class SegmentBuilder {
252279 private reservations: Reservation[] = [];253280 private mods: PlanMod[] = [];254281 private spurs: SpurDrop[] = [];282+ private braids: Braid[] = [];255283 private crossings: IslandCrossing[] = [];256284 private pose: HeadPose;257285 private sinceAnchor: number;
@@ -266,6 +294,10 @@ class SegmentBuilder {
266294 state: HeadState,267295 private readonly occ: OccView,268296 private readonly registry: PieceRegistry,297+ /** Per-seed breadth knob (1..3, drawn once by the world from298+ * rngFamily(seed, "braid-breadth")) — some seeds are tangly, some299+ * clean. Caps how far a braid's alt lane may wander out. */300+ private readonly braidBias: number = 1,269301 ) {270302 this.pose = { ...state.pose };271303 this.sinceAnchor = state.sinceAnchor;
@@ -311,11 +343,51 @@ class SegmentBuilder {
311343 }312344 }313345 346+ // Reorient window veto: the twist's presentation field is a pure347+ // axial blend across an infinite slab (frame.ts twistParam) — any348+ // OTHER geometry of this segment inside the open window would349+ // present mid-roll: continuous within the segment, but the boundary350+ // cells then carry a partial roll against the neighbor segment's351+ // exact entry/exit frame and the world tears at the seam (a marble352+ // fell through one). The whole attempt is discarded; the caller353+ // retries under a fresh label.354+ if (this.twistWindow) {355+ const { px, pz, dx, dz, start, len } = this.twistWindow;356+ const bad = (x: number, z: number): void => {357+ const a = (x - px) * dx + (z - pz) * dz;358+ if (a > 0.01 && a < len - 0.01) {359+ throw new PlannerStuck(360+ `segment ${this.index}: geometry at (${x},${z}) inside the ` +361+ `reorient window`,362+ );363+ }364+ };365+ for (const [i, c] of this.cells.entries()) {366+ if (i >= start && i < start + len) continue;367+ bad(c.x, c.z);368+ }369+ for (const b of this.braids) {370+ for (const c of b.altCells) bad(c.x, c.z);371+ }372+ for (const s of this.spurs) {373+ const c = this.cells[s.host];374+ const [wx, wz] = DIR_STEP[s.dir];375+ bad(c.x + wx, c.z + wz);376+ }377+ for (const cr of this.crossings) {378+ for (const [x, z] of cr.deckCells ?? []) bad(x, z);379+ for (const [x, z] of cr.branchCells ?? []) bad(x, z);380+ for (const [x, z] of cr.wideFlanks ?? []) bad(x, z);381+ for (const sc of cr.sideCells ?? []) bad(sc.cell[0], sc.cell[1]);382+ }383+ }384+314385 const plan: SegmentPlan = {315386 index: this.index,316387 cells: this.cells,317388 mods: this.mods,318389 spurs: this.spurs,390+ braids: this.braids,319391 reservations: this.reservations,320392 crossings: this.crossings,321393 entry: this.entry,
@@ -437,6 +509,7 @@ class SegmentBuilder {
437509 ["gravity", 0.5],438510 ["spurDrop", 0.35],439511 ["reorient", 0.45],512+ ["braid", 0.5],440513 ] as const,441514 );442515 const attempts: (() => boolean)[] = [];
@@ -495,6 +568,8 @@ class SegmentBuilder {
495568 return this.trySpurDrop(rng);496569 case "reorient":497570 return this.tryReorient(rng);571+ case "braid":572+ return this.tryBraid(rng);498573 }499574 for (const attempt of attempts) if (attempt()) return true;500575 return false;
@@ -950,6 +1025,157 @@ class SegmentBuilder {
9501025 return true;9511026 }9521027 1028+ /**1029+ * BRAIDED PATH (M17, USER FEATURE): the line forks into TWO genuine1030+ * lanes that wander apart and merge back L cells later — parts of the1031+ * stream read as a winding tangle, and per-racer lane choice makes the1032+ * pack visibly split and rejoin. V1 scope: flat (one y), void-only1033+ * (this is a void clause), one braid per segment, MAIN lane straight,1034+ * ALT lane wandering beside it.1035+ *1036+ * Layout: L = 8..14 straight main cells; cell 0 is the FORK host and1037+ * cell L−1 the MERGE host, both pinned to 1-cell straight windows1038+ * (world.ts mutates them straight → split, spur-style). The alt lane1039+ * is a REAL OpenCell walk beside the main lane at lateral offset1040+ * o ∈ [1, B] (B from the per-seed breadth knob): F cells while the1041+ * offset holds, turn PAIRS to shift a lane (each offset change spends1042+ * two walk cells — diagonal adjacency is impossible by construction).1043+ * The walk enters from the fork's branch mouth (heading sideways,1044+ * turning into travel) and leaves turning back INTO the merge's1045+ * branch mouth aligned with the arriving lane. Alt cells claim their1046+ * own channel bands; they are a SUB-skeleton on the Braid record, not1047+ * part of plan.cells.1048+ */1049+ private hadBraid = false;1050+ private tryBraid(rng: Rng): boolean {1051+ if (this.hadBraid) return false;1052+ // Flat approach runway: a fork at the crest of a climb receives its1053+ // marbles at near-zero speed and they park on the junction lip1054+ // (grav-scan-21's alt rider stalled against the deck edge at the top1055+ // of a 1.0 slant). The 2 cells behind the fork must be plain flat1056+ // cells at the braid's level — reserved windows (slopes, set-pieces)1057+ // and level changes veto the clause. Segment starts are safe by1058+ // construction (the boundary margin is a flat 2-cell straight).1059+ const at = this.cells.length;1060+ for (let k = Math.max(0, at - 2); k < at; k++) {1061+ if (this.cells[k].baseY !== this.pose.y) return false;1062+ }1063+ if (1064+ this.reservations.some((r) => r.start < at && r.start + r.len > at - 2)1065+ ) return false;1066+ const L = rng.int(8, 15);1067+ // Per-braid breadth from the per-seed bias: bias 1 → always lane 11068+ // (a clean parallel siding); bias 3 → up to three lanes out.1069+ const B = Math.min(3, 1 + Math.floor(this.braidBias * rng.float()));1070+ const sides: ("L" | "R")[] = rng.chance(0.5) ? ["L", "R"] : ["R", "L"];1071+ for (const side of sides) {1072+ if (this.tryBraidSide(rng.child(`side/${side}`), side, L, B)) {1073+ return true;1074+ }1075+ }1076+ return false;1077+ }1078+1079+ private tryBraidSide(1080+ rng: Rng,1081+ side: "L" | "R",1082+ L: number,1083+ B: number,1084+ ): boolean {1085+ const d = this.pose.dir;1086+ const y0 = this.pose.y;1087+ const [dx, dz] = DIR_STEP[d];1088+ const w = side === "L" ? turnLeft(d) : turnRight(d);1089+ const [wx, wz] = DIR_STEP[w];1090+ const steps = this.straightSteps(L);1091+ const probed = this.probe(steps);1092+ if (!probed) return false;1093+ const fork = probed.cells[0];1094+1095+ // Offset profile per main column j = 1..L−2: starts and ends at 11096+ // (the fork/merge branch mouths), wanders in [1, B], |Δ| ≤ 1, with1097+ // the return-to-1 budget enforced ahead of time.1098+ const pRng = rng.child("profile");1099+ const o = new Array<number>(L).fill(1);1100+ for (let j = 2; j <= L - 3; j++) {1101+ const maxHere = Math.min(B, 1 + (L - 2 - j));1102+ const choices: number[] = [];1103+ for (const step of [-1, 0, 1]) {1104+ const v = o[j - 1] + step;1105+ if (v >= 1 && v <= maxHere) choices.push(v);1106+ }1107+ o[j] = pRng.pick(choices);1108+ }1109+1110+ // The alt walk (see the clause comment for the move law).1111+ const cellAt = (j: number, off: number): readonly [number, number] => [1112+ fork.x + dx * j + wx * off,1113+ fork.z + dz * j + wz * off,1114+ ];1115+ const altCells: OpenCell[] = [];1116+ const emit = (j: number, off: number, dirIn: Dir, dirOut: Dir): void => {1117+ const [x, z] = cellAt(j, off);1118+ altCells.push({1119+ x,1120+ z,1121+ dirIn,1122+ dirOut,1123+ move: moveLabel(dirIn, dirOut),1124+ baseY: y0,1125+ });1126+ };1127+ // Branch cell beside the fork: enter sideways, turn into travel.1128+ emit(0, 1, w, d);1129+ for (let j = 1; j <= L - 2; j++) {1130+ const next = j === L - 2 ? 1 : o[j + 1];1131+ if (next === o[j]) {1132+ emit(j, o[j], d, d);1133+ } else if (next > o[j]) {1134+ emit(j, o[j], d, w);1135+ emit(j, next, w, d);1136+ } else {1137+ emit(j, o[j], d, opposite(w));1138+ emit(j, next, opposite(w), d);1139+ }1140+ }1141+ // Branch cell beside the merge: turn back into the branch mouth.1142+ emit(L - 1, 1, d, opposite(w));1143+1144+ // Alt claims must be free (disjoint from the main lane by offset ≥ 11145+ // construction), and the exit corridor must survive BOTH lanes.1146+ for (const c of altCells) {1147+ if (!this.occ.isFree(c.x, c.z, y0, y0 + CHANNEL_CLEARANCE)) {1148+ return false;1149+ }1150+ }1151+ const pending = new Set([1152+ ...probed.cells.map((p) => key(p.x, p.z)),1153+ ...altCells.map((c) => key(c.x, c.z)),1154+ ]);1155+ if (!hasFlatContinuation(this.occ, probed.exit, 3, pending)) return false;1156+1157+ const start = this.cells.length;1158+ if (!this.commitSteps(steps)) return false;1159+ for (const c of altCells) {1160+ this.occ.claim(c.x, c.z, y0, y0 + CHANNEL_CLEARANCE, this.ref);1161+ }1162+ // Pin fork + merge to plain 1-cell straights (their placements mutate1163+ // straight → split in world.ts; the junction composites assume a bare1164+ // straight window). The braid zone marks both: rail centering keeps1165+ // main-lane marbles out of the open branch mouths.1166+ this.reservations.push(1167+ { start, len: 1, pieceIds: ["straight"], reversed: false },1168+ { start: start + L - 1, len: 1, pieceIds: ["straight"], reversed: false },1169+ );1170+ this.mods.push(1171+ { start, len: 1, kind: "braid" },1172+ { start: start + L - 1, len: 1, kind: "braid" },1173+ );1174+ this.braids.push({ start, len: L, side: w, altCells });1175+ this.hadBraid = true;1176+ return true;1177+ }1178+9531179 /**9541180 * GRAVITY REORIENTATION RUN (M16 — the real gravity feature, USER9551181 * HIGHLIGHT): a straight void run where the whole stream ROLLS about
@@ -964,6 +1190,22 @@ class SegmentBuilder {
9641190 * ribbon itself, corkscrewing between the two frames.9651191 */9661192 private hadReorient = false;1193+ /** Committed reorient window (track units): entry-port pivot + axis +1194+ * cell range. build() vetoes any OTHER geometry of this segment that1195+ * projects axially into the open window — the presentation field is a1196+ * pure axial blend (frame.ts twistParam), so anything else inside the1197+ * slab would present mid-roll and tear a seam at the segment1198+ * boundaries (grav-scan-13's post-twist spur hook did exactly that). */1199+ private twistWindow:1200+ | {1201+ px: number;1202+ pz: number;1203+ dx: number;1204+ dz: number;1205+ start: number;1206+ len: number;1207+ }1208+ | null = null;9671209 private tryReorient(rng: Rng): boolean {9681210 // One per segment (the presentation frame carries a single twist);9691211 // hero spawn runway: never open the very first segment mid-roll.
@@ -985,8 +1227,50 @@ class SegmentBuilder {
9851227 // channel band.9861228 band: [-0.75, CHANNEL_CLEARANCE] as const,9871229 }));1230+ const probed = this.probe(steps);1231+ if (!probed) return false;1232+ // FLANK COLUMNS: the roll's swept drum reaches laterally past the1233+ // cell (wall tips ~±0.72 track mid-roll), and the frame law's roll1234+ // blend owns the run's corridor (frame.ts TWIST_LAT_MAX) — claim1235+ // both flanks of every run cell so no other line rides beside the1236+ // roll. A later clause doubling back through a flank projected1237+ // axially INTO the twist window and its channel presented mid-roll,1238+ // tearing a world seam a marble fell through (grav-scan-13).1239+ const flanks: [number, number][] = [];1240+ for (const p of probed.cells) {1241+ for (const side of [turnLeft(this.pose.dir), turnRight(this.pose.dir)]) {1242+ const [sx, sz] = DIR_STEP[side];1243+ const fx = p.x + sx;1244+ const fz = p.z + sz;1245+ if (!this.occ.isFree(fx, fz, p.y - 0.75, p.y + CHANNEL_CLEARANCE)) {1246+ return false;1247+ }1248+ flanks.push([fx, fz]);1249+ }1250+ }1251+ const pendingBlock = new Set([1252+ ...probed.cells.map((p) => key(p.x, p.z)),1253+ ...flanks.map(([fx, fz]) => key(fx, fz)),1254+ ]);1255+ if (!hasFlatContinuation(this.occ, probed.exit, 3, pendingBlock)) {1256+ return false;1257+ }1258+ const dir = this.pose.dir;1259+ const [ddx, ddz] = DIR_STEP[dir];9881260 const start = this.cells.length;9891261 if (!this.commitSteps(steps)) return false;1262+ const y0 = probed.cells[0].y;1263+ for (const [fx, fz] of flanks) {1264+ this.occ.claim(fx, fz, y0 - 0.75, y0 + CHANNEL_CLEARANCE, this.ref);1265+ }1266+ this.twistWindow = {1267+ px: probed.cells[0].x - ddx / 2,1268+ pz: probed.cells[0].z - ddz / 2,1269+ dx: ddx,1270+ dz: ddz,1271+ start,1272+ len,1273+ };9901274 this.mods.push({ start, len, kind: "reorient", angle });9911275 // Pin every run cell to a bare straight: the twist ribbon assumes9921276 // the centerline — no wave/arch/funnel may realize under the roll.
M src/gen/endless/proxies.ts +137 -2
@@ -13,7 +13,7 @@
1313 * Shared by the client driver and the headless survivability tests —1414 * one sampling law, one truth.1515 */16-import type { LoopPath, Placement } from "../../shared/types.ts";16+import type { CurveSeg, LoopPath, Placement } from "../../shared/types.ts";1717 import { segLength, segPoint } from "../geom.ts";1818 import { TRACK_SCALE } from "./scale.ts";1919 import {
@@ -306,7 +306,10 @@ export function channelProxies(
306306 seg.curve,307307 gapSegments?.has(segIdx) ?? false,308308 stations,309- seg.alt !== undefined,309+ // Composite-carried windows: diamonds/spurs (alt) and both braid310+ // junctions (the fork carries the alt, the merge its own flag) —311+ // the ribbon stops at their ports.312+ seg.alt !== undefined || seg.braidMerge === true,310313 wideScales?.get(segIdx) ?? null,311314 );312315 }
@@ -453,11 +456,143 @@ export function channelProxies(
453456 // composite instead: the branch deck with the hole OPEN.454457 for (const seg of path.segments) {455458 if (seg.alt?.spur) emitSpur(seg, out);459+ else if (seg.alt?.braid) continue; // braid junctions emitted below456460 else if (seg.alt) emitDiamond(seg, out);457461 }462+ // Braid junctions (M17): fork (alt.braid) and merge (braidMerge) share463+ // ONE composite shape — a straight junction deck whose branch mouth is464+ // OPEN on the alt side (the spur junction without the hole/board/cap).465+ // The alt LANE between them is a plain channel — the stream emits466+ // channelProxies over EndlessSegment.altPath separately, its ribbon467+ // meeting these mouths at the branch ports. The branch side comes from468+ // sampling the fork's alt chain just past each junction (the lane sits469+ // a full track cell out — the sign is unambiguous).470+ const forkSeg = path.segments.find((s) => s.alt?.braid);471+ if (forkSeg?.alt) {472+ const total = forkSeg.alt.length;473+ const near = pointAt(forkSeg.alt.curve, Math.min(2.5, total / 2));474+ const far = pointAt(forkSeg.alt.curve, Math.max(total - 2.5, total / 2));475+ emitBraidJunction(forkSeg, near, out);476+ const mergeSeg = path.segments.find((s) => s.braidMerge);477+ if (mergeSeg) emitBraidJunction(mergeSeg, far, out);478+ }458479 return out;459480 }460481 482+/** Point at arc length `s` along a curve chain (clamped to its ends). */483+function pointAt(484+ curve: readonly CurveSeg[],485+ s: number,486+): [number, number, number] {487+ let d = Math.max(0, s);488+ for (const part of curve) {489+ const len = segLength(part);490+ if (d <= len) {491+ const p = segPoint(part, len === 0 ? 0 : d / len);492+ return [p[0], p[1], p[2]];493+ }494+ d -= len;495+ }496+ const p = segPoint(curve[curve.length - 1], 1);497+ return [p[0], p[1], p[2]];498+}499+500+/** Slab composite for a braid junction window (fork AND merge — the501+ * shape is flow-symmetric). World geometry (the 1-cell host window is502+ * straight and flat): the split-junction deck with the branch mouth open503+ * toward `ref` (a point on the alt lane beside this junction), mouth504+ * side walls out to the branch PORT at the cell face — where the alt505+ * lane's own ribbon takes over — and wall stubs framing the mouth on the506+ * main line. Deck top a hair above the neighboring ribbon sheets (the507+ * spur/diamond law: arriving under a one-sided mesh edge means catching508+ * it forever). */509+function emitBraidJunction(510+ seg: LoopPath["segments"][number],511+ ref: readonly [number, number, number],512+ out: CollisionProxy[],513+): void {514+ const entry = segPoint(seg.curve[0], 0);515+ const lastPart = seg.curve[seg.curve.length - 1];516+ const exit = segPoint(lastPart, 1);517+ const dx = exit[0] - entry[0];518+ const dz = exit[2] - entry[2];519+ const L = Math.hypot(dx, dz);520+ if (L < 1e-6) return;521+ const yaw = Math.atan2(dx, dz);522+ const fwd: [number, number] = [dx / L, dz / L];523+ let lat: [number, number] = [fwd[1], -fwd[0]];524+ const floorY = entry[1];525+ // Orient +side toward the branch.526+ const mx = entry[0] + fwd[0] * (L / 2);527+ const mz = entry[2] + fwd[1] * (L / 2);528+ if ((ref[0] - mx) * lat[0] + (ref[2] - mz) * lat[1] < 0) {529+ lat = [-lat[0], -lat[1]];530+ }531+ const branchYaw = Math.atan2(lat[0], lat[1]);532+ const at = (533+ along: number,534+ side: number,535+ ): [number, number] => [536+ entry[0] + fwd[0] * along + lat[0] * side,537+ entry[2] + fwd[1] * along + lat[1] * side,538+ ];539+ const wall = (540+ a: readonly [number, number],541+ b: readonly [number, number],542+ ): void => {543+ const [ax, az] = at(a[0], a[1]);544+ const [bx, bz] = at(b[0], b[1]);545+ const wx = bx - ax;546+ const wz = bz - az;547+ const wlen = Math.hypot(wx, wz);548+ out.push({549+ kind: "slab",550+ center: [(ax + bx) / 2, floorY + 0.35, (az + bz) / 2],551+ half: [0.09, 0.4, wlen / 2],552+ yaw: Math.atan2(wx, wz),553+ pitch: 0,554+ });555+ };556+557+ const HW = 0.95; // main channel half width at the walls558+ const BR = 0.86; // branch channel half width (ribbon wall line)559+ const EDGE = L / 2 + 0.05; // branch port at the cell face (+ seal)560+ const deckY = floorY - FLOOR_HALF_T + 0.02;561+562+ // Junction deck (main lane), reaching to both ports.563+ {564+ const [cx, cz] = at(L / 2, 0);565+ out.push({566+ kind: "slab",567+ center: [cx, deckY, cz],568+ half: [HW, FLOOR_HALF_T, L / 2 + 0.05],569+ yaw,570+ pitch: 0,571+ });572+ }573+ // Branch mouth deck out to the port.574+ {575+ const s0 = HW - 0.15;576+ const [cx, cz] = at(L / 2, (s0 + EDGE) / 2);577+ out.push({578+ kind: "slab",579+ center: [cx, deckY, cz],580+ half: [BR, FLOOR_HALF_T, (EDGE - s0) / 2],581+ yaw: branchYaw,582+ pitch: 0,583+ });584+ }585+ // Main-lane walls: solid on the far side; mouth-framing stubs on the586+ // branch side (the neighbor ribbons' walls stop at the ports).587+ wall([-0.05, -HW], [L + 0.05, -HW]);588+ wall([-0.05, HW], [L / 2 - BR, HW]);589+ wall([L / 2 + BR, HW], [L + 0.05, HW]);590+ // Mouth side walls, junction wall line out to the branch port (the alt591+ // ribbon's own gutter walls continue from there).592+ wall([L / 2 - BR, HW], [L / 2 - BR, EDGE]);593+ wall([L / 2 + BR, HW], [L / 2 + BR, EDGE]);594+}595+461596 /** Slab composite for a split–merge diamond window. World geometry (the462597 * window is straight and flat): branches at lateral ±2, channel half463598 * width 0.92, diverge/merge cubics 4 world long each, N straights
M src/gen/endless/world.ts +237 -6
@@ -35,6 +35,7 @@ import {
3535 DIR_STEP,3636 segLength,3737 segPoint,38+ turnLeft,3839 turnRight,3940 windowXform,4041 } from "../geom.ts";
@@ -80,6 +81,12 @@ export interface EndlessSegment {
8081 * shape, but nothing wraps — the "loop" never closes). */8182 path: LoopPath;8283 islands: Island[];84+ /** Braid alt LANE in WORLD coordinates (M17): the sub-assembled open85+ * path over the braid's alt cells, branch port → branch port. The86+ * client (and the drive harness) append plain channelProxies over it —87+ * the alt lane is an ordinary channel; only the fork/merge junctions88+ * need composite treatment (proxies.ts). Absent = no braid here. */89+ altPath?: LoopPath;8390 /** Cumulative arc length of the line at this segment's entry. */8491 startS: number;8592 /** Presentation frame (M16 gravity reorientation): how this segment's
@@ -114,6 +121,10 @@ export class EndlessWorld {
114121 /** Cumulative presentation frame (M16) — reorient runs compose onto115122 * it; as seed-pure as nextStartS (generation is strictly forward). */116123 private frame: Affine = AFFINE_IDENTITY;124+ /** Per-seed braid breadth knob (M17): 1..3, drawn once — some seeds125+ * braid clean parallel sidings, some tangle three lanes out. The126+ * planner draws each braid's actual breadth under this cap. */127+ private readonly braidBias: number;117128 118129 constructor(seed: string, catalog: DecorCatalog = { packs: [] }) {119130 this.seed = seed;
@@ -121,6 +132,7 @@ export class EndlessWorld {
121132 this.registry = buildRegistry();122133 // Same style family as finite mode: one seed, one theme, both modes.123134 this.style = pickStyle(rngFamily(seed, "style"));135+ this.braidBias = 1 + rngFamily(seed, "braid-breadth").int(0, 3);124136 }125137 126138 /** Segment by index — generates forward as needed (never backward). */
@@ -186,6 +198,7 @@ export class EndlessWorld {
186198 state,187199 new OccupancyTx(base),188200 this.registry,201+ this.braidBias,189202 );190203 return true;191204 } catch (err) {
@@ -209,6 +222,9 @@ export class EndlessWorld {
209222 plan: ReturnType<typeof planSegment>["plan"];210223 next: ReturnType<typeof planSegment>["next"];211224 windows: ReturnType<typeof realize>;225+ /** Braid alt-lane windows (M17) — realized inside the same226+ * transaction; null when the plan carries no braid. */227+ altWindows: ReturnType<typeof realize> | null;212228 skeleton: Skeleton;213229 elevation: ElevationPlan;214230 tx: OccupancyTx;
@@ -232,7 +248,14 @@ export class EndlessWorld {
232248 const rng = label === "" ? segRng : segRng.child(label);233249 let planned;234250 try {235- planned = planSegment(rng, i, this.state, tx, this.registry);251+ planned = planSegment(252+ rng,253+ i,254+ this.state,255+ tx,256+ this.registry,257+ this.braidBias,258+ );236259 } catch (err) {237260 if (err instanceof PlannerStuck) continue;238261 throw err;
@@ -256,6 +279,17 @@ export class EndlessWorld {
256279 const noLateral: [number, number][] = plan.mods257280 .filter((m) => m.kind === "jump" || m.kind === "propeller")258281 .map((m) => [m.start - 3, m.start + m.len + 2]);282+ // Braid spans (M17) are flat runway: the kit's waves AND short283+ // s-curves DIP the channel vertically, and a dip against a284+ // junction port parks a slow marble on the crest at the deck lip285+ // (grav-scan-21's alt rider stalled exactly there — first on a286+ // wave, then on the s-curve the collapse picked instead). ±2 cells287+ // of margin keeps the approach/exit flat as well. Kept separate288+ // from the jump ranges so the braid rule can cover both families289+ // without touching the priced jump-approach behavior.290+ const braidFlat: [number, number][] = plan.braids.map(291+ (b) => [b.start - 2, b.start + b.len + 2],292+ );259293 // Bump lips: bumps re-admit ONLY fully inside a single boost zone260294 // (sprint runs are the only zones with ≥4 free flat cells) — the261295 // crest launch is priced by the zone's speed, and elsewhere the
@@ -289,7 +323,11 @@ export class EndlessWorld {
289323 ))) ||290324 (def.family === "wave" && noLateral.some(([a, b]) =>291325 start < b && a < start + def.moves.length292- )),326+ )) ||327+ ((def.family === "wave" || def.family === "s-curve") &&328+ braidFlat.some(([a, b]) =>329+ start < b && a < start + def.moves.length330+ )),293331 },294332 );295333 // Claim the extras into the transaction, then confirm the exit
@@ -315,6 +353,70 @@ export class EndlessWorld {
315353 }316354 }317355 }356+ // BRAID alt lane (M17): realize the alt sub-walk inside the SAME357+ // transaction (its footprint extras are world matter too — they358+ // must be claimed before the exit-corridor checks and the359+ // successor probe run). No boost zones exist on the alt lane, so360+ // bumps are excluded outright (the main realize's momentum rule).361+ let altWindows: ReturnType<typeof realize> | null = null;362+ if (plan.braids.length > 0) {363+ const braid = plan.braids[0];364+ const altSkeleton: Skeleton = {365+ cells: braid.altCells,366+ crossings: [],367+ helixLoops: [],368+ };369+ const altElevation: ElevationPlan = {370+ baseY: braid.altCells.map((c) => c.baseY),371+ reservations: [],372+ maxBaseY: Math.max(...braid.altCells.map((c) => c.baseY)),373+ };374+ try {375+ altWindows = realize(376+ rng.child("braid-wfc"),377+ altSkeleton,378+ altElevation,379+ this.style,380+ this.registry,381+ [],382+ {383+ open: true,384+ extraFree: (x, z, baseY) =>385+ tx.isFree(x, z, baseY, baseY + CHANNEL_CLEARANCE),386+ // No bumps (they demand crest momentum and the alt lane387+ // has no boost zones) and no waves/s-curves (vertical dips388+ // — the alt rider corners slowly, and a dip beside the389+ // junction mouths parks it; V1 alt lanes are flat-floored).390+ exclude: (def) =>391+ def.family === "bump" || def.family === "wave" ||392+ def.family === "s-curve",393+ },394+ );395+ } catch (_err) {396+ // A wedged alt collapse discards the whole attempt — same397+ // contract as a planner dead end, deterministic per seed.398+ continue;399+ }400+ for (const win of altWindows) {401+ const cell = braid.altCells[win.start];402+ const xf = windowXform(cell.x, cell.z, cell.dirIn, 0);403+ const windowSet = new Set<string>();404+ for (let j = 0; j < win.len; j++) {405+ const c = braid.altCells[win.start + j];406+ windowSet.add(key(c.x, c.z));407+ }408+ const baseY = cell.baseY;409+ for (const [fx, fz] of win.def.footprint) {410+ const w = applyXform(xf, [fx, 0, fz]);411+ const wx = Math.round(w[0]);412+ const wz = Math.round(w[2]);413+ if (windowSet.has(key(wx, wz))) continue;414+ if (tx.isFree(wx, wz, baseY, baseY + CHANNEL_CLEARANCE)) {415+ tx.claim(wx, wz, baseY, baseY + CHANNEL_CLEARANCE, `seg/${i}`);416+ }417+ }418+ }419+ }318420 // Exit viability, two stages. First the cheap one: a flat corridor319421 // must survive this attempt's claims (path, islands, canopy, WFC320422 // extras). Then the real one: PLAN THE SUCCESSOR as probes on a
@@ -326,7 +428,7 @@ export class EndlessWorld {
326428 // good attempt.327429 if (!hasFlatContinuation(tx, plan.exit, 6)) continue;328430 if (!this.successorPlans(i + 1, next, tx)) continue;329- result = { plan, next, windows, skeleton, elevation, tx };431+ result = { plan, next, windows, altWindows, skeleton, elevation, tx };330432 }331433 if (!result) {332434 throw new Error(
@@ -334,7 +436,7 @@ export class EndlessWorld {
334436 );335437 }336438 result.tx.commit();337- const { plan, next, windows, skeleton, elevation } = result;439+ const { plan, next, windows, altWindows, skeleton, elevation } = result;338440 // Pipe-color chapters (user rule: the pipe color is PER ISLAND): a339441 // cell belongs to the most recent island's chapter — islands opened340442 // in this segment take over from their run start; earlier cells
@@ -460,6 +562,115 @@ export class EndlessWorld {
460562 };461563 }462564 565+ // BRAIDED PATHS (M17): the planner pinned fork + merge to 1-cell566+ // straight windows. Here (a) both host placements mutate straight →567+ // split — the fork branches toward the alt side (right-hand branch568+ // renders mirrored, the takeable.ts move); the merge is the SAME569+ // split z-flipped in its local frame (rotation + 2 with the mirror570+ // flag inverted — R(r+2)·M_x ≡ R(r)·M_z), so its branch mouth faces571+ // the arriving alt lane and the branch curve sweeps INTO downstream;572+ // (b) the alt sub-walk assembles into its own open path (altPath —573+ // plain channel proxies hang on it, client + drive harness); (c) the574+ // fork window's LoopSegment gains the full alt chain: pull-off575+ // through the fork junction, the whole alt lane, pull-in through the576+ // merge junction — railForSegment(seg, true) rides the braid's other577+ // lane and skips the main windows underneath it (resuming at the578+ // window that starts at the merge exit port).579+ const braidPieces: { placement: Placement; host: number }[] = [];580+ let braidAltLoop: LoopPath | null = null;581+ for (const braid of plan.braids) {582+ if (!altWindows) break; // realized with the plan — always present583+ const forkIdx = braid.start;584+ const mergeIdx = braid.start + braid.len - 1;585+ const wi = windows.findIndex((w) => w.start === forkIdx && w.len === 1);586+ const wj = windows.findIndex((w) => w.start === mergeIdx && w.len === 1);587+ if (wi < 0 || wj < 0) continue; // reserved by the planner — always found588+ const lsFork = assembled.loop.segments[wi];589+ const lsMerge = assembled.loop.segments[wj];590+ const hpF = lsFork.placementIndex >= 0591+ ? assembled.placements[lsFork.placementIndex]592+ : undefined;593+ const hpM = lsMerge.placementIndex >= 0594+ ? assembled.placements[lsMerge.placementIndex]595+ : undefined;596+ if (!hpF || hpF.pieceId !== "straight") continue;597+ if (!hpM || hpM.pieceId !== "straight") continue;598+ const fork = plan.cells[forkIdx];599+ const merge = plan.cells[mergeIdx];600+ hpF.pieceId = "split";601+ if (braid.side === turnRight(fork.dirIn)) hpF.mirrored = true;602+ hpM.pieceId = "split";603+ hpM.rotation = ((hpM.rotation + 2) % 4) as Dir;604+ if (braid.side === turnLeft(merge.dirIn)) hpM.mirrored = true;605+ lsMerge.braidMerge = true;606+607+ const altSkeleton: Skeleton = {608+ cells: braid.altCells,609+ crossings: [],610+ helixLoops: [],611+ };612+ const altElevation: ElevationPlan = {613+ baseY: braid.altCells.map((c) => c.baseY),614+ reservations: [],615+ maxBaseY: Math.max(...braid.altCells.map((c) => c.baseY)),616+ };617+ const altAssembled = assemble(618+ altSkeleton,619+ altElevation,620+ this.style,621+ altWindows,622+ { open: true },623+ );624+ for (const p of altAssembled.placements) {625+ braidPieces.push({ placement: p, host: forkIdx });626+ }627+ braidAltLoop = altAssembled.loop;628+629+ // The alt chain: fork entry port → junction pull-off out the branch630+ // mouth (the sub-loop's centerline starts exactly at the fork's631+ // branch PORT and ends at the merge's) → the whole alt lane →632+ // pull-in through the merge junction → merge exit port.633+ const floor = fork.baseY + 0.25;634+ const [fx, fz] = DIR_STEP[fork.dirIn];635+ const [wx, wz] = DIR_STEP[braid.side];636+ const curve: CurveSeg[] = [637+ {638+ kind: "line",639+ a: [fork.x - fx / 2, floor, fork.z - fz / 2],640+ b: [fork.x - fx * 0.1, floor, fork.z - fz * 0.1],641+ },642+ {643+ kind: "cubic",644+ p: [645+ [fork.x - fx * 0.1, floor, fork.z - fz * 0.1],646+ [fork.x, floor, fork.z],647+ [fork.x + wx * 0.28, floor, fork.z + wz * 0.28],648+ [fork.x + wx * 0.5, floor, fork.z + wz * 0.5],649+ ] as [Vec3, Vec3, Vec3, Vec3],650+ },651+ ...altAssembled.loop.segments.flatMap((s) => s.curve),652+ {653+ kind: "cubic",654+ p: [655+ [merge.x + wx * 0.5, floor, merge.z + wz * 0.5],656+ [merge.x + wx * 0.28, floor, merge.z + wz * 0.28],657+ [merge.x, floor, merge.z],658+ [merge.x + fx * 0.1, floor, merge.z + fz * 0.1],659+ ] as [Vec3, Vec3, Vec3, Vec3],660+ },661+ {662+ kind: "line",663+ a: [merge.x + fx * 0.1, floor, merge.z + fz * 0.1],664+ b: [merge.x + fx / 2, floor, merge.z + fz / 2],665+ },666+ ];667+ lsFork.alt = {668+ curve,669+ length: curve.reduce((acc, s) => acc + segLength(s), 0),670+ braid: true,671+ };672+ }673+463674 // Islands anchored in this segment.464675 const islands = plan.crossings.map((crossing) =>465676 buildIsland(
@@ -484,6 +695,7 @@ export class EndlessWorld {
484695 "propeller",485696 "reorient",486697 "spur",698+ "braid",487699 "boost",488700 "gravity",489701 "icy",
@@ -625,6 +837,10 @@ export class EndlessWorld {
625837 ...placement,626838 chapter: cellChapter(host),627839 })),840+ ...braidPieces.map(({ placement, host }): Placement => ({841+ ...placement,842+ chapter: cellChapter(host),843+ })),628844 ...pads,629845 ].map((p): Placement => ({630846 ...p,
@@ -635,19 +851,33 @@ export class EndlessWorld {
635851 segments: assembled.loop.segments.map((s) => {636852 const curve = s.curve.map(scaleSeg);637853 const length = s.length * TRACK_SCALE;638- // Diamond/spur branches: the alt centerline scales with the639- // track too (the spur marker rides along for the proxies).854+ // Diamond/spur/braid branches: the alt centerline scales with855+ // the track too (the markers ride along for the proxies).640856 const alt = s.alt641857 ? {642858 curve: s.alt.curve.map(scaleSeg),643859 length: s.alt.length * TRACK_SCALE,644860 ...(s.alt.spur ? { spur: true } : {}),861+ ...(s.alt.braid ? { braid: true } : {}),645862 }646863 : undefined;647864 return { ...s, curve, length, ...(alt ? { alt } : {}) };648865 }),649866 totalLength: assembled.loop.totalLength * TRACK_SCALE,650867 };868+ // Braid alt LANE, world-scaled — the client (and drive harness)869+ // append plain channel proxies over it (placementIndex −1: its870+ // meshes ride in `placements` already, chapter-stamped).871+ const altPath: LoopPath | undefined = braidAltLoop872+ ? {873+ segments: braidAltLoop.segments.map((s) => ({874+ placementIndex: -1,875+ curve: s.curve.map(scaleSeg),876+ length: s.length * TRACK_SCALE,877+ })),878+ totalLength: braidAltLoop.totalLength * TRACK_SCALE,879+ }880+ : undefined;651881 652882 // Gravity reorientation (M16): a reorient run ROLLS everything653883 // downstream about its travel axis. The cumulative presentation
@@ -702,6 +932,7 @@ export class EndlessWorld {
702932 windowMods,703933 placements,704934 path,935+ ...(altPath ? { altPath } : {}),705936 islands,706937 startS: this.nextStartS,707938 ...(frame ? { frame } : {}),
M src/shared/frame.ts +7 -1
M src/shared/types.ts +17 -2
A tests/braid_test.ts +441 -0
@@ -0,0 +1,441 @@
1+/**2+ * Braided paths (M17) — generation invariants. The physics half (both3+ * lanes drive clean through fork and merge) lives in physics_test.ts.4+ *5+ * The contract, per committed braid: L straight flat main cells with the6+ * FORK host at cell 0 and the MERGE host at cell L−1, both pinned 1-cell7+ * straight windows whose placements mutated straight → split (fork8+ * branching toward the alt side; merge the same split z-flipped in its9+ * local frame — rotation + 2, mirror inverted — so its mouth faces the10+ * arriving lane); a REAL adjacent-cell alt walk beside the main lane11+ * (offsets 1..breadth, one lane shift per turn pair); the fork window12+ * carrying the full alt chain flagged `braid`; the merge window flagged13+ * `braidMerge`; an `altPath` for the alt lane's own channel proxies; and14+ * junction composites with OPEN branch mouths where the main ribbon15+ * stops. Breadth is capped by the per-seed knob drawn from16+ * rngFamily(seed, "braid-breadth").17+ */18+import { assert, assertEquals } from "@std/assert";19+import { EndlessWorld } from "../src/gen/endless/world.ts";20+import { TRACK_SCALE } from "../src/gen/endless/scale.ts";21+import { channelProxies, wideScalesOf } from "../src/gen/endless/proxies.ts";22+import { gapSegmentsOf, railForSegment } from "../src/client/physics/rail.ts";23+import { braidLaneAlt } from "../src/client/physics/autopilot.ts";24+import { segmentHash } from "../tools/pin_endless.ts";25+import { rngFamily } from "../src/gen/rng.ts";26+import { DIR_STEP, opposite, segPoint, turnRight } from "../src/gen/geom.ts";27+import type { DecorCatalog } from "../src/gen/decor.ts";28+29+const catalog: DecorCatalog = {30+ packs: [31+ {32+ slug: "nature-kit",33+ ids: JSON.parse(Deno.readTextFileSync(34+ new URL("../assets/nature-kit.manifest.json", import.meta.url),35+ )).pieces.map((p: { id: string }) => p.id),36+ },37+ ],38+};39+40+/** Scan corpus. Pinned exemplars: grav-scan-17 braids in segments 0 AND41+ * 1 (physics_test drives both lanes of the same seed); grav-scan-3842+ * (bias 3) shows a breadth-3 braid in segment 1 — re-scan with a fresh43+ * label family if a reshuffle moves them. */44+const SEEDS = [45+ "alpha",46+ "dami",47+ "cua-seed-1",48+ "marbles",49+ "grav-scan-17",50+ "grav-scan-38",51+ ...Array.from({ length: 30 }, (_, i) => `braid-scan-${i}`),52+];53+const SEGS = 8;54+55+/** Per-seed breadth knob, exactly as EndlessWorld draws it. */56+const biasOf = (seed: string): number =>57+ 1 + rngFamily(seed, "braid-breadth").int(0, 3);58+59+Deno.test("braid: pinned seeds braid early, breadth knob honored", () => {60+ const w17 = new EndlessWorld("grav-scan-17", catalog);61+ assert(62+ w17.get(0).plan.braids.length > 0 && w17.get(1).plan.braids.length > 0,63+ "grav-scan-17 lost its segment-0/1 braids — re-scan (physics_test " +64+ "pins the same seed)",65+ );66+ // The knob's top end is reachable: a bias-3 seed shows a breadth-367+ // braid (alt lane wandering three cells out).68+ assertEquals(biasOf("grav-scan-38"), 3, "grav-scan-38 bias drifted");69+ const w38 = new EndlessWorld("grav-scan-38", catalog);70+ let maxOff = 0;71+ for (let i = 0; i < 3; i++) {72+ for (const b of w38.get(i).plan.braids) {73+ const fork = w38.get(i).plan.cells[b.start];74+ const [wx, wz] = DIR_STEP[b.side];75+ for (const c of b.altCells) {76+ maxOff = Math.max(maxOff, (c.x - fork.x) * wx + (c.z - fork.z) * wz);77+ }78+ }79+ }80+ assertEquals(maxOff, 3, "grav-scan-38 lost its breadth-3 braid — re-scan");81+});82+83+Deno.test("braid: every braid obeys the fork/merge/lane contract", () => {84+ let braids = 0;85+ for (const seed of SEEDS) {86+ const bias = biasOf(seed);87+ const w = new EndlessWorld(seed, catalog);88+ for (let i = 0; i < SEGS; i++) {89+ const seg = w.get(i);90+ assert(91+ seg.plan.braids.length <= 1,92+ `${seed} seg ${i}: ${seg.plan.braids.length} braids (one per segment)`,93+ );94+ for (const braid of seg.plan.braids) {95+ braids++;96+ const { cells } = seg.plan;97+ const fork = cells[braid.start];98+ const merge = cells[braid.start + braid.len - 1];99+ const [dx, dz] = DIR_STEP[fork.dirIn];100+ const [wx, wz] = DIR_STEP[braid.side];101+102+ // MAIN lane: L straight flat cells; braid length in range.103+ assert(104+ braid.len >= 8 && braid.len <= 14,105+ `${seed} seg ${i}: braid length ${braid.len}`,106+ );107+ for (let k = 0; k < braid.len; k++) {108+ const c = cells[braid.start + k];109+ assert(110+ c.move === "F" && c.baseY === fork.baseY &&111+ c.dirIn === fork.dirIn,112+ `${seed} seg ${i}: main braid cell ${k} not straight/flat`,113+ );114+ }115+116+ // Fork + merge: 1-cell straight windows carrying the braid zone.117+ for (118+ const [tag, at] of [119+ ["fork", braid.start],120+ ["merge", braid.start + braid.len - 1],121+ ] as const122+ ) {123+ const wi = seg.windowRanges.findIndex((r) =>124+ r.start === at && r.len === 1125+ );126+ assert(wi >= 0, `${seed} seg ${i}: ${tag} window not 1 cell`);127+ assertEquals(128+ seg.pieceFamilies[wi],129+ "straight",130+ `${seed} seg ${i}: ${tag} window family`,131+ );132+ assertEquals(133+ seg.windowMods[wi],134+ "braid",135+ `${seed} seg ${i}: ${tag} window zone`,136+ );137+ }138+ const wi = seg.windowRanges.findIndex((r) =>139+ r.start === braid.start && r.len === 1140+ );141+ const wj = seg.windowRanges.findIndex((r) =>142+ r.start === braid.start + braid.len - 1 && r.len === 1143+ );144+145+ // Split placements: fork branches toward the alt side; the merge146+ // is the same split z-flipped (rotation + 2, mirror inverted) so147+ // its branch port faces the arriving lane.148+ const splitAt = (x: number, z: number, y: number) =>149+ seg.placements.find((p) =>150+ p.pieceId === "split" &&151+ Math.round(p.pos[0] / TRACK_SCALE) === x &&152+ Math.round(p.pos[2] / TRACK_SCALE) === z &&153+ Math.abs(p.pos[1] / TRACK_SCALE - y) < 1e-6154+ );155+ const fp = splitAt(fork.x, fork.z, fork.baseY);156+ const mp = splitAt(merge.x, merge.z, merge.baseY);157+ assert(fp, `${seed} seg ${i}: fork host did not mutate to split`);158+ assert(mp, `${seed} seg ${i}: merge host did not mutate to split`);159+ assertEquals(160+ Boolean(fp.mirrored),161+ braid.side === turnRight(fork.dirIn),162+ `${seed} seg ${i}: fork split mirroring wrong for branch side`,163+ );164+ assertEquals(165+ mp.rotation,166+ (fp.rotation + 2) % 4,167+ `${seed} seg ${i}: merge split not rotated 180° from the fork`,168+ );169+ assertEquals(170+ Boolean(mp.mirrored),171+ braid.side !== turnRight(merge.dirIn),172+ `${seed} seg ${i}: merge split mirroring wrong for branch side`,173+ );174+175+ // ALT walk: enters beside the fork heading sideways, leaves176+ // beside the merge turning back in; adjacent-cell continuity;177+ // offsets in [1, min(3, bias)]; one lane shift per step at most;178+ // disjoint from the main lane.179+ const alt = braid.altCells;180+ assert(alt.length >= braid.len, `${seed} seg ${i}: alt walk short`);181+ assertEquals(182+ [alt[0].x, alt[0].z, alt[0].dirIn],183+ [fork.x + wx, fork.z + wz, braid.side],184+ `${seed} seg ${i}: alt walk entry not the fork branch cell`,185+ );186+ const last = alt[alt.length - 1];187+ assertEquals(188+ [last.x, last.z, last.dirOut],189+ [merge.x + wx, merge.z + wz, opposite(braid.side)],190+ `${seed} seg ${i}: alt walk exit not into the merge mouth`,191+ );192+ const mainSet = new Set(193+ Array.from(194+ { length: braid.len },195+ (_, k) => `${cells[braid.start + k].x},${cells[braid.start + k].z}`,196+ ),197+ );198+ let prevOff: number | null = null;199+ for (const [k, c] of alt.entries()) {200+ assert(201+ c.baseY === fork.baseY,202+ `${seed} seg ${i}: alt cell ${k} not flat at the braid level`,203+ );204+ if (k > 0) {205+ const p = alt[k - 1];206+ const [ox, oz] = DIR_STEP[p.dirOut];207+ assertEquals(208+ [p.x + ox, p.z + oz],209+ [c.x, c.z],210+ `${seed} seg ${i}: alt walk breaks at cell ${k}`,211+ );212+ assertEquals(213+ c.dirIn,214+ p.dirOut,215+ `${seed} seg ${i}: alt walk heading breaks at cell ${k}`,216+ );217+ }218+ const off = (c.x - fork.x) * wx + (c.z - fork.z) * wz;219+ assert(220+ off >= 1 && off <= Math.min(3, bias),221+ `${seed} seg ${i}: alt offset ${off} outside [1, ${222+ Math.min(3, bias)223+ }] (bias ${bias})`,224+ );225+ if (prevOff !== null) {226+ assert(227+ Math.abs(off - prevOff) <= 1,228+ `${seed} seg ${i}: alt lane jumps ${prevOff} → ${off}`,229+ );230+ }231+ prevOff = off;232+ assert(233+ !mainSet.has(`${c.x},${c.z}`),234+ `${seed} seg ${i}: alt cell ${k} collides with the main lane`,235+ );236+ }237+238+ // Rail plumbing: the fork window carries the braid-flagged alt239+ // chain (fork entry port → merge exit port, C0-continuous); the240+ // merge window carries the braidMerge marker; altPath spans the241+ // branch ports.242+ const lsF = seg.path.segments[wi];243+ const lsM = seg.path.segments[wj];244+ assert(245+ lsF.alt !== undefined && lsF.alt.braid === true,246+ `${seed} seg ${i}: fork window lost its braid alt chain`,247+ );248+ assert(249+ lsM.braidMerge === true,250+ `${seed} seg ${i}: merge window lost its braidMerge marker`,251+ );252+ const floorW = (fork.baseY + 0.25) * TRACK_SCALE;253+ const a0 = segPoint(lsF.alt.curve[0], 0);254+ assertEquals(255+ a0.map((v) => Math.round(v * 4)),256+ [257+ (fork.x - dx / 2) * TRACK_SCALE,258+ floorW,259+ (fork.z - dz / 2) * TRACK_SCALE,260+ ].map((v) => Math.round(v * 4)),261+ `${seed} seg ${i}: alt chain does not start at the fork entry port`,262+ );263+ const aEnd = segPoint(lsF.alt.curve[lsF.alt.curve.length - 1], 1);264+ assertEquals(265+ aEnd.map((v) => Math.round(v * 4)),266+ [267+ (merge.x + dx / 2) * TRACK_SCALE,268+ floorW,269+ (merge.z + dz / 2) * TRACK_SCALE,270+ ].map((v) => Math.round(v * 4)),271+ `${seed} seg ${i}: alt chain does not end at the merge exit port`,272+ );273+ for (let s = 1; s < lsF.alt.curve.length; s++) {274+ const pa = segPoint(lsF.alt.curve[s - 1], 1);275+ const pb = segPoint(lsF.alt.curve[s], 0);276+ assert(277+ Math.hypot(pa[0] - pb[0], pa[1] - pb[1], pa[2] - pb[2]) < 1e-6,278+ `${seed} seg ${i}: alt chain gap at part ${s}`,279+ );280+ }281+ assert(seg.altPath !== undefined, `${seed} seg ${i}: altPath missing`);282+ const ap0 = segPoint(seg.altPath.segments[0].curve[0], 0);283+ const apLast = seg.altPath.segments[seg.altPath.segments.length - 1];284+ const apEnd = segPoint(apLast.curve[apLast.curve.length - 1], 1);285+ const forkPort = [286+ (fork.x + wx / 2) * TRACK_SCALE,287+ floorW,288+ (fork.z + wz / 2) * TRACK_SCALE,289+ ];290+ const mergePort = [291+ (merge.x + wx / 2) * TRACK_SCALE,292+ floorW,293+ (merge.z + wz / 2) * TRACK_SCALE,294+ ];295+ for (let k = 0; k < 3; k++) {296+ assert(297+ Math.abs(ap0[k] - forkPort[k]) < 1e-6 &&298+ Math.abs(apEnd[k] - mergePort[k]) < 1e-6,299+ `${seed} seg ${i}: altPath does not span branch port → branch port`,300+ );301+ }302+303+ // The alt rail rides the whole alt chain and SKIPS the main304+ // windows underneath the braid: no alt-rail station may sit on305+ // an interior main-lane cell center (the interior lane belongs306+ // to main riders only).307+ const altRail = railForSegment(seg, true);308+ for (let k = 1; k < braid.len - 1; k++) {309+ const cx = cells[braid.start + k].x * TRACK_SCALE;310+ const cz = cells[braid.start + k].z * TRACK_SCALE;311+ assert(312+ !altRail.pts.some((p) =>313+ Math.hypot(p[0] - cx, p[2] - cz) < 0.4 &&314+ Math.abs(p[1] - floorW) < 0.4315+ ),316+ `${seed} seg ${i}: alt rail rides the interior main lane @${k}`,317+ );318+ }319+320+ // PROXIES: junction decks under both hosts (top a hair above the321+ // channel floor), mouth deck reaching toward the branch port,322+ // and the ribbon stopped over the junctions.323+ const proxies = channelProxies(324+ seg.path,325+ true,326+ gapSegmentsOf(seg),327+ wideScalesOf(seg),328+ );329+ const coveredBySlab = (x: number, y: number, z: number): boolean =>330+ proxies.some((p) => {331+ if (p.kind !== "slab" || Math.abs(p.pitch) > 0.05) return false;332+ const top = p.center[1] + p.half[1];333+ if (Math.abs(top - y) > 0.1) return false;334+ const px = x - p.center[0];335+ const pz = z - p.center[2];336+ const lx = px * Math.cos(p.yaw) - pz * Math.sin(p.yaw);337+ const lz = px * Math.sin(p.yaw) + pz * Math.cos(p.yaw);338+ return Math.abs(lx) <= p.half[0] && Math.abs(lz) <= p.half[2];339+ });340+ for (const [tag, host] of [["fork", fork], ["merge", merge]] as const) {341+ assert(342+ coveredBySlab(343+ host.x * TRACK_SCALE,344+ floorW + 0.02,345+ host.z * TRACK_SCALE,346+ ),347+ `${seed} seg ${i}: no junction deck under the ${tag}`,348+ );349+ assert(350+ coveredBySlab(351+ (host.x + wx * 0.45) * TRACK_SCALE,352+ floorW + 0.02,353+ (host.z + wz * 0.45) * TRACK_SCALE,354+ ),355+ `${seed} seg ${i}: no mouth deck toward the ${tag} branch port`,356+ );357+ const meshOverJunction = proxies.some((p) => {358+ if (p.kind !== "mesh") return false;359+ for (let v = 0; v < p.vertices.length; v += 3) {360+ if (361+ Math.abs(p.vertices[v + 1] - floorW) < 0.1 &&362+ Math.hypot(363+ p.vertices[v] - host.x * TRACK_SCALE,364+ p.vertices[v + 2] - host.z * TRACK_SCALE,365+ ) < 0.6366+ ) return true;367+ }368+ return false;369+ });370+ assert(371+ !meshOverJunction,372+ `${seed} seg ${i}: ribbon floor crosses the ${tag} junction`,373+ );374+ }375+ }376+ }377+ }378+ assert(braids >= 8, `too few braids across the corpus (${braids})`);379+});380+381+Deno.test("braid: deterministic per seed", () => {382+ const a = new EndlessWorld("grav-scan-17", catalog);383+ const b = new EndlessWorld("grav-scan-17", catalog);384+ for (let i = 0; i < 3; i++) {385+ assertEquals(segmentHash(a.get(i)), segmentHash(b.get(i)), `segment ${i}`);386+ }387+ assertEquals(a.get(0).plan.braids, b.get(0).plan.braids, "braids diverged");388+ assertEquals(a.get(0).altPath, b.get(0).altPath, "altPath diverged");389+});390+391+Deno.test("braid lane choice: per-racer streams, deterministic, ~half split", () => {392+ // The choice law (USER RULE): each racer draws from ITS OWN label-393+ // derived stream — rngFamily(seed, `lane/${idx}/${segIndex}`) — like394+ // pilotFor personalities. Deterministic, and independent by395+ // construction: one racer's choice is a pure function of (seed, idx,396+ // segIndex), so nothing any other racer does can perturb it.397+ const seed = "lane-law-seed";398+ // Determinism: repeated evaluation is bit-identical.399+ for (let idx = 0; idx < 20; idx++) {400+ assertEquals(braidLaneAlt(seed, idx, 7), braidLaneAlt(seed, idx, 7));401+ }402+ // Independence: evaluating the whole pack in any order (or not at403+ // all) never changes racer 13's draw.404+ const lone = braidLaneAlt(seed, 13, 7);405+ const inOrder = Array.from(406+ { length: 100 },407+ (_, idx) => braidLaneAlt(seed, idx, 7),408+ );409+ const reversed = Array.from(410+ { length: 100 },411+ (_, k) => braidLaneAlt(seed, 99 - k, 7),412+ ).reverse();413+ assertEquals(inOrder, reversed, "draw order perturbed the choices");414+ assertEquals(inOrder[13], lone, "pack evaluation perturbed racer 13");415+ // A 100-pack splits roughly in half at every braid, across seeds.416+ for (const s of [seed, "alpha", "dami"]) {417+ for (const segIndex of [2, 7, 31]) {418+ let alt = 0;419+ for (let idx = 0; idx < 100; idx++) {420+ if (braidLaneAlt(s, idx, segIndex)) alt++;421+ }422+ assert(423+ alt >= 30 && alt <= 70,424+ `pack split ${alt}/100 at ${s}/${segIndex} — not roughly half`,425+ );426+ }427+ }428+ // Different braids re-draw: the same pack's pattern varies by segment.429+ const at2 = Array.from(430+ { length: 100 },431+ (_, idx) => braidLaneAlt(seed, idx, 2),432+ );433+ const at7 = Array.from(434+ { length: 100 },435+ (_, idx) => braidLaneAlt(seed, idx, 7),436+ );437+ assert(438+ at2.some((v, k) => v !== at7[k]),439+ "lane choices identical across braids — segIndex not in the label",440+ );441+});
M tests/golden_endless.json +46 -46
@@ -1,59 +1,59 @@
11 {22 "__version": "g491fc4c7a685",33 "alpha": [4- "aafbf36d8c8dc155",5- "811ea3d4bda98667",6- "1ac0f6068cadcb27",7- "e1b8f10a772c966b",8- "8a5681f26285485f",9- "d227d6afb24275c6",10- "7c2359eae09a7dc6",11- "2158128e73290f0b",12- "3025b32f15b7d06e",13- "c251665c4601c051",14- "9e5a496cf864ad97",15- "f1f4eb9babe39e36"4+ "fc5bb5852166bfc3",5+ "79a491f1f4acfc66",6+ "4397e6bdfb29112e",7+ "2496a4b6dd5001a5",8+ "e6a0884c2d780352",9+ "82aedb6dd37b3db0",10+ "d3568358534fa373",11+ "bb1611801fa763fb",12+ "ed166c0fbc83dd10",13+ "1e8e15769f76bfe7",14+ "078749c92db0603d",15+ "9e3f73d3d677ecae"1616 ],1717 "zx9k2m4p1q": [18- "74fb045a2dda1057",19- "e47434996716052f",20- "efff74cdc4ffbd04",21- "03aa8d27f21bd775",22- "6a52cab450baccff",23- "fb5f81f3d1f450e5",24- "1e559c95740c7554",25- "695aed379287df42",26- "d585a9acc5d036be",27- "de63deae183abb94",28- "ed134d99bcf53fcd",29- "bc4299a406ff68c7"18+ "5baa8a1e6db88f3d",19+ "ef39a57246d72895",20+ "e876fa7b8c9bf5b5",21+ "2595624e3dc2def9",22+ "1b8090583d29674c",23+ "67de521fd10da071",24+ "b3f755670dea8996",25+ "820dc188db72d9b3",26+ "af794b8bf1c32890",27+ "a8a5d76c4ab307fc",28+ "532d1bcbea21d46f",29+ "e46e3533b12efcbb"3030 ],3131 "dami": [3232 "5fc5f6caf05d117c",3333 "239ded7babc29836",34- "e14d57f51bb973e9",35- "457a48df838e589d",36- "9a4cb14260d91e34",37- "57598ae62f5ce25f",38- "0fcea243578ad3a4",39- "48a322db4ea22a5f",40- "d71191a0ec006419",41- "facf16d6d599644d",42- "a78c630b06dce140",43- "0095fcd024f98954"34+ "f69717ee7de42a72",35+ "04b346f980745fbe",36+ "c320c43350c57a6a",37+ "48b4fc82718106c6",38+ "22ccdd982057c862",39+ "ac7f463f6685b2cb",40+ "d667aad8f4067140",41+ "634e194144374ee1",42+ "31926b484090dbf3",43+ "4ec26ba1e23d18de"4444 ],4545 "marbles": [46- "062d9442a75a9060",47- "3e97c06685c14e84",48- "68f9ef0b65546599",49- "8c8fd758c561218e",50- "5c99fb405789d378",51- "bbbf06741a24db59",52- "e8c644d0f7d64cde",53- "ee73ca6d91f1aec6",54- "0e9d23d1b67a6077",55- "9fa0050337e7a7be",56- "052453bc225cd250",57- "96b07f7fe8c3e437"46+ "c5ef91cdb62a327c",47+ "6c64e184cb4fc9be",48+ "03d3b9c46570cf41",49+ "c79a3482a8acd1fa",50+ "6f4651658099186f",51+ "f9e1993574c18cd9",52+ "df3acd6d48439183",53+ "33a486156155368b",54+ "5306836210fbaf9a",55+ "a61cbec166e497c8",56+ "22039caa827a549e",57+ "e53d8ef37deb26ff"5858 ]5959 }
M tests/physics_test.ts +56 -25
@@ -124,6 +124,9 @@ function driveWith(
124124 rails.push(segRail);125125 let proxies = [126126 ...channelProxies(seg.path, true, gapSegmentsOf(seg), wideScalesOf(seg)),127+ // Braid alt LANE (M17): plain channel between the junction128+ // composites — same law as the client build.129+ ...(seg.altPath ? channelProxies(seg.altPath, true) : []),127130 ...seg.islands.flatMap((isle) => islandProxies(isle.placements)),128131 ...bumperProxies(seg.placements),129132 ];
@@ -280,11 +283,11 @@ Deno.test("physics survivability: assisted marble clears early segments", async
280283 281284 Deno.test("scenario jump-gap: cold entry launches, flies, and is caught (both kinds)", async () => {282285 module_ = await factory();283- // One pinned case per gap kind: grav-scan-11 early segments plan a284- // PROPELLER (2-cell gap at 0.35 gravity), wide-scan-5 a plain JUMP285- // (re-pinned after the reorient clause reshuffled the streams).286- // (Re-pinned after the spur clause reshuffled every seed's draws —287- // no seed freeze, so pins follow the stream.)286+ // One pinned case per gap kind: helix-scan-6 early segments plan a287+ // PROPELLER (2-cell gap at 0.35 gravity), grav-scan-25 a plain JUMP288+ // (re-pinned after the braid clause reshuffled the streams; the289+ // reorient and spur clauses moved them before that — no seed freeze,290+ // so pins follow the stream.)288291 // Locate the flight span on the shared rail, spawn the marble COLD289292 // (base speed 2.2) just before the boost approach, and require it to290293 // cross the gap and keep rolling. This is the envelope MINIMUM — the
@@ -294,8 +297,8 @@ Deno.test("scenario jump-gap: cold entry launches, flies, and is caught (both ki
294297 // the 7-seed survivability gate above.)295298 for (296299 const { seed, kind } of [297- { seed: "grav-scan-11", kind: "propeller" },298- { seed: "wide-scan-5", kind: "jump" },300+ { seed: "helix-scan-6", kind: "propeller" },301+ { seed: "grav-scan-25", kind: "jump" },299302 ]300303 ) {301304 const w = new EndlessWorld(seed, catalog);
@@ -334,8 +337,8 @@ Deno.test("scenario lip-undershoot rescue: the jump decision saves a slow arriva
334337 // the marble's actual state, not a pre-priced launch.335338 for (336339 const { seed, kind } of [337- { seed: "grav-scan-11", kind: "propeller" },338- { seed: "wide-scan-5", kind: "jump" },340+ { seed: "helix-scan-6", kind: "propeller" },341+ { seed: "grav-scan-25", kind: "jump" },339342 ]340343 ) {341344 const w = new EndlessWorld(seed, catalog);
@@ -366,21 +369,21 @@ Deno.test("scenario lip-undershoot rescue: the jump decision saves a slow arriva
366369 367370 Deno.test("scenario diamond-branch: both branches carry the marble through", async () => {368371 module_ = await factory();369- // grav-scan-24 early segments host an island diamond (re-pinned after370- // the viaduct clause, and before that371- // the gravity clause). Drive through segment 2372+ // grav-scan-9 early segments host island diamonds (re-pinned after373+ // the braid clause reshuffled the streams; the viaduct and gravity374+ // clauses moved the pin before that). Drive through segment 2372375 // twice — once on the main (left) branch rail, once on the alt (right)373376 // — the marble must clear the split, branch, and merge on BOTH. This is374377 // the branch-respawn contract: after a fall, the retry may ride the375378 // other branch, so both must be survivable.376- const w = new EndlessWorld("grav-scan-24", catalog);379+ const w = new EndlessWorld("grav-scan-9", catalog);377380 const hasDiamond = [0, 1, 2].some((i) =>378381 w.get(i).plan.reservations.some((r) => r.pieceIds[0]?.startsWith("diamond"))379382 );380- assert(hasDiamond, "grav-scan-24 early segments lost their diamond");383+ assert(hasDiamond, "grav-scan-9 early segments lost their diamond");381384 const goal = w.get(3).startS;382385 for (const useAlt of [false, true]) {383- const r = drive("grav-scan-24", 5, 90, goal, 0, useAlt);386+ const r = drive("grav-scan-9", 5, 90, goal, 0, useAlt);384387 assert(385388 !r.fell && r.reachedS >= goal,386389 `${useAlt ? "alt" : "main"} branch failed: fell=${r.fell} ` +
@@ -389,6 +392,32 @@ Deno.test("scenario diamond-branch: both branches carry the marble through", asy
389392 }390393 });391394 395+Deno.test("scenario braid: both lanes carry the marble through fork and merge", async () => {396+ module_ = await factory();397+ // grav-scan-17 hosts braids in segments 0 AND 1 (breadth-2 wanders,398+ // nothing else alt-flavored in range — the useAlt drive rides exactly399+ // the braid alt chains). Main lane: straight through both junction400+ // composites. Alt lane: out the fork mouth, along the wandering side401+ // lane, back in through the merge mouth — twice. Both lanes must clear402+ // both braids and keep rolling; this is the fork/merge-proxy physical403+ // acceptance (M17).404+ const w = new EndlessWorld("grav-scan-17", catalog);405+ const nBraids = [0, 1].reduce(406+ (acc, i) => acc + w.get(i).plan.braids.length,407+ 0,408+ );409+ assert(nBraids >= 2, "grav-scan-17 lost its early braids — re-scan");410+ const goal = w.get(2).startS;411+ for (const useAlt of [false, true]) {412+ const r = drive("grav-scan-17", 4, 120, goal, 0, useAlt);413+ assert(414+ !r.fell && r.reachedS >= goal,415+ `${useAlt ? "alt" : "main"} lane failed: fell=${r.fell} ` +416+ `reached=${r.reachedS.toFixed(1)}/${goal.toFixed(1)}`,417+ );418+ }419+});420+392421 Deno.test("physics determinism: identical trajectory hash across runs", async () => {393422 module_ = await factory();394423 const a = drive("dia-2", 4, 45);
@@ -400,20 +429,21 @@ Deno.test("physics determinism: identical trajectory hash across runs", async ()
400429 401430 Deno.test("scenario helix-dive: marble rides the full spiral and the underpass", async () => {402431 module_ = await factory();403- // helix-scan-25 segment 0 hosts a full-helix ensemble (re-pinned404- // after the reorient clause reshuffled the stream). The marble must enter the approach corner,432+ // grav-scan-6 segment 0 hosts a full-helix ensemble (re-pinned after433+ // the braid clause reshuffled the streams; the reorient clause moved434+ // it before that). The marble must enter the approach corner,405435 // ride the 360° spiral down 2.0 (helix family mod: banked, ridden406436 // fast), come out through its own underpass, and keep rolling.407- const w = new EndlessWorld("helix-scan-25", catalog);437+ const w = new EndlessWorld("grav-scan-6", catalog);408438 const seg = w.get(0);409439 const r0 = seg.plan.reservations.find((r) =>410440 /^helix-(large-)?(left|right)$/.test(r.pieceIds[0])411441 );412- assert(r0 !== undefined, "helix-scan-25 seg 0 lost its full helix");442+ assert(r0 !== undefined, "grav-scan-6 seg 0 lost its full helix");413443 const wi = seg.windowRanges.findIndex((wr) => wr.start === r0.start);414444 let endS = 0;415445 for (let k = 0; k <= wi; k++) endS += seg.path.segments[k].length;416- const r = drive("helix-scan-25", 3, 60, endS + 8);446+ const r = drive("grav-scan-6", 3, 60, endS + 8);417447 assert(!r.fell, "marble fell during the helix dive");418448 assert(419449 r.reachedS >= endS + 7,
@@ -557,22 +587,23 @@ Deno.test("scenario gravity-run: tilted gravity surges the marble on the flat",
557587 558588 Deno.test("scenario reorient: marble corkscrews the twist and rides the wall", async () => {559589 module_ = await factory();560- // biome-3 segment 0 rolls +90° about the travel axis (also crossed by561- // the survivability corpus). The marble must enter the twist chute,590+ // jump-scan-25 segment 0 rolls +90° about the travel axis (re-pinned591+ // after the braid clause reshuffled the streams; biome-3 held the pin592+ // before that). The marble must enter the twist chute,562593 // corkscrew through it, and keep making progress DOWNSTREAM of the563594 // run — where the channel is a WALL in world space and only the564595 // conjugated control law + rotated gravity delta keep it in the pipe.565- const w = new EndlessWorld("biome-3", catalog);596+ const w = new EndlessWorld("jump-scan-25", catalog);566597 const seg = w.get(0);567598 const f = seg.frame;568- assert(f?.twist, "biome-3 seg 0 lost its reorient — re-scan");599+ assert(f?.twist, "jump-scan-25 seg 0 lost its reorient — re-scan");569600 const tw = f.twist!;570601 // Drive well past the twist (its axial end, plus a margin).571602 let twistEndS = 0;572603 for (let k = 0; k <= tw.to; k++) twistEndS += seg.path.segments[k].length;573604 const goal = twistEndS + 10;574605 let wallProof = false;575- const r = drive("biome-3", 4, 120, goal, 0, false, (pos) => {606+ const r = drive("jump-scan-25", 4, 120, goal, 0, false, (pos) => {576607 // Wall-riding evidence: past the run, the marble's PLAN height sits577608 // near the floor while its world position is genuinely rotated away578609 // from the plan point (the twist really presented).
M tests/reorient_test.ts +10 -11
@@ -19,13 +19,12 @@ const close = (a: readonly number[], b: readonly number[], eps = 1e-9) => {
1919 };2020 2121 Deno.test("reorient: pinned seeds twist early and frames stay continuous", () => {22- // biome-3 rolls +90° in segment 0 (also crossed by the survivability23- // corpus); abs-1 rolls −90° at 0 then +90° at 2 — the composed exit24- // must return to identity orientation.25- const w = new EndlessWorld("biome-3");22+ // jump-scan-25 rolls +90° in segment 0 (re-pinned after the braid23+ // clause reshuffled the streams; before that biome-3 held the pin).24+ const w = new EndlessWorld("jump-scan-25");2625 const s0 = w.get(0);2726 const m = s0.plan.mods.find((mm) => mm.kind === "reorient");28- assert(m, "biome-3 seg 0 lost its reorient — re-scan");27+ assert(m, "jump-scan-25 seg 0 lost its reorient — re-scan");2928 assertAlmostEquals(m.angle!, Math.PI / 2, 1e-12);3029 assert(s0.frame?.twist, "reorient must stamp a twist frame");3130
@@ -63,22 +62,22 @@ Deno.test("reorient: pinned seeds twist early and frames stay continuous", () =>
6362 });6463 6564 Deno.test("reorient: 180° roll presents the descent as a world CLIMB", () => {66- // wide-scan-2 seg 0 carries a 180° roll: plan-down maps to world-up65+ // grav-scan-33 seg 0 carries a 180° roll: plan-down maps to world-up6766 // past the twist — the ceiling-world signature (re-pinned after the68- // viaduct clause reshuffled island claims).69- const w = new EndlessWorld("wide-scan-2");67+ // braid clause reshuffled the streams; wide-scan-2 before that).68+ const w = new EndlessWorld("grav-scan-33");7069 const s = w.get(0);7170 const m = s.plan.mods.find((mm) =>7271 mm.kind === "reorient" && Math.abs(Math.abs(mm.angle!) - Math.PI) < 1e-97372 );74- assert(m, "wide-scan-2 seg 0 lost its 180° reorient — re-scan");73+ assert(m, "grav-scan-33 seg 0 lost its 180° reorient — re-scan");7574 assertAlmostEquals(Math.abs(m.angle!), Math.PI, 1e-12);7675 const down = quatRotate(s.frame!.exit.q, [0, -1, 0]);7776 close(down, [0, 1, 0], 1e-9);7877 });7978 8079 Deno.test("reorient: frame law is exact at the run's ends", () => {81- const w = new EndlessWorld("biome-3");80+ const w = new EndlessWorld("jump-scan-25");8281 const f = w.get(0).frame!;8382 const tw = f.twist!;8483 // At the pivot the frames agree; past the run the exit frame owns it.
@@ -96,6 +95,6 @@ Deno.test("reorient: frame law is exact at the run's ends", () => {
9695 affineApply(f.exit, past),9796 );9897 // Determinism: a second world reproduces the identical frame.99- const w2 = new EndlessWorld("biome-3");98+ const w2 = new EndlessWorld("jump-scan-25");10099 assertEquals(w2.get(0).frame, f);101100 });
M tests/scenario_test.ts +21 -12
@@ -478,18 +478,21 @@ Deno.test("gravity zones: flat straight runs with brake runway, and they READ",
478478 // The surface READS: at least one placement in the zone's span is479479 // tagged zone="gravity" (unless a higher-priority mod overlaps480480 // the same window — rare; the zone must still be flat above).481- const zoneCells = new Set<string>();481+ // Cell match is Chebyshev ≤ 1: wave meshes anchor their origin a482+ // column off the centerline (grav-scan-3's whole zone realized483+ // as one wave window and the exact-cell match missed its tag).484+ const zoneXZ: [number, number][] = [];482485 for (let k = m.start; k < m.start + m.len; k++) {483- zoneCells.add(`${cells[k].x},${cells[k].z}`);486+ zoneXZ.push([cells[k].x, cells[k].z]);484487 }485- const tagged = seg.placements.some((p) =>486- p.zone === "gravity" &&487- zoneCells.has(488- `${Math.round(p.pos[0] / TRACK_SCALE)},${489- Math.round(p.pos[2] / TRACK_SCALE)490- }`,491- )492- );488+ const tagged = seg.placements.some((p) => {489+ if (p.zone !== "gravity") return false;490+ const px = Math.round(p.pos[0] / TRACK_SCALE);491+ const pz = Math.round(p.pos[2] / TRACK_SCALE);492+ return zoneXZ.some(([zx, zz]) =>493+ Math.max(Math.abs(px - zx), Math.abs(pz - zz)) <= 1494+ );495+ });493496 const overlapped = mods.some((o) =>494497 o !== m && o.kind !== "gravity" &&495498 o.start < m.start + m.len && m.start < o.start + o.len
@@ -1045,14 +1048,20 @@ Deno.test("island ground vocabulary: wide runs, furniture, solids obey the rules
10451048 );10461049 }10471050 if (p.pieceId === "split") {1048- // A split junction is either a furnished ledge host or a1049- // takeable spur host (the M13 spur wired into endless).1051+ // A split junction is either a furnished ledge host, a takeable1052+ // spur host (the M13 spur wired into endless), or a braid1053+ // fork/merge host (M17).10501054 const hostCell = [...hostSet].some((h) => {10511055 const c = seg.plan.cells[h];10521056 return `${c.x},${c.z}` === cellKey;10531057 }) || seg.plan.spurs.some((s) => {10541058 const c = seg.plan.cells[s.host];10551059 return `${c.x},${c.z}` === cellKey;1060+ }) || seg.plan.braids.some((b) => {1061+ const f = seg.plan.cells[b.start];1062+ const mg = seg.plan.cells[b.start + b.len - 1];1063+ return `${f.x},${f.z}` === cellKey ||1064+ `${mg.x},${mg.z}` === cellKey;10561065 });10571066 assert(10581067 hostCell,
M tests/spur_test.ts +8 -7