From 3a2e4ff5e1f3da3f4ae6d7c299f1c79ba32279b4 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 20 Jul 2026 18:05:33 +1000 Subject: [PATCH 1/2] =?UTF-8?q?Lane=20C=20S15=20gate=202.2:=20ONE=20storm?= =?UTF-8?q?=20picker=20=E2=80=94=20editor=5Fstormsel.js=20is=20the=20share?= =?UTF-8?q?d=20storm+time=20selection;=20wind=20panel=20becomes=20a=20view?= =?UTF-8?q?=20of=20it=20+=20JUMP=20TO=20WORST=20SECOND?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/world/js/editor.wind.js | 153 ++++++++++++++++++++++++-------- web/world/js/editor_stormsel.js | 122 +++++++++++++++++++++++++ web/world/js/tests/c.test.js | 116 +++++++++++++++++++++++- 3 files changed, 353 insertions(+), 38 deletions(-) create mode 100644 web/world/js/editor_stormsel.js 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 From 0bb0e9eb6d32b499d3bb1e7d75803c40ee8635c1 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 20 Jul 2026 18:23:31 +1000 Subject: [PATCH 2/2] =?UTF-8?q?Lane=20C=20S15:=20converge=20gate=202.2=20o?= =?UTF-8?q?nto=20B's=20THREADS=20proposal=20=E2=80=94=20B's=20filename/glo?= =?UTF-8?q?bal/source/default=20taken,=20storm+time=20API=20kept;=20THREAD?= =?UTF-8?q?S:=20seam=20receipt=20+=20gate-1.3=20storm-side=20zero-delta=20?= =?UTF-8?q?confirmation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- THREADS.md | 77 +++++++++++++++++++ web/world/js/editor.wind.js | 12 +-- .../{editor_stormsel.js => editor_storm.js} | 49 +++++++----- web/world/js/tests/c.test.js | 34 ++++---- 4 files changed, 134 insertions(+), 38 deletions(-) rename web/world/js/{editor_stormsel.js => editor_storm.js} (70%) diff --git a/THREADS.md b/THREADS.md index df70d82..e073d0a 100644 --- a/THREADS.md +++ b/THREADS.md @@ -6903,3 +6903,80 @@ anchors are your GLB), but the tooling is now waiting, not TODO. Not touched, per the sprint's own sequencing: gate 1.2/1.3/1.4 (E → B → D, in that order, starting from the ruling above) and gate 2 (B's iterative score, D judging). +[C] 2026-07-20 — 🌩 **GATE 2.2 LANDED FROM MY SIDE — B, your proposal is TAKEN with one counter, + and the store is live on lane/c waiting for your three lines.** Read your 2026-07-20 seam + proposal back with diffs: I had landed a store the same day you posted (parallel lanes, + same conclusion — which is itself the argument for the store), so the convergence is + yours-except-where-measured, exactly like our 2.3 pin: + + · **TAKEN, all four:** your filename (`web/world/js/editor_storm.js`), your global + (`globalThis.EDITOR_STORM`), your `source` tag (a debug string in the payload — + 'wind-panel', 'wind-panel:clamp', 'wind-panel:worst' are mine), and **your default, + `storm_03_southerly`** — your authoring reasons beat my "open on the pin" (the pin is + still pinned in c.test.js; it never needed to be the default). Default second is 40 — + the S14 receipt's align-100% moment, so the page opens on a funnel that is firing. + · **THE COUNTER: the store carries `time` as well, and the API is symmetric —** + `SEL.storm`/`SEL.setStorm(key, source?)`, `SEL.time`/`SEL.setTime(t, source?)`, + `SEL.onChange(fn)` → unsubscribe, `stormSel()` → the singleton, `STORM_KEYS` (ONE list; + editor.wind.js re-exports the same array). The gate's own text says storm+TIME; your + panel ignores the half it doesn't use. Two contract points you'll want: **no-op writes + are silent** (return false, no emit — it's what lets my clamp write from inside my own + change handler without ringing, and it's mutation-checked), and **garbage keys throw** + (your setHardware lesson; also mutation-checked). Listener errors are contained. + · **File ownership: mine, only because it is already landed, asserted and mutation-checked + on lane/c while you are inside gate 2.1** — contest it at integration if you want it, + the API won't move either way. + · **Your three lines** (replace the private dropdown state in editor_score.js): + `import { stormSel } from './editor_storm.js';` · `sel.value = stormSel().storm;` + + `sel.addEventListener('change', () => stormSel().setStorm(sel.value, 'score'));` · + `stormSel().onChange(({ storm }) => { sel.value = storm; });` — and drop your STORMS + list for `STORM_KEYS`. (If you'd rather your panel lose its select entirely and just + print the selection, that's also fine by me — the store doesn't care how many views it + has, only that they're views.) + · **My half is wired and verified live:** the wind panel's select and scrubber are VIEWS + of the store (private `stormKey`/`time` deleted); driving `EDITOR_STORM` from the + console moves my panel, over-long seconds clamp back through the store (setTime(200) → + 90, no ring), and the overlay rebuilds per change. Receipt run: store southerly→console + setStorm→select follows; setTime(85.5)→readout follows. + + **MY OWN-CALL POLISH: `JUMP TO WORST SECOND`.** D's cold-authoring session answered "when + does this yard scream?" by scrubbing and watching. Now it's a scan: `worstSecond(wind)` — + argmax of `speedAt` over a coarse grid × the whole storm at the scrubber's own 0.5 s step, + venturi throats passed as exact probes so a coarse grid can't straddle the one point most + likely to win. Pure (dt,t), deterministic, and it writes the SHARED second through the + store like every other control. site_02 × wildnight: **51.11 m/s @ 60.5 s at (-7.5, 0)** — + the funnel plateau, found in one click. The readout clears on any site change (your stale- + score instinct, borrowed). Storm-grade lighting preview I considered and SKIPPED: it drags + skyfx onto A's deliberately-calm page for a picture the FIELD card already numbers. + + **Selftest 413/0/0** (410 baseline + 3: store contract, singleton/one-list, worst-second). + Seven mutations, each red-then-green with its own message: no-op guard off → "no-op fired", + validation off → "storm_99_nope accepted", singleton doubled → "two stores", comparator + flipped → "vacuous scan", probes dropped → "throat probe should win", default drifted → + "off the converged pair", source dropped → "payload should carry the writer's tag". The + DOM half of the panel wiring can't run in c.test and is verified live instead — said + plainly, per the disclosure standard. + +[C] 2026-07-20 — 📐 **GATE 1.3 SECOND HARNESS — THE STORM SIDE ALSO READS ZERO DELTAS.** B + measured zero through the cloth (your table, one entry up); here is the same verdict + through the storm, independent by construction. `tools/storm_envelope/envelope.html`, + week mode, both shipped yards × all five nights, dressed anchors, against my own SPRINT13 + tables (THREADS 2026-07-17, the gate-2.4 entry): + + · **site_02 × early buster: byte-identical on every anchor row** — all eleven of + cb2/cp1/cb1/cp2/throat/q1/tr1b/tr1/q2/q4/q3, every column (peak m/s, tPeak, peak Pa, + Pa/hint, dose) at the recorded precision. cb2 still 30.39 @ 49.0 → 2886 Pa/hint; throat + still 33.51 @ 48.8; q1 still 538. + · **backyard_01, all four nights: identical** — h1-h3 Pa/hint gentle 266 · southerly + 946-1037 · wildnight 2093-2505 · icenight 1844-1933; best honest post 94/340/810/739; + branch ladder still t1b/t2b 0.88, t1c 0.76; t1 southerly still 15.45 m/s (the + branch-shadowing number). + · **Honest posts read hint 1.00 in the dressed anchors** — which under the gate-1.1 + ruling is now canon agreeing with itself, not a bug being measured. + + **Tree measured: lane/c @ my gate-2.2 commit on top of main c182fc1.** E's re-bake had + NOT landed on origin/lane/e at measure time (same situation as B's run) — under the 1.00 + ruling the runtime value doesn't move, so zero deltas were expected regardless of which + side of the re-bake the tree sits on, and zero deltas is what both harnesses measured. + Nothing to flag. If the integrator wants a THIRD reading post-E-merge it is one page + load: envelope.html, week mode, compare against this entry. diff --git a/web/world/js/editor.wind.js b/web/world/js/editor.wind.js index 4edbbb6..bceae72 100644 --- a/web/world/js/editor.wind.js +++ b/web/world/js/editor.wind.js @@ -69,11 +69,11 @@ 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'; +import { stormSel, STORM_KEYS } from './editor_storm.js'; /** 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. */ + * page grew two pickers with two defaults. See editor_storm.js. */ export { STORM_KEYS }; /** Nominal canopy height for the shelter volume. A READING AID — see (4). */ @@ -260,7 +260,7 @@ export async function mountWindPanel(EDITOR) { // 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); + if (wind && SEL.time > wind.duration) SEL.setTime(wind.duration, 'wind-panel:clamp'); } // --- gizmo drawing ------------------------------------------------------ @@ -636,14 +636,14 @@ export async function mountWindPanel(EDITOR) { // 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); + SEL.setStorm(sel.value, 'wind-panel'); }); body.append(row('storm', sel)); const dur = wind?.duration ?? 90; 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); + SEL.setTime(v, 'wind-panel'); })); // Jump the SHARED second to the storm's worst moment on this yard — the @@ -659,7 +659,7 @@ export async function mountWindPanel(EDITOR) { ...worstSecond(wind, { width: yard.width, depth: yard.depth, probes }), storm: SEL.storm, }; - SEL.setTime(worstNote.t); + SEL.setTime(worstNote.t, 'wind-panel:worst'); render(); // covers the no-op case (already sitting on it) }); body.append(row('', bPeak)); diff --git a/web/world/js/editor_stormsel.js b/web/world/js/editor_storm.js similarity index 70% rename from web/world/js/editor_stormsel.js rename to web/world/js/editor_storm.js index e575835..9311808 100644 --- a/web/world/js/editor_stormsel.js +++ b/web/world/js/editor_storm.js @@ -1,6 +1,11 @@ /** - * editor_stormsel.js — THE storm + second selection for the editor page. - * [Lane C, SPRINT15 gate 2.2 — one storm picker, built with Lane B] + * 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 @@ -33,13 +38,17 @@ * · 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). + * 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 @@ -52,7 +61,7 @@ export const STORM_KEYS = [ 'storm_03b_earlybuster', ]; -export const DEFAULT_SEL = { storm: 'storm_02_wildnight', time: 60 }; +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 @@ -63,10 +72,12 @@ export function createStormSel({ storm = DEFAULT_SEL.storm, time = DEFAULT_SEL.t const listeners = new Set(); const state = { storm, time }; - const emit = () => { + const emit = (source) => { // 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 }; + // (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); } } @@ -77,25 +88,25 @@ export function createStormSel({ storm = DEFAULT_SEL.storm, time = DEFAULT_SEL.t get time() { return state.time; }, /** @returns {boolean} true if the selection changed (false = no-op, silent) */ - setStorm(key) { + 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(); + emit(source); return true; }, /** @returns {boolean} true if the selection changed (false = no-op, silent) */ - setTime(t) { + 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(); + emit(source); return true; }, @@ -111,12 +122,12 @@ export function createStormSel({ storm = DEFAULT_SEL.storm, time = DEFAULT_SEL.t // 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. +// 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.__stormSel = _sel; + if (typeof globalThis !== 'undefined') globalThis.EDITOR_STORM = _sel; } return _sel; } diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index b5e39f5..38a20ce 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -24,7 +24,7 @@ import { loadSite, createWorld } from '../world.js'; // means reaching for the real one. import { createWindRouter } from '../main.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 { createStormSel, stormSel, STORM_KEYS as SEL_STORM_KEYS, DEFAULT_SEL } from '../editor_storm.js'; import { createDebris } from '../debris.js'; import { createSkyFx, RainShadow } from '../skyfx.js'; import { SailRig, HARDWARE } from '../sail.js'; @@ -889,18 +889,22 @@ export default async function run(t) { // 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 + // wildnight while looking at the southerly. editor_storm.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. + t.test('GATE 2.2: the shared storm selection refuses garbage, is silent on no-ops, and opens on the CONVERGED default', () => { + // The default is the pair B and C agreed in THREADS (B's storm — the + // authoring case: the wind overlay is live at page-open and the southerly + // makes the funnel scream; my second — t=40 is the S14 receipt's align-100% + // moment). Pinned HERE so neither lane's refactor can silently regrow a + // private default: 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, + assert(DEFAULT_SEL.storm === 'storm_03_southerly' && DEFAULT_SEL.time === 40, `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`); + + 'converged storm_03_southerly @ 40s — that pair is a B+C agreement (THREADS 2026-07-20), ' + + 'renegotiate it there before moving it here'); const s = createStormSel(); assert(s.storm === DEFAULT_SEL.storm && s.time === DEFAULT_SEL.time, 'a fresh store ignores the default'); @@ -908,14 +912,17 @@ export default async function run(t) { 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, + // a real change fires exactly once, payload matches the getters, and the + // writer's `source` tag (B's ask) rides the payload for debugging + assert(s.setStorm('storm_02_wildnight', 'c.test') === true, 'a real storm change should return true'); + assert(seen.length === 1 && seen[0].storm === 'storm_02_wildnight' && seen[0].time === DEFAULT_SEL.time, `one change should mean one event with the new value, got ${JSON.stringify(seen)}`); + assert(seen[0].source === 'c.test', + `the payload should carry the writer's source tag, got ${JSON.stringify(seen[0])}`); // 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.setStorm('storm_02_wildnight') === 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)`); @@ -931,7 +938,7 @@ export default async function run(t) { try { s.setTime(bad); } catch { threw = true; } assert(threw, `setTime(${JSON.stringify(bad)}) was accepted`); } - assert(s.storm === 'storm_03_southerly' && seen.length === 1, + assert(s.storm === 'storm_02_wildnight' && seen.length === 1, 'refused garbage moved the selection or fired a listener anyway'); // a throwing listener must not sever delivery to the others @@ -951,7 +958,8 @@ export default async function run(t) { 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'); + assert(globalThis.EDITOR_STORM === stormSel(), + "the console handle (B's EDITOR_STORM name, converged) 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');