/** * 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, forecastLines, forecastFor, leadFor } 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'; import { ENVELOPE_TESTS } from '../../../../tools/storm_envelope/envelope.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); // --- SPRINT12 gate 2.4: the storm-side envelope harness audits itself --- // Same pattern as sweep.selftest in b.test.js: the tool that second-guesses // B's audit must not be the thing that drifts. See envelope.selftest.js. for (const [name, fn] of ENVELOPE_TESTS) t.test(name, fn); // --- the bits that need the THREE adapter, not just the core --- t.test('weather.js satisfies the wind contract', () => { const wind = createWind(storms.storm_02_wildnight); const problems = checkContract('wind', wind); assert(problems.length === 0, problems.join('; ')); }); t.test('sample() returns a Vector3 and honours an out param', () => { const wind = createWind(storms.storm_02_wildnight); const pos = new THREE.Vector3(3, 0, -2); const a = wind.sample(pos, 12.5); assert(a instanceof THREE.Vector3, 'sample did not return a THREE.Vector3'); assert(Number.isFinite(a.x) && Number.isFinite(a.y) && Number.isFinite(a.z), `sample returned a non-finite vector: ${a.x},${a.y},${a.z}`); // out param must not change the answer, only where it lands const out = new THREE.Vector3(); const b = wind.sample(pos, 12.5, out); assert(b === out, 'out param was ignored'); assert(a.x === b.x && a.y === b.y && a.z === b.z, 'out param changed the result'); }); // This assert once read `a.y === 0` — "wind is horizontal". SPRINT2 decision 3 // made that false on purpose (gusts descend, so a flat sail pays); SPRINT3 // decision 8 made the descent a fraction of TOTAL wind, so it's present // whenever it's windy, not only in gusts. Keeping the invariants that consumers // (player shove, rain angle, HUD) rely on: y is down-or-zero, never up, never // garbage, and speedAt() is horizontal-only. t.test('vertical wind is downward-only and rides the wind', () => { const wind = createWind(storms.storm_02_wildnight); const pos = new THREE.Vector3(0, 1.7, 0); const v = new THREE.Vector3(); let sawDown = false; fixedLoop(wind.duration, FIXED_DT, (dt, time) => { wind.sample(pos, time, v); assert(v.y <= 1e-9, `wind blew UP (y=${v.y.toFixed(3)}) at t=${time.toFixed(2)}`); assert(Number.isFinite(v.y), `vertical wind is not finite at t=${time.toFixed(2)}`); if (v.y < -2) sawDown = true; }); assert(sawDown, 'never saw a downdraft worth the name in a whole wild night'); // the wind meter must stay horizontal — falling air shouldn't spike the HUD const s = wind.sample(pos, 0.5); assert(Math.abs(wind.speedAt(pos, 0.5) - Math.hypot(s.x, s.z)) < 1e-9, 'speedAt() is not the horizontal magnitude of sample()'); }); // Lifted from a.test.js onto the real wind (Lane A's note in this file's // header). This is the promise the whole storm rests on: the player can // always react. Deliberately walks the public surface, not the core. t.test('gust telegraph always gives at least 1.2 s of warning', () => { const wind = createWind(storms.storm_02_wildnight); let had = false, edges = 0; fixedLoop(wind.duration, FIXED_DT, (dt, time) => { const tel = wind.gustTelegraph(time); if (tel && !had) { edges++; assert(tel.eta >= 1.2, `telegraph appeared with only ${tel.eta.toFixed(2)}s of warning`); assert(Number.isFinite(tel.power) && tel.power > 0, 'telegraph carried no power'); assert(Number.isFinite(tel.dir), 'telegraph carried no direction'); } had = !!tel; }); assert(edges >= 5, `only ${edges} gusts telegraphed in a ${wind.duration}s storm — too quiet to test`); }); // --- SPRINT2 decision 5: debris.pieces is Lane B's to read, so it's frozen --- t.test('debris conforms and its pieces match the frozen shape', () => { const wind = createWind(storms.storm_02_wildnight); const debris = createDebris({ wind }); assert(checkContract('debris', debris).length === 0, checkContract('debris', debris).join('; ')); const p = debris.spawn({ model: 'BlueCrate_v2', lateral: 0 }, 40); for (const [field, want] of Object.entries(DEBRIS_PIECE_FIELDS)) { const got = typeof p[field]; assert(got === want, `piece.${field} is ${got}, contract says ${want}`); if (want === 'number') assert(Number.isFinite(p[field]), `piece.${field} is not finite`); } assert(debris.pieces.includes(p), 'spawn() returned a piece that is not in pieces'); assert(p.r > 0 && p.mass > 0, 'a piece with no radius or no mass cannot be collided with'); // The array is mutated in place and pieces are spliced on despawn — that's // documented, and B reads it fresh inside step(). Prove clear() empties it // rather than swapping in a new array behind their reference. const ref = debris.pieces; debris.clear(); assert(ref === debris.pieces && debris.pieces.length === 0, 'clear() replaced the pieces array instead of emptying it — B holds a reference'); }); // Lane A rebuilds skyfx on every phase change, so dispose() is on the hot path. // They verified sun/hemi restore exactly and spotted that fog didn't; this pins // both. The vacuity guards matter — a restore test where nothing ever moved is // a test that passes forever and checks nothing. t.test('skyfx.dispose() hands the scene back exactly as it found it', () => { const scene = new THREE.Scene(); scene.background = new THREE.Color(0x9fc4e8); scene.fog = new THREE.Fog(0x9fc4e8, 30, 140); const camera = new THREE.PerspectiveCamera(); const sun = new THREE.DirectionalLight(0xfff4e0, 2.0); const hemi = new THREE.HemisphereLight(0xbfd8ff, 0x3a4a2a, 1.8); const before = { bg: scene.background, fogColor: scene.fog.color.getHex(), fogNear: scene.fog.near, fogFar: scene.fog.far, sun: sun.intensity, hemi: hemi.intensity, children: scene.children.length, }; const wind = createWind(storms.storm_02_wildnight); const sky = createSkyFx({ scene, camera, wind, sun, hemi }); fixedLoop(40, FIXED_DT, (dt, time) => sky.step(dt, time, {})); assert(sun.intensity < before.sun * 0.9, 'the storm never dimmed the sun — this test proves nothing'); assert(scene.fog.near !== before.fogNear, 'the storm never touched the fog — this test proves nothing'); sky.dispose(); assert(sun.intensity === before.sun, `sun left at ${sun.intensity}, want ${before.sun}`); assert(hemi.intensity === before.hemi, `hemi left at ${hemi.intensity}, want ${before.hemi}`); assert(scene.background === before.bg, 'scene.background not restored'); assert(scene.fog.color.getHex() === before.fogColor, `fog colour left at #${scene.fog.color.getHex().toString(16)}, want #${before.fogColor.toString(16)}`); assert(scene.fog.near === before.fogNear && scene.fog.far === before.fogFar, `fog left at near=${scene.fog.near} far=${scene.fog.far}, want ${before.fogNear}/${before.fogFar}`); assert(scene.children.length === before.children, `skyfx left ${scene.children.length - before.children} object(s) in the scene`); }); // SPRINT13 gate 3.3 — A's M key. main.js feature-detects sky.setMute and only // advertises M once it exists, so this contract is what lights the key up. // The pre-unlock case is the one that bites: M pressed on the splash screen, // BEFORE the first gesture builds the audio graph, must survive the unlock. t.test('M-mute: setMute silences the bus, and a pre-unlock mute survives unlock', () => { const wind = createWind(storms.storm_02_wildnight); const sky = createSkyFx({ wind }); // headless — the bus needs no scene assert(typeof sky.setMute === 'function', "no setMute — A's M key has nothing to call"); assert(sky.muted === false, 'a fresh sky must not start muted'); sky.setMute(true); // before unlock — the splash case assert(sky.muted === true, 'setMute(true) did not take'); sky.unlockAudio(); if (sky.audio.ready) { // no AudioContext = nothing to silence assert(sky.audio.masterGain === 0, `unlock forgot a pre-unlock mute: master at ${sky.audio.masterGain}`); sky.setMute(false); assert(sky.audio.masterGain > 0, 'unmute left the master gain at 0'); } sky.dispose(); }); // SPRINT13 gate 2.1 — the storm grade. The QA sighting: 65 km/h + driving // rain under a noon-blue sky with razor midday shadows. Cause: sky and sun // both multiplied the weather by the author's palette (`darkness`, 0.5 on // the southerly), so the dial could zero the weather's say — and nothing // touched shadow softness at all. Pins: the grade floors the darkness dial, // the sun dims/goes slate/softens, and dispose hands all of it back. t.test('storm grade: a mid-darkness gale dims the sun, softens shadows, restores on dispose', () => { const scene = new THREE.Scene(); scene.background = new THREE.Color(0x9fc4e8); const camera = new THREE.PerspectiveCamera(); const sun = new THREE.DirectionalLight(0xfff2dc, 2.0); const before = { sun: sun.intensity, color: sun.color.getHex(), radius: sun.shadow.radius }; // a darkness-0.5 storm blowing 18 m/s (65 km/h) in full rain — the QA scene const gale = { seed: 1, def: { sky: { darkness: 0.5 } }, sample: (p, t2, o) => o.set(18, 0, 0), speedAt: () => 18, rainAt: () => 1, rainMmPerHour: () => 30, gustTelegraph: () => null, eventsBetween: () => [], setSheltersFromTrees() {}, }; const sky = createSkyFx({ scene, camera, wind: gale, sun }); fixedLoop(10, FIXED_DT, (dt, time) => sky.step(dt, time, {})); assert(sky.stormGrade > 0.7, `grade ${sky.stormGrade} — the darkness dial still zeroes the weather (the old formula reads 0.50 here)`); assert(sun.intensity < before.sun * 0.5, `sun at ${sun.intensity.toFixed(2)} of ${before.sun} — still noon at 65 km/h`); assert(sun.shadow.radius > 4, `shadow radius ${sun.shadow.radius} — razor-sharp midday shadows in a gale`); assert(sun.color.getHex() !== before.color, 'the sun disc never lost its noon warmth'); sky.dispose(); assert(sun.intensity === before.sun && sun.color.getHex() === before.color && sun.shadow.radius === before.radius, 'dispose did not hand the sun back exactly (intensity / colour / shadow radius)'); }); // SPRINT13 gate 2.2 — the wall's edges, judged from in-yard eye height. // Two QA sightings, both geometry: a sliver of bright sky under the bank's // base (it stopped AT the camera's horizontal plane, above the ground's true // horizon), and a hard vertical seam at the arc ends (0.36 alpha at the // outer tenth). The band now overshoots the equator and the end fade is // steeper; these pins are what "grounded" and "feathered" mean in numbers. t.test('the change-front wall grounds below the horizon and feathers its ends', () => { const wind = createWind(storms.storm_03_southerly); const sky = createSkyFx({ wind }); const geo = sky.front.geometry; geo.computeBoundingBox(); assert(geo.boundingBox.min.y < -20, `front base at y=${geo.boundingBox.min.y.toFixed(1)} — a band stopping at eye level floats a sky sliver under the wall`); const uv = geo.attributes.uv, col = geo.attributes.color; assert(col && col.itemSize === 4, 'front lost its vertex alpha — the edges are hard cuts again'); let edgeMax = 0; for (let i = 0; i < uv.count; i++) { const u = uv.getX(i); if (u <= 0.09 || u >= 0.91) edgeMax = Math.max(edgeMax, col.getW(i)); } assert(edgeMax < 0.2, `arc-end alpha ${edgeMax.toFixed(2)} — the vertical seam QA saw at the bank's ends`); sky.dispose(); }); // --- SPRINT2 §Lane C.3: rain has to stop at the cloth --- // Driven with a synthetic 4×4 m panel rather than a whole cloth sim: the thing // under test is the projection, and a flat panel makes the right answer // something you can work out on paper. const PANEL = { pos: new Float32Array([-2, 3, -2, 2, 3, -2, 2, 3, 2, -2, 3, 2]), tris: [0, 1, 2, 0, 2, 3], }; t.test('rain shadow: straight-down rain leaves a dry patch under the panel', () => { const s = new RainShadow(); s.update(PANEL, 0, -1, 0); assert(s.live, 'shadow never built'); assert(s.occluded(0, 1, 0), 'drop directly under the panel is still falling'); assert(s.occluded(1.5, 0.1, 1.5), 'drop near the panel corner is still falling'); assert(!s.occluded(0, 5, 0), 'drop ABOVE the panel was culled — it has not hit yet'); assert(!s.occluded(8, 1, 0), 'drop well clear of the panel was culled'); assert(!s.occluded(0, 1, 9), 'drop well clear of the panel was culled'); }); t.test('rain shadow leans with the rain, and follows the wind round', () => { const s = new RainShadow(); // rain driving hard along +x: the dry ground moves +x, out from under the panel s.update(PANEL, 0.6, -0.8, 0); const shift = 3 * (0.6 / 0.8); // 3 m of fall × the lean assert(s.occluded(shift, 0.05, 0), `dry patch is not downwind at x=${shift.toFixed(2)}`); assert(!s.occluded(-shift, 0.05, 0), 'dry patch went UPWIND — the projection is inverted'); // swing the wind 180° and the patch has to swap sides. This is the southerly // change: the sail stops covering the bed without a single corner failing. s.update(PANEL, -0.6, -0.8, 0); assert(s.occluded(-shift, 0.05, 0), 'dry patch did not follow the wind round'); assert(!s.occluded(shift, 0.05, 0), 'dry patch stayed put when the wind swung'); }); t.test('rain shadow: no sail, no shelter', () => { const s = new RainShadow(); s.update(null, 0, -1, 0); assert(!s.live && !s.occluded(0, 1, 0), 'sheltered by a sail that does not exist'); // and rain that is not falling can't cast a shadow (guards a divide by ~0) s.update(PANEL, 1, 0, 0); assert(!s.live, 'horizontal rain projected to infinity instead of bailing out'); }); t.test('rain shadow: fractionOver reads a rect the way coverageOver does', () => { const s = new RainShadow(); s.update(PANEL, 0, -1, 0); // the panel spans x,z in [-2,2]; a rect inside it is fully covered assert(s.fractionOver({ x: 0, z: 0, w: 2, d: 2 }) === 1, 'a rect wholly under the panel is not fully covered'); assert(s.fractionOver({ x: 12, z: 0, w: 2, d: 2 }) === 0, 'a rect nowhere near the panel is covered'); const half = s.fractionOver({ x: 2, z: 0, w: 4, d: 2 }); assert(half > 0.2 && half < 0.8, `a rect straddling the edge reads ${half}, want a partial`); }); // --- decision 7: the drain helper Lane A wires garden HP to --- t.test('gardenExposure is rain AND no cover, and needs both', () => { const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(); const wind = createWind(storms.storm_02_wildnight); const sky = createSkyFx({ scene, camera, wind }); const bed = { x: 0, z: 0, w: 4, d: 3 }; // no sail: exposure is simply the rain fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 70 + time, {})); const open = sky.gardenExposure(bed, 70); assert(Math.abs(open - wind.rainAt(70)) < 1e-9, `open ground should be exposed exactly as hard as it rains: ${open} vs ${wind.rainAt(70)}`); assert(open > 0.5, 'storm_02 at t=70 should be pouring'); // a panel over the bed dries it out const panel = { pos: new Float32Array([-3, 3, -3, 3, 3, -3, 3, 3, 3, -3, 3, 3]), tris: [0, 1, 2, 0, 2, 3] }; fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 70 + time, { sail: panel })); const covered = sky.gardenExposure(bed, 70); assert(covered < open * 0.5, `cloth over the bed barely helped: ${covered.toFixed(2)} vs open ${open.toFixed(2)}`); // and no rain means no drain, sail or not assert(sky.gardenExposure(bed, 0) === 0, 'the bed is draining before the rain has started'); sky.dispose(); }); t.test('storm_02 lights up on its biggest gusts, storm_01 does not', () => { const mk = (name) => { const scene = new THREE.Scene(); const wind = createWind(storms[name]); const sky = createSkyFx({ scene, camera: new THREE.PerspectiveCamera(), wind }); let flashes = 0, peak = 0; let was = 0; fixedLoop(wind.duration, FIXED_DT, (dt, time) => { sky.step(dt, time, {}); if (sky.flash > was + 0.05) flashes++; // a rising edge = a strike was = sky.flash; peak = Math.max(peak, sky.flash); }); sky.dispose(); return { flashes, peak }; }; const wild = mk('storm_02_wildnight'); const gentle = mk('storm_01_gentle'); // 3 authored strikes + the worst gusts; must be more than the authored ones assert(wild.flashes > 3, `wild night only flashed ${wild.flashes} times — the big gusts aren't lighting up`); assert(gentle.flashes === 0, `the gentle storm flashed ${gentle.flashes} times — it has no lightning at all`); }); // --- SPRINT12 gate 3.4: the change announces itself --- // The corner block's thesis is "looks like night 2 and isn't" — storm_03b's // change lands at 18 s, not 30. The tell is a dark front wall standing in the // quarter the new wind comes FROM, rising across the 12 s before the change // and clearing after the swing. These asserts pin: the window (nothing before // the lead opens, gone after the fade), the rise (monotonic, reaches ~full), // the DIRECTION (the wall stands in the southern sky — +Z, the open fence // side — and the mesh really is rotated there), and that a changeless storm // never raises it. D judges whether it READS; this pins that it exists, // where it stands, and when. t.test('gate 3.4: the front wall rises before the buster, stands in the south, clears after', () => { const scene = new THREE.Scene(); const wind = createWind(storms.storm_03b_earlybuster); const sky = createSkyFx({ scene, camera: new THREE.PerspectiveCamera(), wind }); const ev = storms.storm_03b_earlybuster.events.find((e) => e.type === 'windchange'); const t0 = ev.t, over = ev.over ?? 6; // 18, 6 let prev = 0, peak = 0, azAtChange = null, rotAtChange = null, opAtChange = 0, visAtChange = false; fixedLoop(40, FIXED_DT, (dt, time) => { sky.step(dt, time, {}); const f = sky.changeFront; assert(Number.isFinite(f) && f >= 0 && f <= 1, `front out of range at t=${time.toFixed(2)}: ${f}`); if (time < t0 - 12.01) assert(f === 0, `front up at t=${time.toFixed(2)} — before its lead window opens`); if (time > t0 - 12 && time <= t0) { assert(f >= prev - 1e-9, `front FELL while rising: ${prev.toFixed(3)} → ${f.toFixed(3)} at t=${time.toFixed(2)}`); prev = f; if (f > peak) peak = f; } if (Math.abs(time - t0) < FIXED_DT) { azAtChange = sky.changeFrontAz; rotAtChange = sky.front.rotation.y; opAtChange = sky.front.material.opacity; visAtChange = sky.front.visible; } if (time > t0 + over + 8.1) assert(f === 0, `front still up at t=${time.toFixed(2)} — the swing has long cleared`); }); assert(peak > 0.98, `front only reached ${peak.toFixed(3)} by the change — it should stand near full`); assert(visAtChange && opAtChange > 0.7, `wall not visible at the change (visible=${visAtChange}, opacity=${opAtChange})`); // direction: the wall stands where the new wind comes FROM. dirAt is the // heading the wind blows TOWARD, so from = settled post-change dir + π. const expected = wind.dirAt(t0 + over + 4) + Math.PI; assert(Math.abs(azAtChange - expected) < 1e-9, `front azimuth ${azAtChange} is not the settled post-change upwind quarter ${expected}`); // and that quarter is the SOUTH: the buster blows toward -Z (the house), // so it comes from +Z, the open fence side. sin(az) is the from-vector's z. assert(Math.sin(azAtChange) > 0.3, `the "southerly" front stands at azimuth ${azAtChange.toFixed(2)} — from-vector z=${Math.sin(azAtChange).toFixed(2)}, not the southern sky`); // the mesh is really rotated there (local band centre sits at azimuth π, // so rotation.y = π − az carries it to az; see skyfx geometry note) assert(Math.abs(rotAtChange - (Math.PI - azAtChange)) < 1e-9, `wall rotation ${rotAtChange} does not place the band at azimuth ${azAtChange}`); sky.dispose(); }); t.test('gate 3.4: night 2 teaches the same tell, a changeless storm never shows it, and it is pure in t', () => { // storm_03 (night 2): same wall, later — up but not full at t=20 (change 30) const mk = (name) => createSkyFx({ scene: new THREE.Scene(), camera: new THREE.PerspectiveCamera(), wind: createWind(storms[name]), }); const a = mk('storm_03_southerly'), b = mk('storm_03_southerly'); let mid = 0; fixedLoop(21, FIXED_DT, (dt, time) => { a.step(dt, time, {}); b.step(dt, time, {}); // determinism: two instances at the same t agree to the bit — no hidden // clock, no per-instance random draw in the tell assert(a.changeFront === b.changeFront, `two skyfx disagree on the front at t=${time.toFixed(2)}: ${a.changeFront} vs ${b.changeFront}`); mid = a.changeFront; }); assert(mid > 0.05 && mid < 0.95, `night 2's wall should be RISING at t=21 (change at 30), reads ${mid.toFixed(3)}`); a.dispose(); b.dispose(); // the gentle day has no windchange: no wall, ever const calm = mk('storm_01_gentle'); fixedLoop(storms.storm_01_gentle.duration, FIXED_DT, (dt, time) => { calm.step(dt, time, {}); assert(calm.changeFront === 0 && !calm.front.visible, `a changeless storm raised the front at t=${time.toFixed(2)}`); }); calm.dispose(); }); // --- SPRINT5 decision 13: hail makes the garden score respond to the rig --- // The whole reason hail exists: a perfect rig scored 54% vs 48% for no rig, // because honest rain walks under a sail. Steep hail doesn't — a sail over the // bed shelters it. This is the gate-2 assert: no-sail hail damage ≥ 2× a good // rig's, over the real storm_02 hail, through the real gardenHailExposure. t.test('decision 13: no-sail garden takes ≥2× the hail of a well-covered bed', () => { const bed = { x: 1, z: 2, w: 6, d: 4 }; // a good rig: a quad sitting over the bed, held on rated shackles const s = 3.6; const anchors = [ { id: 'a', pos: { x: -2, y: s, z: -0.5 }, type: 'post', sway() { return this.pos; } }, { id: 'b', pos: { x: 4, y: s + 0.4, z: -0.5 }, type: 'post', sway() { return this.pos; } }, { id: 'c', pos: { x: 4, y: s, z: 4.5 }, type: 'post', sway() { return this.pos; } }, { id: 'd', pos: { x: -2, y: s + 0.4, z: 4.5 }, type: 'post', sway() { return this.pos; } }, ]; const rig = new SailRig({ anchors, gridN: 10 }); rig.attach(['a', 'b', 'c', 'd'], [2, 2, 2, 2].map((i) => HARDWARE[i]), 1.0); const damageOverStorm = (getWorld, rigToStep) => { const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(); camera.position.set(0, 6, 8); const wind = createWind(storms.storm_02_wildnight); const sky = createSkyFx({ scene, camera, wind }); let dmg = 0; fixedLoop(wind.duration, FIXED_DT, (dt, time) => { if (rigToStep) rigToStep.step(dt, wind, time); sky.step(dt, time, getWorld()); dmg += sky.gardenHailExposure(bed, time) * dt; }); const cover = rigToStep ? rigToStep.coverageOver(bed, { x: 0, y: 1, z: 0 }) : 0; sky.dispose(); return { dmg, cover }; }; const open = damageOverStorm(() => ({}), null); const covered = damageOverStorm(() => ({ sail: rig }), rig); // sanity: the rig must actually be over the bed, or this proves nothing assert(covered.cover > 0.6, `test rig only covers ${(covered.cover * 100).toFixed(0)}% of the bed — fix the quad, not the mechanic`); assert(open.dmg > 0, 'no hail damage in the open at all — storm_02 should be pelting'); const ratio = open.dmg / Math.max(1e-6, covered.dmg); assert(ratio >= 2, `open bed took only ${ratio.toFixed(1)}× the hail of the covered one — decision 13 wants ≥2×`); }); t.test('hail exposure needs BOTH hail and no cover; steep shadow, unlike rain', () => { const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(); const wind = createWind(storms.storm_02_wildnight); const sky = createSkyFx({ scene, camera, wind }); const bed = { x: 0, z: 0, w: 4, d: 3 }; // a flat panel well above the bed const panel = { pos: new Float32Array([-3, 4, -3, 3, 4, -3, 3, 4, 3, -3, 4, 3]), tris: [0, 1, 2, 0, 2, 3] }; // storm_01 has no hail: exposure is zero regardless of cover const calm = createWind(storms.storm_01_gentle); const skyCalm = createSkyFx({ scene: new THREE.Scene(), camera, wind: calm }); fixedLoop(1, FIXED_DT, (dt, time) => skyCalm.step(dt, 40 + time, {})); assert(skyCalm.gardenHailExposure(bed, 40) === 0, 'a hail-free storm still reported hail exposure'); skyCalm.dispose(); // at the wild night's hail peak, open bed is exposed; a panel over it is not fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 55 + time, {})); const openExp = sky.gardenHailExposure(bed, 56); assert(openExp > 0.5, `open bed hail exposure only ${openExp.toFixed(2)} at the burst — should be high`); fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 55 + time, { sail: panel })); const coveredExp = sky.gardenHailExposure(bed, 56); assert(coveredExp < openExp * 0.4, `steep hail still hit the covered bed: ${coveredExp.toFixed(2)} vs open ${openExp.toFixed(2)}`); sky.dispose(); }); // The trap that cost gate 0 two sprints, asserted so it cannot come back. // skyfx.step() used to open `if (!camera) return`, so a headless harness // skipped the shadow rebuild and scored every rig as if the sail weren't // there. A camera is a view, not a weather system: the grids are physics and // must build without one. If someone re-adds an early return, this goes red. t.test('skyfx shelters the bed with NO camera — the grids are physics, not view', () => { const bed = { x: 0, z: 0, w: 4, d: 3 }; const panel = { pos: new Float32Array([-3, 4, -3, 3, 4, -3, 3, 4, 3, -3, 4, 3]), tris: [0, 1, 2, 0, 2, 3] }; const run = (withCamera) => { const scene = new THREE.Scene(); const wind = createWind(storms.storm_02_wildnight); const sky = createSkyFx(withCamera ? { scene, camera: new THREE.PerspectiveCamera(), wind } : { scene, wind }); // headless: no camera at all fixedLoop(2, FIXED_DT, (dt, time) => sky.step(dt, 55 + time, { sail: panel })); const out = { hailShadow: sky.hailShadowOver(bed), rainShadow: sky.rainShadowOver(bed), hailExp: sky.gardenHailExposure(bed, 56), hail: sky.hailAmount, }; sky.dispose(); return out; }; const headless = run(false); const viewed = run(true); assert(headless.hailShadow > 0.9, `no camera → hail shadow ${headless.hailShadow.toFixed(2)} — the grid did not build, the sail is invisible to scoring`); assert(headless.hail > 0.5, 'no camera → hail intensity was not tracked'); assert(headless.hailExp < 0.1, `no camera → bed reads ${headless.hailExp.toFixed(2)} exposed under a panel — this is the hp-36 bare-bed bug`); // and a camera must not CHANGE the physics, only add the view assert(Math.abs(headless.hailShadow - viewed.hailShadow) < 1e-9, `the camera changed the hail shadow: ${headless.hailShadow} vs ${viewed.hailShadow}`); assert(Math.abs(headless.rainShadow - viewed.rainShadow) < 1e-9, `the camera changed the rain shadow: ${headless.rainShadow} vs ${viewed.rainShadow}`); }); // The card's copy, not just the model — this is what a player actually reads. t.test('forecastLines: tonight is exact, a week out hedges, never a bare NaN', () => { const def = storms.storm_02_wildnight; const tonight = forecastLines(def, 0); assert(tonight.name === 'WILD NIGHT', `name reads "${tonight.name}"`); assert(tonight.night === true, 'the wild night should read as a NIGHT'); assert(!/–/.test(tonight.wind), `tonight's wind should be exact, got "${tonight.wind}"`); assert(tonight.confidence === '', 'tonight should not print a confidence line — 100% is noise'); assert(/hail likely/.test(tonight.rain), `tonight should call the hail: "${tonight.rain}"`); const far = forecastLines(def, 1); assert(/–/.test(far.wind), `a week out should hedge with a range, got "${far.wind}"`); assert(/confidence/.test(far.confidence), 'a distant forecast should state its confidence'); // no NaN/undefined can reach the card, for any storm at any lead for (const [name, d] of Object.entries(storms)) { for (const lead of [0, 0.5, 1]) { const f = forecastLines(d, lead); for (const k of ['name', 'wind', 'rain', 'confidence']) { assert(typeof f[k] === 'string' && !/NaN|undefined/.test(f[k]), `${name} @lead ${lead}: ${k} reads "${f[k]}"`); } } } // a hail-free storm must not advertise hail assert(!/hail/.test(forecastLines(storms.storm_01_gentle, 0).rain), 'the gentle storm advertised hail it does not have'); }); // SPRINT11 gate 2: the job sheet shows TOMORROW, and tomorrow becomes tonight. // A band that widens or jumps as the night approaches is a card that lied // once — so the RESOLVING is the contract, not just the width. // // What each half is worth, established by mutation (SPRINT11, see THREADS): // · NESTING is the live one. It rests on the wander being the SAME seeded // draw at every lead — reseed `r` per lead and this goes red immediately. // · CONTAINMENT is structural under today's band(): the wander is 0.6 of the // half-width, so c±w never crosses v, and the min/max clamps are belt-and- // braces. Removing the clamps alone does NOT make it fail. It is kept as a // contract guard for a future rewrite of band() (a percentile model, say), // where it stops being free — not as proof of today's code. t.test('the forecast band resolves as the night approaches: nests, and never sheds the truth', () => { const keys = [ ['sustained', (s) => s.sustained], ['gustPeak', (s) => s.gustPeak], ['rain', (s) => s.rainPeak], ['rainMmPerHour', (s) => s.rainPeakMmPerHour], ]; for (const [name, def] of Object.entries(storms)) { for (const [k, truthOf] of keys) { let prev = null; // walk the week backwards: the far end -> tonight for (let i = 40; i >= 0; i--) { const f = forecastFor(def, i / 40); const b = f[k]; const truth = truthOf(f.truth); assert(b.lo <= truth + 1e-9 && b.hi >= truth - 1e-9, `${name}.${k} @lead ${i / 40}: band ${b.lo}–${b.hi} rules out the truth ${truth}`); if (prev) { assert(b.lo >= prev.lo - 1e-9 && b.hi <= prev.hi + 1e-9, `${name}.${k} @lead ${i / 40}: band ${b.lo.toFixed(3)}–${b.hi.toFixed(3)} is not inside ` + `the vaguer ${prev.lo.toFixed(3)}–${prev.hi.toFixed(3)} — it jumped instead of resolving`); } prev = b; } // and it must ARRIVE: tonight is the truth, not merely a narrow band const exact = forecastFor(def, 0); assert(exact[k].lo === exact[k].hi, `${name}.${k}: tonight should be exact, got ${exact[k].lo}–${exact[k].hi}`); } } }); t.test('leadFor: tonight is exact, the far end of the week is vague, past it clamps', () => { assert(leadFor(0, 5) === 0, 'tonight must be lead 0 — the job sheet is not a guess'); assert(leadFor(4, 5) === 1, "the week's far end must be lead 1"); assert(leadFor(1, 5) === 0.25, `tomorrow @5 nights should be 0.25, got ${leadFor(1, 5)}`); assert(leadFor(9, 5) === 1, 'beyond the week must clamp, not exceed 1'); assert(leadFor(-1, 5) === 0, 'a night already survived must not go negative'); assert(leadFor(1, 1) === 1, 'a one-night week has no far end: anything but tonight is a guess'); // the point of the param: tomorrow reads hedged, tonight does not const def = storms.storm_02_wildnight; assert(!/–/.test(forecastLines(def, leadFor(0, 5)).wind), 'tonight should not hedge'); assert(/–/.test(forecastLines(def, leadFor(1, 5)).wind), `tomorrow should hedge, got "${forecastLines(def, leadFor(1, 5)).wind}"`); }); t.test('every storm in data/storms/ loads and validates', () => { // loadStorm throws on invalid, so reaching here with all of them is the pass assert(Object.keys(storms).length === STORMS.length, 'a storm failed to load'); for (const [name, def] of Object.entries(storms)) { assert(def.duration > 0, `${name} has no duration`); assert(Array.isArray(def.baseCurve), `${name} has no baseCurve`); } }); }