Lane C S15 gate 2.2: ONE storm picker — editor_stormsel.js is the shared storm+time selection; wind panel becomes a view of it + JUMP TO WORST SECOND
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 <noreply@anthropic.com>
This commit is contained in:
parent
c182fc14cb
commit
3a2e4ff5e1
@ -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; } };
|
||||
}
|
||||
|
||||
122
web/world/js/editor_stormsel.js
Normal file
122
web/world/js/editor_stormsel.js
Normal file
@ -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;
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user