/** * 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, // SPRINT18 gate 1 — the dispatch: the read with no paper behind it, and the // honesty gate extended to the storms nobody forecast. midStormRead, dispatchHonest, } 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'; // SPRINT18 gate 1: A's EMERGENCIES table read through a NAMESPACE import, so this // file survives the window where A's spine is on lane/a and not yet in my tree — // a named import of a not-yet-exported symbol is a hard module error, not a // graceful no-op. A's own reasoning for `weather.midStormRead?.()`, applied back. import * as board 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'); }); // ------------------------------------------------------------------------- // SPRINT18 gate 1 — THE MID-STORM ENTRY, debris half. // // The emergency callout arrives at t≈30. weather.selftest.js pins that the WIND // is the same storm however you got to t0 (it is closed-form, measured // order-independent). Debris was not, and it cost two determinism breaks that // I measured before writing a line — both recorded in debris.js beside the fix: // // · the same storm flown twice through one module spawned DIFFERENT crates // (wildnight crate height 1.582 m then 1.165 m). main.js builds one debris // module at boot and clear()s it per phase change; clear() rewound the leaf // accumulators and not the piece PRNG. Live on main today — night 2 of the // seven-night week never flew night 1's crates, and no test looked. // · an entry at t=45 (past the wildnight's t=38 event) gave every LATER crate // the skipped crate's height, spin axis and phase — the stream sat 5 draws // behind. Skipping history corrupted the FUTURE, not just the present. // // Both had one cause: a crate's jitter came off a shared stream whose POSITION // encoded how much history had happened. It is now keyed on the event itself. // These three cases are what stop it coming back. // ------------------------------------------------------------------------- /** Every piece the storm spawns, snapshotted AT BIRTH. */ const crateLog = (dbg, from, to, dt = FIXED_DT) => { const born = []; const seen = new Set(); for (let i = Math.round(from / dt); i < Math.round(to / dt); i++) { const time = i * dt; dbg.step(dt, time, {}); for (const p of dbg.pieces) { if (seen.has(p)) continue; seen.add(p); // y/sx/sy/sz/phase are the seeded jitter; x/z/vx come off the wind and // were never in doubt. Round only for the message — compare exactly. born.push({ model: p.model, y: p.y, sx: p.sx, sy: p.sy, sz: p.sz, phase: p.phase }); } } return born; }; const sameCrates = (a, b) => a.length === b.length && a.every((p, i) => p.model === b[i].model && p.y === b[i].y && p.sx === b[i].sx && p.sy === b[i].sy && p.sz === b[i].sz && p.phase === b[i].phase); const showCrates = (a) => a.map((p) => `${p.model}@y${p.y.toFixed(3)}/ph${p.phase.toFixed(3)}`).join(', ') || '(none)'; t.test('a crate is a pure function of its event, not of what came before it', () => { for (const name of STORMS) { const def = storms[name]; const dur = def.duration ?? 90; // two independently built modules on the same seed const one = crateLog(createDebris({ wind: createWind(def) }), 0, dur); const two = crateLog(createDebris({ wind: createWind(def) }), 0, dur); assert(sameCrates(one, two), `${name}: two freshly built debris modules spawned different crates — ${showCrates(one)} vs ${showCrates(two)}`); // and the same module flown twice, clear()ed between: night 1 vs night 2 const reused = createDebris({ wind: createWind(def) }); const night1 = crateLog(reused, 0, dur); reused.clear(); const night2 = crateLog(reused, 0, dur); assert(sameCrates(night1, night2), `${name}: the same storm flown twice through one module spawned different crates — night 1 ${showCrates(night1)}, night 2 ${showCrates(night2)}. clear() has stopped rewinding something.`); } }); t.test('an entry at t0 spawns the crates a t=0 run spawns after t0 (every storm, every t0)', () => { for (const name of STORMS) { const def = storms[name]; const dur = def.duration ?? 90; const mk = () => createDebris({ wind: createWind(def) }); // the reference: watch the whole storm, and count how many crates are born // at or after each candidate entry time const whole = []; const seen = new Set(); const ref = mk(); for (let i = 0; i < Math.round(dur / FIXED_DT); i++) { const time = i * FIXED_DT; ref.step(FIXED_DT, time, {}); for (const p of ref.pieces) { if (seen.has(p)) continue; seen.add(p); whole.push({ at: time, model: p.model, y: p.y, sx: p.sx, sy: p.sy, sz: p.sz, phase: p.phase }); } } for (const t0 of [10, 30, 45, 60]) { const want = whole.filter((p) => p.at >= t0); const got = crateLog(mk(), t0, dur); assert(sameCrates(want, got), `${name}: entering at t=${t0} spawned ${showCrates(got)} where a t=0 run spawns ${showCrates(want)} — the crates a callout flies depend on whether anybody watched the first ${t0} s`); } } }); t.test('arriving mid-storm, the grass is already moving (and a cold entry proves it can fail)', () => { // DESIGN.md line 149: a gust front is readable because a wave of motion // crosses the grass a beat before it hits. The callout has NO forecast paper // (gate 1), so that motion is not decoration — it is the briefing. `leafDist` // and `leafEma` are genuine path integrals (a 2.5 s EMA off zero), so they are // the one part of my modules a mid-storm entry cannot teleport: measured, a // t=0 run has 6 leaves crossing at t=30 and a cold entry has ZERO, staying // becalmed for ~7 s while the EMA climbs. debris.enterAt() runs them. const def = storms.storm_02_wildnight; const T0 = 30; const mk = () => createDebris({ wind: createWind(def) }); const look = (d) => ({ n: d.leafCount, at: d.leaves.map((p) => [p.x, p.y, p.z]) }); const same = (a, b) => a.n === b.n && a.at.every((v, i) => v[0] === b.at[i][0] && v[1] === b.at[i][1] && v[2] === b.at[i][2]); const watched = mk(); fixedLoop(T0, FIXED_DT, (dt, time) => watched.step(dt, time, {})); const want = look(watched); assert(want.n > 0, `the reference t=0 run has ${want.n} leaves flying at t=${T0} — pick a windier moment or this case proves nothing`); const arrived = mk(); arrived.enterAt(T0, FIXED_DT); const got = look(arrived); assert(same(want, got), `enterAt(${T0}) put ${got.n} leaves in the yard where a t=0 run has ${want.n}, or put them elsewhere — the arrival is not the storm the player would have been standing in`); // NEGATIVE CONTROL. Without this the case above could be satisfied by a // do-nothing enterAt on a layer that happened to warm instantly. The naive // path — fresh module, first step lands at t0 — must be measurably wrong, and // is: 0 leaves against 6. This is also the guard against somebody later // "optimising" enterAt into a teleport. const cold = mk(); cold.step(FIXED_DT, T0, {}); assert(!same(want, look(cold)), `a cold module stepped straight to t=${T0} already matches the watched run — the ambient layer carries no history, so the case above cannot fail and must be rewritten`); }); // 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'); }); // ═══ SPRINT18 GATE 1 — THE DISPATCH: what an honest read is when no paper comes ═══ // // `offerBand`'s sin was OMISSION. The dispatch's sin is INVENTION, and these // cases are aimed at that inversion. The sharpest of them states the rule as an // invariant rather than as a vocabulary check: A READ AT t MAY NOT MOVE WHEN THE // STORM'S FUTURE BEYOND t MOVES. If it does, the card is printing something // nobody standing in that yard could know. // // ⚠️ NOT pinned by diffing storm_03_southerly against storm_03b_earlybuster, // which was my first instinct and is wrong: THREADS has them "identical in every // number the band prints", but they carry DIFFERENT SEEDS (30717/30818), so their // gust timelines differ and their instantaneous wind differs at every t. They are // twins on the FORECAST's numbers, not on the sim's. Isolating the variable means // constructing it, so these cases mutate a def's future and hold everything else. const AT = 30; // A's authored arriveAt (§1), the number ROADMAP names /** A copy of `def` whose FUTURE past `t` is different and whose present is not. */ const futureChanged = (def, t) => { const d = JSON.parse(JSON.stringify(def)); // Curve points strictly after the first point at/after t cannot affect the // value AT t (piecewise-linear interpolation there brackets t). Move those. for (const key of ['dirCurve', 'baseCurve']) { const c = d[key]; if (!Array.isArray(c)) continue; let first = c.findIndex((p) => p[0] >= t); if (first < 0) continue; for (let i = first + 1; i < c.length; i++) c[i][1] = -(c[i][1] ?? 0) * 1.7 - 0.3; } // A hail burst that has not started by t cannot be falling at t. if (d.hail && Array.isArray(d.hail.bursts)) { d.hail.bursts = d.hail.bursts.filter((b) => b.t <= t); d.hail.bursts.push({ t: t + 5, ramp: 0.5, hold: 9, fade: 1, intensity: 1 }); } // Events are announcements; the read never reads them. Move them anyway. if (Array.isArray(d.events)) d.events = d.events.filter((e) => e.t <= t); return d; }; t.test('GATE 1: the mid-storm read cannot know the future (change the future, the read holds)', () => { const same = (a, b) => a.length === b.length && a.every((l, i) => l.key === b[i].key && l.text === b[i].text); let futuresActuallyChanged = 0; for (const name of STORMS) { const def = storms[name]; for (const t of [8, 20, AT]) { const now = midStormRead(def, t); const altered = midStormRead(futureChanged(def, t), t); assert(same(now, altered), `${name}: the read at t=${t} MOVED when only the storm's future moved.\n was: ` + `${now.map((l) => l.key + '=' + l.text).join(' | ')}\n now: ` + `${altered.map((l) => l.key + '=' + l.text).join(' | ')}\n ` + 'a dispatch that reads past t is inventing the paper it says it has not got'); // VACUITY GUARD: the mutation must actually alter the storm, or the case // above is comparing a def to itself. Read them both LATER than t. const later = Math.min((def.duration ?? 90) - 1, t + 12); if (!same(midStormRead(def, later), midStormRead(futureChanged(def, t), later))) { futuresActuallyChanged++; } } } assert(futuresActuallyChanged >= STORMS.length, `only ${futuresActuallyChanged} of ${STORMS.length * 3} mutations changed the storm at all — ` + 'the invariant above is being satisfied by a no-op mutation, not by an honest read'); }); t.test('GATE 1: the read never carries a forecast-only fact, by key or by number', () => { for (const name of STORMS) { const def = storms[name]; const s = stormStats(def); for (const t of [8, AT, 60]) { const read = midStormRead(def, t); for (const l of read) { // the offer band's forward-looking keys have no business on a dispatch assert(l.key !== 'change' && l.key !== 'confidence', `${name} @${t}: the read carries a '${l.key}' line — that is forecast paper`); } const all = read.map((l) => l.text).join(' '); // the two numbers only a bureau could hand you if (s.changeAt != null && s.changeAt > t) { assert(!all.includes(String(Math.round(s.changeAt))), `${name} @${t}: the read prints ${Math.round(s.changeAt)} and the change lands at ` + `${s.changeAt} — nobody told you when it swings`); } if (s.hailSeconds > 0) { assert(!all.includes(`${s.hailSeconds.toFixed(0)}s of it`), `${name} @${t}: the read prints the storm's TOTAL hail duration — that is the forecast's number`); } } } }); t.test('GATE 1: the admission is unconditional — every storm, every t, same words', () => { // The subtle half of the design. On an offer card a missing hail line means // THE STORM DOES NOT HAIL. On a dispatch it means NOBODY KNOWS. Silence cannot // tell those apart, so the read says so out loud — and it must say it in the // SAME words every time, because a line that appears only when there is // something to not-know becomes the very fact it claims not to have. const texts = { nopaper: new Set(), unknown: new Set() }; let reads = 0; for (const name of STORMS) { for (let t = 1; t < (storms[name].duration ?? 90); t += 7) { const read = midStormRead(storms[name], t); reads++; assert(read[0].key === 'nopaper', `${name} @${t}: the read does not OPEN with the no-forecast line — it frames every line under it`); assert(read[read.length - 1].key === 'unknown', `${name} @${t}: the read does not CLOSE with what you cannot know`); for (const l of read) if (texts[l.key]) texts[l.key].add(l.text); } } assert(reads > 60, `only ${reads} reads sampled — widen the grid`); for (const k of ['nopaper', 'unknown']) { assert(texts[k].size === 1, `the '${k}' line has ${texts[k].size} different wordings across the shipped storms — ` + `it is conditional on something, and its presence is then information the observer ` + `cannot have:\n ${[...texts[k]].join('\n ')}`); } }); t.test('GATE 1: the honesty gate covers emergency storms — a storm nobody can describe cannot be dispatched', () => { // "A storm that cannot be described honestly cannot be DISPATCHED either." // The storm-side gate applies unchanged; the arrival adds its own three. for (const name of STORMS) { const r = dispatchHonest(storms[name], AT, name); assert(r.ok, `${name} cannot be honestly dispatched at t=${AT}:\n ${r.errors.join('\n ')}`); } // NEGATIVE CONTROLS — four, because a gate that only ever says yes is decoration. const base = storms.storm_03_southerly; assert(!dispatchHonest(base, 0, 'x').ok, 'arriveAt 0 was accepted — that is not a mid-storm arrival, it is an ordinary night'); assert(!dispatchHonest(base, base.duration ?? 90, 'x').ok, 'arriveAt at the storm\'s end was accepted — you would be dispatched to finished weather'); assert(!dispatchHonest(base, 500, 'x').ok, 'arriveAt past the end was accepted'); // THE S17 FILING, CORRECTED BY MEASUREMENT. I filed "stoneWord has no floor, // size 0 words itself fine pea" — and that hole was already shut one layer up: // validateStorm refuses size <= 0, so this is the case proving the gate above // does the work, not a floor check in forecastHonest. const zero = JSON.parse(JSON.stringify(storms.storm_06_soaker)); zero.hail.size = 0; assert(!dispatchHonest(zero, AT, 'zero').ok, 'hail.size 0 was dispatched'); // ⚠️ THE REAL HOLE, found by probing the layer that DOES pass: the validator // only checks a size that is PRESENT (`hd.size != null &&`), and hailSize reads // `size ?? 1`. So a hailing storm with NO authored size prints "marble stones" // — the middle of the vocabulary, stated as fact, authored by nobody. On the // soaker that turns a real 0.45 "fine pea" into a stone 2.2x bigger, on the one // axis the fabric bet is decided on. const sizeless = JSON.parse(JSON.stringify(storms.storm_06_soaker)); delete sizeless.hail.size; assert(stormStats(sizeless).hailSeconds > 1, 'the sizeless fixture does not actually hail — this case would prove nothing'); const sr = dispatchHonest(sizeless, AT, 'sizeless'); assert(!sr.ok, 'a storm that hails with no authored hail.size was dispatched — the card ' + 'would state "marble stones" as fact for a stone nobody wrote'); assert(sr.errors.some((e) => /hail\.size is not authored/.test(e)), `the refusal does not say WHY: ${sr.errors.join('; ')}`); // and the ceiling still bites, so the new check did not displace it const boulders = JSON.parse(JSON.stringify(storms.storm_06_soaker)); boulders.hail.size = 2.6; assert(!dispatchHonest(boulders, AT, 'boulders').ok, 'a 2.6 stone was dispatched — the vocabulary ceiling stopped biting'); }); t.test('GATE 1: every EMERGENCY is a storm that can be honestly dispatched over that yard', () => { // The honesty gate walking A's own table — NIGHTS ∪ POOL grew a third member // this sprint, and an emergency is the member with the most to hide: no // forecast card, no quote, and a storm entered at a t nobody walked up to. // // ⚠️ Read through a NAMESPACE import (`board.EMERGENCIES`), not a named one, so // this file does not hard-error in the sprint-window where A's spine is on // lane/a and not yet in my tree — the same reasoning A gave for calling // `weather.midStormRead?.()` that way, applied back at them. And it walks a // FIXTURE mirroring A's §6 as well as the real table, so the gate is exercised // on known data TODAY rather than reading green off an absent import: the // calm-day vacuity that has now bitten this repo in three separate gates. const fixture = [{ id: 'fixture:corner_block_emergency', night: 5, storm: 'storm_03_southerly', site: 'site_02_corner_block', emergency: { arriveAt: 30 }, }]; const real = Array.isArray(board.EMERGENCIES) ? board.EMERGENCIES : []; const walk = [...fixture, ...real]; assert(walk.length >= 1, 'the emergency walk found nothing at all'); for (const e of walk) { const at = e.emergency?.arriveAt; const def = storms[e.storm]; assert(def, `${e.id}: emergency storm '${e.storm}' is not in the loaded storm set — ` + 'an emergency whose weather nobody can load cannot be dispatched'); assert(Number.isFinite(at), `${e.id}: no numeric emergency.arriveAt`); // (1) the storm-and-instant gate const r = dispatchHonest(def, at, e.id); assert(r.ok, `${e.id} cannot be honestly dispatched at arriveAt=${at}:\n ${r.errors.join('\n ')}`); // (2) THE PAIRING LAW, extended to the new job type. A read can be perfectly // honest about a storm and still be an invitation to a night nobody can // work — and that is worse on a callout, which the player cannot decline // once they are standing in it. const why = pairingRefusal(e.storm, e.site); assert(why === null, `${e.id}: dispatching ${e.storm} over ${e.site} is refused by measurement — ${why}`); } // VACUITY GUARDS — three, because a walk that only ever says yes is decoration. assert(pairingRefusal('storm_06_soaker', 'backyard_01') !== null, 'the pairing arm of this walk cannot say no — an emergency on a refused yard would pass'); assert(!dispatchHonest(storms[fixture[0].storm], 95, 'late').ok, 'an emergency arriving after its storm ended was accepted'); const sizeless = JSON.parse(JSON.stringify(storms.storm_06_soaker)); delete sizeless.hail.size; assert(!dispatchHonest(sizeless, 30, 'sizeless').ok, 'the storm arm of this walk cannot say no'); }); t.test('GATE 1: every line of the read is reachable, and each one is earned', () => { // A vocabulary with an unreachable word is a vocabulary that has never been // read. Walk the shipped storms and prove each key fires somewhere — and that // the two conditional-on-weather keys do NOT fire where the weather is absent. const where = {}; for (const name of STORMS) { for (let t = 0.5; t < (storms[name].duration ?? 90); t += 0.25) { for (const l of midStormRead(storms[name], t)) (where[l.key] ??= []).push(name); } } for (const k of ['nopaper', 'wind', 'unknown', 'lull', 'front', 'rain', 'veer', 'hail', 'ground']) { assert(where[k]?.length, `no shipped storm ever produces a '${k}' line — it is unreachable vocabulary`); } // storm_01_gentle is the calm-day preload: no hail, no change. It must not // grow a hail line or a swing, and this is the case that would catch a read // that manufactures weather to fill a card. const gentle = new Set(); for (let t = 0.5; t < 90; t += 0.25) for (const l of midStormRead(storms.storm_01_gentle, t)) gentle.add(l.key); assert(!gentle.has('hail') && !gentle.has('ground'), 'the gentle night reads hail or stones on the ground, and it has neither'); assert(!gentle.has('veer'), 'the gentle night reads a wind swing, and it has no change'); }); // ── 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'); }); }