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.
921 lines
47 KiB
JavaScript
921 lines
47 KiB
JavaScript
'use strict';
|
||
// SHADES — Lane C — weather selftest.
|
||
//
|
||
// Imports the pure core only (no THREE, no DOM), so this runs in node OR from
|
||
// Lane A's selftest.html. Fixed-dt loops, never rAF (rAF pauses in hidden tabs
|
||
// — PLAN3D §0).
|
||
//
|
||
// node web/world/js/tests/run-node.mjs
|
||
//
|
||
// Lane A: `import { runWeatherSuite } from './js/tests/weather.selftest.js'` and
|
||
// call it with the fetched storm defs.
|
||
|
||
import {
|
||
createWindField, validateStorm, validateSiteWind, gustEnvelope, GUST, RAIN_TIME_COMPRESSION,
|
||
stormStats, forecastFor, hailBlockFor,
|
||
} from '../weather.core.js';
|
||
import { makeStorm, checkEnvelope, ENVELOPES } from '../stormgen.js';
|
||
|
||
const DT = 1 / 60;
|
||
|
||
// yard is ~30×20 m, origin at centre — probe the corners and the middle
|
||
const PROBES = [
|
||
{ x: 0, z: 0 }, { x: -14, z: -9 }, { x: 14, z: -9 },
|
||
{ x: -14, z: 9 }, { x: 14, z: 9 }, { x: 5, z: -3 },
|
||
];
|
||
|
||
// t1 and t2 from Lane A's landed yard (THREADS: "yard layout is now FACT")
|
||
const TREE_SHELTERS = [
|
||
{ x: -9, z: 2, radius: 3, strength: 0.45, length: 14 },
|
||
{ x: 8, z: -2, radius: 2.5, strength: 0.4, length: 12 },
|
||
];
|
||
|
||
/**
|
||
* The case list, defined once and run by two harnesses: run-node.mjs for a
|
||
* one-second tuning loop, and js/tests/c.test.js for Lane A's selftest.html at
|
||
* merge time. Cases are sync, matching testkit's Suite.test(label, fn).
|
||
*
|
||
* @param {Object<string, object>} storms parsed storm defs, keyed by name
|
||
* @returns {{cases: {name:string, fn:() => void}[], metrics: object}}
|
||
* `metrics` fills in as the cases run — read it after, not before.
|
||
*/
|
||
export function weatherCases(storms) {
|
||
const cases = [];
|
||
const metrics = {};
|
||
const test = (name, fn) => cases.push({ name, fn });
|
||
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
|
||
|
||
// SPRINT16 gate 3.2: two generated storms ride the SAME per-storm honesty
|
||
// gates as the authored ones below (telegraph lead, continuity, validator).
|
||
// A family whose members can be dishonest is not an envelope, it's a slot
|
||
// machine — fixed seeds so the suite stays deterministic run to run.
|
||
const defs = [
|
||
...Object.entries(storms),
|
||
...[11, 47].map((s) => [`gen_soaker_${s}`, makeStorm(s)]),
|
||
];
|
||
|
||
// ---- 1. gust telegraph lead (PLAN3D §5-C.5: always >= 1.2 s before ramp) ----
|
||
// The whole gust read is "you SEE it coming, then it hits". If the lead ever
|
||
// collapses, the storm stops being fair and starts being a dice roll.
|
||
for (const [name, def] of defs) {
|
||
test(`${name}: gust telegraph lead >= 1.2s`, () => {
|
||
const field = createWindField(def);
|
||
assert(field.gusts.length > 0, 'storm scheduled no gusts at all');
|
||
const step = 1 / 240;
|
||
let worst = Infinity;
|
||
for (const g of field.gusts) {
|
||
// when does THIS gust first actually push?
|
||
let rise = null;
|
||
for (let t = g.t0; t < g.endAt; t += step) {
|
||
if (gustEnvelope(t - g.t0, g.pow) > 1e-6) { rise = t; break; }
|
||
}
|
||
assert(rise !== null, `gust at t=${g.t0.toFixed(2)} never rises`);
|
||
|
||
// when did the HUD first get told about it?
|
||
let announced = null;
|
||
for (let t = Math.max(0, g.t0 - 2); t < rise; t += step) {
|
||
const tg = field.telegraph(t);
|
||
if (tg && Math.abs(tg.eta - (g.rampAt - t)) < 1e-6) { announced = t; break; }
|
||
}
|
||
assert(announced !== null, `gust at t=${g.t0.toFixed(2)} was never telegraphed`);
|
||
|
||
const lead = rise - announced;
|
||
worst = Math.min(worst, lead);
|
||
assert(lead >= 1.2,
|
||
`gust at t=${g.t0.toFixed(2)}: telegraph lead ${lead.toFixed(3)}s < 1.2s`);
|
||
|
||
// and the telegraph window must be silent — no force before the ramp
|
||
for (let t = g.t0; t < g.rampAt; t += step) {
|
||
assert(gustEnvelope(t - g.t0, g.pow) === 0,
|
||
`gust at t=${g.t0.toFixed(2)} pushes during its telegraph window`);
|
||
}
|
||
}
|
||
metrics[`${name}.worstTelegraphLead`] = +worst.toFixed(3);
|
||
});
|
||
}
|
||
|
||
// ---- 2. wind.sample continuity ----
|
||
// Sail load goes with wind², so a discontinuity here is an impulse that can
|
||
// snap a corner out of nowhere. Both axes matter: time (a standing player)
|
||
// and space (a running one).
|
||
const MAX_JUMP = 1.5; // m/s per 1/60 frame
|
||
for (const [name, def] of defs) {
|
||
test(`${name}: wind continuity in time (< ${MAX_JUMP} m/s per frame)`, () => {
|
||
const field = createWindField(def).setShelters(TREE_SHELTERS);
|
||
const a = { x: 0, y: 0, z: 0 }, b = { x: 0, y: 0, z: 0 };
|
||
let worst = 0, worstT = 0, worstP = null;
|
||
for (const p of PROBES) {
|
||
field.vecAt(p.x, p.z, 0, a);
|
||
for (let t = DT; t <= field.duration; t += DT) {
|
||
field.vecAt(p.x, p.z, t, b);
|
||
const d = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
|
||
if (d > worst) { worst = d; worstT = t; worstP = p; }
|
||
a.x = b.x; a.y = b.y; a.z = b.z;
|
||
}
|
||
}
|
||
metrics[`${name}.maxTemporalJump`] = +worst.toFixed(4);
|
||
assert(worst < MAX_JUMP,
|
||
`jump ${worst.toFixed(3)} m/s at t=${worstT.toFixed(2)} probe=(${worstP.x},${worstP.z})`);
|
||
});
|
||
|
||
test(`${name}: wind continuity in space (< ${MAX_JUMP} m/s per 0.1 m)`, () => {
|
||
const field = createWindField(def).setShelters(TREE_SHELTERS);
|
||
const a = { x: 0, y: 0, z: 0 }, b = { x: 0, y: 0, z: 0 };
|
||
let worst = 0, worstAt = null;
|
||
// sweep the yard at the storm's angriest moments, straight through both trees
|
||
for (const t of [10, 30, 57, 64, 80]) {
|
||
for (let z = -10; z <= 10; z += 1) {
|
||
field.vecAt(-15, z, t, a);
|
||
for (let x = -15 + 0.1; x <= 15; x += 0.1) {
|
||
field.vecAt(x, z, t, b);
|
||
const d = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
|
||
if (d > worst) { worst = d; worstAt = { x: +x.toFixed(1), z, t }; }
|
||
a.x = b.x; a.y = b.y; a.z = b.z;
|
||
}
|
||
}
|
||
}
|
||
metrics[`${name}.maxSpatialJump`] = +worst.toFixed(4);
|
||
assert(worst < MAX_JUMP,
|
||
`jump ${worst.toFixed(3)} m/s at ${JSON.stringify(worstAt)}`);
|
||
});
|
||
}
|
||
|
||
// ---- 3. storm JSON validator ----
|
||
for (const [name, def] of defs) {
|
||
test(`${name}: validates`, () => {
|
||
const { ok, errors } = validateStorm(def, name);
|
||
assert(ok, errors.join('; '));
|
||
});
|
||
}
|
||
|
||
// A validator that only ever says yes isn't a validator.
|
||
test('validator rejects broken storms', () => {
|
||
const base = () => JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||
const cases = [
|
||
['duration missing', (d) => { delete d.duration; }],
|
||
['duration negative', (d) => { d.duration = -5; }],
|
||
['baseCurve absent', (d) => { delete d.baseCurve; }],
|
||
['baseCurve non-monotonic t', (d) => { d.baseCurve = [[0, 5], [50, 9], [20, 7], [90, 6]]; }],
|
||
['baseCurve negative speed', (d) => { d.baseCurve = [[0, 5], [90, -2]]; }],
|
||
['baseCurve ends before duration', (d) => { d.baseCurve = [[0, 5], [40, 9]]; }],
|
||
['dirCurve absent', (d) => { delete d.dirCurve; }],
|
||
['gusts absent', (d) => { delete d.gusts; }],
|
||
['gusts.minGap zero', (d) => { d.gusts.minGap = 0; }],
|
||
['gusts.maxGap < minGap', (d) => { d.gusts.minGap = 9; d.gusts.maxGap = 4; }],
|
||
['gusts overlap (minGap < gust length)', (d) => { d.gusts.minGap = 2; }],
|
||
['debris event with no model', (d) => { d.events = [{ t: 10, type: 'debris' }]; }],
|
||
['event with no type', (d) => { d.events = [{ t: 10 }]; }],
|
||
['windchange that dirCurve never delivers', (d) => {
|
||
d.dirCurve = [[0, 0.9], [90, 1.0]];
|
||
d.events = [{ t: 55, type: 'windchange', telegraph: 6 }];
|
||
}],
|
||
];
|
||
for (const [label, mutate] of cases) {
|
||
const d = base();
|
||
mutate(d);
|
||
const { ok } = validateStorm(d, 'broken');
|
||
assert(!ok, `validator ACCEPTED a storm with: ${label}`);
|
||
}
|
||
});
|
||
|
||
// ---- 4. determinism ----
|
||
// Everything downstream (selftest fast-forward, Lane B's byte-equal load
|
||
// traces) rests on this. Two builds of the same storm must be indiscernible.
|
||
test('same def + same seed => identical trace', () => {
|
||
const def = storms.storm_02_wildnight;
|
||
const a = createWindField(def).setShelters(TREE_SHELTERS);
|
||
const b = createWindField(def).setShelters(TREE_SHELTERS);
|
||
const va = { x: 0, y: 0, z: 0 }, vb = { x: 0, y: 0, z: 0 };
|
||
for (let t = 0; t <= def.duration; t += DT) {
|
||
for (const p of PROBES) {
|
||
a.vecAt(p.x, p.z, t, va);
|
||
b.vecAt(p.x, p.z, t, vb);
|
||
assert(va.x === vb.x && va.z === vb.z,
|
||
`diverged at t=${t.toFixed(3)} probe=(${p.x},${p.z})`);
|
||
}
|
||
}
|
||
assert(a.gusts.length === b.gusts.length, 'gust timelines differ in length');
|
||
a.gusts.forEach((g, i) => {
|
||
assert(g.t0 === b.gusts[i].t0 && g.pow === b.gusts[i].pow, `gust ${i} differs`);
|
||
});
|
||
});
|
||
|
||
test('different seed => different storm', () => {
|
||
const def = storms.storm_02_wildnight;
|
||
const a = createWindField(def, { seed: 1 });
|
||
const b = createWindField(def, { seed: 2 });
|
||
const same = a.gusts.length === b.gusts.length
|
||
&& a.gusts.every((g, i) => g.t0 === b.gusts[i].t0 && g.pow === b.gusts[i].pow);
|
||
assert(!same, 'seed is being ignored — every storm would be identical');
|
||
});
|
||
|
||
// ---- 5. sampling order must not matter ----
|
||
// sample() is called by sail/player/debris/rain in whatever order the frame
|
||
// happens to run. If it ever depends on call order, storms stop replaying.
|
||
test('sample order independent', () => {
|
||
const def = storms.storm_02_wildnight;
|
||
// same fixed t values, walked in two different orders, on two fields
|
||
const times = [0, 3.5, 17.3, 4.1, 55.0, 88.9, 63.2, 21.7, 39.9, 70.4];
|
||
const sorted = [...times].sort((a, b) => a - b);
|
||
const inOrder = createWindField(def).setShelters(TREE_SHELTERS);
|
||
const shuffled = createWindField(def).setShelters(TREE_SHELTERS);
|
||
const va = { x: 0, y: 0, z: 0 }, vb = { x: 0, y: 0, z: 0 };
|
||
|
||
const seen = new Map();
|
||
for (const t of sorted) {
|
||
inOrder.vecAt(3, -2, t, va);
|
||
seen.set(t, { x: va.x, z: va.z });
|
||
}
|
||
for (const t of times) { // deliberately out of order, and jumping backwards
|
||
shuffled.vecAt(3, -2, t, vb);
|
||
const want = seen.get(t);
|
||
assert(vb.x === want.x && vb.z === want.z,
|
||
`sampling order changed the wind at t=${t}: ${vb.x},${vb.z} vs ${want.x},${want.z}`);
|
||
}
|
||
});
|
||
|
||
// ---- 6. the storms are what the design says they are (PLAN3D §7) ----
|
||
// storm_02 has to be able to destroy a flat cheap rig; storm_01 must not.
|
||
test('storm_02 is genuinely violent, storm_01 is not', () => {
|
||
const wild = createWindField(storms.storm_02_wildnight);
|
||
const gentle = createWindField(storms.storm_01_gentle);
|
||
const peak = (f) => {
|
||
let mx = 0, mxBase = 0;
|
||
for (let t = 0; t <= f.duration; t += DT) {
|
||
mx = Math.max(mx, f.uniformSpeed(t));
|
||
mxBase = Math.max(mxBase, f.uniformSpeed(t) - f.gustOnly(t));
|
||
}
|
||
return { mx, mxBase };
|
||
};
|
||
const w = peak(wild), g = peak(gentle);
|
||
metrics['storm_02.peakGustSpeed'] = +w.mx.toFixed(2);
|
||
metrics['storm_02.peakSustained'] = +w.mxBase.toFixed(2);
|
||
metrics['storm_01.peakGustSpeed'] = +g.mx.toFixed(2);
|
||
metrics['storm_01.peakSustained'] = +g.mxBase.toFixed(2);
|
||
assert(w.mx >= 30, `storm_02 peaks at only ${w.mx.toFixed(1)} m/s — won't break a cheap rig`);
|
||
assert(w.mxBase >= 18, `storm_02 sustained peaks at only ${w.mxBase.toFixed(1)} m/s`);
|
||
assert(g.mx <= 15, `storm_01 peaks at ${g.mx.toFixed(1)} m/s — too wild for the gentle storm`);
|
||
assert(w.mx > g.mx * 2, 'storm_02 should be far worse than storm_01');
|
||
});
|
||
|
||
// ---- 7. wind change actually swings the wind ----
|
||
test('storm_02 southerly change swings the wind', () => {
|
||
const f = createWindField(storms.storm_02_wildnight);
|
||
const ev = (storms.storm_02_wildnight.events || []).find((e) => e.type === 'windchange');
|
||
assert(ev, 'storm_02 has no windchange event');
|
||
const before = f.dirAt(ev.t - 5);
|
||
const after = f.dirAt(ev.t + 8);
|
||
const swing = Math.abs(after - before);
|
||
metrics['storm_02.changeSwingRad'] = +swing.toFixed(3);
|
||
assert(swing > 0.9, `change only swings ${swing.toFixed(2)} rad — should be a real slew`);
|
||
// and the player must be warned before it lands
|
||
assert((ev.telegraph ?? 0) >= 4, 'windchange telegraph is too short to react to');
|
||
});
|
||
|
||
// ---- 8. shelters ----
|
||
test('tree wind shadow bites downwind and nowhere else', () => {
|
||
const def = storms.storm_02_wildnight;
|
||
const bare = createWindField(def);
|
||
const shad = createWindField(def).setShelters([{ x: 0, z: 0, radius: 3, strength: 0.5, length: 14 }]);
|
||
const t = 30;
|
||
const d = bare.dirAt(t);
|
||
const dx = Math.cos(d), dz = Math.sin(d);
|
||
const lee = { x: dx * 5, z: dz * 5 }; // 5 m downwind of the tree
|
||
const luv = { x: -dx * 5, z: -dz * 5 }; // 5 m upwind
|
||
const leeS = shad.speedAt(lee.x, lee.z, t), leeB = bare.speedAt(lee.x, lee.z, t);
|
||
const luvS = shad.speedAt(luv.x, luv.z, t), luvB = bare.speedAt(luv.x, luv.z, t);
|
||
metrics['shelter.leeDrop'] = +(1 - leeS / leeB).toFixed(3);
|
||
assert(leeS < leeB * 0.85, `lee side only dropped to ${(leeS / leeB).toFixed(2)}× — shadow too weak`);
|
||
assert(Math.abs(luvS - luvB) < 1e-9, 'upwind side is being sheltered — shadow is pointing the wrong way');
|
||
});
|
||
|
||
// ---- 8b. venturi (SPRINT9 site_02 — the corner block funnels the southerly) ----
|
||
test('venturi speeds the wind up when it blows along the gap, not across it', () => {
|
||
const def = storms.storm_02_wildnight;
|
||
// a funnel whose axis runs NE–SW (0.9 rad ≈ 51°), at the yard centre
|
||
const AXIS = 0.9;
|
||
const bare = createWindField(def);
|
||
const funnel = createWindField(def).setVenturi([{ x: 0, z: 0, axis: AXIS, gain: 1.5, radius: 5, sharp: 3 }]);
|
||
|
||
// when the wind blows ALONG the axis, the throat should scream
|
||
const dt = 1 / 60;
|
||
let alongBoost = 1, crossBoost = 1, alongT = 0, crossT = 0;
|
||
for (let t = 0; t <= def.duration; t += dt) {
|
||
const d = bare.dirAt(t);
|
||
// alignment of the wind with the funnel axis, |dot|
|
||
const align = Math.abs(Math.cos(d) * Math.cos(AXIS) + Math.sin(d) * Math.sin(AXIS));
|
||
const boost = funnel.speedAt(0, 0, t) / bare.speedAt(0, 0, t);
|
||
if (align > 0.98 && boost > alongBoost) { alongBoost = boost; alongT = t; }
|
||
if (align < 0.15 && boost < crossBoost) { crossBoost = boost; crossT = t; }
|
||
}
|
||
metrics['venturi.alignedBoost'] = +alongBoost.toFixed(3);
|
||
metrics['venturi.crosswindBoost'] = +crossBoost.toFixed(3);
|
||
assert(alongBoost > 1.35, `a dead-on wind only boosted ${alongBoost.toFixed(2)}× at t=${alongT.toFixed(1)} — funnel too weak`);
|
||
assert(crossBoost < 1.03, `a crosswind still boosted ${crossBoost.toFixed(2)}× at t=${crossT.toFixed(1)} — a funnel shouldn't fire across its axis`);
|
||
// never slows the wind (a venturi accelerates, it doesn't shelter)
|
||
assert(alongBoost >= 1 && crossBoost >= 1, 'venturi reduced the wind somewhere — it can only speed it up');
|
||
});
|
||
|
||
test('venturi only reaches inside its radius, and downdraft rides it', () => {
|
||
const def = storms.storm_02_wildnight;
|
||
const f = createWindField(def).setVenturi([{ x: 0, z: 0, axis: 0.9, gain: 1.5, radius: 5, sharp: 3 }]);
|
||
const bare = createWindField(def);
|
||
// far outside the throat: untouched
|
||
assert(Math.abs(f.speedAt(20, 0, 40) - bare.speedAt(20, 0, 40)) < 1e-9, 'venturi reached 20 m away');
|
||
// the vertical downdraft is a fraction of local speed, so it funnels too
|
||
const out = { x: 0, y: 0, z: 0 }, outBare = { x: 0, y: 0, z: 0 };
|
||
let maxRatio = 0;
|
||
for (let t = 0; t <= def.duration; t += 1 / 60) {
|
||
f.vecAt(0, 0, t, out); bare.vecAt(0, 0, t, outBare);
|
||
if (outBare.y < -0.1) maxRatio = Math.max(maxRatio, out.y / outBare.y); // both negative
|
||
}
|
||
assert(maxRatio > 1.3, `funnel didn't strengthen the downdraft (max ${maxRatio.toFixed(2)}×) — it should ride local speed`);
|
||
});
|
||
|
||
test('validateSiteWind: a good funnel passes, a bad one fails loud', () => {
|
||
const ok = validateSiteWind({ venturi: [{ x: -6, z: 4, axis: -1.08, gain: 1.4, radius: 5, sharp: 3 }] }, 's');
|
||
assert(ok.ok, `a valid venturi was rejected: ${ok.errors.join('; ')}`);
|
||
assert(validateSiteWind(null, 's').ok, 'a site with no wind block should validate');
|
||
assert(validateSiteWind({}, 's').ok, 'an empty wind block should validate');
|
||
assert(validateSiteWind({ venturi: [] }, 's').ok, 'an empty venturi list should validate');
|
||
for (const [label, bad] of [
|
||
['gain < 1 (a venturi speeds up, never slows)', { venturi: [{ x: 0, z: 0, gain: 0.7 }] }],
|
||
['gain absurd', { venturi: [{ x: 0, z: 0, gain: 9 }] }],
|
||
['no x,z', { venturi: [{ axis: 1, gain: 1.4 }] }],
|
||
['axis not finite', { venturi: [{ x: 0, z: 0, axis: 'south' }] }],
|
||
['radius <= 0', { venturi: [{ x: 0, z: 0, radius: 0 }] }],
|
||
['venturi not an array', { venturi: { x: 0 } }],
|
||
]) {
|
||
assert(!validateSiteWind(bad, 's').ok, `validateSiteWind accepted a bad funnel: ${label}`);
|
||
}
|
||
});
|
||
|
||
test('an empty venturi list is a perfect no-op (backyard_01 is untouched)', () => {
|
||
const def = storms.storm_02_wildnight;
|
||
const bare = createWindField(def);
|
||
const set = createWindField(def).setVenturi([]);
|
||
for (const p of PROBES) {
|
||
for (const t of [10, 40, 60, 75]) {
|
||
assert(bare.speedAt(p.x, p.z, t) === set.speedAt(p.x, p.z, t),
|
||
`setVenturi([]) changed the wind at (${p.x},${p.z}) t=${t}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('venturi keeps the wind continuous — no frame-to-frame snap', () => {
|
||
// a discontinuity in the funnel would be an impulse straight into a corner;
|
||
// both axes matter — a standing sail (time) and a swinging wind (alignment).
|
||
const def = storms.storm_02_wildnight;
|
||
const f = createWindField(def).setVenturi([{ x: 0, z: 0, axis: 0.9, gain: 1.6, radius: 5, sharp: 3 }]);
|
||
const a = { x: 0, y: 0, z: 0 }, b = { x: 0, y: 0, z: 0 };
|
||
let worst = 0, worstT = 0;
|
||
// sit right in the throat through the whole storm, including the change
|
||
f.vecAt(0.5, 0.5, 0, a);
|
||
for (let t = 1 / 60; t <= def.duration; t += 1 / 60) {
|
||
f.vecAt(0.5, 0.5, t, b);
|
||
const jump = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
|
||
if (jump > worst) { worst = jump; worstT = t; }
|
||
a.x = b.x; a.y = b.y; a.z = b.z;
|
||
}
|
||
metrics['venturi.maxTemporalJump'] = +worst.toFixed(3);
|
||
assert(worst < 1.5, `venturi jumped ${worst.toFixed(2)} m/s in one frame at t=${worstT.toFixed(1)}`);
|
||
});
|
||
|
||
// ---- 9. vertical structure (SPRINT3 decision 8: fraction of TOTAL) ----
|
||
// Cloth pressure goes with dot(wind, normal). A flat panel's normal points at
|
||
// the sky, so in a purely horizontal wind that dot is ~0 and "lie it flat and
|
||
// ignore the storm" wins — the opposite of the game. The downdraft is now a
|
||
// fraction of the LOCAL total wind speed (was: gust power), so a flat roof is
|
||
// pressed whenever it's windy, not only at gust peaks. Lane B owns the
|
||
// cloth-side no-free-lunch assert; these are the wind side.
|
||
test('downdraft is a fixed fraction of the local horizontal speed', () => {
|
||
const def = storms.storm_02_wildnight;
|
||
const f = createWindField(def);
|
||
const frac = def.gusts.downdraftOfTotal;
|
||
assert(Math.abs(f.downFrac - frac) < 1e-12, `field downFrac ${f.downFrac} != json ${frac}`);
|
||
const out = { x: 0, y: 0, z: 0 };
|
||
let peakDown = 0;
|
||
for (const p of PROBES) {
|
||
for (let t = 0; t <= f.duration; t += DT) {
|
||
f.vecAt(p.x, p.z, t, out);
|
||
const horiz = Math.hypot(out.x, out.z);
|
||
assert(out.y <= 1e-9, `vertical went UP (${out.y.toFixed(3)}) at t=${t.toFixed(2)} — downdraft only`);
|
||
// out.y must be exactly -frac * horizontal, everywhere, always
|
||
assert(Math.abs(out.y + frac * horiz) < 1e-9,
|
||
`downdraft ${out.y.toFixed(3)} != -${frac}×${horiz.toFixed(3)} at t=${t.toFixed(2)}`);
|
||
peakDown = Math.min(peakDown, out.y);
|
||
}
|
||
}
|
||
metrics['storm_02.peakDowndraft'] = +peakDown.toFixed(2);
|
||
// Held at downdraftOfTotal 0.15 → ~-4.9 m/s; target 0.45 → ~-14.7. Floor at
|
||
// -3 so this proves "a real downdraft exists" across the whole transition
|
||
// range without false-failing when the joint step bumps the value.
|
||
assert(peakDown < -3, `peak downdraft only ${peakDown.toFixed(2)} m/s — a flat sail would still shrug it off`);
|
||
});
|
||
|
||
test('downdraft rides the wind: present when windy, gone when calm', () => {
|
||
// The point of fraction-of-total: it's not a gust-only feature any more. Some
|
||
// sustained-wind moment between gusts must still carry a real downdraft, and a
|
||
// hypothetically dead-calm field must carry none.
|
||
const f = createWindField(storms.storm_02_wildnight);
|
||
let sustainedDown = 0;
|
||
for (let t = 0; t <= f.duration; t += DT) {
|
||
const inGust = f.gusts.some((g) => t > g.t0 && t < g.endAt);
|
||
if (!inGust) sustainedDown = Math.min(sustainedDown, f.verticalAt(0, 0, t));
|
||
}
|
||
assert(sustainedDown < -2,
|
||
`between gusts the downdraft peaks at only ${sustainedDown.toFixed(2)} — total-speed semantics should keep it pressing`);
|
||
|
||
// dead calm → no downdraft (guards against a constant offset sneaking in)
|
||
const calm = createWindField({
|
||
duration: 10, baseCurve: [[0, 0], [10, 0]], dirCurve: [[0, 0], [10, 0]],
|
||
gusts: { minGap: 6, maxGap: 6, powBase: 0, powRand: 0, powRamp: 0, downdraftOfTotal: 0.5 },
|
||
});
|
||
for (let t = 0; t <= 10; t += 0.1) {
|
||
assert(Math.abs(calm.verticalAt(0, 0, t)) < 1e-9, `air is falling in a dead calm at t=${t.toFixed(1)}`);
|
||
}
|
||
});
|
||
|
||
test('downdraft follows the tree shadow (shelters from falling air too)', () => {
|
||
const def = storms.storm_02_wildnight;
|
||
const f = createWindField(def).setShelters([{ x: 0, z: 0, radius: 3, strength: 0.5, length: 14 }]);
|
||
const t = 30;
|
||
const d = f.dirAt(t);
|
||
const dx = Math.cos(d), dz = Math.sin(d);
|
||
const leeDown = Math.abs(f.verticalAt(dx * 5, dz * 5, t)); // downwind of the tree
|
||
const openDown = Math.abs(f.verticalAt(-dx * 5, -dz * 5, t)); // upwind, unsheltered
|
||
assert(leeDown < openDown * 0.85, `lee downdraft ${leeDown.toFixed(2)} not sheltered vs open ${openDown.toFixed(2)}`);
|
||
});
|
||
|
||
test('downdraftOfTotal 0 gives a perfectly horizontal wind', () => {
|
||
const def = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||
def.gusts.downdraftOfTotal = 0;
|
||
const f = createWindField(def);
|
||
const out = { x: 0, y: 0, z: 0 };
|
||
for (let t = 0; t <= f.duration; t += 0.05) {
|
||
f.vecAt(2, -1, t, out);
|
||
assert(out.y === 0, `y=${out.y} at t=${t.toFixed(2)} with downdraft 0 — the opt-out leaks`);
|
||
}
|
||
});
|
||
|
||
test('downdraft does not re-time the storm', () => {
|
||
// The vertical carries NO rng draws of its own now (it's a pure function of
|
||
// local speed), so tuning it cannot possibly shift gust times or powers. Lane
|
||
// A hand-drove storm_02 and watched the carabiner blow at t=45.4 and p2
|
||
// cascade at t=56; a downdraft tweak silently moving those would be a nasty
|
||
// way to lose an afternoon. Determinism is now structural, but still asserted.
|
||
const base = storms.storm_02_wildnight;
|
||
const a = createWindField(base);
|
||
for (const dd of [0, 0.1, 0.22, 0.5, 1]) {
|
||
const d = JSON.parse(JSON.stringify(base));
|
||
d.gusts.downdraftOfTotal = dd;
|
||
const b = createWindField(d);
|
||
assert(a.gusts.length === b.gusts.length, `downdraft ${dd} changed the gust count`);
|
||
a.gusts.forEach((g, i) => {
|
||
assert(g.t0 === b.gusts[i].t0,
|
||
`downdraft ${dd} moved gust ${i} from t=${g.t0.toFixed(3)} to ${b.gusts[i].t0.toFixed(3)}`);
|
||
assert(g.pow === b.gusts[i].pow, `downdraft ${dd} changed gust ${i}'s power`);
|
||
});
|
||
// and the HORIZONTAL wind must be byte-identical regardless of downdraft
|
||
assert(a.speedAt(3, -2, 47.3) === b.speedAt(3, -2, 47.3), `downdraft ${dd} changed the horizontal wind`);
|
||
}
|
||
});
|
||
|
||
test('speedAt stays horizontal — a wind meter does not read falling air', () => {
|
||
const f = createWindField(storms.storm_02_wildnight);
|
||
const out = { x: 0, y: 0, z: 0 };
|
||
for (const p of PROBES) {
|
||
for (const t of [12, 40, 60, 75.3]) {
|
||
f.vecAt(p.x, p.z, t, out);
|
||
assert(Math.abs(f.speedAt(p.x, p.z, t) - Math.hypot(out.x, out.z)) < 1e-9,
|
||
`speedAt != horizontal magnitude of sample at t=${t}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('validator rejects a bad downdraft and the renamed field', () => {
|
||
for (const dd of [-0.1, 1.5, NaN, 'lots']) {
|
||
const d = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||
d.gusts.downdraftOfTotal = dd;
|
||
assert(!validateStorm(d, 'broken').ok, `validator ACCEPTED downdraftOfTotal = ${dd}`);
|
||
}
|
||
// the old gust-only field must be rejected, not silently re-meant
|
||
const legacy = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||
delete legacy.gusts.downdraftOfTotal;
|
||
legacy.gusts.downdraft = 0.3;
|
||
assert(!validateStorm(legacy, 'legacy').ok, 'validator silently accepted the pre-SPRINT3 downdraft field');
|
||
});
|
||
|
||
// ---- 10. the ponding story (SPRINT4 decision 10) ----
|
||
// Lane B owns water-on-cloth; these assert the RAIN DATA can tell the story
|
||
// their mass model needs, using their own arithmetic, before their cloth lands.
|
||
// B measured: 5 cm over a 25 m² flat sail = 1250 kg = 3.1 kN/corner, against a
|
||
// storm_02 wind load of only 0.2–1.1 kN. So depth is the whole mechanic.
|
||
const FLAT_AREA = 25; // m², B's reference flat sail
|
||
const KILL_DEPTH_MM = 50; // 5 cm — B's 3.1 kN/corner
|
||
/** Water on a flat sail over a whole storm, at decision 10's compression. */
|
||
const stormDepthMm = (def) => {
|
||
const f = createWindField(def);
|
||
return f.rainDepthMm(0, f.duration) * RAIN_TIME_COMPRESSION;
|
||
};
|
||
/** B's arithmetic: mm over an area → kN per corner (4 corners, 1000 kg/m³). */
|
||
const kNPerCorner = (mm) => (mm / 1000) * FLAT_AREA * 1000 * 9.81 / 4 / 1000;
|
||
|
||
test('storm_02 rain can drown a flat rig; storm_01 rain cannot', () => {
|
||
const wild = stormDepthMm(storms.storm_02_wildnight);
|
||
const gentle = stormDepthMm(storms.storm_01_gentle);
|
||
metrics['storm_02.pondDepth_mm'] = +wild.toFixed(1);
|
||
metrics['storm_02.pond_kN_per_corner'] = +kNPerCorner(wild).toFixed(2);
|
||
metrics['storm_01.pondDepth_mm'] = +gentle.toFixed(2);
|
||
metrics['storm_01.pond_kN_per_corner'] = +kNPerCorner(gentle).toFixed(3);
|
||
|
||
assert(wild >= KILL_DEPTH_MM * 0.9,
|
||
`storm_02 only delivers ${wild.toFixed(1)} mm — Lane B needs ~${KILL_DEPTH_MM} mm to drown a flat rig`);
|
||
// and it must dwarf the wind it's competing with (B: 0.2–1.1 kN/corner)
|
||
assert(kNPerCorner(wild) > 1.5,
|
||
`storm_02 ponding is only ${kNPerCorner(wild).toFixed(2)} kN/corner — no stronger than the wind`);
|
||
// the gentle storm must be unable to hurt anything, or the ramp is a lie
|
||
assert(kNPerCorner(gentle) < 0.3,
|
||
`storm_01 ponds ${kNPerCorner(gentle).toFixed(2)} kN/corner — a light shower must not threaten a rig`);
|
||
assert(wild > gentle * 20, 'the wild night should deliver vastly more water than a shower');
|
||
});
|
||
|
||
test('storm_03 ponding sits between the other two', () => {
|
||
const mid = stormDepthMm(storms.storm_03_southerly);
|
||
const wild = stormDepthMm(storms.storm_02_wildnight);
|
||
const gentle = stormDepthMm(storms.storm_01_gentle);
|
||
metrics['storm_03.pondDepth_mm'] = +mid.toFixed(1);
|
||
metrics['storm_03.pond_kN_per_corner'] = +kNPerCorner(mid).toFixed(2);
|
||
assert(mid > gentle * 3 && mid < wild * 0.5,
|
||
`storm_03 delivers ${mid.toFixed(1)} mm — wanted a real middle rung between ${gentle.toFixed(1)} and ${wild.toFixed(1)}`);
|
||
// it should threaten a carabiner (1.2 kN) once wind is added, not a shackle (3.2)
|
||
assert(kNPerCorner(mid) > 0.25 && kNPerCorner(mid) < 1.2,
|
||
`storm_03 ponds ${kNPerCorner(mid).toFixed(2)} kN/corner — should tease a cheap rig, not drown a decent one`);
|
||
});
|
||
|
||
test('rain depth integrates monotonically and matches its curve', () => {
|
||
const f = createWindField(storms.storm_02_wildnight);
|
||
// depth only ever accumulates
|
||
let prev = 0;
|
||
for (let t = 1; t <= f.duration; t += 1) {
|
||
const d = f.rainDepthMm(0, t);
|
||
assert(d >= prev - 1e-9, `rain depth went BACKWARDS at t=${t}: ${d} < ${prev}`);
|
||
prev = d;
|
||
}
|
||
// splitting the interval must give the same water (no double-count, no gap)
|
||
const whole = f.rainDepthMm(0, 90);
|
||
const split = f.rainDepthMm(0, 30) + f.rainDepthMm(30, 60) + f.rainDepthMm(60, 90);
|
||
assert(Math.abs(whole - split) < 0.05,
|
||
`depth(0,90)=${whole.toFixed(3)} but the three thirds sum to ${split.toFixed(3)}`);
|
||
// dead calm before the rain starts
|
||
assert(f.rainDepthMm(0, 0) === 0, 'zero-length interval delivered water');
|
||
assert(f.rainDepthMm(10, 5) === 0, 'a backwards interval delivered water');
|
||
});
|
||
|
||
test('rainMmPerHour tracks rainAt against the storm scale', () => {
|
||
for (const [name, def] of defs) {
|
||
if (!def.rain || !def.rain.curve) continue;
|
||
const f = createWindField(def);
|
||
const peak = def.rain.peakMmPerHour;
|
||
assert(Number.isFinite(peak), `${name} has a rain curve but no peakMmPerHour`);
|
||
for (let t = 0; t <= f.duration; t += 2.5) {
|
||
const want = f.rainAt(t) * peak;
|
||
assert(Math.abs(f.rainMmPerHour(t) - want) < 1e-9,
|
||
`${name}: rainMmPerHour ${f.rainMmPerHour(t)} != rainAt×peak ${want} at t=${t}`);
|
||
}
|
||
// rainAt stays a 0..1 intensity — skyfx uses it for drop count and opacity
|
||
for (let t = 0; t <= f.duration; t += 2.5) {
|
||
const r = f.rainAt(t);
|
||
assert(r >= 0 && r <= 1, `${name}: rainAt ${r} outside 0..1 at t=${t}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('validator rejects a rain curve with no scale, and a silly scale', () => {
|
||
const noScale = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||
delete noScale.rain.peakMmPerHour;
|
||
assert(!validateStorm(noScale, 'x').ok, 'validator accepted a rain curve with no mm/hr scale — ponding would silently use the default');
|
||
for (const mm of [-1, 5000, NaN]) {
|
||
const d = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||
d.rain.peakMmPerHour = mm;
|
||
assert(!validateStorm(d, 'x').ok, `validator accepted peakMmPerHour=${mm}`);
|
||
}
|
||
const hot = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||
hot.rain.curve = [[0, 0], [45, 3], [90, 0]];
|
||
assert(!validateStorm(hot, 'x').ok, 'validator accepted rain intensity above 1 — the scale is peakMmPerHour, not the curve');
|
||
});
|
||
|
||
// ---- 11. hail (SPRINT5 decision 13) ----
|
||
// The wind-side of hail: intensity timeline and its ramp across the campaign.
|
||
// The garden-damage half (steep shadow, ≥2× rig response) needs skyfx +
|
||
// SailRig, so it lives in c.test.js.
|
||
test('hail ramps across the campaign: none / mild / a wild-night burst', () => {
|
||
const peakAndArea = (def) => {
|
||
const f = createWindField(def);
|
||
let peak = 0, peakT = 0, area = 0;
|
||
for (let t = 0; t <= f.duration; t += DT) {
|
||
const h = f.hailAt(t);
|
||
if (h > peak) { peak = h; peakT = t; }
|
||
area += h * DT;
|
||
}
|
||
return { peak, peakT, area };
|
||
};
|
||
const g = peakAndArea(storms.storm_01_gentle);
|
||
const m = peakAndArea(storms.storm_03_southerly);
|
||
const w = peakAndArea(storms.storm_02_wildnight);
|
||
metrics['storm_01.hailPeak'] = +g.peak.toFixed(2);
|
||
metrics['storm_03.hailPeak'] = +m.peak.toFixed(2);
|
||
metrics['storm_02.hailPeak'] = +w.peak.toFixed(2);
|
||
metrics['storm_02.hailSeconds'] = +w.area.toFixed(1);
|
||
|
||
assert(g.peak === 0, `the gentle storm hailed (${g.peak}) — storm_01 must have no hail`);
|
||
assert(m.peak > 0.2 && m.peak < 0.8, `storm_03 hail peak ${m.peak.toFixed(2)} — wanted a mild middle rung`);
|
||
assert(w.peak >= 0.95, `storm_02 hail peak only ${w.peak.toFixed(2)} — the wild night needs a proper burst`);
|
||
assert(w.area > m.area * 3, 'the wild night should carry vastly more hail than the mild storm');
|
||
});
|
||
|
||
test('storm_02 hail lands ON the southerly change', () => {
|
||
const change = (storms.storm_02_wildnight.events.find((e) => e.type === 'windchange')).t;
|
||
const f = createWindField(storms.storm_02_wildnight);
|
||
// find the authored-burst peak (near the change), not just any gust-synced tick
|
||
let peak = 0, peakT = 0;
|
||
for (let t = change - 3; t <= change + 8; t += DT) {
|
||
const h = f.hailAt(t);
|
||
if (h > peak) { peak = h; peakT = t; }
|
||
}
|
||
assert(peak >= 0.95, `hail near the change only reached ${peak.toFixed(2)}`);
|
||
assert(Math.abs(peakT - change) < 6, `hail peaks at t=${peakT.toFixed(1)}, the change is at t=${change} — they should coincide`);
|
||
});
|
||
|
||
test('hail is silent through a gust telegraph and rides its own gust', () => {
|
||
// the gust-synced bursts must not fire during the telegraph window, or hail
|
||
// would arrive before the gust it belongs to — the opposite of the drama
|
||
const def = storms.storm_02_wildnight;
|
||
const f = createWindField(def);
|
||
const thresh = def.hail.withGustsAbove;
|
||
for (const g of f.gusts) {
|
||
if (g.pow < thresh) continue;
|
||
// during the telegraph (first GUST.TELEGRAPH s) the gust-synced part is 0.
|
||
// an authored burst may still overlap, so only check gusts clear of t=55±8.
|
||
if (Math.abs(g.t0 - 55) < 10) continue;
|
||
for (let t = g.t0 + 0.05; t < g.t0 + GUST.TELEGRAPH; t += DT) {
|
||
assert(f.hailAt(t) < 1e-9, `hail fell during a gust's telegraph at t=${t.toFixed(2)} — it should wait for the gust`);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('hail does not re-time the storm (zero draws of its own)', () => {
|
||
const base = storms.storm_02_wildnight;
|
||
const a = createWindField(base);
|
||
for (const gi of [0, 0.3, 0.85, 1]) {
|
||
const d = JSON.parse(JSON.stringify(base));
|
||
d.hail.gustBurstIntensity = gi;
|
||
d.hail.bursts = []; // even removing the authored burst mustn't move gusts
|
||
const b = createWindField(d);
|
||
assert(a.gusts.length === b.gusts.length, `hail change moved the gust count`);
|
||
a.gusts.forEach((g, i) => {
|
||
assert(g.t0 === b.gusts[i].t0 && g.pow === b.gusts[i].pow, `hail change re-timed gust ${i}`);
|
||
});
|
||
assert(a.speedAt(3, -2, 47.3) === b.speedAt(3, -2, 47.3), 'hail change altered the wind');
|
||
}
|
||
});
|
||
|
||
test('validator rejects broken hail blocks', () => {
|
||
const base = () => JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||
const cases = [
|
||
['size 0', (d) => { d.hail.size = 0; }],
|
||
['size huge', (d) => { d.hail.size = 20; }],
|
||
['intensity > 1', (d) => { d.hail.bursts[0].intensity = 1.5; }],
|
||
['negative fade', (d) => { d.hail.bursts[0].fade = -1; }],
|
||
['burst after the storm', (d) => { d.hail.bursts[0].t = 200; }],
|
||
['gustBurstIntensity > 1', (d) => { d.hail.gustBurstIntensity = 2; }],
|
||
['neither bursts nor gusts', (d) => { delete d.hail.bursts; delete d.hail.withGustsAbove; }],
|
||
];
|
||
for (const [label, mutate] of cases) {
|
||
const d = base(); mutate(d);
|
||
assert(!validateStorm(d, 'broken').ok, `validator accepted broken hail: ${label}`);
|
||
}
|
||
// a hail-free storm (no block at all) must still validate
|
||
const none = base(); delete none.hail;
|
||
assert(validateStorm(none, 'nohail').ok, 'validator rejected a storm with no hail — hail is optional');
|
||
});
|
||
|
||
// ---- 12. forecast uncertainty (SPRINT6 §Lane C) ----
|
||
// DESIGN.md's partial-information canon. The one rule that matters: a vague
|
||
// forecast is fine, a WRONG one isn't. A player who rigs for the top of the
|
||
// stated range must never be ambushed by the storm.
|
||
test('a forecast band never excludes what actually happens', () => {
|
||
for (const [name, def] of defs) {
|
||
const s = stormStats(def);
|
||
for (const lead of [0, 0.25, 0.5, 0.75, 1]) {
|
||
const f = forecastFor(def, lead);
|
||
const inside = (b, v, what) => assert(v >= b.lo - 1e-9 && v <= b.hi + 1e-9,
|
||
`${name} @lead ${lead}: ${what} truth ${v.toFixed(2)} outside forecast ${b.lo.toFixed(2)}–${b.hi.toFixed(2)}`);
|
||
inside(f.sustained, s.sustained, 'sustained');
|
||
inside(f.gustPeak, s.gustPeak, 'gustPeak');
|
||
inside(f.rain, s.rainPeak, 'rain');
|
||
inside(f.hail.seconds, s.hailSeconds, 'hailSeconds');
|
||
inside(f.hail.size, s.hailSize, 'hailSize'); // gate 3.3: the stone is a band too
|
||
if (s.changeAt != null) inside(f.changeAt, s.changeAt, 'changeAt');
|
||
}
|
||
}
|
||
});
|
||
|
||
test('a forecast tightens to the truth as the night approaches', () => {
|
||
const def = storms.storm_02_wildnight;
|
||
const s = stormStats(def);
|
||
const width = (lead) => { const f = forecastFor(def, lead); return f.gustPeak.hi - f.gustPeak.lo; };
|
||
const far = width(1), mid = width(0.5), near = width(0);
|
||
metrics['forecast.gustBandWidth@lead1'] = +far.toFixed(1);
|
||
metrics['forecast.gustBandWidth@lead0.5'] = +mid.toFixed(1);
|
||
assert(far > mid && mid > near, `band must narrow with lead: ${far.toFixed(1)} → ${mid.toFixed(1)} → ${near.toFixed(1)}`);
|
||
assert(near === 0, `tonight's forecast should be exact, band is ${near.toFixed(2)} wide`);
|
||
const f0 = forecastFor(def, 0);
|
||
assert(f0.gustPeak.lo === s.gustPeak && f0.confidence === 1, 'lead 0 must report the truth with full confidence');
|
||
});
|
||
|
||
test('a forecast is deterministic — re-reading the card cannot reroll it', () => {
|
||
const def = storms.storm_02_wildnight;
|
||
for (const lead of [0.3, 0.8]) {
|
||
const a = forecastFor(def, lead), b = forecastFor(def, lead);
|
||
assert(a.gustPeak.lo === b.gustPeak.lo && a.gustPeak.hi === b.gustPeak.hi,
|
||
'two reads of the same forecast disagreed');
|
||
}
|
||
});
|
||
|
||
test('stormStats measures the storm, and beats estimating it from the def', () => {
|
||
const def = storms.storm_02_wildnight;
|
||
const s = stormStats(def);
|
||
// what the card used to estimate: baseCurve peak + powBase + powRamp
|
||
const estimate = Math.max(...def.baseCurve.map((p) => p[1]))
|
||
+ (def.gusts.powBase ?? 0) + (def.gusts.powRamp ?? 0);
|
||
metrics['storm_02.gustPeak.measured'] = +s.gustPeak.toFixed(1);
|
||
metrics['storm_02.gustPeak.oldEstimate'] = +estimate.toFixed(1);
|
||
assert(Math.abs(s.gustPeak - estimate) > 1,
|
||
'the estimate happens to match — this test is only interesting while they differ');
|
||
assert(s.gustPeak > 30 && s.gustPeak < 35, `measured gust peak ${s.gustPeak.toFixed(1)} is not credible`);
|
||
assert(s.changeAt === 55, `storm_02's change is at ${s.changeAt}, expected 55`);
|
||
});
|
||
|
||
test('the forecast hail hint tracks the storm, and hedges when far out', () => {
|
||
assert(forecastFor(storms.storm_01_gentle, 0).hail.chance === 'none', 'gentle storm forecast hail');
|
||
assert(forecastFor(storms.storm_02_wildnight, 0).hail.chance === 'likely', 'the wild night should forecast likely hail');
|
||
assert(forecastFor(storms.storm_02_wildnight, 1).hail.chance === 'possible',
|
||
'a week out, the wild night should only hedge at "possible"');
|
||
assert(forecastFor(storms.storm_03_southerly, 0).hail.chance === 'possible', 'storm_03 hails mildly');
|
||
});
|
||
|
||
// ---- 13. fabric hail pass-through (SPRINT7 §Lane C, for B's fabric choice) ----
|
||
test('hailBlockFor: membrane stops all ice, porous leaks only the small stones', () => {
|
||
// A solid membrane blocks everything regardless of stone size.
|
||
for (const size of [0.4, 0.7, 1.0, 1.3, 1.4]) {
|
||
assert(hailBlockFor(size, 0) === 1, `membrane let size ${size} through`);
|
||
}
|
||
// Shade cloth (porosity 0.3) fully blocks the wild-night stones (1.3+) but
|
||
// leaks the finest pea hail — the honest, size-gated difference.
|
||
assert(hailBlockFor(1.3, 0.3) > 0.99, 'shade cloth failed to stop a 1.3 storm stone');
|
||
assert(hailBlockFor(1.4, 0.3) > 0.99, 'shade cloth failed to stop a 1.4 ice-night stone');
|
||
const pea = hailBlockFor(0.7, 0.3);
|
||
assert(pea > 0.4 && pea < 0.9, `shade cloth blocks ${pea.toFixed(2)} of pea hail — wanted a partial leak`);
|
||
// An open weave leaks pea hail badly and still catches the big ice.
|
||
assert(hailBlockFor(0.7, 0.5) < 0.3, 'an open weave should let most pea hail through');
|
||
assert(hailBlockFor(1.3, 0.5) > 0.8, 'even an open weave should mostly stop a 1.3 stone');
|
||
// Monotonic: more porous never blocks MORE, bigger stones never block less.
|
||
for (const size of [0.6, 1.0, 1.4]) {
|
||
assert(hailBlockFor(size, 0.5) <= hailBlockFor(size, 0.3) + 1e-9, 'more porous blocked more hail');
|
||
}
|
||
for (const por of [0.3, 0.5]) {
|
||
assert(hailBlockFor(0.5, por) <= hailBlockFor(1.2, por) + 1e-9, 'a bigger stone passed more easily');
|
||
}
|
||
metrics['fabric.shadecloth.blocksPeaHail'] = +pea.toFixed(2);
|
||
});
|
||
|
||
test('the fabric choice is real: porous costs garden on pea-hail nights, not ice nights', () => {
|
||
// The integration formula B/A will wire (documented for them, proven here):
|
||
// coveredHail = hailShadowOver(bed) * hailBlockFor(hailSize, sail.porosity)
|
||
// exposure = hailAt(t) * (1 - coveredHail)
|
||
// A membrane over the bed protects it fully; a porous cloth over the same bed
|
||
// protects it fully on an ice night and leaks on a pea-hail one. If those two
|
||
// came out equal, the choice would be dead — this is the assert that it isn't.
|
||
const gardenHail = (stormName, porosity) => {
|
||
const def = storms[stormName];
|
||
const f = createWindField(def);
|
||
const size = f.hailSize;
|
||
let dmg = 0;
|
||
const COVER = 1; // bed fully under the cloth
|
||
for (let t = 0; t <= f.duration; t += DT) {
|
||
const covered = COVER * hailBlockFor(size, porosity);
|
||
dmg += f.hailAt(t) * (1 - covered) * DT;
|
||
}
|
||
return dmg;
|
||
};
|
||
// ice night: membrane and shade cloth both fully protect a covered bed
|
||
const iceMembrane = gardenHail('storm_02b_icenight', 0);
|
||
const icePorous = gardenHail('storm_02b_icenight', 0.3);
|
||
assert(Math.abs(iceMembrane - icePorous) < 0.2 && iceMembrane < 0.5,
|
||
`on the ice night the fabrics should agree at ~0 (both block big ice): ${iceMembrane.toFixed(2)} vs ${icePorous.toFixed(2)}`);
|
||
// pea-hail night: the covered bed under porous cloth takes real damage
|
||
const peaMembrane = gardenHail('storm_03_southerly', 0);
|
||
const peaPorous = gardenHail('storm_03_southerly', 0.3);
|
||
metrics['fabric.storm03.gardenHail.membrane'] = +peaMembrane.toFixed(2);
|
||
metrics['fabric.storm03.gardenHail.porous'] = +peaPorous.toFixed(2);
|
||
assert(peaMembrane < 0.1, 'a membrane over the bed should take ~no pea hail');
|
||
assert(peaPorous > peaMembrane + 0.3, `porous should leak real pea hail: ${peaPorous.toFixed(2)} vs ${peaMembrane.toFixed(2)}`);
|
||
});
|
||
|
||
// ---- 14. makeStorm(seed) — SPRINT16 gate 3.2, arc 4's groundwork ----
|
||
// A seeded generator inside authored envelopes. No shipped night uses it;
|
||
// these cases are what "inside" and "deterministic" MEAN.
|
||
|
||
test('makeStorm: same seed → byte-equal def; different seed → different storm', () => {
|
||
const a = JSON.stringify(makeStorm(1234));
|
||
const b = JSON.stringify(makeStorm(1234));
|
||
assert(a === b, 'two calls with the same seed produced different JSON — a hidden draw or a Date leaked in');
|
||
const c = JSON.stringify(makeStorm(1235));
|
||
assert(a !== c, 'seeds 1234 and 1235 produced the same storm — the seed is not reaching the draws');
|
||
// the def carries its seed, so the gust timeline downstream is seeded by
|
||
// the same number the caller noted down
|
||
assert(makeStorm(1234).seed === 1234, 'generated def does not carry its seed');
|
||
});
|
||
|
||
test('makeStorm: 100 seeds — every def validates AND sits inside its envelope', () => {
|
||
let gustLo = Infinity, gustHi = 0;
|
||
for (let s = 1; s <= 100; s++) {
|
||
const d = makeStorm(s);
|
||
const v = validateStorm(d, `gen_${s}`);
|
||
assert(v.ok, `seed ${s} failed the storm validator:\n ${v.errors.join('\n ')}`);
|
||
const e = checkEnvelope(d);
|
||
assert(e.ok, `seed ${s} escaped its envelope:\n ${e.errors.join('\n ')}`);
|
||
const st = stormStats(d);
|
||
gustLo = Math.min(gustLo, st.gustPeak); gustHi = Math.max(gustHi, st.gustPeak);
|
||
assert(st.hailSeconds > 2, `seed ${s} barely hails (${st.hailSeconds.toFixed(1)} s) — the family's thesis is pea hail all night`);
|
||
assert(d.hail.size < 0.6, `seed ${s} threw size-${d.hail.size} stones — over the family's pea range, cloth would block them`);
|
||
}
|
||
metrics['stormgen.gustPeak.lo'] = +gustLo.toFixed(1);
|
||
metrics['stormgen.gustPeak.hi'] = +gustHi.toFixed(1);
|
||
// the family is genuinely a FAMILY: seeds move the storm, inside a band
|
||
assert(gustHi - gustLo > 1, `100 seeds moved gustPeak only ${(gustHi - gustLo).toFixed(2)} m/s — the ranges are decoration`);
|
||
assert(gustHi < 20, `a soaker gusted to ${gustHi.toFixed(1)} m/s — the family has stopped being mild`);
|
||
});
|
||
|
||
test('checkEnvelope: a def outside the envelope goes red, not shrugged', () => {
|
||
// vacuity guard first: the def we are about to mutate passes as generated
|
||
const clean = makeStorm(7);
|
||
assert(checkEnvelope(clean).ok, 'the unmutated control failed — every red below would be noise');
|
||
|
||
// each mutation is one escape route, and each must be NAMED in the errors
|
||
const mutations = [
|
||
['base speed over the family ceiling', (d) => { d.baseCurve[3][1] = 12; }, /baseCurve\[3\]/],
|
||
['pea family throwing ice', (d) => { d.hail.size = 1.3; }, /hail\.size/],
|
||
['gusts overlapping (minGap under the gust length)', (d) => { d.gusts.minGap = 4; }, /minGap/],
|
||
['the change re-timed outside its window', (d) => { d.events[0].t = 20; d.dirCurve = [[0, 0.6], [16, 0.7], [28, -0.7], [90, -0.65]]; }, /change at t=20/],
|
||
];
|
||
for (const [label, mutate, want] of mutations) {
|
||
const d = makeStorm(7);
|
||
mutate(d);
|
||
const e = checkEnvelope(d);
|
||
assert(!e.ok, `checkEnvelope shrugged at: ${label}`);
|
||
assert(e.errors.some((m) => want.test(m)),
|
||
`${label}: red, but no error names the escape — got:\n ${e.errors.join('\n ')}`);
|
||
}
|
||
|
||
// and the one that separates checkEnvelope from validateStorm: a def that
|
||
// is a perfectly LOADABLE storm but is not a member of this family
|
||
const stranger = makeStorm(7);
|
||
stranger.rain.peakMmPerHour = 75; // a severe soaker — valid, off-family
|
||
assert(validateStorm(stranger, 'stranger').ok, 'the stranger should still validate as a storm');
|
||
assert(!checkEnvelope(stranger).ok, 'a valid-but-off-family storm passed the envelope — checkEnvelope is just the validator in a hat');
|
||
});
|
||
|
||
test('makeStorm: the change event and the physics agree by construction', () => {
|
||
// the validator's windchange/dirCurve drift trap, proven closed for every
|
||
// seed we ship gates on — the event's promised swing is real in dirCurve
|
||
for (const s of [3, 21, 88]) {
|
||
const d = makeStorm(s);
|
||
const ev = d.events.find((e) => e.type === 'windchange');
|
||
const f = createWindField(d);
|
||
const before = f.dirAt(ev.t - 6);
|
||
const after = f.dirAt(ev.t + (ev.over ?? 6) + 6);
|
||
const swing = Math.abs(after - before);
|
||
assert(swing > 0.6, `seed ${s}: the change at t=${ev.t} only swung ${swing.toFixed(2)} rad`);
|
||
}
|
||
});
|
||
|
||
return { cases, metrics };
|
||
}
|
||
|
||
/** Run every case and collect results. Used by run-node.mjs. */
|
||
export function runWeatherSuite(storms) {
|
||
const { cases, metrics } = weatherCases(storms);
|
||
const results = cases.map((c) => {
|
||
try {
|
||
c.fn();
|
||
return { name: c.name, ok: true };
|
||
} catch (e) {
|
||
return { name: c.name, ok: false, err: e.message };
|
||
}
|
||
});
|
||
const pass = results.filter((r) => r.ok).length;
|
||
return { suite: 'weather', pass, fail: results.length - pass, results, metrics };
|
||
}
|