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.
This commit is contained in:
type-two 2026-07-18 16:27:03 +10:00
parent 4c1086c805
commit 15ea431b23
2 changed files with 220 additions and 4 deletions

View File

@ -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');
}

View File

@ -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');
});
}