From 15ea431b232ab725151c50dfa9ee95839b11ecab Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 16:27:03 +1000 Subject: [PATCH] Gate 2.3: pin editor wind == game wind exact, and fix the stale-wind seam The pin (co-owned with B, probe/second moved and argued in THREADS): site_02_corner_block / storm_02_wildnight / the authored throat centre (-6,0) / t=60.0 / exact ===. B proposed backyard_01, the garden bed and t=30; all three are places the funnel contributes ~nothing (backyard_01's venturi is [], the bed measures 0.0000 m/s of funnel even on site_02, and t=30 is worth 0.4%), so that pin could not have failed. A vacuity guard now asserts the funnel is worth >25% AT the pin, so it cannot rot back into decoration. Also fixed, found by looking: editor.wind rebuilt its wind in commit() rather than on 'change', so any EXTERNAL mutation + markDirty() re-rendered the panel against the previous site's wind. The readout kept insisting a restored gap was 24% aligned. Refresh moved to the listener, so the overlay is a function of the site as it is, whoever changed it. 368/0/0 (362 baseline + 6). All six mutation-checked in one run: dropping setVenturi from windForSite reds the pin (31.60 vs 47.38 m/s), the guard and the sweep; normalizeAxis as identity reds the fold; drifting the shelterAtten mirror 0.5 -> 0.62 reds the volume assert. --- web/world/js/editor.wind.js | 6 +- web/world/js/tests/c.test.js | 218 ++++++++++++++++++++++++++++++++++- 2 files changed, 220 insertions(+), 4 deletions(-) diff --git a/web/world/js/editor.wind.js b/web/world/js/editor.wind.js index bed5b87..9d30de3 100644 --- a/web/world/js/editor.wind.js +++ b/web/world/js/editor.wind.js @@ -778,6 +778,10 @@ export async function mountWindPanel(EDITOR) { // page is A's file and I do not edit it). if (globalThis.EDITOR) { await mountWindPanel(globalThis.EDITOR); -} else { +} else if (globalThis.document?.getElementById('ed-canvas')) { + // On the editor page with no EDITOR = the import landed in the wrong place. + // Anywhere else (c.test.js importing the pure helpers) this is not a problem + // and must not print — a selftest that warns every run trains people to + // ignore warnings. console.warn('[editor.wind] no globalThis.EDITOR — import me AFTER createEditor() resolves'); } diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index 2d8f291..d60cc8c 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -14,10 +14,16 @@ */ import * as THREE from '../../vendor/three.module.js'; -import { assert, fixedLoop } from '../testkit.js'; -import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS } from '../contracts.js'; +import { assert, assertClose, fixedLoop } from '../testkit.js'; +import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS, createStubWind } from '../contracts.js'; import { loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor } from '../weather.js'; -import { loadSite } from '../world.js'; +import { loadSite, createWorld } from '../world.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 } from '../editor.wind.js'; import { createDebris } from '../debris.js'; import { createSkyFx, RainShadow } from '../skyfx.js'; import { SailRig, HARDWARE } from '../sail.js'; @@ -759,4 +765,210 @@ export default async function run(t) { 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'); + }); + + // ========================================================================= + // 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'); + }); }