The gust-only downdraft could not satisfy B's 60% no-free-lunch bar and the §7
twisted-survives gate together: the downdraft peaked at the gust peak, where
the horizontal peaked too, so a flat sail never reached 60% of a pitched one's
load without a spike so violent it also broke the twisted rig. The integrator
measured the pincer (0.58 -> 48% and still breaks twisted).
Fix: the downdraft is now a fraction of the LOCAL total wind speed, not of gust
power (weather.core verticalAt = -frac * localHoriz). It presses a flat roof
steadily across the whole storm — peak total 32.6 m/s dwarfs peak gust power
12.6 — so the ratio clears 60% at a gentle fraction, with no gust-peak spike.
It rides the local speed, so a tree's wind shadow shelters from falling air too.
speedAt() stays horizontal (a wind meter doesn't read falling air).
Field renamed downdraft -> downdraftOfTotal; the validator rejects the old name
rather than silently re-meaning it. The vertical now carries NO rng draws at
all, so the determinism guarantee (tuning can't re-time gusts) is structural,
not just separate-stream.
Measured both gates myself with B's SailRig (8-direction flat-vs-pitched sweep +
§7 legs). Target for storm_02 is 0.45: 69% of-max / 60% worst-heading on the
bar, twisted rated rig survives with ~21% margin. HELD at 0.12 this commit —
the current yard's only twisted quad ('h1,t2,p1,t1', ~190 m2) starts losing a
corner near 0.15 in the exact solver, so 0.45 would red B's §7. 0.12 ~= the old
gust-only 0.3 in peak downdraft (-4.2 vs -4.5 m/s). Bump to 0.45 is a one-number
joint step once A lands decision-2 anchors (18-45 m2 quads) and B re-points §7.
storm_03_southerly: campaign ramp between gentle and wildnight — peak gust 21
m/s, sustained 13, one moderate southerly change. Auto-swept by the suite.
Selftest 170/0/0 (all three §7 legs green). Verified live: downdraft/horizontal
ratio is exactly 0.12 in-game, rain occlusion still covers the bed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
225 lines
11 KiB
JavaScript
225 lines
11 KiB
JavaScript
/**
|
||
* 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 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`);
|
||
});
|
||
|
||
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`);
|
||
}
|
||
});
|
||
}
|