diff --git a/web/world/js/editor.wind.js b/web/world/js/editor.wind.js index 9d30de3..4edbbb6 100644 --- a/web/world/js/editor.wind.js +++ b/web/world/js/editor.wind.js @@ -69,20 +69,12 @@ import * as THREE from '../vendor/three.module.js'; import { smoothstep } from './weather.core.js'; import { loadStorm, windForSite } from './weather.js'; +import { stormSel, STORM_KEYS } from './editor_stormsel.js'; -/** Keep in step with data/storms/ — same list the game's STORMS carries. */ -export const STORM_KEYS = [ - 'storm_01_gentle', - 'storm_02_wildnight', - 'storm_02b_icenight', - 'storm_03_southerly', - 'storm_03b_earlybuster', -]; - -/** The southerly is the storm that makes a corner block scream — a sensible - * place for an author to land, because a funnel authored under the gentle - * storm looks like it does nothing. */ -const DEFAULT_STORM = 'storm_03_southerly'; +/** Re-exported from the shared selection store — ONE list, one copy. Sprint 14 + * had this list here and a second copy in editor_score.js, which is how the + * page grew two pickers with two defaults. See editor_stormsel.js. */ +export { STORM_KEYS }; /** Nominal canopy height for the shelter volume. A READING AID — see (4). */ const CANOPY_Y = 3.2; @@ -167,6 +159,46 @@ export function sampleWindField(wind, { width, depth, step = 1.5, t = 0 }) { return { samples, dir, uniform, max, min }; } +/** + * The storm's WORST second on this yard: argmax of `speedAt` over a coarse + * grid × the whole storm. [SPRINT15, lane C's own-call polish] + * + * D authored site_03 cold and the scrubber's question was always "WHEN does + * this yard scream?" — answered until now by dragging and watching, which is + * measurement by patience. This is the same question asked properly: scan the + * lattice, return the second. Pure — same wind + same args = same answer on + * any machine (speedAt has no clock and no call-time RNG), and dt defaults to + * the scrubber's own 0.5 s step so the answer is always a second the scrubber + * can actually sit on. + * + * `probes` exist because a coarse grid can straddle a throat: the panel passes + * every authored venturi centre so the one point most likely to BE the worst + * is always scanned exactly. + * + * @returns {{t:number, speed:number, x:number, z:number}} + */ +export function worstSecond(wind, { width, depth, step = 3, dt = 0.5, probes = [] } = {}) { + const pts = []; + const nx = Math.max(2, Math.round(width / step)); + const nz = Math.max(2, Math.round(depth / step)); + for (let i = 0; i < nx; i++) { + for (let j = 0; j < nz; j++) { + pts.push({ x: -width / 2 + (i + 0.5) * (width / nx), z: -depth / 2 + (j + 0.5) * (depth / nz) }); + } + } + for (const p of probes) pts.push({ x: p.x, z: p.z }); + const n = Math.round(wind.duration / dt); + let best = { t: 0, speed: -Infinity, x: 0, z: 0 }; + for (let k = 0; k <= n; k++) { + const t = k * dt; + for (const p of pts) { + const s = wind.core.speedAt(p.x, p.z, t); + if (s > best.speed) best = { t, speed: s, x: p.x, z: p.z }; + } + } + return best; +} + /** Amplification → colour. Cool/dim = sheltered, white = untouched, hot = funnelled. */ function ratioColor(ratio, out) { // 0.55 …… 1.0 …… 1.6 mapped blue → pale → red. The neutral band is @@ -190,9 +222,17 @@ export async function mountWindPanel(EDITOR) { const { root, body } = EDITOR.mountPanel({ id: 'wind', title: 'WIND', order: 50 }); // --- state ------------------------------------------------------------- + // Storm + second live in the SHARED selection store, not here — gate 2.2. + // This panel is the picker UI (the owner's controls), but the value it edits + // is THE page selection, the same one SCORE IT scores. Local `stormKey`/ + // `time` are gone on purpose: state this panel privately held is exactly how + // D scored one storm while looking at another. + const SEL = stormSel(); const storms = {}; - let stormKey = DEFAULT_STORM; - let time = 40; + /** Last worst-second scan, or null. Cleared on any site change — the claim + * "this is the storm's worst second on this yard" dies the moment the yard + * moves (B's stale-score instinct, borrowed). Keyed by storm as well. */ + let worstNote = null; let showField = true; let showShelters = true; let gridStep = 1.5; @@ -209,14 +249,18 @@ export async function mountWindPanel(EDITOR) { * game would fly, not ones re-derived from the JSON by hand. */ function buildWind() { - const def = storms[stormKey]; + const def = storms[SEL.storm]; if (!def) return null; return windForSite(def, EDITOR.site, EDITOR.world?.anchors ?? []); } function refreshWind() { wind = buildWind(); - if (wind && time > wind.duration) time = wind.duration; + // Clamp an over-long second back into the storm THROUGH the store, so the + // clamped value is the selection everyone shares, not a private display + // fix. The store's no-op guard is what keeps this from ringing: the emit + // this triggers re-enters here, finds time ≤ duration, and writes nothing. + if (wind && SEL.time > wind.duration) SEL.setTime(wind.duration); } // --- gizmo drawing ------------------------------------------------------ @@ -432,7 +476,7 @@ export async function mountWindPanel(EDITOR) { if (!wind) return null; const yard = EDITOR.site.yard ?? { width: 30, depth: 20 }; const field = sampleWindField(wind, { - width: yard.width, depth: yard.depth, step: gridStep, t: time, + width: yard.width, depth: yard.depth, step: gridStep, t: SEL.time, }); if (showField) drawField(field); if (showShelters) for (const s of wind.core.shelters) drawShelter(s, field.dir); @@ -580,29 +624,50 @@ export async function mountWindPanel(EDITOR) { body.textContent = ''; const field = redraw(); - // --- storm + second --- + // --- storm + second — views of the SHARED selection, gate 2.2 --- const sel = el('select', 'ed-sel'); for (const k of STORM_KEYS) { const o = el('option', null, k.replace(/^storm_/, '')); o.value = k; - if (k === stormKey) o.selected = true; + if (k === SEL.storm) o.selected = true; sel.append(o); } - sel.addEventListener('change', async () => { - stormKey = sel.value; - await ensureStorm(stormKey); - refreshWind(); - render(); + sel.addEventListener('change', () => { + // Write the store, nothing else — the store's change is what re-renders, + // so a storm picked ANYWHERE (this select, B's panel, the console) walks + // the identical path through this panel. + SEL.setStorm(sel.value); }); body.append(row('storm', sel)); const dur = wind?.duration ?? 90; - body.append(row('second', el('span', null, `${time.toFixed(1)} s / ${dur.toFixed(0)} s`))); - body.append(slider(0, dur, 0.5, Math.min(time, dur), (v) => `${v.toFixed(1)} s`, (v) => { - time = v; - render(); + body.append(row('second', el('span', null, `${Math.min(SEL.time, dur).toFixed(1)} s / ${dur.toFixed(0)} s`))); + body.append(slider(0, dur, 0.5, Math.min(SEL.time, dur), (v) => `${v.toFixed(1)} s`, (v) => { + SEL.setTime(v); })); + // Jump the SHARED second to the storm's worst moment on this yard — the + // scrubber question D kept answering by patience, answered by scan. Writes + // through the store like every other control, so SCORE IT and any future + // subscriber see the same landing. + const bPeak = el('button', 'ed-btn', 'JUMP TO WORST SECOND'); + bPeak.addEventListener('click', () => { + if (!wind) return; + const yard = EDITOR.site.yard ?? { width: 30, depth: 20 }; + const probes = (wind.core.venturi ?? []).map((v) => ({ x: v.x, z: v.z })); + worstNote = { + ...worstSecond(wind, { width: yard.width, depth: yard.depth, probes }), + storm: SEL.storm, + }; + SEL.setTime(worstNote.t); + render(); // covers the no-op case (already sitting on it) + }); + body.append(row('', bPeak)); + if (worstNote && worstNote.storm === SEL.storm) { + body.append(row('worst', el('span', worstNote.speed > (field?.uniform ?? 0) * 1.15 ? 'ed-warn' : null, + `${worstNote.speed.toFixed(2)} m/s @ ${worstNote.t.toFixed(1)} s · (${worstNote.x.toFixed(1)}, ${worstNote.z.toFixed(1)})`))); + } + if (field) { const card = el('div', 'ed-card'); const head = el('div', 'ed-card-head'); @@ -714,10 +779,10 @@ export async function mountWindPanel(EDITOR) { if (wind) { const core = wind.core.venturi[i]; if (core) { - const d = wind.core.dirAt(time); + const d = wind.core.dirAt(SEL.time); const align = Math.abs(Math.cos(d) * core.axisX + Math.sin(d) * core.axisZ); const fired = Math.pow(align, core.sharp); - const at0 = wind.core.speedAt(core.x, core.z, time); + const at0 = wind.core.speedAt(core.x, core.z, SEL.time); const r = el('div', 'ed-card-row'); r.append(el('span', 'ed-kv', 'now'), el('span', fired > 0.5 ? 'ed-warn' : 'ed-kv', @@ -743,15 +808,29 @@ export async function mountWindPanel(EDITOR) { if (!storms[k]) storms[k] = await loadStorm(k); } - await ensureStorm(stormKey); - refreshWind(); - render(); + /** + * React to the shared selection. Async because a newly-picked storm may need + * fetching; the token drops a stale response — two quick switches must not + * let the slower fetch paint the panel with the loser (the def cache makes + * this near-impossible to hit, and near-impossible is not a guard). + */ + let selGen = 0; + async function applySel() { + const gen = ++selGen; + await ensureStorm(SEL.storm); + if (gen !== selGen) return; + refreshWind(); + render(); + } + + await applySel(); + SEL.onChange(() => { applySel(); }); // A clears the overlay on every rebuild — so every rebuild, I redraw. My // gizmos are a pure function of (site, storm, second), which is exactly why // this is one line instead of a lifetime problem. - EDITOR.on('rebuild', () => { refreshWind(); render(); }); - EDITOR.on('siteload', () => { selVenturi = 0; }); + EDITOR.on('rebuild', () => { worstNote = null; refreshWind(); render(); }); + EDITOR.on('siteload', () => { selVenturi = 0; worstNote = null; }); /** * The wind is rebuilt HERE, on any 'change', rather than in my own commit(). * @@ -768,7 +847,7 @@ export async function mountWindPanel(EDITOR) { * function of the site as it IS, no matter who changed it. render() never * calls markDirty(), so this cannot loop. */ - EDITOR.on('change', () => { refreshWind(); render(); }); + EDITOR.on('change', () => { worstNote = null; refreshWind(); render(); }); return { root, body, redraw, buildWind, get wind() { return wind; } }; } diff --git a/web/world/js/editor_stormsel.js b/web/world/js/editor_stormsel.js new file mode 100644 index 0000000..e575835 --- /dev/null +++ b/web/world/js/editor_stormsel.js @@ -0,0 +1,122 @@ +/** + * 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; +} diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index d60cc8c..b5e39f5 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -23,7 +23,8 @@ import { loadSite, createWorld } from '../world.js'; // including on the day the game itself changed. "Two harnesses, one number" // means reaching for the real one. import { createWindRouter } from '../main.js'; -import { normalizeAxis, shelterAtten } from '../editor.wind.js'; +import { normalizeAxis, shelterAtten, worstSecond, STORM_KEYS as WIND_STORM_KEYS } from '../editor.wind.js'; +import { createStormSel, stormSel, STORM_KEYS as SEL_STORM_KEYS, DEFAULT_SEL } from '../editor_stormsel.js'; import { createDebris } from '../debris.js'; import { createSkyFx, RainShadow } from '../skyfx.js'; import { SailRig, HARDWARE } from '../sail.js'; @@ -884,6 +885,119 @@ export default async function run(t) { + 'chain, so this sweep proves nothing about setSheltersFromTrees'); }); + // ========================================================================= + // GATE 2.2 (SPRINT15) — ONE STORM PICKER: the shared selection store + // + // Sprint 14's page had two pickers with two defaults, and D scored the + // wildnight while looking at the southerly. editor_stormsel.js is the one + // owner now; these asserts pin its contract. The DOM half (my select and + // scrubber writing/reading it) cannot run here and is verified live on the + // editor page — said plainly rather than papered over with a fake DOM. + + t.test('GATE 2.2: the shared storm selection refuses garbage, is silent on no-ops, and opens on the pin', () => { + // The page must open on the gate-2.3 pinned moment — the storm+second every + // measured number is argued against. Two defaults was the whole bug. + assert(SEL_STORM_KEYS.includes(DEFAULT_SEL.storm), 'the default storm is not a real storm'); + assert(DEFAULT_SEL.storm === PIN.storm && DEFAULT_SEL.time === PIN.t, + `the default selection (${DEFAULT_SEL.storm} @ ${DEFAULT_SEL.time}s) has drifted off the ` + + `gate-2.3 pin (${PIN.storm} @ ${PIN.t}s) — if that is intentional, move the pin too`); + + const s = createStormSel(); + assert(s.storm === DEFAULT_SEL.storm && s.time === DEFAULT_SEL.time, 'a fresh store ignores the default'); + + const seen = []; + const off = s.onChange((p) => seen.push(p)); + + // a real change fires exactly once, payload matches the getters + assert(s.setStorm('storm_03_southerly') === true, 'a real storm change should return true'); + assert(seen.length === 1 && seen[0].storm === 'storm_03_southerly' && seen[0].time === DEFAULT_SEL.time, + `one change should mean one event with the new value, got ${JSON.stringify(seen)}`); + + // no-op writes are SILENT — this is what lets a subscriber clamp the + // selection from inside its own change handler without ringing forever + assert(s.setStorm('storm_03_southerly') === false, 'a no-op storm write should return false'); + assert(s.setTime(s.time) === false, 'a no-op time write should return false'); + assert(seen.length === 1, `a no-op fired a listener — the anti-loop guard is gone (${seen.length} events)`); + + // garbage refused, loudly, without moving the selection or ringing + for (const bad of ['storm_99_nope', '', null]) { + let threw = false; + try { s.setStorm(bad); } catch { threw = true; } + assert(threw, `setStorm(${JSON.stringify(bad)}) was accepted — a selection that can hold a ` + + 'storm that does not exist is the _v1-404 bug one layer up'); + } + for (const bad of [NaN, -1, Infinity, 'soon']) { + let threw = false; + try { s.setTime(bad); } catch { threw = true; } + assert(threw, `setTime(${JSON.stringify(bad)}) was accepted`); + } + assert(s.storm === 'storm_03_southerly' && seen.length === 1, + 'refused garbage moved the selection or fired a listener anyway'); + + // a throwing listener must not sever delivery to the others + const order = []; + s.onChange(() => { order.push('a'); throw new Error('deliberate'); }); + s.onChange(() => order.push('b')); + s.setTime(12.5); + assert(order.join('') === 'ab', `a throwing listener severed its neighbours (delivered: ${order})`); + + // unsubscribe works: `seen` has the storm change + the 12.5 s write = 2, + // and the write AFTER off() must not add a third + off(); + s.setTime(20); + assert(seen.length === 2, `unsubscribed listener still delivered (${seen.length} events)`); + }); + + t.test('GATE 2.2: one singleton, one storm list — every consumer holds the same objects', () => { + // Import identity is the sharing mechanism, so it is the thing to pin. + assert(stormSel() === stormSel(), 'stormSel() built two stores — the page is back to two pickers'); + assert(globalThis.__stormSel === stormSel(), 'the console spelunking handle points at a different store'); + // editor.wind.js re-exports the store's list — SAME ARRAY, not a copy that + // can drift (two copies of this list is how the page grew two pickers). + assert(WIND_STORM_KEYS === SEL_STORM_KEYS, 'editor.wind.js carries its own storm list again'); + // and the shared list agrees with this suite's (which the node runner + // keeps honest against data/storms/) + const a = [...SEL_STORM_KEYS].sort().join(','); + const b = [...STORMS].sort().join(','); + assert(a === b, `the shared storm list and the suite's disagree:\n store: ${a}\n suite: ${b}`); + }); + + t.test('worst-second finder: deterministic, self-consistent, and it cannot miss the pinned throat moment', () => { + // The JUMP TO WORST SECOND button (SPRINT15 polish) is a claim about the + // whole storm, so it gets the same discipline as any other number. + const wind = editorWind(); // site_02 × wildnight, dressed + const yard = pinSite.yard ?? { width: 30, depth: 20 }; + const args = { + width: yard.width, depth: yard.depth, + probes: [{ x: PIN.probe.x, z: PIN.probe.z }], // the throat, as the panel passes it + }; + const a = worstSecond(wind, args); + const b = worstSecond(wind, args); + assert(a.t === b.t && a.speed === b.speed && a.x === b.x && a.z === b.z, + `two identical scans disagreed: ${JSON.stringify(a)} vs ${JSON.stringify(b)} — the scan is not pure`); + assert(Number.isFinite(a.speed) && a.speed > 0, `vacuous scan: ${JSON.stringify(a)}`); + // the reported worst is a real sample, not an accumulator artefact + assert(a.speed === wind.core.speedAt(a.x, a.z, a.t), + `worst.speed ${a.speed} ≠ speedAt(worst) ${wind.core.speedAt(a.x, a.z, a.t)}`); + // dominance at a point ON the scan lattice: the throat probe is scanned + // exactly, and t=60.0 sits on the 0.5 s lattice, so the argmax reporting + // anything BELOW the pinned throat moment means the comparator or the + // probe wiring is broken. (An off-lattice point could legitimately exceed + // the scan; an on-lattice one cannot.) + const throatAtPin = wind.core.speedAt(PIN.probe.x, PIN.probe.z, PIN.t); + assert(a.speed >= throatAtPin, + `worst ${a.speed} m/s < throat@pin ${throatAtPin} m/s — the scan missed a lattice point it was given`); + // probes are WIRED, proven by making them the only way to win: a tiny grid + // pinned to the yard centre sits > 6 m from the throat — outside the + // funnel's 4 m radius, so no grid point ever sees the gain — while the + // throat probe does. If the probe list is dropped, the winner cannot be at + // the throat. (Deterministic: same scan, same winner, every run.) + const c = worstSecond(wind, { width: 4, depth: 4, step: 3, probes: [{ x: PIN.probe.x, z: PIN.probe.z }] }); + assert(c.x === PIN.probe.x && c.z === PIN.probe.z, + `with the grid held off the funnel, the throat probe should win the scan — winner was ` + + `(${c.x}, ${c.z}) at ${c.speed} m/s, so the probes argument is not reaching the lattice`); + }); + // ========================================================================= // The gizmos tell the truth about the maths they draw