HardYards/web/world/js/tests/weather.selftest.js
m3ultra 383471d0f5 Add debris, skyfx and the Lane C bench; align to landed contracts
debris.js: hand-rolled kinematic tumble, drag ∝ speed² so the gust that
spikes a corner is the one that launches the neighbour's bin. Ground bounce
via world.heightAt (contracts.js documents it as ours), sphere-vs-player
knockdown reported to Lane D, sphere-vs-sail-node impulse duck-typed so it
lights up when Lane B exposes nodes and stays silent until then.

skyfx.js: instanced rain that wraps around the camera rather than respawning,
storm sky + procedural cloud dome, lightning, and synthesized WebAudio layers
(wind bed, howl, rain, gust whoosh on the telegraph, rope creak off the worst
corner, flog when one blows). It modulates Lane A's lights and hands them back
on dispose() rather than owning them.

weather_demo.html: graybox bench to drive all three before M0 — mock sail,
storm scrub, 4x, throw-a-crate.

Aligned to contracts.js now that it has landed: relative three imports (there
is no importmap), contracts' rng() instead of a local copy, and storm paths
resolved off import.meta.url — server.py serves the repo root, so an absolute
/world/... would have 404'd at integration.

storm_02: fix the southerly change. contracts.js puts north at -Z and the wind
vector blows toward (cos d, sin d), so the old swing to +2.6 blew toward due
south — a northerly wearing a southerly's name. Now slews to -1.35: a SSW
buster off the open side of the yard, into the house.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:38:21 +10:00

299 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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, gustEnvelope, GUST,
} from '../weather.core.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 },
];
const TREE_SHELTERS = [
{ x: -9, z: 4, radius: 3, strength: 0.45, length: 14 },
{ x: 8, z: -5, 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); };
const defs = Object.entries(storms);
// ---- 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');
});
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 };
}