- Respawns: 0.4s eased scale-in (group + instanced matrix paths) and a ~2s ghost window (collides with track, not marbles) that resolidifies only when clear of the pack (binary-searched rank window), 4s hard cap. Headless test: materializing inside a cluster kicks nobody. - Takeable spurs in endless (trySpurDrop, ~1/12 segments): elevated host straight mutates to a split; branch cell carries an end-hole diving board; the main line hooks around and passes EXACTLY 1.0 under it (bridge law) — a branch rider rolls off the board, falls through the real hole (proxies leave it open; cap wall + shaft guide + catch pan), lands on its own line. Alt centerline dives the shaft so the wishdir pilot commits; alt rail resumes at the receiver so nearest- station recovery is continuous. Respawn variety rides the existing branchOf flip. Physics-verified end to end (scenario spur-drop). - Fans are now INTERACTIVE (user intent): fanField.ts wind capsules derived from fan placements (standing = local +z, table = updraft), fed into every racer's force pass via segment-binned forceNear. Cross-winds bend lines mid-channel (4.1->2.4 u/s^2 vs the corridor spring), new furniture variants (appended draws): fan-powered boost corridors along the channel + updraft columns at the lip (~40% weight cut). Optional Placement.wind hint carries tunings. - Endless goldens regenerated (planner draws changed); scenario pins re-scanned (wide-scan-4/5, helix-scan-11, jump-scan-13; diamond pin survived). genVersion unchanged - finite pipeline untouched. 150 tests, check clean.
- commit
- 1908048aa4e43a322fb2aecc268a2b0a6423ff4c
- parent
- df23d15aa519493e4820462c8552d8214c6fa282
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 118 | ++++++++++++- |
| A | src/ | 205 | ++++++++++++++++++++++ |
| M | src/ | 35 | +++- |
| M | src/ | 109 | ++++++++++- |
| M | src/ | 126 | +++++++++++++- |
| M | src/ | 154 | ++++++++++++++++- |
| M | src/ | 107 | +++++++++++- |
| M | src/ | 21 | ++- |
| A | tests/ | 266 | ++++++++++++++++++++++++++++ |
| M | tests/ | 96 | +++++----- |
| M | tests/ | 89 | +++++++++ |
| M | tests/ | 83 | +++++++-- |
| M | tests/ | 5 | + |
| A | tests/ | 284 | ++++++++++++++++++++++++++++++ |
| M | tools/ | 8 | + |
@@ -68,6 +68,7 @@ import {
6868 wideScalesOf,6969 } from "../gen/endless/proxies.ts";7070 import { buildIslandInstanced, type InstancedBuild } from "./islandRender.ts";71+import { FanField, fanVolumesOf } from "./fanField.ts";7172 import { Atmosphere, drawZone } from "./atmosphere.ts";7273 import type { DecorCatalog } from "../gen/decor.ts";7374 import { genVersion } from "../gen/version.ts";@@ -295,6 +296,20 @@ const PLACE_REFRESH = 0.25;
295296 * tight pack; skill modulation follows the smoothed position. */296297 const RUBBER_TAU = 2;297298 299+/** Respawn spawn-in envelope (seconds): the visual eases ~0.1 → 1 instead300+ * of popping (the initial grid avalanche keeps its pop — it reads fine). */301+const SPAWN_IN_T = 0.4;302+/** Respawn collision immunity (seconds): a fresh marble ghosts through303+ * neighbors (track collisions stay real) until it is clear of the pack. */304+const GHOST_T = 2;305+/** Ghost hard cap (seconds) — a still-overlapped marble resolidifies here306+ * regardless, so nobody ghosts forever. */307+const GHOST_MAX = 4;308+/** Resolidify clearance: no other racer within ~2.5 marble radii — expiry309+ * while overlapped fires the violent depenetration pop the ghost exists310+ * to avoid. */311+const GHOST_CLEAR_D = MARBLE_RADIUS * 2.5;312+298313 /** One physics racer (M15): the hero (idx 0) or a real peer marble. */299314 interface Racer {300315 idx: number;@@ -319,6 +334,12 @@ interface Racer {
319334 stallClock: number;320335 stallHopped: boolean;321336 wasHop: boolean;337+ /** Respawn spawn-in clock (seconds left; 0 = done) — physicsMove eases338+ * the visual scale while it runs. */339+ spawnAnim: number;340+ /** Respawn ghost clock (seconds until eligible to resolidify; 0 =341+ * solid). Runs negative toward the hard cap while still overlapped. */342+ ghostT: number;322343 /** Fuel-boost economy (competition round). */323344 boost: BoostState;324345 wasBoosting: boolean;@@ -418,6 +439,10 @@ class Session {
418439 private readonly instGlow = new THREE.Color();419440 private readonly glowBlue = new THREE.Color(0x66aaff);420441 private readonly rails = new Map<number, Rail>();442+ /** Fan wind volumes, binned per segment (M16) — fans push marbles. */443+ private readonly fanField = new FanField();444+ /** Scratch for the per-decision wind force (allocation-free). */445+ private readonly windScratch: [number, number, number] = [0, 0, 0];421446 /** Active diamond branch per segment — respawns alternate it, so a422447 * fall's retry may ride the OTHER branch (respawn variety). */423448 private readonly branchOf = new Map<number, "main" | "alt">();@@ -605,6 +630,8 @@ class Session {
605630 stallClock: 0,606631 stallHopped: false,607632 wasHop: false,633+ spawnAnim: 0,634+ ghostT: 0,608635 boost: { ...FULL_TANK },609636 wasBoosting: false,610637 dbg: {},@@ -747,6 +774,8 @@ class Session {
747774 stallClock: 0,748775 stallHopped: false,749776 wasHop: false,777+ spawnAnim: 0,778+ ghostT: 0,750779 boost: { ...FULL_TANK },751780 wasBoosting: false,752781 dbg: {},@@ -940,6 +969,11 @@ class Session {
940969 i,941970 railForSegment(data, this.branchOf.get(i) === "alt"),942971 );972+ // Fan wind capsules (M16): derived from the fan placements —973+ // deterministic, no rng. Ledge fans face the channel, so their974+ // capsules cross it (lateral gusts); wind-hinted fans carry their975+ // own tuning (boost corridors, lip updrafts).976+ this.fanField.addSegment(i, fanVolumesOf(data.placements));943977 }944978 const prev = this.sMeta.get(i - 1);945979 const startS = i === 0 ? 0 : (prev ? prev.start + prev.len : 0);@@ -993,6 +1027,7 @@ class Session {
9931027 this.spawning.delete(seg.index);9941028 this.engine?.removeSegment(seg.index);9951029 this.rails.delete(seg.index);1030+ this.fanField.removeSegment(seg.index);9961031 // Sink away rather than pop out; the group leaves the scene when the9971032 // exit wave finishes. (Occupancy claims release much later — the9981033 // generator's keep window — so nothing can build into a sinking spot.)@@ -1206,9 +1241,16 @@ class Session {
12061241 // Gravity zones as per-marble delta forces (world gravity is global12071242 // and fixed — one vector cannot serve N marbles).12081243 const gd = stationGravityDelta(rail, r.nearest.idx, r.mass);1209- const fx = (f?.[0] ?? 0) + (gd?.[0] ?? 0);1210- const fy = (f?.[1] ?? 0) + (gd?.[1] ?? 0);1211- const fz = (f?.[2] ?? 0) + (gd?.[2] ?? 0);1244+ // Fan wind (M16): fans are force-field volumes — accumulate the1245+ // capsule forces near the racer's segment (O(nearby), no alloc).1246+ const w = this.windScratch;1247+ w[0] = 0;1248+ w[1] = 0;1249+ w[2] = 0;1250+ this.fanField.forceNear(r.nearest.seg, r.pos, r.mass, w);1251+ const fx = (f?.[0] ?? 0) + (gd?.[0] ?? 0) + w[0];1252+ const fy = (f?.[1] ?? 0) + (gd?.[1] ?? 0) + w[1];1253+ const fz = (f?.[2] ?? 0) + (gd?.[2] ?? 0) + w[2];12121254 r.cachedF = fx !== 0 || fy !== 0 || fz !== 0 ? [fx, fy, fz] : null;12131255 }12141256 @@ -1273,8 +1315,20 @@ class Session {
12731315 r.pos[1] = flat[b + 1];12741316 r.pos[2] = flat[b + 2];12751317 r.speed = Math.hypot(flat[b + 3], flat[b + 4], flat[b + 5]);1318+ // Respawn spawn-in: ease the visual ~0.1 → 1 instead of popping.1319+ // The group path scales r.obj (the kit mesh inside keeps its own1320+ // MARBLE_SCALE); the instanced path folds the scale into the1321+ // instance matrix. Allocation-free — scalar math only.1322+ let spawnScale = 1;1323+ const spawnAnimating = r.spawnAnim > 0;1324+ if (spawnAnimating) {1325+ r.spawnAnim = Math.max(0, r.spawnAnim - dt);1326+ const k = 1 - r.spawnAnim / SPAWN_IN_T;1327+ spawnScale = 0.1 + 0.9 * (1 - (1 - k) * (1 - k) * (1 - k));1328+ }12761329 if (r.obj && r.pivot) {12771330 r.obj.position.set(flat[b], flat[b + 1], flat[b + 2]);1331+ if (spawnAnimating) r.obj.scale.setScalar(spawnScale);12781332 r.pivot.quaternion.set(12791333 flat[b + 6],12801334 flat[b + 7],@@ -1290,10 +1344,26 @@ class Session {
12901344 flat[b + 8],12911345 flat[b + 9],12921346 );1347+ // instScale is shared scratch — set it EVERY racer (1 when not1348+ // animating), or one spawning racer shrinks the next N instances.1349+ this.instScale.setScalar(spawnScale);12931350 this.instMatrix.compose(this.instPos, this.instQuat, this.instScale);12941351 this.peerMesh.setMatrixAt(r.idx - 1, this.instMatrix);12951352 this.peerShellMesh?.setMatrixAt(r.idx - 1, this.instMatrix);12961353 }1354+ // Respawn immunity: ghosted marbles pass through neighbors (track1355+ // stays solid). Tick down; resolidify only once CLEAR of the pack,1356+ // with a hard cap (GHOST_MAX) so nobody ghosts forever.1357+ if (r.ghostT !== 0) {1358+ r.ghostT -= dt;1359+ if (1360+ r.ghostT <= GHOST_T - GHOST_MAX ||1361+ (r.ghostT <= 0 && this.clearOfPack(r))1362+ ) {1363+ engine.setGhost(r.idx, false);1364+ r.ghostT = 0;1365+ }1366+ }12971367 const seg = this.built.get(r.nearest.seg);12981368 const rail = this.rails.get(r.nearest.seg);12991369 if (seg && rail) {@@ -1459,6 +1529,36 @@ class Session {
14591529 }14601530 }14611531 1532+ /** True when no other racer sits within GHOST_CLEAR_D of `r` — checked1533+ * against progress-rank neighbors (`this.sorted`, ordered by s): a1534+ * cheap stand-in for a spatial query at pack scale. Binary-search the1535+ * s-window, then scan it (capped — a mega-clump stops mattering past1536+ * the nearest ranks). Allocation-free. */1537+ private clearOfPack(r: Racer): boolean {1538+ const arr = this.sorted;1539+ const d2 = GHOST_CLEAR_D * GHOST_CLEAR_D;1540+ // First rank whose s is inside the window (arr is sorted by s).1541+ const loS = r.s - 0.5;1542+ const hiS = r.s + 0.5;1543+ let lo = 0;1544+ let hi = arr.length;1545+ while (lo < hi) {1546+ const mid = (lo + hi) >> 1;1547+ if (arr[mid].s < loS) lo = mid + 1;1548+ else hi = mid;1549+ }1550+ for (let j = lo, seen = 0; j < arr.length && seen < 64; j++, seen++) {1551+ const o = arr[j];1552+ if (o.s > hiS) break;1553+ if (o === r) continue;1554+ const dx = o.pos[0] - r.pos[0];1555+ const dy = o.pos[1] - r.pos[1];1556+ const dz = o.pos[2] - r.pos[2];1557+ if (dx * dx + dy * dy + dz * dz < d2) return false;1558+ }1559+ return true;1560+ }1561+14621562 /** Respawn a peer racer DISTRIBUTED across the pack's fill range (user14631563 * rule: liveliness is distribution, not a clump — a culled segment once14641564 * respawned every straggler onto one station). The range grows with@@ -1507,6 +1607,12 @@ class Session {
15071607 r.lastProgressS = r.s;15081608 r.stallClock = 0;15091609 r.stallHopped = false;1610+ // Materialize gently: scale-in envelope + collision immunity until1611+ // clear of the pack. setGhost must come AFTER spawnMarble — spawn1612+ // resets the shim shape (and the engine's ghost cache) to solid.1613+ r.spawnAnim = SPAWN_IN_T;1614+ engine.setGhost(r.idx, true);1615+ r.ghostT = GHOST_T;15101616 }15111617 15121618 /** Tinted marble visuals for a real racer peer (mirrors the ring's@@ -1691,6 +1797,11 @@ class Session {
16911797 hero.lastProgressS = cp.startS;16921798 hero.stallClock = 0;16931799 hero.stallHopped = false;1800+ // Same materialization as peers: scale-in + ghost-until-clear1801+ // (setGhost AFTER spawnMarble — spawn resets the shape to solid).1802+ hero.spawnAnim = SPAWN_IN_T;1803+ this.engine.setGhost(0, true);1804+ hero.ghostT = GHOST_T;16941805 }16951806 }16961807 @@ -1801,6 +1912,7 @@ class Session {
18011912 }18021913 this.built.clear();18031914 this.spawning.clear();1915+ this.fanField.clear();18041916 for (const { group, disposables } of this.exiting) {18051917 this.root.remove(group);18061918 group.clear();@@ -0,0 +1,205 @@
1+/**2+ * Fan wind field (M16): fans are elements marbles physically INTERACT3+ * with. Every endless fan placement contributes a capsule of moving air4+ * — origin, unit blow direction, length, radius, strength — and the5+ * per-marble force pass adds mass · strength · falloff · direction while6+ * a racer is inside the capsule.7+ *8+ * Geometry is DERIVED from the placement (deterministic, no rng):9+ * - a STANDING fan blows along its local +z rotated by the placement10+ * yaw — the legacy ledge fans face the channel, so their capsules11+ * CROSS it: lateral gusts fall out of existing furniture for free;12+ * - a TABLE fan lies flat and blows straight up — an updraft column.13+ *14+ * The optional `placement.wind` hint (shared/types.ts) tunes strength /15+ * length / radius and nudges the capsule origin in world units — that is16+ * how the boost-corridor fan parks its wash over the channel while its17+ * housing stays on the ledge, and how the lip updraft starts low enough18+ * to catch a rolling marble.19+ *20+ * Falloff model: axial linear from 1 at the fan face to 0 at `length`;21+ * radial full inside FULL_CORE·radius, feathered linearly to 0 at22+ * `radius`.23+ *24+ * Hot-path contract: volumes are binned per segment and `forceNear`25+ * tests only bins segIdx−1 .. segIdx+1, allocation-free — it runs per26+ * racer per decision at pack sizes up to N=9999.27+ */28+import type { Placement, Vec3 } from "../shared/types.ts";29+30+export interface FanVolume {31+ /** Capsule start — the fan face, world coords. */32+ origin: Vec3;33+ /** Unit blow direction, world frame. */34+ dir: Vec3;35+ /** Capsule length, world units (axial falloff hits 0 here). */36+ length: number;37+ /** Capsule radius, world units (radial feather hits 0 here). */38+ radius: number;39+ /** Acceleration at the fan face, world u/s². */40+ strength: number;41+}42+43+/** Radial fraction at full strength; feather over the outer band. */44+export const FULL_CORE = 0.6;45+46+// Family defaults (world units, tuned for TRACK_SCALE=2 endless pieces).47+// Cross-wind (standing fan): 6.5 u/s² at the face over a 4-unit throw —48+// the channel centerline sits ~2 units out, so mid-channel marbles feel49+// ~3.3 u/s² laterally against a corridor spring that caps at ±10: lines50+// bend visibly without pinning anyone to the wall.51+export const CROSS_STRENGTH = 6.5;52+export const CROSS_LENGTH = 4.0;53+export const CROSS_RADIUS = 1.0;54+// Updraft (table fan): 9 u/s² against GRAVITY_Y=−20 — a marble in the55+// column runs at roughly half weight (visible lift wobble, not orbit)56+// over a short 2.2-unit column.57+export const UPDRAFT_STRENGTH = 9.0;58+export const UPDRAFT_LENGTH = 2.2;59+export const UPDRAFT_RADIUS = 0.9;60+61+/** Hub height above the placement origin, in TRACK units (×scale at62+ * derivation): standing fans are 1 unit tall with the disc centered63+ * ~0.55 up; table-fan blades sit ~0.42 up in a 0.5-tall housing. */64+export const STANDING_HUB = 0.55;65+export const TABLE_HUB = 0.42;66+67+/** Dir index → world step (0=+x, 1=+z, 2=−x, 3=−z). */68+const DIR_VEC: readonly (readonly [number, number, number])[] = [69+ [1, 0, 0],70+ [0, 0, 1],71+ [-1, 0, 0],72+ [0, 0, -1],73+];74+75+/** Derive the wind capsule for one placement, or null when the piece is76+ * not a fan. Standing fans blow along local +z (world dir (1−r)%4 —77+ * the ledge fans' rotation ((1−facing)%4) makes that exactly `facing`);78+ * table fans blow straight up regardless of yaw. */79+export function fanVolumeFor(p: Placement): FanVolume | null {80+ if (!p.pieceId.startsWith("fan-")) return null;81+ const s = p.scale ?? 1;82+ const standing = p.pieceId.startsWith("fan-standing");83+ let dx: number, dy: number, dz: number;84+ let hub: number;85+ let strength: number, length: number, radius: number;86+ if (standing) {87+ const d = DIR_VEC[(1 - p.rotation + 4) % 4];88+ // Render mirror is scale.x = −s applied after yaw: flip world x.89+ dx = p.mirrored ? -d[0] : d[0];90+ dy = d[1];91+ dz = d[2];92+ hub = STANDING_HUB * s;93+ strength = CROSS_STRENGTH;94+ length = CROSS_LENGTH;95+ radius = CROSS_RADIUS;96+ } else {97+ dx = 0;98+ dy = 1;99+ dz = 0;100+ hub = TABLE_HUB * s;101+ strength = UPDRAFT_STRENGTH;102+ length = UPDRAFT_LENGTH;103+ radius = UPDRAFT_RADIUS;104+ }105+ const w = p.wind;106+ const off = w?.offset;107+ return {108+ origin: [109+ p.pos[0] + (off?.[0] ?? 0),110+ p.pos[1] + hub + (off?.[1] ?? 0),111+ p.pos[2] + (off?.[2] ?? 0),112+ ],113+ dir: [dx, dy, dz],114+ length: w?.length ?? length,115+ radius: w?.radius ?? radius,116+ strength: w?.strength ?? strength,117+ };118+}119+120+/** All wind capsules of a segment's placements (build-time, cold path). */121+export function fanVolumesOf(placements: readonly Placement[]): FanVolume[] {122+ const out: FanVolume[] = [];123+ for (const p of placements) {124+ const v = fanVolumeFor(p);125+ if (v) out.push(v);126+ }127+ return out;128+}129+130+/** Per-segment wind-volume registry. */131+export class FanField {132+ private readonly bins = new Map<number, readonly FanVolume[]>();133+134+ addSegment(index: number, volumes: readonly FanVolume[]): void {135+ if (volumes.length > 0) this.bins.set(index, volumes);136+ else this.bins.delete(index);137+ }138+139+ removeSegment(index: number): void {140+ this.bins.delete(index);141+ }142+143+ clear(): void {144+ this.bins.clear();145+ }146+147+ /** Accumulate the wind force at `pos` for a marble of `mass` into148+ * `out` (+=, caller zeroes). Tests only segments segIdx−1..segIdx+1 —149+ * O(nearby volumes), allocation-free. Returns true when any capsule150+ * contributed. */151+ forceNear(152+ segIdx: number,153+ pos: readonly [number, number, number],154+ mass: number,155+ out: [number, number, number],156+ ): boolean {157+ let hit = false;158+ for (let k = segIdx - 1; k <= segIdx + 1; k++) {159+ const vols = this.bins.get(k);160+ if (!vols) continue;161+ if (this.probeBin(vols, pos, mass, out)) hit = true;162+ }163+ return hit;164+ }165+166+ /** Cold-path variant scanning every bin (debug/tests). */167+ forceAt(168+ pos: readonly [number, number, number],169+ mass: number,170+ out: [number, number, number],171+ ): boolean {172+ let hit = false;173+ for (const vols of this.bins.values()) {174+ if (this.probeBin(vols, pos, mass, out)) hit = true;175+ }176+ return hit;177+ }178+179+ private probeBin(180+ vols: readonly FanVolume[],181+ pos: readonly [number, number, number],182+ mass: number,183+ out: [number, number, number],184+ ): boolean {185+ let hit = false;186+ for (const v of vols) {187+ const rx = pos[0] - v.origin[0];188+ const ry = pos[1] - v.origin[1];189+ const rz = pos[2] - v.origin[2];190+ const axial = rx * v.dir[0] + ry * v.dir[1] + rz * v.dir[2];191+ if (axial < 0 || axial >= v.length) continue;192+ const rad2 = rx * rx + ry * ry + rz * rz - axial * axial;193+ if (rad2 >= v.radius * v.radius) continue;194+ const rad = Math.sqrt(Math.max(0, rad2));195+ const core = v.radius * FULL_CORE;196+ const feather = rad <= core ? 1 : (v.radius - rad) / (v.radius - core);197+ const f = mass * v.strength * (1 - axial / v.length) * feather;198+ out[0] += f * v.dir[0];199+ out[1] += f * v.dir[1];200+ out[2] += f * v.dir[2];201+ hit = true;202+ }203+ return hit;204+ }205+}@@ -141,6 +141,10 @@ const ZONE_MODS: Record<string, RailMod> = {
141141 // envelope cap, so gravity itself is what hurls the marble — and the142142 // centering spring keeps the surge straight.143143 gravity: { tilt: 0.3, center: true },144+ // Takeable spur window: centering keeps main-line marbles out of the145+ // branch mouth (the composite opens one wall there) and branch riders146+ // square on the diving board.147+ spur: { center: true },144148 };145149 146150 /** The station spacing every driver samples at. */@@ -169,15 +173,37 @@ export function hasAltBranch(seg: RailSource): boolean {
169173 }170174 171175 /** Dense rail for one segment (local s from 0). `useAlt` rides every172- * diamond's OTHER branch — the respawn-variety rail. */176+ * diamond's OTHER branch — the respawn-variety rail. A takeable SPUR's177+ * alt ends diving through the hole; the stations then SKIP the main178+ * hook and resume at the receiver window passing directly under the179+ * hole (Δ 1.0 by construction) — station order matches what the falling180+ * marble physically does, so the wishdir look target never points back181+ * up at geometry the rider skipped. */173182 export function railForSegment(seg: RailSource, useAlt = false): Rail {174183 const rail: Rail = { pts: [], tans: [], s: [], mods: [] };175184 let acc = 0;185+ /** Set after a spur dive: skip windows until one passes this point. */186+ let resumeNear: readonly [number, number, number] | null = null;187+ const approaches = (188+ curve: readonly LoopPath["segments"][number]["curve"][number][],189+ p: readonly [number, number, number],190+ ): boolean =>191+ curve.some((part) =>192+ [0, 0.5, 1].some((t) => {193+ const q = segPoint(part, t);194+ return Math.hypot(q[0] - p[0], q[1] - p[1], q[2] - p[2]) < 0.6;195+ })196+ );176197 for (const [lsIdx, ls] of seg.path.segments.entries()) {198+ if (resumeNear) {199+ if (!approaches(ls.curve, resumeNear)) continue;200+ resumeNear = null;201+ }177202 const zone = seg.windowMods[lsIdx];178203 const mod = (zone ? ZONE_MODS[zone] : undefined) ??179204 TILE_MODS[seg.pieceFamilies[lsIdx]] ?? null;180- for (const part of (useAlt && ls.alt ? ls.alt.curve : ls.curve)) {205+ const alt = useAlt && ls.alt;206+ for (const part of (alt ? ls.alt!.curve : ls.curve)) {181207 const plen = segLength(part);182208 const steps = Math.max(1, Math.ceil(plen / RAIL_STEP));183209 for (let j = 0; j < steps; j++) {@@ -192,6 +218,11 @@ export function railForSegment(seg: RailSource, useAlt = false): Rail {
192218 acc += l;193219 }194220 }221+ if (alt && ls.alt!.spur) {222+ const lastPart = ls.alt!.curve[ls.alt!.curve.length - 1];223+ const tip = segPoint(lastPart, 1);224+ resumeNear = [tip[0], tip[1], tip[2]];225+ }195226 }196227 return rail;197228 }@@ -30,6 +30,30 @@ const FANS = [
3030 ];3131 const FAN_BOOST = 6;3232 33+// M16 wind variety: fans are force-field volumes now (client fanField.ts),34+// and two new placements exploit that. Both replace the legacy cross fan35+// on a ledge via draws APPENDED after the legacy ones — every rng stream36+// that existed before this milestone draws byte-identically.37+const STANDING_FANS = ["fan-standing-two", "fan-standing-four"];38+const TABLE_FANS = ["fan-two", "fan-four", "fan-simple-two", "fan-simple-four"];39+/** Boost corridor: standing fan aligned WITH the channel on the straight40+ * run, blowing along travel. Punctuation, not weather. */41+const BOOST_FAN_CHANCE = 0.22;42+/** Updraft column: table fan flat at the channel lip — lift assist for43+ * marbles hugging that edge. */44+const UPDRAFT_FAN_CHANCE = 0.2;45+/** Lip offset toward the channel, TRACK units. Must stay < 0.5 so the46+ * fan still rounds to its ledge cell (the ground-vocabulary test's own47+ * "fans exist only as ledge furniture" rule). */48+const LIP_OFFSET = 0.45;49+/** Dir index → unit step (0=+x, 1=+z, 2=−x, 3=−z). */50+const DIR_STEP: readonly (readonly [number, number])[] = [51+ [1, 0],52+ [0, 1],53+ [-1, 0],54+ [0, -1],55+];56+3357 export interface FurnishedPiece {3458 placement: Placement;3559 /** Absolute plan-cell index of the host (for chapter stamping). */@@ -140,17 +164,80 @@ export function planIslandFurniture(
140164 // FAN: blades face the channel, spinning; boost is kinematic-141165 // mode flavor (the endless assist law has its own zones).142166 const facing = ((away + 2) % 4) as Dir;143- out.push({144- placement: {145- pieceId: r.pick(FANS),146- pos: [cx, host.baseY, cz],147- rotation: ((1 - facing + 4) % 4) as Dir,148- role: "track",149- spin: { axis: [0, 0, 1], radPerSec: 4 + r.float() * 3 },150- boost: FAN_BOOST,151- },152- host: sc.host,153- });167+ // Legacy draw order preserved EXACTLY (pick, then float), then168+ // the M16 wind-variant draws appended after.169+ const crossPiece = r.pick(FANS);170+ const radPerSec = 4 + r.float() * 3;171+ const variant = r.float();172+ const [tx, tz] = DIR_STEP[facing]; // unit step toward the channel173+ if (variant < BOOST_FAN_CHANCE) {174+ // BOOST CORRIDOR: standing fan aligned WITH the channel,175+ // blowing along travel. The scaled disc spans 2 world units —176+ // its near edge reaches the rail line — so the wind hint parks177+ // the capsule 1 world unit over the channel edge and sinks it178+ // toward marble height. ~6.5 u/s² tailwind at the face fading179+ // over 5 world units, strongest on the fan's side of the180+ // corridor. (Wind hint values are WORLD units.)181+ out.push({182+ placement: {183+ pieceId: r.pick(STANDING_FANS),184+ pos: [cx, host.baseY, cz],185+ rotation: ((1 - host.dirIn + 4) % 4) as Dir,186+ role: "track",187+ spin: { axis: [0, 0, 1], radPerSec },188+ boost: FAN_BOOST,189+ wind: {190+ strength: 6.5,191+ length: 5,192+ radius: 1.3,193+ offset: [tx, -0.3, tz],194+ },195+ },196+ host: sc.host,197+ });198+ } else if (variant < BOOST_FAN_CHANCE + UPDRAFT_FAN_CHANCE) {199+ // UPDRAFT COLUMN: table fan flat at the channel lip, blowing200+ // straight up — ~9.5 u/s² against gravity 20 (half-weight lift201+ // wobble / jump assist, not orbit) for marbles hugging this202+ // edge; the hint slides the column over the lip and starts it203+ // low enough to catch a rolling marble.204+ out.push({205+ placement: {206+ pieceId: r.pick(TABLE_FANS),207+ pos: [208+ cx + tx * LIP_OFFSET,209+ host.baseY,210+ cz + tz * LIP_OFFSET,211+ ],212+ rotation: ((1 - facing + 4) % 4) as Dir,213+ role: "track",214+ spin: { axis: [0, 0, 1], radPerSec },215+ boost: FAN_BOOST,216+ wind: {217+ strength: 9.5,218+ length: 2.2,219+ radius: 0.9,220+ offset: [tx * 0.35, -0.5, tz * 0.35],221+ },222+ },223+ host: sc.host,224+ });225+ } else {226+ // CROSS-WIND (legacy): blades face the channel; the derived227+ // capsule crosses it — the client fan field turns this into a228+ // lateral gust with no field needed here.229+ out.push({230+ placement: {231+ pieceId: crossPiece,232+ pos: [cx, host.baseY, cz],233+ rotation: ((1 - facing + 4) % 4) as Dir,234+ role: "track",235+ spin: { axis: [0, 0, 1], radPerSec },236+ boost: FAN_BOOST,237+ },238+ host: sc.host,239+ });240+ }154241 }155242 }156243 }@@ -140,7 +140,26 @@ export interface IslandCrossing {
140140 export interface PlanMod {141141 start: number;142142 len: number;143- kind: "boost" | "jump" | "propeller" | "shock" | "icy" | "gravity";143+ kind: "boost" | "jump" | "propeller" | "shock" | "icy" | "gravity" | "spur";144+}145+146+/** Takeable spur drop (M13's loop-mode takeable.ts wired into the void):147+ * a 1-cell straight host whose side cell carries an end-hole diving148+ * board, with the MAIN line hooking down to pass back DIRECTLY UNDER149+ * that cell at Δ exactly 1.0 (the bridge law). world.ts mutates the host150+ * placement straight → split, adds the board, and hangs the one-way alt151+ * centerline (out over the hole) on the host window. */152+export interface SpurDrop {153+ /** Cell index of the split host (its window is pinned to 1 cell). */154+ host: number;155+ /** Cell index where the main line passes back under the branch cell —156+ * the receiver the falling marble lands on (same coords as the branch157+ * cell, baseY exactly host − 1.0). */158+ receiver: number;159+ /** World dir from the host toward the branch cell. */160+ dir: Dir;161+ /** The diving-board piece on the branch cell. */162+ cap: "end-hole-rounded" | "end-hole-square";144163 }145164 146165 export interface SegmentPlan {@@ -148,6 +167,8 @@ export interface SegmentPlan {
148167 cells: OpenCell[];149168 /** Movement set-piece zones (M11): e.g. boost runs into big chutes. */150169 mods: PlanMod[];170+ /** Takeable spur drops (local host/receiver cell indices). */171+ spurs: SpurDrop[];151172 /** Slope/set-piece windows (local indices) — realize commits them as-is. */152173 reservations: Reservation[];153174 crossings: IslandCrossing[];@@ -214,6 +235,7 @@ class SegmentBuilder {
214235 private cells: OpenCell[] = [];215236 private reservations: Reservation[] = [];216237 private mods: PlanMod[] = [];238+ private spurs: SpurDrop[] = [];217239 private crossings: IslandCrossing[] = [];218240 private pose: HeadPose;219241 private sinceAnchor: number;@@ -277,6 +299,7 @@ class SegmentBuilder {
277299 index: this.index,278300 cells: this.cells,279301 mods: this.mods,302+ spurs: this.spurs,280303 reservations: this.reservations,281304 crossings: this.crossings,282305 entry: this.entry,@@ -396,6 +419,7 @@ class SegmentBuilder {
396419 ["helixDive", 0.5],397420 ["icy", 0.7],398421 ["gravity", 0.5],422+ ["spurDrop", 0.35],399423 ] as const,400424 );401425 const attempts: (() => boolean)[] = [];@@ -450,6 +474,8 @@ class SegmentBuilder {
450474 return this.tryIcy(rng);451475 case "gravity":452476 return this.tryGravity(rng);477+ case "spurDrop":478+ return this.trySpurDrop(rng);453479 }454480 for (const attempt of attempts) if (attempt()) return true;455481 return false;@@ -809,6 +835,104 @@ class SegmentBuilder {
809835 return true;810836 }811837 838+ /**839+ * TAKEABLE SPUR DROP (M13's loop-mode machinery wired into the void):840+ * a straight host cell whose side cell carries an end-hole diving841+ * board, with the MAIN line hooking down 1.0 and passing back DIRECTLY842+ * UNDER that side cell — the receiver at Δ exactly 1.0 below the deck843+ * (the bridge law: two stacked channels just clear). A marble riding844+ * the branch (respawn variety alternates onto it) rolls out over the845+ * hole, falls through, and lands ON ITS OWN LINE (hole semantics, USER846+ * RULE: every fall lands on track below — enforced by construction).847+ * The continuation IS the main line, so the head walks on normally.848+ *849+ * Move pattern (branch left shown; right mirrors), deck level y0:850+ *851+ * B ← A4 host H straight, then a 0.5 descent, two turns852+ * ↑ ↑ around the block at y0−0.5, a second 0.5853+ * → H → A2 → A3 descent into B (= the branch cell, at y0−1),854+ * (B above H+left) and a flat runway cell out.855+ *856+ * One per segment — punctuation, not texture.857+ */858+ private hadSpur = false;859+ private trySpurDrop(rng: Rng): boolean {860+ if (this.hadSpur) return false;861+ // The two descents realize as reversed slant-b (the jump clause's862+ // own availability check).863+ if (864+ !this.registry.variants.some((v) =>865+ v.id === "slant-b" && v.reversed && v.moves.length === 1866+ )867+ ) return false;868+ const sides: ("L" | "R")[] = rng.chance(0.5) ? ["L", "R"] : ["R", "L"];869+ for (const side of sides) {870+ if (this.trySpurSide(rng.child(`side/${side}`), side)) return true;871+ }872+ return false;873+ }874+875+ private trySpurSide(rng: Rng, side: "L" | "R"): boolean {876+ const steps: Step[] = [877+ // Host (the future split junction).878+ { move: "F", band: [0, CHANNEL_CLEARANCE] },879+ // First 0.5 descent (band shape mirrors commitSlope/tryJump).880+ { move: "F", band: [-0.5, CHANNEL_CLEARANCE], stepAfter: -0.5 },881+ // Two turns around the block at y0−0.5.882+ { move: side, band: [0, CHANNEL_CLEARANCE] },883+ { move: side, band: [0, CHANNEL_CLEARANCE] },884+ // Second 0.5 descent, arriving under the branch cell.885+ { move: "F", band: [-0.5, CHANNEL_CLEARANCE], stepAfter: -0.5 },886+ // The receiver: the branch cell's column at deck − 1.0.887+ { move: "F", band: [0, CHANNEL_CLEARANCE] },888+ // Flat runway so the landing rolls on.889+ { move: "F", band: [0, CHANNEL_CLEARANCE] },890+ ];891+ const probed = this.probe(steps);892+ if (!probed) return false;893+ const host = probed.cells[0];894+ const w = side === "L"895+ ? turnLeft(this.pose.dir)896+ : turnRight(this.pose.dir);897+ const [wx, wz] = DIR_STEP[w];898+ const bx = host.x + wx;899+ const bz = host.z + wz;900+ const rec = probed.cells[5];901+ // The hook must come back exactly under the branch cell at Δ 1.0 —902+ // true by construction; keep the law loud if the pattern drifts.903+ if (rec.x !== bx || rec.z !== bz || rec.y !== host.y - 1) return false;904+ // The branch deck band is claimed manually (probe only walks the905+ // line); it stacks EXACTLY on the receiver's channel claim below.906+ if (!this.occ.isFree(bx, bz, host.y, host.y + CHANNEL_CLEARANCE)) {907+ return false;908+ }909+ const start = this.cells.length;910+ if (!this.commitSteps(steps)) return false;911+ this.occ.claim(bx, bz, host.y, host.y + CHANNEL_CLEARANCE, this.ref);912+ // Pin the structural windows: the host must realize as a plain913+ // 1-cell straight (its placement mutates straight → split in914+ // world.ts), the descents as slant-b, and the receiver as a bare915+ // open channel — no arch/wave may realize over the landing.916+ this.reservations.push(917+ { start, len: 1, pieceIds: ["straight"], reversed: false },918+ { start: start + 1, len: 1, pieceIds: ["slant-b"], reversed: true },919+ { start: start + 4, len: 1, pieceIds: ["slant-b"], reversed: true },920+ { start: start + 5, len: 1, pieceIds: ["straight"], reversed: false },921+ );922+ // The spur zone marks the host window: rail centering keeps923+ // main-line marbles out of the branch mouth and branch riders on924+ // the board (proxies key the composite off the alt marker instead).925+ this.mods.push({ start, len: 1, kind: "spur" });926+ this.spurs.push({927+ host: start,928+ receiver: start + 5,929+ dir: w,930+ cap: rng.chance(0.5) ? "end-hole-rounded" : "end-hole-square",931+ });932+ this.hadSpur = true;933+ return true;934+ }935+812936 /** Last resort when boxed in: steep 1.5 chute straight down and out. */813937 private tryEscape(rng: Rng): boolean {814938 for (const delta of ["1.5", "1", "0.5"]) {@@ -388,8 +388,11 @@ export function channelProxies(
388388 // divider, mirroring the real split-double geometry. (Two overlapping389389 // one-sided branch RIBBONS were tried first: their surfaces cross at390390 // the split and the wedged marble destabilized the solver.)391+ // Takeable spur windows carry the alt marker `spur` and get their own392+ // composite instead: the branch deck with the hole OPEN.391393 for (const seg of path.segments) {392- if (seg.alt) emitDiamond(seg, out);394+ if (seg.alt?.spur) emitSpur(seg, out);395+ else if (seg.alt) emitDiamond(seg, out);393396 }394397 return out;395398 }@@ -468,6 +471,155 @@ function emitDiamond(
468471 }469472 }470473 474+/** Slab composite for a takeable spur window (endless respawn variety).475+ * World geometry (the 1-cell host window is straight and flat): the476+ * split-junction deck with the branch mouth open on one side, the477+ * diving-board floor running out to the HOLE LIP and then STOPPING (no478+ * floor over the hole — the fall is real physics), branch side walls out479+ * to a cap wall past the hole that reaches down into the drop shaft (a480+ * rolling marble can never clear it: it leaves the lip falling, so it481+ * always meets the cap below its top and drops in), a near shaft guide482+ * under the floor lip, and a catch pan a hair above the receiver's483+ * ribbon floor Δ exactly 1.0 track (2.0 world) below — the receiver is484+ * the segment's OWN line passing back under the branch cell by485+ * construction, so every fall lands on track. */486+function emitSpur(487+ seg: LoopPath["segments"][number],488+ out: CollisionProxy[],489+): void {490+ if (!seg.alt) return;491+ const entry = segPoint(seg.curve[0], 0);492+ const lastPart = seg.curve[seg.curve.length - 1];493+ const exit = segPoint(lastPart, 1);494+ const dx = exit[0] - entry[0];495+ const dz = exit[2] - entry[2];496+ const L = Math.hypot(dx, dz);497+ if (L < 1e-6) return;498+ const yaw = Math.atan2(dx, dz);499+ const fwd: [number, number] = [dx / L, dz / L];500+ let lat: [number, number] = [fwd[1], -fwd[0]];501+ const floorY = entry[1];502+ // Orient +side toward the branch: the alt centerline ends out over the503+ // hole, so its endpoint picks the sign.504+ const altLast = seg.alt.curve[seg.alt.curve.length - 1];505+ const tip = segPoint(altLast, 1);506+ const mx = entry[0] + fwd[0] * (L / 2);507+ const mz = entry[2] + fwd[1] * (L / 2);508+ if ((tip[0] - mx) * lat[0] + (tip[2] - mz) * lat[1] < 0) {509+ lat = [-lat[0], -lat[1]];510+ }511+ const branchYaw = Math.atan2(lat[0], lat[1]);512+ const at = (513+ along: number,514+ side: number,515+ ): [number, number] => [516+ entry[0] + fwd[0] * along + lat[0] * side,517+ entry[2] + fwd[1] * along + lat[1] * side,518+ ];519+ const wall = (520+ a: readonly [number, number],521+ b: readonly [number, number],522+ ): void => {523+ const [ax, az] = at(a[0], a[1]);524+ const [bx, bz] = at(b[0], b[1]);525+ const wx = bx - ax;526+ const wz = bz - az;527+ const wlen = Math.hypot(wx, wz);528+ out.push({529+ kind: "slab",530+ center: [(ax + bx) / 2, floorY + 0.35, (az + bz) / 2],531+ half: [0.09, 0.4, wlen / 2],532+ yaw: Math.atan2(wx, wz),533+ pitch: 0,534+ });535+ };536+537+ const HW = 0.95; // main channel half width at the walls538+ const BR = 0.86; // branch channel half width (ribbon wall line)539+ const HOLE = TRACK_SCALE; // hole center: the branch cell center540+ const NEAR = HOLE - 0.55; // the floor lip — the hole opens here541+ const CAP = HOLE + 0.6; // cap wall past the hole542+ const recY = floorY - TRACK_SCALE; // Δ 1.0 track below (bridge law)543+ const deckY = floorY - FLOOR_HALF_T + 0.02; // hair above ribbon sheets544+545+ // Junction deck (main lane), reaching to both ports.546+ {547+ const [cx, cz] = at(L / 2, 0);548+ out.push({549+ kind: "slab",550+ center: [cx, deckY, cz],551+ half: [HW, FLOOR_HALF_T, L / 2 + 0.05],552+ yaw,553+ pitch: 0,554+ });555+ }556+ // Diving-board floor: junction edge out to the hole lip. NOTHING past557+ // the lip — the opening IS the set-piece.558+ {559+ const s0 = HW - 0.15;560+ const [cx, cz] = at(L / 2, (s0 + NEAR) / 2);561+ out.push({562+ kind: "slab",563+ center: [cx, deckY, cz],564+ half: [BR, FLOOR_HALF_T, (NEAR - s0) / 2],565+ yaw: branchYaw,566+ pitch: 0,567+ });568+ }569+ // Main-lane walls: solid on the far side; mouth-framing stubs on the570+ // branch side (the neighbor ribbons' walls stop at the ports).571+ wall([-0.05, -HW], [L + 0.05, -HW]);572+ wall([-0.05, HW], [L / 2 - BR, HW]);573+ wall([L / 2 + BR, HW], [L + 0.05, HW]);574+ // Branch side walls, junction mouth out past the hole to the cap.575+ wall([L / 2 - BR, HW], [L / 2 - BR, CAP]);576+ wall([L / 2 + BR, HW], [L / 2 + BR, CAP]);577+ // Cap wall: crosses the branch past the hole and reaches DOWN into the578+ // shaft (top at wall height, bottom just above the receiver's own wall579+ // tops — the marble cannot slip under, and the receiver channel stays580+ // clear beneath it).581+ {582+ const top = floorY + 0.75;583+ const bot = recY + 1.25;584+ const [cx, cz] = at(L / 2, CAP);585+ out.push({586+ kind: "slab",587+ center: [cx, (top + bot) / 2, cz],588+ half: [BR, (top - bot) / 2, 0.09],589+ yaw: branchYaw,590+ pitch: 0,591+ });592+ }593+ // Near shaft guide under the floor lip: a cap-side bounce-back drops594+ // straight down inside the shaft instead of drifting back under the595+ // board and over the receiver's near wall.596+ {597+ const top = floorY - 0.2;598+ const bot = recY + 1.25;599+ const [cx, cz] = at(L / 2, NEAR);600+ out.push({601+ kind: "slab",602+ center: [cx, (top + bot) / 2, cz],603+ half: [BR, (top - bot) / 2, 0.09],604+ yaw: branchYaw,605+ pitch: 0,606+ });607+ }608+ // Catch pan under the hole, a hair ABOVE the receiver ribbon sheet609+ // (arriving below a one-sided mesh means catching its edge forever —610+ // the drop-shaft law).611+ {612+ const [cx, cz] = at(L / 2, HOLE);613+ out.push({614+ kind: "slab",615+ center: [cx, recY - FLOOR_HALF_T + 0.03, cz],616+ half: [0.75, FLOOR_HALF_T, 0.75],617+ yaw,618+ pitch: 0,619+ });620+ }621+}622+471623 /** Catch pan at a jump landing, reaching back under the flight path. Its472624 * top sits a hair ABOVE the landing ribbon sheet (arriving below a473625 * one-sided mesh means catching its edge forever). Lateral catching is@@ -15,7 +15,13 @@
1515 * segment and its islands made, so memory tracks the streaming window, not1616 * the distance travelled.1717 */18-import type { Dir, LoopPath, Placement } from "../../shared/types.ts";18+import type {19+ CurveSeg,20+ Dir,21+ LoopPath,22+ Placement,23+ Vec3,24+} from "../../shared/types.ts";1925 import { rngFamily } from "../rng.ts";2026 import { buildRegistry, type PieceRegistry } from "../registry.ts";2127 import { pickStyle, type StyleField } from "../style.ts";@@ -24,7 +30,13 @@ import type { Skeleton } from "../topology.ts";
2430 import { realize } from "../wfc.ts";2531 import { assemble } from "../assemble.ts";2632 import type { DecorCatalog } from "../decor.ts";27-import { applyXform, windowXform } from "../geom.ts";33+import {34+ applyXform,35+ DIR_STEP,36+ segLength,37+ turnRight,38+ windowXform,39+} from "../geom.ts";2840 import { CHANNEL_CLEARANCE, Occupancy, OccupancyTx } from "./occupancy.ts";2941 import { scaleSeg, scaleVec, TRACK_SCALE } from "./scale.ts";3042 import {@@ -350,6 +362,88 @@ export class EndlessWorld {
350362 assembled.placements,351363 );352364 365+ // TAKEABLE SPUR DROPS (loop-mode takeable.ts wired into the void):366+ // the planner pinned the host to a 1-cell straight window; here the367+ // host placement mutates straight → split (the junction's main368+ // channel IS the host's channel; a right-hand branch renders the369+ // native mesh mirrored — the takeable.ts move), the branch cell gets370+ // its end-hole diving board, and the host window's loop segment371+ // gains a ONE-WAY alt centerline out over the hole, flagged `spur`372+ // so the collision proxies emit the branch deck with the hole OPEN:373+ // a marble riding the branch genuinely falls through and lands on374+ // the segment's own receiver channel Δ exactly 1.0 below.375+ const spurPieces: { placement: Placement; host: number }[] = [];376+ for (const spur of plan.spurs) {377+ const wi = windows.findIndex((w) =>378+ w.start === spur.host && w.len === 1379+ );380+ if (wi < 0) continue; // reserved by the planner — always found381+ const ls = assembled.loop.segments[wi];382+ const hp = ls.placementIndex >= 0383+ ? assembled.placements[ls.placementIndex]384+ : undefined;385+ if (!hp || hp.pieceId !== "straight") continue;386+ const cell = plan.cells[spur.host];387+ hp.pieceId = "split";388+ if (spur.dir === turnRight(cell.dirIn)) hp.mirrored = true;389+ const [wx, wz] = DIR_STEP[spur.dir];390+ const bx = cell.x + wx;391+ const bz = cell.z + wz;392+ spurPieces.push({393+ placement: {394+ pieceId: spur.cap,395+ pos: [bx, cell.baseY, bz],396+ rotation: ((1 - spur.dir + 4) % 4) as Dir,397+ role: "track",398+ },399+ host: spur.host,400+ });401+ // The alt: entry port → rounded pull-off at the junction → out to402+ // the hole center → STRAIGHT DOWN the shaft. No return leg (loop403+ // mode's teardrop): the fall is guaranteed. The dive is part of404+ // the line ON PURPOSE — the wishdir autopilot steers toward a look405+ // station ahead, and without shaft stations the look target406+ // crosses back to the main exit port and PARKS the marble a407+ // length short of the lip (found the hard way on spur-scan-7).408+ // railForSegment then skips the main hook after the dive, so the409+ // stations resume at the receiver window directly below.410+ const floor = cell.baseY + 0.25;411+ const [fx, fz] = DIR_STEP[cell.dirIn];412+ const cx = cell.x;413+ const cz = cell.z;414+ const curve: CurveSeg[] = [415+ {416+ kind: "line",417+ a: [cx - fx / 2, floor, cz - fz / 2],418+ b: [cx - fx * 0.1, floor, cz - fz * 0.1],419+ },420+ {421+ kind: "cubic",422+ p: [423+ [cx - fx * 0.1, floor, cz - fz * 0.1],424+ [cx, floor, cz],425+ [cx + wx * 0.18, floor, cz + wz * 0.18],426+ [cx + wx * 0.35, floor, cz + wz * 0.35],427+ ] as [Vec3, Vec3, Vec3, Vec3],428+ },429+ {430+ kind: "line",431+ a: [cx + wx * 0.35, floor, cz + wz * 0.35],432+ b: [bx, floor, bz],433+ },434+ {435+ kind: "line",436+ a: [bx, floor, bz],437+ b: [bx, floor - 1, bz],438+ },439+ ];440+ ls.alt = {441+ curve,442+ length: curve.reduce((acc, s) => acc + segLength(s), 0),443+ spur: true,444+ };445+ }446+353447 // Islands anchored in this segment.354448 const islands = plan.crossings.map((crossing) =>355449 buildIsland(@@ -372,6 +466,7 @@ export class EndlessWorld {
372466 const MOD_PRIORITY: readonly string[] = [373467 "jump",374468 "propeller",469+ "spur",375470 "boost",376471 "gravity",377472 "icy",@@ -504,6 +599,10 @@ export class EndlessWorld {
504599 ...placement,505600 chapter: cellChapter(host),506601 })),602+ ...spurPieces.map(({ placement, host }): Placement => ({603+ ...placement,604+ chapter: cellChapter(host),605+ })),507606 ...pads,508607 ].map((p): Placement => ({509608 ...p,@@ -514,11 +613,13 @@ export class EndlessWorld {
514613 segments: assembled.loop.segments.map((s) => {515614 const curve = s.curve.map(scaleSeg);516615 const length = s.length * TRACK_SCALE;517- // Diamond branches: the alt centerline scales with the track too.616+ // Diamond/spur branches: the alt centerline scales with the617+ // track too (the spur marker rides along for the proxies).518618 const alt = s.alt519619 ? {520620 curve: s.alt.curve.map(scaleSeg),521621 length: s.alt.length * TRACK_SCALE,622+ ...(s.alt.spur ? { spur: true } : {}),522623 }523624 : undefined;524625 return { ...s, curve, length, ...(alt ? { alt } : {}) };@@ -41,6 +41,19 @@ export interface Placement {
4141 scale?: number;4242 /** Constant spin for fan blades etc. */4343 spin?: { axis: Vec3; radPerSec: number };44+ /** Wind-capsule tuning hint (endless fan field, M16). The client45+ * derives a capsule of moving air from the fan's geometry + rotation46+ * (src/client/fanField.ts); these override the family defaults. All47+ * values are WORLD units — endless placements are world-scaled, and48+ * this field rides through untouched. `offset` nudges the derived49+ * capsule origin (e.g. the boost fan's wash parks over the channel50+ * while its housing stays on the ledge). */51+ wind?: {52+ strength?: number;53+ length?: number;54+ radius?: number;55+ offset?: Vec3;56+ };4457 /** Lap-line gate: renderer restyles this placement distinctly. */4558 goal?: boolean;4659 /** Endless pipe-color chapter (the island id whose reach this track@@ -74,8 +87,12 @@ export interface LoopSegment {
7487 /** World-space centerline through this placement, entry → exit. */7588 curve: readonly CurveSeg[];7689 length: number;77- /** A diamond's other branch (same endpoints) — laps alternate. */78- alt?: { curve: readonly CurveSeg[]; length: number };90+ /** A diamond's other branch (same endpoints) — laps alternate. With91+ * `spur` set the branch is a takeable spur instead (endless respawn92+ * variety): a ONE-WAY line out over an end-hole diving board that93+ * ends diving through the hole onto the receiver channel below —94+ * railForSegment then skips the main hook and resumes there. */95+ alt?: { curve: readonly CurveSeg[]; length: number; spur?: boolean };7996 }8097 8198 export interface LoopPath {@@ -0,0 +1,266 @@
1+/**2+ * Fan wind field (M16): volume math, placement-derived directions for3+ * both fan families, per-segment binning, and the furniture wind4+ * variants (boost corridor / lip updraft) emerging deterministically5+ * from the endless stream.6+ */7+import { assert, assertAlmostEquals, assertEquals } from "@std/assert";8+import type { Placement } from "../src/shared/types.ts";9+import {10+ CROSS_LENGTH,11+ CROSS_RADIUS,12+ CROSS_STRENGTH,13+ FanField,14+ type FanVolume,15+ fanVolumeFor,16+ fanVolumesOf,17+ FULL_CORE,18+ STANDING_HUB,19+ TABLE_HUB,20+ UPDRAFT_STRENGTH,21+} from "../src/client/fanField.ts";22+import { EndlessWorld } from "../src/gen/endless/world.ts";23+import { TRACK_SCALE } from "../src/gen/endless/scale.ts";24+import type { DecorCatalog } from "../src/gen/decor.ts";25+26+const catalog: DecorCatalog = {27+ packs: [28+ {29+ slug: "nature-kit",30+ ids: JSON.parse(Deno.readTextFileSync(31+ new URL("../assets/nature-kit.manifest.json", import.meta.url),32+ )).pieces.map((p: { id: string }) => p.id),33+ },34+ ],35+};36+37+/** Dir index → world step (0=+x, 1=+z, 2=−x, 3=−z). */38+const DIR_VEC: readonly (readonly [number, number, number])[] = [39+ [1, 0, 0],40+ [0, 0, 1],41+ [-1, 0, 0],42+ [0, 0, -1],43+];44+45+function fieldWith(vols: FanVolume[], seg = 0): FanField {46+ const f = new FanField();47+ f.addSegment(seg, vols);48+ return f;49+}50+51+const AXIS_X: FanVolume = {52+ origin: [0, 0, 0],53+ dir: [1, 0, 0],54+ length: 4,55+ radius: 1,56+ strength: 6,57+};58+59+Deno.test("fan field: axial falloff is linear from face to length", () => {60+ const f = fieldWith([AXIS_X]);61+ const out: [number, number, number] = [0, 0, 0];62+ // At the face: full strength · mass.63+ assert(f.forceNear(0, [0, 0, 0], 2, out));64+ assertAlmostEquals(out[0], 2 * 6, 1e-12);65+ assertEquals(out[1], 0);66+ assertEquals(out[2], 0);67+ // Halfway: half strength.68+ out[0] = out[1] = out[2] = 0;69+ assert(f.forceNear(0, [2, 0, 0], 2, out));70+ assertAlmostEquals(out[0], 2 * 3, 1e-12);71+ // At/behind the ends: nothing.72+ out[0] = 0;73+ assert(!f.forceNear(0, [4, 0, 0], 2, out));74+ assert(!f.forceNear(0, [-0.1, 0, 0], 2, out));75+ assertEquals(out, [0, 0, 0]);76+});77+78+Deno.test("fan field: radial core is full, edge is feathered to zero", () => {79+ const f = fieldWith([AXIS_X]);80+ const out: [number, number, number] = [0, 0, 0];81+ // Inside the full core (radius·FULL_CORE = 0.6): same as on-axis.82+ assert(f.forceNear(0, [2, 0.5, 0], 1, out));83+ assertAlmostEquals(out[0], 3, 1e-12);84+ // In the feather band: linear to 0 at radius. rad=0.9 → (1−0.9)/0.4.85+ out[0] = 0;86+ assert(f.forceNear(0, [2, 0, 0.9], 1, out));87+ assertAlmostEquals(out[0], 3 * (1 - 0.9) / (1 - FULL_CORE), 1e-12);88+ // Outside the radius: nothing.89+ out[0] = 0;90+ assert(!f.forceNear(0, [2, 1.1, 0], 1, out));91+ assertEquals(out[0], 0);92+});93+94+Deno.test("fan field: force accumulates into out and scales with mass", () => {95+ const f = fieldWith([AXIS_X, {96+ origin: [0, 0, 0],97+ dir: [0, 1, 0],98+ length: 4,99+ radius: 1,100+ strength: 6,101+ }]);102+ const out: [number, number, number] = [7, 0, 0];103+ assert(f.forceNear(0, [0, 0, 0], 0.5, out));104+ assertAlmostEquals(out[0], 7 + 0.5 * 6, 1e-12); // accumulated, not reset105+ assertAlmostEquals(out[1], 0.5 * 6, 1e-12); // second volume too106+});107+108+Deno.test("standing fan: blow direction follows placement rotation", () => {109+ // Placement rotation r maps local +z (dir 1) to world dir (1−r)%4.110+ for (let rot = 0; rot < 4; rot++) {111+ const v = fanVolumeFor({112+ pieceId: "fan-standing-two",113+ pos: [10, 4, -6],114+ rotation: rot as 0 | 1 | 2 | 3,115+ role: "track",116+ scale: 2,117+ });118+ assert(v, `rotation ${rot}: no volume`);119+ assertEquals(v.dir, DIR_VEC[(1 - rot + 4) % 4], `rotation ${rot}`);120+ // Hub: origin sits STANDING_HUB·scale above the placement origin.121+ assertEquals(v.origin, [10, 4 + STANDING_HUB * 2, -6]);122+ assertEquals(v.strength, CROSS_STRENGTH);123+ assertEquals(v.length, CROSS_LENGTH);124+ assertEquals(v.radius, CROSS_RADIUS);125+ }126+});127+128+Deno.test("table fan: blows straight up regardless of rotation", () => {129+ for (const id of ["fan-two", "fan-four", "fan-simple-two"]) {130+ for (let rot = 0; rot < 4; rot++) {131+ const v = fanVolumeFor({132+ pieceId: id,133+ pos: [0, 2, 0],134+ rotation: rot as 0 | 1 | 2 | 3,135+ role: "track",136+ scale: 2,137+ });138+ assert(v, `${id} rot ${rot}: no volume`);139+ assertEquals(v.dir, [0, 1, 0]);140+ assertEquals(v.origin, [0, 2 + TABLE_HUB * 2, 0]);141+ assertEquals(v.strength, UPDRAFT_STRENGTH);142+ }143+ }144+});145+146+Deno.test("wind hint overrides tuning and nudges the origin", () => {147+ const v = fanVolumeFor({148+ pieceId: "fan-standing-four",149+ pos: [0, 0, 0],150+ rotation: 1,151+ role: "track",152+ scale: 2,153+ wind: { strength: 6.5, length: 5, radius: 1.3, offset: [1, -0.3, 0] },154+ });155+ assert(v);156+ assertEquals(v.strength, 6.5);157+ assertEquals(v.length, 5);158+ assertEquals(v.radius, 1.3);159+ assertEquals(v.origin, [1, STANDING_HUB * 2 - 0.3, 0]);160+ assertEquals(v.dir, [1, 0, 0]); // rotation 1 → world +x161+});162+163+Deno.test("non-fan placements derive no volume", () => {164+ assertEquals(165+ fanVolumeFor({166+ pieceId: "straight",167+ pos: [0, 0, 0],168+ rotation: 0,169+ role: "track",170+ }),171+ null,172+ );173+ assertEquals(174+ fanVolumesOf([175+ { pieceId: "banner", pos: [0, 0, 0], rotation: 0, role: "track" },176+ { pieceId: "fan-two", pos: [0, 0, 0], rotation: 0, role: "track" },177+ ]).length,178+ 1,179+ );180+});181+182+Deno.test("fan field: binning tests only segIdx ± 1", () => {183+ const f = new FanField();184+ f.addSegment(0, [AXIS_X]);185+ f.addSegment(5, [{ ...AXIS_X, origin: [0, 100, 0] }]);186+ const out: [number, number, number] = [0, 0, 0];187+ // Near bin 0: seen from segIdx −1..1, not from 3.188+ for (const segIdx of [-1, 0, 1]) {189+ out[0] = 0;190+ assert(f.forceNear(segIdx, [0, 0, 0], 1, out), `segIdx ${segIdx}`);191+ }192+ assert(!f.forceNear(3, [0, 0, 0], 1, out));193+ // Bin 5's volume from 4/5/6 only.194+ assert(f.forceNear(4, [0, 100, 0], 1, out));195+ assert(!f.forceNear(7, [0, 100, 0], 1, out));196+ // forceAt scans everything.197+ assert(f.forceAt([0, 100, 0], 1, out));198+ // Removal unregisters.199+ f.removeSegment(0);200+ assert(!f.forceNear(0, [0, 0, 0], 1, out));201+ f.clear();202+ assert(!f.forceAt([0, 100, 0], 1, out));203+});204+205+// ---------------------------------------------------------------------------206+// Furniture wind variants (M16): the endless stream deterministically207+// grows boost-corridor and lip-updraft fans, and every wind fan still208+// obeys the ledge rules.209+// ---------------------------------------------------------------------------210+211+Deno.test("endless furniture: wind fan variants emerge and stay on their ledges", () => {212+ let cross = 0;213+ let boost = 0;214+ let updraft = 0;215+ for (let n = 0; n < 10; n++) {216+ const w = new EndlessWorld(`wind-${n}`, catalog);217+ for (let i = 0; i < 8; i++) {218+ const seg = w.get(i);219+ for (const p of seg.placements) {220+ if (!p.pieceId.startsWith("fan-")) continue;221+ const standing = p.pieceId.startsWith("fan-standing");222+ if (!p.wind) {223+ cross++;224+ continue;225+ }226+ // Wind hints only ride on the two new varieties.227+ if (standing) {228+ boost++;229+ // Boost corridor: capsule parked over the channel, blowing230+ // along travel (horizontal — never up).231+ assertEquals(p.wind.strength, 6.5);232+ assert(p.wind.offset && p.wind.offset[1] === -0.3);233+ } else {234+ updraft++;235+ assertEquals(p.wind.strength, 9.5);236+ // Lip fan is offset < 0.5 track units — still rounds to a237+ // whole cell (the ledge), the ground-vocabulary rule.238+ const fx = p.pos[0] / TRACK_SCALE;239+ const fz = p.pos[2] / TRACK_SCALE;240+ assert(241+ Math.abs(fx - Math.round(fx)) < 0.5 - 1e-9 &&242+ Math.abs(fz - Math.round(fz)) < 0.5 - 1e-9,243+ `updraft fan strays off its cell (${fx},${fz})`,244+ );245+ }246+ }247+ }248+ }249+ assert(cross > 0, "legacy cross-wind fans vanished from the corpus");250+ assert(boost > 0, "no boost-corridor fan emerged across the corpus");251+ assert(updraft > 0, "no lip-updraft fan emerged across the corpus");252+});253+254+Deno.test("endless furniture: wind draws are deterministic per seed", () => {255+ const a = new EndlessWorld("wind-repeat", catalog);256+ const b = new EndlessWorld("wind-repeat", catalog);257+ for (let i = 0; i < 6; i++) {258+ const fansA = a.get(i).placements.filter((p: Placement) =>259+ p.pieceId.startsWith("fan-")260+ );261+ const fansB = b.get(i).placements.filter((p: Placement) =>262+ p.pieceId.startsWith("fan-")263+ );264+ assertEquals(fansA, fansB);265+ }266+});@@ -1,59 +1,59 @@
11 {22 "__version": "g535114c0fc9e",33 "alpha": [4- "006984b12d58d0df",5- "f0f51778cc573628",6- "ad695256c3954c00",7- "008975c9bee9a7e5",8- "5ccafa95da1303ff",9- "0111caa20efdbea3",10- "8de9da5ea3ff2de0",11- "1015982c3377f2e7",12- "267b046776dc6c04",13- "3e294de043df4bac",14- "50ed081e906cd2ef",15- "894da3770e3e5d56"4+ "620a7e82882399f6",5+ "d3f1bd21fa5acba0",6+ "c6780404ffa88a97",7+ "92d303076b7dfc98",8+ "8eee50563239ce82",9+ "3f359c281e9fc492",10+ "0f29d59a52befa82",11+ "6b86644211e6720d",12+ "4e5fce22fcf780ec",13+ "4695f8dfb5896376",14+ "be86dba6b6db3e4c",15+ "924b5a7059613da6"1616 ],1717 "zx9k2m4p1q": [18- "6c909421c7950df0",19- "0ae39a5dcfdb2c3f",20- "4bd2d354a864001c",21- "0dfd690d2a9b8d27",22- "4e3078c12e013823",23- "444614c80c34b220",24- "cca2ea16d562ceed",25- "17ac5e73da1a5d19",26- "667330ee7c53d785",27- "e94de16bb1e718d1",28- "5c4c11354e8c1d1b",29- "a6f263a70399c4dc"18+ "090f0e78413a17b2",19+ "31b77d36ab040fe0",20+ "e0ce61bd54a53b95",21+ "67333b162cb56d77",22+ "2436a657bd6e30c9",23+ "b5dd98cc25a8a812",24+ "11a664e9c7d97e71",25+ "2895507c98512dca",26+ "2bd41b506600fb04",27+ "5a1039071f7ab20d",28+ "5291aa43b4e01ff4",29+ "bec4fa5d4178d57f"3030 ],3131 "dami": [32- "ba4545457bf10088",33- "d6117050f28d28e9",34- "8fec794667fa2f27",35- "1b95d661dac7b923",36- "e483c73f69bf5c30",37- "c37292eb3cb0d93c",38- "0addcfe5e4ab0b7e",39- "eae4482169a69502",40- "7707cb3d433dcce2",41- "dd5008bcfcf2b61a",42- "228ace782d968ced",43- "851ed2a491e3da44"32+ "6384b026c8b9cac1",33+ "743336240edb75ae",34+ "974f3c577308cbfd",35+ "4f26ee6b0126f788",36+ "63ba72b805927e64",37+ "a3b008bef7070780",38+ "90de8a43783be44a",39+ "e47b29a9abe27af4",40+ "6211aa22111e0ffa",41+ "29bc7feab695757f",42+ "aac26fdac0998105",43+ "79cab7e1aead250e"4444 ],4545 "marbles": [46- "477112c6d276990e",47- "4436d4bfa4ef24b2",48- "d3ca3fee5f62c0a0",49- "f2b8a6e1de0b7502",50- "1fc8459aadcca894",51- "015b7e252ecab2e1",52- "f1e9fdff7cf8bd66",53- "bb5155eebac04066",54- "bb54d7e04e58d493",55- "545882b25599bf32",56- "e15f049b84fbb635",57- "4fbd613a487f5f36"46+ "170f1cf2a52888ed",47+ "be8fc17fe5fc1022",48+ "12826f679848dbc6",49+ "c69b253404805da3",50+ "e88cdad798736c42",51+ "b77edb0c8fabde6f",52+ "75b641db6d06f0e9",53+ "863cd5cef9d23e2c",54+ "0b8d4c23cb422194",55+ "8ef83aff9e547390",56+ "f8149e3f4d64aa02",57+ "1a5e55de24296ffc"5858 ]5959 }@@ -261,3 +261,92 @@ Deno.test("M15 race: the pack progresses, touches, and finishes deterministicall
261261 assertEquals(a.order, b.order);262262 assertEquals(a.hash, b.hash);263263 });264+265+Deno.test("respawn ghost: materializing inside the pack kicks nobody, then resolidifies once clear", async () => {266+ const module_ = await factory();267+ const w = new EndlessWorld("ghost-seed", catalog);268+ const engine = new PhysicsEngine(module_);269+ const seg = w.get(0);270+ engine.addSegment(0, [271+ ...channelProxies(seg.path, true, gapSegmentsOf(seg), wideScalesOf(seg)),272+ ...seg.islands.flatMap((isle) => islandProxies(isle.placements)),273+ ...bumperProxies(seg.placements),274+ ]);275+ const rail = railForSegment(seg);276+ const at = 6;277+ const t = rail.tans[at];278+ const nl = Math.hypot(t[2], t[0]) || 1;279+ const side = [t[2] / nl, 0, -t[0] / nl] as const;280+281+ // A settled cluster of three SOLID marbles across the channel.282+ const lanes = [-0.22, 0, 0.22];283+ for (let i = 1; i <= 3; i++) {284+ engine.spawnMarble(i, [285+ rail.pts[at][0] + side[0] * lanes[i - 1],286+ rail.pts[at][1] + 0.3,287+ rail.pts[at][2] + side[2] * lanes[i - 1],288+ ], [0, 0, 0]);289+ }290+ for (let s = 0; s < 60; s++) engine.step(1 / 60);291+ const before = [1, 2, 3].map((i) => engine.state(i));292+293+ // Respawn marble 0 DEEP inside the middle marble (centers 0.05 apart,294+ // 2r = 0.2), ghosted — setGhost AFTER spawnMarble (spawn resets the295+ // shape to solid) — drifting forward so it can clear the pack.296+ const mid = engine.state(2);297+ engine.spawnMarble(0, [298+ mid.pos[0] + side[0] * 0.05,299+ mid.pos[1],300+ mid.pos[2] + side[2] * 0.05,301+ ], [t[0] * 3, 0, t[2] * 3]);302+ engine.setGhost(0, true);303+ engine.step(1 / 60);304+305+ // (a) No neighbor velocity explosion on the spawn step: a solid306+ // overlap-spawn fires a depenetration impulse; the ghost must not.307+ for (const [k, i] of [1, 2, 3].entries()) {308+ const st = engine.state(i);309+ const dv = Math.hypot(310+ st.vel[0] - before[k].vel[0],311+ st.vel[1] - before[k].vel[1],312+ st.vel[2] - before[k].vel[2],313+ );314+ assert(dv < 0.6, `neighbor ${i} kicked by ghost spawn (|dv| ${dv.toFixed(2)})`);315+ }316+317+ // (b) It clears the pack and resolidifies within the 4s hard cap,318+ // using the client's separation rule (no neighbor within 2.5 radii).319+ const CLEAR = MARBLE_RADIUS * 2.5;320+ let solidAt = -1;321+ for (let s = 1; s <= 240; s++) {322+ engine.step(1 / 60);323+ const p0 = engine.state(0).pos;324+ let minD = Infinity;325+ for (const i of [1, 2, 3]) {326+ const p = engine.state(i).pos;327+ minD = Math.min(328+ minD,329+ Math.hypot(p[0] - p0[0], p[1] - p0[1], p[2] - p0[2]),330+ );331+ }332+ if (minD > CLEAR) {333+ engine.setGhost(0, false);334+ solidAt = s;335+ break;336+ }337+ }338+ assert(solidAt >= 0, "ghost never cleared the pack within the 4s cap");339+340+ // Solid again and stable: no depenetration pop after resolidifying.341+ for (let s = 0; s < 30; s++) engine.step(1 / 60);342+ for (const i of [0, 1, 2, 3]) {343+ const st = engine.state(i);344+ const speed = Math.hypot(st.vel[0], st.vel[1], st.vel[2]);345+ assert(346+ speed < 12,347+ `marble ${i} exploded after resolidify (|v| ${speed.toFixed(1)})`,348+ );349+ }350+ engine.removeSegment(0);351+ for (const i of [0, 1, 2, 3]) engine.destroyMarble(i);352+});@@ -42,6 +42,8 @@ import {
4242 wantJump,4343 } from "../src/client/physics/autopilot.ts";4444 import type { DecorCatalog } from "../src/gen/decor.ts";45+import { DIR_STEP } from "../src/gen/geom.ts";46+import { TRACK_SCALE } from "../src/gen/endless/scale.ts";4547 4648 const catalog: DecorCatalog = {4749 packs: [@@ -72,8 +74,9 @@ function drive(
7274 stopAtS = Infinity,7375 spawnAtS = 0,7476 useAlt = false,77+ onStep?: (pos: readonly [number, number, number]) => void,7578 ): RunResult {76- return driveWith(seed, segments, simSeconds, stopAtS, spawnAtS, useAlt);79+ return driveWith(seed, segments, simSeconds, stopAtS, spawnAtS, useAlt, onStep);7780 }7881 7982 let module_: PhysicsModule | null = null;@@ -85,6 +88,9 @@ function driveWith(
8588 stopAtS = Infinity,8689 spawnAtS = 0,8790 useAlt = false,91+ /** Per-step trajectory observer (scenario assertions about WHERE the92+ * marble actually went, e.g. the spur shaft transit). */93+ onStep?: (pos: readonly [number, number, number]) => void,8894 ): RunResult {8995 const w = new EndlessWorld(seed, catalog);9096 const engine = new PhysicsEngine(module_!);@@ -185,6 +191,7 @@ function driveWith(
185191 if (j) engine.jump(0, j[0], j[1]);186192 });187193 st = engine.state();194+ onStep?.(st.pos);188195 reachedS = Math.max(reachedS, rail.s[nearest]);189196 minY = Math.min(minY, st.pos[1]);190197 if (st.pos[1] < lowest - 8) fell = true;@@ -233,9 +240,9 @@ Deno.test("physics survivability: assisted marble clears early segments", async
233240 234241 Deno.test("scenario jump-gap: cold entry launches, flies, and is caught (both kinds)", async () => {235242 module_ = await factory();236- // One pinned case per gap kind: grav-scan-0 early segments plan a237- // PROPELLER (2-cell gap at 0.35 gravity), wide-scan-0 a plain JUMP.238- // (Re-pinned after the gravity clause reshuffled every seed's draws —243+ // One pinned case per gap kind: wide-scan-4 early segments plan a244+ // PROPELLER (2-cell gap at 0.35 gravity), wide-scan-5 a plain JUMP.245+ // (Re-pinned after the spur clause reshuffled every seed's draws —239246 // no seed freeze, so pins follow the stream.)240247 // Locate the flight span on the shared rail, spawn the marble COLD241248 // (base speed 2.2) just before the boost approach, and require it to@@ -245,8 +252,8 @@ Deno.test("scenario jump-gap: cold entry launches, flies, and is caught (both ki
245252 // landing cells absorb by construction; hot arrivals are exercised by246253 // the 7-seed survivability gate above.)247254 for (const { seed, kind } of [248- { seed: "grav-scan-0", kind: "propeller" },249- { seed: "wide-scan-0", kind: "jump" },255+ { seed: "wide-scan-4", kind: "propeller" },256+ { seed: "wide-scan-5", kind: "jump" },250257 ]) {251258 const w = new EndlessWorld(seed, catalog);252259 const rails: Rail[] = [];@@ -283,8 +290,8 @@ Deno.test("scenario lip-undershoot rescue: the jump decision saves a slow arriva
283290 // still crosses. This is the M14b contract: jumping is a DECISION from284291 // the marble's actual state, not a pre-priced launch.285292 for (const { seed, kind } of [286- { seed: "grav-scan-0", kind: "propeller" },287- { seed: "wide-scan-0", kind: "jump" },293+ { seed: "wide-scan-4", kind: "propeller" },294+ { seed: "wide-scan-5", kind: "jump" },288295 ]) {289296 const w = new EndlessWorld(seed, catalog);290297 const rails: Rail[] = [];@@ -347,20 +354,20 @@ Deno.test("physics determinism: identical trajectory hash across runs", async ()
347354 348355 Deno.test("scenario helix-dive: marble rides the full spiral and the underpass", async () => {349356 module_ = await factory();350- // grav-scan-23 segment 0 hosts a full-helix ensemble (pinned by the351- // scenario corpus test). The marble must enter the approach corner,357+ // helix-scan-11 segment 0 hosts a full-helix ensemble (re-pinned358+ // after the spur clause reshuffled the stream). The marble must enter the approach corner,352359 // ride the 360° spiral down 2.0 (helix family mod: banked, ridden353360 // fast), come out through its own underpass, and keep rolling.354- const w = new EndlessWorld("grav-scan-23", catalog);361+ const w = new EndlessWorld("helix-scan-11", catalog);355362 const seg = w.get(0);356363 const r0 = seg.plan.reservations.find((r) =>357364 /^helix-(large-)?(left|right)$/.test(r.pieceIds[0])358365 );359- assert(r0 !== undefined, "grav-scan-23 seg 0 lost its full helix");366+ assert(r0 !== undefined, "helix-scan-11 seg 0 lost its full helix");360367 const wi = seg.windowRanges.findIndex((wr) => wr.start === r0.start);361368 let endS = 0;362369 for (let k = 0; k <= wi; k++) endS += seg.path.segments[k].length;363- const r = drive("grav-scan-23", 3, 60, endS + 8);370+ const r = drive("helix-scan-11", 3, 60, endS + 8);364371 assert(!r.fell, "marble fell during the helix dive");365372 assert(366373 r.reachedS >= endS + 7,@@ -394,16 +401,16 @@ Deno.test("scenario wide-run: marble rides funnel → wide channel → funnel",
394401 395402 Deno.test("scenario icy-run: marble slides the low-grip patch and carries on", async () => {396403 module_ = await factory();397- // jump-scan-2 early segments plan an ICY zone (assist authority drops398- // to 0.3 there — the marble must coast across on carried momentum and399- // regain the line after).400- const w = new EndlessWorld("jump-scan-2", catalog);404+ // jump-scan-13 early segments plan an ICY zone (re-pinned after the405+ // spur clause; assist authority drops to 0.3 there — the marble must406+ // coast across on carried momentum and regain the line after).407+ const w = new EndlessWorld("jump-scan-13", catalog);401408 let icyAt: { seg: number; start: number; len: number } | null = null;402409 for (let i = 0; i < 3 && !icyAt; i++) {403410 const m = w.get(i).plan.mods.find((m) => m.kind === "icy");404411 if (m) icyAt = { seg: i, start: m.start, len: m.len };405412 }406- assert(icyAt !== null, "jump-scan-2 early segments lost their icy zone");413+ assert(icyAt !== null, "jump-scan-13 early segments lost their icy zone");407414 const rails: Rail[] = [];408415 for (let i = 0; i < 4; i++) rails.push(railForSegment(w.get(i)));409416 const rail = concatRails(rails);@@ -412,7 +419,7 @@ Deno.test("scenario icy-run: marble slides the low-grip patch and carries on", a
412419 let last = first;413420 while (rail.mods[last + 1]?.icy) last++;414421 const r = drive(415- "jump-scan-2",422+ "jump-scan-13",416423 4,417424 40,418425 rail.s[last] + 10,@@ -498,3 +505,41 @@ Deno.test("scenario gravity-run: tilted gravity surges the marble on the flat",
498505 })`,499506 );500507 });508+509+Deno.test("scenario spur-drop: the branch rider falls through onto its own line", async () => {510+ module_ = await factory();511+ // spur-scan-7 segment 1 hosts a takeable spur (pinned by spur_test).512+ // Ride the ALT rails: the marble takes the branch at the split, rolls513+ // out over the end-hole board, and must fall THROUGH the hole — proven514+ // by a mid-shaft transit inside the branch cell's column, a place the515+ // MAIN hook never visits at that height — landing on the receiver516+ // passing directly below (Δ 1.0 track), then keep progressing. The517+ // respawn net (fell) must never fire.518+ const w = new EndlessWorld("spur-scan-7", catalog);519+ const seg = w.get(1);520+ const spur = seg.plan.spurs[0];521+ assert(spur !== undefined, "spur-scan-7 seg 1 lost its spur");522+ const host = seg.plan.cells[spur.host];523+ const [wx, wz] = DIR_STEP[spur.dir];524+ const holeX = (host.x + wx) * TRACK_SCALE;525+ const holeZ = (host.z + wz) * TRACK_SCALE;526+ const deckFloor = (host.baseY + 0.25) * TRACK_SCALE;527+ const recFloor = deckFloor - TRACK_SCALE;528+ let shaftTransit = false;529+ const goal = w.get(2).startS;530+ const r = drive("spur-scan-7", 4, 120, goal, 0, true, (pos) => {531+ if (532+ Math.hypot(pos[0] - holeX, pos[2] - holeZ) < 0.7 &&533+ pos[1] > recFloor + 0.5 && pos[1] < recFloor + 1.6534+ ) shaftTransit = true;535+ });536+ assert(!r.fell, "branch rider fell out of the world at the spur");537+ assert(538+ shaftTransit,539+ "marble never transited the drop shaft — it did not take the branch",540+ );541+ assert(542+ r.reachedS >= goal,543+ `marble stalled at s=${r.reachedS.toFixed(1)} (goal ${goal.toFixed(1)})`,544+ );545+});@@ -1044,9 +1044,14 @@ Deno.test("island ground vocabulary: wide runs, furniture, solids obey the rules
10441044 );10451045 }10461046 if (p.pieceId === "split") {1047+ // A split junction is either a furnished ledge host or a1048+ // takeable spur host (the M13 spur wired into endless).10471049 const hostCell = [...hostSet].some((h) => {10481050 const c = seg.plan.cells[h];10491051 return `${c.x},${c.z}` === cellKey;1052+ }) || seg.plan.spurs.some((s) => {1053+ const c = seg.plan.cells[s.host];1054+ return `${c.x},${c.z}` === cellKey;10501055 });10511056 assert(10521057 hostCell,@@ -0,0 +1,284 @@
1+/**2+ * Takeable spur drops in endless mode (M13's loop-mode machinery wired3+ * into the void as respawn variety).4+ *5+ * The contract, per committed spur (rule-based, no time dimension):6+ * a 1-cell straight host window whose placement mutated straight → split,7+ * an end-hole diving board on the branch cell, a ONE-WAY alt centerline8+ * out over the hole (flagged spur), and the MAIN line hooking down to9+ * pass back DIRECTLY UNDER the branch cell at Δ exactly 1.0 track units10+ * (the bridge law) — so the guaranteed fall lands ON TRACK BELOW (hole11+ * semantics, USER RULE), and the head keeps walking (the continuation IS12+ * the main line). The collision proxies must leave the hole OPEN (no13+ * floor at deck level over the hole cell) with a catch pan below.14+ */15+import { assert, assertEquals } from "@std/assert";16+import { EndlessWorld } from "../src/gen/endless/world.ts";17+import { TRACK_SCALE } from "../src/gen/endless/scale.ts";18+import { channelProxies, wideScalesOf } from "../src/gen/endless/proxies.ts";19+import { gapSegmentsOf, railForSegment } from "../src/client/physics/rail.ts";20+import { segmentHash } from "../tools/pin_endless.ts";21+import { DIR_STEP, turnRight } from "../src/gen/geom.ts";22+import { segPoint } from "../src/gen/geom.ts";23+import type { DecorCatalog } from "../src/gen/decor.ts";24+25+const catalog: DecorCatalog = {26+ packs: [27+ {28+ slug: "nature-kit",29+ ids: JSON.parse(Deno.readTextFileSync(30+ new URL("../assets/nature-kit.manifest.json", import.meta.url),31+ )).pieces.map((p: { id: string }) => p.id),32+ },33+ ],34+};35+36+/** Scan corpus (same style as the jump/gravity scenario seeds). Pinned37+ * exemplars: alpha (golden seed) spurs at segment 0, spur-scan-7 at38+ * segment 1 — re-scan with a fresh label family if a reshuffle moves39+ * them (tools-free: the corpus loop below prints nothing, just widen). */40+const SEEDS = [41+ "alpha",42+ "dami",43+ "cua-seed-1",44+ ...Array.from({ length: 12 }, (_, i) => `spur-scan-${i}`),45+];46+const SEGS = 12;47+48+Deno.test("spur drop: pinned seeds exhibit a spur early", () => {49+ const alpha = new EndlessWorld("alpha", catalog);50+ assert(51+ alpha.get(0).plan.spurs.length > 0,52+ "seed alpha lost its segment-0 spur — re-scan for a pinned exemplar",53+ );54+ const scan7 = new EndlessWorld("spur-scan-7", catalog);55+ assert(56+ scan7.get(1).plan.spurs.length > 0,57+ "seed spur-scan-7 lost its segment-1 spur — re-scan (physics_test " +58+ "pins the same seed)",59+ );60+});61+62+Deno.test("spur drop: every spur obeys the bridge-law contract", () => {63+ let spurs = 0;64+ for (const seed of SEEDS) {65+ const w = new EndlessWorld(seed, catalog);66+ for (let i = 0; i < SEGS; i++) {67+ const seg = w.get(i);68+ assert(69+ seg.plan.spurs.length <= 1,70+ `${seed} seg ${i}: ${seg.plan.spurs.length} spurs (set-piece, not texture)`,71+ );72+ for (const spur of seg.plan.spurs) {73+ spurs++;74+ const { cells } = seg.plan;75+ const host = cells[spur.host];76+ const rec = cells[spur.receiver];77+ const [wx, wz] = DIR_STEP[spur.dir];78+ const bx = host.x + wx;79+ const bz = host.z + wz;80+81+ // The host is a straight cell; its window is pinned to 1 cell of82+ // family "straight" and carries the spur zone.83+ assert(host.move === "F", `${seed} seg ${i}: spur host not straight`);84+ const wi = seg.windowRanges.findIndex((r) =>85+ r.start === spur.host && r.len === 186+ );87+ assert(wi >= 0, `${seed} seg ${i}: spur host window not 1 cell`);88+ assertEquals(89+ seg.pieceFamilies[wi],90+ "straight",91+ `${seed} seg ${i}: spur host window family`,92+ );93+ assertEquals(94+ seg.windowMods[wi],95+ "spur",96+ `${seed} seg ${i}: spur host window zone`,97+ );98+99+ // The bridge law: the receiver is the branch cell's column at100+ // Δ exactly 1.0 below the deck, and the line continues past it.101+ assertEquals(102+ [rec.x, rec.z],103+ [bx, bz],104+ `${seed} seg ${i}: receiver not under the branch cell`,105+ );106+ assert(107+ Math.abs(rec.baseY - (host.baseY - 1)) < 1e-9,108+ `${seed} seg ${i}: receiver at Δ ${host.baseY - rec.baseY}, not 1.0`,109+ );110+ assert(111+ cells.length > spur.receiver + 1,112+ `${seed} seg ${i}: no runway after the receiver`,113+ );114+ // Main-line continuity through the hook (adjacent cells walk).115+ for (let k = spur.host; k < spur.receiver + 1; k++) {116+ const [dx, dz] = DIR_STEP[cells[k].dirOut];117+ assertEquals(118+ [cells[k].x + dx, cells[k].z + dz],119+ [cells[k + 1].x, cells[k + 1].z],120+ `${seed} seg ${i}: main line breaks at cell ${k}`,121+ );122+ }123+124+ // The host placement mutated into the split junction (mirrored125+ // for a right-hand branch), and the branch cell carries its126+ // end-hole diving board at deck level.127+ const split = seg.placements.find((p) =>128+ p.pieceId === "split" &&129+ Math.round(p.pos[0] / TRACK_SCALE) === host.x &&130+ Math.round(p.pos[2] / TRACK_SCALE) === host.z &&131+ Math.abs(p.pos[1] / TRACK_SCALE - host.baseY) < 1e-6132+ );133+ assert(split, `${seed} seg ${i}: spur host did not mutate to split`);134+ assertEquals(135+ Boolean(split.mirrored),136+ spur.dir === turnRight(host.dirIn),137+ `${seed} seg ${i}: split mirroring wrong for branch side`,138+ );139+ const board = seg.placements.find((p) =>140+ p.pieceId === spur.cap &&141+ Math.round(p.pos[0] / TRACK_SCALE) === bx &&142+ Math.round(p.pos[2] / TRACK_SCALE) === bz &&143+ Math.abs(p.pos[1] / TRACK_SCALE - host.baseY) < 1e-6144+ );145+ assert(board, `${seed} seg ${i}: no end-hole board on the branch`);146+ assertEquals(147+ board.rotation,148+ ((1 - spur.dir + 4) % 4),149+ `${seed} seg ${i}: board rotation must open toward the junction`,150+ );151+152+ // The alt centerline: present on the host window, spur-flagged,153+ // one-way — out over the hole, then the DIVE down the shaft154+ // (the fall is part of the line: the wishdir look target must155+ // lead over the lip), ending at receiver floor level.156+ const ls = seg.path.segments[wi];157+ assert(158+ ls.alt !== undefined && ls.alt.spur === true,159+ `${seed} seg ${i}: spur window lost its alt centerline`,160+ );161+ const floorWorld = (host.baseY + 0.25) * TRACK_SCALE;162+ const dive = ls.alt.curve.find((part) => {163+ const a = segPoint(part, 0);164+ const b = segPoint(part, 1);165+ return Math.hypot(a[0] - b[0], a[2] - b[2]) < 1e-6 &&166+ Math.abs(a[1] - floorWorld) < 1e-6 &&167+ Math.abs(b[1] - (floorWorld - TRACK_SCALE)) < 1e-6 &&168+ Math.hypot(a[0] - bx * TRACK_SCALE, a[2] - bz * TRACK_SCALE) <169+ 0.3;170+ });171+ assert(172+ dive !== undefined,173+ `${seed} seg ${i}: alt centerline has no shaft dive at the hole`,174+ );175+ const tip = segPoint(ls.alt.curve[ls.alt.curve.length - 1], 1);176+ assert(177+ Math.abs(tip[1] - (floorWorld - TRACK_SCALE)) < 1e-6 &&178+ Math.hypot(tip[0] - bx * TRACK_SCALE, tip[2] - bz * TRACK_SCALE) <179+ TRACK_SCALE,180+ `${seed} seg ${i}: alt tip not on the receiver below`,181+ );182+183+ // The descents realize as the reserved slant-b pair.184+ for (const at of [spur.host + 1, spur.host + 4]) {185+ assert(186+ seg.plan.reservations.some((r) =>187+ r.start === at && r.len === 1 && r.reversed &&188+ r.pieceIds.includes("slant-b")189+ ),190+ `${seed} seg ${i}: hook descent @${at} not reserved`,191+ );192+ }193+194+ // PROXIES — the fall must be real physics: no floor at deck195+ // level over the hole (the branch cell center), a diving-board196+ // floor before it, and a catch pan a hair above the receiver.197+ const proxies = channelProxies(198+ seg.path,199+ true,200+ gapSegmentsOf(seg),201+ wideScalesOf(seg),202+ );203+ const holeX = bx * TRACK_SCALE;204+ const holeZ = bz * TRACK_SCALE;205+ const recFloor = floorWorld - TRACK_SCALE;206+ const coveredBySlab = (207+ x: number,208+ y: number,209+ z: number,210+ yTol: number,211+ ): boolean =>212+ proxies.some((p) => {213+ if (p.kind !== "slab" || Math.abs(p.pitch) > 0.05) return false;214+ const top = p.center[1] + p.half[1];215+ if (Math.abs(top - y) > yTol) return false;216+ // Rotate into the slab's yaw frame.217+ const dx = x - p.center[0];218+ const dz = z - p.center[2];219+ const lx = dx * Math.cos(p.yaw) - dz * Math.sin(p.yaw);220+ const lz = dx * Math.sin(p.yaw) + dz * Math.cos(p.yaw);221+ return Math.abs(lx) <= p.half[0] && Math.abs(lz) <= p.half[2];222+ });223+ // Hole open: no flat slab top near deck floor level anywhere in224+ // the hole's opening, and no ribbon triangle either.225+ assert(226+ !coveredBySlab(holeX, floorWorld, holeZ, 0.3),227+ `${seed} seg ${i}: a slab floors over the hole`,228+ );229+ const meshOverHole = proxies.some((p) => {230+ if (p.kind !== "mesh") return false;231+ for (let v = 0; v < p.vertices.length; v += 3) {232+ if (233+ Math.abs(p.vertices[v + 1] - floorWorld) < 0.3 &&234+ Math.hypot(p.vertices[v] - holeX, p.vertices[v + 2] - holeZ) <235+ 0.45236+ ) return true;237+ }238+ return false;239+ });240+ assert(241+ !meshOverHole,242+ `${seed} seg ${i}: a ribbon sheet floors over the hole`,243+ );244+ // Diving board present between junction and hole.245+ const midX = (host.x * TRACK_SCALE + holeX) / 2;246+ const midZ = (host.z * TRACK_SCALE + holeZ) / 2;247+ assert(248+ coveredBySlab(midX, floorWorld + 0.02, midZ, 0.1),249+ `${seed} seg ${i}: no diving-board floor before the hole`,250+ );251+ // Catch pan just above the receiver floor, under the hole.252+ assert(253+ coveredBySlab(holeX, recFloor + 0.03, holeZ, 0.1),254+ `${seed} seg ${i}: no catch pan above the receiver`,255+ );256+ // The receiver rail passes directly below: riding the ALT rail,257+ // some later station sits under the hole at the receiver floor.258+ const rail = railForSegment(seg, true);259+ const under = rail.pts.some((p) =>260+ Math.abs(p[1] - recFloor) < 0.15 &&261+ Math.hypot(p[0] - holeX, p[2] - holeZ) < 0.6262+ );263+ assert(264+ under,265+ `${seed} seg ${i}: no receiver rail station under the hole`,266+ );267+ }268+ }269+ }270+ assert(spurs >= 8, `too few spurs across the corpus (${spurs})`);271+});272+273+Deno.test("spur drop: deterministic per seed", () => {274+ const a = new EndlessWorld("alpha", catalog);275+ const b = new EndlessWorld("alpha", catalog);276+ for (let i = 0; i < 3; i++) {277+ assertEquals(segmentHash(a.get(i)), segmentHash(b.get(i)), `segment ${i}`);278+ }279+ assertEquals(280+ a.get(0).plan.spurs,281+ b.get(0).plan.spurs,282+ "spur plans diverged",283+ );284+});@@ -63,6 +63,14 @@ function canonPlacement(p: Placement): string {
6363 }`6464 : "",6565 p.boost !== undefined ? Math.round(p.boost * 16) : "",66+ p.wind67+ ? [68+ p.wind.strength,69+ p.wind.length,70+ p.wind.radius,71+ ...(p.wind.offset ?? []),72+ ].map((v) => (v === undefined ? "" : Math.round(v * 16))).join(";")73+ : "",6674 p.chapter ?? "",6775 p.zone ?? "",6876 p.bumper ? 1 : 0,