clone

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

OPEN FORKS (V3, user directive): the line forks now and answers later

Dami committed 1abfbcf
'Forking currently exists as a singular generative pass only. It should
allow for unresolved forks to resolve in later generation up to a
point.' — the braid clause may now leave its fork OPEN at the segment
boundary; the successor segment MUST open with the continuation and
merge within OPEN_BRAID_CAP total cells (bounded irresolution).

- path.ts: HeadState.openBraid carries {side, offset, cells, laneMods,
  wanderer} across the boundary; tryBraidSide draws an open coin (new
  child stream — zero draw perturbation; the pinned braids simply drew
  closed) and runs the braid flush to the boundary (its straight tail IS
  the seam margin; profile keeps no return budget); planSegment resolves
  an inherited fork FIRST via tryBraidContinuation (longest-fits-first,
  profile walks the carried offset home, V1 merge at a pinned straight).
  The alt lane's own boundary corridor is checked with the
  hasFlatContinuation CONTRACT pose (the next cell past the seam — the
  bug that silently killed every open attempt in the first cut), and the
  successor-probe gate is what makes leaving a fork open safe: it only
  commits when the continuation actually plans.
- world.ts: fork mutation only on non-cont braids, merge only on
  non-open; alt chains build headless/tailless per case and hang
  contBraid/openBraid flags; EndlessSegment.braidContOf names the fork
  segment; lane-tag lip margins apply only at ends that ARE junctions.
- proxies.ts: a continuation chain's carrier window keeps its plain
  channel RIBBON (no fork mouth cut into an ordinary wall) and no fork
  composite; the merge composite emits as ever.
- Client: railFor + braidLaneFor + the rider hook carry the FORK
  segment's per-racer lane draw across the boundary (one draw per BRAID
  — no re-draw, no teleport-flip mid-braid); rival mirroring keys on the
  same label.
- Tests: V3 contract (open resolves next segment exactly once,
  braidContOf, side/laneMods carry, cap law, alt-lane PORT CONTINUITY
  across the seam <1e-6); closed contract scoped to closed braids; lip
  law knows boundary ends; drive() soak on grav-scan-32 crosses the
  boundary on BOTH lanes and merges home. Corpus: 136 braids / 14 open /
  0 planner failures over 160 seeds x 8 segments.
  205 green, check clean, endless goldens re-pinned.
commit
1abfbcfa2ee94e3884c5b119d2560237cdb8e1a3
parent
b82e133b3c8b151f300cbb23c2ff29c4de94422d
author
Dami <dami.etoile@gmail.com>
8 files changed, +541 -87
Mprogress.md20+-
Msrc/client/endless.ts43++++-
Msrc/gen/endless/path.ts269++++++++++++++++++++++++++++--
Msrc/gen/endless/proxies.ts13+-
Msrc/gen/endless/world.ts136++++++++++-----
Msrc/shared/types.ts8+
Mtests/braid_test.ts116++++++++++++-
Mtests/physics_test.ts23+++
M progress.md +12 -8
@@ -65,13 +65,16 @@ cross composite, quantitative metrics CI).
6565 until something can hit decor (island-top pieces are off the channel).6666 - **Bumper density tuning**: after watching feedback (pins currently6767 only on wide island decks).68-- **Braids V2 residual (M19b)**: lane mods + wanderer shipped. Deferred69- ON THE RECORD: vertical lane divergence (re-litigates the junction-dip70- law that shaped V1 — its own milestone), braids over islands, >171- braid/segment as a planner feature (world.ts realize is already72- generalized over plan.braids — flipping the cap is a one-line planner73- change with its own pin cycle). Super-bounce zone assessed and parked74- (fights shock's reason to exist on hard landings).68+- **Braids V2/V3 residual**: lane mods + wanderer (V2) and OPEN FORKS69+ (V3, user directive: forks may leave a segment unresolved and resolve70+ in the successor, capped at OPEN_BRAID_CAP total cells) shipped.71+ Still deferred ON THE RECORD: vertical lane divergence (re-litigates72+ the junction-dip law — its own milestone), braids over islands, >173+ braid/segment (realize is generalized over plan.braids — one-line74+ planner change + pin cycle), multi-segment open chains (the cap75+ currently forces resolution at the very next boundary; raising it76+ needs open-state re-emission in the continuation). Super-bounce zone77+ assessed and parked (fights shock's reason to exist).7578 - **Theme biomes (M18a residual)**: winter/spooky/tropical/autumn rows7679 shipped on Holiday/Graveyard/Pirate/Survival kits. Open: biome-weight7780 and scale-range tuning after watching real streams, and the SPACE
@@ -102,7 +105,8 @@ cross composite, quantitative metrics CI).
102105 grav-scan-6 helix, wide-scan-17 icy, wide-scan-21 gravity,103106 jump-scan-4 + jump-scan-12 spurs, jump-scan-25 + grav-scan-33104107 reorients, grav-scan-17/38 + marbles braids, wide-scan-13 gust bank,105- grav-scan-6@4 icy/wanderer lane braid, grav-scan-10@3 boost lane);108+ grav-scan-6@4 icy/wanderer lane braid, grav-scan-10@3 boost lane,109+ grav-scan-32@0 open fork);106110 regenerate endless goldens with `deno run --allow-read --allow-write107111 tools/pin_endless.ts`. Canon note: golden placements hash wind108112 pulse/swirl since M19a.
M src/client/endless.ts +37 -6
@@ -556,6 +556,8 @@ class Session {
556556 private readonly braidSegs = new Set<number>();557557 /** Fan wind volumes, binned per segment (M16) — fans push marbles. */558558 private readonly fanField = new FanField();559+ /** V3: continuation segment → its fork segment (lane-label carrier). */560+ private readonly braidContOf = new Map<number, number>();559561 /** Accumulated sim time for pulsed wind (M19a gust walls). */560562 private windT = 0;561563 /** Respawn timestamps inside the trailing 2s (M20a pile-up telemetry). */
@@ -1288,6 +1290,9 @@ class Session {
12881290 if (fr) attachFrames(altRail, fr);12891291 this.altRails.set(i, altRail);12901292 if (hasBraid(data)) this.braidSegs.add(i);1293+ if (data.braidContOf !== undefined) {1294+ this.braidContOf.set(i, data.braidContOf);1295+ }12911296 }12921297 // Fan wind capsules (M16): derived from the fan placements —12931298 // deterministic, no rng. Ledge fans face the channel, so their
@@ -1448,6 +1453,7 @@ class Session {
14481453 this.rails.delete(seg.index);14491454 this.altRails.delete(seg.index);14501455 this.braidSegs.delete(seg.index);1456+ this.braidContOf.delete(seg.index);14511457 this.fanField.removeSegment(seg.index);14521458 // Sink away rather than pop out; the group leaves the scene when the14531459 // exit wave finishes. (Occupancy claims release much later — the
@@ -1773,10 +1779,19 @@ class Session {
17731779 const seg = this.segmentAt(r.s);17741780 if (!seg) return;17751781 // Braid entry (M20a): riders draw the SAME per-racer lane law as1776- // live racers, so the fork split ratio holds at LOD distance.1782+ // live racers, so the fork split ratio holds at LOD distance. V3:1783+ // a continuation carries the fork's lane across the boundary.17771784 if (this.braidSegs.has(seg.index) && r.laneBraid !== seg.index) {1785+ const contOf = this.braidContOf.get(seg.index);1786+ if (contOf === undefined || r.laneBraid !== contOf) {1787+ r.laneAlt = braidLaneAlt(1788+ this.seed,1789+ r.idx,1790+ contOf ?? seg.index,1791+ r.pilot.lanePref,1792+ );1793+ }17781794 r.laneBraid = seg.index;1779- r.laneAlt = braidLaneAlt(this.seed, r.idx, seg.index, r.pilot.lanePref);17801795 r.nearest = { seg: seg.index, idx: 0 };17811796 }17821797 const rail = this.railFor(r, seg.index);
@@ -2588,7 +2603,12 @@ class Session {
25882603 * r.nearest.idx always indexes into THIS rail — every dereference of a25892604 * racer's station must come through here. */25902605 private railFor(r: Racer, segIdx: number): Rail | undefined {2591- if (r.laneAlt && r.laneBraid === segIdx) {2606+ // V3: a racer whose lane was drawn at the FORK segment keeps riding2607+ // alt through the continuation segment (the boundary is mid-braid).2608+ if (2609+ r.laneAlt &&2610+ (r.laneBraid === segIdx || this.braidContOf.get(segIdx) === r.laneBraid)2611+ ) {25922612 return this.altRails.get(segIdx) ?? this.rails.get(segIdx);25932613 }25942614 return this.rails.get(segIdx);
@@ -2604,10 +2624,21 @@ class Session {
26042624 const segIdx = r.nearest.seg;26052625 if (!this.braidSegs.has(segIdx)) return;26062626 if (r.laneBraid !== segIdx) {2627+ // V3 continuation: the boundary is MID-braid — a racer whose lane2628+ // was drawn at the fork keeps it (no re-draw, no teleport-flip).2629+ const contOf = this.braidContOf.get(segIdx);2630+ if (contOf !== undefined && r.laneBraid === contOf) {2631+ r.laneBraid = segIdx;2632+ this.updateNearestFor(r, r.pos);2633+ return;2634+ }26072635 r.laneBraid = segIdx;26082636 // Lane identity is CHARACTER (M20a): base pilot's threshold, never2609- // the rubber-banded scratch. Same label, same single draw.2610- let want = braidLaneAlt(this.seed, r.idx, segIdx, r.pilot.lanePref);2637+ // the rubber-banded scratch. Same label, same single draw. A2638+ // continuation segment keys the draw on its FORK segment (one2639+ // draw per BRAID, not per segment).2640+ const labelSeg = contOf ?? segIdx;2641+ let want = braidLaneAlt(this.seed, r.idx, labelSeg, r.pilot.lanePref);26112642 // Lane mirroring (M20b): a nearby rival bends the choice — the26122643 // grudge FOLLOWS its target's own label-pure draw, the ally takes26132644 // the opposite (hunters chase, allies spread). No stream coupling:
@@ -2619,7 +2650,7 @@ class Session {
26192650 const theirs = braidLaneAlt(26202651 this.seed,26212652 rv.target,2622- segIdx,2653+ labelSeg,26232654 target.pilot.lanePref,26242655 );26252656 want = rv.valence === "grudge" ? theirs : !theirs;
M src/gen/endless/path.ts +253 -16
@@ -211,8 +211,36 @@ export interface Braid {
211211 * offsets this draw — always compensated by an alt boost tag so the212212 * long way around is never a strict loser. */213213 wanderer?: boolean;214+ /** OPEN fork (V3, user directive): this braid runs to the SEGMENT215+ * BOUNDARY without merging — the alt lane exits forward beside the216+ * main lane and the SUCCESSOR segment must plan the continuation217+ * (HeadState.openBraid; the successor-probe gate is what makes218+ * leaving it open safe: an open fork only commits when its resolution219+ * actually plans). No merge host, no merge mutation this segment. */220+ open?: boolean;221+ /** Continuation (V3): this braid RESOLVES a fork left open by the222+ * previous segment — the alt lane enters at the boundary (no fork223+ * host) and merges at `start + len − 1`. */224+ cont?: boolean;214225 }215226 227+/** An alt lane crossing a segment boundary (V3). Rides HeadState. */228+export interface OpenBraidState {229+ /** World dir from the main lane toward the alt side. */230+ side: Dir;231+ /** Alt-lane offset at the boundary (1..3). */232+ offset: number;233+ /** Main-lane cells covered since the fork (the resolution cap counts234+ * against this — "up to a point"). */235+ cells: number;236+ laneMods?: Braid["laneMods"];237+ wanderer?: boolean;238+}239+240+/** Hard cap on a fork's total main-lane span (fork → merge), cells.241+ * The continuation must merge before this — bounded irresolution. */242+export const OPEN_BRAID_CAP = 34;243+216244 export interface SegmentPlan {217245 index: number;218246 cells: OpenCell[];
@@ -238,6 +266,9 @@ export interface HeadState {
238266 anchorEvery: number;239267 /** Monotone island counter — ids must be world-unique. */240268 islandCount: number;269+ /** A fork still unresolved at this boundary (V3) — the next segment270+ * MUST open with its continuation and merge within the cap. */271+ openBraid?: OpenBraidState;241272 }242273 243274 /** The line spawns anchored: segment 0 opens with its home island due. */
@@ -300,6 +331,11 @@ class SegmentBuilder {
300331 private readonly ref: string;301332 private readonly entry: HeadPose;302333 334+ /** Fork left open by the PREVIOUS segment (V3) — resolved first. */335+ private readonly openBraidIn?: OpenBraidState;336+ /** Fork THIS segment leaves open at its boundary (V3). */337+ private openBraidOut?: OpenBraidState;338+303339 constructor(304340 private readonly rng: Rng,305341 private readonly index: number,
@@ -317,9 +353,24 @@ class SegmentBuilder {
317353 this.islandCount = state.islandCount;318354 this.ref = `seg/${index}`;319355 this.entry = { ...state.pose };356+ this.openBraidIn = state.openBraid;320357 }321358 322359 build(): { plan: SegmentPlan; next: HeadState } {360+ // V3: a fork left open at the previous boundary resolves FIRST — the361+ // continuation is not a clause the planner may skip. Failure is a362+ // PlannerStuck (the retry/probe machinery re-attempts; the fork only363+ // committed because a successor probe planned this very364+ // continuation, so a viable label exists).365+ if (this.openBraidIn) {366+ if (!this.tryBraidContinuation(this.rng.child("braid-cont"))) {367+ throw new PlannerStuck(368+ `segment ${this.index}: open braid continuation failed ` +369+ `(offset ${this.openBraidIn.offset}, ` +370+ `${this.openBraidIn.cells} cells since fork)`,371+ );372+ }373+ }323374 while (this.cells.length < SEGMENT_TARGET) {324375 const at = this.cells.length;325376 const clauseRng = this.rng.child(`clause/${at}`);
@@ -342,9 +393,12 @@ class SegmentBuilder {
342393 }343394 // Boundary margin: end the segment inside a straight so the WFC seam344395 // between segments always sits mid-run. Keep flying normal clauses345- // until a 2-cell straight fits.396+ // until a 2-cell straight fits. An OPEN braid (V3) already ran its397+ // straight main lane flush to the boundary — its tail IS the margin,398+ // and appending more cells would strand the alt lane short of the399+ // seam.346400 let guard = 0;347- while (!this.commitSteps(this.straightSteps(2))) {401+ while (!this.openBraidOut && !this.commitSteps(this.straightSteps(2))) {348402 if (guard++ > 40) {349403 throw new PlannerStuck(`closing segment ${this.index}`);350404 }
@@ -412,6 +466,7 @@ class SegmentBuilder {
412466 sinceAnchor: this.sinceAnchor,413467 anchorEvery: this.anchorEvery,414468 islandCount: this.islandCount,469+ ...(this.openBraidOut ? { openBraid: this.openBraidOut } : {}),415470 },416471 };417472 }
@@ -1099,6 +1154,15 @@ class SegmentBuilder {
10991154 const [dx, dz] = DIR_STEP[d];11001155 const w = side === "L" ? turnLeft(d) : turnRight(d);11011156 const [wx, wz] = DIR_STEP[w];1157+ // OPEN FORK (V3, user directive): sometimes the fork does NOT1158+ // resolve this segment — the braid runs flush to the boundary and1159+ // the successor plans the merge (bounded by OPEN_BRAID_CAP). Only1160+ // when the run to the boundary is a sane braid length; the drawn L1161+ // is overridden by that distance.1162+ const toBoundary = SEGMENT_TARGET - this.cells.length;1163+ const open = toBoundary >= 6 && toBoundary <= 18 &&1164+ rng.child("open").chance(0.3);1165+ if (open) L = toBoundary;11021166 const steps = this.straightSteps(L);11031167 const probed = this.probe(steps);11041168 if (!probed) return false;
@@ -1116,8 +1180,11 @@ class SegmentBuilder {
11161180 // stay byte-identical — the wanderer bias is THE M19b reshuffle.11171181 const pRng = rng.child("profile");11181182 const o = new Array<number>(L).fill(1);1119- for (let j = 2; j <= L - 3; j++) {1120- const maxHere = Math.min(B, 1 + (L - 2 - j));1183+ const wanderEnd = open ? L - 1 : L - 3;1184+ for (let j = 2; j <= wanderEnd; j++) {1185+ // An open braid never owes the return-to-1 budget this segment —1186+ // the continuation walks it home.1187+ const maxHere = open ? B : Math.min(B, 1 + (L - 2 - j));11211188 const choices: number[] = [];11221189 for (const step of [-1, 0, 1]) {11231190 const v = o[j - 1] + step;
@@ -1147,8 +1214,9 @@ class SegmentBuilder {
11471214 };11481215 // Branch cell beside the fork: enter sideways, turn into travel.11491216 emit(0, 1, w, d);1150- for (let j = 1; j <= L - 2; j++) {1151- const next = j === L - 2 ? 1 : o[j + 1];1217+ const lastJ = open ? L - 1 : L - 2;1218+ for (let j = 1; j <= lastJ; j++) {1219+ const next = j === lastJ ? (open ? o[j] : 1) : o[j + 1];11521220 if (next === o[j]) {11531221 emit(j, o[j], d, d);11541222 } else if (next > o[j]) {
@@ -1160,7 +1228,9 @@ class SegmentBuilder {
11601228 }11611229 }11621230 // Branch cell beside the merge: turn back into the branch mouth.1163- emit(L - 1, 1, d, opposite(w));1231+ // (An open braid has no merge this segment — its last alt cell1232+ // exits FORWARD across the boundary.)1233+ if (!open) emit(L - 1, 1, d, opposite(w));11641234 11651235 // Alt claims must be free (disjoint from the main lane by offset ≥ 111661236 // construction), and the exit corridor must survive BOTH lanes.
@@ -1174,24 +1244,46 @@ class SegmentBuilder {
11741244 ...altCells.map((c) => key(c.x, c.z)),11751245 ]);11761246 if (!hasFlatContinuation(this.occ, probed.exit, 3, pending)) return false;1247+ if (open) {1248+ // BOTH lanes cross the boundary: the alt lane's forward corridor1249+ // must be plannable too (the successor probe then truly plans it).1250+ // The corridor pose is the NEXT cell PAST the boundary — the1251+ // hasFlatContinuation contract (its own first cell must be free).1252+ const lastAlt = altCells[altCells.length - 1];1253+ const altExit: HeadPose = {1254+ x: lastAlt.x + dx,1255+ z: lastAlt.z + dz,1256+ y: y0,1257+ dir: d,1258+ };1259+ if (!hasFlatContinuation(this.occ, altExit, 3, pending)) return false;1260+ }11771261 11781262 const start = this.cells.length;11791263 if (!this.commitSteps(steps)) return false;11801264 for (const c of altCells) {11811265 this.occ.claim(c.x, c.z, y0, y0 + CHANNEL_CLEARANCE, this.ref);11821266 }1183- // Pin fork + merge to plain 1-cell straights (their placements mutate1184- // straight → split in world.ts; the junction composites assume a bare1185- // straight window). The braid zone marks both: rail centering keeps1186- // main-lane marbles out of the open branch mouths.1267+ // Pin fork (+ merge when it resolves here) to plain 1-cell straights1268+ // (their placements mutate straight → split in world.ts; the1269+ // junction composites assume a bare straight window). The braid zone1270+ // marks the junction hosts: rail centering keeps main-lane marbles1271+ // out of the open branch mouths.11871272 this.reservations.push(11881273 { start, len: 1, pieceIds: ["straight"], reversed: false },1189- { start: start + L - 1, len: 1, pieceIds: ["straight"], reversed: false },1190- );1191- this.mods.push(1192- { start, len: 1, kind: "braid" },1193- { start: start + L - 1, len: 1, kind: "braid" },11941274 );1275+ this.mods.push({ start, len: 1, kind: "braid" });1276+ if (!open) {1277+ this.reservations.push(1278+ {1279+ start: start + L - 1,1280+ len: 1,1281+ pieceIds: ["straight"],1282+ reversed: false,1283+ },1284+ );1285+ this.mods.push({ start: start + L - 1, len: 1, kind: "braid" });1286+ }11951287 // Lane mods (M19b): a surface CONTRAST so lane choice matters — an11961288 // icy lane against a boost lane. Own child stream; tags only (the11971289 // Braid.laneMods law). A wanderer alt always carries the boost
@@ -1220,6 +1312,151 @@ class SegmentBuilder {
12201312 altCells,12211313 ...(laneMods ? { laneMods } : {}),12221314 ...(wanderer ? { wanderer: true } : {}),1315+ ...(open ? { open: true } : {}),1316+ });1317+ if (open) {1318+ this.openBraidOut = {1319+ side: w,1320+ offset: o[L - 1],1321+ cells: L,1322+ ...(laneMods ? { laneMods } : {}),1323+ ...(wanderer ? { wanderer: true } : {}),1324+ };1325+ }1326+ this.hadBraid = true;1327+ return true;1328+ }1329+1330+ /** BRAID CONTINUATION (V3): resolve the fork the previous segment left1331+ * open. Runs FIRST — the alt lane enters at the boundary at the1332+ * carried offset, wanders home, and merges at a pinned straight within1333+ * the cap. No fork host this segment; the merge is the V1 merge. */1334+ private tryBraidContinuation(rng: Rng): boolean {1335+ const ob = this.openBraidIn!;1336+ const d = this.pose.dir;1337+ const y0 = this.pose.y;1338+ const [dx, dz] = DIR_STEP[d];1339+ const w = ob.side;1340+ const [wx, wz] = DIR_STEP[w];1341+ const remaining = OPEN_BRAID_CAP - ob.cells;1342+ const minL = Math.max(6, ob.offset + 4);1343+ const maxL = Math.max(minL, Math.min(13, remaining));1344+ // Longest first — a failed probe steps the merge closer (bounded1345+ // retry inside one attempt, deterministic).1346+ const want = rng.int(minL, maxL + 1);1347+ for (let L2 = want; L2 >= minL; L2--) {1348+ if (this.planContinuation(rng.child(`fit/${L2}`), L2, d, y0, w, [1349+ dx,1350+ dz,1351+ ], [wx, wz], ob)) {1352+ return true;1353+ }1354+ }1355+ return false;1356+ }1357+1358+ private planContinuation(1359+ rng: Rng,1360+ L2: number,1361+ d: Dir,1362+ y0: number,1363+ w: Dir,1364+ [dx, dz]: readonly [number, number],1365+ [wx, wz]: readonly [number, number],1366+ ob: OpenBraidState,1367+ ): boolean {1368+ const steps = this.straightSteps(L2);1369+ const probed = this.probe(steps);1370+ if (!probed) return false;1371+ const first = probed.cells[0];1372+1373+ // Offset profile home: o[0] is the carried boundary offset; wander1374+ // with the return budget so o[L2−2] lands at 1 for the merge mouth.1375+ const pRng = rng.child("profile");1376+ const o = new Array<number>(L2).fill(1);1377+ o[0] = ob.offset;1378+ for (let j = 1; j <= L2 - 3; j++) {1379+ const maxHere = Math.min(3, 1 + (L2 - 2 - j));1380+ const choices: number[] = [];1381+ for (const step of [-1, 0, 1]) {1382+ const v = o[j - 1] + step;1383+ if (v >= 1 && v <= maxHere) choices.push(v);1384+ }1385+ if (choices.length === 0) return false;1386+ o[j] = ob.wanderer1387+ ? pRng.weighted(choices.map((c) => [c, c * c] as const))1388+ : pRng.pick(choices);1389+ }1390+ o[L2 - 2] = 1;1391+ // |Δ| ≤ 1 must hold into the forced return cell too.1392+ if (L2 >= 3 && Math.abs(o[L2 - 3] - 1) > 1) return false;1393+1394+ const cellAt = (j: number, off: number): readonly [number, number] => [1395+ first.x + dx * j + wx * off,1396+ first.z + dz * j + wz * off,1397+ ];1398+ const altCells: OpenCell[] = [];1399+ const emit = (j: number, off: number, dirIn: Dir, dirOut: Dir): void => {1400+ const [x, z] = cellAt(j, off);1401+ altCells.push({1402+ x,1403+ z,1404+ dirIn,1405+ dirOut,1406+ move: moveLabel(dirIn, dirOut),1407+ baseY: y0,1408+ });1409+ };1410+ // The boundary cell continues FORWARD at the carried offset — no1411+ // fork branch cell (the fork lives segments back).1412+ for (let j = 0; j <= L2 - 2; j++) {1413+ const next = j === L2 - 2 ? 1 : o[j + 1];1414+ if (next === o[j]) {1415+ emit(j, o[j], d, d);1416+ } else if (next > o[j]) {1417+ emit(j, o[j], d, w);1418+ emit(j, next, w, d);1419+ } else {1420+ emit(j, o[j], d, opposite(w));1421+ emit(j, next, opposite(w), d);1422+ }1423+ }1424+ // Branch cell beside the merge: turn back into the branch mouth.1425+ emit(L2 - 1, 1, d, opposite(w));1426+1427+ for (const c of altCells) {1428+ if (!this.occ.isFree(c.x, c.z, y0, y0 + CHANNEL_CLEARANCE)) {1429+ return false;1430+ }1431+ }1432+ const pending = new Set([1433+ ...probed.cells.map((p) => key(p.x, p.z)),1434+ ...altCells.map((c) => key(c.x, c.z)),1435+ ]);1436+ if (!hasFlatContinuation(this.occ, probed.exit, 3, pending)) return false;1437+1438+ const start = this.cells.length;1439+ if (!this.commitSteps(steps)) return false;1440+ for (const c of altCells) {1441+ this.occ.claim(c.x, c.z, y0, y0 + CHANNEL_CLEARANCE, this.ref);1442+ }1443+ this.reservations.push(1444+ {1445+ start: start + L2 - 1,1446+ len: 1,1447+ pieceIds: ["straight"],1448+ reversed: false,1449+ },1450+ );1451+ this.mods.push({ start: start + L2 - 1, len: 1, kind: "braid" });1452+ this.braids.push({1453+ start,1454+ len: L2,1455+ side: w,1456+ altCells,1457+ cont: true,1458+ ...(ob.laneMods ? { laneMods: ob.laneMods } : {}),1459+ ...(ob.wanderer ? { wanderer: true } : {}),12231460 });12241461 this.hadBraid = true;12251462 return true;
M src/gen/endless/proxies.ts +10 -3
@@ -442,8 +442,11 @@ export function channelProxies(
442442 // Composite-carried windows: diamonds/spurs (alt), both braid443443 // junctions (the fork carries the alt, the merge its own flag),444444 // and grade crossings (host composite / quiet phantom) — the445- // ribbon stops at their ports.446- seg.alt !== undefined || seg.braidMerge === true ||445+ // ribbon stops at their ports. V3: a CONTINUATION chain's carrier446+ // window is a PLAIN channel (the chain merely hangs there; the447+ // real junction lives segments back) — its ribbon must stay.448+ (seg.alt !== undefined && seg.alt.contBraid !== true) ||449+ seg.braidMerge === true ||447450 seg.junction !== undefined,448451 wideScales?.get(segIdx) ?? null,449452 holes,
@@ -665,7 +668,11 @@ export function channelProxies(
665668 const total = forkSeg.alt.length;666669 const near = pointAt(forkSeg.alt.curve, Math.min(2.5, total / 2));667670 const far = pointAt(forkSeg.alt.curve, Math.max(total - 2.5, total / 2));668- emitBraidJunction(forkSeg, near, out);671+ // V3: a CONTINUATION chain enters at the segment boundary — its672+ // entry host is a plain straight, not a junction; cutting a fork673+ // mouth into it would open a hole in an ordinary channel wall.674+ if (!forkSeg.alt.contBraid) emitBraidJunction(forkSeg, near, out);675+ // Likewise an OPEN chain exits at the boundary — no merge here.669676 const mergeSeg = path.segments.find((s) => s.braidMerge);670677 if (mergeSeg) emitBraidJunction(mergeSeg, far, out);671678 }
M src/gen/endless/world.ts +89 -47
@@ -92,6 +92,10 @@ export interface EndlessSegment {
9292 * `placements` so the proxy path structurally cannot see it (never9393 * collides, never claims occupancy). */9494 voidDecor: Placement[];95+ /** Braid V3: this segment RESOLVES a fork opened in the named earlier96+ * segment — the client keys per-racer lane draws on the FORK segment97+ * so a rider's lane survives the boundary. */98+ braidContOf?: number;9599 /** Cumulative arc length of the line at this segment's entry. */96100 startS: number;97101 /** Presentation frame (M16 gravity reorientation): how this segment's
@@ -598,27 +602,39 @@ export class EndlessWorld {
598602 if (!altWindows) break; // realized with the plan — always present599603 const forkIdx = braid.start;600604 const mergeIdx = braid.start + braid.len - 1;601- const wi = windows.findIndex((w) => w.start === forkIdx && w.len === 1);602- const wj = windows.findIndex((w) => w.start === mergeIdx && w.len === 1);603- if (wi < 0 || wj < 0) continue; // reserved by the planner — always found605+ // V3: an OPEN braid has no merge this segment; a CONT braid has no606+ // fork (it resolves the previous segment's open fork). The host607+ // ls for the alt chain is the fork window, or — for a cont — the608+ // FIRST window (the chain enters at the segment boundary).609+ const wi = braid.cont610+ ? 0611+ : windows.findIndex((w) => w.start === forkIdx && w.len === 1);612+ const wj = braid.open613+ ? -1614+ : windows.findIndex((w) => w.start === mergeIdx && w.len === 1);615+ if (wi < 0 || (!braid.open && wj < 0)) continue; // planner-reserved604616 const lsFork = assembled.loop.segments[wi];605- const lsMerge = assembled.loop.segments[wj];606- const hpF = lsFork.placementIndex >= 0607- ? assembled.placements[lsFork.placementIndex]608- : undefined;609- const hpM = lsMerge.placementIndex >= 0610- ? assembled.placements[lsMerge.placementIndex]611- : undefined;612- if (!hpF || hpF.pieceId !== "straight") continue;613- if (!hpM || hpM.pieceId !== "straight") continue;614- const fork = plan.cells[forkIdx];615- const merge = plan.cells[mergeIdx];616- hpF.pieceId = "split";617- if (braid.side === turnRight(fork.dirIn)) hpF.mirrored = true;618- hpM.pieceId = "split";619- hpM.rotation = ((hpM.rotation + 2) % 4) as Dir;620- if (braid.side === turnLeft(merge.dirIn)) hpM.mirrored = true;621- lsMerge.braidMerge = true;617+ if (!braid.cont) {618+ const hpF = lsFork.placementIndex >= 0619+ ? assembled.placements[lsFork.placementIndex]620+ : undefined;621+ if (!hpF || hpF.pieceId !== "straight") continue;622+ const fork = plan.cells[forkIdx];623+ hpF.pieceId = "split";624+ if (braid.side === turnRight(fork.dirIn)) hpF.mirrored = true;625+ }626+ if (!braid.open) {627+ const lsMerge = assembled.loop.segments[wj];628+ const hpM = lsMerge.placementIndex >= 0629+ ? assembled.placements[lsMerge.placementIndex]630+ : undefined;631+ if (!hpM || hpM.pieceId !== "straight") continue;632+ const merge = plan.cells[mergeIdx];633+ hpM.pieceId = "split";634+ hpM.rotation = ((hpM.rotation + 2) % 4) as Dir;635+ if (braid.side === turnLeft(merge.dirIn)) hpM.mirrored = true;636+ lsMerge.braidMerge = true;637+ }622638 623639 const altSkeleton: Skeleton = {624640 cells: braid.altCells,
@@ -645,7 +661,12 @@ export class EndlessWorld {
645661 ): "icy" | "boost" | null => {646662 const lm = braid.laneMods?.alt;647663 if (!lm) return null;648- return win.start >= 2 && win.start + win.len <= chainN - 2 ? lm : null;664+ // Lip margins exist only at ends that ARE junctions (V3): an665+ // open braid's far end and a cont braid's near end are segment666+ // boundaries, not mouths.667+ const nearOk = braid.cont ? true : win.start >= 2;668+ const farOk = braid.open ? true : win.start + win.len <= chainN - 2;669+ return nearOk && farOk ? lm : null;649670 };650671 // Tagged windows recolor their placements too (zones must READ).651672 const altSegs = altAssembled.loop.segments;
@@ -674,56 +695,68 @@ export class EndlessWorld {
674695 }675696 braidAltLoop = altAssembled.loop;676697 677- // The alt chain: fork entry port → junction pull-off out the branch678- // mouth (the sub-loop's centerline starts exactly at the fork's679- // branch PORT and ends at the merge's) → the whole alt lane →680- // pull-in through the merge junction → merge exit port.681- const floor = fork.baseY + 0.25;682- const [fx, fz] = DIR_STEP[fork.dirIn];698+ // The alt chain (V3 cases): a CLOSED braid runs fork entry port →699+ // junction pull-off → alt lane → merge pull-in → merge exit port.700+ // An OPEN braid ends with the alt lane at the boundary (no tail);701+ // a CONT chain starts with the alt lane at the boundary (no head).702+ // Boundary ports are exact, so consecutive segments' chains meet703+ // the way main-channel windows do.704+ const forkCell = plan.cells[forkIdx];705+ const mergeCell = plan.cells[mergeIdx];706+ const floor = forkCell.baseY + 0.25;707+ const [fx, fz] = DIR_STEP[forkCell.dirIn];683708 const [wx, wz] = DIR_STEP[braid.side];684- const curve: CurveSeg[] = [709+ const head: CurveSeg[] = braid.cont ? [] : [685710 {686711 kind: "line",687- a: [fork.x - fx / 2, floor, fork.z - fz / 2],688- b: [fork.x - fx * 0.1, floor, fork.z - fz * 0.1],712+ a: [forkCell.x - fx / 2, floor, forkCell.z - fz / 2],713+ b: [forkCell.x - fx * 0.1, floor, forkCell.z - fz * 0.1],689714 },690715 {691716 kind: "cubic",692717 p: [693- [fork.x - fx * 0.1, floor, fork.z - fz * 0.1],694- [fork.x, floor, fork.z],695- [fork.x + wx * 0.28, floor, fork.z + wz * 0.28],696- [fork.x + wx * 0.5, floor, fork.z + wz * 0.5],718+ [forkCell.x - fx * 0.1, floor, forkCell.z - fz * 0.1],719+ [forkCell.x, floor, forkCell.z],720+ [forkCell.x + wx * 0.28, floor, forkCell.z + wz * 0.28],721+ [forkCell.x + wx * 0.5, floor, forkCell.z + wz * 0.5],697722 ] as [Vec3, Vec3, Vec3, Vec3],698723 },699- ...altAssembled.loop.segments.flatMap((s) => s.curve),724+ ];725+ const tail: CurveSeg[] = braid.open ? [] : [700726 {701727 kind: "cubic",702728 p: [703- [merge.x + wx * 0.5, floor, merge.z + wz * 0.5],704- [merge.x + wx * 0.28, floor, merge.z + wz * 0.28],705- [merge.x, floor, merge.z],706- [merge.x + fx * 0.1, floor, merge.z + fz * 0.1],729+ [mergeCell.x + wx * 0.5, floor, mergeCell.z + wz * 0.5],730+ [mergeCell.x + wx * 0.28, floor, mergeCell.z + wz * 0.28],731+ [mergeCell.x, floor, mergeCell.z],732+ [mergeCell.x + fx * 0.1, floor, mergeCell.z + fz * 0.1],707733 ] as [Vec3, Vec3, Vec3, Vec3],708734 },709735 {710736 kind: "line",711- a: [merge.x + fx * 0.1, floor, merge.z + fz * 0.1],712- b: [merge.x + fx / 2, floor, merge.z + fz / 2],737+ a: [mergeCell.x + fx * 0.1, floor, mergeCell.z + fz * 0.1],738+ b: [mergeCell.x + fx / 2, floor, mergeCell.z + fz / 2],713739 },714740 ];715- // Per-part zone tags aligned with the chain curve: 2 junction716- // pull-off parts, each alt window's parts, 2 pull-in parts.717- const zones: (string | null)[] = [null, null];741+ const curve: CurveSeg[] = [742+ ...head,743+ ...altAssembled.loop.segments.flatMap((s) => s.curve),744+ ...tail,745+ ];746+ // Per-part zone tags aligned with the chain curve: junction parts747+ // untagged at whichever ends exist.748+ const zones: (string | null)[] = head.map(() => null);718749 for (let k = 0; k < altSegs.length; k++) {719750 const z = altZoneOfWindow(altWindows[k]);720751 for (let c = 0; c < altSegs[k].curve.length; c++) zones.push(z);721752 }722- zones.push(null, null);753+ for (const _ of tail) zones.push(null);723754 lsFork.alt = {724755 curve,725756 length: curve.reduce((acc, s) => acc + segLength(s), 0),726757 braid: true,758+ ...(braid.cont ? { contBraid: true } : {}),759+ ...(braid.open ? { openBraid: true } : {}),727760 ...(zones.some((z) => z !== null) ? { zones } : {}),728761 };729762 }
@@ -781,8 +814,11 @@ export class EndlessWorld {
781814 for (const braid of plan.braids) {782815 const main = braid.laneMods?.main;783816 if (!main) continue;784- const lo = braid.start + 2;785- const hi = braid.start + braid.len - 3;817+ // V3: lip margins only at real junction ends (see altZoneOfWindow).818+ const lo = braid.cont ? braid.start : braid.start + 2;819+ const hi = braid.open820+ ? braid.start + braid.len - 1821+ : braid.start + braid.len - 3;786822 for (let wi = 0; wi < windows.length; wi++) {787823 const w = windows[wi];788824 if (
@@ -942,6 +978,8 @@ export class EndlessWorld {
942978 ...(s.alt.spur ? { spur: true } : {}),943979 ...(s.alt.braid ? { braid: true } : {}),944980 ...(s.alt.zones ? { zones: s.alt.zones } : {}),981+ ...(s.alt.contBraid ? { contBraid: true } : {}),982+ ...(s.alt.openBraid ? { openBraid: true } : {}),945983 }946984 : undefined;947985 return {
@@ -1027,6 +1065,10 @@ export class EndlessWorld {
10271065 // Void decor last (M18b): its own labeled family, consuming10281066 // nothing from any stream above — and fed the finished segment.10291067 voidDecor: planVoidDecor(this.seed, i, path, altPath, islands, this.occ),1068+ // V3: a continuation's fork is always the immediately previous1069+ // segment (open forks resolve at the very next boundary; the CAP1070+ // bounds the total span, not the segment count).1071+ ...(plan.braids.some((b) => b.cont) ? { braidContOf: i - 1 } : {}),10301072 startS: this.nextStartS,10311073 ...(frame ? { frame } : {}),10321074 });
M src/shared/types.ts +8 -0
M tests/braid_test.ts +109 -7
@@ -21,6 +21,7 @@ import { TRACK_SCALE } from "../src/gen/endless/scale.ts";
2121 import { channelProxies, wideScalesOf } from "../src/gen/endless/proxies.ts";2222 import { gapSegmentsOf, railForSegment } from "../src/client/physics/rail.ts";2323 import { braidLaneAlt } from "../src/client/physics/autopilot.ts";24+import { OPEN_BRAID_CAP } from "../src/gen/endless/path.ts";2425 import { goldenCatalog, segmentHash } from "../tools/pin_endless.ts";2526 import { rngFamily } from "../src/gen/rng.ts";2627 import { DIR_STEP, opposite, segPoint, turnRight } from "../src/gen/geom.ts";
@@ -85,6 +86,9 @@ Deno.test("braid: every braid obeys the fork/merge/lane contract", () => {
8586 );8687 for (const braid of seg.plan.braids) {8788 braids++;89+ // V3 open/cont braids have their own contract test below — the90+ // closed fork/merge law here doesn't apply to them.91+ if (braid.open || braid.cont) continue;8892 const { cells } = seg.plan;8993 const fork = cells[braid.start];9094 const merge = cells[braid.start + braid.len - 1];
@@ -470,9 +474,10 @@ Deno.test("braid lanes: tags obey the lip law, wanderers get their boost", () =>
470474 tagged++;471475 // MAIN lane: tagged windows only ≥2 cells from fork AND merge472476 // (windowMods carries the tag; fork/merge windows carry473- // "braid" and their neighbors stay untagged).474- const lo = b.start + 2;475- const hi = b.start + b.len - 3;477+ // "braid" and their neighbors stay untagged). V3: margins only478+ // at ends that ARE junctions — boundaries are not mouths.479+ const lo = b.cont ? b.start : b.start + 2;480+ const hi = b.open ? b.start + b.len - 1 : b.start + b.len - 3;476481 for (const [wi, wr] of seg.windowRanges.entries()) {477482 const m = seg.windowMods[wi];478483 if (m !== "icy" && m !== "boost") continue;
@@ -500,10 +505,16 @@ Deno.test("braid lanes: tags obey the lip law, wanderers get their boost", () =>
500505 assert(fork?.alt?.zones, `${seed} seg ${i}: alt tag lost`);501506 const zones = fork!.alt!.zones!;502507 assertEquals(zones.length, fork!.alt!.curve.length);503- assertEquals(zones[0], null);504- assertEquals(zones[1], null);505- assertEquals(zones[zones.length - 1], null);506- assertEquals(zones[zones.length - 2], null);508+ // Junction parts stay untagged at whichever ends exist (V3:509+ // open chains end at a boundary, cont chains start at one).510+ if (!b.cont) {511+ assertEquals(zones[0], null);512+ assertEquals(zones[1], null);513+ }514+ if (!b.open) {515+ assertEquals(zones[zones.length - 1], null);516+ assertEquals(zones[zones.length - 2], null);517+ }507518 assert(508519 zones.some((z) => z === lm.alt),509520 `${seed} seg ${i}: no interior part carries the alt tag`,
@@ -552,3 +563,94 @@ Deno.test("braid lanes: rail stations resolve the tag OVER the fork's centering"
552563 );553564 }554565 });566+567+// ---------------------------------------------------------------------------568+// V3 (user directive) — forks that resolve in LATER generation.569+// ---------------------------------------------------------------------------570+571+Deno.test("open forks: resolve next segment, bounded, port-continuous", () => {572+ let opens = 0;573+ let conts = 0;574+ for (const fam of ["grav", "jump", "wide", "helix"]) {575+ for (let n = 0; n < 30; n++) {576+ const seed = `${fam}-scan-${n}`;577+ let w: EndlessWorld;578+ try {579+ w = new EndlessWorld(seed, catalog);580+ } catch {581+ continue;582+ }583+ for (let i = 0; i < 7; i++) {584+ let seg;585+ try {586+ seg = w.get(i);587+ } catch {588+ break;589+ }590+ const openBraid = seg.plan.braids.find((b) => b.open);591+ if (!openBraid) continue;592+ opens++;593+ // The open segment: fork mutated, NO merge here, alt chain flagged594+ // open and ending at the segment boundary.595+ const lsF = seg.path.segments.find((ls) => ls.alt?.braid);596+ assert(lsF?.alt?.openBraid, `${seed} seg ${i}: open chain unflagged`);597+ assert(598+ !seg.path.segments.some((ls) => ls.braidMerge),599+ `${seed} seg ${i}: open braid must not merge in its own segment`,600+ );601+ // The successor resolves it: cont braid, merge mutation, lane602+ // carried, total span under the cap.603+ const nextSeg = w.get(i + 1);604+ const cont = nextSeg.plan.braids.find((b) => b.cont);605+ assert(cont, `${seed} seg ${i}: open fork never resolved`);606+ conts++;607+ assertEquals(608+ nextSeg.braidContOf,609+ i,610+ `${seed} seg ${i + 1}: braidContOf must name the fork segment`,611+ );612+ assertEquals(cont!.side, openBraid.side, "side must carry over");613+ assertEquals(614+ cont!.laneMods,615+ openBraid.laneMods,616+ "lane mods must carry over",617+ );618+ assert(619+ openBraid.len + cont!.len <= OPEN_BRAID_CAP,620+ `${seed}: fork span ${openBraid.len}+${cont!.len} over the cap`,621+ );622+ assert(623+ nextSeg.path.segments.some((ls) => ls.braidMerge),624+ `${seed} seg ${i + 1}: continuation lost its merge junction`,625+ );626+ // Port continuity: the open chain's end is the cont chain's start627+ // (world coords — the alt lane crosses the boundary seamlessly).628+ const lsC = nextSeg.path.segments.find((ls) => ls.alt?.braid);629+ assert(lsC?.alt?.contBraid, `${seed} seg ${i + 1}: cont unflagged`);630+ const openEnd = segPoint(631+ lsF!.alt!.curve[lsF!.alt!.curve.length - 1],632+ 1,633+ );634+ const contStart = segPoint(lsC!.alt!.curve[0], 0);635+ assert(636+ Math.hypot(637+ openEnd[0] - contStart[0],638+ openEnd[1] - contStart[1],639+ openEnd[2] - contStart[2],640+ ) < 1e-6,641+ `${seed}: alt lane tears at the ${i}/${i + 1} boundary ` +642+ `(${openEnd} vs ${contStart})`,643+ );644+ // The continuation merges home: its chain ends at the merge exit645+ // port on the MAIN lane (already asserted via braidMerge above);646+ // and no cont exists without an open predecessor.647+ assert(648+ seg.plan.braids.some((b) => b.open),649+ "cont without an open predecessor",650+ );651+ }652+ }653+ }654+ assert(opens > 3, `only ${opens} open forks across the corpus`);655+ assertEquals(opens, conts, "every open fork must resolve exactly once");656+});
M tests/physics_test.ts +23 -0