clone

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

GUST WALLS (M19a): the wind gets a clock — pulsed blockades, vortex eddies, loop backport

Dami committed 1c0d8ec
Wind & hazard vocabulary, all appended draws (zero rng-stream reshuffle):
- Placement.wind gains pulse {period,duty,phase} + swirl; canon hashes both.
- fanField: pulseGain (cosine-eased duty windows on SIM time), swirl volumes
  (tangential force around a vertical column), FanField.setTime.
- furniture: gust walls upgrade cross fans in place (strength 14, duty .45,
  period 2.4-3.6s); vortex = table fan at the lip, eddy over the channel;
  coordinateGusts post-pass (rng-free) phase-locks banks into travelling
  waves and sets same-host L/R pairs anti-phase.
- endless Session: windT sim clock drives the field (advanceTime-pure).
- Loop backport: FanField over loop fan placements; kinematic marble takes
  tangential wind into speed and lateral wind into a clamped channel sway.
- Tests: pulse/swirl/coordination laws, corpus emergence (16x10), gust
  scenario rules + pinned bank (wide-scan-13), windy drive() soak through
  the bank with bit-identical replay. 181 green, check clean.
- Endless goldens re-pinned (content adds); genVersion unchanged.
commit
1c0d8ec2b0e44a5bf85de6f00ef329b835a5f442
parent
38d562aafc37964742db8c997283d7e10eff4e7d
author
Dami <dami.etoile@gmail.com>
12 files changed, +600 -46
Mprogress.md14++-
Msrc/client/endless.ts7+
Msrc/client/fanField.ts69+++++++++++-
Msrc/client/main.ts13++-
Msrc/client/marble.ts49++++++++
Msrc/gen/endless/furniture.ts170++++++++++++++++++++++++++--
Msrc/shared/types.ts10++
Mtests/fanfield_test.ts179++++++++++++++++++++++++++++--
Mtests/golden_endless.json6+-
Mtests/physics_test.ts57+++++++++-
Mtests/scenario_test.ts68+++++++++++-
Mtools/pin_endless.ts4+
M progress.md +9 -5
@@ -65,9 +65,12 @@ cross composite, quantitative metrics CI).
6565 until something can hit decor (island-top pieces are off the channel).6666 - **Bumper density tuning**: after watching feedback (pins currently6767 only on wide island decks).68-- **More fan set-pieces**: cross-winds, updraft columns and boost69- corridors shipped; blockading gust WALLS (a fan bank the pack must70- time) remain unexplored vocabulary.68+- **Fan vocabulary (M19a residual)**: gust walls (pulsed blockades,69+ phase-locked banks + anti-phase pairs), vortex eddies and the loop70+ wind backport shipped. Left open: gust strength/duty tuning after71+ watching the pack time real banks, and downdrafts (RULED OUT over72+ dips — that is where wishdir pilots park; revisit only with a73+ different host).7174 7275 ## Audio (PARKED — user rule)7376
@@ -85,8 +88,9 @@ cross composite, quantitative metrics CI).
8588 jump, helix-scan-6 propeller, grav-scan-9 diamond, wide-scan-7 wide,8689 grav-scan-6 helix, wide-scan-17 icy, wide-scan-21 gravity,8790 jump-scan-4 + jump-scan-12 spurs, jump-scan-25 + grav-scan-3388- reorients, grav-scan-17/38 + marbles braids);91+ reorients, grav-scan-17/38 + marbles braids, wide-scan-13 gust bank);8992 regenerate endless goldens with `deno run --allow-read --allow-write90- tools/pin_endless.ts`.93+ tools/pin_endless.ts`. Canon note: golden placements hash wind94+ pulse/swirl since M19a.9195 - `deno task check` is fully clean now — keep it that way; `deno task9296 test` remains the gate.
M src/client/endless.ts +7 -0
M src/client/fanField.ts +63 -6
@@ -38,11 +38,40 @@ export interface FanVolume {
3838 radius: number;3939 /** Acceleration at the fan face, world u/s². */4040 strength: number;41+ /** Gust wall (M19a): blow only during the duty window each period. */42+ pulse?: { period: number; duty: number; phase: number };43+ /** Vortex (M19a): force tangential around the axis, not along it. */44+ swirl?: boolean;4145 }4246 4347 /** Radial fraction at full strength; feather over the outer band. */4448 export const FULL_CORE = 0.6;4549 50+/** Edge-ramp fraction of the on-window: force eases in/out over this51+ * slice of the duty window instead of stepping (a step force reads as a52+ * collision and double-integrates poorly at decision cadence). */53+export const PULSE_RAMP = 0.2;54+55+/** Pulse gain at sim time `t` — 0 outside the duty window, 1 in the56+ * middle, cosine-eased over PULSE_RAMP·onTime at both edges. Pure in57+ * (pulse, t): replays and headless runs agree by construction. */58+export function pulseGain(59+ pulse: { period: number; duty: number; phase: number } | undefined,60+ t: number,61+): number {62+ if (!pulse) return 1;63+ const period = pulse.period;64+ if (!(period > 0)) return 1;65+ const on = Math.min(1, Math.max(0, pulse.duty)) * period;66+ if (on <= 0) return 0;67+ const tau = (((t + pulse.phase) % period) + period) % period;68+ if (tau >= on) return 0;69+ const ramp = on * PULSE_RAMP;70+ const edge = Math.min(tau, on - tau);71+ if (edge >= ramp) return 1;72+ return 0.5 - 0.5 * Math.cos(Math.PI * (edge / ramp));73+}74+4675 // Family defaults (world units, tuned for TRACK_SCALE=2 endless pieces).4776 // Cross-wind (standing fan): 6.5 u/s² at the face over a 4-unit throw —4877 // the channel centerline sits ~2 units out, so mid-channel marbles feel
@@ -104,17 +133,22 @@ export function fanVolumeFor(p: Placement): FanVolume | null {
104133 }105134 const w = p.wind;106135 const off = w?.offset;107- return {136+ const v: FanVolume = {108137 origin: [109138 p.pos[0] + (off?.[0] ?? 0),110139 p.pos[1] + hub + (off?.[1] ?? 0),111140 p.pos[2] + (off?.[2] ?? 0),112141 ],113- dir: [dx, dy, dz],142+ // A swirl column stands on its vertical axis regardless of the fan's143+ // yaw — the housing points at the channel, the eddy spins beside it.144+ dir: w?.swirl ? [0, 1, 0] : [dx, dy, dz],114145 length: w?.length ?? length,115146 radius: w?.radius ?? radius,116147 strength: w?.strength ?? strength,117148 };149+ if (w?.pulse) v.pulse = w.pulse;150+ if (w?.swirl) v.swirl = true;151+ return v;118152 }119153 120154 /** All wind capsules of a segment's placements (build-time, cold path). */
@@ -130,6 +164,13 @@ export function fanVolumesOf(placements: readonly Placement[]): FanVolume[] {
130164 /** Per-segment wind-volume registry. */131165 export class FanField {132166 private readonly bins = new Map<number, readonly FanVolume[]>();167+ /** Accumulated SIM time (seconds) — gates pulsed volumes. The session168+ * advances it from the same dt stream advanceTime drives. */169+ private t = 0;170+171+ setTime(t: number): void {172+ this.t = t;173+ }133174 134175 addSegment(index: number, volumes: readonly FanVolume[]): void {135176 if (volumes.length > 0) this.bins.set(index, volumes);
@@ -184,6 +225,8 @@ export class FanField {
184225 ): boolean {185226 let hit = false;186227 for (const v of vols) {228+ const gain = v.pulse ? pulseGain(v.pulse, this.t) : 1;229+ if (gain <= 0) continue;187230 const rx = pos[0] - v.origin[0];188231 const ry = pos[1] - v.origin[1];189232 const rz = pos[2] - v.origin[2];
@@ -194,10 +237,24 @@ export class FanField {
194237 const rad = Math.sqrt(Math.max(0, rad2));195238 const core = v.radius * FULL_CORE;196239 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];240+ const f = mass * v.strength * (1 - axial / v.length) * feather * gain;241+ if (v.swirl) {242+ // Vortex: push TANGENTIALLY around the axis — dir × radial, so a243+ // marble entering the column is deflected sideways, arcing around244+ // the center instead of being blown along the axis. At the exact245+ // center there is no tangent (rad ~ 0) and no force.246+ if (rad < 1e-6) continue;247+ const px = (rx - axial * v.dir[0]) / rad;248+ const py = (ry - axial * v.dir[1]) / rad;249+ const pz = (rz - axial * v.dir[2]) / rad;250+ out[0] += f * (v.dir[1] * pz - v.dir[2] * py);251+ out[1] += f * (v.dir[2] * px - v.dir[0] * pz);252+ out[2] += f * (v.dir[0] * py - v.dir[1] * px);253+ } else {254+ out[0] += f * v.dir[0];255+ out[1] += f * v.dir[1];256+ out[2] += f * v.dir[2];257+ }201258 hit = true;202259 }203260 return hit;
M src/client/main.ts +12 -1
@@ -13,10 +13,11 @@ import { initSeed, newSeed } from "./seed.ts";
1313 import { buildTrack, type BuiltTrack } from "./track.ts";1414 import { buildCenterline, type Centerline } from "./centerline.ts";1515 import { createMarble, type Marble } from "./marble.ts";16+import { FanField, fanVolumesOf } from "./fanField.ts";1617 import { type CamMode, createCameras } from "./camera.ts";1718 import { generateTrack } from "../gen/generateTrack.ts";1819 import type { DecorCatalog } from "../gen/decor.ts";19-import type { Track } from "../shared/types.ts";20+import type { Placement, Track } from "../shared/types.ts";2021 import { genVersion } from "../gen/version.ts";2122 import { installCua } from "./cua.ts";2223 import { installInspect } from "./inspect.ts";
@@ -277,6 +278,13 @@ function boot(assets: Assets): void {
277278 fail(err);278279 return;279280 }281+ // M19a: derive the loop's wind capsules once per track (family282+ // defaults only — loop fans carry no wind hints, no pulses).283+ const loopWindField = (placements: readonly Placement[]): FanField => {284+ const field = new FanField();285+ field.addSegment(0, fanVolumesOf(placements));286+ return field;287+ };280288 281289 lastTrack = track;282290 lastSeed = seed;
@@ -297,6 +305,9 @@ function boot(assets: Assets): void {
297305 landPos: f.landPos as [number, number, number],298306 ...(f.guaranteed ? { guaranteed: true } : {}),299307 })),308+ // M19a loop backport: the same wind-field law endless uses, derived309+ // from the loop's own fan placements — zero generation change.310+ loopWindField(track.placements),300311 );301312 ctx.scene.add(marble.obj);302313 trail.setWidth(marble.radius * 0.6);
M src/client/marble.ts +49 -0
@@ -8,6 +8,7 @@
88 */99 import * as THREE from "three";1010 import type { Assets } from "./assets.ts";11+import type { FanField } from "./fanField.ts";1112 import { addMarbleCore } from "./marbleGlass.ts";1213 1314 const MARBLE_PIECE = "marble-center-high";
@@ -41,6 +42,12 @@ export function createMarble(
4142 /** Takeable spur drains always drop (end-hole law). */4243 guaranteed?: boolean;4344 }[] = [],45+ /** M19a loop backport: the endless wind-field law over the loop's own46+ * fans. The kinematic marble is curve-locked, so wind resolves into47+ * its two honest effects: the tangential component works the speed,48+ * the lateral component sways the marble inside the channel (a49+ * spring-damped visual offset, clamped well inside the walls). */50+ windField?: FanField,4451 ): Marble {4552 const obj = new THREE.Group();4653 obj.name = "marble";
@@ -141,6 +148,16 @@ export function createMarble(
141148 const tangent = new THREE.Vector3();142149 const axis = new THREE.Vector3();143150 const spin = new THREE.Quaternion();151+ const lateral = new THREE.Vector3();152+153+ // Wind sway state: lateral offset inside the channel, spring-damped.154+ // ±0.15 keeps the (0.2-radius) marble clear of the 0.5-half-width walls.155+ const SWAY_MAX = 0.15;156+ const SWAY_K = 18; // spring toward the centerline157+ const SWAY_D = 6; // damping158+ let sway = 0;159+ let swayV = 0;160+ const windAccel: [number, number, number] = [0, 0, 0];144161 145162 const marble: Marble = {146163 obj,
@@ -180,6 +197,32 @@ export function createMarble(
180197 const dz = obj.position.z - b.pos[2];181198 if (dx * dx + dz * dz < 1.2 * 1.2) marble.speed += b.strength * dt;182199 }200+ // Wind (M19a): tangential component works the speed; lateral201+ // component drives the in-channel sway spring.202+ let latAccel = 0;203+ if (windField) {204+ windAccel[0] = 0;205+ windAccel[1] = 0;206+ windAccel[2] = 0;207+ if (208+ windField.forceAt(209+ [obj.position.x, obj.position.y, obj.position.z],210+ 1,211+ windAccel,212+ )213+ ) {214+ marble.speed += (windAccel[0] * tangent.x +215+ windAccel[1] * tangent.y +216+ windAccel[2] * tangent.z) * dt;217+ lateral.copy(up).cross(tangent);218+ if (lateral.lengthSq() > 1e-8) {219+ lateral.normalize();220+ latAccel = windAccel[0] * lateral.x + windAccel[2] * lateral.z;221+ }222+ }223+ }224+ swayV += (latAccel - SWAY_K * sway - SWAY_D * swayV) * dt;225+ sway = Math.max(-SWAY_MAX, Math.min(SWAY_MAX, sway + swayV * dt));183226 marble.speed = Math.min(MAX_SPEED, Math.max(MIN_SPEED, marble.speed));184227 185228 const prevS = marble.s;
@@ -208,6 +251,12 @@ export function createMarble(
208251 209252 active().getPointAt(marble.s / total, point);210253 obj.position.copy(point);254+ if (sway !== 0) {255+ lateral.copy(up).cross(tangent);256+ if (lateral.lengthSq() > 1e-8) {257+ obj.position.addScaledVector(lateral.normalize(), sway);258+ }259+ }211260 212261 // Roll: spin about the horizontal axis perpendicular to travel.213262 axis.copy(up).cross(tangent);
M src/gen/endless/furniture.ts +156 -14
@@ -46,6 +46,27 @@ const UPDRAFT_FAN_CHANCE = 0.2;
4646 * fan still rounds to its ledge cell (the ground-vocabulary test's own4747 * "fans exist only as ledge furniture" rule). */4848 const LIP_OFFSET = 0.45;49+50+// M19a wind vocabulary: two more ledge-fan behaviors, drawn from values51+// APPENDED after the M16 draws — every stream that existed before this52+// milestone still draws byte-identically (per-cell child streams; nothing53+// drew after `variant`).54+/** Gust wall: a legacy cross fan upgraded to a pulsed blockade — full55+ * blocked-phase strength shoves the line against the far wall, the56+ * off-phase releases it. The pack must time the window. */57+const GUST_CHANCE = 0.4;58+/** Vortex: table fan at the lip whose swirl column parks over the59+ * channel — marbles S-wiggle around the eddy center. */60+const VORTEX_CHANCE = 0.15;61+const GUST_STRENGTH = 14;62+const GUST_LENGTH = 5;63+const GUST_RADIUS = 1.1;64+const GUST_DUTY = 0.45;65+const GUST_PERIOD_MIN = 2.4;66+const GUST_PERIOD_SPAN = 1.2;67+const VORTEX_STRENGTH = 7;68+const VORTEX_RADIUS = 1.2;69+const VORTEX_HEIGHT = 1.6;4970 /** Dir index → unit step (0=+x, 1=+z, 2=−x, 3=−z). */5071 const DIR_STEP: readonly (readonly [number, number])[] = [5172 [1, 0],
@@ -60,6 +81,66 @@ export interface FurnishedPiece {
6081 host: number;6182 }6283 84+/** One gust wall's mutable pulse, for the coordination post-pass. */85+export interface GustEntry {86+ wind: { pulse: { period: number; duty: number; phase: number } };87+ host: number;88+ side: string;89+ isle: number;90+}91+92+/** Phase coordination over drawn gust walls — rng-FREE (a pure rewrite93+ * of pulse phase/period; which ledges gust was already drawn):94+ * - BANK: ≥2 same-side gusts on one island whose hosts are ≤2 cells95+ * apart phase-lock into a travelling wave (phase_i = i·period/N along96+ * travel) sharing the first member's period.97+ * - OPPOSED PAIR: L and R gusts on the SAME host share a period and go98+ * anti-phase (offset period/2) — an oscillating slalom gate.99+ * Pair coupling runs last so a paired bank member still opposes its100+ * across-channel partner. */101+export function coordinateGusts(gusts: readonly GustEntry[]): void {102+ const byIsleSide = new Map<string, GustEntry[]>();103+ for (const g of gusts) {104+ const key = `${g.isle}/${g.side}`;105+ const list = byIsleSide.get(key);106+ if (list) list.push(g);107+ else byIsleSide.set(key, [g]);108+ }109+ for (const list of byIsleSide.values()) {110+ list.sort((a, b) => a.host - b.host);111+ let run: GustEntry[] = [];112+ const flush = () => {113+ if (run.length >= 2) {114+ const period = run[0].wind.pulse.period;115+ for (let i = 0; i < run.length; i++) {116+ run[i].wind.pulse.period = period;117+ run[i].wind.pulse.phase = (i * period) / run.length;118+ }119+ }120+ run = [];121+ };122+ for (const g of list) {123+ if (run.length > 0 && g.host - run[run.length - 1].host > 2) flush();124+ run.push(g);125+ }126+ flush();127+ }128+ // Opposed pairs: same island, same host, both sides.129+ const byHost = new Map<string, GustEntry[]>();130+ for (const g of gusts) {131+ const key = `${g.isle}/${g.host}`;132+ const list = byHost.get(key);133+ if (list) list.push(g);134+ else byHost.set(key, [g]);135+ }136+ for (const list of byHost.values()) {137+ if (list.length < 2) continue;138+ const [a, b] = list;139+ b.wind.pulse.period = a.wind.pulse.period;140+ b.wind.pulse.phase = a.wind.pulse.phase + a.wind.pulse.period / 2;141+ }142+}143+63144 /** Bouncer pin chance per wide-deck cell (M13). */64145 const BUMPER_CHANCE = 0.6;65146 /** Lateral pin offset from the wide centerline, track units (the wide
@@ -76,6 +157,7 @@ export function planIslandFurniture(
76157 assembled: Placement[],77158 ): FurnishedPiece[] {78159 const out: FurnishedPiece[] = [];160+ const gusts: GustEntry[] = [];79161 const windowAt = (idx: number): RealizedWindow | undefined =>80162 windows.find((w) => idx >= w.start && idx < w.start + w.len);81163
@@ -227,23 +309,83 @@ export function planIslandFurniture(
227309 host: sc.host,228310 });229311 } else {230- // CROSS-WIND (legacy): blades face the channel; the derived231- // capsule crosses it — the client fan field turns this into a232- // lateral gust with no field needed here.233- out.push({234- placement: {235- pieceId: crossPiece,236- pos: [cx, host.baseY, cz],237- rotation: ((1 - facing + 4) % 4) as Dir,238- role: "track",239- spin: { axis: [0, 0, 1], radPerSec },240- boost: FAN_BOOST,241- },242- host: sc.host,243- });312+ // M19a draws — appended after the legacy prefix (pick, float,313+ // variant), so they exist only in this branch and this cell's314+ // child stream; nothing before them moved.315+ const extra = r.float();316+ if (extra < GUST_CHANCE) {317+ // GUST WALL: the cross fan keeps its mesh, pose and spin —318+ // the upgrade is a pulsed wind field spanning the channel.319+ // Coordination (banks / opposed pairs) is a phase-only320+ // post-pass over these entries (coordinateGusts).321+ const period = GUST_PERIOD_MIN + r.float() * GUST_PERIOD_SPAN;322+ const phase = r.float() * period;323+ const wind = {324+ strength: GUST_STRENGTH,325+ length: GUST_LENGTH,326+ radius: GUST_RADIUS,327+ offset: [tx, -0.3, tz] as [number, number, number],328+ pulse: { period, duty: GUST_DUTY, phase },329+ };330+ out.push({331+ placement: {332+ pieceId: crossPiece,333+ pos: [cx, host.baseY, cz],334+ rotation: ((1 - facing + 4) % 4) as Dir,335+ role: "track",336+ spin: { axis: [0, 0, 1], radPerSec },337+ boost: FAN_BOOST,338+ wind,339+ },340+ host: sc.host,341+ });342+ gusts.push({ wind, host: sc.host, side: sc.side, isle: crossing.id });343+ } else if (extra < GUST_CHANCE + VORTEX_CHANCE) {344+ // VORTEX: table fan at the lip (ledge rule holds — the345+ // housing rounds to its cell like the updraft); the swirl346+ // column parks over the channel and marbles arc around it.347+ out.push({348+ placement: {349+ pieceId: TABLE_FANS[Math.floor(r.float() * TABLE_FANS.length)],350+ pos: [351+ cx + tx * LIP_OFFSET,352+ host.baseY,353+ cz + tz * LIP_OFFSET,354+ ],355+ rotation: ((1 - facing + 4) % 4) as Dir,356+ role: "track",357+ spin: { axis: [0, 0, 1], radPerSec },358+ boost: FAN_BOOST,359+ wind: {360+ strength: VORTEX_STRENGTH,361+ length: VORTEX_HEIGHT,362+ radius: VORTEX_RADIUS,363+ offset: [tx * 1.1, -0.5, tz * 1.1],364+ swirl: true,365+ },366+ },367+ host: sc.host,368+ });369+ } else {370+ // CROSS-WIND (legacy): blades face the channel; the derived371+ // capsule crosses it — the client fan field turns this into a372+ // lateral gust with no field needed here.373+ out.push({374+ placement: {375+ pieceId: crossPiece,376+ pos: [cx, host.baseY, cz],377+ rotation: ((1 - facing + 4) % 4) as Dir,378+ role: "track",379+ spin: { axis: [0, 0, 1], radPerSec },380+ boost: FAN_BOOST,381+ },382+ host: sc.host,383+ });384+ }244385 }245386 }246387 }247388 }389+ coordinateGusts(gusts);248390 return out;249391 }
M src/shared/types.ts +10 -0
M tests/fanfield_test.ts +166 -13
@@ -15,10 +15,16 @@ import {
1515 fanVolumeFor,1616 fanVolumesOf,1717 FULL_CORE,18+ PULSE_RAMP,19+ pulseGain,1820 STANDING_HUB,1921 TABLE_HUB,2022 UPDRAFT_STRENGTH,2123 } from "../src/client/fanField.ts";24+import {25+ coordinateGusts,26+ type GustEntry,27+} from "../src/gen/endless/furniture.ts";2228 import { EndlessWorld } from "../src/gen/endless/world.ts";2329 import { TRACK_SCALE } from "../src/gen/endless/scale.ts";2430 import type { DecorCatalog } from "../src/gen/decor.ts";
@@ -212,9 +218,25 @@ Deno.test("endless furniture: wind fan variants emerge and stay on their ledges"
212218 let cross = 0;213219 let boost = 0;214220 let updraft = 0;215- for (let n = 0; n < 10; n++) {221+ let gust = 0;222+ let vortex = 0;223+ const onLedge = (p: Placement, label: string) => {224+ // Lip fans are offset < 0.5 track units — still round to a whole225+ // cell (the ledge), the ground-vocabulary rule.226+ const fx = p.pos[0] / TRACK_SCALE;227+ const fz = p.pos[2] / TRACK_SCALE;228+ assert(229+ Math.abs(fx - Math.round(fx)) < 0.5 - 1e-9 &&230+ Math.abs(fz - Math.round(fz)) < 0.5 - 1e-9,231+ `${label} fan strays off its cell (${fx},${fz})`,232+ );233+ };234+ // 16×10 is the smallest corpus where all five variants emerge (the235+ // fan pipeline sits behind stub coins and mod-zone exclusions — fans236+ // are punctuation, not weather).237+ for (let n = 0; n < 16; n++) {216238 const w = new EndlessWorld(`wind-${n}`, catalog);217- for (let i = 0; i < 8; i++) {239+ for (let i = 0; i < 10; i++) {218240 const seg = w.get(i);219241 for (const p of seg.placements) {220242 if (!p.pieceId.startsWith("fan-")) continue;
@@ -223,8 +245,28 @@ Deno.test("endless furniture: wind fan variants emerge and stay on their ledges"
223245 cross++;224246 continue;225247 }226- // Wind hints only ride on the two new varieties.227- if (standing) {248+ if (p.wind.pulse) {249+ // Gust wall (M19a): pulsed cross fan — full period/duty sanity250+ // and the coordination pass keeps duty untouched.251+ gust++;252+ assert(standing || !standing); // any mesh; the pulse is the law253+ assertEquals(p.wind.strength, 14);254+ assertEquals(p.wind.pulse.duty, 0.45);255+ assert(256+ p.wind.pulse.period >= 2.4 - 1e-9 &&257+ p.wind.pulse.period <= 3.6 + 1e-9,258+ `gust period out of band (${p.wind.pulse.period})`,259+ );260+ assert(p.wind.pulse.phase >= -1e-9, "negative gust phase");261+ onLedge(p, "gust");262+ } else if (p.wind.swirl) {263+ // Vortex (M19a): table fan at the lip, swirl column over the264+ // channel.265+ vortex++;266+ assert(!standing, "vortex must ride a table fan");267+ assertEquals(p.wind.strength, 7);268+ onLedge(p, "vortex");269+ } else if (standing) {228270 boost++;229271 // Boost corridor: capsule parked over the channel, blowing230272 // along travel (horizontal — never up).
@@ -233,15 +275,7 @@ Deno.test("endless furniture: wind fan variants emerge and stay on their ledges"
233275 } else {234276 updraft++;235277 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- );278+ onLedge(p, "updraft");245279 }246280 }247281 }
@@ -249,6 +283,125 @@ Deno.test("endless furniture: wind fan variants emerge and stay on their ledges"
249283 assert(cross > 0, "legacy cross-wind fans vanished from the corpus");250284 assert(boost > 0, "no boost-corridor fan emerged across the corpus");251285 assert(updraft > 0, "no lip-updraft fan emerged across the corpus");286+ assert(gust > 0, "no gust wall emerged across the corpus");287+ assert(vortex > 0, "no vortex emerged across the corpus");288+});289+290+// ---------------------------------------------------------------------------291+// M19a: pulsed gusts, vortex swirl, and gust coordination.292+// ---------------------------------------------------------------------------293+294+Deno.test("pulse gain: periodic, zero outside duty, full mid-window", () => {295+ const pulse = { period: 3, duty: 0.45, phase: 0 };296+ const on = 3 * 0.45;297+ // Mid-window full strength.298+ assertEquals(pulseGain(pulse, on / 2), 1);299+ // Off-phase silent (just past the window and just before wrap).300+ assertEquals(pulseGain(pulse, on + 0.01), 0);301+ assertEquals(pulseGain(pulse, 3 - 0.01), 0);302+ // Periodicity: same gain one and two periods later, negative t too.303+ for (const t of [0.2, on / 2, on - 0.05, 2.9]) {304+ assertAlmostEquals(pulseGain(pulse, t), pulseGain(pulse, t + 3), 1e-12);305+ assertAlmostEquals(pulseGain(pulse, t), pulseGain(pulse, t - 6), 1e-12);306+ }307+ // Edges ramp: strictly between 0 and 1 inside the ramp band.308+ const ramp = on * PULSE_RAMP;309+ const g = pulseGain(pulse, ramp / 2);310+ assert(g > 0 && g < 1, `edge should ramp, got ${g}`);311+ // No pulse = always on.312+ assertEquals(pulseGain(undefined, 123.4), 1);313+});314+315+Deno.test("pulsed volume: force gates on the field's sim time", () => {316+ const gust: FanVolume = {317+ ...AXIS_X,318+ pulse: { period: 3, duty: 0.45, phase: 0 },319+ };320+ const f = fieldWith([gust]);321+ const out: [number, number, number] = [0, 0, 0];322+ f.setTime(3 * 0.45 / 2); // mid-duty323+ assert(f.forceNear(0, [1, 0, 0], 1, out));324+ const onForce = out[0];325+ assert(onForce > 0);326+ out[0] = 0;327+ f.setTime(2.9); // off-phase328+ assert(!f.forceNear(0, [1, 0, 0], 1, out));329+ assertEquals(out[0], 0);330+ // Determinism: two fields, same time sequence, same output.331+ const g2 = fieldWith([{ ...gust }]);332+ g2.setTime(3 * 0.45 / 2);333+ const out2: [number, number, number] = [0, 0, 0];334+ g2.forceNear(0, [1, 0, 0], 1, out2);335+ assertEquals(out2[0], onForce);336+});337+338+Deno.test("anti-phase pair: never both blowing at full", () => {339+ const period = 3;340+ const a = { period, duty: 0.45, phase: 0 };341+ const b = { period, duty: 0.45, phase: period / 2 };342+ // Duty 0.45 < 0.5 ⇒ windows are disjoint: whenever one is at full343+ // gain the other is silent.344+ for (let t = 0; t < 2 * period; t += 0.05) {345+ const ga = pulseGain(a, t);346+ const gb = pulseGain(b, t);347+ if (ga === 1) assertEquals(gb, 0);348+ if (gb === 1) assertEquals(ga, 0);349+ }350+});351+352+Deno.test("swirl volume: force is tangential (⟂ radial and ⟂ axis)", () => {353+ const vortex: FanVolume = {354+ origin: [0, 0, 0],355+ dir: [0, 1, 0],356+ length: 2,357+ radius: 1.5,358+ strength: 7,359+ swirl: true,360+ };361+ const f = fieldWith([vortex]);362+ for (363+ const pos of [[0.8, 0.5, 0], [0, 1, 0.6], [-0.4, 0.2, 0.4]] as const364+ ) {365+ const out: [number, number, number] = [0, 0, 0];366+ assert(f.forceNear(0, pos, 1, out));367+ // ⟂ axis: no vertical component.368+ assertAlmostEquals(out[1], 0, 1e-12);369+ // ⟂ radial: dot with the horizontal radial vector vanishes.370+ const dot = out[0] * pos[0] + out[2] * pos[2];371+ assertAlmostEquals(dot, 0, 1e-9);372+ assert(Math.hypot(out[0], out[2]) > 0);373+ }374+ // Center singularity: no force on the exact axis.375+ const out: [number, number, number] = [0, 0, 0];376+ assert(!f.forceNear(0, [0, 0.5, 0], 1, out));377+});378+379+Deno.test("gust coordination: banks phase-lock, opposed pairs anti-phase", () => {380+ const mk = (381+ host: number,382+ side: string,383+ period: number,384+ phase: number,385+ isle = 1,386+ ): GustEntry => ({387+ wind: { pulse: { period, duty: 0.45, phase } },388+ host,389+ side,390+ isle,391+ });392+ // Bank of three on one side: shared period, travelling phases.393+ const bank = [mk(4, "L", 3.0, 1.7), mk(5, "L", 2.6, 0.2), mk(6, "L", 3.4, 2.9)];394+ // A same-side gust too far downstream stays independent.395+ const lone = mk(11, "L", 2.8, 1.1);396+ // Opposed pair on host 4: R locks to L's period, anti-phase.397+ const paired = mk(4, "R", 3.3, 0.9);398+ coordinateGusts([...bank, lone, paired]);399+ for (const g of bank) assertEquals(g.wind.pulse.period, 3.0);400+ assertEquals(bank.map((g) => g.wind.pulse.phase), [0, 1, 2]);401+ assertEquals(lone.wind.pulse.period, 2.8);402+ assertEquals(lone.wind.pulse.phase, 1.1);403+ assertEquals(paired.wind.pulse.period, 3.0);404+ assertAlmostEquals(paired.wind.pulse.phase, 0 + 1.5, 1e-12);252405 });253406 254407 Deno.test("endless furniture: wind draws are deterministic per seed", () => {
M tests/golden_endless.json +3 -3
M tests/physics_test.ts +54 -3
@@ -52,6 +52,8 @@ import {
5252 import type { DecorCatalog } from "../src/gen/decor.ts";5353 import { DIR_STEP } from "../src/gen/geom.ts";5454 import { TRACK_SCALE } from "../src/gen/endless/scale.ts";55+import { FanField, fanVolumesOf } from "../src/client/fanField.ts";56+import { segmentFrameAt } from "../src/shared/frame.ts";5557 5658 const catalog: DecorCatalog = {5759 packs: [
@@ -107,10 +109,14 @@ function driveWith(
107109 /** Per-step trajectory observer (scenario assertions about WHERE the108110 * marble actually went, e.g. the spur shaft transit). */109111 onStep?: (pos: readonly [number, number, number]) => void,112+ /** M19a: mirror the client's fan wind pass (pulsed gusts included).113+ * Opt-in — the older survivability corpora were calibrated windless. */114+ withWind = false,110115 ): RunResult {111116 const w = new EndlessWorld(seed, catalog);112117 const engine = new PhysicsEngine(module_!);113118 const rails: Rail[] = [];119+ const fanField = new FanField();114120 let lowest = Infinity;115121 for (let i = 0; i < segments; i++) {116122 const seg = w.get(i);
@@ -129,6 +135,25 @@ function driveWith(
129135 ];130136 if (seg.frame) proxies = worldifyProxies(proxies, seg.frame);131137 engine.addSegment(i, proxies);138+ if (withWind) {139+ // Same volume derivation + presentation-frame transform as the140+ // client build path (endless.ts).141+ let volumes = fanVolumesOf(seg.placements);142+ const fr = seg.frame;143+ if (fr) {144+ volumes = volumes.map((v) => {145+ const vf = fr.twist146+ ? segmentFrameAt(fr, twistParam(fr, v.origin))147+ : fr.entry;148+ return {149+ ...v,150+ origin: affineApply(vf, v.origin) as [number, number, number],151+ dir: quatRotate(vf.q, v.dir) as [number, number, number],152+ };153+ });154+ }155+ fanField.addSegment(i, volumes);156+ }132157 for (const c of seg.plan.cells) lowest = Math.min(lowest, c.baseY * 2);133158 }134159 // ONE rail law with the client driver: rail.ts builds stations + mods.
@@ -219,9 +244,15 @@ function driveWith(
219244 // Gravity zones as per-marble delta forces (M15 — world gravity is220245 // global and fixed).221246 const gd = stationGravityDelta(rail, nearest, engine.mass);222- const fx = (f?.[0] ?? 0) + (gd?.[0] ?? 0);223- const fy = (f?.[1] ?? 0) + (gd?.[1] ?? 0);224- const fz = (f?.[2] ?? 0) + (gd?.[2] ?? 0);247+ // Fan wind (client parity, opt-in): pulsed volumes gate on simT.248+ const wf: [number, number, number] = [0, 0, 0];249+ if (withWind) {250+ fanField.setTime(simT);251+ fanField.forceAt(st.pos, engine.mass, wf);252+ }253+ const fx = (f?.[0] ?? 0) + (gd?.[0] ?? 0) + wf[0];254+ const fy = (f?.[1] ?? 0) + (gd?.[1] ?? 0) + wf[1];255+ const fz = (f?.[2] ?? 0) + (gd?.[2] ?? 0) + wf[2];225256 if (fx !== 0 || fy !== 0 || fz !== 0) engine.force(0, fx, fy, fz);226257 const j = wantJump(rail, nearest, cpos, cvel, null, pilot);227258 if (j) engine.jump(0, frame ? quatRotate(frame.q, j[0]) : j[0], j[1]);
@@ -278,6 +309,26 @@ Deno.test("physics survivability: assisted marble clears early segments", async
278309 );279310 });280311 312+Deno.test("scenario gust bank (wide-scan-13): pulsed walls never stall the line", async () => {313+ module_ = await factory();314+ // wide-scan-13 grows a phase-locked gust bank on the island run in315+ // segment 5 (pinned in scenario_test). Drive THROUGH it with the fan316+ // wind pass on: the marble must clear the bank without stalling or317+ // being blown off the channel, and the run must replay bit-identically318+ // (the pulse clock is sim time, not wall time).319+ const w = new EndlessWorld("wide-scan-13", catalog);320+ const goal = w.get(6).startS; // past the bank segment321+ const a = driveWith("wide-scan-13", 8, 300, goal, 0, false, undefined, true);322+ assert(323+ !a.fell && a.reachedS >= goal,324+ `gust bank stalled the marble: fell=${a.fell} reached=${325+ a.reachedS.toFixed(1)326+ }/${goal.toFixed(1)}`,327+ );328+ const b = driveWith("wide-scan-13", 8, 300, goal, 0, false, undefined, true);329+ assertEquals(a.hash, b.hash, "windy drive must replay bit-identically");330+});331+281332 Deno.test("scenario jump-gap: cold entry launches, flies, and is caught (both kinds)", async () => {282333 module_ = await factory();283334 // One pinned case per gap kind: helix-scan-6 early segments plan a
M tests/scenario_test.ts +67 -1
@@ -26,7 +26,7 @@
2626 * (piece traversal at speed-envelope extremes) arrive with the physics2727 * marble.2828 */29-import { assert } from "@std/assert";29+import { assert, assertEquals } from "@std/assert";3030 import { buildRegistry } from "../src/gen/registry.ts";3131 import { EndlessWorld, KEEP_SEGMENTS } from "../src/gen/endless/world.ts";3232 import { TRACK_SCALE } from "../src/gen/endless/scale.ts";
@@ -1089,3 +1089,69 @@ Deno.test("island ground vocabulary: wide runs, furniture, solids obey the rules
10891089 assert(furnished > 0, "no furniture emerged across the corpus");10901090 assert(solids > 0, "no solids emerged across the corpus");10911091 });1092+1093+// ---------------------------------------------------------------------------1094+// Gust walls (M19a): pulsed cross fans — pinned bank + corpus-wide laws.1095+// ---------------------------------------------------------------------------1096+1097+Deno.test("gust walls: pulses in band, banks phase-locked, pairs anti-phase", () => {1098+ // Pinned exemplar: wide-scan-13 grows a phase-locked bank on adjacent1099+ // ledges in segment 5 (found by scan; re-scan if furniture draws move).1100+ const pinned = new EndlessWorld("wide-scan-13", catalog);1101+ let pinnedBank = 0;1102+ for (let i = 0; i <= 5; i++) {1103+ const gusts = pinned.get(i).placements.filter((p) => p.wind?.pulse);1104+ if (gusts.length >= 2) {1105+ const periods = gusts.map((g) => g.wind!.pulse!.period);1106+ if (periods.every((p) => p === periods[0])) pinnedBank++;1107+ }1108+ }1109+ assert(pinnedBank > 0, "wide-scan-13 lost its phase-locked gust bank");1110+1111+ // Corpus-wide laws over the standard scan families.1112+ const seeds = [1113+ ...Array.from({ length: 15 }, (_, i) => `wide-scan-${i}`),1114+ ...Array.from({ length: 12 }, (_, i) => `grav-scan-${i}`),1115+ ...Array.from({ length: 12 }, (_, i) => `jump-scan-${i}`),1116+ ];1117+ let gusts = 0;1118+ for (const seed of seeds) {1119+ const w = new EndlessWorld(seed, catalog);1120+ for (let i = 0; i < 6; i++) {1121+ const seg = w.get(i);1122+ const segGusts = seg.placements.filter((p) => p.wind?.pulse);1123+ for (const p of segGusts) {1124+ gusts++;1125+ const pulse = p.wind!.pulse!;1126+ assertEquals(pulse.duty, 0.45, `${seed} seg ${i}: gust duty`);1127+ assert(1128+ pulse.period >= 2.4 - 1e-9 && pulse.period <= 3.6 + 1e-9,1129+ `${seed} seg ${i}: gust period ${pulse.period}`,1130+ );1131+ assert(pulse.phase >= -1e-9, `${seed} seg ${i}: negative phase`);1132+ // A gust wall is a full-strength blockade and still a fan on a1133+ // ledge (the fan rule test covers cell rounding; here: strength).1134+ assertEquals(p.wind!.strength, 14, `${seed} seg ${i}: gust strength`);1135+ }1136+ // Same-cell opposed pairs must be anti-phase on a shared period.1137+ const byCell = new Map<string, typeof segGusts>();1138+ for (const p of segGusts) {1139+ const k = `${Math.round(p.pos[0])},${Math.round(p.pos[1])},${1140+ Math.round(p.pos[2])1141+ }`;1142+ byCell.set(k, [...(byCell.get(k) ?? []), p]);1143+ }1144+ for (const list of byCell.values()) {1145+ if (list.length < 2) continue;1146+ const [a, b] = list;1147+ assertEquals(a.wind!.pulse!.period, b.wind!.pulse!.period);1148+ const diff = Math.abs(a.wind!.pulse!.phase - b.wind!.pulse!.phase);1149+ assert(1150+ Math.abs(diff - a.wind!.pulse!.period / 2) < 1e-9,1151+ `${seed} seg ${i}: opposed pair not anti-phase`,1152+ );1153+ }1154+ }1155+ }1156+ assert(gusts > 0, "no gust walls emerged across the corpus");1157+});
M tools/pin_endless.ts +4 -0