From 0bb0e9eb6d32b499d3bb1e7d75803c40ee8635c1 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 20 Jul 2026 18:23:31 +1000 Subject: [PATCH] =?UTF-8?q?Lane=20C=20S15:=20converge=20gate=202.2=20onto?= =?UTF-8?q?=20B's=20THREADS=20proposal=20=E2=80=94=20B's=20filename/global?= =?UTF-8?q?/source/default=20taken,=20storm+time=20API=20kept;=20THREADS:?= =?UTF-8?q?=20seam=20receipt=20+=20gate-1.3=20storm-side=20zero-delta=20co?= =?UTF-8?q?nfirmation?= 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');