HardYards/web/world/js/editor_storm.js
type-two d03712ae47 Lane C S16: gate 3 lands — the fabric bet pinned, forecast argues it, storm in the lists; selftest 438/0/0
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.
2026-07-20 20:33:10 +10:00

135 lines
6.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;
}