Lane C S15 gate 2.2: ONE storm picker — editor_stormsel.js is the shared storm+time selection; wind panel becomes a view of it + JUMP TO WORST SECOND

The store: one owner for 'which storm, which second' on the editor page.
Garbage keys refused loudly, no-op writes silent (the anti-ring guard that
lets a subscriber clamp from inside its own handler), listener errors
contained, default = the gate-2.3 pin (wildnight @ 60 s). editor.wind.js
drops its private stormKey/time and re-exports the store's STORM_KEYS —
one list, same array. B's editor_score.js patch proposed in THREADS.

Polish (my call): JUMP TO WORST SECOND — argmax of speedAt over grid ×
storm, venturi throats passed as exact probes, dt = the scrubber's own
0.5 s step. Writes the SHARED second. site_02 × wildnight: 51.11 m/s
@ 60.5 s at (-7.5, 0), the funnel plateau, found by scan not patience.

Selftest 413/0/0 (410 + 3). Five mutations red-then-green: no-op guard
off, validation off, singleton doubled, comparator flipped, probes
dropped — each reds its own assert with its own message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 18:05:33 +10:00
parent c182fc14cb
commit 3a2e4ff5e1
3 changed files with 353 additions and 38 deletions

View File

@ -69,20 +69,12 @@
import * as THREE from '../vendor/three.module.js';
import { smoothstep } from './weather.core.js';
import { loadStorm, windForSite } from './weather.js';
import { stormSel, STORM_KEYS } from './editor_stormsel.js';
/** Keep in step with data/storms/ — same list the game's STORMS carries. */
export const STORM_KEYS = [
'storm_01_gentle',
'storm_02_wildnight',
'storm_02b_icenight',
'storm_03_southerly',
'storm_03b_earlybuster',
];
/** The southerly is the storm that makes a corner block scream a sensible
* place for an author to land, because a funnel authored under the gentle
* storm looks like it does nothing. */
const DEFAULT_STORM = 'storm_03_southerly';
/** Re-exported from the shared selection store ONE list, one copy. Sprint 14
* had this list here and a second copy in editor_score.js, which is how the
* page grew two pickers with two defaults. See editor_stormsel.js. */
export { STORM_KEYS };
/** Nominal canopy height for the shelter volume. A READING AID — see (4). */
const CANOPY_Y = 3.2;
@ -167,6 +159,46 @@ export function sampleWindField(wind, { width, depth, step = 1.5, t = 0 }) {
return { samples, dir, uniform, max, min };
}
/**
* The storm's WORST second on this yard: argmax of `speedAt` over a coarse
* grid × the whole storm. [SPRINT15, lane C's own-call polish]
*
* D authored site_03 cold and the scrubber's question was always "WHEN does
* this yard scream?" answered until now by dragging and watching, which is
* measurement by patience. This is the same question asked properly: scan the
* lattice, return the second. Pure same wind + same args = same answer on
* any machine (speedAt has no clock and no call-time RNG), and dt defaults to
* the scrubber's own 0.5 s step so the answer is always a second the scrubber
* can actually sit on.
*
* `probes` exist because a coarse grid can straddle a throat: the panel passes
* every authored venturi centre so the one point most likely to BE the worst
* is always scanned exactly.
*
* @returns {{t:number, speed:number, x:number, z:number}}
*/
export function worstSecond(wind, { width, depth, step = 3, dt = 0.5, probes = [] } = {}) {
const pts = [];
const nx = Math.max(2, Math.round(width / step));
const nz = Math.max(2, Math.round(depth / step));
for (let i = 0; i < nx; i++) {
for (let j = 0; j < nz; j++) {
pts.push({ x: -width / 2 + (i + 0.5) * (width / nx), z: -depth / 2 + (j + 0.5) * (depth / nz) });
}
}
for (const p of probes) pts.push({ x: p.x, z: p.z });
const n = Math.round(wind.duration / dt);
let best = { t: 0, speed: -Infinity, x: 0, z: 0 };
for (let k = 0; k <= n; k++) {
const t = k * dt;
for (const p of pts) {
const s = wind.core.speedAt(p.x, p.z, t);
if (s > best.speed) best = { t, speed: s, x: p.x, z: p.z };
}
}
return best;
}
/** Amplification → colour. Cool/dim = sheltered, white = untouched, hot = funnelled. */
function ratioColor(ratio, out) {
// 0.55 …… 1.0 …… 1.6 mapped blue → pale → red. The neutral band is
@ -190,9 +222,17 @@ export async function mountWindPanel(EDITOR) {
const { root, body } = EDITOR.mountPanel({ id: 'wind', title: 'WIND', order: 50 });
// --- state -------------------------------------------------------------
// Storm + second live in the SHARED selection store, not here — gate 2.2.
// This panel is the picker UI (the owner's controls), but the value it edits
// is THE page selection, the same one SCORE IT scores. Local `stormKey`/
// `time` are gone on purpose: state this panel privately held is exactly how
// D scored one storm while looking at another.
const SEL = stormSel();
const storms = {};
let stormKey = DEFAULT_STORM;
let time = 40;
/** Last worst-second scan, or null. Cleared on any site change the claim
* "this is the storm's worst second on this yard" dies the moment the yard
* moves (B's stale-score instinct, borrowed). Keyed by storm as well. */
let worstNote = null;
let showField = true;
let showShelters = true;
let gridStep = 1.5;
@ -209,14 +249,18 @@ export async function mountWindPanel(EDITOR) {
* game would fly, not ones re-derived from the JSON by hand.
*/
function buildWind() {
const def = storms[stormKey];
const def = storms[SEL.storm];
if (!def) return null;
return windForSite(def, EDITOR.site, EDITOR.world?.anchors ?? []);
}
function refreshWind() {
wind = buildWind();
if (wind && time > wind.duration) time = wind.duration;
// Clamp an over-long second back into the storm THROUGH the store, so the
// clamped value is the selection everyone shares, not a private display
// fix. The store's no-op guard is what keeps this from ringing: the emit
// this triggers re-enters here, finds time ≤ duration, and writes nothing.
if (wind && SEL.time > wind.duration) SEL.setTime(wind.duration);
}
// --- gizmo drawing ------------------------------------------------------
@ -432,7 +476,7 @@ export async function mountWindPanel(EDITOR) {
if (!wind) return null;
const yard = EDITOR.site.yard ?? { width: 30, depth: 20 };
const field = sampleWindField(wind, {
width: yard.width, depth: yard.depth, step: gridStep, t: time,
width: yard.width, depth: yard.depth, step: gridStep, t: SEL.time,
});
if (showField) drawField(field);
if (showShelters) for (const s of wind.core.shelters) drawShelter(s, field.dir);
@ -580,29 +624,50 @@ export async function mountWindPanel(EDITOR) {
body.textContent = '';
const field = redraw();
// --- storm + second ---
// --- storm + second — views of the SHARED selection, gate 2.2 ---
const sel = el('select', 'ed-sel');
for (const k of STORM_KEYS) {
const o = el('option', null, k.replace(/^storm_/, ''));
o.value = k;
if (k === stormKey) o.selected = true;
if (k === SEL.storm) o.selected = true;
sel.append(o);
}
sel.addEventListener('change', async () => {
stormKey = sel.value;
await ensureStorm(stormKey);
refreshWind();
render();
sel.addEventListener('change', () => {
// Write the store, nothing else — the store's change is what re-renders,
// so a storm picked ANYWHERE (this select, B's panel, the console) walks
// the identical path through this panel.
SEL.setStorm(sel.value);
});
body.append(row('storm', sel));
const dur = wind?.duration ?? 90;
body.append(row('second', el('span', null, `${time.toFixed(1)} s / ${dur.toFixed(0)} s`)));
body.append(slider(0, dur, 0.5, Math.min(time, dur), (v) => `${v.toFixed(1)} s`, (v) => {
time = v;
render();
body.append(row('second', el('span', null, `${Math.min(SEL.time, dur).toFixed(1)} s / ${dur.toFixed(0)} s`)));
body.append(slider(0, dur, 0.5, Math.min(SEL.time, dur), (v) => `${v.toFixed(1)} s`, (v) => {
SEL.setTime(v);
}));
// Jump the SHARED second to the storm's worst moment on this yard — the
// scrubber question D kept answering by patience, answered by scan. Writes
// through the store like every other control, so SCORE IT and any future
// subscriber see the same landing.
const bPeak = el('button', 'ed-btn', 'JUMP TO WORST SECOND');
bPeak.addEventListener('click', () => {
if (!wind) return;
const yard = EDITOR.site.yard ?? { width: 30, depth: 20 };
const probes = (wind.core.venturi ?? []).map((v) => ({ x: v.x, z: v.z }));
worstNote = {
...worstSecond(wind, { width: yard.width, depth: yard.depth, probes }),
storm: SEL.storm,
};
SEL.setTime(worstNote.t);
render(); // covers the no-op case (already sitting on it)
});
body.append(row('', bPeak));
if (worstNote && worstNote.storm === SEL.storm) {
body.append(row('worst', el('span', worstNote.speed > (field?.uniform ?? 0) * 1.15 ? 'ed-warn' : null,
`${worstNote.speed.toFixed(2)} m/s @ ${worstNote.t.toFixed(1)} s · (${worstNote.x.toFixed(1)}, ${worstNote.z.toFixed(1)})`)));
}
if (field) {
const card = el('div', 'ed-card');
const head = el('div', 'ed-card-head');
@ -714,10 +779,10 @@ export async function mountWindPanel(EDITOR) {
if (wind) {
const core = wind.core.venturi[i];
if (core) {
const d = wind.core.dirAt(time);
const d = wind.core.dirAt(SEL.time);
const align = Math.abs(Math.cos(d) * core.axisX + Math.sin(d) * core.axisZ);
const fired = Math.pow(align, core.sharp);
const at0 = wind.core.speedAt(core.x, core.z, time);
const at0 = wind.core.speedAt(core.x, core.z, SEL.time);
const r = el('div', 'ed-card-row');
r.append(el('span', 'ed-kv', 'now'),
el('span', fired > 0.5 ? 'ed-warn' : 'ed-kv',
@ -743,15 +808,29 @@ export async function mountWindPanel(EDITOR) {
if (!storms[k]) storms[k] = await loadStorm(k);
}
await ensureStorm(stormKey);
refreshWind();
render();
/**
* React to the shared selection. Async because a newly-picked storm may need
* fetching; the token drops a stale response two quick switches must not
* let the slower fetch paint the panel with the loser (the def cache makes
* this near-impossible to hit, and near-impossible is not a guard).
*/
let selGen = 0;
async function applySel() {
const gen = ++selGen;
await ensureStorm(SEL.storm);
if (gen !== selGen) return;
refreshWind();
render();
}
await applySel();
SEL.onChange(() => { applySel(); });
// A clears the overlay on every rebuild — so every rebuild, I redraw. My
// gizmos are a pure function of (site, storm, second), which is exactly why
// this is one line instead of a lifetime problem.
EDITOR.on('rebuild', () => { refreshWind(); render(); });
EDITOR.on('siteload', () => { selVenturi = 0; });
EDITOR.on('rebuild', () => { worstNote = null; refreshWind(); render(); });
EDITOR.on('siteload', () => { selVenturi = 0; worstNote = null; });
/**
* The wind is rebuilt HERE, on any 'change', rather than in my own commit().
*
@ -768,7 +847,7 @@ export async function mountWindPanel(EDITOR) {
* function of the site as it IS, no matter who changed it. render() never
* calls markDirty(), so this cannot loop.
*/
EDITOR.on('change', () => { refreshWind(); render(); });
EDITOR.on('change', () => { worstNote = null; refreshWind(); render(); });
return { root, body, redraw, buildWind, get wind() { return wind; } };
}

View File

@ -0,0 +1,122 @@
/**
* editor_stormsel.js THE storm + second selection for the editor page.
* [Lane C, SPRINT15 gate 2.2 one storm picker, built with Lane B]
*
* Sprint 14 shipped two storm pickers on one page: the WIND panel defaulted the
* southerly (mine), SCORE IT defaulted the wildnight (B's), and D scored the
* wildnight WHILE LOOKING AT the southerly two harnesses disagreeing inside a
* single page, the exact bug class this repo keeps paying for (three harnesses
* measured a funnel-off yard in Sprint 11 for the same species of reason).
*
* The fix is ownership, not UI: this module is the ONE owner of "which storm,
* which second" on the editor page. Panels are views of it any number of
* selects and scrubbers may DISPLAY the selection, but they all write here and
* they all re-read from here, so there is nothing for two panels to disagree
* about. Same shape as A's editor owning the site and every panel calling
* `markDirty()`: one place remembers, nobody else has to.
*
* The contract
* · `stormSel()` returns the page singleton (import identity both panels
* import this same specifier, so they cannot get different stores).
* · `setStorm(key)` REFUSES garbage (throws on a key not in STORM_KEYS)
* a selection that can silently hold a storm that doesn't exist is the
* `_v1`-suffix 404 one layer up.
* · No-op writes are SILENT: setting the value it already holds fires no
* listener and returns false. This is load-bearing, not politeness it is
* what lets a subscriber clamp the selection from inside its own change
* handler (wind panel: `time > duration` after a storm switch) without
* ringing forever.
* · `time` is seconds into the storm, 0, finite. The store deliberately
* does NOT know storm durations (it would have to fetch to learn them, and
* a synchronous store is the point) the wind panel, which has the loaded
* def, clamps over-long times back into the storm via the same setTime.
* · Listener errors are caught and logged, never propagated one broken
* panel must not sever the selection for the others (A's emit() rule).
*
* DEFAULT: the wildnight at t=60 the gate-2.3 PIN, on purpose. It is the
* storm every Sprint-13 number was argued over (B's reasoning, kept) at the
* second the repo already agreed is THE representative moment: the funnel is
* worth 33% at the throat there, sitting on the 5963 s plateau, so the page
* opens showing a yard's weather doing something rather than a funnel that
* looks dead (my southerly rationale, retired it argued against a default
* nobody shares now).
*/
/** THE storm list, one copy. editor.wind.js re-exports it; keep in step with
* data/storms/ (c.test.js pins this list against its own, so drift is red). */
export const STORM_KEYS = [
'storm_01_gentle',
'storm_02_wildnight',
'storm_02b_icenight',
'storm_03_southerly',
'storm_03b_earlybuster',
];
export const DEFAULT_SEL = { storm: 'storm_02_wildnight', time: 60 };
/**
* A fresh store exported for c.test.js, which must be able to make throwaway
* instances without touching the page singleton.
*/
export function createStormSel({ storm = DEFAULT_SEL.storm, time = DEFAULT_SEL.time } = {}) {
if (!STORM_KEYS.includes(storm)) throw new Error(`[stormsel] unknown storm '${storm}'`);
const listeners = new Set();
const state = { storm, time };
const emit = () => {
// snapshot payload + snapshot listener set: a listener that unsubscribes
// (or subscribes) mid-emit must not skew delivery for the rest.
const payload = { storm: state.storm, time: state.time };
for (const fn of [...listeners]) {
try { fn(payload); } catch (err) { console.error('[stormsel] listener threw:', err); }
}
};
return {
get storm() { return state.storm; },
get time() { return state.time; },
/** @returns {boolean} true if the selection changed (false = no-op, silent) */
setStorm(key) {
if (!STORM_KEYS.includes(key)) {
throw new Error(`[stormsel] unknown storm '${key}' — keys: ${STORM_KEYS.join(', ')}`);
}
if (key === state.storm) return false;
state.storm = key;
emit();
return true;
},
/** @returns {boolean} true if the selection changed (false = no-op, silent) */
setTime(t) {
const v = Number(t);
if (!Number.isFinite(v) || v < 0) {
throw new Error(`[stormsel] time must be a finite second ≥ 0, got ${t}`);
}
if (v === state.time) return false;
state.time = v;
emit();
return true;
},
/** @returns {() => void} unsubscribe */
onChange(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
};
}
// --- the page singleton ------------------------------------------------------
// Module-import identity IS the sharing mechanism: editor.wind.js and
// editor_score.js import this same file, so stormSel() hands both the same
// object and there is no registration step to forget. Exposed on globalThis
// for console spelunking, same convention as __editorScore / __envelope.
let _sel = null;
export function stormSel() {
if (!_sel) {
_sel = createStormSel();
if (typeof globalThis !== 'undefined') globalThis.__stormSel = _sel;
}
return _sel;
}

View File

@ -23,7 +23,8 @@ import { loadSite, createWorld } from '../world.js';
// 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 { 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_stormsel.js';
import { createDebris } from '../debris.js';
import { createSkyFx, RainShadow } from '../skyfx.js';
import { SailRig, HARDWARE } from '../sail.js';
@ -884,6 +885,119 @@ export default async function run(t) {
+ '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_stormsel.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 pin', () => {
// The page must open on the gate-2.3 pinned moment — the storm+second every
// measured number is argued against. 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 === PIN.storm && DEFAULT_SEL.time === PIN.t,
`the default selection (${DEFAULT_SEL.storm} @ ${DEFAULT_SEL.time}s) has drifted off the `
+ `gate-2.3 pin (${PIN.storm} @ ${PIN.t}s) — if that is intentional, move the pin too`);
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
assert(s.setStorm('storm_03_southerly') === true, 'a real storm change should return true');
assert(seen.length === 1 && seen[0].storm === 'storm_03_southerly' && seen[0].time === DEFAULT_SEL.time,
`one change should mean one event with the new value, got ${JSON.stringify(seen)}`);
// 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_03_southerly') === 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_03_southerly' && 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.__stormSel === stormSel(), 'the console spelunking handle 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