Lane mods + wanderer character — one intentional reshuffle, tag-only law:
- path.ts: Braid.laneMods drawn from a new child stream (0.5 none /
0.25 icy-alt+boost-main / 0.25 inverse); wanderer character (own
child, 0.35) leans the SAME profile range outward via weighted picks
(pick and weighted both consume one draw — non-wanderer braids stay
byte-identical; the wanderer bias IS the reshuffle) and always carries
the alt-boost compensation, so the long way around is never a strict
loser. Breadth stays capped at B (no new twist-window veto pressure).
- TAG-ONLY LAW: lane mods never join plan.mods (realize's exclude rules
must not see them — geometry is sacred), never pin windows. world.ts
stamps main-lane windowMods over the braid INTERIOR only (>=2 cells
from fork and merge — the junction-lip law in friction form) and hangs
per-part zone tags on the alt chain (LoopSegment.alt.zones, chain ends
untagged); tagged windows' placements carry zone for the recolor.
- world.ts realize generalized over plan.braids (bare braid-wfc label
for braid 0 — existing seeds byte-identical; >1 braid is now a
one-line planner change away, with its own pin cycle).
- rail.ts: alt-chain stations OVERLAY the tag on the fork's centering
({center, icy}/{center, speed}) — untagged chains (diamonds, spurs,
V1 braids) byte-identical.
- Presentation: BOOST_PIPE amber joins ICE_PIPE/GRAVITY_PIPE — boost
zones (sprints AND boost lanes) now recolor: zones must read.
- Tests: corpus lip-law scan (plan.mods zones exempt — a jump's boost
approach may brush the merge), wanderer-compensation law, alt-zones
alignment, rail overlay signature on the pinned grav-scan-6@4 braid;
drive() soak clears the tagged braid on BOTH lanes. New pins:
grav-scan-6@4 icy/wanderer, grav-scan-10@3 boost lane; braid pins
grav-scan-17/38 held. 199 green, check clean, endless goldens
re-pinned, genVersion g491fc4c7a685 unchanged.- commit
- eb4271346ab5c2112dfc25c36041e7611c8fb6b9
- parent
- fb13e4f3dbbd8f5858a573c0801eae576979a7ff
- author
- Dami <dami.etoile@gmail.com>
| M | progress.md | 10 | ++- |
| M | src/ | 5 | + |
| M | src/ | 14 | +++- |
| M | src/ | 55 | +++++++++++++- |
| M | src/ | 106 | +++++++++++++++++++++++--- |
| M | src/ | 5 | + |
| M | tests/ | 121 | ++++++++++++++++++++++++++++++ |
| M | tests/ | 78 | ++++++++++---------- |
| M | tests/ | 20 | +++++ |
@@ -65,6 +65,13 @@ 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).6875 - **Theme biomes (M18a residual)**: winter/spooky/tropical/autumn rows6976 shipped on Holiday/Graveyard/Pirate/Survival kits. Open: biome-weight7077 and scale-range tuning after watching real streams, and the SPACE@@ -94,7 +101,8 @@ cross composite, quantitative metrics CI).
94101 jump, helix-scan-6 propeller, grav-scan-9 diamond, wide-scan-7 wide,95102 grav-scan-6 helix, wide-scan-17 icy, wide-scan-21 gravity,96103 jump-scan-4 + jump-scan-12 spurs, jump-scan-25 + grav-scan-3397- reorients, grav-scan-17/38 + marbles braids, wide-scan-13 gust bank);104+ 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);98106 regenerate endless goldens with `deno run --allow-read --allow-write99107 tools/pin_endless.ts`. Canon note: golden placements hash wind100108 pulse/swirl since M19a.@@ -113,6 +113,9 @@ const ZONE_SEGMENTS = 5;
113113 const ICE_PIPE: readonly [number, number, number] = [178, 224, 240];114114 /** Violet-charged pipe recolor for gravity-rotation zones. */115115 const GRAVITY_PIPE: readonly [number, number, number] = [186, 148, 255];116+/** Boost surfaces (M19b): sprint zones and braid boost lanes read warm117+ * amber — the lane contrast must READ (zones-must-read law). */118+const BOOST_PIPE: readonly [number, number, number] = [255, 178, 64];116119 117120 /** Peer count above which peers render as one InstancedMesh (per-racer118121 * kit meshes + cloned materials would swamp the scene). */@@ -1187,6 +1190,8 @@ class Session {
11871190 ? ICE_PIPE11881191 : p.zone === "gravity"11891192 ? GRAVITY_PIPE1193+ : p.zone === "boost"1194+ ? BOOST_PIPE11901195 : p.chapter === undefined11911196 ? null11921197 : this.chapterColor(p.chapter);@@ -265,7 +265,17 @@ export function railForSegment(seg: RailSource, useAlt = false): Rail {
265265 const mod = (zone ? ZONE_MODS[zone] : undefined) ??266266 TILE_MODS[seg.pieceFamilies[lsIdx]] ?? null;267267 const alt = useAlt && ls.alt;268- for (const part of (alt ? ls.alt!.curve : ls.curve)) {268+ const parts = alt ? ls.alt!.curve : ls.curve;269+ for (const [partIdx, part] of parts.entries()) {270+ // Braid lane mods (M19b): per-part zone tags OVERLAY the host271+ // window's mod on the alt chain — the fork's centering still rides272+ // the whole lane (keeps wishdir riders on the wander), the tag273+ // adds its surface on top. Untagged chains (diamonds, spurs, V1274+ // braids) are byte-identical to before.275+ const altZone = alt ? ls.alt!.zones?.[partIdx] : undefined;276+ const partMod = altZone277+ ? { ...(mod ?? {}), ...ZONE_MODS[altZone] }278+ : mod;269279 const plen = segLength(part);270280 const steps = Math.max(1, Math.ceil(plen / RAIL_STEP));271281 for (let j = 0; j < steps; j++) {@@ -276,7 +286,7 @@ export function railForSegment(seg: RailSource, useAlt = false): Rail {
276286 rail.pts.push([a[0], a[1], a[2]]);277287 rail.tans.push([d[0] / l, d[1] / l, d[2] / l]);278288 rail.s.push(acc);279- rail.mods.push(mod);289+ rail.mods.push(partMod);280290 acc += l;281291 }282292 }@@ -199,6 +199,18 @@ export interface Braid {
199199 /** The alt lane as a real adjacent-cell walk, fork branch cell →200200 * merge branch cell, flat at the fork's baseY. */201201 altCells: OpenCell[];202+ /** Lane character (M19b): surface contrast between the lanes — TAGS203+ * ONLY (rail-station modifiers + pipe recolor). The tag-only law:204+ * laneMods never pin windows, never join plan.mods (realize's exclude205+ * rules must not see them), never alter geometry. Both lanes exclude206+ * tags within 2 cells of fork and merge (the junction-lip law in207+ * friction form — an icy patch at a merge mouth parks a slow rider on208+ * the lip). */209+ laneMods?: { main?: "icy" | "boost"; alt?: "icy" | "boost" };210+ /** Wanderer character (M19b): the alt lane leaned into the far211+ * offsets this draw — always compensated by an alt boost tag so the212+ * long way around is never a strict loser. */213+ wanderer?: boolean;202214 }203215 204216 export interface SegmentPlan {@@ -1092,9 +1104,16 @@ class SegmentBuilder {
10921104 if (!probed) return false;10931105 const fork = probed.cells[0];10941106 1107+ // Wanderer character (M19b): drawn from its own child so non-braid1108+ // streams never move. Breadth stays capped at B — the wanderer only1109+ // LEANS the same profile range outward, so its claims are never1110+ // wider than V1 could produce (no new twist-window veto pressure).1111+ const wanderer = rng.child("character").chance(0.35);10951112 // Offset profile per main column j = 1..L−2: starts and ends at 110961113 // (the fork/merge branch mouths), wanders in [1, B], |Δ| ≤ 1, with1097- // the return-to-1 budget enforced ahead of time.1114+ // the return-to-1 budget enforced ahead of time. pick() and1115+ // weighted() both consume exactly ONE draw, so non-wanderer braids1116+ // stay byte-identical — the wanderer bias is THE M19b reshuffle.10981117 const pRng = rng.child("profile");10991118 const o = new Array<number>(L).fill(1);11001119 for (let j = 2; j <= L - 3; j++) {@@ -1104,7 +1123,9 @@ class SegmentBuilder {
11041123 const v = o[j - 1] + step;11051124 if (v >= 1 && v <= maxHere) choices.push(v);11061125 }1107- o[j] = pRng.pick(choices);1126+ o[j] = wanderer1127+ ? pRng.weighted(choices.map((c) => [c, c * c] as const))1128+ : pRng.pick(choices);11081129 }11091130 11101131 // The alt walk (see the clause comment for the move law).@@ -1171,7 +1192,35 @@ class SegmentBuilder {
11711192 { start, len: 1, kind: "braid" },11721193 { start: start + L - 1, len: 1, kind: "braid" },11731194 );1174- this.braids.push({ start, len: L, side: w, altCells });1195+ // Lane mods (M19b): a surface CONTRAST so lane choice matters — an1196+ // icy lane against a boost lane. Own child stream; tags only (the1197+ // Braid.laneMods law). A wanderer alt always carries the boost1198+ // compensation, whatever the contrast draw said.1199+ const lRng = rng.child("lanes");1200+ const laneRoll = lRng.float();1201+ let laneMods: Braid["laneMods"] = laneRoll < 0.51202+ ? undefined1203+ : laneRoll < 0.751204+ ? { alt: "icy", main: "boost" }1205+ : { alt: "boost", main: "icy" };1206+ if (wanderer) {1207+ // Compensation trumps the contrast draw; a boost-vs-boost pair is1208+ // no contrast at all, so the main tag drops in that case.1209+ laneMods = {1210+ alt: "boost",1211+ ...(laneMods?.main && laneMods.main !== "boost"1212+ ? { main: laneMods.main }1213+ : {}),1214+ };1215+ }1216+ this.braids.push({1217+ start,1218+ len: L,1219+ side: w,1220+ altCells,1221+ ...(laneMods ? { laneMods } : {}),1222+ ...(wanderer ? { wanderer: true } : {}),1223+ });11751224 this.hadBraid = true;11761225 return true;11771226 }@@ -227,9 +227,10 @@ export class EndlessWorld {
227227 plan: ReturnType<typeof planSegment>["plan"];228228 next: ReturnType<typeof planSegment>["next"];229229 windows: ReturnType<typeof realize>;230- /** Braid alt-lane windows (M17) — realized inside the same231- * transaction; null when the plan carries no braid. */232- altWindows: ReturnType<typeof realize> | null;230+ /** Braid alt-lane windows (M17, per braid since M19b) —231+ * realized inside the same transaction; empty when the plan232+ * carries no braid. */233+ altWindowsOf: ReturnType<typeof realize>[];233234 skeleton: Skeleton;234235 elevation: ElevationPlan;235236 tx: OccupancyTx;@@ -363,9 +364,14 @@ export class EndlessWorld {
363364 // must be claimed before the exit-corridor checks and the364365 // successor probe run). No boost zones exist on the alt lane, so365366 // bumps are excluded outright (the main realize's momentum rule).366- let altWindows: ReturnType<typeof realize> | null = null;367- if (plan.braids.length > 0) {368- const braid = plan.braids[0];367+ // Generalized over plan.braids (M19b): the planner still emits at368+ // most one, but the realize is a loop — flipping that cap later is369+ // a planner-side weight change with its own pin cycle. Braid 0370+ // keeps the bare "braid-wfc" child label so existing seeds draw371+ // byte-identically.372+ const altWindowsOf: ReturnType<typeof realize>[] = [];373+ let altWedged = false;374+ for (const [bi, braid] of plan.braids.entries()) {369375 const altSkeleton: Skeleton = {370376 cells: braid.altCells,371377 crossings: [],@@ -376,9 +382,10 @@ export class EndlessWorld {
376382 reservations: [],377383 maxBaseY: Math.max(...braid.altCells.map((c) => c.baseY)),378384 };385+ let altWindows: ReturnType<typeof realize>;379386 try {380387 altWindows = realize(381- rng.child("braid-wfc"),388+ bi === 0 ? rng.child("braid-wfc") : rng.child(`braid-wfc/${bi}`),382389 altSkeleton,383390 altElevation,384391 this.style,@@ -400,8 +407,10 @@ export class EndlessWorld {
400407 } catch (_err) {401408 // A wedged alt collapse discards the whole attempt — same402409 // contract as a planner dead end, deterministic per seed.403- continue;410+ altWedged = true;411+ break;404412 }413+ altWindowsOf.push(altWindows);405414 for (const win of altWindows) {406415 const cell = braid.altCells[win.start];407416 const xf = windowXform(cell.x, cell.z, cell.dirIn, 0);@@ -422,6 +431,7 @@ export class EndlessWorld {
422431 }423432 }424433 }434+ if (altWedged) continue;425435 // Exit viability, two stages. First the cheap one: a flat corridor426436 // must survive this attempt's claims (path, islands, canopy, WFC427437 // extras). Then the real one: PLAN THE SUCCESSOR as probes on a@@ -433,7 +443,7 @@ export class EndlessWorld {
433443 // good attempt.434444 if (!hasFlatContinuation(tx, plan.exit, 6)) continue;435445 if (!this.successorPlans(i + 1, next, tx)) continue;436- result = { plan, next, windows, altWindows, skeleton, elevation, tx };446+ result = { plan, next, windows, altWindowsOf, skeleton, elevation, tx };437447 }438448 if (!result) {439449 throw new Error(@@ -441,7 +451,7 @@ export class EndlessWorld {
441451 );442452 }443453 result.tx.commit();444- const { plan, next, windows, altWindows, skeleton, elevation } = result;454+ const { plan, next, windows, altWindowsOf, skeleton, elevation } = result;445455 // Pipe-color chapters (user rule: the pipe color is PER ISLAND): a446456 // cell belongs to the most recent island's chapter — islands opened447457 // in this segment take over from their run start; earlier cells@@ -583,7 +593,8 @@ export class EndlessWorld {
583593 // window that starts at the merge exit port).584594 const braidPieces: { placement: Placement; host: number }[] = [];585595 let braidAltLoop: LoopPath | null = null;586- for (const braid of plan.braids) {596+ for (const [bi, braid] of plan.braids.entries()) {597+ const altWindows = altWindowsOf[bi];587598 if (!altWindows) break; // realized with the plan — always present588599 const forkIdx = braid.start;589600 const mergeIdx = braid.start + braid.len - 1;@@ -626,8 +637,40 @@ export class EndlessWorld {
626637 altWindows,627638 { open: true },628639 );629- for (const p of altAssembled.placements) {630- braidPieces.push({ placement: p, host: forkIdx });640+ // Alt-lane zone tags (M19b): a tagged alt window is one that lies641+ // wholly ≥2 chain cells from BOTH junction mouths (the lip law).642+ const chainN = braid.altCells.length;643+ const altZoneOfWindow = (644+ win: { start: number; len: number },645+ ): "icy" | "boost" | null => {646+ const lm = braid.laneMods?.alt;647+ if (!lm) return null;648+ return win.start >= 2 && win.start + win.len <= chainN - 2 ? lm : null;649+ };650+ // Tagged windows recolor their placements too (zones must READ).651+ const altSegs = altAssembled.loop.segments;652+ const altZone = new Array<string | null>(653+ altAssembled.placements.length,654+ ).fill(null);655+ for (let k = 0; k < altSegs.length; k++) {656+ const startIdx = altSegs[k].placementIndex;657+ if (startIdx < 0) continue;658+ const z = altZoneOfWindow(altWindows[k]);659+ if (!z) continue;660+ let end = altAssembled.placements.length;661+ for (let k2 = k + 1; k2 < altSegs.length; k2++) {662+ if (altSegs[k2].placementIndex >= 0) {663+ end = altSegs[k2].placementIndex;664+ break;665+ }666+ }667+ for (let p = startIdx; p < end; p++) altZone[p] = z;668+ }669+ for (const [pi, p] of altAssembled.placements.entries()) {670+ braidPieces.push({671+ placement: altZone[pi] ? { ...p, zone: altZone[pi]! } : p,672+ host: forkIdx,673+ });631674 }632675 braidAltLoop = altAssembled.loop;633676 @@ -669,10 +712,19 @@ export class EndlessWorld {
669712 b: [merge.x + fx / 2, floor, merge.z + fz / 2],670713 },671714 ];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];718+ for (let k = 0; k < altSegs.length; k++) {719+ const z = altZoneOfWindow(altWindows[k]);720+ for (let c = 0; c < altSegs[k].curve.length; c++) zones.push(z);721+ }722+ zones.push(null, null);672723 lsFork.alt = {673724 curve,674725 length: curve.reduce((acc, s) => acc + segLength(s), 0),675726 braid: true,727+ ...(zones.some((z) => z !== null) ? { zones } : {}),676728 };677729 }678730 @@ -721,6 +773,26 @@ export class EndlessWorld {
721773 }722774 return best;723775 });776+ // M19b lane mods (TAG-ONLY law): stamp the braid interior's main-777+ // lane windows with the drawn surface — never via plan.mods778+ // (realize's exclude rules must not see lane tags: geometry is779+ // sacred) and never within 2 cells of fork or merge (the junction-780+ // lip law in friction form).781+ for (const braid of plan.braids) {782+ const main = braid.laneMods?.main;783+ if (!main) continue;784+ const lo = braid.start + 2;785+ const hi = braid.start + braid.len - 3;786+ for (let wi = 0; wi < windows.length; wi++) {787+ const w = windows[wi];788+ if (789+ windowMods[wi] === null && w.start >= lo &&790+ w.start + w.len - 1 <= hi791+ ) {792+ windowMods[wi] = main;793+ }794+ }795+ }724796 // JUMP gaps: the reserved gap window realized a piece (the WFC needs725797 // the elevation step to be honest), but the set-piece is the ABSENCE726798 // of that piece — drop its placements so nothing renders in the gap@@ -756,7 +828,12 @@ export class EndlessWorld {
756828 // collision ribbon is rendered directly (visual == collision).757829 dropped.add(k);758830 }759- if (windowMods[wi] === "icy" || windowMods[wi] === "gravity") {831+ if (832+ windowMods[wi] === "icy" || windowMods[wi] === "gravity" ||833+ windowMods[wi] === "boost"834+ ) {835+ // Boost joined in M19b (lane contrast must READ) — sprint836+ // zones recolor with it: one coherent presentation.760837 zoneOf[k] = windowMods[wi];761838 }762839 }@@ -864,6 +941,7 @@ export class EndlessWorld {
864941 length: s.alt.length * TRACK_SCALE,865942 ...(s.alt.spur ? { spur: true } : {}),866943 ...(s.alt.braid ? { braid: true } : {}),944+ ...(s.alt.zones ? { zones: s.alt.zones } : {}),867945 }868946 : undefined;869947 return {@@ -118,6 +118,11 @@ export interface LoopSegment {
118118 length: number;119119 spur?: boolean;120120 braid?: boolean;121+ /** Per-curve-part zone tags (M19b braid lane mods): aligned with122+ * `curve`; a tag overlays the host window's rail mod at those123+ * stations ("icy" | "boost"). Absent = the host mod rides the whole124+ * chain (diamonds/spurs, and untagged braids, unchanged). */125+ zones?: readonly (string | null)[];121126 };122127 /** A braid's merge-junction window (endless): the host placement123128 * mutated straight → split facing the arriving alt lane; the ribbon@@ -431,3 +431,124 @@ Deno.test("braid lane choice: per-racer streams, deterministic, ~half split", ()
431431 "lane choices identical across braids — segIndex not in the label",432432 );433433 });434+435+// ---------------------------------------------------------------------------436+// M19b — lane identity: tags only, junction-lip exclusion, compensation.437+// ---------------------------------------------------------------------------438+439+Deno.test("braid lanes: tags obey the lip law, wanderers get their boost", () => {440+ let tagged = 0;441+ let wanderers = 0;442+ for (const fam of ["grav", "jump", "wide"]) {443+ for (let n = 0; n < 20; n++) {444+ const seed = `${fam}-scan-${n}`;445+ let w: EndlessWorld;446+ try {447+ w = new EndlessWorld(seed, catalog);448+ } catch {449+ continue;450+ }451+ for (let i = 0; i < 5; i++) {452+ let seg;453+ try {454+ seg = w.get(i);455+ } catch {456+ break;457+ }458+ for (const b of seg.plan.braids) {459+ // Compensation law: a wanderer alt lane always carries boost.460+ if (b.wanderer) {461+ wanderers++;462+ assertEquals(463+ b.laneMods?.alt,464+ "boost",465+ `${seed} seg ${i}: wanderer without boost compensation`,466+ );467+ }468+ const lm = b.laneMods;469+ if (!lm) continue;470+ tagged++;471+ // MAIN lane: tagged windows only ≥2 cells from fork AND merge472+ // (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;476+ for (const [wi, wr] of seg.windowRanges.entries()) {477+ const m = seg.windowMods[wi];478+ if (m !== "icy" && m !== "boost") continue;479+ const overlapsBraid = wr.start < b.start + b.len &&480+ wr.start + wr.len > b.start;481+ if (!overlapsBraid) continue; // sprint/icy zones elsewhere482+ // Genuine planner ZONES (plan.mods — e.g. a jump's boost483+ // approach brushing the merge) may overlap the span; the lip484+ // law binds LANE TAGS only (windows plan.mods never touched).485+ const fromPlanMod = seg.plan.mods.some((pm) =>486+ pm.kind === m && wr.start < pm.start + pm.len &&487+ pm.start < wr.start + wr.len488+ );489+ if (fromPlanMod) continue;490+ assert(491+ wr.start >= lo && wr.start + wr.len - 1 <= hi,492+ `${seed} seg ${i}: main tag ${m} inside the junction lip ` +493+ `(window ${wr.start}+${wr.len}, braid ${b.start}+${b.len})`,494+ );495+ }496+ // ALT lane: the zones array aligns with the chain curve and497+ // keeps both chain ends untagged (2 junction parts each side).498+ const fork = seg.path.segments.find((ls) => ls.alt?.braid);499+ if (lm.alt) {500+ assert(fork?.alt?.zones, `${seed} seg ${i}: alt tag lost`);501+ const zones = fork!.alt!.zones!;502+ 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);507+ assert(508+ zones.some((z) => z === lm.alt),509+ `${seed} seg ${i}: no interior part carries the alt tag`,510+ );511+ }512+ }513+ }514+ }515+ }516+ assert(tagged > 0, "no lane-tagged braids across the corpus");517+ assert(wanderers > 0, "no wanderer braids across the corpus");518+});519+520+Deno.test("braid lanes: rail stations resolve the tag OVER the fork's centering", () => {521+ // grav-scan-6 seg 4 pins an icy wanderer braid (scan 2026-08-09).522+ const w = new EndlessWorld("grav-scan-6", catalog);523+ const seg = w.get(4);524+ const braid = seg.plan.braids[0];525+ assert(braid && braid.laneMods, "grav-scan-6@4 lost its tagged braid");526+ const altRail = railForSegment(seg, true);527+ const altTag = braid.laneMods!.alt;528+ if (altTag) {529+ // The OVERLAY signature: tag + the fork's centering on one station.530+ // (A genuine icy/boost zone elsewhere in the segment carries the tag531+ // WITHOUT center — the alt rail passes those too, so the signature532+ // is what separates lane stations from zone stations.)533+ let overlay = 0;534+ for (const m of altRail.mods) {535+ if (!m || !m.center) continue;536+ if (altTag === "icy" ? m.icy === true : (m.speed ?? 1) > 1.5) overlay++;537+ }538+ assert(539+ overlay > 4,540+ `only ${overlay} centered ${altTag} stations on the tagged alt lane`,541+ );542+ }543+ // Main lane of the same braid: interior stations carry the main tag.544+ if (braid.laneMods!.main) {545+ const mainRail = railForSegment(seg, false);546+ const wantIcy = braid.laneMods!.main === "icy";547+ assert(548+ mainRail.mods.some((m) =>549+ m && (wantIcy ? m.icy === true : (m.speed ?? 1) > 1.5)550+ ),551+ "main lane never resolves its tag",552+ );553+ }554+});@@ -1,59 +1,59 @@
11 {22 "__version": "g491fc4c7a685",33 "alpha": [4- "2301fe8351939c11",5- "871af9903d52caa8",4+ "57ffc533ec8d9cd6",5+ "e16d3cc236c8ea8f",66 "a4a5bce81ce5960f",77 "afb9de4dd5d431fe",8- "9aa74add78799dac",9- "bd57986bdb77189e",10- "75a5037e72ba44f2",11- "3e9ca1ceba2578d9",12- "c2e1aa8597fc836f",13- "352971e191d9728d",14- "06ecf63f454cfed9",15- "cb2571d849301758"8+ "d9ed72047151d83b",9+ "41fd606a56b859f9",10+ "5e2cc6bfef74ca65",11+ "643768d862c523b4",12+ "957a5c74e259eea2",13+ "8b0072a4f673353d",14+ "1855054916b86a25",15+ "59205ee048b615b0"1616 ],1717 "zx9k2m4p1q": [18- "d71e9ebc0003f12f",19- "112a943058f2929d",20- "40921f6f098a50a8",21- "180d6a4505e0328b",22- "9f69d40d1333cb2d",23- "cec0a275de871833",24- "7215ea2d06a3c990",25- "5099dc10e08aad5f",18+ "28b90ec41fc026a4",19+ "6980f6eed6e673a3",20+ "bc88a345783b5a0b",21+ "91ed1e1fb57cf853",22+ "152008334da2dac6",23+ "8b85689c81846012",24+ "509409b6fd771230",25+ "4ed82fb13b24dc73",2626 "6b6184fc9a642961",27- "59bdf1590b49428a",27+ "6482a0b5548df169",2828 "e7c31d2e4fdcfe95",29- "de2d58870eb8b6db"29+ "9c8a38414ca0e53c"3030 ],3131 "dami": [32- "30afe7333e4f8093",32+ "dca6314d8a394a4c",3333 "1a3b97a3750a6e90",34- "caca8a0bb120466f",35- "490024f85c78e457",36- "24df67ad9c1af1b9",37- "8c901bc15e00a673",34+ "7f8b098959be3ffa",35+ "25b26c94ff2ecb39",36+ "56daa31fbe30f013",37+ "ccdc69f9dc9e4322",3838 "53a805d9b41c764c",39- "3aa866ecc03656e0",40- "be4bd2617188131e",41- "f55825096afd639b",42- "fec3cd94aa504386",43- "b906f7aab6f942a9"39+ "32d88306c8a07e8a",40+ "4befa3ea1bfca303",41+ "0878138bec723532",42+ "6640de5a3b4c5f78",43+ "ad7e72fe64fbe1ff"4444 ],4545 "marbles": [4646 "2c2040556134a62e",47- "7cc8312c3da8b26d",48- "2b60d112e1b7ad0a",49- "f2bfea4f444874f9",50- "30ff7e88a1caab15",51- "45f1bb8759e2b972",52- "044b086c9f69dca5",47+ "e79e59f656a205c1",48+ "618433fa54fb570c",49+ "206723058faac281",50+ "0b6701e7934db90f",51+ "f236d07ffcc7e41c",52+ "4dd60e2296baf3cf",5353 "e77b8633d9e2015a",54- "082b0b12bf2becf5",54+ "139c393a347b10c4",5555 "ae7e93c04de083b3",56- "e089bf39f8898247",57- "09841e8d72c5e4fd"56+ "0d5e85ca411aa200",57+ "274216627012303f"5858 ]5959 }@@ -322,6 +322,26 @@ Deno.test("scenario gust bank (wide-scan-13): pulsed walls never stall the line"
322322 assertEquals(a.hash, b.hash, "windy drive must replay bit-identically");323323 });324324 325+Deno.test("scenario braid lanes (grav-scan-6): both lanes of a tagged braid drive clean", async () => {326+ module_ = await factory();327+ // grav-scan-6 seg 4 pins an icy/wanderer TAGGED braid (M19b scan). The328+ // lane surfaces must never park a marble: drive the WHOLE early run on329+ // the main line, then again riding every alt lane. An icy lane corners330+ // slowly but the junction-lip exclusion keeps the mouths grippy — both331+ // runs must clear the braid segment.332+ const w = new EndlessWorld("grav-scan-6", catalog);333+ const goal = w.get(5).startS; // past the braid segment334+ for (const useAlt of [false, true]) {335+ const r = driveWith("grav-scan-6", 7, 300, goal, 0, useAlt);336+ assert(337+ !r.fell && r.reachedS >= goal,338+ `lane ${useAlt ? "alt" : "main"} stalled: fell=${r.fell} reached=${339+ r.reachedS.toFixed(1)340+ }/${goal.toFixed(1)}`,341+ );342+ }343+});344+325345 Deno.test("scenario jump-gap: cold entry launches, flies, and is caught (both kinds)", async () => {326346 module_ = await factory();327347 // One pinned case per gap kind: helix-scan-6 early segments plan a