Gate 3.1 pins (c.test): the soaker over site_02 through gardenfly, porosity the only variable — bare <30 (17.5), best cloth line <50 (42.3, still >bare: the leak is partial), same $75 ring on membrane >90 FULL (96.5) with lost<2 and cost<=80, $60 membrane ring wins too (68.8), separation >40 (54.2). The inverse guard: membrane on carabiners reads WORSE than cloth (19.1/3 lost vs 24.5/2) — the F key is a bet, not an upgrade. Gate 3.3: forecastLines grows stones + rainRate (worded, banded); stormStats.hailSize + forecastFor hail.size band, spread 0.30, nesting/truth-holding pinned in the resolving loop; existing card fields byte-identical (new band draws last). storm_06 in STORM_KEYS + suite STORMS (sync assert is the tripwire). Envelope tool flown: throat 21.55 @ 59.2, q1 17.78 (rated slot), q2 wind-coldest yet cloth-side second-hottest — the pond's signature isolated between harnesses. Mutations, separate runs, red-then-green: gardenfly seam hardcoded -> both 3.1 pins red; STORM_KEYS drop -> list-sync red with diff; stormgen rng -> byte-equal red. Browser selftest 438/0/0 (418 + 4 storm honesty + 12 stormgen + 4 gate-3). THREADS: gate entries + D pairing receipt + B seam/finding notes.
1288 lines
71 KiB
JavaScript
1288 lines
71 KiB
JavaScript
/**
|
||
* Lane C selftests — wind field, storm timelines, rain, debris.
|
||
*
|
||
* The asserts live in weather.selftest.js as a plain case list, because they
|
||
* import nothing but weather.core.js (no THREE, no DOM) and so also run under
|
||
* `node web/world/js/tests/run-node.mjs` — a one-second loop for tuning a storm
|
||
* curve, instead of a browser round trip. This file is the browser half: it
|
||
* feeds them the fetched storms and adds the checks that need the real
|
||
* weather.js adapter (contract shape, THREE.Vector3 out).
|
||
*
|
||
* Lane A: a.test.js's 'gust telegraph always gives at least 1.2 s of warning'
|
||
* is lifted onto the real wind below, per your note — the stub's claim to it is
|
||
* now redundant and yours to drop whenever suits. Logged in THREADS.
|
||
*/
|
||
|
||
import * as THREE from '../../vendor/three.module.js';
|
||
import { assert, assertClose, fixedLoop } from '../testkit.js';
|
||
import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS, createStubWind } from '../contracts.js';
|
||
import { loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor, hailBlockFor } from '../weather.js';
|
||
import { loadSite, createWorld } from '../world.js';
|
||
// GATE 2.3 — the GAME's own wind wiring, IMPORTED rather than re-typed. A pin
|
||
// that copied main.js's two lines would agree with a copy of the game forever,
|
||
// including on the day the game itself changed. "Two harnesses, one number"
|
||
// means reaching for the real one.
|
||
import { createWindRouter } from '../main.js';
|
||
import { normalizeAxis, shelterAtten, worstSecond, STORM_KEYS as WIND_STORM_KEYS } from '../editor.wind.js';
|
||
import { createStormSel, stormSel, STORM_KEYS as SEL_STORM_KEYS, DEFAULT_SEL } from '../editor_storm.js';
|
||
import { createDebris } from '../debris.js';
|
||
import { createSkyFx, RainShadow } from '../skyfx.js';
|
||
import { SailRig, HARDWARE } from '../sail.js';
|
||
import { weatherCases } from './weather.selftest.js';
|
||
import { ENVELOPE_TESTS } from '../../../../tools/storm_envelope/envelope.selftest.js';
|
||
// SPRINT16 gate 3: the membrane night's pin flies THE audit chain (gardenfly),
|
||
// and the seeded generator must satisfy the storm-side envelope harness.
|
||
import { flyGarden, hardwareByName } from '../../../../tools/site_audit/gardenfly.js';
|
||
import { stormEnvelope } from '../../../../tools/storm_envelope/envelope.js';
|
||
import { makeStorm } from '../stormgen.js';
|
||
|
||
// Keep in step with data/storms/. The node runner globs the directory, so this
|
||
// list going stale shows up here first — as it did when storm_03 landed and the
|
||
// ponding case reached for a storm the browser half had never loaded.
|
||
const STORMS = [
|
||
'storm_01_gentle', 'storm_02_wildnight', 'storm_03_southerly',
|
||
// SPRINT6: the week's two variants — night 3 (same force, half the warning)
|
||
// and night 5 (less wind, 2.8× the hail).
|
||
'storm_03b_earlybuster', 'storm_02b_icenight',
|
||
// SPRINT16 gate 3.1: the pea-hail soaker — the first night the default
|
||
// fabric is the wrong answer. Its membrane-vs-cloth pin is below.
|
||
'storm_06_soaker',
|
||
];
|
||
|
||
/** @param {import('../testkit.js').Suite} t */
|
||
export default async function run(t) {
|
||
const storms = {};
|
||
for (const name of STORMS) storms[name] = await loadStorm(name);
|
||
|
||
// --- the shared case list (also runs headless in node) ---
|
||
const { cases } = weatherCases(storms);
|
||
for (const c of cases) t.test(c.name, c.fn);
|
||
|
||
// --- SPRINT12 gate 2.4: the storm-side envelope harness audits itself ---
|
||
// Same pattern as sweep.selftest in b.test.js: the tool that second-guesses
|
||
// B's audit must not be the thing that drifts. See envelope.selftest.js.
|
||
for (const [name, fn] of ENVELOPE_TESTS) t.test(name, fn);
|
||
|
||
// --- the bits that need the THREE adapter, not just the core ---
|
||
|
||
t.test('weather.js satisfies the wind contract', () => {
|
||
const wind = createWind(storms.storm_02_wildnight);
|
||
const problems = checkContract('wind', wind);
|
||
assert(problems.length === 0, problems.join('; '));
|
||
});
|
||
|
||
t.test('sample() returns a Vector3 and honours an out param', () => {
|
||
const wind = createWind(storms.storm_02_wildnight);
|
||
const pos = new THREE.Vector3(3, 0, -2);
|
||
const a = wind.sample(pos, 12.5);
|
||
assert(a instanceof THREE.Vector3, 'sample did not return a THREE.Vector3');
|
||
assert(Number.isFinite(a.x) && Number.isFinite(a.y) && Number.isFinite(a.z),
|
||
`sample returned a non-finite vector: ${a.x},${a.y},${a.z}`);
|
||
// out param must not change the answer, only where it lands
|
||
const out = new THREE.Vector3();
|
||
const b = wind.sample(pos, 12.5, out);
|
||
assert(b === out, 'out param was ignored');
|
||
assert(a.x === b.x && a.y === b.y && a.z === b.z, 'out param changed the result');
|
||
});
|
||
|
||
// This assert once read `a.y === 0` — "wind is horizontal". SPRINT2 decision 3
|
||
// made that false on purpose (gusts descend, so a flat sail pays); SPRINT3
|
||
// decision 8 made the descent a fraction of TOTAL wind, so it's present
|
||
// whenever it's windy, not only in gusts. Keeping the invariants that consumers
|
||
// (player shove, rain angle, HUD) rely on: y is down-or-zero, never up, never
|
||
// garbage, and speedAt() is horizontal-only.
|
||
t.test('vertical wind is downward-only and rides the wind', () => {
|
||
const wind = createWind(storms.storm_02_wildnight);
|
||
const pos = new THREE.Vector3(0, 1.7, 0);
|
||
const v = new THREE.Vector3();
|
||
let sawDown = false;
|
||
fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
|
||
wind.sample(pos, time, v);
|
||
assert(v.y <= 1e-9, `wind blew UP (y=${v.y.toFixed(3)}) at t=${time.toFixed(2)}`);
|
||
assert(Number.isFinite(v.y), `vertical wind is not finite at t=${time.toFixed(2)}`);
|
||
if (v.y < -2) sawDown = true;
|
||
});
|
||
assert(sawDown, 'never saw a downdraft worth the name in a whole wild night');
|
||
// the wind meter must stay horizontal — falling air shouldn't spike the HUD
|
||
const s = wind.sample(pos, 0.5);
|
||
assert(Math.abs(wind.speedAt(pos, 0.5) - Math.hypot(s.x, s.z)) < 1e-9,
|
||
'speedAt() is not the horizontal magnitude of sample()');
|
||
});
|
||
|
||
// Lifted from a.test.js onto the real wind (Lane A's note in this file's
|
||
// header). This is the promise the whole storm rests on: the player can
|
||
// always react. Deliberately walks the public surface, not the core.
|
||
t.test('gust telegraph always gives at least 1.2 s of warning', () => {
|
||
const wind = createWind(storms.storm_02_wildnight);
|
||
let had = false, edges = 0;
|
||
fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
|
||
const tel = wind.gustTelegraph(time);
|
||
if (tel && !had) {
|
||
edges++;
|
||
assert(tel.eta >= 1.2, `telegraph appeared with only ${tel.eta.toFixed(2)}s of warning`);
|
||
assert(Number.isFinite(tel.power) && tel.power > 0, 'telegraph carried no power');
|
||
assert(Number.isFinite(tel.dir), 'telegraph carried no direction');
|
||
}
|
||
had = !!tel;
|
||
});
|
||
assert(edges >= 5, `only ${edges} gusts telegraphed in a ${wind.duration}s storm — too quiet to test`);
|
||
});
|
||
|
||
// --- SPRINT2 decision 5: debris.pieces is Lane B's to read, so it's frozen ---
|
||
t.test('debris conforms and its pieces match the frozen shape', () => {
|
||
const wind = createWind(storms.storm_02_wildnight);
|
||
const debris = createDebris({ wind });
|
||
assert(checkContract('debris', debris).length === 0, checkContract('debris', debris).join('; '));
|
||
|
||
const p = debris.spawn({ model: 'BlueCrate_v2', lateral: 0 }, 40);
|
||
for (const [field, want] of Object.entries(DEBRIS_PIECE_FIELDS)) {
|
||
const got = typeof p[field];
|
||
assert(got === want, `piece.${field} is ${got}, contract says ${want}`);
|
||
if (want === 'number') assert(Number.isFinite(p[field]), `piece.${field} is not finite`);
|
||
}
|
||
assert(debris.pieces.includes(p), 'spawn() returned a piece that is not in pieces');
|
||
assert(p.r > 0 && p.mass > 0, 'a piece with no radius or no mass cannot be collided with');
|
||
|
||
// The array is mutated in place and pieces are spliced on despawn — that's
|
||
// documented, and B reads it fresh inside step(). Prove clear() empties it
|
||
// rather than swapping in a new array behind their reference.
|
||
const ref = debris.pieces;
|
||
debris.clear();
|
||
assert(ref === debris.pieces && debris.pieces.length === 0,
|
||
'clear() replaced the pieces array instead of emptying it — B holds a reference');
|
||
});
|
||
|
||
// SPRINT13 gate 2.3 — ambient leaves. QA: "debris reads 0 through night 3";
|
||
// the crates are event drama, the leaves are the continuous tell. Threshold
|
||
// 8.3 m/s (30 km/h) matches D's lean threshold on purpose — the yard and the
|
||
// body start telling the same story at the same speed. Count stays single
|
||
// digits, they stream WITH the wind, and the layer is deterministic.
|
||
t.test('ambient leaves: none in a calm, a streaming handful from ~30 km/h', () => {
|
||
const stub = (sp) => ({
|
||
seed: 5,
|
||
sample: (p, t2, o) => o.set(sp, 0, 0),
|
||
eventsBetween: () => [],
|
||
});
|
||
const calm = createDebris({ wind: stub(5) });
|
||
fixedLoop(8, FIXED_DT, (dt, time) => calm.step(dt, time, {}));
|
||
assert(calm.leafCount === 0,
|
||
`${calm.leafCount} leaves flying at 18 km/h — below the threshold the yard is still`);
|
||
|
||
const gale = createDebris({ wind: stub(13) });
|
||
fixedLoop(8, FIXED_DT, (dt, time) => gale.step(dt, time, {}));
|
||
assert(gale.leafCount > 0, 'no leaves at 47 km/h — the gale has no ambient tell');
|
||
assert(gale.leafCount <= 9, `${gale.leafCount} leaves — the plan says single digits`);
|
||
|
||
// they stream WITH the wind (+x here): over one step every leaf either
|
||
// moves downwind or recycles ~a span upwind; none drifts against it
|
||
const before = gale.leaves.map((pos) => pos.x);
|
||
gale.step(FIXED_DT, 8, {});
|
||
const after = gale.leaves.map((pos) => pos.x);
|
||
assert(before.length === after.length, 'leaf count strobed across one step');
|
||
for (let i = 0; i < before.length; i++) {
|
||
const d = after[i] - before[i];
|
||
assert(d > 0 || d < -10, `leaf ${i} moved ${d.toFixed(3)} m against a +x wind`);
|
||
}
|
||
|
||
// deterministic: same seed, same (dt, t) stream, same leaves — bit for bit
|
||
const a = createDebris({ wind: stub(13) });
|
||
const b = createDebris({ wind: stub(13) });
|
||
fixedLoop(5, FIXED_DT, (dt, time) => { a.step(dt, time, {}); b.step(dt, time, {}); });
|
||
assert(a.leafCount === b.leafCount && a.leaves.every((pos, i) =>
|
||
pos.x === b.leaves[i].x && pos.y === b.leaves[i].y && pos.z === b.leaves[i].z),
|
||
'two identical (dt, t) streams produced different leaves');
|
||
|
||
// clear() rewinds the layer — next night starts from the same state
|
||
gale.clear();
|
||
assert(gale.leafCount === 0, 'clear() left leaves flying into the aftermath card');
|
||
});
|
||
|
||
// SPRINT13, the third time this repo learned it: the venturi lives in the
|
||
// SITE json, not the storm def. B's site_audit flew site_02 funnel-OFF in
|
||
// Sprint 11 (their sweep.selftest tells it); C's garden_bench did it again
|
||
// in Sprint 13 — `def.wind.venturi` off a storm def LOOKS right and never
|
||
// fires, and the funnel-off bench called the $80 line 91.5 FULL on a night
|
||
// the real game scores it 39.8 TATTERED (D's live replay). windForSite is
|
||
// the shared builder that makes the mistake unmakeable; this assert is
|
||
// B's strictly-raises pin lifted onto it, with the REAL shipped funnel:
|
||
// drop the setVenturi line and the two winds collapse to equal reads.
|
||
const site02 = await loadSite('site_02_corner_block'); // awaited here: Suite.test() is sync
|
||
t.test('windForSite flies the SITE venturi — the shipped funnel strictly raises the throat wind', () => {
|
||
const def = storms.storm_02_wildnight;
|
||
const site = site02;
|
||
const vl = site.wind?.venturi ?? [];
|
||
assert(vl.length > 0, "site_02 lost its venturi — this test (and the yard's weather) proves nothing");
|
||
const bare = createWind(def); // storm def alone — the trap itself
|
||
const funnelled = windForSite(def, site);
|
||
const throat = new THREE.Vector3(vl[0].x, 1.5, vl[0].z);
|
||
const a = new THREE.Vector3(), b = new THREE.Vector3();
|
||
let bareMean = 0, funMean = 0, n = 0;
|
||
for (let time = 20; time <= 80; time += 0.5) {
|
||
bare.sample(throat, time, a);
|
||
funnelled.sample(throat, time, b);
|
||
bareMean += Math.hypot(a.x, a.z); funMean += Math.hypot(b.x, b.z); n++;
|
||
}
|
||
bareMean /= n; funMean /= n;
|
||
assert(bareMean > 3, `the storm never blew at the throat (${bareMean.toFixed(1)} m/s) — vacuous`);
|
||
assert(funMean > bareMean * 1.1,
|
||
`throat wind ${funMean.toFixed(2)} vs bare ${bareMean.toFixed(2)} m/s — the site's venturi is not `
|
||
+ 'reaching windForSite\'s wind (it must call setVenturi the way main.js does at every site load)');
|
||
});
|
||
|
||
// D's aftermath find (S13, lane/d): the hail InstancedMesh at count:0 with
|
||
// frustum culling and depthWrite both off still draws instance 0's identity
|
||
// matrix — a ~5 cm always-on-top white sliver at the origin, the QA's "ghost
|
||
// near the shed table". The gate is mesh.visible, not count. This flies the
|
||
// wild night and demands the pool is invisible whenever no hail is falling
|
||
// and visible whenever it is — both directions, whole storm.
|
||
t.test("hail pool: invisible when nothing falls (D's aftermath sliver)", () => {
|
||
const scene = new THREE.Scene();
|
||
const camera = new THREE.PerspectiveCamera();
|
||
const wind = createWind(storms.storm_02_wildnight);
|
||
const sky = createSkyFx({ scene, camera, wind });
|
||
let sawHail = false, sawCalm = false;
|
||
fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
|
||
sky.step(dt, time, {});
|
||
// the mesh draws floor(1300 × intensity) stones, so a burst's first
|
||
// millisecond can honestly show zero — demand visibility only once the
|
||
// intensity buys at least one stone, and invisibility only at exactly 0
|
||
if (sky.hailAmount >= 1 / 1300) {
|
||
sawHail = true;
|
||
assert(sky.hail.mesh.visible, `hail falling at t=${time.toFixed(1)} but the pool is hidden`);
|
||
} else if (sky.hailAmount === 0) {
|
||
sawCalm = true;
|
||
assert(!sky.hail.mesh.visible, `no hail at t=${time.toFixed(1)} but the pool still draws — the sliver`);
|
||
}
|
||
});
|
||
assert(sawHail && sawCalm, 'storm never exercised both states — this test proves nothing');
|
||
sky.dispose();
|
||
});
|
||
|
||
// Lane A rebuilds skyfx on every phase change, so dispose() is on the hot path.
|
||
// They verified sun/hemi restore exactly and spotted that fog didn't; this pins
|
||
// both. The vacuity guards matter — a restore test where nothing ever moved is
|
||
// a test that passes forever and checks nothing.
|
||
t.test('skyfx.dispose() hands the scene back exactly as it found it', () => {
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color(0x9fc4e8);
|
||
scene.fog = new THREE.Fog(0x9fc4e8, 30, 140);
|
||
const camera = new THREE.PerspectiveCamera();
|
||
const sun = new THREE.DirectionalLight(0xfff4e0, 2.0);
|
||
const hemi = new THREE.HemisphereLight(0xbfd8ff, 0x3a4a2a, 1.8);
|
||
const before = {
|
||
bg: scene.background, fogColor: scene.fog.color.getHex(),
|
||
fogNear: scene.fog.near, fogFar: scene.fog.far,
|
||
sun: sun.intensity, hemi: hemi.intensity, children: scene.children.length,
|
||
};
|
||
|
||
const wind = createWind(storms.storm_02_wildnight);
|
||
const sky = createSkyFx({ scene, camera, wind, sun, hemi });
|
||
fixedLoop(40, FIXED_DT, (dt, time) => sky.step(dt, time, {}));
|
||
|
||
assert(sun.intensity < before.sun * 0.9, 'the storm never dimmed the sun — this test proves nothing');
|
||
assert(scene.fog.near !== before.fogNear, 'the storm never touched the fog — this test proves nothing');
|
||
|
||
sky.dispose();
|
||
assert(sun.intensity === before.sun, `sun left at ${sun.intensity}, want ${before.sun}`);
|
||
assert(hemi.intensity === before.hemi, `hemi left at ${hemi.intensity}, want ${before.hemi}`);
|
||
assert(scene.background === before.bg, 'scene.background not restored');
|
||
assert(scene.fog.color.getHex() === before.fogColor,
|
||
`fog colour left at #${scene.fog.color.getHex().toString(16)}, want #${before.fogColor.toString(16)}`);
|
||
assert(scene.fog.near === before.fogNear && scene.fog.far === before.fogFar,
|
||
`fog left at near=${scene.fog.near} far=${scene.fog.far}, want ${before.fogNear}/${before.fogFar}`);
|
||
assert(scene.children.length === before.children,
|
||
`skyfx left ${scene.children.length - before.children} object(s) in the scene`);
|
||
});
|
||
|
||
// SPRINT13 gate 3.3 — A's M key. main.js feature-detects sky.setMute and only
|
||
// advertises M once it exists, so this contract is what lights the key up.
|
||
// The pre-unlock case is the one that bites: M pressed on the splash screen,
|
||
// BEFORE the first gesture builds the audio graph, must survive the unlock.
|
||
t.test('M-mute: setMute silences the bus, and a pre-unlock mute survives unlock', () => {
|
||
const wind = createWind(storms.storm_02_wildnight);
|
||
const sky = createSkyFx({ wind }); // headless — the bus needs no scene
|
||
assert(typeof sky.setMute === 'function', "no setMute — A's M key has nothing to call");
|
||
assert(sky.muted === false, 'a fresh sky must not start muted');
|
||
sky.setMute(true); // before unlock — the splash case
|
||
assert(sky.muted === true, 'setMute(true) did not take');
|
||
sky.unlockAudio();
|
||
if (sky.audio.ready) { // no AudioContext = nothing to silence
|
||
assert(sky.audio.masterGain === 0,
|
||
`unlock forgot a pre-unlock mute: master at ${sky.audio.masterGain}`);
|
||
sky.setMute(false);
|
||
assert(sky.audio.masterGain > 0, 'unmute left the master gain at 0');
|
||
}
|
||
sky.dispose();
|
||
});
|
||
|
||
// SPRINT13 gate 2.1 — the storm grade. The QA sighting: 65 km/h + driving
|
||
// rain under a noon-blue sky with razor midday shadows. Cause: sky and sun
|
||
// both multiplied the weather by the author's palette (`darkness`, 0.5 on
|
||
// the southerly), so the dial could zero the weather's say — and nothing
|
||
// touched shadow softness at all. Pins: the grade floors the darkness dial,
|
||
// the sun dims/goes slate/softens, and dispose hands all of it back.
|
||
t.test('storm grade: a mid-darkness gale dims the sun, softens shadows, restores on dispose', () => {
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color(0x9fc4e8);
|
||
const camera = new THREE.PerspectiveCamera();
|
||
const sun = new THREE.DirectionalLight(0xfff2dc, 2.0);
|
||
const before = { sun: sun.intensity, color: sun.color.getHex(), radius: sun.shadow.radius };
|
||
// a darkness-0.5 storm blowing 18 m/s (65 km/h) in full rain — the QA scene
|
||
const gale = {
|
||
seed: 1, def: { sky: { darkness: 0.5 } },
|
||
sample: (p, t2, o) => o.set(18, 0, 0),
|
||
speedAt: () => 18, rainAt: () => 1, rainMmPerHour: () => 30,
|
||
gustTelegraph: () => null, eventsBetween: () => [], setSheltersFromTrees() {},
|
||
};
|
||
const sky = createSkyFx({ scene, camera, wind: gale, sun });
|
||
fixedLoop(10, FIXED_DT, (dt, time) => sky.step(dt, time, {}));
|
||
assert(sky.stormGrade > 0.7,
|
||
`grade ${sky.stormGrade} — the darkness dial still zeroes the weather (the old formula reads 0.50 here)`);
|
||
assert(sun.intensity < before.sun * 0.5,
|
||
`sun at ${sun.intensity.toFixed(2)} of ${before.sun} — still noon at 65 km/h`);
|
||
assert(sun.shadow.radius > 4,
|
||
`shadow radius ${sun.shadow.radius} — razor-sharp midday shadows in a gale`);
|
||
assert(sun.color.getHex() !== before.color, 'the sun disc never lost its noon warmth');
|
||
sky.dispose();
|
||
assert(sun.intensity === before.sun && sun.color.getHex() === before.color
|
||
&& sun.shadow.radius === before.radius,
|
||
'dispose did not hand the sun back exactly (intensity / colour / shadow radius)');
|
||
});
|
||
|
||
// SPRINT13 gate 2.2 — the wall's edges, judged from in-yard eye height.
|
||
// Two QA sightings, both geometry: a sliver of bright sky under the bank's
|
||
// base (it stopped AT the camera's horizontal plane, above the ground's true
|
||
// horizon), and a hard vertical seam at the arc ends (0.36 alpha at the
|
||
// outer tenth). The band now overshoots the equator and the end fade is
|
||
// steeper; these pins are what "grounded" and "feathered" mean in numbers.
|
||
t.test('the change-front wall grounds below the horizon and feathers its ends', () => {
|
||
const wind = createWind(storms.storm_03_southerly);
|
||
const sky = createSkyFx({ wind });
|
||
const geo = sky.front.geometry;
|
||
geo.computeBoundingBox();
|
||
assert(geo.boundingBox.min.y < -20,
|
||
`front base at y=${geo.boundingBox.min.y.toFixed(1)} — a band stopping at eye level floats a sky sliver under the wall`);
|
||
const uv = geo.attributes.uv, col = geo.attributes.color;
|
||
assert(col && col.itemSize === 4, 'front lost its vertex alpha — the edges are hard cuts again');
|
||
let edgeMax = 0;
|
||
for (let i = 0; i < uv.count; i++) {
|
||
const u = uv.getX(i);
|
||
if (u <= 0.09 || u >= 0.91) edgeMax = Math.max(edgeMax, col.getW(i));
|
||
}
|
||
assert(edgeMax < 0.2,
|
||
`arc-end alpha ${edgeMax.toFixed(2)} — the vertical seam QA saw at the bank's ends`);
|
||
sky.dispose();
|
||
});
|
||
|
||
// --- SPRINT2 §Lane C.3: rain has to stop at the cloth ---
|
||
// Driven with a synthetic 4×4 m panel rather than a whole cloth sim: the thing
|
||
// under test is the projection, and a flat panel makes the right answer
|
||
// something you can work out on paper.
|
||
const PANEL = {
|
||
pos: new Float32Array([-2, 3, -2, 2, 3, -2, 2, 3, 2, -2, 3, 2]),
|
||
tris: [0, 1, 2, 0, 2, 3],
|
||
};
|
||
|
||
t.test('rain shadow: straight-down rain leaves a dry patch under the panel', () => {
|
||
const s = new RainShadow();
|
||
s.update(PANEL, 0, -1, 0);
|
||
assert(s.live, 'shadow never built');
|
||
assert(s.occluded(0, 1, 0), 'drop directly under the panel is still falling');
|
||
assert(s.occluded(1.5, 0.1, 1.5), 'drop near the panel corner is still falling');
|
||
assert(!s.occluded(0, 5, 0), 'drop ABOVE the panel was culled — it has not hit yet');
|
||
assert(!s.occluded(8, 1, 0), 'drop well clear of the panel was culled');
|
||
assert(!s.occluded(0, 1, 9), 'drop well clear of the panel was culled');
|
||
});
|
||
|
||
t.test('rain shadow leans with the rain, and follows the wind round', () => {
|
||
const s = new RainShadow();
|
||
// rain driving hard along +x: the dry ground moves +x, out from under the panel
|
||
s.update(PANEL, 0.6, -0.8, 0);
|
||
const shift = 3 * (0.6 / 0.8); // 3 m of fall × the lean
|
||
assert(s.occluded(shift, 0.05, 0), `dry patch is not downwind at x=${shift.toFixed(2)}`);
|
||
assert(!s.occluded(-shift, 0.05, 0), 'dry patch went UPWIND — the projection is inverted');
|
||
|
||
// swing the wind 180° and the patch has to swap sides. This is the southerly
|
||
// change: the sail stops covering the bed without a single corner failing.
|
||
s.update(PANEL, -0.6, -0.8, 0);
|
||
assert(s.occluded(-shift, 0.05, 0), 'dry patch did not follow the wind round');
|
||
assert(!s.occluded(shift, 0.05, 0), 'dry patch stayed put when the wind swung');
|
||
});
|
||
|
||
t.test('rain shadow: no sail, no shelter', () => {
|
||
const s = new RainShadow();
|
||
s.update(null, 0, -1, 0);
|
||
assert(!s.live && !s.occluded(0, 1, 0), 'sheltered by a sail that does not exist');
|
||
// and rain that is not falling can't cast a shadow (guards a divide by ~0)
|
||
s.update(PANEL, 1, 0, 0);
|
||
assert(!s.live, 'horizontal rain projected to infinity instead of bailing out');
|
||
});
|
||
|
||
t.test('rain shadow: fractionOver reads a rect the way coverageOver does', () => {
|
||
const s = new RainShadow();
|
||
s.update(PANEL, 0, -1, 0);
|
||
// the panel spans x,z in [-2,2]; a rect inside it is fully covered
|
||
assert(s.fractionOver({ x: 0, z: 0, w: 2, d: 2 }) === 1,
|
||
'a rect wholly under the panel is not fully covered');
|
||
assert(s.fractionOver({ x: 12, z: 0, w: 2, d: 2 }) === 0,
|
||
'a rect nowhere near the panel is covered');
|
||
const half = s.fractionOver({ x: 2, z: 0, w: 4, d: 2 });
|
||
assert(half > 0.2 && half < 0.8, `a rect straddling the edge reads ${half}, want a partial`);
|
||
});
|
||
|
||
// --- decision 7: the drain helper Lane A wires garden HP to ---
|
||
t.test('gardenExposure is rain AND no cover, and needs both', () => {
|
||
const scene = new THREE.Scene();
|
||
const camera = new THREE.PerspectiveCamera();
|
||
const wind = createWind(storms.storm_02_wildnight);
|
||
const sky = createSkyFx({ scene, camera, wind });
|
||
const bed = { x: 0, z: 0, w: 4, d: 3 };
|
||
|
||
// no sail: exposure is simply the rain
|
||
fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 70 + time, {}));
|
||
const open = sky.gardenExposure(bed, 70);
|
||
assert(Math.abs(open - wind.rainAt(70)) < 1e-9,
|
||
`open ground should be exposed exactly as hard as it rains: ${open} vs ${wind.rainAt(70)}`);
|
||
assert(open > 0.5, 'storm_02 at t=70 should be pouring');
|
||
|
||
// a panel over the bed dries it out
|
||
const panel = { pos: new Float32Array([-3, 3, -3, 3, 3, -3, 3, 3, 3, -3, 3, 3]), tris: [0, 1, 2, 0, 2, 3] };
|
||
fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 70 + time, { sail: panel }));
|
||
const covered = sky.gardenExposure(bed, 70);
|
||
assert(covered < open * 0.5, `cloth over the bed barely helped: ${covered.toFixed(2)} vs open ${open.toFixed(2)}`);
|
||
|
||
// and no rain means no drain, sail or not
|
||
assert(sky.gardenExposure(bed, 0) === 0, 'the bed is draining before the rain has started');
|
||
sky.dispose();
|
||
});
|
||
|
||
t.test('storm_02 lights up on its biggest gusts, storm_01 does not', () => {
|
||
const mk = (name) => {
|
||
const scene = new THREE.Scene();
|
||
const wind = createWind(storms[name]);
|
||
const sky = createSkyFx({ scene, camera: new THREE.PerspectiveCamera(), wind });
|
||
let flashes = 0, peak = 0;
|
||
let was = 0;
|
||
fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
|
||
sky.step(dt, time, {});
|
||
if (sky.flash > was + 0.05) flashes++; // a rising edge = a strike
|
||
was = sky.flash;
|
||
peak = Math.max(peak, sky.flash);
|
||
});
|
||
sky.dispose();
|
||
return { flashes, peak };
|
||
};
|
||
const wild = mk('storm_02_wildnight');
|
||
const gentle = mk('storm_01_gentle');
|
||
// 3 authored strikes + the worst gusts; must be more than the authored ones
|
||
assert(wild.flashes > 3, `wild night only flashed ${wild.flashes} times — the big gusts aren't lighting up`);
|
||
assert(gentle.flashes === 0, `the gentle storm flashed ${gentle.flashes} times — it has no lightning at all`);
|
||
});
|
||
|
||
// --- SPRINT12 gate 3.4: the change announces itself ---
|
||
// The corner block's thesis is "looks like night 2 and isn't" — storm_03b's
|
||
// change lands at 18 s, not 30. The tell is a dark front wall standing in the
|
||
// quarter the new wind comes FROM, rising across the 12 s before the change
|
||
// and clearing after the swing. These asserts pin: the window (nothing before
|
||
// the lead opens, gone after the fade), the rise (monotonic, reaches ~full),
|
||
// the DIRECTION (the wall stands in the southern sky — +Z, the open fence
|
||
// side — and the mesh really is rotated there), and that a changeless storm
|
||
// never raises it. D judges whether it READS; this pins that it exists,
|
||
// where it stands, and when.
|
||
t.test('gate 3.4: the front wall rises before the buster, stands in the south, clears after', () => {
|
||
const scene = new THREE.Scene();
|
||
const wind = createWind(storms.storm_03b_earlybuster);
|
||
const sky = createSkyFx({ scene, camera: new THREE.PerspectiveCamera(), wind });
|
||
const ev = storms.storm_03b_earlybuster.events.find((e) => e.type === 'windchange');
|
||
const t0 = ev.t, over = ev.over ?? 6; // 18, 6
|
||
|
||
let prev = 0, peak = 0, azAtChange = null, rotAtChange = null, opAtChange = 0, visAtChange = false;
|
||
fixedLoop(40, FIXED_DT, (dt, time) => {
|
||
sky.step(dt, time, {});
|
||
const f = sky.changeFront;
|
||
assert(Number.isFinite(f) && f >= 0 && f <= 1, `front out of range at t=${time.toFixed(2)}: ${f}`);
|
||
if (time < t0 - 12.01) assert(f === 0, `front up at t=${time.toFixed(2)} — before its lead window opens`);
|
||
if (time > t0 - 12 && time <= t0) {
|
||
assert(f >= prev - 1e-9, `front FELL while rising: ${prev.toFixed(3)} → ${f.toFixed(3)} at t=${time.toFixed(2)}`);
|
||
prev = f;
|
||
if (f > peak) peak = f;
|
||
}
|
||
if (Math.abs(time - t0) < FIXED_DT) {
|
||
azAtChange = sky.changeFrontAz;
|
||
rotAtChange = sky.front.rotation.y;
|
||
opAtChange = sky.front.material.opacity;
|
||
visAtChange = sky.front.visible;
|
||
}
|
||
if (time > t0 + over + 8.1) assert(f === 0, `front still up at t=${time.toFixed(2)} — the swing has long cleared`);
|
||
});
|
||
|
||
assert(peak > 0.98, `front only reached ${peak.toFixed(3)} by the change — it should stand near full`);
|
||
assert(visAtChange && opAtChange > 0.7, `wall not visible at the change (visible=${visAtChange}, opacity=${opAtChange})`);
|
||
|
||
// direction: the wall stands where the new wind comes FROM. dirAt is the
|
||
// heading the wind blows TOWARD, so from = settled post-change dir + π.
|
||
const expected = wind.dirAt(t0 + over + 4) + Math.PI;
|
||
assert(Math.abs(azAtChange - expected) < 1e-9,
|
||
`front azimuth ${azAtChange} is not the settled post-change upwind quarter ${expected}`);
|
||
// and that quarter is the SOUTH: the buster blows toward -Z (the house),
|
||
// so it comes from +Z, the open fence side. sin(az) is the from-vector's z.
|
||
assert(Math.sin(azAtChange) > 0.3,
|
||
`the "southerly" front stands at azimuth ${azAtChange.toFixed(2)} — from-vector z=${Math.sin(azAtChange).toFixed(2)}, not the southern sky`);
|
||
// the mesh is really rotated there (local band centre sits at azimuth π,
|
||
// so rotation.y = π − az carries it to az; see skyfx geometry note)
|
||
assert(Math.abs(rotAtChange - (Math.PI - azAtChange)) < 1e-9,
|
||
`wall rotation ${rotAtChange} does not place the band at azimuth ${azAtChange}`);
|
||
sky.dispose();
|
||
});
|
||
|
||
t.test('gate 3.4: night 2 teaches the same tell, a changeless storm never shows it, and it is pure in t', () => {
|
||
// storm_03 (night 2): same wall, later — up but not full at t=20 (change 30)
|
||
const mk = (name) => createSkyFx({
|
||
scene: new THREE.Scene(), camera: new THREE.PerspectiveCamera(),
|
||
wind: createWind(storms[name]),
|
||
});
|
||
const a = mk('storm_03_southerly'), b = mk('storm_03_southerly');
|
||
let mid = 0;
|
||
fixedLoop(21, FIXED_DT, (dt, time) => {
|
||
a.step(dt, time, {}); b.step(dt, time, {});
|
||
// determinism: two instances at the same t agree to the bit — no hidden
|
||
// clock, no per-instance random draw in the tell
|
||
assert(a.changeFront === b.changeFront,
|
||
`two skyfx disagree on the front at t=${time.toFixed(2)}: ${a.changeFront} vs ${b.changeFront}`);
|
||
mid = a.changeFront;
|
||
});
|
||
assert(mid > 0.05 && mid < 0.95,
|
||
`night 2's wall should be RISING at t=21 (change at 30), reads ${mid.toFixed(3)}`);
|
||
a.dispose(); b.dispose();
|
||
|
||
// the gentle day has no windchange: no wall, ever
|
||
const calm = mk('storm_01_gentle');
|
||
fixedLoop(storms.storm_01_gentle.duration, FIXED_DT, (dt, time) => {
|
||
calm.step(dt, time, {});
|
||
assert(calm.changeFront === 0 && !calm.front.visible,
|
||
`a changeless storm raised the front at t=${time.toFixed(2)}`);
|
||
});
|
||
calm.dispose();
|
||
});
|
||
|
||
// --- SPRINT5 decision 13: hail makes the garden score respond to the rig ---
|
||
// The whole reason hail exists: a perfect rig scored 54% vs 48% for no rig,
|
||
// because honest rain walks under a sail. Steep hail doesn't — a sail over the
|
||
// bed shelters it. This is the gate-2 assert: no-sail hail damage ≥ 2× a good
|
||
// rig's, over the real storm_02 hail, through the real gardenHailExposure.
|
||
t.test('decision 13: no-sail garden takes ≥2× the hail of a well-covered bed', () => {
|
||
const bed = { x: 1, z: 2, w: 6, d: 4 };
|
||
|
||
// a good rig: a quad sitting over the bed, held on rated shackles
|
||
const s = 3.6;
|
||
const anchors = [
|
||
{ id: 'a', pos: { x: -2, y: s, z: -0.5 }, type: 'post', sway() { return this.pos; } },
|
||
{ id: 'b', pos: { x: 4, y: s + 0.4, z: -0.5 }, type: 'post', sway() { return this.pos; } },
|
||
{ id: 'c', pos: { x: 4, y: s, z: 4.5 }, type: 'post', sway() { return this.pos; } },
|
||
{ id: 'd', pos: { x: -2, y: s + 0.4, z: 4.5 }, type: 'post', sway() { return this.pos; } },
|
||
];
|
||
const rig = new SailRig({ anchors, gridN: 10 });
|
||
rig.attach(['a', 'b', 'c', 'd'], [2, 2, 2, 2].map((i) => HARDWARE[i]), 1.0);
|
||
|
||
const damageOverStorm = (getWorld, rigToStep) => {
|
||
const scene = new THREE.Scene();
|
||
const camera = new THREE.PerspectiveCamera();
|
||
camera.position.set(0, 6, 8);
|
||
const wind = createWind(storms.storm_02_wildnight);
|
||
const sky = createSkyFx({ scene, camera, wind });
|
||
let dmg = 0;
|
||
fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
|
||
if (rigToStep) rigToStep.step(dt, wind, time);
|
||
sky.step(dt, time, getWorld());
|
||
dmg += sky.gardenHailExposure(bed, time) * dt;
|
||
});
|
||
const cover = rigToStep ? rigToStep.coverageOver(bed, { x: 0, y: 1, z: 0 }) : 0;
|
||
sky.dispose();
|
||
return { dmg, cover };
|
||
};
|
||
|
||
const open = damageOverStorm(() => ({}), null);
|
||
const covered = damageOverStorm(() => ({ sail: rig }), rig);
|
||
|
||
// sanity: the rig must actually be over the bed, or this proves nothing
|
||
assert(covered.cover > 0.6, `test rig only covers ${(covered.cover * 100).toFixed(0)}% of the bed — fix the quad, not the mechanic`);
|
||
assert(open.dmg > 0, 'no hail damage in the open at all — storm_02 should be pelting');
|
||
const ratio = open.dmg / Math.max(1e-6, covered.dmg);
|
||
assert(ratio >= 2,
|
||
`open bed took only ${ratio.toFixed(1)}× the hail of the covered one — decision 13 wants ≥2×`);
|
||
});
|
||
|
||
t.test('hail exposure needs BOTH hail and no cover; steep shadow, unlike rain', () => {
|
||
const scene = new THREE.Scene();
|
||
const camera = new THREE.PerspectiveCamera();
|
||
const wind = createWind(storms.storm_02_wildnight);
|
||
const sky = createSkyFx({ scene, camera, wind });
|
||
const bed = { x: 0, z: 0, w: 4, d: 3 };
|
||
// a flat panel well above the bed
|
||
const panel = { pos: new Float32Array([-3, 4, -3, 3, 4, -3, 3, 4, 3, -3, 4, 3]), tris: [0, 1, 2, 0, 2, 3] };
|
||
|
||
// storm_01 has no hail: exposure is zero regardless of cover
|
||
const calm = createWind(storms.storm_01_gentle);
|
||
const skyCalm = createSkyFx({ scene: new THREE.Scene(), camera, wind: calm });
|
||
fixedLoop(1, FIXED_DT, (dt, time) => skyCalm.step(dt, 40 + time, {}));
|
||
assert(skyCalm.gardenHailExposure(bed, 40) === 0, 'a hail-free storm still reported hail exposure');
|
||
skyCalm.dispose();
|
||
|
||
// at the wild night's hail peak, open bed is exposed; a panel over it is not
|
||
fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 55 + time, {}));
|
||
const openExp = sky.gardenHailExposure(bed, 56);
|
||
assert(openExp > 0.5, `open bed hail exposure only ${openExp.toFixed(2)} at the burst — should be high`);
|
||
fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 55 + time, { sail: panel }));
|
||
const coveredExp = sky.gardenHailExposure(bed, 56);
|
||
assert(coveredExp < openExp * 0.4, `steep hail still hit the covered bed: ${coveredExp.toFixed(2)} vs open ${openExp.toFixed(2)}`);
|
||
sky.dispose();
|
||
});
|
||
|
||
// The trap that cost gate 0 two sprints, asserted so it cannot come back.
|
||
// skyfx.step() used to open `if (!camera) return`, so a headless harness
|
||
// skipped the shadow rebuild and scored every rig as if the sail weren't
|
||
// there. A camera is a view, not a weather system: the grids are physics and
|
||
// must build without one. If someone re-adds an early return, this goes red.
|
||
t.test('skyfx shelters the bed with NO camera — the grids are physics, not view', () => {
|
||
const bed = { x: 0, z: 0, w: 4, d: 3 };
|
||
const panel = { pos: new Float32Array([-3, 4, -3, 3, 4, -3, 3, 4, 3, -3, 4, 3]), tris: [0, 1, 2, 0, 2, 3] };
|
||
|
||
const run = (withCamera) => {
|
||
const scene = new THREE.Scene();
|
||
const wind = createWind(storms.storm_02_wildnight);
|
||
const sky = createSkyFx(withCamera
|
||
? { scene, camera: new THREE.PerspectiveCamera(), wind }
|
||
: { scene, wind }); // headless: no camera at all
|
||
fixedLoop(2, FIXED_DT, (dt, time) => sky.step(dt, 55 + time, { sail: panel }));
|
||
const out = {
|
||
hailShadow: sky.hailShadowOver(bed),
|
||
rainShadow: sky.rainShadowOver(bed),
|
||
hailExp: sky.gardenHailExposure(bed, 56),
|
||
hail: sky.hailAmount,
|
||
};
|
||
sky.dispose();
|
||
return out;
|
||
};
|
||
|
||
const headless = run(false);
|
||
const viewed = run(true);
|
||
assert(headless.hailShadow > 0.9,
|
||
`no camera → hail shadow ${headless.hailShadow.toFixed(2)} — the grid did not build, the sail is invisible to scoring`);
|
||
assert(headless.hail > 0.5, 'no camera → hail intensity was not tracked');
|
||
assert(headless.hailExp < 0.1,
|
||
`no camera → bed reads ${headless.hailExp.toFixed(2)} exposed under a panel — this is the hp-36 bare-bed bug`);
|
||
// and a camera must not CHANGE the physics, only add the view
|
||
assert(Math.abs(headless.hailShadow - viewed.hailShadow) < 1e-9,
|
||
`the camera changed the hail shadow: ${headless.hailShadow} vs ${viewed.hailShadow}`);
|
||
assert(Math.abs(headless.rainShadow - viewed.rainShadow) < 1e-9,
|
||
`the camera changed the rain shadow: ${headless.rainShadow} vs ${viewed.rainShadow}`);
|
||
});
|
||
|
||
// The card's copy, not just the model — this is what a player actually reads.
|
||
t.test('forecastLines: tonight is exact, a week out hedges, never a bare NaN', () => {
|
||
const def = storms.storm_02_wildnight;
|
||
|
||
const tonight = forecastLines(def, 0);
|
||
assert(tonight.name === 'WILD NIGHT', `name reads "${tonight.name}"`);
|
||
assert(tonight.night === true, 'the wild night should read as a NIGHT');
|
||
assert(!/–/.test(tonight.wind), `tonight's wind should be exact, got "${tonight.wind}"`);
|
||
assert(tonight.confidence === '', 'tonight should not print a confidence line — 100% is noise');
|
||
assert(/hail likely/.test(tonight.rain), `tonight should call the hail: "${tonight.rain}"`);
|
||
|
||
const far = forecastLines(def, 1);
|
||
assert(/–/.test(far.wind), `a week out should hedge with a range, got "${far.wind}"`);
|
||
assert(/confidence/.test(far.confidence), 'a distant forecast should state its confidence');
|
||
|
||
// no NaN/undefined can reach the card, for any storm at any lead
|
||
for (const [name, d] of Object.entries(storms)) {
|
||
for (const lead of [0, 0.5, 1]) {
|
||
const f = forecastLines(d, lead);
|
||
for (const k of ['name', 'wind', 'rain', 'confidence']) {
|
||
assert(typeof f[k] === 'string' && !/NaN|undefined/.test(f[k]),
|
||
`${name} @lead ${lead}: ${k} reads "${f[k]}"`);
|
||
}
|
||
}
|
||
}
|
||
// a hail-free storm must not advertise hail
|
||
assert(!/hail/.test(forecastLines(storms.storm_01_gentle, 0).rain),
|
||
'the gentle storm advertised hail it does not have');
|
||
});
|
||
|
||
// SPRINT11 gate 2: the job sheet shows TOMORROW, and tomorrow becomes tonight.
|
||
// A band that widens or jumps as the night approaches is a card that lied
|
||
// once — so the RESOLVING is the contract, not just the width.
|
||
//
|
||
// What each half is worth, established by mutation (SPRINT11, see THREADS):
|
||
// · NESTING is the live one. It rests on the wander being the SAME seeded
|
||
// draw at every lead — reseed `r` per lead and this goes red immediately.
|
||
// · CONTAINMENT is structural under today's band(): the wander is 0.6 of the
|
||
// half-width, so c±w never crosses v, and the min/max clamps are belt-and-
|
||
// braces. Removing the clamps alone does NOT make it fail. It is kept as a
|
||
// contract guard for a future rewrite of band() (a percentile model, say),
|
||
// where it stops being free — not as proof of today's code.
|
||
t.test('the forecast band resolves as the night approaches: nests, and never sheds the truth', () => {
|
||
const keys = [
|
||
['sustained', (s) => s.sustained],
|
||
['gustPeak', (s) => s.gustPeak],
|
||
['rain', (s) => s.rainPeak],
|
||
['rainMmPerHour', (s) => s.rainPeakMmPerHour],
|
||
];
|
||
// gate 3.3: the stone-size band obeys the same law as every other number
|
||
// on the card — nested, truth-holding, exact at lead 0. It lives at
|
||
// f.hail.size, so the key row carries its own accessor.
|
||
keys.push(['hail.size', (s) => s.hailSize, (f) => f.hail.size]);
|
||
for (const [name, def] of Object.entries(storms)) {
|
||
for (const [k, truthOf, getBand] of keys) {
|
||
let prev = null;
|
||
// walk the week backwards: the far end -> tonight
|
||
for (let i = 40; i >= 0; i--) {
|
||
const f = forecastFor(def, i / 40);
|
||
const b = getBand ? getBand(f) : f[k];
|
||
const truth = truthOf(f.truth);
|
||
assert(b.lo <= truth + 1e-9 && b.hi >= truth - 1e-9,
|
||
`${name}.${k} @lead ${i / 40}: band ${b.lo}–${b.hi} rules out the truth ${truth}`);
|
||
if (prev) {
|
||
assert(b.lo >= prev.lo - 1e-9 && b.hi <= prev.hi + 1e-9,
|
||
`${name}.${k} @lead ${i / 40}: band ${b.lo.toFixed(3)}–${b.hi.toFixed(3)} is not inside `
|
||
+ `the vaguer ${prev.lo.toFixed(3)}–${prev.hi.toFixed(3)} — it jumped instead of resolving`);
|
||
}
|
||
prev = b;
|
||
}
|
||
// and it must ARRIVE: tonight is the truth, not merely a narrow band
|
||
const exact = forecastFor(def, 0);
|
||
const eb = getBand ? getBand(exact) : exact[k];
|
||
assert(eb.lo === eb.hi,
|
||
`${name}.${k}: tonight should be exact, got ${eb.lo}–${eb.hi}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
t.test('leadFor: tonight is exact, the far end of the week is vague, past it clamps', () => {
|
||
assert(leadFor(0, 5) === 0, 'tonight must be lead 0 — the job sheet is not a guess');
|
||
assert(leadFor(4, 5) === 1, "the week's far end must be lead 1");
|
||
assert(leadFor(1, 5) === 0.25, `tomorrow @5 nights should be 0.25, got ${leadFor(1, 5)}`);
|
||
assert(leadFor(9, 5) === 1, 'beyond the week must clamp, not exceed 1');
|
||
assert(leadFor(-1, 5) === 0, 'a night already survived must not go negative');
|
||
assert(leadFor(1, 1) === 1, 'a one-night week has no far end: anything but tonight is a guess');
|
||
// the point of the param: tomorrow reads hedged, tonight does not
|
||
const def = storms.storm_02_wildnight;
|
||
assert(!/–/.test(forecastLines(def, leadFor(0, 5)).wind), 'tonight should not hedge');
|
||
assert(/–/.test(forecastLines(def, leadFor(1, 5)).wind),
|
||
`tomorrow should hedge, got "${forecastLines(def, leadFor(1, 5)).wind}"`);
|
||
});
|
||
|
||
t.test('every storm in data/storms/ loads and validates', () => {
|
||
// loadStorm throws on invalid, so reaching here with all of them is the pass
|
||
assert(Object.keys(storms).length === STORMS.length, 'a storm failed to load');
|
||
for (const [name, def] of Object.entries(storms)) {
|
||
assert(def.duration > 0, `${name} has no duration`);
|
||
assert(Array.isArray(def.baseCurve), `${name} has no baseCurve`);
|
||
}
|
||
});
|
||
|
||
// =========================================================================
|
||
// GATE 2.3 — THE EDITOR'S WIND IS THE GAME'S WIND, PINNED EXACT
|
||
//
|
||
// Co-owned with Lane B. B proposed backyard_01 / storm_02_wildnight /
|
||
// (1,0,2) / t=30; I moved all three and posted the receipts in THREADS, for
|
||
// one reason: **B's pin could not have failed.**
|
||
// · backyard_01's `wind.venturi` is `[]`. On that site "setVenturi called
|
||
// with an empty list" and "setVenturi never called" are the same number,
|
||
// so the funnel-off regression the pin exists to catch is invisible.
|
||
// · the garden bed at (1,0,2) is outside the funnel radius even on
|
||
// site_02 — measured Δ 0.0000 m/s.
|
||
// · t=30 is a second where the wildnight's direction does not line up with
|
||
// the gap: at the throat the funnel is worth 0.4% there (0.0% on the
|
||
// southerly). A pin at 0.4% passes with the funnel wired backwards.
|
||
// So: the ONLY site with a shipped venturi, the throat centre (authored site
|
||
// geometry, not a magic number), and a second on the alignment plateau where
|
||
// the funnel is worth a THIRD of the answer. Same storm B picked, same exact
|
||
// `===`. The vacuity guard below is what stops this pin rotting back into
|
||
// decoration — I am the lane that shipped half an assert made of decoration,
|
||
// so the guard is not optional.
|
||
const PIN = {
|
||
site: 'site_02_corner_block',
|
||
storm: 'storm_02_wildnight',
|
||
probe: { x: -6, y: 0, z: 0 }, // the authored throat centre; speedAt ignores y
|
||
t: 60.0,
|
||
};
|
||
|
||
const pinSite = await loadSite(PIN.site);
|
||
let pinWorld = null;
|
||
try {
|
||
pinWorld = createWorld(new THREE.Scene(), {
|
||
wind: createStubWind({ calm: true }), site: structuredClone(pinSite),
|
||
});
|
||
await pinWorld.dress();
|
||
} catch (err) {
|
||
console.warn('[c.test] gate 2.3: dress() unavailable, using graybox anchors:', err.message);
|
||
}
|
||
const pinAnchors = pinWorld ? pinWorld.anchors : [];
|
||
const pinProbe = new THREE.Vector3(PIN.probe.x, PIN.probe.y, PIN.probe.z);
|
||
|
||
/** The EDITOR's wind: `windForSite` off the site object, exactly as
|
||
* editor.wind.js's `buildWind()` and B's SCORE IT both build it. */
|
||
const editorWind = () => windForSite(storms[PIN.storm], structuredClone(pinSite), pinAnchors);
|
||
|
||
/** The GAME's wind: main.js's router, wired by main.js's own two lines
|
||
* (loadSiteInto, `wind.setVenturi(...)` + `wind.setSheltersFromTrees(...)`). */
|
||
const gameWind = () => {
|
||
const all = STORMS.map((k) => createWind(storms[k]));
|
||
const router = createWindRouter(all);
|
||
router.use(all[STORMS.indexOf(PIN.storm)]);
|
||
router.setVenturi(pinSite.wind?.venturi ?? []);
|
||
router.setSheltersFromTrees(pinAnchors.filter((a) => a.type === 'tree'));
|
||
return router;
|
||
};
|
||
|
||
t.test('GATE 2.3: editor wind === game wind at the pinned probe and second (exact)', () => {
|
||
const ed = editorWind().speedAt(pinProbe, PIN.t);
|
||
const gm = gameWind().speedAt(pinProbe, PIN.t);
|
||
assert(Number.isFinite(ed) && ed > 0, `the pin sampled nothing: ${ed} m/s — vacuous`);
|
||
// EXACT, per B: two chains that agree to 1e-9 agree, and any epsilon big
|
||
// enough to feel safe is big enough to hide a 33% funnel.
|
||
assert(ed === gm,
|
||
`editor ${ed} m/s vs game ${gm} m/s at (${PIN.probe.x},${PIN.probe.z}) t=${PIN.t} on `
|
||
+ `${PIN.site}/${PIN.storm} — the editor is scoring a yard the game does not play`);
|
||
// the vector too, so a sign or a component can't drift under an equal scalar
|
||
const a = editorWind().sample(pinProbe, PIN.t, new THREE.Vector3());
|
||
const b = gameWind().sample(pinProbe, PIN.t, new THREE.Vector3());
|
||
assert(a.x === b.x && a.y === b.y && a.z === b.z,
|
||
`vector mismatch: editor (${a.x},${a.y},${a.z}) vs game (${b.x},${b.y},${b.z})`);
|
||
});
|
||
|
||
t.test('GATE 2.3 guard: the pinned probe/second is one the funnel actually decides', () => {
|
||
// Without this, the pin above passes just as happily at a probe the venturi
|
||
// never reaches — which is exactly how three harnesses measured a funnel-off
|
||
// yard and believed it. Re-measure the funnel's worth AT THE PIN, and demand
|
||
// it is a big fraction of the answer.
|
||
const full = editorWind().speedAt(pinProbe, PIN.t);
|
||
const funnelOff = createWind(storms[PIN.storm]);
|
||
funnelOff.setSheltersFromTrees(pinAnchors.filter((a) => a.type === 'tree')); // shelters ON
|
||
const off = funnelOff.speedAt(pinProbe, PIN.t); // venturi OFF
|
||
const share = (full - off) / full;
|
||
assert(share > 0.25,
|
||
`the venturi is worth only ${(share * 100).toFixed(1)}% of the wind at the pinned probe/second `
|
||
+ `(${full.toFixed(3)} vs ${off.toFixed(3)} m/s). The pin still passes — that is the problem. `
|
||
+ 'Move the probe/second back onto the funnel or the gate is decoration.');
|
||
});
|
||
|
||
t.test('GATE 2.3 (wider): editor and game agree across the yard and the storm', () => {
|
||
// The single pin is a point. Shelters live nowhere near the throat, so a
|
||
// point at the throat cannot see them: this sweep is what covers the tree
|
||
// half of `windForSite`'s wiring.
|
||
const ed = editorWind();
|
||
const gm = gameWind();
|
||
let checked = 0;
|
||
let sheltered = 0;
|
||
const bare = createWind(storms[PIN.storm]); // no shelters, no venturi
|
||
for (const time of [20, 45, 60, 75]) {
|
||
for (let x = -11; x <= 11; x += 1.5) {
|
||
for (let z = -7; z <= 7; z += 1.5) {
|
||
const p = new THREE.Vector3(x, 0, z);
|
||
const a = ed.speedAt(p, time);
|
||
const b = gm.speedAt(p, time);
|
||
assert(a === b, `editor ${a} vs game ${b} at (${x},${z}) t=${time}`);
|
||
if (a < bare.speedAt(p, time) * 0.95) sheltered++;
|
||
checked++;
|
||
}
|
||
}
|
||
}
|
||
// This floor caught its own sweep on the first run: at a 2 m step the grid
|
||
// was 384 samples, not the >500 I had claimed. Densified to 1.5 m rather
|
||
// than dropping the number to fit — a coverage floor edited down to match
|
||
// whatever the loop happened to do is not a floor.
|
||
assert(checked > 500, `only ${checked} samples — the sweep is not sweeping`);
|
||
assert(sheltered > 0,
|
||
'not one sampled point was materially slowed — the tree shelters are not reaching either '
|
||
+ 'chain, so this sweep proves nothing about setSheltersFromTrees');
|
||
});
|
||
|
||
// =========================================================================
|
||
// GATE 2.2 (SPRINT15) — ONE STORM PICKER: the shared selection store
|
||
//
|
||
// Sprint 14's page had two pickers with two defaults, and D scored the
|
||
// wildnight while looking at the southerly. editor_storm.js is the one
|
||
// owner now; these asserts pin its contract. The DOM half (my select and
|
||
// scrubber writing/reading it) cannot run here and is verified live on the
|
||
// editor page — said plainly rather than papered over with a fake DOM.
|
||
|
||
t.test('GATE 2.2: the shared storm selection refuses garbage, is silent on no-ops, and opens on the CONVERGED default', () => {
|
||
// The default is the pair B and C agreed in THREADS (B's storm — the
|
||
// authoring case: the wind overlay is live at page-open and the southerly
|
||
// makes the funnel scream; my second — t=40 is the S14 receipt's align-100%
|
||
// moment). Pinned HERE so neither lane's refactor can silently regrow a
|
||
// private default: two defaults was the whole bug.
|
||
assert(SEL_STORM_KEYS.includes(DEFAULT_SEL.storm), 'the default storm is not a real storm');
|
||
assert(DEFAULT_SEL.storm === 'storm_03_southerly' && DEFAULT_SEL.time === 40,
|
||
`the default selection (${DEFAULT_SEL.storm} @ ${DEFAULT_SEL.time}s) has drifted off the `
|
||
+ 'converged storm_03_southerly @ 40s — that pair is a B+C agreement (THREADS 2026-07-20), '
|
||
+ 'renegotiate it there before moving it here');
|
||
|
||
const s = createStormSel();
|
||
assert(s.storm === DEFAULT_SEL.storm && s.time === DEFAULT_SEL.time, 'a fresh store ignores the default');
|
||
|
||
const seen = [];
|
||
const off = s.onChange((p) => seen.push(p));
|
||
|
||
// a real change fires exactly once, payload matches the getters, and the
|
||
// writer's `source` tag (B's ask) rides the payload for debugging
|
||
assert(s.setStorm('storm_02_wildnight', 'c.test') === true, 'a real storm change should return true');
|
||
assert(seen.length === 1 && seen[0].storm === 'storm_02_wildnight' && seen[0].time === DEFAULT_SEL.time,
|
||
`one change should mean one event with the new value, got ${JSON.stringify(seen)}`);
|
||
assert(seen[0].source === 'c.test',
|
||
`the payload should carry the writer's source tag, got ${JSON.stringify(seen[0])}`);
|
||
|
||
// no-op writes are SILENT — this is what lets a subscriber clamp the
|
||
// selection from inside its own change handler without ringing forever
|
||
assert(s.setStorm('storm_02_wildnight') === false, 'a no-op storm write should return false');
|
||
assert(s.setTime(s.time) === false, 'a no-op time write should return false');
|
||
assert(seen.length === 1, `a no-op fired a listener — the anti-loop guard is gone (${seen.length} events)`);
|
||
|
||
// garbage refused, loudly, without moving the selection or ringing
|
||
for (const bad of ['storm_99_nope', '', null]) {
|
||
let threw = false;
|
||
try { s.setStorm(bad); } catch { threw = true; }
|
||
assert(threw, `setStorm(${JSON.stringify(bad)}) was accepted — a selection that can hold a `
|
||
+ 'storm that does not exist is the _v1-404 bug one layer up');
|
||
}
|
||
for (const bad of [NaN, -1, Infinity, 'soon']) {
|
||
let threw = false;
|
||
try { s.setTime(bad); } catch { threw = true; }
|
||
assert(threw, `setTime(${JSON.stringify(bad)}) was accepted`);
|
||
}
|
||
assert(s.storm === 'storm_02_wildnight' && seen.length === 1,
|
||
'refused garbage moved the selection or fired a listener anyway');
|
||
|
||
// a throwing listener must not sever delivery to the others
|
||
const order = [];
|
||
s.onChange(() => { order.push('a'); throw new Error('deliberate'); });
|
||
s.onChange(() => order.push('b'));
|
||
s.setTime(12.5);
|
||
assert(order.join('') === 'ab', `a throwing listener severed its neighbours (delivered: ${order})`);
|
||
|
||
// unsubscribe works: `seen` has the storm change + the 12.5 s write = 2,
|
||
// and the write AFTER off() must not add a third
|
||
off();
|
||
s.setTime(20);
|
||
assert(seen.length === 2, `unsubscribed listener still delivered (${seen.length} events)`);
|
||
});
|
||
|
||
t.test('GATE 2.2: one singleton, one storm list — every consumer holds the same objects', () => {
|
||
// Import identity is the sharing mechanism, so it is the thing to pin.
|
||
assert(stormSel() === stormSel(), 'stormSel() built two stores — the page is back to two pickers');
|
||
assert(globalThis.EDITOR_STORM === stormSel(),
|
||
"the console handle (B's EDITOR_STORM name, converged) points at a different store");
|
||
// editor.wind.js re-exports the store's list — SAME ARRAY, not a copy that
|
||
// can drift (two copies of this list is how the page grew two pickers).
|
||
assert(WIND_STORM_KEYS === SEL_STORM_KEYS, 'editor.wind.js carries its own storm list again');
|
||
// and the shared list agrees with this suite's (which the node runner
|
||
// keeps honest against data/storms/)
|
||
const a = [...SEL_STORM_KEYS].sort().join(',');
|
||
const b = [...STORMS].sort().join(',');
|
||
assert(a === b, `the shared storm list and the suite's disagree:\n store: ${a}\n suite: ${b}`);
|
||
});
|
||
|
||
t.test('worst-second finder: deterministic, self-consistent, and it cannot miss the pinned throat moment', () => {
|
||
// The JUMP TO WORST SECOND button (SPRINT15 polish) is a claim about the
|
||
// whole storm, so it gets the same discipline as any other number.
|
||
const wind = editorWind(); // site_02 × wildnight, dressed
|
||
const yard = pinSite.yard ?? { width: 30, depth: 20 };
|
||
const args = {
|
||
width: yard.width, depth: yard.depth,
|
||
probes: [{ x: PIN.probe.x, z: PIN.probe.z }], // the throat, as the panel passes it
|
||
};
|
||
const a = worstSecond(wind, args);
|
||
const b = worstSecond(wind, args);
|
||
assert(a.t === b.t && a.speed === b.speed && a.x === b.x && a.z === b.z,
|
||
`two identical scans disagreed: ${JSON.stringify(a)} vs ${JSON.stringify(b)} — the scan is not pure`);
|
||
assert(Number.isFinite(a.speed) && a.speed > 0, `vacuous scan: ${JSON.stringify(a)}`);
|
||
// the reported worst is a real sample, not an accumulator artefact
|
||
assert(a.speed === wind.core.speedAt(a.x, a.z, a.t),
|
||
`worst.speed ${a.speed} ≠ speedAt(worst) ${wind.core.speedAt(a.x, a.z, a.t)}`);
|
||
// dominance at a point ON the scan lattice: the throat probe is scanned
|
||
// exactly, and t=60.0 sits on the 0.5 s lattice, so the argmax reporting
|
||
// anything BELOW the pinned throat moment means the comparator or the
|
||
// probe wiring is broken. (An off-lattice point could legitimately exceed
|
||
// the scan; an on-lattice one cannot.)
|
||
const throatAtPin = wind.core.speedAt(PIN.probe.x, PIN.probe.z, PIN.t);
|
||
assert(a.speed >= throatAtPin,
|
||
`worst ${a.speed} m/s < throat@pin ${throatAtPin} m/s — the scan missed a lattice point it was given`);
|
||
// probes are WIRED, proven by making them the only way to win: a tiny grid
|
||
// pinned to the yard centre sits > 6 m from the throat — outside the
|
||
// funnel's 4 m radius, so no grid point ever sees the gain — while the
|
||
// throat probe does. If the probe list is dropped, the winner cannot be at
|
||
// the throat. (Deterministic: same scan, same winner, every run.)
|
||
const c = worstSecond(wind, { width: 4, depth: 4, step: 3, probes: [{ x: PIN.probe.x, z: PIN.probe.z }] });
|
||
assert(c.x === PIN.probe.x && c.z === PIN.probe.z,
|
||
`with the grid held off the funnel, the throat probe should win the scan — winner was `
|
||
+ `(${c.x}, ${c.z}) at ${c.speed} m/s, so the probes argument is not reaching the lattice`);
|
||
});
|
||
|
||
// =========================================================================
|
||
// The gizmos tell the truth about the maths they draw
|
||
|
||
t.test("shelter volume: the drawn attenuation IS weather.core's shelterFactor", () => {
|
||
// editor.wind.js draws the tree-shelter volume from `shelterAtten`, which
|
||
// mirrors weather.core's shelterFactor. Mirrors drift, and a gizmo that
|
||
// disagrees with the sim while looking authoritative is worse than none —
|
||
// so measure the mirror against the real field instead of trusting it.
|
||
// speedAt is multiplicative (uniform × noise × shelter × venturi), so the
|
||
// ratio of a sheltered field to an unsheltered one at the same (x,z,t) IS
|
||
// shelterFactor, with the noise divided out.
|
||
const def = storms.storm_02_wildnight;
|
||
const S = { x: 2, z: -3, radius: 3, strength: 0.45, length: 14 };
|
||
const withS = createWind(def).setShelters([S]);
|
||
const noS = createWind(def).setShelters([]);
|
||
let compared = 0;
|
||
let sawRealShadow = false;
|
||
for (const time of [12, 33, 58, 71]) {
|
||
const d = withS.core.dirAt(time);
|
||
const dx = Math.cos(d), dz = Math.sin(d);
|
||
for (let along = 0.5; along <= 13; along += 1.5) {
|
||
for (let perp = -2.5; perp <= 2.5; perp += 1.25) {
|
||
const x = S.x + dx * along - dz * perp;
|
||
const z = S.z + dz * along + dx * perp;
|
||
const bare = noS.core.speedAt(x, z, time);
|
||
if (bare <= 1e-9) continue;
|
||
const factor = withS.core.speedAt(x, z, time) / bare;
|
||
assertClose(factor, 1 - shelterAtten(S, along, perp), 1e-9,
|
||
`shelterAtten disagrees with the sim at along=${along} perp=${perp} t=${time} — `
|
||
+ 'the drawn volume is not the shadow the wind actually casts');
|
||
if (shelterAtten(S, along, perp) > 0.1) sawRealShadow = true;
|
||
compared++;
|
||
}
|
||
}
|
||
}
|
||
assert(compared > 100, `only ${compared} comparisons`);
|
||
assert(sawRealShadow, 'never sampled a point the shelter actually shades — vacuous');
|
||
});
|
||
|
||
t.test('the venturi axis is a LINE: axis and axis+π are the same gap', () => {
|
||
// The fact the editor's UI teaches, asserted rather than asserted-in-a-
|
||
// comment. weather.core aligns on |dot(wind, axis)|, so adding π must change
|
||
// nothing anywhere. If this ever fails, the axis has quietly become a
|
||
// heading and the mod-π UI is lying to whoever authored against it.
|
||
const def = storms.storm_02_wildnight;
|
||
const v = pinSite.wind.venturi[0];
|
||
const mk = (axis) => windForSite(def, { ...pinSite, wind: { venturi: [{ ...v, axis }] } }, pinAnchors);
|
||
const a = mk(v.axis);
|
||
const b = mk(v.axis + Math.PI);
|
||
let maxDiff = 0;
|
||
let sawFunnel = false;
|
||
const plain = createWind(def).setSheltersFromTrees(pinAnchors.filter((n) => n.type === 'tree'));
|
||
for (const time of [30, 60, 60.5, 75]) {
|
||
for (let x = -10; x <= -2; x += 1) {
|
||
for (let z = -4; z <= 4; z += 1) {
|
||
const p = new THREE.Vector3(x, 0, z);
|
||
const sa = a.speedAt(p, time);
|
||
maxDiff = Math.max(maxDiff, Math.abs(sa - b.speedAt(p, time)));
|
||
if (sa > plain.speedAt(p, time) * 1.05) sawFunnel = true;
|
||
}
|
||
}
|
||
}
|
||
assert(sawFunnel, 'the funnel never fired anywhere in the sweep — this proves nothing');
|
||
// not `===`: cos(θ+π) is only -cos(θ) to within floating point, so the two
|
||
// fields agree to rounding, not to the bit. Rounding is the honest bar here.
|
||
assert(maxDiff < 1e-9,
|
||
`axis and axis+π gave different winds (max ${maxDiff} m/s) — the venturi is not mod π`);
|
||
});
|
||
|
||
t.test('normalizeAxis folds any angle into [0, π) without moving the gap', () => {
|
||
const cases = [2.1, 2.1 + Math.PI, 2.1 - Math.PI, -1.08, 0, 7 * Math.PI, -3 * Math.PI / 4];
|
||
for (const raw of cases) {
|
||
const n = normalizeAxis(raw);
|
||
assert(n >= 0 && n < Math.PI, `normalizeAxis(${raw}) = ${n} is outside [0, π)`);
|
||
// same LINE: the direction vectors must be parallel or antiparallel
|
||
const cross = Math.cos(raw) * Math.sin(n) - Math.sin(raw) * Math.cos(n);
|
||
assertClose(cross, 0, 1e-12, `normalizeAxis(${raw}) rotated the gap instead of folding it`);
|
||
}
|
||
// the Sprint 11 pair, which cost two exchanges: 2.1 is the gap, -1.08 is the
|
||
// southerly's heading, and they are NOT the same number — 2.2° apart.
|
||
const gap = normalizeAxis(2.1);
|
||
const heading = normalizeAxis(-1.08);
|
||
assert(Math.abs(gap - heading) > 0.03,
|
||
'the gap axis and the southerly heading have collapsed to the same value — they are '
|
||
+ 'different quantities that merely nearly coincide, and conflating them is the '
|
||
+ 'mistake THREADS records A and C both nearly making');
|
||
});
|
||
|
||
// =========================================================================
|
||
// SPRINT16 GATE 3.3 — the stone and the rain rate join the card
|
||
t.test('gate 3.3: forecast stones and rain rate — worded, banded, exact tonight, never NaN', () => {
|
||
const soaker = storms.storm_06_soaker;
|
||
const tonight = forecastLines(soaker, 0);
|
||
// tonight is exact: the soaker's truth, in the fabric bet's own words
|
||
assert(tonight.stones === 'fine pea stones',
|
||
`the soaker's stones read "${tonight.stones}" — size 0.45 is the fine pea the cloth leaks, `
|
||
+ 'and the card is where the player learns that before spending a dollar');
|
||
assert(tonight.rainRate === 'rain to 44 mm/hr',
|
||
`the soaker's rain rate reads "${tonight.rainRate}" — want the exact 44 mm/hr at lead 0`);
|
||
assert(/hail likely/.test(tonight.rain),
|
||
`the soaker (14.9 hail-seconds) should call its hail likely: "${tonight.rain}"`);
|
||
// the sizes cloth stops dead read as the big words
|
||
assert(forecastLines(storms.storm_02_wildnight, 0).stones === 'golf ball stones',
|
||
`wild night stones read "${forecastLines(storms.storm_02_wildnight, 0).stones}"`);
|
||
assert(forecastLines(storms.storm_03_southerly, 0).stones === 'pea stones',
|
||
`southerly stones read "${forecastLines(storms.storm_03_southerly, 0).stones}" — 0.7 is `
|
||
+ "storm_03's own comment's word");
|
||
// a hail-free storm advertises nothing
|
||
assert(forecastLines(storms.storm_01_gentle, 0).stones === '',
|
||
'the gentle storm advertised stones it does not have');
|
||
// far out, the rate hedges as a band (the stone word may or may not span
|
||
// two words — the BAND honesty is pinned in the resolving test above)
|
||
const far = forecastLines(soaker, 1);
|
||
assert(/–/.test(far.rainRate), `a week out the rain rate should hedge, got "${far.rainRate}"`);
|
||
assert(/fine pea/.test(far.stones), `the soaker a week out still forecasts pea-family stones, got "${far.stones}"`);
|
||
// no NaN/undefined reaches the card, any storm, any lead
|
||
for (const [name, d] of Object.entries(storms)) {
|
||
for (const lead of [0, 0.5, 1]) {
|
||
const f = forecastLines(d, lead);
|
||
for (const k of ['stones', 'rainRate']) {
|
||
assert(typeof f[k] === 'string' && !/NaN|undefined/.test(f[k]),
|
||
`${name} @lead ${lead}: ${k} reads "${f[k]}"`);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// =========================================================================
|
||
// SPRINT16 GATE 3.2 — the generator satisfies the storm-side harness
|
||
t.test('gate 3.2: a generated storm flies the storm_envelope harness, deterministically', () => {
|
||
const anchors = [
|
||
{ id: 'a', type: 'post', pos: { x: 0, y: 4, z: 0 } },
|
||
{ id: 'b', type: 'post', pos: { x: 5, y: 4, z: 3 }, ratingHint: 0.45 },
|
||
];
|
||
const r1 = stormEnvelope({ anchors, stormDef: makeStorm(7) });
|
||
const r2 = stormEnvelope({ anchors, stormDef: makeStorm(7) });
|
||
for (const r of r1.rows) {
|
||
assert(Number.isFinite(r.peak) && r.peak > 0, `vacuous envelope row for ${r.id}: peak ${r.peak}`);
|
||
assert(Number.isFinite(r.dose) && r.dose > 0, `vacuous dose for ${r.id}`);
|
||
assert(r.tPeak >= 0 && r.tPeak <= 90, `${r.id} peaked outside the storm at t=${r.tPeak}`);
|
||
}
|
||
r1.rows.forEach((r, i) => {
|
||
const s = r2.rows[i];
|
||
assert(r.peak === s.peak && r.tPeak === s.tPeak && r.dose === s.dose,
|
||
`same seed, two envelope runs disagree at ${r.id}: ${r.peak} vs ${s.peak}`);
|
||
});
|
||
// hint reaches the ranking exactly as it does for authored storms
|
||
assert(r1.rows[0].id === 'b',
|
||
`equal-ish wind, hint 0.45 vs 1.0 — 'b' must rank first, got ${r1.rows.map((r) => r.id).join(',')}`);
|
||
// and the seed is a real variable through the harness, not just the JSON
|
||
const other = stormEnvelope({ anchors, stormDef: makeStorm(8) });
|
||
assert(other.rows.some((r, i) => r.peak !== r1.rows[i].peak),
|
||
'seeds 7 and 8 measured identically through the harness — the seed is not reaching the wind');
|
||
});
|
||
|
||
// =========================================================================
|
||
// SPRINT16 GATE 3.1 — THE MEMBRANE NIGHT, PINNED THROUGH THE REAL CHAIN
|
||
//
|
||
// storm_06_soaker exists to make the F key matter: pea stones under the
|
||
// cloth aperture, heavy rain, mild wind. The pin flies gardenfly — THE
|
||
// audit scoring chain (windForSite → attach → skyfx → garden.js), dressed
|
||
// site_02 anchors, live sway, funnel ON — with porosity the ONLY variable
|
||
// between the cloth and membrane flights. Numbers measured 2026-07-20
|
||
// (fabric_bet probe, post-ruling tree): bare 17.5 · cloth $75 ring 42.3 ·
|
||
// membrane $75 ring 96.5 · membrane $60 ring 68.8. Thresholds pin the
|
||
// BOUNDARIES the night stakes, with margin, not the digits.
|
||
const soakerDef = storms.storm_06_soaker;
|
||
const soakerSite = site02; // loaded above for gate 2.3
|
||
let soakerFlights = null;
|
||
{
|
||
// garden_probe's proxy pattern: world.anchors fly THEMSELVES, and `use`
|
||
// re-points the sway closures at the storm actually flying (the frozen-
|
||
// tree landmine — tr1/tr1b are the pin's own corners, they must sway).
|
||
let cur = createStubWind({ calm: true });
|
||
const proxy = {
|
||
sample: (p, t2, o) => cur.sample(p, t2, o || new THREE.Vector3()),
|
||
speedAt: (p, t2) => cur.speedAt(p, t2),
|
||
rainAt: (t2) => (cur.rainAt ? cur.rainAt(t2) : 0),
|
||
rainMmPerHour: (t2) => (cur.rainMmPerHour ? cur.rainMmPerHour(t2) : 0),
|
||
gustTelegraph: (t2) => (cur.gustTelegraph ? cur.gustTelegraph(t2) : null),
|
||
eventsBetween: (a, b) => (cur.eventsBetween ? cur.eventsBetween(a, b) : []),
|
||
setSheltersFromTrees() {},
|
||
};
|
||
const use = (w) => { cur = w; };
|
||
try {
|
||
const world = createWorld(new THREE.Scene(), { wind: proxy, site: structuredClone(soakerSite) });
|
||
await world.dress(); // graybox would move the tree corners — fail loud instead
|
||
const bed = world.gardenBed;
|
||
const ring = ['q1', 'q2', 'q3', 'q4'];
|
||
const hw75 = ['rated shackle', 'shackle', 'shackle', 'shackle'].map(hardwareByName);
|
||
const hw60 = ['shackle', 'shackle', 'shackle', 'shackle'].map(hardwareByName);
|
||
const hw20 = Array(4).fill(hardwareByName('carabiner'));
|
||
const common = { anchors: world.anchors, bed, stormDef: soakerDef, siteDef: soakerSite, use };
|
||
soakerFlights = {
|
||
bare: flyGarden({ ...common }),
|
||
cloth75: flyGarden({ ...common, ids: ring, hw: hw75, porosity: 0.30 }),
|
||
mem75: flyGarden({ ...common, ids: ring, hw: hw75, porosity: 0 }),
|
||
mem60: flyGarden({ ...common, ids: ring, hw: hw60, porosity: 0 }),
|
||
cloth20: flyGarden({ ...common, ids: ring, hw: hw20, porosity: 0.30 }),
|
||
mem20: flyGarden({ ...common, ids: ring, hw: hw20, porosity: 0 }),
|
||
};
|
||
} catch (err) {
|
||
soakerFlights = { error: err };
|
||
}
|
||
}
|
||
|
||
t.test('GATE 3.1: the soaker flips the fabric bet — membrane wins the night cloth loses', () => {
|
||
if (soakerFlights?.error) throw soakerFlights.error;
|
||
const { bare, cloth75, mem75, mem60 } = soakerFlights;
|
||
|
||
// vacuity guards first: the night must actually threaten, through hail
|
||
assert(bare.byHail > 40,
|
||
`bare bed took only ${bare.byHail} HP of hail — the soaker has stopped soaking and this pin proves nothing`);
|
||
assert(bare.hp < 30, `bare bed survived at ${bare.hp} — the night has no teeth`);
|
||
|
||
// the primitive the whole night stands on: these stones pass a 2 mm weave
|
||
const size = soakerDef.hail.size;
|
||
assert(hailBlockFor(size, 0.30) < 0.30,
|
||
`cloth blocks ${(hailBlockFor(size, 0.30) * 100).toFixed(0)}% of size-${size} stones — the soaker's `
|
||
+ 'hail has drifted above the weave aperture and the bet is dead');
|
||
assert(hailBlockFor(size, 0) === 1, 'membrane stopped blocking — hailBlockFor broke');
|
||
|
||
// CLOTH LOSES: the best measured cloth line in the yard (this ring, $75)
|
||
// ends under the win line — the default fabric is the wrong answer.
|
||
assert(cloth75.hp < 50,
|
||
`the $75 ring on CLOTH reads ${cloth75.hp} — over the win line, so the default fabric wins the `
|
||
+ 'membrane night and storm_06 is a decoration (retune the hail spectrum, per the gate)');
|
||
// ...but visibly beats bare: the cloth is leaking, not absent
|
||
assert(cloth75.hp > bare.hp + 10,
|
||
`cloth ${cloth75.hp} vs bare ${bare.hp} — cloth is doing nothing; the leak should be partial`);
|
||
|
||
// MEMBRANE WINS, same line, one variable: the F key is the night
|
||
assert(mem75.hp > 90 && mem75.state === 'full',
|
||
`the SAME $75 ring on MEMBRANE reads ${mem75.hp} ${mem75.state} — want >90 FULL (measured 96.5)`);
|
||
assert(mem75.cornersLost < 2,
|
||
`membrane $75 lost ${mem75.cornersLost} corners — main.js's win rule (lost<2) fails, the load is not affordable`);
|
||
assert(mem75.cost <= 80, `the membrane line costs $${mem75.cost} — the $80 wallet cannot buy the night's answer`);
|
||
|
||
// and the bet is affordable a tier down: the $60 ring also wins on membrane
|
||
// (measured 68.8 FULL, one pond corner late — the broom's job in play)
|
||
assert(mem60.hp > 60 && mem60.cornersLost < 2,
|
||
`the $60 ring on membrane reads ${mem60.hp} with ${mem60.cornersLost} lost — the bet should not `
|
||
+ 'need the last dollar of the wallet');
|
||
|
||
// THE SEPARATION, pinned: same line, same steel, fabric alone is worth
|
||
// most of a garden (measured Δ54.2 — threshold carries real margin)
|
||
assert(mem75.hp - cloth75.hp > 40,
|
||
`membrane-vs-cloth separation ${(mem75.hp - cloth75.hp).toFixed(1)} HP on the same $75 line — `
|
||
+ 'under 40, the F key has stopped being the night');
|
||
});
|
||
|
||
t.test('GATE 3.1 guard: membrane is not a free win — on cheap steel it tears', () => {
|
||
// The inverse control, so the night cannot rot into "always press F":
|
||
// the doubled load must make membrane WORSE than cloth on carabiners.
|
||
// (Measured: cloth $20 ring 24.5 hp / 2 lost · membrane $20 ring 19.1 hp
|
||
// / 3 lost.) Without this, fabric would be a one-way upgrade and the bet
|
||
// a formality — the same decoration failure from the other side.
|
||
if (soakerFlights?.error) throw soakerFlights.error;
|
||
const { cloth20, mem20 } = soakerFlights;
|
||
assert(cloth20.cornersLost > 0 || mem20.cornersLost > 0,
|
||
'neither cheap ring lost a corner — carabiners hold the soaker and this control proves nothing');
|
||
assert(mem20.cornersLost > cloth20.cornersLost || mem20.hp < cloth20.hp,
|
||
`membrane on carabiners (${mem20.hp} hp, ${mem20.cornersLost} lost) should read WORSE than cloth `
|
||
+ `(${cloth20.hp} hp, ${cloth20.cornersLost} lost) — the doubled load has stopped costing anything`);
|
||
});
|
||
}
|