Attach source window, family, zone and lane metadata to dense rail stations. Remap placement indices after filtered jump/reorientation meshes and carry provenance through concatenation. Record bounded recovery and LOD telemetry with generated-window exposure counts for diagnosing stalled pieces. Add provenance regressions and preserve the earlier recovery sample notes.
- commit
- 6e38293cbab70130b367cac05a0adce902505bd1
- parent
- a8237c65e1c2260f12cdc98937f338c2f53a6756
- author
- Dami <dami.etoile@gmail.com>
| A | docs/ | 89 | ++++++++++++ |
| M | src/ | 225 | +++++++++++++++++++++++++++++- |
| M | src/ | 52 | ++++++- |
| M | src/ | 26 | +++- |
| M | tests/ | 17 | ++ |
| A | tests/ | 46 | ++++++ |
@@ -0,0 +1,89 @@
1+# Rail recovery sample — 2026-08-262+3+## Method4+5+- 18 isolated headless Chrome runs (`rail-sample-000` through `017`).6+- 401-racer field, 300 simulated seconds per seed, real endless client and7+ Box3D/autopilot code. Pack LOD keeps the nearby physics subset live.8+- Counted only `tileCandidate: true` recoveries: falls and six-second stalls.9+ Intentional distance-LOD handoffs and stream culls were excluded.10+- 644 candidate events across 413 generated segments and 7,965 generated path11+ windows. No page errors occurred.12+- Percentages are event share, not an independent per-marble failure13+ probability: many racers can hit the same broken generated transition.14+15+## Event share by concrete piece16+17+| Piece | Events | Event share | Falls | Stalls | Seeds with event | Generated-window share |18+|---|---:|---:|---:|---:|---:|---:|19+| `ramp-long-b` | 367 | 56.99% | 0 | 367 | 1 | 0.43% |20+| `straight` | 151 | 23.45% | 21 | 130 | 4 | 58.20% |21+| `helix-large-quarter-right` | 42 | 6.52% | 0 | 42 | 1 | 0.14% |22+| `end-hole-rounded` | 18 | 2.80% | 0 | 18 | 8 | 0.46% |23+| `ramp` | 18 | 2.80% | 17 | 1 | 6 | 0.38% |24+| `end-hole-square` | 14 | 2.17% | 0 | 14 | 6 | 0.31% |25+| `slant` | 12 | 1.86% | 12 | 0 | 2 | 1.36% |26+| `split` | 9 | 1.40% | 0 | 9 | 2 | 2.03% |27+| `ramp-start-c` | 7 | 1.09% | 7 | 0 | 1 | 2.07% |28+| `s-curve-short-left-a` | 2 | 0.31% | 2 | 0 | 2 | 2.40% |29+| `wave-c` | 2 | 0.31% | 2 | 0 | 1 | 1.82% |30+| `ramp-long-c` | 1 | 0.16% | 1 | 0 | 1 | 0.58% |31+| `wave-b` | 1 | 0.16% | 1 | 0 | 1 | 1.48% |32+33+Overall: 581 stalls (90.22%) and 63 falls (9.78%).34+35+## Dominant generated instances36+37+Two seeds produced 565/644 events (87.73%), so the main result is two broken38+layouts/transitions rather than a uniform weakness in common pieces.39+40+### `rail-sample-008`41+42+- Segment 11, window 5, `ramp-long-b`: repeated stalls (367 in the sample;43+ rate-limited replay reached the 256-count log).44+- Immediately adjacent segment 11, window 6,45+ `helix-large-quarter-right`: 42 stalls (32-count replay log).46+- Segment 11, window 11, boost `ramp-start-c`: a small fall cluster.47+48+The adjacency makes the `ramp-long-b` → `helix-large-quarter-right` transition49+the first fix target; treating the two pieces independently would miss the50+stronger explanation.51+52+### `rail-sample-015`53+54+- Segment 14, window 5, plain `straight`: largest repeated stall cluster.55+- Segment 14, window 0, alternate icy tunnel lane, parts 6–7: another large56+ stall cluster (the concrete rendered host is `straight`).57+- Segment 14, windows 21–22 around the braid/tunnel exit: smaller stall58+ clusters, including `split` on the alternate lane.59+60+This looks like one problematic braid/icy-lane layout whose congestion spills61+onto nominally ordinary straight stations, not evidence that plain straight62+pieces are broadly bad.63+64+## Sensitivity without the two pathological seeds65+66+The remaining 79 events rank as follows:67+68+| Piece | Events | Share of remaining events |69+|---|---:|---:|70+| `ramp` | 18 | 22.78% |71+| `end-hole-rounded` | 16 | 20.25% |72+| `straight` | 15 | 18.99% |73+| `end-hole-square` | 14 | 17.72% |74+| `slant` | 12 | 15.19% |75+| `s-curve-short-left-a` | 2 | 2.53% |76+| `split` | 1 | 1.27% |77+| `ramp-long-c` | 1 | 1.27% |78+79+The recurring cross-seed signals are therefore the two end-hole variants,80+generic ramps/slants, and a much smaller ordinary-straight tail.81+82+## Instrumentation correction made before the final sample83+84+Jump/reorient meshes are removed from the final endless placement array. Path85+`placementIndex` values were not remapped after that filtering, so the first86+sample could mislabel later windows as supports/decor. The final sample above87+was collected only after remapping surviving path windows to final placement88+indices and adding a regression test that every addressable path window resolves89+to a track placement.@@ -37,6 +37,7 @@ import {
3737 type Rail,3838 railForSegment,3939 railFrameAt,40+ type RailStationSource,4041 stationForce,4142 stationGravityDelta,4243 stationRestitution,@@ -317,6 +318,8 @@ export function bootEndless(
317318 atmosphere: session.atmosphere.name,318319 weather: session.atmosphere.weatherKind,319320 physics: session.physicsActive,321+ railDebug: session.railDebugState,322+ railDebugSummary: session.railDebugSummary,320323 deaths: session.deaths,321324 // Peer ring lifecycle (marble ring): live count + relative arcs.322325 peers: session.peerRing.liveCount,@@ -474,6 +477,47 @@ interface Racer {
474477 blockBlend: number;475478 }476479 480+type RailDebugReason = "lod-distance" | "fall" | "stall" | "stream-cull";481+482+interface RailDebugTile {483+ segment: number;484+ station: number;485+ window: number | null;486+ placementIndex: number | null;487+ pieceId: string | null;488+ family: string | null;489+ zone: string | null;490+ alt: boolean;491+ part: number | null;492+}493+494+interface RailDebugEvent {495+ kind: "rail-handoff" | "recovery";496+ reason: RailDebugReason;497+ tileCandidate: boolean;498+ count: number;499+ seed: string;500+ racer: number;501+ s: number;502+ from: RailDebugTile;503+ to?: RailDebugTile;504+}505+506+interface RailDebugPieceStat {507+ pieceId: string;508+ family: string | null;509+ events: number;510+ falls: number;511+ stalls: number;512+ generatedWindows: number;513+}514+515+interface RailDebugSummary {516+ candidateEvents: number;517+ generatedWindows: number;518+ byPiece: RailDebugPieceStat[];519+}520+477521 /** Reusable neighbor slots (separation steering) — mutated in place. */478522 interface NeighborSlot {479523 pos: [number, number, number];@@ -555,6 +599,16 @@ class Session {
555599 private readonly instQuat = new THREE.Quaternion();556600 private readonly instScale = new THREE.Vector3(1, 1, 1);557601 private readonly rails = new Map<number, Rail>();602+ /** ?debug=1 provenance for every physics→rail/recovery handoff. Falls603+ * and stalls are tile candidates; distance LOD and stream culling are604+ * called out separately so optimization does not masquerade as bad flow. */605+ private readonly railDebug =606+ new URLSearchParams(location.search).get("debug") === "1";607+ private readonly railDebugCounts = new Map<string, number>();608+ private readonly railDebugRecent: RailDebugEvent[] = [];609+ private railDebugCandidateEvents = 0;610+ private readonly railDebugPieces = new Map<string, RailDebugPieceStat>();611+ private railDebugGeneratedWindows = 0;558612 /** Braid alt-lane rails (M17): built alongside `rails` for every559613 * segment carrying an alt branch; per-racer lane choice picks between560614 * them on braid segments (railFor). */@@ -1125,6 +1179,21 @@ class Session {
11251179 return this.engine !== null;11261180 }11271181 1182+ get railDebugState(): readonly RailDebugEvent[] | null {1183+ return this.railDebug ? this.railDebugRecent : null;1184+ }1185+1186+ get railDebugSummary(): RailDebugSummary | null {1187+ if (!this.railDebug) return null;1188+ return {1189+ candidateEvents: this.railDebugCandidateEvents,1190+ generatedWindows: this.railDebugGeneratedWindows,1191+ byPiece: [...this.railDebugPieces.values()].sort((a, b) =>1192+ b.events - a.events || a.pieceId.localeCompare(b.pieceId)1193+ ),1194+ };1195+ }1196+11281197 /** Apply a built segment's presentation frame to a plan-space sample11291198 * (camera path, trail, decorative riders live in world space). */11301199 private presentSample(@@ -1251,6 +1320,7 @@ class Session {
12511320 12521321 private build(i: number): BuiltSegment {12531322 const data = this.world.get(i);1323+ this.recordRailDebugExposure(data);12541324 const fr = data.frame;12551325 const group = new THREE.Group();12561326 group.name = `segment:${i}`;@@ -1803,6 +1873,7 @@ class Session {
18031873 18041874 /** Real body → kinematic rider: the far racer keeps racing on rails. */18051875 private demoteRacer(r: Racer): void {1876+ this.logRailDebug("lod-distance", r, this.railDebugTile(r));18061877 this.engine!.destroyMarble(r.idx);18071878 r.lod = "rider";18081879 r.cachedF = null;@@ -2291,7 +2362,7 @@ class Session {
22912362 this.peerMesh.setMatrixAt(r.idx - 1, this.instMatrix);22922363 this.peerShellMesh?.setMatrixAt(r.idx - 1, this.instMatrix);22932364 }2294- if (r.s < windowLoS - 2) this.respawnPeer(r);2365+ if (r.s < windowLoS - 2) this.respawnPeer(r, "stream-cull");22952366 continue;22962367 }22972368 r.pos[0] = flat[b];@@ -2411,7 +2482,7 @@ class Session {
24112482 );24122483 this.audio.cue("launch");24132484 }2414- if (this.stallClock > 6) this.respawn();2485+ if (this.stallClock > 6) this.respawn("stall");24152486 }24162487 continue;24172488 }@@ -2432,7 +2503,12 @@ class Session {
24322503 }24332504 }24342505 if (fell || r.stallClock > 6 || r.s < windowLoS - 2) {2435- this.respawnPeer(r);2506+ const reason: Exclude<RailDebugReason, "lod-distance"> = fell2507+ ? "fall"2508+ : r.stallClock > 62509+ ? "stall"2510+ : "stream-cull";2511+ this.respawnPeer(r, reason);24362512 }24372513 }24382514 if (this.peerMesh) this.peerMesh.instanceMatrix.needsUpdate = true;@@ -2440,7 +2516,7 @@ class Session {
24402516 this.peerShellMesh.instanceMatrix.needsUpdate = true;24412517 }24422518 if (heroFell) {2443- this.respawn();2519+ this.respawn("fall");24442520 return;24452521 }24462522 @@ -2569,9 +2645,13 @@ class Session {
25692645 * clamped inside the built rails; each racer owns a deterministic slot25702646 * (golden-ratio hash of its idx) plus a lane, so simultaneous respawns25712647 * land spread out. No checkpoint escalation, no death counting. */2572- private respawnPeer(r: Racer): void {2648+ private respawnPeer(2649+ r: Racer,2650+ reason: Exclude<RailDebugReason, "lod-distance">,2651+ ): void {25732652 const engine = this.engine;25742653 if (!engine) return;2654+ const debugFrom = this.railDebug ? this.railDebugTile(r) : null;25752655 let loS = Infinity;25762656 let hiS = -Infinity;25772657 for (const b of this.built.values()) {@@ -2608,6 +2688,14 @@ class Session {
26082688 if (!seg || !rail) return;26092689 let idx = 0;26102690 while (idx < rail.s.length - 1 && rail.s[idx] < at - seg.startS) idx++;2691+ if (debugFrom) {2692+ this.logRailDebug(2693+ reason,2694+ r,2695+ debugFrom,2696+ this.railDebugTile(r, seg.index, idx, rail),2697+ );2698+ }26112699 // Kinematic riders (physics LOD) redistribute by progress alone —26122700 // no body to spawn; the next riderUpdate seats them on the rail.26132701 if (r.lod === "rider") {@@ -2755,6 +2843,120 @@ class Session {
27552843 return this.rails.get(segIdx);27562844 }27572845 2846+ /** Resolve a racer's dense station back to the authored tile/window. */2847+ private railDebugTile(2848+ r: Racer,2849+ segIdx = r.nearest.seg,2850+ stationIdx = r.nearest.idx,2851+ railOverride?: Rail,2852+ ): RailDebugTile {2853+ const rail = railOverride ?? this.railFor(r, segIdx);2854+ const station = rail?.pts.length2855+ ? Math.max(0, Math.min(rail.pts.length - 1, stationIdx))2856+ : Math.max(0, stationIdx);2857+ const source: RailStationSource | undefined = rail?.sources?.[station];2858+ return {2859+ segment: segIdx,2860+ station,2861+ window: source?.window ?? null,2862+ placementIndex: source?.placementIndex ?? null,2863+ pieceId: source?.pieceId ?? null,2864+ family: source?.family ?? null,2865+ zone: source?.zone ?? null,2866+ alt: source?.alt ?? false,2867+ part: source?.part ?? null,2868+ };2869+ }2870+2871+ /** Count each generated path window once, before streaming can cull it.2872+ * This is the exposure denominator for candidate-event percentages. */2873+ private recordRailDebugExposure(2874+ data: ReturnType<EndlessWorld["get"]>,2875+ ): void {2876+ if (!this.railDebug) return;2877+ for (const [window, pathSeg] of data.path.segments.entries()) {2878+ const family = data.pieceFamilies[window] ?? "unknown";2879+ const pieceId = pathSeg.placementIndex >= 02880+ ? data.placements[pathSeg.placementIndex]?.pieceId ?? family2881+ : family;2882+ let stat = this.railDebugPieces.get(pieceId);2883+ if (!stat) {2884+ stat = {2885+ pieceId,2886+ family,2887+ events: 0,2888+ falls: 0,2889+ stalls: 0,2890+ generatedWindows: 0,2891+ };2892+ this.railDebugPieces.set(pieceId, stat);2893+ }2894+ stat.generatedWindows++;2895+ this.railDebugGeneratedWindows++;2896+ }2897+ }2898+2899+ /** Emit compact, rate-limited provenance. Recovery counts log at2900+ * 1/2/4/8… so a repeatedly bad tile becomes obvious without a 10k-racer2901+ * console flood. Intentional LOD handoffs log once per source tile. */2902+ private logRailDebug(2903+ reason: RailDebugReason,2904+ r: Racer,2905+ from: RailDebugTile,2906+ to?: RailDebugTile,2907+ ): void {2908+ if (!this.railDebug) return;2909+ const kind = reason === "lod-distance" ? "rail-handoff" : "recovery";2910+ const tileCandidate = reason === "fall" || reason === "stall";2911+ if (tileCandidate) {2912+ this.railDebugCandidateEvents++;2913+ const pieceId = from.pieceId ?? from.family ?? "unknown";2914+ let stat = this.railDebugPieces.get(pieceId);2915+ if (!stat) {2916+ stat = {2917+ pieceId,2918+ family: from.family,2919+ events: 0,2920+ falls: 0,2921+ stalls: 0,2922+ generatedWindows: 0,2923+ };2924+ this.railDebugPieces.set(pieceId, stat);2925+ }2926+ stat.events++;2927+ if (reason === "fall") stat.falls++;2928+ else stat.stalls++;2929+ }2930+ const key = [2931+ reason,2932+ from.segment,2933+ from.window,2934+ from.pieceId ?? from.family,2935+ from.alt ? "alt" : "main",2936+ ].join("|");2937+ const count = (this.railDebugCounts.get(key) ?? 0) + 1;2938+ this.railDebugCounts.set(key, count);2939+ const shouldLog = kind === "rail-handoff"2940+ ? count === 12941+ : (count & (count - 1)) === 0;2942+ if (!shouldLog) return;2943+ const event: RailDebugEvent = {2944+ kind,2945+ reason,2946+ tileCandidate,2947+ count,2948+ seed: this.seed,2949+ racer: r.idx,2950+ s: Number(r.s.toFixed(2)),2951+ from,2952+ ...(to ? { to } : {}),2953+ };2954+ this.railDebugRecent.push(event);2955+ if (this.railDebugRecent.length > 12) this.railDebugRecent.shift();2956+ if (tileCandidate) console.warn("[rail-debug] tile candidate", event);2957+ else console.info("[rail-debug] rail flow", event);2958+ }2959+27582960 /** Braid lane law (M17), run each decision: entering a braid segment27592961 * draws the racer's own deterministic lane (braidLaneAlt — per-racer27602962 * label-derived stream, hero included); and while inside one, a marble@@ -2889,7 +3091,9 @@ class Session {
28893091 /** Respawn at the last checkpoint (the current segment's entry), and28903092 * after repeated deaths there, at the NEXT segment — forward progress is28913093 * guaranteed even over broken geometry. */2892- private respawn(): void {3094+ private respawn(reason: "fall" | "stall"): void {3095+ const hero = this.racers[0];3096+ const debugFrom = this.railDebug && hero ? this.railDebugTile(hero) : null;28933097 this.deaths++;28943098 this.audio.cue("fall");28953099 this.stallClock = 0;@@ -2933,6 +3137,14 @@ class Session {
29333137 }29343138 const rail = this.rails.get(cp.index);29353139 if (!rail) return;3140+ if (debugFrom && hero) {3141+ this.logRailDebug(3142+ reason,3143+ hero,3144+ debugFrom,3145+ this.railDebugTile(hero, cp.index, 0, rail),3146+ );3147+ }29363148 const t = rail.tans[0];29373149 const spawnPos: [number, number, number] = [29383150 rail.pts[0][0],@@ -2948,7 +3160,6 @@ class Session {
29483160 this.engine.spawnMarble(0, spawnPos, spawnVel);29493161 this.nearest = { seg: cp.index, idx: 0 };29503162 this.marbleS = cp.startS;2951- const hero = this.racers[0];29523163 if (hero) {29533164 hero.nearest = { seg: cp.index, idx: 0 };29543165 hero.laneAlt = false;@@ -61,6 +61,11 @@ export interface Rail {
6161 /** Cumulative arc length, starting at 0 for this rail. */6262 s: number[];6363 mods: (RailMod | null)[];64+ /** Authoring provenance aligned 1:1 with the dense stations. Debug65+ * recovery logs use this to name the exact realized tile/window that66+ * handed a marble to the kinematic rail or respawn net. Older synthetic67+ * test rails may omit it. */68+ sources?: RailStationSource[];6469 /** Gravity-reorientation frames (M16): pts/tans/mods stay in PLAN6570 * space (+y up — the control laws' one truth); each station's affine6671 * maps plan → presented world. Undefined = identity (the common case);@@ -73,6 +78,24 @@ export interface Rail {
7378 worldPts?: [number, number, number][];7479 }7580 81+/** Authored source of one dense rail station. */82+export interface RailStationSource {83+ /** Path/window index within the endless segment. */84+ window: number;85+ /** Generator family selected for that window (straight, slant, ...). */86+ family: string;87+ /** Planner set-piece tag, including a per-part alt-lane override. */88+ zone: string | null;89+ /** Index into the segment's authored placements, or -1 for shared mesh. */90+ placementIndex: number;91+ /** Concrete GLB piece id when the placement is addressable. */92+ pieceId: string | null;93+ /** Whether this station follows the alternate branch/lane. */94+ alt: boolean;95+ /** Curve-part index within the selected window/lane. */96+ part: number;97+}98+7699 /** Attach a segment's presentation frames to its rail: per-station77100 * affines (a partial roll inside the twist run) + cached world points.78101 * Rails without reorient history skip this entirely. */@@ -200,6 +223,8 @@ export interface RailSource {
200223 path: LoopPath;201224 pieceFamilies: string[];202225 windowMods: (string | null)[];226+ /** Optional for small synthetic law tests; EndlessSegment supplies it. */227+ placements?: readonly { pieceId: string }[];203228 }204229 205230 /** Path-segment indices that are jump gaps — the collision proxies emit no@@ -227,7 +252,7 @@ export function hasBraid(seg: RailSource): boolean {
227252 * marble physically does, so the wishdir look target never points back228253 * up at geometry the rider skipped. */229254 export function railForSegment(seg: RailSource, useAlt = false): Rail {230- const rail: Rail = { pts: [], tans: [], s: [], mods: [] };255+ const rail: Rail = { pts: [], tans: [], s: [], mods: [], sources: [] };231256 let acc = 0;232257 /** Set after a spur dive: skip windows until one passes this point. */233258 let resumeNear: readonly [number, number, number] | null = null;@@ -273,9 +298,7 @@ export function railForSegment(seg: RailSource, useAlt = false): Rail {
273298 // adds its surface on top. Untagged chains (diamonds, spurs, V1274299 // braids) are byte-identical to before.275300 const altZone = alt ? ls.alt!.zones?.[partIdx] : undefined;276- const partMod = altZone277- ? { ...(mod ?? {}), ...ZONE_MODS[altZone] }278- : mod;301+ const partMod = altZone ? { ...(mod ?? {}), ...ZONE_MODS[altZone] } : mod;279302 const plen = segLength(part);280303 const steps = Math.max(1, Math.ceil(plen / RAIL_STEP));281304 for (let j = 0; j < steps; j++) {@@ -287,6 +310,17 @@ export function railForSegment(seg: RailSource, useAlt = false): Rail {
287310 rail.tans.push([d[0] / l, d[1] / l, d[2] / l]);288311 rail.s.push(acc);289312 rail.mods.push(partMod);313+ rail.sources!.push({314+ window: lsIdx,315+ family: seg.pieceFamilies[lsIdx] ?? "unknown",316+ zone: altZone ?? zone ?? null,317+ placementIndex: ls.placementIndex,318+ pieceId: ls.placementIndex >= 0319+ ? seg.placements?.[ls.placementIndex]?.pieceId ?? null320+ : null,321+ alt: Boolean(alt),322+ part: partIdx,323+ });290324 acc += l;291325 }292326 }@@ -307,7 +341,14 @@ export function railForSegment(seg: RailSource, useAlt = false): Rail {
307341 * frames (M16) survive the concat: any framed input promotes the output308342 * to per-station frames (null = identity) + world points. */309343 export function concatRails(rails: readonly Rail[]): Rail {310- const out: Rail = { pts: [], tans: [], s: [], mods: [] };344+ const sourced = rails.every((r) => r.sources !== undefined);345+ const out: Rail = {346+ pts: [],347+ tans: [],348+ s: [],349+ mods: [],350+ sources: sourced ? [] : undefined,351+ };311352 let base = 0;312353 const framed = rails.some((r) => r.frames !== undefined);313354 if (framed) {@@ -319,6 +360,7 @@ export function concatRails(rails: readonly Rail[]): Rail {
319360 out.tans.push(...r.tans);320361 for (const s of r.s) out.s.push(base + s);321362 out.mods.push(...r.mods);363+ if (sourced) out.sources!.push(...r.sources!);322364 if (framed) {323365 const frames = out.frames as (Affine | null)[];324366 for (let i = 0; i < r.pts.length; i++) {@@ -965,14 +965,23 @@ export class EndlessWorld {
965965 // Slalom rule (M8): the generator plans in track cells; the WORLD sees966966 // track pieces at ×TRACK_SCALE (position and mesh scale) while islands967967 // and decor stay 1×. The centerline scales with the track.968+ // Filtering jump/reorient meshes changes array indices. Remap every969+ // surviving path window to the FINAL placement array so diagnostic970+ // provenance (and the LoopPath contract) cannot accidentally name a971+ // later support/decor placement after an earlier gap was removed.972+ const placementRemap = new Map<number, number>();973+ const mainPlacements: Placement[] = [];974+ for (const [idx, p] of assembled.placements.entries()) {975+ if (dropped.has(idx)) continue;976+ placementRemap.set(idx, mainPlacements.length);977+ mainPlacements.push({978+ ...p,979+ chapter: chapterOf[idx],980+ ...(zoneOf[idx] ? { zone: zoneOf[idx]! } : {}),981+ });982+ }968983 const placements = [969- ...assembled.placements970- .map((p, idx): Placement => ({971- ...p,972- chapter: chapterOf[idx],973- ...(zoneOf[idx] ? { zone: zoneOf[idx]! } : {}),974- }))975- .filter((_, idx) => !dropped.has(idx)),984+ ...mainPlacements,976985 ...furniture.map(({ placement, host }): Placement => ({977986 ...placement,978987 chapter: cellChapter(host),@@ -1010,6 +1019,9 @@ export class EndlessWorld {
10101019 : undefined;10111020 return {10121021 ...s,1022+ placementIndex: s.placementIndex >= 01023+ ? placementRemap.get(s.placementIndex) ?? -11024+ : -1,10131025 curve,10141026 length,10151027 ...(alt ? { alt } : {}),@@ -39,6 +39,23 @@ Deno.test("endless: different seeds diverge", () => {
3939 assert(JSON.stringify(cellsA) !== JSON.stringify(cellsB));4040 });4141 42+Deno.test("endless: path placement indices survive dropped gap meshes", () => {43+ const world = new EndlessWorld("rail-sample-008", catalog);44+ for (let i = 0; i < 24; i++) {45+ const seg = world.get(i);46+ for (const [window, pathSeg] of seg.path.segments.entries()) {47+ if (pathSeg.placementIndex < 0) continue;48+ const placement = seg.placements[pathSeg.placementIndex];49+ assert(50+ placement !== undefined && placement.role === "track",51+ `segment ${i} window ${window} resolved to ${52+ placement?.pieceId ?? "nothing"53+ } (${placement?.role ?? "missing"})`,54+ );55+ }56+ }57+});58+4259 Deno.test("endless: access pattern cannot perturb the stream", () => {4360 // Eager: generate 12 up front. Lazy: one at a time (auto-cull runs at4461 // different moments relative to reads). Streams must match exactly.@@ -0,0 +1,46 @@
1+import { assertEquals } from "@std/assert";2+import {3+ concatRails,4+ railForSegment,5+ type RailSource,6+} from "../src/client/physics/rail.ts";7+8+Deno.test("rail debug provenance names the authored tile at every station", () => {9+ const source: RailSource = {10+ path: {11+ totalLength: 2,12+ segments: [{13+ placementIndex: 0,14+ length: 2,15+ curve: [{ kind: "line", a: [0, 0, 0], b: [2, 0, 0] }],16+ alt: {17+ length: 2,18+ curve: [{ kind: "line", a: [0, 0, 0], b: [2, 0, 1] }],19+ zones: ["icy"],20+ },21+ }],22+ },23+ pieceFamilies: ["slant"],24+ windowMods: ["boost"],25+ placements: [{ pieceId: "slalom-debug-tile" }],26+ };27+28+ const main = railForSegment(source);29+ assertEquals(main.sources?.length, main.pts.length);30+ assertEquals(main.sources?.[0], {31+ window: 0,32+ family: "slant",33+ zone: "boost",34+ placementIndex: 0,35+ pieceId: "slalom-debug-tile",36+ alt: false,37+ part: 0,38+ });39+40+ const alt = railForSegment(source, true);41+ assertEquals(alt.sources?.[0]?.alt, true);42+ assertEquals(alt.sources?.[0]?.zone, "icy");43+ const joined = concatRails([main, alt]);44+ assertEquals(joined.sources?.length, joined.pts.length);45+ assertEquals(joined.sources?.at(-1)?.pieceId, "slalom-debug-tile");46+});