HardYards/web/world/js/tests/c.test.js
m3ultra 40be89c238 The week's two storm variants + forecast uncertainty
Variants for A's five-night week. Each gets ONE new trick, and neither is
"the same storm but harder":

- storm_03b_earlybuster (night 3): same force as storm_03 (21 m/s gust, 13
  sustained) but the change lands at t=18 instead of t=30. You rig for a hot
  NW'er and get a southerly a third of the way in. Tests timing, not budget.
- storm_02b_icenight (night 5): LESS wind than the wild night (28 vs 32 gust,
  19 vs 20 sustained) and 2.8× the hail — 32 hail-seconds vs 11.4, held 26 s
  straight across the change, size 1.4 stones, ice on every gust ≥9. Hail is
  the score (decision 13), so this asks the one question storm_02 only asks
  for nine seconds: is the bed actually covered, and does the cover hold? A rig
  that survives storm_02 by hiding in a small quad off the bed loses here.

Week escalation now reads 11.3 / 21.4 / 21.2 / 32.3 / 28.3 m/s peak gust with
0 / 2.4 / 2.4 / 11.4 / 32.0 hail-seconds — force ramps then plateaus while the
question changes underneath it.

Forecast uncertainty (DESIGN.md partial-information canon): `forecastFor(def,
lead)` + `stormStats(def)`, pure functions on a def rather than methods on the
wind — the card holds defs, doesn't want a field, and this keeps forecasting
off the wind contract and out of A's router. Bands widen with lead and resolve
to the exact truth at lead 0; deterministic per storm, so re-reading the card
can't reroll it.

The invariant, asserted across every storm at five leads: a band ALWAYS
contains the truth. That's the line between partial information and a lie — a
player who rigs for the top of the stated range must never be ambushed.

stormStats also measures what the card was estimating: storm_02 gusts to 32.3
m/s, not the 30 that `baseCurve peak + powBase + powRamp` implied.

Selftest 253/0/0; node 49/0/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 09:24:06 +10:00

356 lines
18 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.

/**
* 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, fixedLoop } from '../testkit.js';
import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS } from '../contracts.js';
import { loadStorm, createWind } from '../weather.js';
import { createDebris } from '../debris.js';
import { createSkyFx, RainShadow } from '../skyfx.js';
import { SailRig, HARDWARE } from '../sail.js';
import { weatherCases } from './weather.selftest.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',
];
/** @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);
// --- 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');
});
// 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`);
});
// --- 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`);
});
// --- 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();
});
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`);
}
});
}