clone

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

main
marbles / tests / fanfield_test.ts
fanfield_test.ts 14.0K
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 */7import { assert, assertAlmostEquals, assertEquals } from "@std/assert";8import type { Placement } from "../src/shared/types.ts";9import {10 CROSS_LENGTH,11 CROSS_RADIUS,12 CROSS_STRENGTH,13 FanField,14 type FanVolume,15 fanVolumeFor,16 fanVolumesOf,17 FULL_CORE,18 PULSE_RAMP,19 pulseGain,20 STANDING_HUB,21 TABLE_HUB,22 UPDRAFT_STRENGTH,23} from "../src/client/fanField.ts";24import {25 coordinateGusts,26 type GustEntry,27} from "../src/gen/endless/furniture.ts";28import { EndlessWorld } from "../src/gen/endless/world.ts";29import { TRACK_SCALE } from "../src/gen/endless/scale.ts";30import type { DecorCatalog } from "../src/gen/decor.ts";31import { goldenCatalog } from "../tools/pin_endless.ts";3233// The catalog the client streams with (all decor packs — M18a).34const catalog: DecorCatalog = goldenCatalog();3536/** Dir index → world step (0=+x, 1=+z, 2=−x, 3=−z). */37const DIR_VEC: readonly (readonly [number, number, number])[] = [38 [1, 0, 0],39 [0, 0, 1],40 [-1, 0, 0],41 [0, 0, -1],42];4344function fieldWith(vols: FanVolume[], seg = 0): FanField {45 const f = new FanField();46 f.addSegment(seg, vols);47 return f;48}4950const AXIS_X: FanVolume = {51 origin: [0, 0, 0],52 dir: [1, 0, 0],53 length: 4,54 radius: 1,55 strength: 6,56};5758Deno.test("fan field: axial falloff is linear from face to length", () => {59 const f = fieldWith([AXIS_X]);60 const out: [number, number, number] = [0, 0, 0];61 // At the face: full strength · mass.62 assert(f.forceNear(0, [0, 0, 0], 2, out));63 assertAlmostEquals(out[0], 2 * 6, 1e-12);64 assertEquals(out[1], 0);65 assertEquals(out[2], 0);66 // Halfway: half strength.67 out[0] = out[1] = out[2] = 0;68 assert(f.forceNear(0, [2, 0, 0], 2, out));69 assertAlmostEquals(out[0], 2 * 3, 1e-12);70 // At/behind the ends: nothing.71 out[0] = 0;72 assert(!f.forceNear(0, [4, 0, 0], 2, out));73 assert(!f.forceNear(0, [-0.1, 0, 0], 2, out));74 assertEquals(out, [0, 0, 0]);75});7677Deno.test("fan field: radial core is full, edge is feathered to zero", () => {78 const f = fieldWith([AXIS_X]);79 const out: [number, number, number] = [0, 0, 0];80 // Inside the full core (radius·FULL_CORE = 0.6): same as on-axis.81 assert(f.forceNear(0, [2, 0.5, 0], 1, out));82 assertAlmostEquals(out[0], 3, 1e-12);83 // In the feather band: linear to 0 at radius. rad=0.9 → (1−0.9)/0.4.84 out[0] = 0;85 assert(f.forceNear(0, [2, 0, 0.9], 1, out));86 assertAlmostEquals(out[0], 3 * (1 - 0.9) / (1 - FULL_CORE), 1e-12);87 // Outside the radius: nothing.88 out[0] = 0;89 assert(!f.forceNear(0, [2, 1.1, 0], 1, out));90 assertEquals(out[0], 0);91});9293Deno.test("fan field: force accumulates into out and scales with mass", () => {94 const f = fieldWith([AXIS_X, {95 origin: [0, 0, 0],96 dir: [0, 1, 0],97 length: 4,98 radius: 1,99 strength: 6,100 }]);101 const out: [number, number, number] = [7, 0, 0];102 assert(f.forceNear(0, [0, 0, 0], 0.5, out));103 assertAlmostEquals(out[0], 7 + 0.5 * 6, 1e-12); // accumulated, not reset104 assertAlmostEquals(out[1], 0.5 * 6, 1e-12); // second volume too105});106107Deno.test("standing fan: blow direction follows placement rotation", () => {108 // Placement rotation r maps local +z (dir 1) to world dir (1−r)%4.109 for (let rot = 0; rot < 4; rot++) {110 const v = fanVolumeFor({111 pieceId: "fan-standing-two",112 pos: [10, 4, -6],113 rotation: rot as 0 | 1 | 2 | 3,114 role: "track",115 scale: 2,116 });117 assert(v, `rotation ${rot}: no volume`);118 assertEquals(v.dir, DIR_VEC[(1 - rot + 4) % 4], `rotation ${rot}`);119 // Hub: origin sits STANDING_HUB·scale above the placement origin.120 assertEquals(v.origin, [10, 4 + STANDING_HUB * 2, -6]);121 assertEquals(v.strength, CROSS_STRENGTH);122 assertEquals(v.length, CROSS_LENGTH);123 assertEquals(v.radius, CROSS_RADIUS);124 }125});126127Deno.test("table fan: blows straight up regardless of rotation", () => {128 for (const id of ["fan-two", "fan-four", "fan-simple-two"]) {129 for (let rot = 0; rot < 4; rot++) {130 const v = fanVolumeFor({131 pieceId: id,132 pos: [0, 2, 0],133 rotation: rot as 0 | 1 | 2 | 3,134 role: "track",135 scale: 2,136 });137 assert(v, `${id} rot ${rot}: no volume`);138 assertEquals(v.dir, [0, 1, 0]);139 assertEquals(v.origin, [0, 2 + TABLE_HUB * 2, 0]);140 assertEquals(v.strength, UPDRAFT_STRENGTH);141 }142 }143});144145Deno.test("wind hint overrides tuning and nudges the origin", () => {146 const v = fanVolumeFor({147 pieceId: "fan-standing-four",148 pos: [0, 0, 0],149 rotation: 1,150 role: "track",151 scale: 2,152 wind: { strength: 6.5, length: 5, radius: 1.3, offset: [1, -0.3, 0] },153 });154 assert(v);155 assertEquals(v.strength, 6.5);156 assertEquals(v.length, 5);157 assertEquals(v.radius, 1.3);158 assertEquals(v.origin, [1, STANDING_HUB * 2 - 0.3, 0]);159 assertEquals(v.dir, [1, 0, 0]); // rotation 1 → world +x160});161162Deno.test("non-fan placements derive no volume", () => {163 assertEquals(164 fanVolumeFor({165 pieceId: "straight",166 pos: [0, 0, 0],167 rotation: 0,168 role: "track",169 }),170 null,171 );172 assertEquals(173 fanVolumesOf([174 { pieceId: "banner", pos: [0, 0, 0], rotation: 0, role: "track" },175 { pieceId: "fan-two", pos: [0, 0, 0], rotation: 0, role: "track" },176 ]).length,177 1,178 );179});180181Deno.test("fan field: binning tests only segIdx ± 1", () => {182 const f = new FanField();183 f.addSegment(0, [AXIS_X]);184 f.addSegment(5, [{ ...AXIS_X, origin: [0, 100, 0] }]);185 const out: [number, number, number] = [0, 0, 0];186 // Near bin 0: seen from segIdx −1..1, not from 3.187 for (const segIdx of [-1, 0, 1]) {188 out[0] = 0;189 assert(f.forceNear(segIdx, [0, 0, 0], 1, out), `segIdx ${segIdx}`);190 }191 assert(!f.forceNear(3, [0, 0, 0], 1, out));192 // Bin 5's volume from 4/5/6 only.193 assert(f.forceNear(4, [0, 100, 0], 1, out));194 assert(!f.forceNear(7, [0, 100, 0], 1, out));195 // forceAt scans everything.196 assert(f.forceAt([0, 100, 0], 1, out));197 // Removal unregisters.198 f.removeSegment(0);199 assert(!f.forceNear(0, [0, 0, 0], 1, out));200 f.clear();201 assert(!f.forceAt([0, 100, 0], 1, out));202});203204// ---------------------------------------------------------------------------205// Furniture wind variants (M16): the endless stream deterministically206// grows boost-corridor and lip-updraft fans, and every wind fan still207// obeys the ledge rules.208// ---------------------------------------------------------------------------209210Deno.test("endless furniture: wind fan variants emerge and stay on their ledges", () => {211 let cross = 0;212 let boost = 0;213 let updraft = 0;214 let gust = 0;215 let vortex = 0;216 const onLedge = (p: Placement, label: string) => {217 // Lip fans are offset < 0.5 track units — still round to a whole218 // cell (the ledge), the ground-vocabulary rule.219 const fx = p.pos[0] / TRACK_SCALE;220 const fz = p.pos[2] / TRACK_SCALE;221 assert(222 Math.abs(fx - Math.round(fx)) < 0.5 - 1e-9 &&223 Math.abs(fz - Math.round(fz)) < 0.5 - 1e-9,224 `${label} fan strays off its cell (${fx},${fz})`,225 );226 };227 // 26×10 is the smallest corpus where all five variants emerge post-M21228 // (the fan pipeline sits behind stub coins, mod-zone exclusions and229 // now climate multipliers — fans are punctuation, not weather).230 for (let n = 0; n < 26; n++) {231 const w = new EndlessWorld(`wind-${n}`, catalog);232 for (let i = 0; i < 10; i++) {233 const seg = w.get(i);234 for (const p of seg.placements) {235 if (!p.pieceId.startsWith("fan-")) continue;236 const standing = p.pieceId.startsWith("fan-standing");237 if (!p.wind) {238 cross++;239 continue;240 }241 if (p.wind.pulse) {242 // Gust wall (M19a): pulsed cross fan — full period/duty sanity243 // and the coordination pass keeps duty untouched.244 gust++;245 assert(standing || !standing); // any mesh; the pulse is the law246 assertEquals(p.wind.strength, 14);247 assertEquals(p.wind.pulse.duty, 0.45);248 assert(249 p.wind.pulse.period >= 2.4 - 1e-9 &&250 p.wind.pulse.period <= 3.6 + 1e-9,251 `gust period out of band (${p.wind.pulse.period})`,252 );253 assert(p.wind.pulse.phase >= -1e-9, "negative gust phase");254 onLedge(p, "gust");255 } else if (p.wind.swirl) {256 // Vortex (M19a): table fan at the lip, swirl column over the257 // channel.258 vortex++;259 assert(!standing, "vortex must ride a table fan");260 assertEquals(p.wind.strength, 7);261 onLedge(p, "vortex");262 } else if (standing) {263 boost++;264 // Boost corridor: capsule parked over the channel, blowing265 // along travel (horizontal — never up).266 assertEquals(p.wind.strength, 6.5);267 assert(p.wind.offset && p.wind.offset[1] === -0.3);268 } else {269 updraft++;270 assertEquals(p.wind.strength, 9.5);271 onLedge(p, "updraft");272 }273 }274 }275 }276 assert(cross > 0, "legacy cross-wind fans vanished from the corpus");277 assert(boost > 0, "no boost-corridor fan emerged across the corpus");278 assert(updraft > 0, "no lip-updraft fan emerged across the corpus");279 assert(gust > 0, "no gust wall emerged across the corpus");280 assert(vortex > 0, "no vortex emerged across the corpus");281});282283// ---------------------------------------------------------------------------284// M19a: pulsed gusts, vortex swirl, and gust coordination.285// ---------------------------------------------------------------------------286287Deno.test("pulse gain: periodic, zero outside duty, full mid-window", () => {288 const pulse = { period: 3, duty: 0.45, phase: 0 };289 const on = 3 * 0.45;290 // Mid-window full strength.291 assertEquals(pulseGain(pulse, on / 2), 1);292 // Off-phase silent (just past the window and just before wrap).293 assertEquals(pulseGain(pulse, on + 0.01), 0);294 assertEquals(pulseGain(pulse, 3 - 0.01), 0);295 // Periodicity: same gain one and two periods later, negative t too.296 for (const t of [0.2, on / 2, on - 0.05, 2.9]) {297 assertAlmostEquals(pulseGain(pulse, t), pulseGain(pulse, t + 3), 1e-12);298 assertAlmostEquals(pulseGain(pulse, t), pulseGain(pulse, t - 6), 1e-12);299 }300 // Edges ramp: strictly between 0 and 1 inside the ramp band.301 const ramp = on * PULSE_RAMP;302 const g = pulseGain(pulse, ramp / 2);303 assert(g > 0 && g < 1, `edge should ramp, got ${g}`);304 // No pulse = always on.305 assertEquals(pulseGain(undefined, 123.4), 1);306});307308Deno.test("pulsed volume: force gates on the field's sim time", () => {309 const gust: FanVolume = {310 ...AXIS_X,311 pulse: { period: 3, duty: 0.45, phase: 0 },312 };313 const f = fieldWith([gust]);314 const out: [number, number, number] = [0, 0, 0];315 f.setTime(3 * 0.45 / 2); // mid-duty316 assert(f.forceNear(0, [1, 0, 0], 1, out));317 const onForce = out[0];318 assert(onForce > 0);319 out[0] = 0;320 f.setTime(2.9); // off-phase321 assert(!f.forceNear(0, [1, 0, 0], 1, out));322 assertEquals(out[0], 0);323 // Determinism: two fields, same time sequence, same output.324 const g2 = fieldWith([{ ...gust }]);325 g2.setTime(3 * 0.45 / 2);326 const out2: [number, number, number] = [0, 0, 0];327 g2.forceNear(0, [1, 0, 0], 1, out2);328 assertEquals(out2[0], onForce);329});330331Deno.test("anti-phase pair: never both blowing at full", () => {332 const period = 3;333 const a = { period, duty: 0.45, phase: 0 };334 const b = { period, duty: 0.45, phase: period / 2 };335 // Duty 0.45 < 0.5 ⇒ windows are disjoint: whenever one is at full336 // gain the other is silent.337 for (let t = 0; t < 2 * period; t += 0.05) {338 const ga = pulseGain(a, t);339 const gb = pulseGain(b, t);340 if (ga === 1) assertEquals(gb, 0);341 if (gb === 1) assertEquals(ga, 0);342 }343});344345Deno.test("swirl volume: force is tangential (⟂ radial and ⟂ axis)", () => {346 const vortex: FanVolume = {347 origin: [0, 0, 0],348 dir: [0, 1, 0],349 length: 2,350 radius: 1.5,351 strength: 7,352 swirl: true,353 };354 const f = fieldWith([vortex]);355 for (356 const pos of [[0.8, 0.5, 0], [0, 1, 0.6], [-0.4, 0.2, 0.4]] as const357 ) {358 const out: [number, number, number] = [0, 0, 0];359 assert(f.forceNear(0, pos, 1, out));360 // ⟂ axis: no vertical component.361 assertAlmostEquals(out[1], 0, 1e-12);362 // ⟂ radial: dot with the horizontal radial vector vanishes.363 const dot = out[0] * pos[0] + out[2] * pos[2];364 assertAlmostEquals(dot, 0, 1e-9);365 assert(Math.hypot(out[0], out[2]) > 0);366 }367 // Center singularity: no force on the exact axis.368 const out: [number, number, number] = [0, 0, 0];369 assert(!f.forceNear(0, [0, 0.5, 0], 1, out));370});371372Deno.test("gust coordination: banks phase-lock, opposed pairs anti-phase", () => {373 const mk = (374 host: number,375 side: string,376 period: number,377 phase: number,378 isle = 1,379 ): GustEntry => ({380 wind: { pulse: { period, duty: 0.45, phase } },381 host,382 side,383 isle,384 });385 // Bank of three on one side: shared period, travelling phases.386 const bank = [mk(4, "L", 3.0, 1.7), mk(5, "L", 2.6, 0.2), mk(6, "L", 3.4, 2.9)];387 // A same-side gust too far downstream stays independent.388 const lone = mk(11, "L", 2.8, 1.1);389 // Opposed pair on host 4: R locks to L's period, anti-phase.390 const paired = mk(4, "R", 3.3, 0.9);391 coordinateGusts([...bank, lone, paired]);392 for (const g of bank) assertEquals(g.wind.pulse.period, 3.0);393 assertEquals(bank.map((g) => g.wind.pulse.phase), [0, 1, 2]);394 assertEquals(lone.wind.pulse.period, 2.8);395 assertEquals(lone.wind.pulse.phase, 1.1);396 assertEquals(paired.wind.pulse.period, 3.0);397 assertAlmostEquals(paired.wind.pulse.phase, 0 + 1.5, 1e-12);398});399400Deno.test("endless furniture: wind draws are deterministic per seed", () => {401 const a = new EndlessWorld("wind-repeat", catalog);402 const b = new EndlessWorld("wind-repeat", catalog);403 for (let i = 0; i < 6; i++) {404 const fansA = a.get(i).placements.filter((p: Placement) =>405 p.pieceId.startsWith("fan-")406 );407 const fansB = b.get(i).placements.filter((p: Placement) =>408 p.pieceId.startsWith("fan-")409 );410 assertEquals(fansA, fansB);411 }412});