debris.js: hand-rolled kinematic tumble, drag ∝ speed² so the gust that spikes a corner is the one that launches the neighbour's bin. Ground bounce via world.heightAt (contracts.js documents it as ours), sphere-vs-player knockdown reported to Lane D, sphere-vs-sail-node impulse duck-typed so it lights up when Lane B exposes nodes and stays silent until then. skyfx.js: instanced rain that wraps around the camera rather than respawning, storm sky + procedural cloud dome, lightning, and synthesized WebAudio layers (wind bed, howl, rain, gust whoosh on the telegraph, rope creak off the worst corner, flog when one blows). It modulates Lane A's lights and hands them back on dispose() rather than owning them. weather_demo.html: graybox bench to drive all three before M0 — mock sail, storm scrub, 4x, throw-a-crate. Aligned to contracts.js now that it has landed: relative three imports (there is no importmap), contracts' rng() instead of a local copy, and storm paths resolved off import.meta.url — server.py serves the repo root, so an absolute /world/... would have 404'd at integration. storm_02: fix the southerly change. contracts.js puts north at -Z and the wind vector blows toward (cos d, sin d), so the old swing to +2.6 blew toward due south — a northerly wearing a southerly's name. Now slews to -1.35: a SSW buster off the open side of the yard, into the house. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
82 lines
3.6 KiB
JavaScript
82 lines
3.6 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 } from '../contracts.js';
|
|
import { loadStorm, createWind } from '../weather.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(a.y === 0, `wind should be horizontal, got y=${a.y}`);
|
|
// 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.z === b.z, 'out param changed the result');
|
|
});
|
|
|
|
// 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`);
|
|
});
|
|
|
|
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`);
|
|
}
|
|
});
|
|
}
|