HardYards/web/world/js/editor_storm.js
type-two 0bb0e9eb6d Lane C S15: converge gate 2.2 onto B's THREADS proposal — B's filename/global/source/default taken, storm+time API kept; THREADS: seam receipt + gate-1.3 storm-side zero-delta confirmation
editor_stormsel.js -> editor_storm.js (B's name); globalThis.EDITOR_STORM
(B's name); setStorm/setTime carry B's source tag in the payload; default
becomes B's storm_03_southerly at my receipted t=40 (the pin stays pinned
in c.test — it never needed to be the default, and the c.test assert now
pins the CONVERGED pair instead so a private default cannot regrow).

Zero-delta second harness: envelope.html week mode, both shipped yards,
all five nights vs my S13 tables — byte-identical everywhere (site_02 x
earlybuster all 11 rows; backyard h1-h3 Pa/hint 266/946-1037/2093-2505/
1844-1933; branch ladder 0.88/0.76; t1 southerly 15.45). Measured on
lane/c @ gate-2.2 commit atop main c182fc1, pre-E-re-bake; zero expected
under the 1.00 ruling, zero measured, nothing to flag.

Selftest 413/0/0; mutations 6 (default drift) and 7 (source dropped)
red-then-green on top of the five in the previous commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:23:31 +10:00

134 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',
];
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;
}