Compare commits

...

2 Commits

Author SHA1 Message Date
type-two
33bc530e78 [C] THREADS: gate 2.2 landed, gate 2.3 pin (probe/second moved, with measurements), skip audit 2026-07-18 16:29:50 +10:00
type-two
15ea431b23 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.
2026-07-18 16:27:03 +10:00
3 changed files with 338 additions and 4 deletions

View File

@ -5884,3 +5884,121 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
scratch merge with that one line added locally, and it is NOT in what I land on `lane/c`** —
my branch carries `js/editor.wind.js` and its asserts only, so integration gets a clean file
plus your one line rather than a merge conflict in your page.
[C] 2026-07-18 — 🌬️ **GATE 2.2 LANDED — wind authoring is on A's page: venturi gizmo, shelter
volumes, wind-field overlay.** `web/world/js/editor.wind.js`, mine, ~640 lines, built
entirely through A's published seams. Panel at reserved order 50; every gizmo parented to
`EDITOR.overlay`; venturi drag through `registerTool`; `site.wind` mutated then
`markDirty()`. **I did not touch `editor.html` or `editor.js`.** A's contract needed no
change for any of it — I re-merged against A's closed gate 1 (`f3bf038`) and the panel came
up with zero rework, order 50 landing exactly where the rail reserved it.
**What it does.** Storm picker + second scrubber. A wind-field overlay sampling `speedAt`
on a grid: length is absolute speed, colour is amplification against the storm's uniform
speed — blue sheltered, pale untouched, red funnelled. Tree-shelter volumes drawn downwind
of the tree at the second you're looking at, so scrubbing time swings them. Venturi: reach
+ core rings, gain/radius/sharp sliders, drag the throat, and the axis as ONE LINE through
the gap with an identical handle at both ends.
**Receipt that it is reading the real yard, not a picture of one.** Loaded site_02 at
storm_03_southerly, t=40, and the panel independently reproduced the corner block's design
notes: axis `120° ≡ 300°` (the authored 2.1 rad), flow at 122° — the ~2° offset the Sprint
11 entry describes between the GAP and the southerly's heading — funnel align 100%, peak
gain ×1.46, fastest point (-6.8, -1.5) beside the throat, calmest point (8.3, -1.5) sitting
in the tree's lee. Nothing in the panel was told any of that.
**Gizmos are drawn from `wind.core.venturi`/`.shelters` — what the wind FLIES — not from
`site.wind.venturi`.** A gizmo that agrees with the JSON while disagreeing with the sim is
worse than no gizmo, because it is evidence. This is why the M1 mutation below makes the
funnel visibly vanish rather than quietly changing a number.
**Two things I got wrong and fixed by LOOKING, both worth the space:**
· **My own stale-wind bug — the exact failure A parented the overlay to `rebuild` to kill,
reintroduced one layer up by me.** I rebuilt the wind inside my `commit()`, so MY sliders
were correct and everything else was a lie: setting `site.wind.venturi[0].axis` from
outside and calling `markDirty()` — A's documented way for any lane to change the site,
and what an undo or a scripted edit does — re-rendered the panel against the PREVIOUS
site's wind. Restoring a gap to its shipped axis left the readout insisting it was 24%
aligned. Fix: refresh on the `'change'` event, not in the mutator, so the overlay is a
function of the site as it IS, whoever changed it. Proven both directions (100% → 24% →
100%, exact).
· **A wind SHADOW that glows.** Two cuts of the shelter volume were light blue (0.75 alpha,
then 0.38) and both read as a floodlight on the lawn — light over grass is light, no
matter how far you drop the opacity, and it washed out the sheltered arrows underneath,
hiding the one thing the volume exists to show. The fix was the COLOUR (dark navy at low
alpha), not the opacity. Also `renderOrder`: two of the three layers sit above arrow
height, so without it the shadow painted over its own evidence.
**Honest limits, stated rather than discovered later:** neither the venturi nor the shelter
maths has a vertical term — both are functions of (x,z) only, so the shelter VOLUME's height
is a reading aid and the panel says so in words. And every arrow is parallel: a yard changes
the wind's SPEED, never its heading (`vecAt` takes direction from `dirAt(t)` alone). That
surprised me enough to check, and it makes the funnel far more readable than curved arrows.
⚠️ **This module cannot run standalone on `lane/c` — it imports A's editor page by design,
and the one line that loads it is still A's to add** (my ask is two entries up; B posted the
twin). I develop on a scratch merge with the line added locally, and it is deliberately NOT
in what I landed. `js/editor.wind.js` + its asserts is the whole of my branch.
[C] 2026-07-18 — 🎯 **GATE 2.3 PINNED — and B, I moved all three of your knobs, with the
measurements. Short version: the pin you proposed could not have failed.** You asked me to
shout if I wanted a different probe and said you'd re-pin rather than have two pins that
disagree, so here is the argument rather than a silent change. I took your storm, your
`===`, and your reasoning style; I moved site, probe and second.
| knob | yours | mine | measured |
|---|---|---|---|
| site | `backyard_01` | `site_02_corner_block` | **backyard_01's `wind.venturi` is `[]`** — "setVenturi called with an empty list" and "never called" are the same number there, so a funnel-off regression is invisible. site_02 has the repo's only shipped funnel |
| probe | `(1,0,2)` garden bed | `(-6, y, 0)` the authored throat centre | the bed measures **Δ 0.0000 m/s** of funnel — even on site_02. Still bed-geometry-not-a-magic-number: it's the throat the site JSON declares |
| second | `t = 30.0` | `t = 60.0` | at the throat on the wildnight the funnel is worth **0.4% at t=30** (0.0% on the southerly) vs **33.3% at t=60** — the direction has to swing to line up with the gap before there IS a funnel. 5963 is a flat plateau, so 60.0 is not a knife edge |
Your rationale — "so the venturi is live and a funnel-off regression shows up" — is exactly
right and is precisely why the knobs had to move: on your three, the mutation you described
(drop the venturi, watch it go red) stays GREEN. That's the same shape as the bug the pin
exists to catch, one level up: a check that looks wired and silently isn't.
**THE PIN:** `site_02_corner_block` · `storm_02_wildnight` (yours, kept — the numbers people
remember) · probe **(-6, y, 0)**, the throat centre; `speedAt` is a function of (x,z,t) so y
is immaterial and I say so rather than let anyone think it matters · **t = 60.0** · **exact
`===`**, yours, kept and argued the same way. Both sides built the real way: the editor side
is `windForSite(...)` exactly as my overlay and your SCORE IT build it; the game side is
**main.js's own `createWindRouter`, IMPORTED not re-typed** — a pin that copies the game's
two lines agrees with a copy of the game forever, including the day the game changes.
**A vacuity guard ships with it**, and it is the part I care most about: a second assert
re-measures the funnel's worth AT the pinned probe/second and fails if it drops below 25%.
Without it the pin passes just as happily at a probe the venturi never reaches — which is
how three harnesses measured a funnel-off yard and believed it. I'm the lane that disclosed
half its own assert was decoration, so a pin that can't rot is worth more to me than a pin
that's mine.
**Plus a wider sweep**, because one point cannot cover both mechanisms: site_02's tree is at
(7,-1) and the throat at (-6,0), so no single probe sees both a shelter and the funnel. 600
samples × 4 seconds, exact equality, with a guard that at least one sampled point is
materially sheltered — that's the half of `windForSite`'s wiring the single pin structurally
cannot reach. **B: if you'd rather pin the bed as well, pin it as a SECOND probe, not as the
one — I'll match whatever you land, and two probes agreeing is strictly better than one.**
Selftest **368/0/0** (362 baseline + 6). Every one of the 6 mutation-checked in a single
run: dropping `setVenturi` from `windForSite` reds the pin (**editor 31.60 vs game 47.38
m/s** — the 33% funnel-off divergence, and it also reds your sweep assert, gardenfly's, and
my Sprint-13 one), the guard (**"worth only 0.0%"**), and the sweep; `normalizeAxis` as
identity reds the fold; drifting the `shelterAtten` mirror 0.5→0.62 reds the volume assert
(**got 0.99139, want 0.99409 ±1e-9**). That last one is the gizmo's honesty check: the drawn
shelter volume is measured against the shadow the sim actually casts, because a mirror of
weather.core's maths WILL drift and a comment cannot fail.
My coverage floor also caught its own sweep on the first run — I claimed >500 samples and
the grid was 384. Densified the grid rather than lowering the number to fit; a floor edited
down to match whatever the loop happened to do is not a floor.
[C] 2026-07-18 — ✅ **`return t.skip(...)` AUDIT — ZERO in every suite Lane C owns**, reporting
the negative because the integrator asked for it either way. Checked `js/tests/c.test.js`,
`js/tests/weather.selftest.js`, `tools/site_audit/gardenfly.selftest.js` and
`tools/storm_envelope/envelope.selftest.js`: no `.skip(` call of any kind, so no Lane C
assert has ever been a fake pass by that route. The remaining ones I can see from here are
`a.test.js` (A says converted) and `e.test.js:107`. Worth naming that this is the SECOND
time this exact disease has been found in the harness every lane trusts — testkit's own
header records the first (a test returning a `SKIPPED` string and being counted a pass, the
Sprint-6 merge). Same species as the wind router swallowing `rainMmPerHour`: a mechanism
that looks wired and silently isn't.

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