HardYards/web/world/js/editor_stormsel.js
type-two 3a2e4ff5e1 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>
2026-07-20 18:05:33 +10:00

123 lines
5.4 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_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;
}