Gate 3.1 pins (c.test): the soaker over site_02 through gardenfly, porosity the only variable — bare <30 (17.5), best cloth line <50 (42.3, still >bare: the leak is partial), same $75 ring on membrane >90 FULL (96.5) with lost<2 and cost<=80, $60 membrane ring wins too (68.8), separation >40 (54.2). The inverse guard: membrane on carabiners reads WORSE than cloth (19.1/3 lost vs 24.5/2) — the F key is a bet, not an upgrade. Gate 3.3: forecastLines grows stones + rainRate (worded, banded); stormStats.hailSize + forecastFor hail.size band, spread 0.30, nesting/truth-holding pinned in the resolving loop; existing card fields byte-identical (new band draws last). storm_06 in STORM_KEYS + suite STORMS (sync assert is the tripwire). Envelope tool flown: throat 21.55 @ 59.2, q1 17.78 (rated slot), q2 wind-coldest yet cloth-side second-hottest — the pond's signature isolated between harnesses. Mutations, separate runs, red-then-green: gardenfly seam hardcoded -> both 3.1 pins red; STORM_KEYS drop -> list-sync red with diff; stormgen rng -> byte-equal red. Browser selftest 438/0/0 (418 + 4 storm honesty + 12 stormgen + 4 gate-3). THREADS: gate entries + D pairing receipt + B seam/finding notes.
135 lines
6.3 KiB
JavaScript
135 lines
6.3 KiB
JavaScript
/**
|
||
* editor_storm.js — THE storm + second selection for the editor page.
|
||
* [Lane C owns the file, SPRINT15 gate 2.2 — one storm picker, CONVERGED with
|
||
* Lane B's THREADS proposal (2026-07-20): B's filename, B's global name
|
||
* (`EDITOR_STORM`), B's `source` argument and B's default storm are all taken;
|
||
* the API keeps my `storm/time` symmetric shape because the gate asks for a
|
||
* shared storm+TIME selection — B's panel simply ignores the half it doesn't
|
||
* use. Countered-and-matched the same way we converged the gate-2.3 pin.]
|
||
*
|
||
* 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 southerly at t=40 — B's converged pick, and the reasons are
|
||
* authoring reasons, not courtesy: the wind overlay is the surface that is
|
||
* LIVE the moment the page opens and needs a storm that shows something (a
|
||
* funnel authored under the gentle storm looks like it does nothing); SCORE IT
|
||
* sits behind an explicit click; and site_03 — the yard most likely to be
|
||
* iterated next — was authored and played on the southerly. t=40 is the second
|
||
* my S14 receipt demonstrated: align 100%, peak gain ×1.46 on site_02. (My
|
||
* first cut defaulted the gate-2.3 pin, wildnight@60 — retired when B argued
|
||
* the authoring case; the pin stays pinned in c.test.js either way, it never
|
||
* needed to be the default.) c.test.js asserts THIS pair so neither lane's
|
||
* refactor can silently regrow a private default.
|
||
*/
|
||
|
||
/** 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',
|
||
'storm_06_soaker',
|
||
];
|
||
|
||
export const DEFAULT_SEL = { storm: 'storm_03_southerly', time: 40 };
|
||
|
||
/**
|
||
* 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 = (source) => {
|
||
// snapshot payload + snapshot listener set: a listener that unsubscribes
|
||
// (or subscribes) mid-emit must not skew delivery for the rest. `source`
|
||
// is B's ask: a debug string saying WHO wrote ('wind-panel', 'score',
|
||
// 'console'…), carried in the payload, never used for logic.
|
||
const payload = { storm: state.storm, time: state.time, source: source ?? null };
|
||
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, source) {
|
||
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(source);
|
||
return true;
|
||
},
|
||
|
||
/** @returns {boolean} true if the selection changed (false = no-op, silent) */
|
||
setTime(t, source) {
|
||
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(source);
|
||
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
|
||
// as EDITOR_STORM (B's name) for consoles and spelunking.
|
||
let _sel = null;
|
||
export function stormSel() {
|
||
if (!_sel) {
|
||
_sel = createStormSel();
|
||
if (typeof globalThis !== 'undefined') globalThis.EDITOR_STORM = _sel;
|
||
}
|
||
return _sel;
|
||
}
|