Merge remote-tracking branch 'origin/lane/c'
# Conflicts: # THREADS.md
This commit is contained in:
commit
3df0d0c5d0
122
THREADS.md
122
THREADS.md
@ -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.0−0.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.
|
||||
|
||||
140
tools/storm_envelope/envelope.html
Normal file
140
tools/storm_envelope/envelope.html
Normal 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>
|
||||
107
tools/storm_envelope/envelope.js
Normal file
107
tools/storm_envelope/envelope.js
Normal 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: ½ρ·v²·(1+downFrac²) — the (1+d²) folds
|
||||
* the downdraft back in, since cloth feels the full vector while
|
||||
* speedAt deliberately reads horizontal-only
|
||||
* dose ∫ v² dt over the whole storm, m²/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 };
|
||||
}
|
||||
100
tools/storm_envelope/envelope.selftest.js
Normal file
100
tools/storm_envelope/envelope.selftest.js
Normal 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
100
web/world/dev_skyfx.html
Normal 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>
|
||||
@ -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();
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user