SPRINT2 decisions 3 and 5, plus Lane A's fog nit. Decision 3 — gusts now descend. Cloth pressure goes with dot(wind, normal); a flat panel's normal points at the sky, so in a perfectly horizontal wind the dot is ~0 and "lie it flat and ignore the storm" was the cheapest winning rig. A gust front is descending air, not just faster air. Per-gust downdraft fraction in storm JSON (storm_02 0.3, storm_01 0.18, default 0.25, validated 0..1), each gust varying 0.6-1.4x. Peak downdraft in storm_02 is 4.4 m/s, 17% of the horizontal. Lane B: the cloth-side assert is yours. The vertical draws from its OWN rng stream, and there's an assert pinning that: pulling it from the main stream would shift every subsequent (t0, pow) and silently re-time storms Lane A has already hand-verified. Their carabiner still blows at t=45.4 and cascades at t=56. speedAt() stays horizontal — an anemometer doesn't read falling air, and a wind meter that spikes because a gust is descending reads as a bug. Decision 5 — debris.pieces frozen and documented in contracts.js as the seam Lane B reads in sail.step(), with the sphere/SI/mutated-in-place semantics spelled out and an assert tying the live shape to the contract table. Fog: dispose() captured scene.fog by reference and step() mutates that object in place, so restoring it restored nothing (Lane A caught it). Now captured by value, and fog we created ourselves is removed rather than left behind. Selftest 130/0/0 (was 121); Lane C 28 asserts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
166 lines
8.4 KiB
JavaScript
166 lines
8.4 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 } 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`);
|
|
});
|
|
|
|
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`);
|
|
}
|
|
});
|
|
}
|