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>
123 lines
5.4 KiB
JavaScript
123 lines
5.4 KiB
JavaScript
/**
|
||
* 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 59–63 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;
|
||
}
|