Landed ON TOP of f029274 with not one line of it changed. Two Lane C sessions
ran gate 1 in parallel; I found out by having my push rejected. forecastHonest
answers "can the forecast describe this STORM" and is better than the storm-side
half I had written, so mine is deleted. These are the two things it structurally
cannot reach — it takes a def, not a pairing, and it never touches the card.
weather.js is +180/-0. The only line of theirs I touch anywhere is widening one
c.test import from { NIGHTS } to { NIGHTS, nightAt }.
1. THE OFFER BAND, and the finding that motivates it. A's card prints f.wind
and f.stones, and under that pair NIGHT 7 LIES:
soaker (scripted) gusts to ~55 km/h · fine pea stones
early buster (alt) gusts to ~76 km/h · pea stones
On both printed lines the soaker is the softer job. It is not: 14.9s of hail
against 2.4s, 44 mm/hr against 16.5. The soaker's whole design is that its
wind reads gentle and its hail is the trap, so the board recommended the trap
in the storm's own voice. offerBand(def, lead) returns an ORDERED LIST, so
the forecast decides which facts a night carries and a card cannot drop the
next one added — same construction as A's stormsToPreload(nights, pool).
Verified in the real card on :8826.
Second finding: storm_03 and storm_03b are identical in every number the band
prints except the change time (30s vs 18s), and the board offers exactly that
pair on nights 5 and 7. Without the change line it offers two indistinguish-
able jobs and calls it a choice. Pinned.
2. PAIRING_LAW. A band can be perfectly honest about a storm and still be an
invitation to a night nobody can work. A's three POOL exclusions were prose;
promoted to checked data with the measurement in each message, credited by
owner (the soaker's is mine with the S16 ~31% receipt; wildnight and icenight
are recorded as A's reasons, not re-measured by me). Absent key = unrestricted
deliberately — inventing a refusal for a pairing nobody flew is the same
offence inverted, and that direction is pinned too. pairingRefusals is NOT a
filter: silently dropping an entry is a runtime surprise wearing a fix's
clothes. Walks NIGHTS u POOL carrying the SITE: 12 pairings, 0 refusals, with
a vacuity guard in the same case.
Mutation-checked on THIS tree after the code changed address: hail line dropped
(RED, names the storm and the seconds), soaker restriction deleted (RED x2,
including the vacuity guard), change line dropped (RED, names the two busters).
All restored, 494/0/0.
Filed in THREADS, not landed: the hud/main hunk for A (leadFor precedent), and
a floor for stoneWord — hail.size 0 words itself "fine pea stones" because
stoneWord has no floor. Their STONE_WORD_CEILING fixes the top of that range;
nothing fixes the bottom. It belongs inside forecastHonest, which is theirs.
Selftest 494/0/0 (their 490 + 4).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1601 lines
89 KiB
JavaScript
1601 lines
89 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, assertClose, assertEq, fixedLoop } from '../testkit.js';
|
||
import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS, createStubWind } from '../contracts.js';
|
||
import {
|
||
loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor, hailBlockFor,
|
||
forecastHonest, stormStats, STONE_WORD_CEILING,
|
||
// SPRINT17, the second half of gate 1 (see weather.js's divider): what the
|
||
// card PRINTS, and which yard a storm may be printed over.
|
||
offerBand, PAIRING_LAW, pairingRefusal, pairingRefusals,
|
||
} from '../weather.js';
|
||
import { loadSite, createWorld } from '../world.js';
|
||
// SPRINT17 gate 1 — the board's weather side. NIGHTS ∪ POOL is every storm an
|
||
// offer card can print; both are data, so the honesty walk below tracks them
|
||
// without a test edit the day D's pool yard lands.
|
||
import { NIGHTS, nightAt } from '../week.js';
|
||
import { POOL, exposureOf } from '../board.js';
|
||
// GATE 2.3 — the GAME's own wind wiring, IMPORTED rather than re-typed. A pin
|
||
// that copied main.js's two lines would agree with a copy of the game forever,
|
||
// including on the day the game itself changed. "Two harnesses, one number"
|
||
// means reaching for the real one.
|
||
import { createWindRouter } from '../main.js';
|
||
import { normalizeAxis, shelterAtten, worstSecond, STORM_KEYS as WIND_STORM_KEYS } from '../editor.wind.js';
|
||
import { createStormSel, stormSel, STORM_KEYS as SEL_STORM_KEYS, DEFAULT_SEL } from '../editor_storm.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';
|
||
// SPRINT16 gate 3: the membrane night's pin flies THE audit chain (gardenfly),
|
||
// and the seeded generator must satisfy the storm-side envelope harness.
|
||
import { flyGarden, hardwareByName } from '../../../../tools/site_audit/gardenfly.js';
|
||
import { stormEnvelope } from '../../../../tools/storm_envelope/envelope.js';
|
||
import { makeStorm } from '../stormgen.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',
|
||
// SPRINT16 gate 3.1: the pea-hail soaker — the first night the default
|
||
// fabric is the wrong answer. Its membrane-vs-cloth pin is below.
|
||
'storm_06_soaker',
|
||
];
|
||
|
||
/** @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');
|
||
});
|
||
|
||
// SPRINT13 gate 2.3 — ambient leaves. QA: "debris reads 0 through night 3";
|
||
// the crates are event drama, the leaves are the continuous tell. Threshold
|
||
// 8.3 m/s (30 km/h) matches D's lean threshold on purpose — the yard and the
|
||
// body start telling the same story at the same speed. Count stays single
|
||
// digits, they stream WITH the wind, and the layer is deterministic.
|
||
t.test('ambient leaves: none in a calm, a streaming handful from ~30 km/h', () => {
|
||
const stub = (sp) => ({
|
||
seed: 5,
|
||
sample: (p, t2, o) => o.set(sp, 0, 0),
|
||
eventsBetween: () => [],
|
||
});
|
||
const calm = createDebris({ wind: stub(5) });
|
||
fixedLoop(8, FIXED_DT, (dt, time) => calm.step(dt, time, {}));
|
||
assert(calm.leafCount === 0,
|
||
`${calm.leafCount} leaves flying at 18 km/h — below the threshold the yard is still`);
|
||
|
||
const gale = createDebris({ wind: stub(13) });
|
||
fixedLoop(8, FIXED_DT, (dt, time) => gale.step(dt, time, {}));
|
||
assert(gale.leafCount > 0, 'no leaves at 47 km/h — the gale has no ambient tell');
|
||
assert(gale.leafCount <= 9, `${gale.leafCount} leaves — the plan says single digits`);
|
||
|
||
// they stream WITH the wind (+x here): over one step every leaf either
|
||
// moves downwind or recycles ~a span upwind; none drifts against it
|
||
const before = gale.leaves.map((pos) => pos.x);
|
||
gale.step(FIXED_DT, 8, {});
|
||
const after = gale.leaves.map((pos) => pos.x);
|
||
assert(before.length === after.length, 'leaf count strobed across one step');
|
||
for (let i = 0; i < before.length; i++) {
|
||
const d = after[i] - before[i];
|
||
assert(d > 0 || d < -10, `leaf ${i} moved ${d.toFixed(3)} m against a +x wind`);
|
||
}
|
||
|
||
// deterministic: same seed, same (dt, t) stream, same leaves — bit for bit
|
||
const a = createDebris({ wind: stub(13) });
|
||
const b = createDebris({ wind: stub(13) });
|
||
fixedLoop(5, FIXED_DT, (dt, time) => { a.step(dt, time, {}); b.step(dt, time, {}); });
|
||
assert(a.leafCount === b.leafCount && a.leaves.every((pos, i) =>
|
||
pos.x === b.leaves[i].x && pos.y === b.leaves[i].y && pos.z === b.leaves[i].z),
|
||
'two identical (dt, t) streams produced different leaves');
|
||
|
||
// clear() rewinds the layer — next night starts from the same state
|
||
gale.clear();
|
||
assert(gale.leafCount === 0, 'clear() left leaves flying into the aftermath card');
|
||
});
|
||
|
||
// SPRINT13, the third time this repo learned it: the venturi lives in the
|
||
// SITE json, not the storm def. B's site_audit flew site_02 funnel-OFF in
|
||
// Sprint 11 (their sweep.selftest tells it); C's garden_bench did it again
|
||
// in Sprint 13 — `def.wind.venturi` off a storm def LOOKS right and never
|
||
// fires, and the funnel-off bench called the $80 line 91.5 FULL on a night
|
||
// the real game scores it 39.8 TATTERED (D's live replay). windForSite is
|
||
// the shared builder that makes the mistake unmakeable; this assert is
|
||
// B's strictly-raises pin lifted onto it, with the REAL shipped funnel:
|
||
// drop the setVenturi line and the two winds collapse to equal reads.
|
||
const site02 = await loadSite('site_02_corner_block'); // awaited here: Suite.test() is sync
|
||
t.test('windForSite flies the SITE venturi — the shipped funnel strictly raises the throat wind', () => {
|
||
const def = storms.storm_02_wildnight;
|
||
const site = site02;
|
||
const vl = site.wind?.venturi ?? [];
|
||
assert(vl.length > 0, "site_02 lost its venturi — this test (and the yard's weather) proves nothing");
|
||
const bare = createWind(def); // storm def alone — the trap itself
|
||
const funnelled = windForSite(def, site);
|
||
const throat = new THREE.Vector3(vl[0].x, 1.5, vl[0].z);
|
||
const a = new THREE.Vector3(), b = new THREE.Vector3();
|
||
let bareMean = 0, funMean = 0, n = 0;
|
||
for (let time = 20; time <= 80; time += 0.5) {
|
||
bare.sample(throat, time, a);
|
||
funnelled.sample(throat, time, b);
|
||
bareMean += Math.hypot(a.x, a.z); funMean += Math.hypot(b.x, b.z); n++;
|
||
}
|
||
bareMean /= n; funMean /= n;
|
||
assert(bareMean > 3, `the storm never blew at the throat (${bareMean.toFixed(1)} m/s) — vacuous`);
|
||
assert(funMean > bareMean * 1.1,
|
||
`throat wind ${funMean.toFixed(2)} vs bare ${bareMean.toFixed(2)} m/s — the site's venturi is not `
|
||
+ 'reaching windForSite\'s wind (it must call setVenturi the way main.js does at every site load)');
|
||
});
|
||
|
||
// D's aftermath find (S13, lane/d): the hail InstancedMesh at count:0 with
|
||
// frustum culling and depthWrite both off still draws instance 0's identity
|
||
// matrix — a ~5 cm always-on-top white sliver at the origin, the QA's "ghost
|
||
// near the shed table". The gate is mesh.visible, not count. This flies the
|
||
// wild night and demands the pool is invisible whenever no hail is falling
|
||
// and visible whenever it is — both directions, whole storm.
|
||
t.test("hail pool: invisible when nothing falls (D's aftermath sliver)", () => {
|
||
const scene = new THREE.Scene();
|
||
const camera = new THREE.PerspectiveCamera();
|
||
const wind = createWind(storms.storm_02_wildnight);
|
||
const sky = createSkyFx({ scene, camera, wind });
|
||
let sawHail = false, sawCalm = false;
|
||
fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
|
||
sky.step(dt, time, {});
|
||
// the mesh draws floor(1300 × intensity) stones, so a burst's first
|
||
// millisecond can honestly show zero — demand visibility only once the
|
||
// intensity buys at least one stone, and invisibility only at exactly 0
|
||
if (sky.hailAmount >= 1 / 1300) {
|
||
sawHail = true;
|
||
assert(sky.hail.mesh.visible, `hail falling at t=${time.toFixed(1)} but the pool is hidden`);
|
||
} else if (sky.hailAmount === 0) {
|
||
sawCalm = true;
|
||
assert(!sky.hail.mesh.visible, `no hail at t=${time.toFixed(1)} but the pool still draws — the sliver`);
|
||
}
|
||
});
|
||
assert(sawHail && sawCalm, 'storm never exercised both states — this test proves nothing');
|
||
sky.dispose();
|
||
});
|
||
|
||
// 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],
|
||
];
|
||
// gate 3.3: the stone-size band obeys the same law as every other number
|
||
// on the card — nested, truth-holding, exact at lead 0. It lives at
|
||
// f.hail.size, so the key row carries its own accessor.
|
||
keys.push(['hail.size', (s) => s.hailSize, (f) => f.hail.size]);
|
||
for (const [name, def] of Object.entries(storms)) {
|
||
for (const [k, truthOf, getBand] 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 = getBand ? getBand(f) : 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);
|
||
const eb = getBand ? getBand(exact) : exact[k];
|
||
assert(eb.lo === eb.hi,
|
||
`${name}.${k}: tonight should be exact, got ${eb.lo}–${eb.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`);
|
||
}
|
||
});
|
||
|
||
// =========================================================================
|
||
// GATE 2.3 — THE EDITOR'S WIND IS THE GAME'S WIND, PINNED EXACT
|
||
//
|
||
// Co-owned with Lane B. B proposed backyard_01 / storm_02_wildnight /
|
||
// (1,0,2) / t=30; I moved all three and posted the receipts in THREADS, for
|
||
// one reason: **B's pin could not have failed.**
|
||
// · backyard_01's `wind.venturi` is `[]`. On that site "setVenturi called
|
||
// with an empty list" and "setVenturi never called" are the same number,
|
||
// so the funnel-off regression the pin exists to catch is invisible.
|
||
// · the garden bed at (1,0,2) is outside the funnel radius even on
|
||
// site_02 — measured Δ 0.0000 m/s.
|
||
// · t=30 is a second where the wildnight's direction does not line up with
|
||
// the gap: at the throat the funnel is worth 0.4% there (0.0% on the
|
||
// southerly). A pin at 0.4% passes with the funnel wired backwards.
|
||
// So: the ONLY site with a shipped venturi, the throat centre (authored site
|
||
// geometry, not a magic number), and a second on the alignment plateau where
|
||
// the funnel is worth a THIRD of the answer. Same storm B picked, same exact
|
||
// `===`. The vacuity guard below is what stops this pin rotting back into
|
||
// decoration — I am the lane that shipped half an assert made of decoration,
|
||
// so the guard is not optional.
|
||
const PIN = {
|
||
site: 'site_02_corner_block',
|
||
storm: 'storm_02_wildnight',
|
||
probe: { x: -6, y: 0, z: 0 }, // the authored throat centre; speedAt ignores y
|
||
t: 60.0,
|
||
};
|
||
|
||
const pinSite = await loadSite(PIN.site);
|
||
let pinWorld = null;
|
||
try {
|
||
pinWorld = createWorld(new THREE.Scene(), {
|
||
wind: createStubWind({ calm: true }), site: structuredClone(pinSite),
|
||
});
|
||
await pinWorld.dress();
|
||
} catch (err) {
|
||
console.warn('[c.test] gate 2.3: dress() unavailable, using graybox anchors:', err.message);
|
||
}
|
||
const pinAnchors = pinWorld ? pinWorld.anchors : [];
|
||
const pinProbe = new THREE.Vector3(PIN.probe.x, PIN.probe.y, PIN.probe.z);
|
||
|
||
/** The EDITOR's wind: `windForSite` off the site object, exactly as
|
||
* editor.wind.js's `buildWind()` and B's SCORE IT both build it. */
|
||
const editorWind = () => windForSite(storms[PIN.storm], structuredClone(pinSite), pinAnchors);
|
||
|
||
/** The GAME's wind: main.js's router, wired by main.js's own two lines
|
||
* (loadSiteInto, `wind.setVenturi(...)` + `wind.setSheltersFromTrees(...)`). */
|
||
const gameWind = () => {
|
||
const all = STORMS.map((k) => createWind(storms[k]));
|
||
const router = createWindRouter(all);
|
||
router.use(all[STORMS.indexOf(PIN.storm)]);
|
||
router.setVenturi(pinSite.wind?.venturi ?? []);
|
||
router.setSheltersFromTrees(pinAnchors.filter((a) => a.type === 'tree'));
|
||
return router;
|
||
};
|
||
|
||
t.test('GATE 2.3: editor wind === game wind at the pinned probe and second (exact)', () => {
|
||
const ed = editorWind().speedAt(pinProbe, PIN.t);
|
||
const gm = gameWind().speedAt(pinProbe, PIN.t);
|
||
assert(Number.isFinite(ed) && ed > 0, `the pin sampled nothing: ${ed} m/s — vacuous`);
|
||
// EXACT, per B: two chains that agree to 1e-9 agree, and any epsilon big
|
||
// enough to feel safe is big enough to hide a 33% funnel.
|
||
assert(ed === gm,
|
||
`editor ${ed} m/s vs game ${gm} m/s at (${PIN.probe.x},${PIN.probe.z}) t=${PIN.t} on `
|
||
+ `${PIN.site}/${PIN.storm} — the editor is scoring a yard the game does not play`);
|
||
// the vector too, so a sign or a component can't drift under an equal scalar
|
||
const a = editorWind().sample(pinProbe, PIN.t, new THREE.Vector3());
|
||
const b = gameWind().sample(pinProbe, PIN.t, new THREE.Vector3());
|
||
assert(a.x === b.x && a.y === b.y && a.z === b.z,
|
||
`vector mismatch: editor (${a.x},${a.y},${a.z}) vs game (${b.x},${b.y},${b.z})`);
|
||
});
|
||
|
||
t.test('GATE 2.3 guard: the pinned probe/second is one the funnel actually decides', () => {
|
||
// Without this, the pin above passes just as happily at a probe the venturi
|
||
// never reaches — which is exactly how three harnesses measured a funnel-off
|
||
// yard and believed it. Re-measure the funnel's worth AT THE PIN, and demand
|
||
// it is a big fraction of the answer.
|
||
const full = editorWind().speedAt(pinProbe, PIN.t);
|
||
const funnelOff = createWind(storms[PIN.storm]);
|
||
funnelOff.setSheltersFromTrees(pinAnchors.filter((a) => a.type === 'tree')); // shelters ON
|
||
const off = funnelOff.speedAt(pinProbe, PIN.t); // venturi OFF
|
||
const share = (full - off) / full;
|
||
assert(share > 0.25,
|
||
`the venturi is worth only ${(share * 100).toFixed(1)}% of the wind at the pinned probe/second `
|
||
+ `(${full.toFixed(3)} vs ${off.toFixed(3)} m/s). The pin still passes — that is the problem. `
|
||
+ 'Move the probe/second back onto the funnel or the gate is decoration.');
|
||
});
|
||
|
||
t.test('GATE 2.3 (wider): editor and game agree across the yard and the storm', () => {
|
||
// The single pin is a point. Shelters live nowhere near the throat, so a
|
||
// point at the throat cannot see them: this sweep is what covers the tree
|
||
// half of `windForSite`'s wiring.
|
||
const ed = editorWind();
|
||
const gm = gameWind();
|
||
let checked = 0;
|
||
let sheltered = 0;
|
||
const bare = createWind(storms[PIN.storm]); // no shelters, no venturi
|
||
for (const time of [20, 45, 60, 75]) {
|
||
for (let x = -11; x <= 11; x += 1.5) {
|
||
for (let z = -7; z <= 7; z += 1.5) {
|
||
const p = new THREE.Vector3(x, 0, z);
|
||
const a = ed.speedAt(p, time);
|
||
const b = gm.speedAt(p, time);
|
||
assert(a === b, `editor ${a} vs game ${b} at (${x},${z}) t=${time}`);
|
||
if (a < bare.speedAt(p, time) * 0.95) sheltered++;
|
||
checked++;
|
||
}
|
||
}
|
||
}
|
||
// This floor caught its own sweep on the first run: at a 2 m step the grid
|
||
// was 384 samples, not the >500 I had claimed. Densified to 1.5 m rather
|
||
// than dropping the number to fit — a coverage floor edited down to match
|
||
// whatever the loop happened to do is not a floor.
|
||
assert(checked > 500, `only ${checked} samples — the sweep is not sweeping`);
|
||
assert(sheltered > 0,
|
||
'not one sampled point was materially slowed — the tree shelters are not reaching either '
|
||
+ 'chain, so this sweep proves nothing about setSheltersFromTrees');
|
||
});
|
||
|
||
// =========================================================================
|
||
// GATE 2.2 (SPRINT15) — ONE STORM PICKER: the shared selection store
|
||
//
|
||
// Sprint 14's page had two pickers with two defaults, and D scored the
|
||
// wildnight while looking at the southerly. editor_storm.js is the one
|
||
// owner now; these asserts pin its contract. The DOM half (my select and
|
||
// scrubber writing/reading it) cannot run here and is verified live on the
|
||
// editor page — said plainly rather than papered over with a fake DOM.
|
||
|
||
t.test('GATE 2.2: the shared storm selection refuses garbage, is silent on no-ops, and opens on the CONVERGED default', () => {
|
||
// The default is the pair B and C agreed in THREADS (B's storm — the
|
||
// authoring case: the wind overlay is live at page-open and the southerly
|
||
// makes the funnel scream; my second — t=40 is the S14 receipt's align-100%
|
||
// moment). Pinned HERE so neither lane's refactor can silently regrow a
|
||
// private default: two defaults was the whole bug.
|
||
assert(SEL_STORM_KEYS.includes(DEFAULT_SEL.storm), 'the default storm is not a real storm');
|
||
assert(DEFAULT_SEL.storm === 'storm_03_southerly' && DEFAULT_SEL.time === 40,
|
||
`the default selection (${DEFAULT_SEL.storm} @ ${DEFAULT_SEL.time}s) has drifted off the `
|
||
+ 'converged storm_03_southerly @ 40s — that pair is a B+C agreement (THREADS 2026-07-20), '
|
||
+ 'renegotiate it there before moving it here');
|
||
|
||
const s = createStormSel();
|
||
assert(s.storm === DEFAULT_SEL.storm && s.time === DEFAULT_SEL.time, 'a fresh store ignores the default');
|
||
|
||
const seen = [];
|
||
const off = s.onChange((p) => seen.push(p));
|
||
|
||
// a real change fires exactly once, payload matches the getters, and the
|
||
// writer's `source` tag (B's ask) rides the payload for debugging
|
||
assert(s.setStorm('storm_02_wildnight', 'c.test') === true, 'a real storm change should return true');
|
||
assert(seen.length === 1 && seen[0].storm === 'storm_02_wildnight' && seen[0].time === DEFAULT_SEL.time,
|
||
`one change should mean one event with the new value, got ${JSON.stringify(seen)}`);
|
||
assert(seen[0].source === 'c.test',
|
||
`the payload should carry the writer's source tag, got ${JSON.stringify(seen[0])}`);
|
||
|
||
// no-op writes are SILENT — this is what lets a subscriber clamp the
|
||
// selection from inside its own change handler without ringing forever
|
||
assert(s.setStorm('storm_02_wildnight') === false, 'a no-op storm write should return false');
|
||
assert(s.setTime(s.time) === false, 'a no-op time write should return false');
|
||
assert(seen.length === 1, `a no-op fired a listener — the anti-loop guard is gone (${seen.length} events)`);
|
||
|
||
// garbage refused, loudly, without moving the selection or ringing
|
||
for (const bad of ['storm_99_nope', '', null]) {
|
||
let threw = false;
|
||
try { s.setStorm(bad); } catch { threw = true; }
|
||
assert(threw, `setStorm(${JSON.stringify(bad)}) was accepted — a selection that can hold a `
|
||
+ 'storm that does not exist is the _v1-404 bug one layer up');
|
||
}
|
||
for (const bad of [NaN, -1, Infinity, 'soon']) {
|
||
let threw = false;
|
||
try { s.setTime(bad); } catch { threw = true; }
|
||
assert(threw, `setTime(${JSON.stringify(bad)}) was accepted`);
|
||
}
|
||
assert(s.storm === 'storm_02_wildnight' && seen.length === 1,
|
||
'refused garbage moved the selection or fired a listener anyway');
|
||
|
||
// a throwing listener must not sever delivery to the others
|
||
const order = [];
|
||
s.onChange(() => { order.push('a'); throw new Error('deliberate'); });
|
||
s.onChange(() => order.push('b'));
|
||
s.setTime(12.5);
|
||
assert(order.join('') === 'ab', `a throwing listener severed its neighbours (delivered: ${order})`);
|
||
|
||
// unsubscribe works: `seen` has the storm change + the 12.5 s write = 2,
|
||
// and the write AFTER off() must not add a third
|
||
off();
|
||
s.setTime(20);
|
||
assert(seen.length === 2, `unsubscribed listener still delivered (${seen.length} events)`);
|
||
});
|
||
|
||
t.test('GATE 2.2: one singleton, one storm list — every consumer holds the same objects', () => {
|
||
// Import identity is the sharing mechanism, so it is the thing to pin.
|
||
assert(stormSel() === stormSel(), 'stormSel() built two stores — the page is back to two pickers');
|
||
assert(globalThis.EDITOR_STORM === stormSel(),
|
||
"the console handle (B's EDITOR_STORM name, converged) points at a different store");
|
||
// editor.wind.js re-exports the store's list — SAME ARRAY, not a copy that
|
||
// can drift (two copies of this list is how the page grew two pickers).
|
||
assert(WIND_STORM_KEYS === SEL_STORM_KEYS, 'editor.wind.js carries its own storm list again');
|
||
// and the shared list agrees with this suite's (which the node runner
|
||
// keeps honest against data/storms/)
|
||
const a = [...SEL_STORM_KEYS].sort().join(',');
|
||
const b = [...STORMS].sort().join(',');
|
||
assert(a === b, `the shared storm list and the suite's disagree:\n store: ${a}\n suite: ${b}`);
|
||
});
|
||
|
||
t.test('worst-second finder: deterministic, self-consistent, and it cannot miss the pinned throat moment', () => {
|
||
// The JUMP TO WORST SECOND button (SPRINT15 polish) is a claim about the
|
||
// whole storm, so it gets the same discipline as any other number.
|
||
const wind = editorWind(); // site_02 × wildnight, dressed
|
||
const yard = pinSite.yard ?? { width: 30, depth: 20 };
|
||
const args = {
|
||
width: yard.width, depth: yard.depth,
|
||
probes: [{ x: PIN.probe.x, z: PIN.probe.z }], // the throat, as the panel passes it
|
||
};
|
||
const a = worstSecond(wind, args);
|
||
const b = worstSecond(wind, args);
|
||
assert(a.t === b.t && a.speed === b.speed && a.x === b.x && a.z === b.z,
|
||
`two identical scans disagreed: ${JSON.stringify(a)} vs ${JSON.stringify(b)} — the scan is not pure`);
|
||
assert(Number.isFinite(a.speed) && a.speed > 0, `vacuous scan: ${JSON.stringify(a)}`);
|
||
// the reported worst is a real sample, not an accumulator artefact
|
||
assert(a.speed === wind.core.speedAt(a.x, a.z, a.t),
|
||
`worst.speed ${a.speed} ≠ speedAt(worst) ${wind.core.speedAt(a.x, a.z, a.t)}`);
|
||
// dominance at a point ON the scan lattice: the throat probe is scanned
|
||
// exactly, and t=60.0 sits on the 0.5 s lattice, so the argmax reporting
|
||
// anything BELOW the pinned throat moment means the comparator or the
|
||
// probe wiring is broken. (An off-lattice point could legitimately exceed
|
||
// the scan; an on-lattice one cannot.)
|
||
const throatAtPin = wind.core.speedAt(PIN.probe.x, PIN.probe.z, PIN.t);
|
||
assert(a.speed >= throatAtPin,
|
||
`worst ${a.speed} m/s < throat@pin ${throatAtPin} m/s — the scan missed a lattice point it was given`);
|
||
// probes are WIRED, proven by making them the only way to win: a tiny grid
|
||
// pinned to the yard centre sits > 6 m from the throat — outside the
|
||
// funnel's 4 m radius, so no grid point ever sees the gain — while the
|
||
// throat probe does. If the probe list is dropped, the winner cannot be at
|
||
// the throat. (Deterministic: same scan, same winner, every run.)
|
||
const c = worstSecond(wind, { width: 4, depth: 4, step: 3, probes: [{ x: PIN.probe.x, z: PIN.probe.z }] });
|
||
assert(c.x === PIN.probe.x && c.z === PIN.probe.z,
|
||
`with the grid held off the funnel, the throat probe should win the scan — winner was `
|
||
+ `(${c.x}, ${c.z}) at ${c.speed} m/s, so the probes argument is not reaching the lattice`);
|
||
});
|
||
|
||
// =========================================================================
|
||
// The gizmos tell the truth about the maths they draw
|
||
|
||
t.test("shelter volume: the drawn attenuation IS weather.core's shelterFactor", () => {
|
||
// editor.wind.js draws the tree-shelter volume from `shelterAtten`, which
|
||
// mirrors weather.core's shelterFactor. Mirrors drift, and a gizmo that
|
||
// disagrees with the sim while looking authoritative is worse than none —
|
||
// so measure the mirror against the real field instead of trusting it.
|
||
// speedAt is multiplicative (uniform × noise × shelter × venturi), so the
|
||
// ratio of a sheltered field to an unsheltered one at the same (x,z,t) IS
|
||
// shelterFactor, with the noise divided out.
|
||
const def = storms.storm_02_wildnight;
|
||
const S = { x: 2, z: -3, radius: 3, strength: 0.45, length: 14 };
|
||
const withS = createWind(def).setShelters([S]);
|
||
const noS = createWind(def).setShelters([]);
|
||
let compared = 0;
|
||
let sawRealShadow = false;
|
||
for (const time of [12, 33, 58, 71]) {
|
||
const d = withS.core.dirAt(time);
|
||
const dx = Math.cos(d), dz = Math.sin(d);
|
||
for (let along = 0.5; along <= 13; along += 1.5) {
|
||
for (let perp = -2.5; perp <= 2.5; perp += 1.25) {
|
||
const x = S.x + dx * along - dz * perp;
|
||
const z = S.z + dz * along + dx * perp;
|
||
const bare = noS.core.speedAt(x, z, time);
|
||
if (bare <= 1e-9) continue;
|
||
const factor = withS.core.speedAt(x, z, time) / bare;
|
||
assertClose(factor, 1 - shelterAtten(S, along, perp), 1e-9,
|
||
`shelterAtten disagrees with the sim at along=${along} perp=${perp} t=${time} — `
|
||
+ 'the drawn volume is not the shadow the wind actually casts');
|
||
if (shelterAtten(S, along, perp) > 0.1) sawRealShadow = true;
|
||
compared++;
|
||
}
|
||
}
|
||
}
|
||
assert(compared > 100, `only ${compared} comparisons`);
|
||
assert(sawRealShadow, 'never sampled a point the shelter actually shades — vacuous');
|
||
});
|
||
|
||
t.test('the venturi axis is a LINE: axis and axis+π are the same gap', () => {
|
||
// The fact the editor's UI teaches, asserted rather than asserted-in-a-
|
||
// comment. weather.core aligns on |dot(wind, axis)|, so adding π must change
|
||
// nothing anywhere. If this ever fails, the axis has quietly become a
|
||
// heading and the mod-π UI is lying to whoever authored against it.
|
||
const def = storms.storm_02_wildnight;
|
||
const v = pinSite.wind.venturi[0];
|
||
const mk = (axis) => windForSite(def, { ...pinSite, wind: { venturi: [{ ...v, axis }] } }, pinAnchors);
|
||
const a = mk(v.axis);
|
||
const b = mk(v.axis + Math.PI);
|
||
let maxDiff = 0;
|
||
let sawFunnel = false;
|
||
const plain = createWind(def).setSheltersFromTrees(pinAnchors.filter((n) => n.type === 'tree'));
|
||
for (const time of [30, 60, 60.5, 75]) {
|
||
for (let x = -10; x <= -2; x += 1) {
|
||
for (let z = -4; z <= 4; z += 1) {
|
||
const p = new THREE.Vector3(x, 0, z);
|
||
const sa = a.speedAt(p, time);
|
||
maxDiff = Math.max(maxDiff, Math.abs(sa - b.speedAt(p, time)));
|
||
if (sa > plain.speedAt(p, time) * 1.05) sawFunnel = true;
|
||
}
|
||
}
|
||
}
|
||
assert(sawFunnel, 'the funnel never fired anywhere in the sweep — this proves nothing');
|
||
// not `===`: cos(θ+π) is only -cos(θ) to within floating point, so the two
|
||
// fields agree to rounding, not to the bit. Rounding is the honest bar here.
|
||
assert(maxDiff < 1e-9,
|
||
`axis and axis+π gave different winds (max ${maxDiff} m/s) — the venturi is not mod π`);
|
||
});
|
||
|
||
t.test('normalizeAxis folds any angle into [0, π) without moving the gap', () => {
|
||
const cases = [2.1, 2.1 + Math.PI, 2.1 - Math.PI, -1.08, 0, 7 * Math.PI, -3 * Math.PI / 4];
|
||
for (const raw of cases) {
|
||
const n = normalizeAxis(raw);
|
||
assert(n >= 0 && n < Math.PI, `normalizeAxis(${raw}) = ${n} is outside [0, π)`);
|
||
// same LINE: the direction vectors must be parallel or antiparallel
|
||
const cross = Math.cos(raw) * Math.sin(n) - Math.sin(raw) * Math.cos(n);
|
||
assertClose(cross, 0, 1e-12, `normalizeAxis(${raw}) rotated the gap instead of folding it`);
|
||
}
|
||
// the Sprint 11 pair, which cost two exchanges: 2.1 is the gap, -1.08 is the
|
||
// southerly's heading, and they are NOT the same number — 2.2° apart.
|
||
const gap = normalizeAxis(2.1);
|
||
const heading = normalizeAxis(-1.08);
|
||
assert(Math.abs(gap - heading) > 0.03,
|
||
'the gap axis and the southerly heading have collapsed to the same value — they are '
|
||
+ 'different quantities that merely nearly coincide, and conflating them is the '
|
||
+ 'mistake THREADS records A and C both nearly making');
|
||
});
|
||
|
||
// =========================================================================
|
||
// SPRINT16 GATE 3.3 — the stone and the rain rate join the card
|
||
t.test('gate 3.3: forecast stones and rain rate — worded, banded, exact tonight, never NaN', () => {
|
||
const soaker = storms.storm_06_soaker;
|
||
const tonight = forecastLines(soaker, 0);
|
||
// tonight is exact: the soaker's truth, in the fabric bet's own words
|
||
assert(tonight.stones === 'fine pea stones',
|
||
`the soaker's stones read "${tonight.stones}" — size 0.45 is the fine pea the cloth leaks, `
|
||
+ 'and the card is where the player learns that before spending a dollar');
|
||
assert(tonight.rainRate === 'rain to 44 mm/hr',
|
||
`the soaker's rain rate reads "${tonight.rainRate}" — want the exact 44 mm/hr at lead 0`);
|
||
assert(/hail likely/.test(tonight.rain),
|
||
`the soaker (14.9 hail-seconds) should call its hail likely: "${tonight.rain}"`);
|
||
// the sizes cloth stops dead read as the big words
|
||
assert(forecastLines(storms.storm_02_wildnight, 0).stones === 'golf ball stones',
|
||
`wild night stones read "${forecastLines(storms.storm_02_wildnight, 0).stones}"`);
|
||
assert(forecastLines(storms.storm_03_southerly, 0).stones === 'pea stones',
|
||
`southerly stones read "${forecastLines(storms.storm_03_southerly, 0).stones}" — 0.7 is `
|
||
+ "storm_03's own comment's word");
|
||
// a hail-free storm advertises nothing
|
||
assert(forecastLines(storms.storm_01_gentle, 0).stones === '',
|
||
'the gentle storm advertised stones it does not have');
|
||
// far out, the rate hedges as a band (the stone word may or may not span
|
||
// two words — the BAND honesty is pinned in the resolving test above)
|
||
const far = forecastLines(soaker, 1);
|
||
assert(/–/.test(far.rainRate), `a week out the rain rate should hedge, got "${far.rainRate}"`);
|
||
assert(/fine pea/.test(far.stones), `the soaker a week out still forecasts pea-family stones, got "${far.stones}"`);
|
||
// no NaN/undefined reaches the card, any storm, 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 ['stones', 'rainRate']) {
|
||
assert(typeof f[k] === 'string' && !/NaN|undefined/.test(f[k]),
|
||
`${name} @lead ${lead}: ${k} reads "${f[k]}"`);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// =========================================================================
|
||
// SPRINT16 GATE 3.2 — the generator satisfies the storm-side harness
|
||
t.test('gate 3.2: a generated storm flies the storm_envelope harness, deterministically', () => {
|
||
const anchors = [
|
||
{ id: 'a', type: 'post', pos: { x: 0, y: 4, z: 0 } },
|
||
{ id: 'b', type: 'post', pos: { x: 5, y: 4, z: 3 }, ratingHint: 0.45 },
|
||
];
|
||
const r1 = stormEnvelope({ anchors, stormDef: makeStorm(7) });
|
||
const r2 = stormEnvelope({ anchors, stormDef: makeStorm(7) });
|
||
for (const r of r1.rows) {
|
||
assert(Number.isFinite(r.peak) && r.peak > 0, `vacuous envelope row for ${r.id}: peak ${r.peak}`);
|
||
assert(Number.isFinite(r.dose) && r.dose > 0, `vacuous dose for ${r.id}`);
|
||
assert(r.tPeak >= 0 && r.tPeak <= 90, `${r.id} peaked outside the storm at t=${r.tPeak}`);
|
||
}
|
||
r1.rows.forEach((r, i) => {
|
||
const s = r2.rows[i];
|
||
assert(r.peak === s.peak && r.tPeak === s.tPeak && r.dose === s.dose,
|
||
`same seed, two envelope runs disagree at ${r.id}: ${r.peak} vs ${s.peak}`);
|
||
});
|
||
// hint reaches the ranking exactly as it does for authored storms
|
||
assert(r1.rows[0].id === 'b',
|
||
`equal-ish wind, hint 0.45 vs 1.0 — 'b' must rank first, got ${r1.rows.map((r) => r.id).join(',')}`);
|
||
// and the seed is a real variable through the harness, not just the JSON
|
||
const other = stormEnvelope({ anchors, stormDef: makeStorm(8) });
|
||
assert(other.rows.some((r, i) => r.peak !== r1.rows[i].peak),
|
||
'seeds 7 and 8 measured identically through the harness — the seed is not reaching the wind');
|
||
});
|
||
|
||
// =========================================================================
|
||
// SPRINT16 GATE 3.1 — THE MEMBRANE NIGHT, PINNED THROUGH THE REAL CHAIN
|
||
//
|
||
// storm_06_soaker exists to make the F key matter: pea stones under the
|
||
// cloth aperture, heavy rain, mild wind. The pin flies gardenfly — THE
|
||
// audit scoring chain (windForSite → attach → skyfx → garden.js), dressed
|
||
// site_02 anchors, live sway, funnel ON — with porosity the ONLY variable
|
||
// between the cloth and membrane flights. Numbers measured 2026-07-20
|
||
// (fabric_bet probe, post-ruling tree): bare 17.5 · cloth $75 ring 42.3 ·
|
||
// membrane $75 ring 96.5 · membrane $60 ring 68.8. Thresholds pin the
|
||
// BOUNDARIES the night stakes, with margin, not the digits.
|
||
const soakerDef = storms.storm_06_soaker;
|
||
const soakerSite = site02; // loaded above for gate 2.3
|
||
let soakerFlights = null;
|
||
{
|
||
// garden_probe's proxy pattern: world.anchors fly THEMSELVES, and `use`
|
||
// re-points the sway closures at the storm actually flying (the frozen-
|
||
// tree landmine — tr1/tr1b are the pin's own corners, they must sway).
|
||
let cur = createStubWind({ calm: true });
|
||
const proxy = {
|
||
sample: (p, t2, o) => cur.sample(p, t2, o || new THREE.Vector3()),
|
||
speedAt: (p, t2) => cur.speedAt(p, t2),
|
||
rainAt: (t2) => (cur.rainAt ? cur.rainAt(t2) : 0),
|
||
rainMmPerHour: (t2) => (cur.rainMmPerHour ? cur.rainMmPerHour(t2) : 0),
|
||
gustTelegraph: (t2) => (cur.gustTelegraph ? cur.gustTelegraph(t2) : null),
|
||
eventsBetween: (a, b) => (cur.eventsBetween ? cur.eventsBetween(a, b) : []),
|
||
setSheltersFromTrees() {},
|
||
};
|
||
const use = (w) => { cur = w; };
|
||
try {
|
||
const world = createWorld(new THREE.Scene(), { wind: proxy, site: structuredClone(soakerSite) });
|
||
await world.dress(); // graybox would move the tree corners — fail loud instead
|
||
const bed = world.gardenBed;
|
||
const ring = ['q1', 'q2', 'q3', 'q4'];
|
||
const hw75 = ['rated shackle', 'shackle', 'shackle', 'shackle'].map(hardwareByName);
|
||
const hw60 = ['shackle', 'shackle', 'shackle', 'shackle'].map(hardwareByName);
|
||
const hw20 = Array(4).fill(hardwareByName('carabiner'));
|
||
const common = { anchors: world.anchors, bed, stormDef: soakerDef, siteDef: soakerSite, use };
|
||
soakerFlights = {
|
||
bare: flyGarden({ ...common }),
|
||
cloth75: flyGarden({ ...common, ids: ring, hw: hw75, porosity: 0.30 }),
|
||
mem75: flyGarden({ ...common, ids: ring, hw: hw75, porosity: 0 }),
|
||
mem60: flyGarden({ ...common, ids: ring, hw: hw60, porosity: 0 }),
|
||
cloth20: flyGarden({ ...common, ids: ring, hw: hw20, porosity: 0.30 }),
|
||
mem20: flyGarden({ ...common, ids: ring, hw: hw20, porosity: 0 }),
|
||
};
|
||
} catch (err) {
|
||
soakerFlights = { error: err };
|
||
}
|
||
}
|
||
|
||
t.test('GATE 3.1: the soaker flips the fabric bet — membrane wins the night cloth loses', () => {
|
||
if (soakerFlights?.error) throw soakerFlights.error;
|
||
const { bare, cloth75, mem75, mem60 } = soakerFlights;
|
||
|
||
// vacuity guards first: the night must actually threaten, through hail
|
||
assert(bare.byHail > 40,
|
||
`bare bed took only ${bare.byHail} HP of hail — the soaker has stopped soaking and this pin proves nothing`);
|
||
assert(bare.hp < 30, `bare bed survived at ${bare.hp} — the night has no teeth`);
|
||
|
||
// the primitive the whole night stands on: these stones pass a 2 mm weave
|
||
const size = soakerDef.hail.size;
|
||
assert(hailBlockFor(size, 0.30) < 0.30,
|
||
`cloth blocks ${(hailBlockFor(size, 0.30) * 100).toFixed(0)}% of size-${size} stones — the soaker's `
|
||
+ 'hail has drifted above the weave aperture and the bet is dead');
|
||
assert(hailBlockFor(size, 0) === 1, 'membrane stopped blocking — hailBlockFor broke');
|
||
|
||
// CLOTH LOSES: the best measured cloth line in the yard (this ring, $75)
|
||
// ends under the win line — the default fabric is the wrong answer.
|
||
assert(cloth75.hp < 50,
|
||
`the $75 ring on CLOTH reads ${cloth75.hp} — over the win line, so the default fabric wins the `
|
||
+ 'membrane night and storm_06 is a decoration (retune the hail spectrum, per the gate)');
|
||
// ...but visibly beats bare: the cloth is leaking, not absent
|
||
assert(cloth75.hp > bare.hp + 10,
|
||
`cloth ${cloth75.hp} vs bare ${bare.hp} — cloth is doing nothing; the leak should be partial`);
|
||
|
||
// MEMBRANE WINS, same line, one variable: the F key is the night
|
||
assert(mem75.hp > 90 && mem75.state === 'full',
|
||
`the SAME $75 ring on MEMBRANE reads ${mem75.hp} ${mem75.state} — want >90 FULL (measured 96.5)`);
|
||
assert(mem75.cornersLost < 2,
|
||
`membrane $75 lost ${mem75.cornersLost} corners — main.js's win rule (lost<2) fails, the load is not affordable`);
|
||
assert(mem75.cost <= 80, `the membrane line costs $${mem75.cost} — the $80 wallet cannot buy the night's answer`);
|
||
|
||
// and the bet is affordable a tier down: the $60 ring also wins on membrane
|
||
// (measured 68.8 FULL, one pond corner late — the broom's job in play)
|
||
assert(mem60.hp > 60 && mem60.cornersLost < 2,
|
||
`the $60 ring on membrane reads ${mem60.hp} with ${mem60.cornersLost} lost — the bet should not `
|
||
+ 'need the last dollar of the wallet');
|
||
|
||
// THE SEPARATION, pinned: same line, same steel, fabric alone is worth
|
||
// most of a garden (measured Δ54.2 — threshold carries real margin)
|
||
assert(mem75.hp - cloth75.hp > 40,
|
||
`membrane-vs-cloth separation ${(mem75.hp - cloth75.hp).toFixed(1)} HP on the same $75 line — `
|
||
+ 'under 40, the F key has stopped being the night');
|
||
});
|
||
|
||
t.test('GATE 3.1 guard: membrane is not a free win — on cheap steel it tears', () => {
|
||
// The inverse control, so the night cannot rot into "always press F":
|
||
// the doubled load must make membrane WORSE than cloth on carabiners.
|
||
// (Measured: cloth $20 ring 24.5 hp / 2 lost · membrane $20 ring 19.1 hp
|
||
// / 3 lost.) Without this, fabric would be a one-way upgrade and the bet
|
||
// a formality — the same decoration failure from the other side.
|
||
if (soakerFlights?.error) throw soakerFlights.error;
|
||
const { cloth20, mem20 } = soakerFlights;
|
||
assert(cloth20.cornersLost > 0 || mem20.cornersLost > 0,
|
||
'neither cheap ring lost a corner — carabiners hold the soaker and this control proves nothing');
|
||
assert(mem20.cornersLost > cloth20.cornersLost || mem20.hp < cloth20.hp,
|
||
`membrane on carabiners (${mem20.hp} hp, ${mem20.cornersLost} lost) should read WORSE than cloth `
|
||
+ `(${cloth20.hp} hp, ${cloth20.cornersLost} lost) — the doubled load has stopped costing anything`);
|
||
});
|
||
|
||
// ════════════════════════════════════════════════════════════════════════
|
||
// SPRINT17 gate 1 — THE BOARD'S WEATHER SIDE (C).
|
||
//
|
||
// A's seam contract (THREADS 2026-07-21): the offer card prints my
|
||
// forecastLines(def, 0), and "a storm the forecast can't describe honestly
|
||
// doesn't get offered" is MY rule to enforce. Enforced here, over the union
|
||
// of both night sources, so a new pool entry meets the gate at integration
|
||
// without anyone remembering to ask.
|
||
// ════════════════════════════════════════════════════════════════════════
|
||
|
||
// Every storm the board can put on a card, from BOTH sources, by
|
||
// construction. A's stormsToPreload mutation finding is the warning label:
|
||
// today every POOL storm also flies in NIGHTS, so a walk of NIGHTS alone
|
||
// would be green by coincidence and rot the day D's pool yard brings a storm
|
||
// of its own. The union is taken from the shipped data, so that day changes
|
||
// this walk without anyone editing a test.
|
||
const offerStormNames = [...new Set([
|
||
...NIGHTS.map((n) => (typeof n === 'string' ? n : n.storm)),
|
||
...POOL.map((p) => p.storm),
|
||
])];
|
||
const offerDefs = {};
|
||
const offerLoadErrors = [];
|
||
for (const nm of offerStormNames) {
|
||
try { offerDefs[nm] = storms[nm] ?? await loadStorm(nm); }
|
||
catch (err) { offerLoadErrors.push(`${nm}: ${err.message}`); }
|
||
}
|
||
|
||
t.test('GATE 1 (S17): a storm the forecast cannot describe honestly is not offered', () => {
|
||
assert(offerLoadErrors.length === 0,
|
||
`offerable storms failed to load/validate:\n ${offerLoadErrors.join('\n ')}`);
|
||
// Vacuity guard: the union walk must actually be walking the shipped week.
|
||
assert(offerStormNames.length >= 6,
|
||
`only ${offerStormNames.length} offerable storms found — the NIGHTS ∪ POOL walk is broken`);
|
||
const failures = [];
|
||
for (const nm of offerStormNames) {
|
||
const h = forecastHonest(offerDefs[nm], nm);
|
||
if (!h.ok) failures.push(`${nm}:\n ${h.errors.join('\n ')}`);
|
||
}
|
||
assert(failures.length === 0,
|
||
'the board offers storms the forecast cannot describe honestly — pull them from POOL/NIGHTS '
|
||
+ `or fix the def (A's contract: C vetoes, A pulls):\n ${failures.join('\n ')}`);
|
||
});
|
||
|
||
t.test('GATE 1 (S17): the honesty gate can fail — a lying change and an unspeakable stone are refused', () => {
|
||
// Negative controls through the SAME predicate the gate walk uses — the
|
||
// composition a pool-only storm exercises the day one exists. (The band-
|
||
// containment loops inside forecastHonest are structural — band() cannot
|
||
// exclude the truth by construction — and are disclosed as regression
|
||
// armour in its header, not counted as coverage. THESE two are the teeth.)
|
||
const lying = structuredClone(storms.storm_03_southerly);
|
||
lying.dirCurve = lying.dirCurve.map(([tt]) => [tt, lying.dirCurve[0][1]]); // promises a change, never turns
|
||
const l = forecastHonest(lying, 'lying_southerly');
|
||
assert(!l.ok, 'a windchange the dirCurve never delivers passed the honesty gate');
|
||
assert(l.errors.some((e) => e.includes('windchange')),
|
||
`the refusal should name the lie, got:\n ${l.errors.join('\n ')}`);
|
||
|
||
const unspeakable = structuredClone(storms.storm_02b_icenight);
|
||
unspeakable.hail.size = STONE_WORD_CEILING + 0.6;
|
||
const u = forecastHonest(unspeakable, 'cricket_ball_night');
|
||
assert(!u.ok, `hail.size ${unspeakable.hail.size} passed — the vocabulary ceiling is decoration`);
|
||
assert(u.errors.some((e) => e.includes('vocabulary') || e.includes('ceiling')),
|
||
`the refusal should name the vocabulary, got:\n ${u.errors.join('\n ')}`);
|
||
// and the shipped icenight sits under the ceiling with honest headroom
|
||
assert(stormStats(storms.storm_02b_icenight).hailSize <= STONE_WORD_CEILING,
|
||
'the shipped icenight is over the vocabulary ceiling — the gate would pull a shipped night');
|
||
});
|
||
|
||
t.test('GATE 1 (S17): the offer card\'s lead-0 lines carry the measured truth', () => {
|
||
// The board prints forecastLines(def, 0) (A's pricedOffers — "the same
|
||
// call the job sheet makes"). Cross the card TEXT against stormStats'
|
||
// measured numbers, so the wording cannot drift off the measurement while
|
||
// both stay green alone. Walks the POOL: these are the cards a player
|
||
// reads about a night they have never flown.
|
||
for (const p of POOL) {
|
||
const def = offerDefs[p.storm];
|
||
const f = forecastLines(def, 0);
|
||
assert(f.confidence === '',
|
||
`${p.id}: a lead-0 offer hedges ("${f.confidence}") — tonight is exact and the card must not waffle`);
|
||
const g = f.wind.match(/gusts to ~(\d+) km\/h/);
|
||
assert(g, `${p.id}: the wind line lost its gust figure: "${f.wind}"`);
|
||
assertEq(g[1], (stormStats(def).gustPeak * 3.6).toFixed(0),
|
||
`${p.id}: the card's gust number is not the measured storm`);
|
||
const su = f.wind.match(/^sustained to (\d+)/);
|
||
assert(su, `${p.id}: the wind line lost its sustained figure: "${f.wind}"`);
|
||
assertEq(su[1], stormStats(def).sustained.toFixed(0),
|
||
`${p.id}: the card's sustained number is not the measured storm`);
|
||
}
|
||
});
|
||
|
||
t.test('GATE 1 (S17): the offer band carries every fact the storm delivers, and none it does not', () => {
|
||
// ⚠️ THE FINDING THIS CASE EXISTS FOR. A's board prints `f.wind` and
|
||
// `f.stones` — a reasonable pair — and under that pair NIGHT 7 LIES:
|
||
// soaker (scripted) gusts to ~55 km/h · fine pea stones
|
||
// early buster (alt) gusts to ~76 km/h · pea stones
|
||
// On both printed lines the soaker is the softer job, while delivering
|
||
// 14.9s of hail against 2.4s and 44 mm/hr against 16.5. `offerBand` returns
|
||
// the ORDERED LIST instead, so the forecast decides which facts a night
|
||
// carries and a card cannot drop the next one somebody adds.
|
||
for (const nm of offerStormNames) {
|
||
const def = offerDefs[nm];
|
||
const st = stormStats(def);
|
||
const lines = offerBand(def, 0);
|
||
const keys = lines.map((l) => l.key);
|
||
const text = (k) => lines.find((l) => l.key === k)?.text ?? '';
|
||
|
||
assert(keys.includes('wind'), `${nm}: no wind line on the offer band`);
|
||
assert(keys.includes('rain'), `${nm}: no rain-rate line on the offer band`);
|
||
assert(keys.includes('change'), `${nm}: no change line on the offer band`);
|
||
assert(!keys.includes('confidence'),
|
||
`${nm}: tonight's band hedged its own confidence — lead 0 is exact`);
|
||
|
||
// HAIL, BOTH DIRECTIONS — the assert night 7 needed. Present iff it hails:
|
||
// a missing line recommends the trap, an invented one teaches that the
|
||
// absence of a hail line means somebody checked.
|
||
assert(keys.includes('hail') === (st.hailSeconds > 0),
|
||
`${nm}: hails for ${st.hailSeconds.toFixed(1)}s but the band's hail line is `
|
||
+ `${keys.includes('hail') ? 'present' : 'MISSING'}`);
|
||
if (st.hailSeconds > 0) {
|
||
assert(/fine pea|pea|marble|golf ball/.test(text('hail')),
|
||
`${nm}: hail line has no stone word — "${text('hail')}"`);
|
||
assert(/\ds of it/.test(text('hail')),
|
||
`${nm}: hail line does not say how long it hails — "${text('hail')}"`);
|
||
}
|
||
// The rate is the OTHER half of the soaker's trap, and the unit ponding
|
||
// reads — its absence is what made the two-field card lie.
|
||
assert(new RegExp(`${Math.round(st.rainPeakMmPerHour)} mm/hr`).test(text('rain')),
|
||
`${nm}: peaks at ${st.rainPeakMmPerHour.toFixed(1)} mm/hr, band says "${text('rain')}"`);
|
||
if (st.changeAt != null) {
|
||
assert(text('change').includes(`${Math.round(st.changeAt)}s`),
|
||
`${nm}: changes at ${st.changeAt}s, band says "${text('change')}"`);
|
||
} else {
|
||
assert(!/\d/.test(text('change')), `${nm}: never changes, band says "${text('change')}"`);
|
||
}
|
||
for (const l of lines) {
|
||
assert(!/NaN|Infinity|undefined/.test(l.text),
|
||
`${nm}: the ${l.key} line does not word its own numbers — "${l.text}"`);
|
||
}
|
||
}
|
||
});
|
||
|
||
t.test('GATE 1 (S17): the two buster variants are TELLABLE APART on a board card', () => {
|
||
// storm_03 and storm_03b are IDENTICAL in every number the band prints
|
||
// (sustained 13.0, gustPeak 21.4/21.2, rain 16.5 mm/hr, hail 2.4s at size
|
||
// 0.70) and differ ONLY in when the change lands, 30s against 18s. The
|
||
// board offers exactly that pair — the buster is the drawn alternative on
|
||
// nights 5 and 7. Drop the change line and the board offers two jobs whose
|
||
// weather it has just claimed to describe, described identically, and calls
|
||
// it a choice. That is why the band is a list and not two named fields.
|
||
const a = offerBand(storms.storm_03_southerly, 0).map((l) => l.text);
|
||
const b = offerBand(storms.storm_03b_earlybuster, 0).map((l) => l.text);
|
||
const differing = a.filter((line, ix) => line !== b[ix]);
|
||
assert(differing.length > 0,
|
||
'storm_03 and storm_03b word IDENTICAL offer bands — the board would offer two '
|
||
+ 'indistinguishable jobs and call it a choice');
|
||
assert(differing.some((l) => /change/.test(l)),
|
||
`the two busters differ on ${differing.join(' / ')} — expected the CHANGE TIME, since `
|
||
+ 'that is the only thing that differs in the storms');
|
||
});
|
||
|
||
// ── The PAIRING half of gate 1. `forecastHonest` above asks whether the
|
||
// forecast can describe a STORM; a band can be perfectly honest about a storm
|
||
// and still be an invitation to a night nobody can work. NIGHTS ∪ POOL again,
|
||
// this time carrying the SITE — the dimension the storm-side walk cannot see.
|
||
const offerPairings = [
|
||
...NIGHTS.map((_, ix) => {
|
||
const n = nightAt(ix);
|
||
return { id: `night ${ix + 1}`, storm: n.storm, site: n.site };
|
||
}),
|
||
...POOL.map((p) => ({ id: `pool:${p.id}`, storm: p.storm, site: p.site })),
|
||
];
|
||
|
||
t.test('GATE 1 (S17): every offerable PAIRING respects the measured yard/storm law', () => {
|
||
assert(offerPairings.length >= 12,
|
||
`only ${offerPairings.length} pairings found — the NIGHTS ∪ POOL walk is broken`);
|
||
const refused = pairingRefusals(offerPairings);
|
||
assert(refused.length === 0,
|
||
`the board would offer ${refused.length} pairing(s) refused by measurement:\n `
|
||
+ refused.map((r) => `${r.id}: ${r.why}`).join('\n '));
|
||
// Vacuity guard, on this exact input: without it the assert above passes on
|
||
// a law that returns [] unconditionally — the failure mode I shipped in S13
|
||
// and named. Move ONE night's yard and exactly one refusal must appear.
|
||
const moved = offerPairings.map((p) =>
|
||
(p.storm === 'storm_06_soaker' ? { ...p, site: 'backyard_01' } : p));
|
||
assert(pairingRefusals(moved).length >= 1,
|
||
'moving the soaker onto backyard_01 did not trip the law — the assert above is '
|
||
+ 'passing on a gate that cannot say no');
|
||
});
|
||
|
||
t.test('GATE 1 (S17): the pairing law refuses what it was measured to refuse, and nothing else', () => {
|
||
// MINE, with the S16 receipt in the message: the soaker over backyard_01
|
||
// has no win in it (hail cover capped at ~31%, both fabrics pond-tearing).
|
||
assert(pairingRefusal('storm_06_soaker', 'backyard_01') !== null,
|
||
'the soaker over the backyard is offerable — the ~31% hail-cover measurement has '
|
||
+ 'stopped being enforced');
|
||
assert(/31%/.test(pairingRefusal('storm_06_soaker', 'backyard_01')),
|
||
'the refusal does not carry the measurement that justifies it');
|
||
assert(pairingRefusal('storm_06_soaker', 'site_03_swing_lawn') !== null,
|
||
'the soaker over the swing lawn is offerable, and nobody has ever flown it');
|
||
assert(pairingRefusal('storm_06_soaker', 'site_02_corner_block') === null,
|
||
'the soaker over site_02 is REFUSED — that is the pairing it shipped for');
|
||
// A's two, enforced the same way and credited to A in the data.
|
||
assert(pairingRefusal('storm_02_wildnight', 'site_02_corner_block') !== null,
|
||
'the wild night is offerable off backyard_01, where its separation pin lives');
|
||
assert(pairingRefusal('storm_02b_icenight', 'site_03_swing_lawn') !== null,
|
||
'the ice night is offerable off backyard_01, where gardenBeyondSaving was measured');
|
||
assert(PAIRING_LAW.storm_02_wildnight.owner === 'A' && PAIRING_LAW.storm_06_soaker.owner === 'C',
|
||
'the law has lost track of who ruled which entry — the owner field is how a reader '
|
||
+ 'knows whose measurement to go and read');
|
||
// And it must NOT over-refuse: absence of a measurement is not a refusal.
|
||
assert(pairingRefusal('storm_03_southerly', 'site_03_swing_lawn') === null,
|
||
'the southerly over the swing lawn is refused — that is night 4, the shipped ladder');
|
||
assert(pairingRefusal('storm_99_unmeasured', 'anywhere') === null,
|
||
'a storm with no entry in the law was refused — inventing a refusal for a pairing '
|
||
+ 'nobody flew is the same offence in the other direction');
|
||
});
|
||
|
||
// ── GATE 1.2 (S17): THE EXPOSURE CROSS — A's exposureOf vs the failure
|
||
// route, the S14 pin pattern (two harnesses, one number, by construction).
|
||
// Prep up front (Suite.test can't await); the tests skip honestly offline.
|
||
const crossWorlds = {};
|
||
for (const nm of ['site_02_corner_block', 'backyard_01', 'site_03_swing_lawn']) {
|
||
try {
|
||
const sj = await loadSite(nm);
|
||
const w = createWorld(new THREE.Scene(), { wind: createStubWind({ calm: true }), site: sj });
|
||
let dressed = true;
|
||
try { await w.dress(); } catch { dressed = false; } // graybox anchors carry no GLB collateral keys
|
||
crossWorlds[nm] = { sj, w, dressed };
|
||
} catch (err) {
|
||
crossWorlds[nm] = { error: err.message };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* MY envelope: what a failure can actually bill, gathered the way the game
|
||
* bills it (main.js scoreRun) — every collateral KEY reachable from a BUILT
|
||
* anchor, priced once per structure through world.collateralFor (the
|
||
* resolver the invoice uses), plus the gnome (billed on a two-corner
|
||
* collapse, priced off world.gnome). A's exposureOf walks the site JSON up
|
||
* front and never sees an anchor; this walks the built world and never sees
|
||
* the JSON's structure list. Two routes to one set of dollars — when they
|
||
* disagree, find the variable before either is tuned; it will be the
|
||
* harness (the S14 rule, verbatim).
|
||
*/
|
||
const failureEnvelope = (w) => {
|
||
const keys = [...new Set(w.anchors.map((a) => a.collateral).filter(Boolean))];
|
||
const items = [];
|
||
const unpriced = [];
|
||
for (const k of keys) {
|
||
const p = w.collateralFor(k);
|
||
if (p) items.push({ what: p.label, cost: p.cost });
|
||
else unpriced.push(k);
|
||
}
|
||
if (Number.isFinite(w.gnome?.collateralValue)) {
|
||
items.push({ what: 'garden gnome', cost: w.gnome.collateralValue });
|
||
}
|
||
return { items, unpriced, total: items.reduce((s, i) => s + i.cost, 0) };
|
||
};
|
||
|
||
t.test('GATE 1.2 (S17): collateral exposure — two harnesses, one set of dollars', () => {
|
||
// The explicit dollar pins are the vacuity guard: two empty walks agreeing
|
||
// on $0 cannot pass here. 205 = carport 180 + gnome 25; 115 = gutter 90 +
|
||
// gnome 25; 255 = gutter 90 + swing set 140 + gnome 25 (the numbers A
|
||
// verified live on the board, and a.test pins per-structure). Every yard
|
||
// the board can currently offer is crossed — the gate asked for one.
|
||
const pins = { site_02_corner_block: 205, backyard_01: 115, site_03_swing_lawn: 255 };
|
||
for (const [nm, want] of Object.entries(pins)) {
|
||
const c = crossWorlds[nm];
|
||
if (c?.error) return `SKIPPED — no server for ${nm}: ${c.error}`;
|
||
if (!c.dressed) return `SKIPPED — ${nm} GLBs unavailable, graybox anchors carry no collateral keys`;
|
||
const mine = failureEnvelope(c.w);
|
||
const board = exposureOf(c.sj);
|
||
assertEq(mine.unpriced.length, 0,
|
||
`${nm}: anchors reach unpriced collateral (${mine.unpriced.join(', ')}) — exposure the card `
|
||
+ 'cannot show, and "not scored" must never become "free"');
|
||
assertEq(mine.total, board.total, `${nm}: the two exposure harnesses disagree — find the variable`);
|
||
assertEq(mine.total, want, `${nm}: both harnesses agree on the WRONG number`);
|
||
// Totals agreeing is necessary, not sufficient — item-level agreement is
|
||
// what rules out two errors cancelling.
|
||
const key = (xs) => xs.map((i) => `${i.what}=$${i.cost}`).sort().join(' · ');
|
||
assertEq(key(mine.items), key(board.items),
|
||
`${nm}: totals agree but the itemisation differs — the agreement is a coincidence`);
|
||
}
|
||
});
|
||
|
||
t.test('GATE 1.2 (S17): the cross has teeth — strip the beam\'s key and the harnesses disagree', () => {
|
||
const c = crossWorlds.site_02_corner_block;
|
||
if (!c || c.error || !c.dressed) return 'SKIPPED — no dressed corner block (see the cross above)';
|
||
// The drift class this cross exists to catch: an anchor that loses its
|
||
// collateral key is $180 the offer card advertises and the failure path
|
||
// can never bill (the free-carport bug, S11–S14, wearing the board's
|
||
// clothes). Mutate the LIVE world — this is its last use in the suite —
|
||
// and the equality above must break, or it never could have.
|
||
const stripped = c.w.anchors.filter((a) => a.collateral === 'carport');
|
||
assert(stripped.length >= 2,
|
||
`only ${stripped.length} anchor(s) carry the carport key — the trap has lost its reach`);
|
||
for (const a of stripped) a.collateral = null;
|
||
const mine = failureEnvelope(c.w);
|
||
assertEq(mine.total, 25, `the stripped envelope should read gnome-only $25, got $${mine.total}`);
|
||
assert(mine.total !== exposureOf(c.sj).total,
|
||
'the harnesses still agree with the carport unreachable — the cross cannot fail');
|
||
});
|
||
}
|