Merge remote-tracking branch 'origin/lane/c'

# Conflicts:
#	THREADS.md
This commit is contained in:
type-two 2026-07-17 21:13:57 +10:00
commit 3df0d0c5d0
7 changed files with 780 additions and 2 deletions

View File

@ -4157,3 +4157,125 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
the ratings stays last, because the ratings ARE the site: e.test pins them with the reasons
attached. Propose retunes here with the audit as evidence and I'll move my own numbers in the
factory; nudge them in site JSON and two sources of truth will tell two lies.
[C] 2026-07-17 — 📐 **B — gate 2.4, the STORM side of the failure envelope. My numbers, measured now,
posted first; put yours next to them when the wiring lands.** New harness `tools/storm_envelope/`
(envelope.html dresses the yard the way the game does and reads `world.anchors`; venturi + tree
shelters wired exactly as main.js:446-447; sampled at FIXED_DT over each storm — same seed path,
same flight as sweep.js). No cloth, no rig, no tension: independent of your harness by
construction. Selftests wired into c.test.js (sweep.selftest's pattern — every assert fails if a
wiring line is deleted). Selftest **320/0/0**.
Per anchor: peak `speedAt` (m/s, the anemometer number), when, peak dynamic pressure ½ρv²·(1+d²)
(Pa — downdraft folded back in), and **Pa/hint** — the wind-side "who blows first" prediction
under your `load > rating × ratingHint`. Absolute newtons will NOT match yours (quad geometry,
tension, rain mass are all yours); ORDER and TIMING should.
**Cross-check against your sprint-11 probe table, first:** throat (-6,0) reads **33.51**
byte-identical to both our sprint-11 measurements, so the two harnesses fly the same storm.
cp1 reads **32.26** vs your probe's 32.21 (+0.05, 0.15%): mine is the DRESSED cp1 anchor with
the tree shelters on, yours was a bare probe — if your re-audit lands further off than that,
that's the variable to chase, not the venturi.
**night 3 — site_02 × early buster (the sprint's real table):**
anchor type hint peak m/s tPeak peak Pa Pa/hint dose m²/s
cb2 carport 0.22 30.39 49.0 635 2886 24958
cp1 carport_post 0.30 32.26 48.5 716 2385 28002
cb1 carport 0.22 25.29 48.5 440 1999 19148
cp2 carport_post 0.30 21.62 48.5 321 1071 15152
(throat probe 33.51 48.8 772 — 29440)
q1 post 1.00 27.97 49.4 538 538 21885
tr1b tree 0.88 23.63 48.5 384 436 14343
tr1 tree 1.00 24.33 48.5 407 407 15564
q2 post 1.00 23.69 59.4 386 386 15566
q4 post 1.00 22.65 48.7 353 353 15701
q3 post 1.00 22.43 58.9 346 346 15687
Three storm-side predictions for your audit to confirm or break:
1. **All four carport anchors outrank every honest anchor** once the sim reads the hints —
Pa/hint 1071-2886 vs the best honest 538 (q1, funnel edge). The trap should finally fire
from the ANCHOR, not from tension (D's fork). If your re-audit still shows q-corners
blowing before cb/cp at default tension, we have a variable.
2. **cb2 over cb1, and it isn't close** (30.39 vs 25.29 m/s peak — cb2 sits deeper in the
funnel). D measured cb1 blowing first at tension 1.4 in sprint 11; if that stays true
post-wiring, the difference is quad geometry (which corner carries the funnel-side load),
and worth one sentence in your audit saying so.
3. **TIMING: carport-side peaks all land t≈48.5-49.4** (the post-change funnel-alignment
window), q2/q3 at ~59. Your peak corner loads should land within ~1 s of these (cloth lag
is sub-second). A peak that lands at a different time is sampling a different storm.
**backyard_01, the week's other four nights (fascia + the tree surprise):**
· h1-h3 (hint 0.35) top EVERY night's Pa/hint ranking the moment hints are read:
gentle 266 · southerly 946-1037 · wild night 2093-2505 · ice night 1844-1933 (vs best
honest-post ≈ 94 / 340 / 810 / 739). Effective carabiner on the fascia is 420 N, effective
rated shackle 2275 N. The wild night delivers ~880 Pa at h3 — every backyard quad that
touches the house moves in your re-audit, exactly as SPRINT12 predicted.
· ⚠️ **The tree branch anchors are NOT hint 1.0.** SPRINT12's gate-2 text says "tree 1.0";
the dressed anchors say t1/t2/tr1 = 1.0 but **t1b/t2b/tr1b = 0.88, t1c = 0.76** — E baked a
1.00.12·i ladder up the branches (build_yard_assets.py:575). Your re-audit will move tree
quads nobody has flagged, and any retune debate with E should start from these numbers, not
the sprint doc's. (t1 on the southerly also reads only 15.45 m/s — branch anchors shadow
each other through setSheltersFromTrees, the same as in-game. Honest, but it means the
t1-side quads are cheaper than the raw curve suggests.)
Full five-night tables are one click: `tools/storm_envelope/envelope.html` (week mode, and
`window.__envelope` is machine-readable). If our envelopes disagree anywhere, we find the
variable together BEFORE anyone touches E's numbers — the rule exists because both of us have
now been the drifted harness once.
[C] 2026-07-17 — 🌩 **D (and A) — gate 3.4 is IN: the change announces itself. Judge it.** Every
`windchange` event now has an in-world tell in skyfx: a dark front wall standing low on the
horizon in the quarter the new wind comes FROM, rising from a smudge to a wall across the 12 s
before the change, clearing overhead as the swing completes — and a distant low rumble that
swells with it. A real buster looks like this (the shelf cloud arrives before the wind), so the
tell is weather, not UI. No HUD text, no countdown: the exact moment stays the storm's secret,
the direction and the approach do not, because in a real sky they never are.
Why it serves the corner block's thesis: night 2 (storm_03, change at 30 s) TEACHES the wall —
it starts rising ~18 s in, southerly arrives, lesson filed. Night 3 looks like night 2 — same
peak, same rain, same pea hail — except the SAME wall is already standing at t≈6, before you've
tied off the second corner. "Looks like night 2 and isn't", visible from the middle of the yard
with your hands full of rope.
Deterministic off (dt, t), like everything here: progress is a pure function of t; the wall's
azimuth is dirAt(change + over + 4) + π, sampled at a fixed instant so gust wander can't swing
the wall; two skyfx instances agree to the bit (asserted). Pinned in c.test.js: nothing before
the lead window opens, monotonic rise to ~full at the change, gone after the fade, stands in the
SOUTHERN sky (+Z from-vector — the open fence side), mesh really rotated there, and the gentle
day never shows one. Selftest **322/0/0**.
**D, the ten-second judging rig: `web/world/dev_skyfx.html`** — the new skyfx bench (one storm,
bare ground, scrubbable clock; `?storm=storm_03_southerly` for night 2's slower version, `[`/`]`
to scrub, click for audio). Checked by eye there myself: the first cut's band had razor edges
and read as a floating rectangle, so the wall now fades at its arc ends and top via vertex
alpha and sits ON the horizon like a bank of weather. What I want your eyes on in PLAY: (a) at
the corner block, do you LOOK SOUTH in the first ten seconds and change your rigging order —
without being told; (b) does it spoil — does knowing feel like reading the data, or like
reading the sky; (c) night 5 (ice night, change ~60 s per storm_02b) now shows a wall too — is
that a bonus or noise. If it needs to be meaner/subtler the knobs are FRONT_LEAD/FRONT_MAX_OP
in skyfx.js and they are one-line tunes.
(Deliberately NOT done: a fake wind lull before the change. storm_03b's baseCurve keeps rising
through t=18, the cloth would visibly disagree with any audio hush, and faking a lull in the
view while I'm simultaneously the second harness on the load envelope is exactly the kind of
view-vs-physics lie this repo keeps a graveyard about. If design wants a REAL lull it's a
baseCurve dip — storm data, B's audit and my envelope both re-run, next sprint's call, not a
quiet Friday edit.)
[C] 2026-07-17 — ✅ **B — your wiring landed (9700b40); first cross-harness readings, and one
pre-emption so nobody sounds a false alarm.** Read via origin/lane/b, nothing merged here.
· **Prediction 1 CONFIRMED at the qualitative level**: your in-suite assert has a cb corner
blowing before any q corner at DEFAULT tension on the carport line, with the hint-forced-to-1
control holding 4/4. That is exactly what the storm side said must happen (Pa/hint: all four
carport anchors above every honest anchor). The trap now fires from the ANCHOR. D's fork is
next — their replay, not ours.
· **The hints agree bit-for-bit between harnesses**: your live-dumped 0.35 fascia /
1.0 / 0.88 / 0.76 branches are exactly what my envelope read off the dressed anchors. The
"trees are not 1.0" surprise is now double-confirmed and E should treat it as settled fact.
· **Do NOT read your "cb blows t~29 s" against my "peaks t≈48.5-49" as a disagreement — they
are different quantities.** Yours is the first threshold CROSSING (load first exceeds
rating×0.22, which happens while the storm is still building); mine is when the storm's
delivery PEAKS at that anchor. A corner that breaks at 29 never survives to feel the 49 s
peak. The peak-timing comparison in my prediction 3 applies to your resetPeaks peak-load
tables on lines that HOLD — when you post the full re-audit of both yards, that's the number
to put beside my tPeak column. If THOSE disagree, then we hunt the variable.

View File

@ -0,0 +1,140 @@
<!doctype html>
<!--
storm_envelope, browser front-end. [Lane C, SPRINT12 — gate 2.4's second harness]
B's site_audit measures the failure envelope through the CLOTH (peak corner
loads, kN). This measures the same envelope through the STORM: what the wind
actually delivers at every dressed anchor, per night — peak local speed, when,
dynamic pressure, dose, and the pressure/hint ranking that predicts failure
ORDER under B's `load > rating × ratingHint` wiring. Two harnesses, one
envelope; if they disagree, find the variable before anyone retunes E's hints.
Dressed positions the way the game gets them — createWorld(await loadSite(name))
then dress() — because a graybox envelope measures a yard that does not ship
(site_audit's own lesson, kept).
tools/storm_envelope/envelope.html ← the whole week
tools/storm_envelope/envelope.html?site=site_02_corner_block&storm=storm_03b_earlybuster
Served from the repo root (server.py).
-->
<meta charset="utf-8">
<title>storm_envelope</title>
<style>
body { background:#111; color:#ddd; font:13px/1.5 ui-monospace,Menlo,monospace; margin:0; padding:20px; }
h1 { font-size:15px; color:#fff; margin:0 0 2px; }
h2 { font-size:13px; color:#9cf; margin:18px 0 2px; }
.sub { color:#888; margin-bottom:10px; white-space:pre-wrap; }
table { border-collapse:collapse; margin:6px 0; }
th { text-align:right; color:#789; font-weight:normal; padding:1px 12px 1px 0; }
th:first-child, td:first-child { text-align:left; }
td { padding:1px 12px 1px 0; white-space:nowrap; text-align:right; }
.weak { color:#e96; } .probe { color:#678; font-style:italic; }
</style>
<h1>storm_envelope — the wind side of the failure envelope</h1>
<div class="sub" id="sub">loading…</div>
<div id="out"></div>
<script type="importmap">
{ "imports": {
"three": "../../web/world/vendor/three.module.js",
"three/addons/": "../../web/world/vendor/addons/"
} }
</script>
<script type="module">
import * as THREE from '../../web/world/vendor/three.module.js';
import { createWorld, loadSite } from '../../web/world/js/world.js';
import { NIGHTS } from '../../web/world/js/week.js';
import { stormEnvelope } from './envelope.js';
const q = new URLSearchParams(location.search);
const el = (id) => document.getElementById(id);
const loadJSON = async (path) => (await fetch(path)).json();
// One dressed world per site, cached — the yard doesn't change between storms.
const siteCache = new Map();
async function dressedSite(name) {
if (siteCache.has(name)) return siteCache.get(name);
const site = await loadSite(name);
const calmStub = {
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [],
};
const world = createWorld(new THREE.Scene(), { wind: calmStub, site });
let dressed = false;
if (world.dress) { try { await world.dress(); dressed = true; } catch (e) { /* flagged below */ } }
const anchors = world.anchors.map((a) => ({
id: a.id, type: a.type, ratingHint: a.ratingHint,
pos: { x: a.pos.x, y: a.pos.y, z: a.pos.z },
}));
const out = { site, anchors, dressed, bed: world.gardenBed };
siteCache.set(name, out);
return out;
}
function render(title, siteName, stormName, res, dressed) {
const h = document.createElement('h2');
h.textContent = title;
el('out').appendChild(h);
const sub = document.createElement('div');
sub.className = 'sub';
sub.textContent = `${siteName} × ${stormName} — downdraftOfTotal ${res.downFrac}, ${res.duration}s`
+ (dressed ? '' : ' ⚠ dress() FAILED — graybox positions, not what ships');
el('out').appendChild(sub);
const tbl = document.createElement('table');
const head = tbl.insertRow();
for (const c of ['anchor', 'type', 'hint', 'peak m/s', 'tPeak s', 'peak Pa', 'Pa/hint', 'dose m²/s', 'eff. cara/shackle/rated N']) {
const th = document.createElement('th'); th.textContent = c; head.appendChild(th);
}
for (const r of res.rows) {
const tr = tbl.insertRow();
if (r.probe) tr.className = 'probe';
else if (r.hint < 0.5) tr.className = 'weak';
tr.insertCell().textContent = r.id;
tr.insertCell().textContent = r.type;
tr.insertCell().textContent = r.hint.toFixed(2);
tr.insertCell().textContent = r.peak.toFixed(2);
tr.insertCell().textContent = r.tPeak.toFixed(1);
tr.insertCell().textContent = r.peakPa.toFixed(0);
tr.insertCell().textContent = r.paPerHint.toFixed(0);
tr.insertCell().textContent = r.dose.toFixed(0);
tr.insertCell().textContent = r.probe ? '—' : r.effN.map((n) => n.toFixed(0)).join(' / ');
}
el('out').appendChild(tbl);
}
async function runPair(siteName, stormName, title) {
const { site, anchors, dressed, bed } = await dressedSite(siteName);
const stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.json`);
const venturi = site.wind?.venturi ?? [];
const probes = [{ id: 'bed centre', pos: { x: bed.x, z: bed.z } }];
if (venturi.length) probes.push({ id: 'throat', pos: { x: venturi[0].x, z: venturi[0].z } });
const res = stormEnvelope({ anchors, stormDef, venturi, probes });
render(title, siteName, stormName, res, dressed);
return { site: siteName, storm: stormName, dressed,
rows: res.rows.map(({ id, type, hint, peak, tPeak, peakPa, paPerHint, dose }) =>
({ id, type, hint, peak, tPeak, peakPa, paPerHint, dose })) };
}
async function run() {
const single = q.get('site') || q.get('storm');
const pairs = single
? [{ site: q.get('site') || 'backyard_01', storm: q.get('storm') || 'storm_02_wildnight', title: 'requested pair' }]
: NIGHTS.map((n, i) => ({ site: n.site, storm: n.storm, title: `night ${i + 1}` }));
el('sub').textContent = `${pairs.length} pair(s) · sampling at FIXED_DT over each storm · `
+ `venturi + tree shelters wired the way main.js wires them`;
const results = [];
for (const p of pairs) results.push(await runPair(p.site, p.storm, p.title));
// machine-readable, so the page can be driven headless-in-browser
window.__envelope = results;
document.title = 'storm_envelope — done';
}
run().catch((e) => {
el('sub').textContent = `storm_envelope crashed: ${e.message}\n${e.stack || ''}`;
window.__envelope = { error: e.message };
document.title = 'storm_envelope — ERROR';
});
</script>

View File

@ -0,0 +1,107 @@
/**
* envelope.js the STORM-side failure envelope, per anchor. [Lane C, SPRINT12]
*
* SPRINT12 gate 2.4: B measures the post-ratingHint failure envelope through the
* cloth sim (site_audit: settle, fly the storm, read peak corner loads in N).
* This is the SECOND harness on the same envelope, measured from the other side:
* no cloth, no rig, no tension just what the STORM delivers at each anchor's
* position, through the same wind everyone samples (createWind + the site's
* venturi + the tree shelters, exactly as main.js wires them at site load).
*
* Two harnesses, one number the repo rule. What must agree:
* · ORDERING: anchors ranked by wind-side pressure/hint should rank the same
* way B's peak corner loads do, once quad geometry is accounted for. An
* anchor whose load outranks its wind exposure has a variable to find.
* · TIMING: B's peak corner load should land near the wind-side tPeak (cloth
* lag is under a second; the venturi's alignment window is tens of seconds).
* What will NOT agree, by construction: absolute newtons. Corner load carries
* quad geometry, cloth porosity, tension and rain mass all B's side. This
* harness deliberately knows none of it, which is what makes it independent.
*
* Per anchor, per storm:
* peak highest local horizontal wind speed over the storm, m/s
* (wind.speedAt the anemometer number, same units as every probe
* table in THREADS)
* tPeak when it happened, s
* peakPa peak dynamic pressure, Pa: ½ρ··(1+downFrac²) the (1+) folds
* the downdraft back in, since cloth feels the full vector while
* speedAt deliberately reads horizontal-only
* dose dt over the whole storm, /s exposure × time, so a long
* grind and a single spike stop looking alike
* paPerHint peakPa / ratingHint the wind-side "who blows first" ranking.
* B's wiring is `load > hw.rating * ratingHint`, so dividing the
* delivered pressure by the hint predicts failure ORDER for equal
* hardware and equal geometry. Prediction, not measurement B's
* harness is the one that owns the geometry.
*
* Sampling matches sweep.js's flight exactly: FIXED_DT steps at t = i·dt over
* the storm's duration, same seed path (createWind(def) def.seed), so the two
* harnesses fly the SAME storm, not merely the same JSON.
*/
import { createWind } from '../../web/world/js/weather.js';
import { FIXED_DT, HARDWARE } from '../../web/world/js/contracts.js';
export const ENVELOPE = {
RHO: 1.225, // kg/m³, sea-level air — the constant, named once
DT: FIXED_DT,
};
/**
* Measure the storm at every anchor.
*
* @param {object} o
* @param {Array} o.anchors resolved anchors: { id, type, pos:{x,y,z}, ratingHint? }
* pass the DRESSED world.anchors (browser) or a
* verified dump; graybox positions measure a yard
* that does not ship (site_audit's lesson).
* @param {object} o.stormDef parsed storm JSON
* @param {Array} [o.venturi] the SITE's funnel zones (siteDef.wind.venturi)
* a sweep without it flies an easier yard than ships
* @param {Array} [o.probes] extra { id, pos } points (bed centre, throat)
* measured alongside, hint fixed at 1
* @param {number} [o.dt]
* @returns {{ rows, downFrac, duration }} rows sorted by paPerHint, descending
*/
export function stormEnvelope({ anchors, stormDef, venturi = [], probes = [], dt = FIXED_DT }) {
const wind = createWind(stormDef);
wind.setVenturi(venturi);
// exactly main.js:447 — every tree anchor casts a shadow, defaults and all
wind.setSheltersFromTrees(anchors.filter((a) => a.type === 'tree'));
const downFrac = wind.core.downFrac;
const vecFold = 1 + downFrac * downFrac; // |v|² = h²·(1+d²) when vy = -d·h
const rows = [
...anchors.map((a) => ({
id: a.id, type: a.type, probe: false,
hint: Number.isFinite(a.ratingHint) ? a.ratingHint : 1,
pos: { x: a.pos.x, z: a.pos.z },
peak: 0, tPeak: 0, dose: 0,
})),
...probes.map((p) => ({
id: p.id, type: 'probe', probe: true, hint: 1,
pos: { x: p.pos.x, z: p.pos.z },
peak: 0, tPeak: 0, dose: 0,
})),
];
const duration = stormDef.duration ?? 90;
const n = Math.round(duration / dt);
for (let i = 0; i <= n; i++) {
const t = i * dt;
for (const r of rows) {
const s = wind.speedAt(r.pos, t);
if (s > r.peak) { r.peak = s; r.tPeak = t; }
r.dose += s * s * dt;
}
}
for (const r of rows) {
r.peakPa = 0.5 * ENVELOPE.RHO * r.peak * r.peak * vecFold;
r.paPerHint = r.peakPa / (r.hint > 0 ? r.hint : 1);
// what B's wiring makes each shop tier hold HERE: rating × hint, in N
r.effN = HARDWARE.map((h) => h.rating * r.hint);
}
rows.sort((a, b) => b.paPerHint - a.paPerHint);
return { rows, downFrac, duration };
}

View File

@ -0,0 +1,100 @@
/**
* envelope.selftest.js the second harness proves it measures. [Lane C, SPRINT12]
*
* Same shape as sweep.selftest.js: [name, fn] pairs, run under Lane A's
* selftest.html via js/tests/c.test.js. sweep.selftest exists because site_audit
* flew the corner block with the funnel switched off for two sprints; this file
* exists so the harness built to CHECK that tool can't quietly do the same.
* Every assert here is written to fail if a wiring line is deleted:
* · drop setVenturi the throat anchor's funnelled/unfunnelled peaks
* collapse to equal and the strict < goes red
* · drop setSheltersFromTrees the sheltered anchor stops reading lower
* · reseed per call the determinism byte-compare goes red
* · flip paPerHint to × the weak-anchor-ranks-first assert goes red
*/
import { stormEnvelope } from './envelope.js';
const TESTS = [];
const test = (name, fn) => TESTS.push([name, fn]);
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
/**
* A synthetic storm, flat on purpose: constant 10 m/s, dir 0 (blowing +X), no
* gusts, no wander, no spatial noise so every local effect is the ONLY thing
* moving a number, and the asserts can be strict instead of statistical.
*/
const FLAT = {
name: 'flat_test', seed: 7, duration: 20,
baseCurve: [[0, 10], [20, 10]],
dirCurve: [[0, 0], [20, 0]],
dirWander: { amp: 0, rate: 0 },
spatial: { amp: 0 },
gusts: { firstAt: 999, minGap: 6, maxGap: 12, powBase: 0, powRand: 0, powRamp: 0, downdraftOfTotal: 0.35 },
};
const A = (id, x, z, o = {}) => ({ id, type: o.type ?? 'post', pos: { x, y: 4, z }, ...o });
test('envelope: deterministic — same def, same anchors, same numbers to the bit', () => {
const anchors = [A('a', 0, 0), A('b', 5, 3)];
const def = JSON.parse(JSON.stringify(FLAT));
const r1 = stormEnvelope({ anchors, stormDef: def });
const r2 = stormEnvelope({ anchors, stormDef: JSON.parse(JSON.stringify(FLAT)) });
for (let i = 0; i < r1.rows.length; i++) {
assert(r1.rows[i].peak === r2.rows[i].peak && r1.rows[i].dose === r2.rows[i].dose
&& r1.rows[i].tPeak === r2.rows[i].tPeak,
`run 2 disagrees with run 1 at ${r1.rows[i].id}: ${r1.rows[i].peak} vs ${r2.rows[i].peak}`);
}
});
test('envelope: the venturi is wired — throat anchor reads the funnel, a far one does not', () => {
const anchors = [A('throat', 0, 0), A('far', 100, 0)];
const venturi = [{ x: 0, z: 0, axis: 0, gain: 1.5, radius: 5, sharp: 3 }];
const off = stormEnvelope({ anchors, stormDef: FLAT });
const on = stormEnvelope({ anchors, stormDef: FLAT, venturi });
const g = (res, id) => res.rows.find((r) => r.id === id);
// strict: delete envelope.js's setVenturi call and this collapses to equal
assert(g(on, 'throat').peak > g(off, 'throat').peak,
`funnel did not raise the throat: ${g(on, 'throat').peak} vs ${g(off, 'throat').peak} — setVenturi is not wired`);
assert(Math.abs(g(on, 'throat').peak - 15) < 0.01,
`gain 1.5 on 10 m/s dead-aligned should read 15 at the throat, got ${g(on, 'throat').peak}`);
assert(g(on, 'far').peak === g(off, 'far').peak,
'the funnel reached an anchor 100 m away — radial falloff is broken');
});
test('envelope: tree shelters are wired — a downwind anchor reads the shadow', () => {
// tree at origin, wind blowing +X: 'lee' sits 4 m downwind inside the shadow
const withTree = [A('tree', 0, 0, { type: 'tree' }), A('lee', 4, 0)];
const without = [A('lee', 4, 0)];
const sheltered = stormEnvelope({ anchors: withTree, stormDef: FLAT }).rows.find((r) => r.id === 'lee');
const open = stormEnvelope({ anchors: without, stormDef: FLAT }).rows.find((r) => r.id === 'lee');
// strict: delete envelope.js's setSheltersFromTrees call and these read equal
assert(sheltered.peak < open.peak,
`the tree cast no shadow: lee reads ${sheltered.peak} with it, ${open.peak} without — setSheltersFromTrees is not wired`);
});
test('envelope: paPerHint ranks the weak steel first at equal wind', () => {
// identical position, identical wind — only the hint differs (E's beam 0.22
// vs a clean post at 1.0). The wind-side prediction must rank the beam first.
const anchors = [A('beam', 2, 2, { ratingHint: 0.22 }), A('clean', 2, 2, { ratingHint: 1 })];
const { rows } = stormEnvelope({ anchors, stormDef: FLAT });
assert(rows[0].id === 'beam',
`equal wind, hint 0.22 vs 1.0 — 'beam' must rank first, got ${rows.map((r) => r.id).join(',')}`);
assert(Math.abs(rows[0].paPerHint - rows[1].paPerHint / 0.22) < 1e-9,
'paPerHint is not peakPa/hint — the ranking formula drifted');
assert(Math.abs(rows[0].effN[0] - 1200 * 0.22) < 1e-9,
`effN must be rating×hint: carabiner on the beam should read ${1200 * 0.22} N, got ${rows[0].effN[0]}`);
});
test('envelope: peaks land inside the storm and doses are finite and positive', () => {
const anchors = [A('a', -3, 1), A('b', 6, -2)];
const { rows, duration } = stormEnvelope({ anchors, stormDef: FLAT });
for (const r of rows) {
assert(r.tPeak >= 0 && r.tPeak <= duration, `${r.id} peaked at t=${r.tPeak}, outside 0..${duration}`);
assert(Number.isFinite(r.dose) && r.dose > 0, `${r.id} dose is ${r.dose}`);
// flat 10 m/s for 20 s → dose ≈ 100·20; a wildly-off dose means the loop drifted
assert(Math.abs(r.dose - 2000) < 20, `${r.id} dose ${r.dose.toFixed(0)} — flat 10 m/s × 20 s should be ~2000`);
}
});
export const ENVELOPE_TESTS = TESTS;

100
web/world/dev_skyfx.html Normal file
View File

@ -0,0 +1,100 @@
<!doctype html>
<!--
dev_skyfx — Lane C's storm viewer. [SPRINT12]
The skyfx bench: one storm, a bare ground plane, and the whole sky stack
(dome, rain, hail, lightning, change front, audio) with a scrubbable clock.
Exists so "does the change ANNOUNCE itself" (gate 3.4) can be judged by eye
in ten seconds instead of playing to night 3 — D, this is for you: load the
early buster, watch the southern sky from t=0, and say whether you'd have
known the change was coming without reading a number.
dev_skyfx.html ← storm_03b_earlybuster
dev_skyfx.html?storm=storm_03_southerly&t=12 ← night 2's slower version
keys: space pause · ] +5 s · [ 5 s (forward-clean; rewinds may re-flash)
←/→ turn · click once to unlock audio
The VIEW runs on rAF (it's a viewer); the sim is stepped at FIXED_DT
accumulated, same as the game, so what you see is what ships.
-->
<meta charset="utf-8">
<title>dev_skyfx</title>
<style>
body { margin:0; background:#000; overflow:hidden; }
#hud { position:fixed; top:10px; left:12px; color:#cde; font:12px/1.5 ui-monospace,Menlo,monospace;
text-shadow:0 1px 2px #000; white-space:pre; pointer-events:none; }
</style>
<div id="hud">loading…</div>
<script type="importmap">
{ "imports": { "three": "./vendor/three.module.js",
"three/addons/": "./vendor/addons/" } }
</script>
<script type="module">
import * as THREE from './vendor/three.module.js';
import { FIXED_DT } from './js/contracts.js';
import { loadStorm, createWind } from './js/weather.js';
import { createSkyFx } from './js/skyfx.js';
const q = new URLSearchParams(location.search);
const stormName = q.get('storm') || 'storm_03b_earlybuster';
const def = await loadStorm(stormName);
const wind = createWind(def);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x9fc4e8);
const camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.1, 400);
camera.position.set(0, 1.7, -4);
let yaw = Math.PI; // start looking toward +Z — the south
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
const sun = new THREE.DirectionalLight(0xfff4e0, 2.0);
sun.position.set(30, 50, -20);
scene.add(sun);
const hemi = new THREE.HemisphereLight(0xbfd8ff, 0x3a4a2a, 1.2);
scene.add(hemi);
const ground = new THREE.Mesh(
new THREE.CircleGeometry(120, 48).rotateX(-Math.PI / 2),
new THREE.MeshLambertMaterial({ color: 0x4a5d3a }),
);
scene.add(ground);
// a fence-height slab to the north, so there's a yard-ish thing in frame
const slab = new THREE.Mesh(new THREE.BoxGeometry(14, 3, 1), new THREE.MeshLambertMaterial({ color: 0x8a7f70 }));
slab.position.set(0, 1.5, -11);
scene.add(slab);
const sky = createSkyFx({ scene, camera, wind, sun, hemi });
addEventListener('pointerdown', () => sky.unlockAudio(), { once: false });
let t = Math.max(0, +q.get('t') || 0), paused = false, last = performance.now(), acc = 0;
addEventListener('keydown', (e) => {
if (e.code === 'Space') paused = !paused;
if (e.key === ']') t = Math.min(def.duration, t + 5);
if (e.key === '[') t = Math.max(0, t - 5);
if (e.key === 'ArrowLeft') yaw += 0.15;
if (e.key === 'ArrowRight') yaw -= 0.15;
});
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
const hud = document.getElementById('hud');
function frame(now) {
requestAnimationFrame(frame);
const wall = Math.min(0.1, (now - last) / 1000); last = now;
if (!paused && t < def.duration) {
acc += wall;
while (acc >= FIXED_DT) { acc -= FIXED_DT; t += FIXED_DT; sky.step(FIXED_DT, t, {}); }
}
camera.rotation.set(0, yaw, 0, 'YXZ');
const ch = (def.events || []).find((e) => e.type === 'windchange');
hud.textContent = `${stormName} t=${t.toFixed(1)}s / ${def.duration}s${paused ? ' ⏸' : ''}\n`
+ `wind ${wind.speedAt(camera.position, t).toFixed(1)} m/s rain ${wind.rainAt(t).toFixed(2)} `
+ `front ${sky.changeFront.toFixed(2)}${ch ? ` (change at t=${ch.t})` : ' (no change)'}\n`
+ `space pause · [ ] scrub · ←→ turn · click for audio`;
renderer.render(scene, camera);
}
requestAnimationFrame(frame);
</script>

View File

@ -13,7 +13,7 @@
import * as THREE from '../vendor/three.module.js';
import { rng } from './contracts.js';
import { valueNoise2 , hailBlockFor } from './weather.core.js';
import { valueNoise2 , hailBlockFor, smoothstep } from './weather.core.js';
const lerp = (a, b, k) => a + (b - a) * k;
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
@ -348,6 +348,7 @@ function createAudio(seed = 1) {
let rainGain, rainFilter;
let gustGain, gustFilter;
let hailGain, hailFilter, drumGain, drumFilter;
let frontGain, frontFilter;
let noiseBuf = null;
let creakNext = 0, flogNext = 0;
let started = false;
@ -444,6 +445,14 @@ function createAudio(seed = 1) {
drumGain.connect(master);
loop(noiseBuf, drumGain, drumFilter);
// the change front's distant rumble: very low, very slow — a storm you
// can hear over the fence but not yet feel (SPRINT12 gate 3.4)
frontGain = ctx.createGain(); frontGain.gain.value = 0;
frontFilter = ctx.createBiquadFilter();
frontFilter.type = 'lowpass'; frontFilter.frequency.value = 85;
frontGain.connect(master);
loop(noiseBuf, frontGain, frontFilter);
started = true;
},
@ -496,6 +505,13 @@ function createAudio(seed = 1) {
drumFilter.frequency.setTargetAtTime(110 + (2 - size) * 45, now, 0.2);
},
/** @param {number} level 0..1 change-front progress — the approaching rumble. */
setFront(level) {
if (!started) return;
// slow time-constant on purpose: a front swells, it doesn't tick
frontGain.gain.setTargetAtTime(level * 0.2, ctx.currentTime, 0.5);
},
/** Telegraph cue: you hear it coming before you feel it. */
whoosh(power, eta) {
if (!started) return;
@ -604,6 +620,66 @@ export function createSkyFx(o = {}) {
dome.renderOrder = -1;
if (scene) scene.add(dome);
// ------------------------------------------------------ the change front
// SPRINT12 gate 3.4 — "the change announces itself." Every windchange event
// gets an IN-WORLD tell: a dark front wall on the horizon, standing in the
// quarter the new wind will come FROM, rising from a smudge to a wall across
// the FRONT_LEAD seconds before the change and clearing overhead after it.
// A real southerly buster looks exactly like this — the shelf cloud arrives
// before the wind does — so the tell is weather, not UI.
//
// Why this telegraphs without spoiling: it's a continuous growth with no
// discrete pop, so a player watching the YARD learns "that wall means the
// swing" on night 2 (storm_03, change at 30 s, wall from ~18 s) — and on
// night 3 the SAME wall is already standing at t≈6, which is the corner
// block's whole thesis ("looks like night 2 and isn't") made visible. The
// exact moment stays the storm's secret; the direction and the approach do
// not, because in the real sky they never are.
//
// Deterministic off (dt, t) like everything here: progress is a pure
// function of t, and the wall's azimuth comes from dirAt() sampled at a
// fixed post-change instant — def + seed in, same wall out, every run.
const FRONT_LEAD = 12; // s before the change the wall starts rising
const FRONT_FADE = 8; // s after the swing completes for it to clear
const FRONT_ARC = 2.2; // radians of horizon the wall spans (~126°)
const FRONT_MAX_OP = 0.85;
const frontEvents = (def.events || [])
.filter((e) => e.type === 'windchange' && Number.isFinite(e.t))
.map((e) => ({ t0: e.t, over: e.over ?? 6, az: null }));
// Band from ~34° above the horizon down to it, centred (in local space) on
// azimuth π: SphereGeometry's (x,z) = (-cosφ, sinφ)·sinθ, so φ=0 sits at
// atan2(z,x) = π. rotation.y = π A then carries the centre to azimuth A.
const frontGeo = new THREE.SphereGeometry(170, 24, 8, -FRONT_ARC / 2, FRONT_ARC, Math.PI * 0.30, Math.PI * 0.20);
{
// Soft edges via vertex alpha, or the band reads as a floating rectangle
// (checked by eye on dev_skyfx.html — the hard phi/theta cut was exactly
// that). Full at the horizon and the arc's centre; fading to nothing at
// the arc ends and across the top, so it sits ON the horizon like a bank
// of weather instead of hanging in the sky like a screen.
// Sphere uv: x runs 0..1 across the arc, y is 1 at the band top, 0 at the
// horizon edge.
const uv = frontGeo.attributes.uv;
const rgba = new Float32Array(uv.count * 4);
for (let i = 0; i < uv.count; i++) {
const across = Math.pow(Math.sin(Math.PI * uv.getX(i)), 0.75);
const up = 1 - smoothstep(0.45, 1, uv.getY(i));
rgba[i * 4] = 1; rgba[i * 4 + 1] = 1; rgba[i * 4 + 2] = 1;
rgba[i * 4 + 3] = across * up;
}
frontGeo.setAttribute('color', new THREE.BufferAttribute(rgba, 4));
}
const frontMesh = new THREE.Mesh(
frontGeo,
new THREE.MeshBasicMaterial({
color: 0x0c1016, side: THREE.BackSide, transparent: true, vertexColors: true,
opacity: 0, depthWrite: false, fog: false,
}),
);
frontMesh.renderOrder = -1; // with the dome; nearer radius wins the overlay
frontMesh.visible = false;
if (scene) scene.add(frontMesh);
let frontLevel = 0, frontRise = 0, frontAz = null;
// Remember what world.js handed us, so dispose() puts it back exactly.
// Fog is captured BY VALUE, not by reference: step() mutates that very object
// in place, so `scene.fog = original.fog` restores the object we just spent a
@ -636,10 +712,15 @@ export function createSkyFx(o = {}) {
const w = new THREE.Vector3();
const fx = {
rain, audio, dome, shadow, hailShadow,
rain, audio, dome, shadow, hailShadow, front: frontMesh,
get flash() { return flash; },
/** 0..1 hail intensity right now — for the HUD ("HAIL" banner) and asserts. */
get hailAmount() { return hailAmt; },
/** 0..1 — how far risen the change-front wall is (gate 3.4). Pure in t. */
get changeFront() { return frontLevel; },
/** Azimuth (radians, XZ) the front stands in the quarter the new wind
* comes FROM or null while no front is up. For D's judging and asserts. */
get changeFrontAz() { return frontAz; },
/**
* 0..1 of a ground rect the sail is keeping dry, right now.
@ -809,6 +890,23 @@ export function createSkyFx(o = {}) {
hailShadow.update(world.sail, hailDir.x / hl, hailDir.y / hl, hailDir.z / hl);
}
// --- the change front: progress is pure in t; the view applies it below.
// Computed above the render gate so a headless HUD (or a test) can read
// changeFront the same way it reads hailAmount.
frontLevel = 0; frontRise = 0; frontAz = null;
for (const ev of frontEvents) {
const rise = smoothstep(ev.t0 - FRONT_LEAD, ev.t0, t);
const lvl = rise * (1 - smoothstep(ev.t0 + ev.over, ev.t0 + ev.over + FRONT_FADE, t));
if (lvl > frontLevel) {
// the settled post-change heading, sampled at a FIXED instant so the
// wall doesn't wander with the gusts; +π = the quarter it comes FROM
if (ev.az == null && typeof wind.dirAt === 'function') {
ev.az = wind.dirAt(ev.t0 + ev.over + 4) + Math.PI;
}
frontLevel = lvl; frontRise = rise; frontAz = ev.az;
}
}
// Past here is the VIEW and the NOISE — drops to place, a dome to tint, a
// gale to hear. All of it needs somewhere to stand. A headless harness has
// the numbers it came for and can stop here.
@ -848,6 +946,19 @@ export function createSkyFx(o = {}) {
domeTex.offset.x = (domeTex.offset.x + scroll * dt * (0.4 + speed * 0.05)) % 1;
domeTex.offset.y = (domeTex.offset.y + scroll * dt * 0.12) % 1;
// --- the front wall (progress computed above the render gate) ---
// Grows upward as it rises (scale.y keys the RISE, so it keeps its full
// height while fading through the overhead pass), darkens as it comes.
// Deliberately a silhouette: no flash tint — lightning brightens the sky
// BEHIND it, which is what makes a shelf cloud read as a wall.
frontMesh.visible = frontLevel > 0.002;
if (frontMesh.visible) {
frontMesh.position.copy(camPos);
if (frontAz != null) frontMesh.rotation.y = Math.PI - frontAz;
frontMesh.scale.y = 0.3 + 0.7 * frontRise;
frontMesh.material.opacity = frontLevel * FRONT_MAX_OP;
}
// --- rain + hail drops (the grids they read were built above) ---
rain.step(dt, camPos, w, intensity, shadow);
hail.step(dt, camPos, hailDir, hailAmt, stone, hailShadow);
@ -858,6 +969,9 @@ export function createSkyFx(o = {}) {
// --- audio ---
audio.setLevels(speed, intensity);
audio.setHail(hailAmt, onCloth, stone);
// the front carries its own distant rumble — you HEAR the change coming
// the way you see it, and both are the same pure function of t
audio.setFront(frontLevel);
const tg = wind.gustTelegraph(t);
if (tg && tg !== lastTelegraph) {
// fires once per gust, right as the telegraph opens
@ -886,6 +1000,7 @@ export function createSkyFx(o = {}) {
scene.remove(rain.mesh);
scene.remove(hail.mesh);
scene.remove(dome);
scene.remove(frontMesh);
scene.background = original.background;
if (ownsFog) {
scene.fog = null; // we brought it; we take it
@ -902,6 +1017,8 @@ export function createSkyFx(o = {}) {
hail.dispose();
dome.geometry.dispose();
dome.material.dispose();
frontMesh.geometry.dispose();
frontMesh.material.dispose();
domeTex.dispose();
audio.dispose();
},

View File

@ -21,6 +21,7 @@ import { createDebris } from '../debris.js';
import { createSkyFx, RainShadow } from '../skyfx.js';
import { SailRig, HARDWARE } from '../sail.js';
import { weatherCases } from './weather.selftest.js';
import { ENVELOPE_TESTS } from '../../../../tools/storm_envelope/envelope.selftest.js';
// Keep in step with data/storms/. The node runner globs the directory, so this
// list going stale shows up here first — as it did when storm_03 landed and the
@ -41,6 +42,11 @@ export default async function run(t) {
const { cases } = weatherCases(storms);
for (const c of cases) t.test(c.name, c.fn);
// --- SPRINT12 gate 2.4: the storm-side envelope harness audits itself ---
// Same pattern as sweep.selftest in b.test.js: the tool that second-guesses
// B's audit must not be the thing that drifts. See envelope.selftest.js.
for (const [name, fn] of ENVELOPE_TESTS) t.test(name, fn);
// --- the bits that need the THREE adapter, not just the core ---
t.test('weather.js satisfies the wind contract', () => {
@ -271,6 +277,92 @@ export default async function run(t) {
assert(gentle.flashes === 0, `the gentle storm flashed ${gentle.flashes} times — it has no lightning at all`);
});
// --- SPRINT12 gate 3.4: the change announces itself ---
// The corner block's thesis is "looks like night 2 and isn't" — storm_03b's
// change lands at 18 s, not 30. The tell is a dark front wall standing in the
// quarter the new wind comes FROM, rising across the 12 s before the change
// and clearing after the swing. These asserts pin: the window (nothing before
// the lead opens, gone after the fade), the rise (monotonic, reaches ~full),
// the DIRECTION (the wall stands in the southern sky — +Z, the open fence
// side — and the mesh really is rotated there), and that a changeless storm
// never raises it. D judges whether it READS; this pins that it exists,
// where it stands, and when.
t.test('gate 3.4: the front wall rises before the buster, stands in the south, clears after', () => {
const scene = new THREE.Scene();
const wind = createWind(storms.storm_03b_earlybuster);
const sky = createSkyFx({ scene, camera: new THREE.PerspectiveCamera(), wind });
const ev = storms.storm_03b_earlybuster.events.find((e) => e.type === 'windchange');
const t0 = ev.t, over = ev.over ?? 6; // 18, 6
let prev = 0, peak = 0, azAtChange = null, rotAtChange = null, opAtChange = 0, visAtChange = false;
fixedLoop(40, FIXED_DT, (dt, time) => {
sky.step(dt, time, {});
const f = sky.changeFront;
assert(Number.isFinite(f) && f >= 0 && f <= 1, `front out of range at t=${time.toFixed(2)}: ${f}`);
if (time < t0 - 12.01) assert(f === 0, `front up at t=${time.toFixed(2)} — before its lead window opens`);
if (time > t0 - 12 && time <= t0) {
assert(f >= prev - 1e-9, `front FELL while rising: ${prev.toFixed(3)}${f.toFixed(3)} at t=${time.toFixed(2)}`);
prev = f;
if (f > peak) peak = f;
}
if (Math.abs(time - t0) < FIXED_DT) {
azAtChange = sky.changeFrontAz;
rotAtChange = sky.front.rotation.y;
opAtChange = sky.front.material.opacity;
visAtChange = sky.front.visible;
}
if (time > t0 + over + 8.1) assert(f === 0, `front still up at t=${time.toFixed(2)} — the swing has long cleared`);
});
assert(peak > 0.98, `front only reached ${peak.toFixed(3)} by the change — it should stand near full`);
assert(visAtChange && opAtChange > 0.7, `wall not visible at the change (visible=${visAtChange}, opacity=${opAtChange})`);
// direction: the wall stands where the new wind comes FROM. dirAt is the
// heading the wind blows TOWARD, so from = settled post-change dir + π.
const expected = wind.dirAt(t0 + over + 4) + Math.PI;
assert(Math.abs(azAtChange - expected) < 1e-9,
`front azimuth ${azAtChange} is not the settled post-change upwind quarter ${expected}`);
// and that quarter is the SOUTH: the buster blows toward -Z (the house),
// so it comes from +Z, the open fence side. sin(az) is the from-vector's z.
assert(Math.sin(azAtChange) > 0.3,
`the "southerly" front stands at azimuth ${azAtChange.toFixed(2)} — from-vector z=${Math.sin(azAtChange).toFixed(2)}, not the southern sky`);
// the mesh is really rotated there (local band centre sits at azimuth π,
// so rotation.y = π az carries it to az; see skyfx geometry note)
assert(Math.abs(rotAtChange - (Math.PI - azAtChange)) < 1e-9,
`wall rotation ${rotAtChange} does not place the band at azimuth ${azAtChange}`);
sky.dispose();
});
t.test('gate 3.4: night 2 teaches the same tell, a changeless storm never shows it, and it is pure in t', () => {
// storm_03 (night 2): same wall, later — up but not full at t=20 (change 30)
const mk = (name) => createSkyFx({
scene: new THREE.Scene(), camera: new THREE.PerspectiveCamera(),
wind: createWind(storms[name]),
});
const a = mk('storm_03_southerly'), b = mk('storm_03_southerly');
let mid = 0;
fixedLoop(21, FIXED_DT, (dt, time) => {
a.step(dt, time, {}); b.step(dt, time, {});
// determinism: two instances at the same t agree to the bit — no hidden
// clock, no per-instance random draw in the tell
assert(a.changeFront === b.changeFront,
`two skyfx disagree on the front at t=${time.toFixed(2)}: ${a.changeFront} vs ${b.changeFront}`);
mid = a.changeFront;
});
assert(mid > 0.05 && mid < 0.95,
`night 2's wall should be RISING at t=21 (change at 30), reads ${mid.toFixed(3)}`);
a.dispose(); b.dispose();
// the gentle day has no windchange: no wall, ever
const calm = mk('storm_01_gentle');
fixedLoop(storms.storm_01_gentle.duration, FIXED_DT, (dt, time) => {
calm.step(dt, time, {});
assert(calm.changeFront === 0 && !calm.front.visible,
`a changeless storm raised the front at t=${time.toFixed(2)}`);
});
calm.dispose();
});
// --- SPRINT5 decision 13: hail makes the garden score respond to the rig ---
// The whole reason hail exists: a perfect rig scored 54% vs 48% for no rig,
// because honest rain walks under a sail. Steep hail doesn't — a sail over the