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>
This commit is contained in:
parent
3a2e4ff5e1
commit
0bb0e9eb6d
77
THREADS.md
77
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.
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user