/** * 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 { weatherCases } from './weather.selftest.js'; const STORMS = ['storm_01_gentle', 'storm_02_wildnight']; /** @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 used to read `a.y === 0` — "wind should be horizontal". SPRINT2 // decision 3 made that false on purpose: gusts now descend, which is what makes // a flat sail pay. Keeping the useful half — y is downward-or-zero, never up, // and never garbage — so player shove and rain angle can still trust the sign. t.test('vertical wind is downward-only, and only during gusts', () => { 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)}`); if (v.y < -1) sawDown = true; }); assert(sawDown, 'never saw a downdraft worth the name in a whole wild night'); // and the wind meter must stay horizontal — a falling gust shouldn't spike the HUD const calm = wind.speedAt(pos, 0.5); assert(Math.abs(calm - Math.hypot(wind.sample(pos, 0.5).x, wind.sample(pos, 0.5).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`); }); 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`); } }); }