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>
391 lines
17 KiB
JavaScript
391 lines
17 KiB
JavaScript
'use strict';
|
||
// SHADES — Lane C — weather selftest.
|
||
//
|
||
// Imports the pure core only (no THREE, no DOM), so this runs in node OR from
|
||
// Lane A's selftest.html. Fixed-dt loops, never rAF (rAF pauses in hidden tabs
|
||
// — PLAN3D §0).
|
||
//
|
||
// node web/world/js/tests/run-node.mjs
|
||
//
|
||
// Lane A: `import { runWeatherSuite } from './js/tests/weather.selftest.js'` and
|
||
// call it with the fetched storm defs.
|
||
|
||
import {
|
||
createWindField, validateStorm, gustEnvelope, GUST,
|
||
} from '../weather.core.js';
|
||
|
||
const DT = 1 / 60;
|
||
|
||
// yard is ~30×20 m, origin at centre — probe the corners and the middle
|
||
const PROBES = [
|
||
{ x: 0, z: 0 }, { x: -14, z: -9 }, { x: 14, z: -9 },
|
||
{ x: -14, z: 9 }, { x: 14, z: 9 }, { x: 5, z: -3 },
|
||
];
|
||
|
||
// t1 and t2 from Lane A's landed yard (THREADS: "yard layout is now FACT")
|
||
const TREE_SHELTERS = [
|
||
{ x: -9, z: 2, radius: 3, strength: 0.45, length: 14 },
|
||
{ x: 8, z: -2, radius: 2.5, strength: 0.4, length: 12 },
|
||
];
|
||
|
||
/**
|
||
* The case list, defined once and run by two harnesses: run-node.mjs for a
|
||
* one-second tuning loop, and js/tests/c.test.js for Lane A's selftest.html at
|
||
* merge time. Cases are sync, matching testkit's Suite.test(label, fn).
|
||
*
|
||
* @param {Object<string, object>} storms parsed storm defs, keyed by name
|
||
* @returns {{cases: {name:string, fn:() => void}[], metrics: object}}
|
||
* `metrics` fills in as the cases run — read it after, not before.
|
||
*/
|
||
export function weatherCases(storms) {
|
||
const cases = [];
|
||
const metrics = {};
|
||
const test = (name, fn) => cases.push({ name, fn });
|
||
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
|
||
|
||
const defs = Object.entries(storms);
|
||
|
||
// ---- 1. gust telegraph lead (PLAN3D §5-C.5: always >= 1.2 s before ramp) ----
|
||
// The whole gust read is "you SEE it coming, then it hits". If the lead ever
|
||
// collapses, the storm stops being fair and starts being a dice roll.
|
||
for (const [name, def] of defs) {
|
||
test(`${name}: gust telegraph lead >= 1.2s`, () => {
|
||
const field = createWindField(def);
|
||
assert(field.gusts.length > 0, 'storm scheduled no gusts at all');
|
||
const step = 1 / 240;
|
||
let worst = Infinity;
|
||
for (const g of field.gusts) {
|
||
// when does THIS gust first actually push?
|
||
let rise = null;
|
||
for (let t = g.t0; t < g.endAt; t += step) {
|
||
if (gustEnvelope(t - g.t0, g.pow) > 1e-6) { rise = t; break; }
|
||
}
|
||
assert(rise !== null, `gust at t=${g.t0.toFixed(2)} never rises`);
|
||
|
||
// when did the HUD first get told about it?
|
||
let announced = null;
|
||
for (let t = Math.max(0, g.t0 - 2); t < rise; t += step) {
|
||
const tg = field.telegraph(t);
|
||
if (tg && Math.abs(tg.eta - (g.rampAt - t)) < 1e-6) { announced = t; break; }
|
||
}
|
||
assert(announced !== null, `gust at t=${g.t0.toFixed(2)} was never telegraphed`);
|
||
|
||
const lead = rise - announced;
|
||
worst = Math.min(worst, lead);
|
||
assert(lead >= 1.2,
|
||
`gust at t=${g.t0.toFixed(2)}: telegraph lead ${lead.toFixed(3)}s < 1.2s`);
|
||
|
||
// and the telegraph window must be silent — no force before the ramp
|
||
for (let t = g.t0; t < g.rampAt; t += step) {
|
||
assert(gustEnvelope(t - g.t0, g.pow) === 0,
|
||
`gust at t=${g.t0.toFixed(2)} pushes during its telegraph window`);
|
||
}
|
||
}
|
||
metrics[`${name}.worstTelegraphLead`] = +worst.toFixed(3);
|
||
});
|
||
}
|
||
|
||
// ---- 2. wind.sample continuity ----
|
||
// Sail load goes with wind², so a discontinuity here is an impulse that can
|
||
// snap a corner out of nowhere. Both axes matter: time (a standing player)
|
||
// and space (a running one).
|
||
const MAX_JUMP = 1.5; // m/s per 1/60 frame
|
||
for (const [name, def] of defs) {
|
||
test(`${name}: wind continuity in time (< ${MAX_JUMP} m/s per frame)`, () => {
|
||
const field = createWindField(def).setShelters(TREE_SHELTERS);
|
||
const a = { x: 0, y: 0, z: 0 }, b = { x: 0, y: 0, z: 0 };
|
||
let worst = 0, worstT = 0, worstP = null;
|
||
for (const p of PROBES) {
|
||
field.vecAt(p.x, p.z, 0, a);
|
||
for (let t = DT; t <= field.duration; t += DT) {
|
||
field.vecAt(p.x, p.z, t, b);
|
||
const d = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
|
||
if (d > worst) { worst = d; worstT = t; worstP = p; }
|
||
a.x = b.x; a.y = b.y; a.z = b.z;
|
||
}
|
||
}
|
||
metrics[`${name}.maxTemporalJump`] = +worst.toFixed(4);
|
||
assert(worst < MAX_JUMP,
|
||
`jump ${worst.toFixed(3)} m/s at t=${worstT.toFixed(2)} probe=(${worstP.x},${worstP.z})`);
|
||
});
|
||
|
||
test(`${name}: wind continuity in space (< ${MAX_JUMP} m/s per 0.1 m)`, () => {
|
||
const field = createWindField(def).setShelters(TREE_SHELTERS);
|
||
const a = { x: 0, y: 0, z: 0 }, b = { x: 0, y: 0, z: 0 };
|
||
let worst = 0, worstAt = null;
|
||
// sweep the yard at the storm's angriest moments, straight through both trees
|
||
for (const t of [10, 30, 57, 64, 80]) {
|
||
for (let z = -10; z <= 10; z += 1) {
|
||
field.vecAt(-15, z, t, a);
|
||
for (let x = -15 + 0.1; x <= 15; x += 0.1) {
|
||
field.vecAt(x, z, t, b);
|
||
const d = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
|
||
if (d > worst) { worst = d; worstAt = { x: +x.toFixed(1), z, t }; }
|
||
a.x = b.x; a.y = b.y; a.z = b.z;
|
||
}
|
||
}
|
||
}
|
||
metrics[`${name}.maxSpatialJump`] = +worst.toFixed(4);
|
||
assert(worst < MAX_JUMP,
|
||
`jump ${worst.toFixed(3)} m/s at ${JSON.stringify(worstAt)}`);
|
||
});
|
||
}
|
||
|
||
// ---- 3. storm JSON validator ----
|
||
for (const [name, def] of defs) {
|
||
test(`${name}: validates`, () => {
|
||
const { ok, errors } = validateStorm(def, name);
|
||
assert(ok, errors.join('; '));
|
||
});
|
||
}
|
||
|
||
// A validator that only ever says yes isn't a validator.
|
||
test('validator rejects broken storms', () => {
|
||
const base = () => JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||
const cases = [
|
||
['duration missing', (d) => { delete d.duration; }],
|
||
['duration negative', (d) => { d.duration = -5; }],
|
||
['baseCurve absent', (d) => { delete d.baseCurve; }],
|
||
['baseCurve non-monotonic t', (d) => { d.baseCurve = [[0, 5], [50, 9], [20, 7], [90, 6]]; }],
|
||
['baseCurve negative speed', (d) => { d.baseCurve = [[0, 5], [90, -2]]; }],
|
||
['baseCurve ends before duration', (d) => { d.baseCurve = [[0, 5], [40, 9]]; }],
|
||
['dirCurve absent', (d) => { delete d.dirCurve; }],
|
||
['gusts absent', (d) => { delete d.gusts; }],
|
||
['gusts.minGap zero', (d) => { d.gusts.minGap = 0; }],
|
||
['gusts.maxGap < minGap', (d) => { d.gusts.minGap = 9; d.gusts.maxGap = 4; }],
|
||
['gusts overlap (minGap < gust length)', (d) => { d.gusts.minGap = 2; }],
|
||
['debris event with no model', (d) => { d.events = [{ t: 10, type: 'debris' }]; }],
|
||
['event with no type', (d) => { d.events = [{ t: 10 }]; }],
|
||
['windchange that dirCurve never delivers', (d) => {
|
||
d.dirCurve = [[0, 0.9], [90, 1.0]];
|
||
d.events = [{ t: 55, type: 'windchange', telegraph: 6 }];
|
||
}],
|
||
];
|
||
for (const [label, mutate] of cases) {
|
||
const d = base();
|
||
mutate(d);
|
||
const { ok } = validateStorm(d, 'broken');
|
||
assert(!ok, `validator ACCEPTED a storm with: ${label}`);
|
||
}
|
||
});
|
||
|
||
// ---- 4. determinism ----
|
||
// Everything downstream (selftest fast-forward, Lane B's byte-equal load
|
||
// traces) rests on this. Two builds of the same storm must be indiscernible.
|
||
test('same def + same seed => identical trace', () => {
|
||
const def = storms.storm_02_wildnight;
|
||
const a = createWindField(def).setShelters(TREE_SHELTERS);
|
||
const b = createWindField(def).setShelters(TREE_SHELTERS);
|
||
const va = { x: 0, y: 0, z: 0 }, vb = { x: 0, y: 0, z: 0 };
|
||
for (let t = 0; t <= def.duration; t += DT) {
|
||
for (const p of PROBES) {
|
||
a.vecAt(p.x, p.z, t, va);
|
||
b.vecAt(p.x, p.z, t, vb);
|
||
assert(va.x === vb.x && va.z === vb.z,
|
||
`diverged at t=${t.toFixed(3)} probe=(${p.x},${p.z})`);
|
||
}
|
||
}
|
||
assert(a.gusts.length === b.gusts.length, 'gust timelines differ in length');
|
||
a.gusts.forEach((g, i) => {
|
||
assert(g.t0 === b.gusts[i].t0 && g.pow === b.gusts[i].pow, `gust ${i} differs`);
|
||
});
|
||
});
|
||
|
||
test('different seed => different storm', () => {
|
||
const def = storms.storm_02_wildnight;
|
||
const a = createWindField(def, { seed: 1 });
|
||
const b = createWindField(def, { seed: 2 });
|
||
const same = a.gusts.length === b.gusts.length
|
||
&& a.gusts.every((g, i) => g.t0 === b.gusts[i].t0 && g.pow === b.gusts[i].pow);
|
||
assert(!same, 'seed is being ignored — every storm would be identical');
|
||
});
|
||
|
||
// ---- 5. sampling order must not matter ----
|
||
// sample() is called by sail/player/debris/rain in whatever order the frame
|
||
// happens to run. If it ever depends on call order, storms stop replaying.
|
||
test('sample order independent', () => {
|
||
const def = storms.storm_02_wildnight;
|
||
// same fixed t values, walked in two different orders, on two fields
|
||
const times = [0, 3.5, 17.3, 4.1, 55.0, 88.9, 63.2, 21.7, 39.9, 70.4];
|
||
const sorted = [...times].sort((a, b) => a - b);
|
||
const inOrder = createWindField(def).setShelters(TREE_SHELTERS);
|
||
const shuffled = createWindField(def).setShelters(TREE_SHELTERS);
|
||
const va = { x: 0, y: 0, z: 0 }, vb = { x: 0, y: 0, z: 0 };
|
||
|
||
const seen = new Map();
|
||
for (const t of sorted) {
|
||
inOrder.vecAt(3, -2, t, va);
|
||
seen.set(t, { x: va.x, z: va.z });
|
||
}
|
||
for (const t of times) { // deliberately out of order, and jumping backwards
|
||
shuffled.vecAt(3, -2, t, vb);
|
||
const want = seen.get(t);
|
||
assert(vb.x === want.x && vb.z === want.z,
|
||
`sampling order changed the wind at t=${t}: ${vb.x},${vb.z} vs ${want.x},${want.z}`);
|
||
}
|
||
});
|
||
|
||
// ---- 6. the storms are what the design says they are (PLAN3D §7) ----
|
||
// storm_02 has to be able to destroy a flat cheap rig; storm_01 must not.
|
||
test('storm_02 is genuinely violent, storm_01 is not', () => {
|
||
const wild = createWindField(storms.storm_02_wildnight);
|
||
const gentle = createWindField(storms.storm_01_gentle);
|
||
const peak = (f) => {
|
||
let mx = 0, mxBase = 0;
|
||
for (let t = 0; t <= f.duration; t += DT) {
|
||
mx = Math.max(mx, f.uniformSpeed(t));
|
||
mxBase = Math.max(mxBase, f.uniformSpeed(t) - f.gustOnly(t));
|
||
}
|
||
return { mx, mxBase };
|
||
};
|
||
const w = peak(wild), g = peak(gentle);
|
||
metrics['storm_02.peakGustSpeed'] = +w.mx.toFixed(2);
|
||
metrics['storm_02.peakSustained'] = +w.mxBase.toFixed(2);
|
||
metrics['storm_01.peakGustSpeed'] = +g.mx.toFixed(2);
|
||
metrics['storm_01.peakSustained'] = +g.mxBase.toFixed(2);
|
||
assert(w.mx >= 30, `storm_02 peaks at only ${w.mx.toFixed(1)} m/s — won't break a cheap rig`);
|
||
assert(w.mxBase >= 18, `storm_02 sustained peaks at only ${w.mxBase.toFixed(1)} m/s`);
|
||
assert(g.mx <= 15, `storm_01 peaks at ${g.mx.toFixed(1)} m/s — too wild for the gentle storm`);
|
||
assert(w.mx > g.mx * 2, 'storm_02 should be far worse than storm_01');
|
||
});
|
||
|
||
// ---- 7. wind change actually swings the wind ----
|
||
test('storm_02 southerly change swings the wind', () => {
|
||
const f = createWindField(storms.storm_02_wildnight);
|
||
const ev = (storms.storm_02_wildnight.events || []).find((e) => e.type === 'windchange');
|
||
assert(ev, 'storm_02 has no windchange event');
|
||
const before = f.dirAt(ev.t - 5);
|
||
const after = f.dirAt(ev.t + 8);
|
||
const swing = Math.abs(after - before);
|
||
metrics['storm_02.changeSwingRad'] = +swing.toFixed(3);
|
||
assert(swing > 0.9, `change only swings ${swing.toFixed(2)} rad — should be a real slew`);
|
||
// and the player must be warned before it lands
|
||
assert((ev.telegraph ?? 0) >= 4, 'windchange telegraph is too short to react to');
|
||
});
|
||
|
||
// ---- 8. shelters ----
|
||
test('tree wind shadow bites downwind and nowhere else', () => {
|
||
const def = storms.storm_02_wildnight;
|
||
const bare = createWindField(def);
|
||
const shad = createWindField(def).setShelters([{ x: 0, z: 0, radius: 3, strength: 0.5, length: 14 }]);
|
||
const t = 30;
|
||
const d = bare.dirAt(t);
|
||
const dx = Math.cos(d), dz = Math.sin(d);
|
||
const lee = { x: dx * 5, z: dz * 5 }; // 5 m downwind of the tree
|
||
const luv = { x: -dx * 5, z: -dz * 5 }; // 5 m upwind
|
||
const leeS = shad.speedAt(lee.x, lee.z, t), leeB = bare.speedAt(lee.x, lee.z, t);
|
||
const luvS = shad.speedAt(luv.x, luv.z, t), luvB = bare.speedAt(luv.x, luv.z, t);
|
||
metrics['shelter.leeDrop'] = +(1 - leeS / leeB).toFixed(3);
|
||
assert(leeS < leeB * 0.85, `lee side only dropped to ${(leeS / leeB).toFixed(2)}× — shadow too weak`);
|
||
assert(Math.abs(luvS - luvB) < 1e-9, 'upwind side is being sheltered — shadow is pointing the wrong way');
|
||
});
|
||
|
||
// ---- 9. vertical gust structure (SPRINT2 decision 3) ----
|
||
// Cloth pressure goes with dot(wind, normal). A flat horizontal panel's normal
|
||
// points at the sky, so in a perfectly horizontal wind that dot is ~0 and the
|
||
// cheapest winning rig is "lie it flat and ignore the storm" — the opposite of
|
||
// the game. Gust fronts are descending air, and descending air hits a flat
|
||
// panel square on. Lane B owns the cloth-side assert; these are the wind side.
|
||
test('gusts carry a downdraft, and still air does not', () => {
|
||
const f = createWindField(storms.storm_02_wildnight);
|
||
let peakDown = 0, betweenMax = 0;
|
||
for (let t = 0; t <= f.duration; t += DT) {
|
||
const v = f.gustVertical(t);
|
||
assert(v <= 1e-12, `vertical wind went UP (${v.toFixed(2)}) at t=${t.toFixed(2)} — downdraft only`);
|
||
const live = f.gusts.some((g) => t > g.t0 && t < g.endAt);
|
||
if (live) peakDown = Math.min(peakDown, v);
|
||
else betweenMax = Math.max(betweenMax, Math.abs(v));
|
||
}
|
||
metrics['storm_02.peakDowndraft'] = +peakDown.toFixed(2);
|
||
assert(betweenMax === 0, `air is falling between gusts (${betweenMax}) — downdraft must be a gust feature`);
|
||
assert(peakDown < -2, `peak downdraft only ${peakDown.toFixed(2)} m/s — a flat sail would still shrug it off`);
|
||
});
|
||
|
||
test('downdraft tracks its own gust and its JSON fraction', () => {
|
||
const def = storms.storm_02_wildnight;
|
||
const f = createWindField(def);
|
||
const frac = def.gusts.downdraft;
|
||
for (const g of f.gusts) {
|
||
assert(g.down >= frac * 0.6 - 1e-9 && g.down <= frac * 1.4 + 1e-9,
|
||
`gust at t=${g.t0.toFixed(1)} has down=${g.down.toFixed(3)}, outside 0.6–1.4× of ${frac}`);
|
||
// minGap >= GUST.TOTAL means gusts never overlap, so at hold it's exactly this gust
|
||
const atHold = f.gustVertical(g.t0 + 3);
|
||
assert(Math.abs(atHold - -(g.pow * g.down)) < 1e-9,
|
||
`at gust hold vertical is ${atHold.toFixed(3)}, want ${(-g.pow * g.down).toFixed(3)}`);
|
||
}
|
||
});
|
||
|
||
test('downdraft 0 gives a perfectly horizontal wind', () => {
|
||
const def = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||
def.gusts.downdraft = 0;
|
||
const f = createWindField(def);
|
||
const out = { x: 0, y: 0, z: 0 };
|
||
for (let t = 0; t <= f.duration; t += 0.05) {
|
||
f.vecAt(2, -1, t, out);
|
||
assert(out.y === 0, `y=${out.y} at t=${t.toFixed(2)} with downdraft 0 — the opt-out leaks`);
|
||
}
|
||
});
|
||
|
||
test('downdraft does not re-time the storm', () => {
|
||
// The vertical draws from its own RNG stream precisely so that adding or
|
||
// tuning it can't shift gust times or powers. Lane A hand-drove storm_02 and
|
||
// watched the carabiner blow at t=45.4 and p2 cascade at t=56; a downdraft
|
||
// tweak silently moving those would be a nasty way to lose an afternoon.
|
||
const base = storms.storm_02_wildnight;
|
||
const a = createWindField(base);
|
||
for (const dd of [0, 0.1, 0.25, 0.5, 1]) {
|
||
const d = JSON.parse(JSON.stringify(base));
|
||
d.gusts.downdraft = dd;
|
||
const b = createWindField(d);
|
||
assert(a.gusts.length === b.gusts.length, `downdraft ${dd} changed the gust count`);
|
||
a.gusts.forEach((g, i) => {
|
||
assert(g.t0 === b.gusts[i].t0,
|
||
`downdraft ${dd} moved gust ${i} from t=${g.t0.toFixed(3)} to ${b.gusts[i].t0.toFixed(3)}`);
|
||
assert(g.pow === b.gusts[i].pow, `downdraft ${dd} changed gust ${i}'s power`);
|
||
});
|
||
}
|
||
});
|
||
|
||
test('at a gust peak the downdraft is a real fraction of the horizontal', () => {
|
||
const f = createWindField(storms.storm_02_wildnight);
|
||
const out = { x: 0, y: 0, z: 0 };
|
||
let bestRatio = 0, atT = 0;
|
||
for (let t = 0; t <= f.duration; t += DT) {
|
||
f.vecAt(0, 0, t, out);
|
||
const horiz = Math.hypot(out.x, out.z);
|
||
if (horiz < 1) continue;
|
||
const r = Math.abs(out.y) / horiz;
|
||
if (r > bestRatio) { bestRatio = r; atT = t; }
|
||
}
|
||
metrics['storm_02.peakVerticalRatio'] = +bestRatio.toFixed(3);
|
||
assert(bestRatio > 0.12,
|
||
`strongest downdraft is only ${(bestRatio * 100).toFixed(0)}% of the horizontal wind (t=${atT.toFixed(1)}) — a flat sail still shrugs`);
|
||
});
|
||
|
||
test('validator rejects a bad downdraft', () => {
|
||
for (const dd of [-0.1, 1.5, NaN, 'lots']) {
|
||
const d = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||
d.gusts.downdraft = dd;
|
||
const { ok } = validateStorm(d, 'broken');
|
||
assert(!ok, `validator ACCEPTED downdraft = ${dd}`);
|
||
}
|
||
});
|
||
|
||
return { cases, metrics };
|
||
}
|
||
|
||
/** Run every case and collect results. Used by run-node.mjs. */
|
||
export function runWeatherSuite(storms) {
|
||
const { cases, metrics } = weatherCases(storms);
|
||
const results = cases.map((c) => {
|
||
try {
|
||
c.fn();
|
||
return { name: c.name, ok: true };
|
||
} catch (e) {
|
||
return { name: c.name, ok: false, err: e.message };
|
||
}
|
||
});
|
||
const pass = results.filter((r) => r.ok).length;
|
||
return { suite: 'weather', pass, fail: results.length - pass, results, metrics };
|
||
}
|