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

# Conflicts:
#	THREADS.md
This commit is contained in:
m3ultra 2026-07-17 01:58:30 +10:00
commit 44a4a3e6ec
8 changed files with 243 additions and 367 deletions

View File

@ -994,3 +994,59 @@ Format: `[lane letter] YYYY-MM-DD — note`
3.1 m/s, before anything has happened. That looks like B's "cascade at t=0.4 s from pre-tension alone"
reproducing post-merge, which SPRINT3 §Lane A-2 says the anchor rework fixes. Flagging only so you know
it survives the merge — no action wanted from me.
[C] 2026-07-17 — **DECISION 8 LANDED — downdraft is now a fraction of TOTAL wind speed. Semantic done;
final VALUE is a joint step still blocked on B.** Selftest **173/0/0** on rebased main.
`weather.core.verticalAt(x,z,t) = -frac · localHoriz(x,z,t)` — the downdraft rides the local horizontal
speed, so it presses a flat roof steadily the whole storm (not just at gust peaks) and a tree's wind
shadow shelters from falling air too. `speedAt()` stays horizontal. Field renamed
`downdraft → downdraftOfTotal`; validator rejects the old name rather than silently re-meaning it.
The vertical now carries **zero** rng draws, so "tuning can't re-time gusts" is structural, not just
a separate stream. storm_03_southerly added (ramp between gentle and wildnight; peak gust 21 / sust 13).
weather_demo.html retired — the game is the bench.
[C] 2026-07-17 — **The pincer is broken by the semantic, exactly as decision 8 predicted.** I measured
both gates myself with B's SailRig (8-heading flat-vs-16.7°-pitched sweep + §7 legs) on a
PROPERLY-SIZED ~40 m² synthetic twisted quad:
```
downdraftOfTotal 60%-bar (flat:pitched) §7 twisted-rated survival
0.22 45% fail 4/4 (2928 N)
0.40 63% PASS 4/4 (4617 N)
0.45 69% of-max / 60% worst-head 4/4 (5142 N, 21% margin) ← TARGET
0.60 78% PASS 3/4 DIES (6567 > 6500)
```
So **0.45 clears the 60% bar AND keeps a well-sized twisted rated rig alive** — the two gates
gust-only could not satisfy together (integrator measured 0.58 → 48% and still broke twisted). Decision
8 works. (My harness reproduces B's scale: fraction-of-total 0.15 → 37%, matching B's gust-only 0.3 →
34% at the same ~-4.5 m/s peak. Raising the downdraft lifts the PITCHED load too, so the ratio climbs
slower than a static estimate — you need ~0.4, not B's ~7.3 m/s single-point guess. That's a real note
for your assert, B.)
[C] 2026-07-17 — ⚠️ **B — A's anchor rework alone does NOT unblock 0.45; your §7 rig is still oversized.
Re-point it and we finish gate 2.** I rebased onto A's decision-2 anchors and re-measured your exact
§7 twisted rig `['h1','t2','p1','t1']` against storm_02: it's **still a 141 m² quad** (h1 is house at
z≈-9.9, t2 at x≈8, p1 at x≈-4.9, t1 at x≈-9 — those four corners span the whole yard), and it dies at
0.45 (3/4, peak 6410 N). A ADDED small quads (`p3` near (0,7.6), branch anchors `t1b/t2b`, posts moved
in to p1≈(-4.9,5.9)/p2≈(4.3,6.5)) — but `h1,t2,p1,t1` isn't one of them. **Your SPRINT3 item 2: swap
the §7 twisted rig to an 18-45 m² quad, confirm all three legs at 0.45, then bump storm_02
`downdraftOfTotal` 0.12 → 0.45 (one number).** ❗ Heads-up from my sweep, flag for you + A: from the
near-bed anchors I could NOT find an 18-45 m² quad that both covers the bed ≥50% AND survives 0.45 with
a rated+shackle mix — the bed sits between the house (z≈-9.9) and the posts (z≈+6), so covering it
tends to want a biggish quad. A's a.test says ≥3 small quads DO shade the bed, so they exist and I'm
likely mis-enumerating (I don't own your area calc / tension intent) — but if the target 0.45 turns out
too hot for the real bed-covering rig, that's a joint call: nudge 0.45 down a touch, or accept the §7
survivable rig is a bigger quad than 45 m². Your rig, your call; I'll match the wind to whatever lands.
[C] 2026-07-17 — **Held storm_02 at `downdraftOfTotal: 0.12` so main stays GREEN until B re-points.** On
the current yard B's oversized §7 rig starts losing a corner near 0.15 in the exact solver, so 0.45
would red the §7 assert (I saw it: 1 fail before I held). 0.12 fraction-of-total ≈ the old gust-only
0.3 in peak downdraft (-4.2 vs -4.5 m/s), so storm_02's feel barely moves, with ~23% load margin on
that rig. storm_01 → 0.25, storm_03 → 0.35 (neither is in a §7 assert, so those are their real values).
Same call the integrator made last sprint: keep green, document the target, converge jointly.
[C] 2026-07-17 — **A — decision 7 (garden HP on rain shadow): `skyfx.rainShadowOver(bed)` is ready, no
new work on my side.** Returns 0..1 of the bed the cloth is keeping dry, this frame; 0 when no sail or
no rain. It is NOT `rig.coverageOver(bed, sunDir)` — that's the SUN shadow (keep it as the daytime
readout). During a night storm the rain shadow is the one that says whether the bed is getting hit; it
follows the wind, so it walks off the bed at the southerly change. Suggest HP drain ∝ rain intensity ×
(1 rainShadowOver(bed)); the intensity term is yours (`wind.rainAt(t)`). Ping if you want a combined
helper and I'll add it.

View File

@ -14,7 +14,7 @@
"powBase": 2,
"powRand": 3,
"powRamp": 2,
"downdraft": 0.18
"downdraftOfTotal": 0.25
},
"dirCurve": [[0, 0.9], [45, 1.0], [90, 1.15]],

View File

@ -11,7 +11,7 @@
"baseCurve": [[0, 7.0], [15, 11.0], [40, 17.0], [60, 20.0], [78, 19.0], [90, 16.0]],
"_gusts_comment": "downdraft = fraction of gust power that blows DOWN, per gust (each gust varies 0.6-1.4x this). A gust front is descending air, not just faster air; without it a flat horizontal sail sheds everything and ignoring the storm is the winning move. 0.3 here because a wild night should punish a flat rig hard.",
"_gusts_comment": "downdraftOfTotal = fraction of TOTAL wind speed that blows DOWN (SPRINT3 decision 8), present whenever it's windy, not only in gusts. TARGET is 0.45 — measured to clear B's 60% flat-horizontal:flat-pitched bar (69% of-max / 60% worst-heading) AND let a properly-sized twisted rated rig survive with ~21% margin, which gust-only semantics provably could NOT do together (0.58 gave 48% and still broke the twisted rig). HELD at 0.12 for now: on the current oversized yard the ONLY twisted quad ('h1,t2,p1,t1', ~190 m²) starts losing a corner around 0.15 in the exact solver, so 0.45 would turn B's §7 assert red. 0.12 fraction-of-total ~= the old gust-only 0.3 in peak downdraft (-4.2 vs -4.5 m/s), so storm_02 barely changes, and leaves ~23% load margin on that twisted rig. Bump to 0.45 is a ONE-NUMBER joint step once A lands decision-2 anchors (18-45 m2 quads) and B re-points §7. See THREADS [C] 2026-07-17.",
"gusts": {
"firstAt": 3,
@ -20,7 +20,7 @@
"powBase": 3,
"powRand": 5,
"powRamp": 7,
"downdraft": 0.3
"downdraftOfTotal": 0.12
},
"dirCurve": [[0, 0.85], [50, 0.95], [55, 0.6], [59, -1.25], [70, -1.45], [90, -1.35]],

View File

@ -0,0 +1,38 @@
{
"name": "Southerly Buster",
"blurb": "Hot still afternoon, then a southerly change rolls through around the half-hour. Gusty but not vicious — a fair test of a first real rig.",
"rating": 2,
"seed": 30717,
"duration": 90,
"_ramp_comment": "The campaign's middle rung: sits between storm_01_gentle (peak ~11 m/s) and storm_02_wildnight (peak ~32). Sustained builds to ~13 (47 km/h), worst gust ~21 (76 km/h, BOM 'strong'). The change is real but slower and smaller than the wild night's, so a decent flat-ish rig can get away with it and a good twisted one is never in doubt — the storm that teaches the swing before the one that punishes it.",
"baseCurve": [[0, 4.0], [12, 6.0], [30, 9.0], [45, 13.0], [65, 12.5], [90, 10.0]],
"gusts": {
"firstAt": 5,
"minGap": 6,
"maxGap": 12,
"powBase": 3,
"powRand": 4,
"powRamp": 4,
"downdraftOfTotal": 0.35
},
"_dir_comment": "Starts blowing toward the SE (a warm NW'er), swings to blow toward the NNE (a moderate southerly) across 30-36 s. ~90 deg, gentler slew than storm_02's buster.",
"dirCurve": [[0, 0.8], [28, 0.9], [30, 0.55], [36, -0.7], [55, -0.85], [90, -0.75]],
"dirWander": { "amp": 0.3, "rate": 0.11 },
"spatial": { "amp": 0.18, "scale": 11, "advect": 0.5 },
"events": [
{ "t": 30, "type": "windchange", "telegraph": 6, "over": 6, "text": "here comes the change" },
{ "t": 48, "type": "debris", "model": "BlackTub_v2", "lateral": 2.5, "mass": 5, "text": "a tub skitters across the lawn" },
{ "t": 62, "type": "lightning", "power": 0.4 }
],
"rain": { "curve": [[0, 0], [28, 0.05], [34, 0.4], [55, 0.55], [80, 0.3], [90, 0.15]] },
"sky": { "darkness": 0.5, "cloudScroll": 0.05 }
}

View File

@ -54,11 +54,13 @@ export default async function run(t) {
assert(a.x === b.x && a.y === b.y && a.z === b.z, 'out param changed the result');
});
// This assert used to read `a.y === 0` — "wind should be horizontal". SPRINT2
// decision 3 made that false on purpose: gusts now descend, which is what makes
// a flat sail pay. Keeping the useful half — y is downward-or-zero, never up,
// and never garbage — so player shove and rain angle can still trust the sign.
t.test('vertical wind is downward-only, and only during gusts', () => {
// This assert once read `a.y === 0` — "wind is horizontal". SPRINT2 decision 3
// made that false on purpose (gusts descend, so a flat sail pays); SPRINT3
// decision 8 made the descent a fraction of TOTAL wind, so it's present
// whenever it's windy, not only in gusts. Keeping the invariants that consumers
// (player shove, rain angle, HUD) rely on: y is down-or-zero, never up, never
// garbage, and speedAt() is horizontal-only.
t.test('vertical wind is downward-only and rides the wind', () => {
const wind = createWind(storms.storm_02_wildnight);
const pos = new THREE.Vector3(0, 1.7, 0);
const v = new THREE.Vector3();
@ -66,12 +68,13 @@ export default async function run(t) {
fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
wind.sample(pos, time, v);
assert(v.y <= 1e-9, `wind blew UP (y=${v.y.toFixed(3)}) at t=${time.toFixed(2)}`);
if (v.y < -1) sawDown = true;
assert(Number.isFinite(v.y), `vertical wind is not finite at t=${time.toFixed(2)}`);
if (v.y < -2) sawDown = true;
});
assert(sawDown, 'never saw a downdraft worth the name in a whole wild night');
// and the wind meter must stay horizontal — a falling gust shouldn't spike the HUD
const calm = wind.speedAt(pos, 0.5);
assert(Math.abs(calm - Math.hypot(wind.sample(pos, 0.5).x, wind.sample(pos, 0.5).z)) < 1e-9,
// the wind meter must stay horizontal — falling air shouldn't spike the HUD
const s = wind.sample(pos, 0.5);
assert(Math.abs(wind.speedAt(pos, 0.5) - Math.hypot(s.x, s.z)) < 1e-9,
'speedAt() is not the horizontal magnitude of sample()');
});

View File

@ -280,44 +280,75 @@ export function weatherCases(storms) {
assert(Math.abs(luvS - luvB) < 1e-9, 'upwind side is being sheltered — shadow is pointing the wrong way');
});
// ---- 9. vertical gust structure (SPRINT2 decision 3) ----
// Cloth pressure goes with dot(wind, normal). A flat horizontal panel's normal
// points at the sky, so in a perfectly horizontal wind that dot is ~0 and the
// cheapest winning rig is "lie it flat and ignore the storm" — the opposite of
// the game. Gust fronts are descending air, and descending air hits a flat
// panel square on. Lane B owns the cloth-side assert; these are the wind side.
test('gusts carry a downdraft, and still air does not', () => {
const f = createWindField(storms.storm_02_wildnight);
let peakDown = 0, betweenMax = 0;
for (let t = 0; t <= f.duration; t += DT) {
const v = f.gustVertical(t);
assert(v <= 1e-12, `vertical wind went UP (${v.toFixed(2)}) at t=${t.toFixed(2)} — downdraft only`);
const live = f.gusts.some((g) => t > g.t0 && t < g.endAt);
if (live) peakDown = Math.min(peakDown, v);
else betweenMax = Math.max(betweenMax, Math.abs(v));
}
metrics['storm_02.peakDowndraft'] = +peakDown.toFixed(2);
assert(betweenMax === 0, `air is falling between gusts (${betweenMax}) — downdraft must be a gust feature`);
assert(peakDown < -2, `peak downdraft only ${peakDown.toFixed(2)} m/s — a flat sail would still shrug it off`);
});
test('downdraft tracks its own gust and its JSON fraction', () => {
// ---- 9. vertical structure (SPRINT3 decision 8: fraction of TOTAL) ----
// Cloth pressure goes with dot(wind, normal). A flat panel's normal points at
// the sky, so in a purely horizontal wind that dot is ~0 and "lie it flat and
// ignore the storm" wins — the opposite of the game. The downdraft is now a
// fraction of the LOCAL total wind speed (was: gust power), so a flat roof is
// pressed whenever it's windy, not only at gust peaks. Lane B owns the
// cloth-side no-free-lunch assert; these are the wind side.
test('downdraft is a fixed fraction of the local horizontal speed', () => {
const def = storms.storm_02_wildnight;
const f = createWindField(def);
const frac = def.gusts.downdraft;
for (const g of f.gusts) {
assert(g.down >= frac * 0.6 - 1e-9 && g.down <= frac * 1.4 + 1e-9,
`gust at t=${g.t0.toFixed(1)} has down=${g.down.toFixed(3)}, outside 0.61.4× of ${frac}`);
// minGap >= GUST.TOTAL means gusts never overlap, so at hold it's exactly this gust
const atHold = f.gustVertical(g.t0 + 3);
assert(Math.abs(atHold - -(g.pow * g.down)) < 1e-9,
`at gust hold vertical is ${atHold.toFixed(3)}, want ${(-g.pow * g.down).toFixed(3)}`);
const frac = def.gusts.downdraftOfTotal;
assert(Math.abs(f.downFrac - frac) < 1e-12, `field downFrac ${f.downFrac} != json ${frac}`);
const out = { x: 0, y: 0, z: 0 };
let peakDown = 0;
for (const p of PROBES) {
for (let t = 0; t <= f.duration; t += DT) {
f.vecAt(p.x, p.z, t, out);
const horiz = Math.hypot(out.x, out.z);
assert(out.y <= 1e-9, `vertical went UP (${out.y.toFixed(3)}) at t=${t.toFixed(2)} — downdraft only`);
// out.y must be exactly -frac * horizontal, everywhere, always
assert(Math.abs(out.y + frac * horiz) < 1e-9,
`downdraft ${out.y.toFixed(3)} != -${frac}×${horiz.toFixed(3)} at t=${t.toFixed(2)}`);
peakDown = Math.min(peakDown, out.y);
}
}
metrics['storm_02.peakDowndraft'] = +peakDown.toFixed(2);
// Held at downdraftOfTotal 0.15 → ~-4.9 m/s; target 0.45 → ~-14.7. Floor at
// -3 so this proves "a real downdraft exists" across the whole transition
// range without false-failing when the joint step bumps the value.
assert(peakDown < -3, `peak downdraft only ${peakDown.toFixed(2)} m/s — a flat sail would still shrug it off`);
});
test('downdraft rides the wind: present when windy, gone when calm', () => {
// The point of fraction-of-total: it's not a gust-only feature any more. Some
// sustained-wind moment between gusts must still carry a real downdraft, and a
// hypothetically dead-calm field must carry none.
const f = createWindField(storms.storm_02_wildnight);
let sustainedDown = 0;
for (let t = 0; t <= f.duration; t += DT) {
const inGust = f.gusts.some((g) => t > g.t0 && t < g.endAt);
if (!inGust) sustainedDown = Math.min(sustainedDown, f.verticalAt(0, 0, t));
}
assert(sustainedDown < -2,
`between gusts the downdraft peaks at only ${sustainedDown.toFixed(2)} — total-speed semantics should keep it pressing`);
// dead calm → no downdraft (guards against a constant offset sneaking in)
const calm = createWindField({
duration: 10, baseCurve: [[0, 0], [10, 0]], dirCurve: [[0, 0], [10, 0]],
gusts: { minGap: 6, maxGap: 6, powBase: 0, powRand: 0, powRamp: 0, downdraftOfTotal: 0.5 },
});
for (let t = 0; t <= 10; t += 0.1) {
assert(Math.abs(calm.verticalAt(0, 0, t)) < 1e-9, `air is falling in a dead calm at t=${t.toFixed(1)}`);
}
});
test('downdraft 0 gives a perfectly horizontal wind', () => {
test('downdraft follows the tree shadow (shelters from falling air too)', () => {
const def = storms.storm_02_wildnight;
const f = createWindField(def).setShelters([{ x: 0, z: 0, radius: 3, strength: 0.5, length: 14 }]);
const t = 30;
const d = f.dirAt(t);
const dx = Math.cos(d), dz = Math.sin(d);
const leeDown = Math.abs(f.verticalAt(dx * 5, dz * 5, t)); // downwind of the tree
const openDown = Math.abs(f.verticalAt(-dx * 5, -dz * 5, t)); // upwind, unsheltered
assert(leeDown < openDown * 0.85, `lee downdraft ${leeDown.toFixed(2)} not sheltered vs open ${openDown.toFixed(2)}`);
});
test('downdraftOfTotal 0 gives a perfectly horizontal wind', () => {
const def = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
def.gusts.downdraft = 0;
def.gusts.downdraftOfTotal = 0;
const f = createWindField(def);
const out = { x: 0, y: 0, z: 0 };
for (let t = 0; t <= f.duration; t += 0.05) {
@ -327,15 +358,16 @@ export function weatherCases(storms) {
});
test('downdraft does not re-time the storm', () => {
// The vertical draws from its own RNG stream precisely so that adding or
// tuning it can't shift gust times or powers. Lane A hand-drove storm_02 and
// watched the carabiner blow at t=45.4 and p2 cascade at t=56; a downdraft
// tweak silently moving those would be a nasty way to lose an afternoon.
// The vertical carries NO rng draws of its own now (it's a pure function of
// local speed), so tuning it cannot possibly shift gust times or powers. Lane
// A hand-drove storm_02 and watched the carabiner blow at t=45.4 and p2
// cascade at t=56; a downdraft tweak silently moving those would be a nasty
// way to lose an afternoon. Determinism is now structural, but still asserted.
const base = storms.storm_02_wildnight;
const a = createWindField(base);
for (const dd of [0, 0.1, 0.25, 0.5, 1]) {
for (const dd of [0, 0.1, 0.22, 0.5, 1]) {
const d = JSON.parse(JSON.stringify(base));
d.gusts.downdraft = dd;
d.gusts.downdraftOfTotal = dd;
const b = createWindField(d);
assert(a.gusts.length === b.gusts.length, `downdraft ${dd} changed the gust count`);
a.gusts.forEach((g, i) => {
@ -343,32 +375,34 @@ export function weatherCases(storms) {
`downdraft ${dd} moved gust ${i} from t=${g.t0.toFixed(3)} to ${b.gusts[i].t0.toFixed(3)}`);
assert(g.pow === b.gusts[i].pow, `downdraft ${dd} changed gust ${i}'s power`);
});
// and the HORIZONTAL wind must be byte-identical regardless of downdraft
assert(a.speedAt(3, -2, 47.3) === b.speedAt(3, -2, 47.3), `downdraft ${dd} changed the horizontal wind`);
}
});
test('at a gust peak the downdraft is a real fraction of the horizontal', () => {
test('speedAt stays horizontal — a wind meter does not read falling air', () => {
const f = createWindField(storms.storm_02_wildnight);
const out = { x: 0, y: 0, z: 0 };
let bestRatio = 0, atT = 0;
for (let t = 0; t <= f.duration; t += DT) {
f.vecAt(0, 0, t, out);
const horiz = Math.hypot(out.x, out.z);
if (horiz < 1) continue;
const r = Math.abs(out.y) / horiz;
if (r > bestRatio) { bestRatio = r; atT = t; }
for (const p of PROBES) {
for (const t of [12, 40, 60, 75.3]) {
f.vecAt(p.x, p.z, t, out);
assert(Math.abs(f.speedAt(p.x, p.z, t) - Math.hypot(out.x, out.z)) < 1e-9,
`speedAt != horizontal magnitude of sample at t=${t}`);
}
}
metrics['storm_02.peakVerticalRatio'] = +bestRatio.toFixed(3);
assert(bestRatio > 0.12,
`strongest downdraft is only ${(bestRatio * 100).toFixed(0)}% of the horizontal wind (t=${atT.toFixed(1)}) — a flat sail still shrugs`);
});
test('validator rejects a bad downdraft', () => {
test('validator rejects a bad downdraft and the renamed field', () => {
for (const dd of [-0.1, 1.5, NaN, 'lots']) {
const d = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
d.gusts.downdraft = dd;
const { ok } = validateStorm(d, 'broken');
assert(!ok, `validator ACCEPTED downdraft = ${dd}`);
d.gusts.downdraftOfTotal = dd;
assert(!validateStorm(d, 'broken').ok, `validator ACCEPTED downdraftOfTotal = ${dd}`);
}
// the old gust-only field must be rejected, not silently re-meant
const legacy = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
delete legacy.gusts.downdraftOfTotal;
legacy.gusts.downdraft = 0.3;
assert(!validateStorm(legacy, 'legacy').ok, 'validator silently accepted the pre-SPRINT3 downdraft field');
});
return { cases, metrics };

View File

@ -130,29 +130,19 @@ function sampleAngleCurve(curve, t) {
// ---------- gust timeline ----------
// Prototype: pow = 12 + rand*16 + 10*p, next = t + 5 + rand*7. Same shape, from JSON.
export const DEFAULT_DOWNDRAFT = 0.25;
export const DEFAULT_DOWNDRAFT = 0.22;
export function buildGustTimeline(def, seed) {
const g = def.gusts || {};
const rng = mulberry32(seed >>> 0);
// Vertical draws from its OWN stream, deliberately. Pulling it from `rng`
// would shift every subsequent (t0, pow) and silently re-time storms that are
// already tuned and hand-verified — A drove storm_02 and watched the carabiner
// blow at t=45.4 and cascade at t=56, one second after the change. Adding a
// downdraft shouldn't move that.
const rngV = mulberry32((seed ^ 0x0d0117) >>> 0);
const minGap = g.minGap ?? 5, maxGap = g.maxGap ?? 12;
const downFrac = g.downdraft ?? DEFAULT_DOWNDRAFT;
const out = [];
let t = g.firstAt ?? 3;
// hard cap: a malformed gap can't spin us forever
while (t < def.duration && out.length < 512) {
const p = def.duration > 0 ? t / def.duration : 0;
const pow = (g.powBase ?? 12) + rng() * (g.powRand ?? 16) + (g.powRamp ?? 10) * p;
// Not every gust slams down the same: some roll through nearly flat, some
// are a proper little downburst. 0.61.4× the storm's fraction.
const down = downFrac * (0.6 + rngV() * 0.8);
out.push({ t0: t, pow, down, rampAt: t + GUST.TELEGRAPH, endAt: t + GUST.TOTAL });
out.push({ t0: t, pow, rampAt: t + GUST.TELEGRAPH, endAt: t + GUST.TOTAL });
t += minGap + rng() * Math.max(0, maxGap - minGap);
}
return out;
@ -174,6 +164,11 @@ export function createWindField(def, opts = {}) {
const wander = def.dirWander || {};
const wAmp = wander.amp ?? 0.25, wRate = wander.rate ?? 0.13;
const nSeed = (seed ^ 0x9e3779b9) | 0;
// SPRINT3 decision 8: the downdraft is a fraction of TOTAL wind speed, not of
// gust power. `downdraftOfTotal` is the field name; `downdraft` is read as a
// legacy alias so an un-migrated storm doesn't silently lose its vertical.
const gd = def.gusts || {};
const downFrac = gd.downdraftOfTotal ?? gd.downdraft ?? DEFAULT_DOWNDRAFT;
let shelters = [];
@ -202,24 +197,37 @@ export function createWindField(def, opts = {}) {
return sampleAngleCurve(def.dirCurve, t) + wAmp * Math.sin(t * wRate);
}
/** Local horizontal wind speed (m/s) base+gusts, spatial noise, tree shadow.
* The one place the local-speed maths lives; speedAt/vecAt/verticalAt share it. */
function localHoriz(x, z, t) {
const uni = uniformSpeed(t);
const d = dirAt(t);
const s = uni * spatialFactor(x, z, t) * shelterFactor(x, z, Math.cos(d), Math.sin(d));
return s > 0 ? s : 0;
}
/**
* Vertical wind, m/s. NEGATIVE = downward. Zero between gusts.
* Vertical wind, m/s. NEGATIVE = downward. A fraction of the LOCAL horizontal
* speed at this point and time.
*
* A gust front is descending air, not just faster air. Without this the field
* is perfectly horizontal, and a horizontal sail is a free lunch: cloth
* pressure goes with dot(wind, normal), a flat panel's normal points at the
* sky, and the dot product is ~0 no matter how hard it blows. So the cheapest
* winning rig was "lie it flat and ignore the storm", which is the opposite of
* the game (SPRINT2 decision 3). A downdraft hits a flat panel square on.
* Why a horizontal sail must pay: cloth pressure goes with dot(wind, normal),
* a flat panel's normal points at the sky, so in a purely horizontal wind the
* dot is ~0 and "lie it flat and ignore the storm" wins the opposite of the
* game. A descending component hits a flat panel square on.
*
* SPRINT3 decision 8 fraction of TOTAL, not of gust power. Under gust-only
* semantics the downdraft peaked exactly at the gust peak, where the horizontal
* ALSO peaked, so a flat sail could never reach 60% of a pitched one's load
* (B measured 34%) without a downdraft so violent it also killed the twisted
* rig the §7 gate needs to survive. The two gates pincered. Riding total speed
* instead spreads the load across the whole storm: a flat roof is pressed
* steadily (peak total 32.6 m/s dwarfs peak gust power 12.6), so the ratio
* clears 60% at a gentle fraction, without a spike at the gust peak. It follows
* the LOCAL speed, so a tree's wind shadow shelters from falling air too.
*/
function gustVertical(t) {
let v = 0;
for (let i = 0; i < gusts.length; i++) {
const g = gusts[i];
if (t <= g.t0) break; // sorted — nothing later is live
if (t < g.endAt) v -= gustEnvelope(t - g.t0, g.pow) * g.down;
}
return v;
function verticalAt(x, z, t) {
if (downFrac <= 0) return 0;
return -downFrac * localHoriz(x, z, t);
}
// ---- noise drift ----
@ -318,27 +326,22 @@ export function createWindField(def, opts = {}) {
* The cheap path no allocation.
*/
speedAt(x, z, t) {
const uni = uniformSpeed(t);
const d = dirAt(t);
const s = uni * spatialFactor(x, z, t) * shelterFactor(x, z, Math.cos(d), Math.sin(d));
return s > 0 ? s : 0;
return localHoriz(x, z, t);
},
dirAt,
uniformSpeed,
gustOnly,
gustVertical,
verticalAt,
get downFrac() { return downFrac; },
/** Writes wind velocity (m/s) into out {x,y,z}. Ground plane is XZ, +Y up. */
vecAt(x, z, t, out) {
const uni = uniformSpeed(t);
const d = dirAt(t);
const dirX = Math.cos(d), dirZ = Math.sin(d);
const m = spatialFactor(x, z, t) * shelterFactor(x, z, dirX, dirZ);
let s = uni * m;
if (s < 0) s = 0;
const s = localHoriz(x, z, t);
out.x = dirX * s;
out.y = gustVertical(t) * m; // gust fronts descend — see gustVertical()
out.y = -downFrac * s; // the downdraft rides the local speed — see verticalAt()
out.z = dirZ * s;
return out;
},
@ -415,9 +418,14 @@ export function validateStorm(def, name = 'storm') {
// Overlapping gusts stack, and a stacked telegraph is unreadable to the player.
if (minGap < GUST.TOTAL) bad(`gusts.minGap (${minGap}) < gust length ${GUST.TOTAL}s — gusts would overlap`);
if ((g.powBase ?? 12) < 0) bad('gusts.powBase must be >= 0');
const dd = g.downdraft ?? DEFAULT_DOWNDRAFT;
// `downdraft` (gust-only, pre-SPRINT3) is still accepted but flagged, so an
// un-migrated storm loads visibly wrong rather than silently at a third power.
if (g.downdraft != null && g.downdraftOfTotal == null) {
bad('gusts.downdraft is the old gust-only field — rename to downdraftOfTotal (SPRINT3 decision 8); it now means a fraction of TOTAL wind speed');
}
const dd = g.downdraftOfTotal ?? g.downdraft ?? DEFAULT_DOWNDRAFT;
if (!Number.isFinite(dd) || dd < 0 || dd > 1) {
bad(`gusts.downdraft must be 0..1 — the fraction of gust power that blows DOWN — got ${dd}`);
bad(`gusts.downdraftOfTotal must be 0..1 — the fraction of TOTAL wind speed that blows DOWN — got ${dd}`);
}
}

View File

@ -1,263 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SHADES — Lane C — weather bench</title>
<style>
:root { --ink:#d8d8e0; --gold:#ffd23d; --neon:#3dff8b; }
* { box-sizing:border-box; }
body { margin:0; overflow:hidden; background:#000;
font:13px/1.45 "Courier New", ui-monospace, monospace; color:var(--ink); }
canvas { display:block; }
#hud { position:fixed; top:10px; left:10px; background:rgba(6,6,12,.75); padding:8px 12px;
border:1px solid #26263a; z-index:3; min-width:250px; }
#hud b { color:var(--gold); }
#hud .warn { color:#ff6; font-weight:bold; }
#hud .bad { color:#f66; font-weight:bold; }
#ctl { position:fixed; bottom:10px; left:10px; background:rgba(6,6,12,.8); padding:8px 12px;
border:1px solid #26263a; z-index:3; }
#ctl button { background:#1d1d2b; color:var(--ink); border:1px solid #666; font:inherit;
padding:4px 9px; cursor:pointer; }
#ctl button:hover { border-color:var(--neon); color:var(--neon); }
#ctl input[type=range] { width:220px; vertical-align:middle; }
#note { position:fixed; top:10px; right:10px; background:rgba(6,6,12,.75); padding:8px 12px;
border:1px solid #26263a; z-index:3; max-width:280px; color:#8a8a99; }
.bar { display:inline-block; width:90px; height:7px; border:1px solid #555; vertical-align:middle; }
.bar i { display:block; height:100%; background:var(--neon); }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="hud"></div>
<div id="note">
<b>Lane C bench.</b> Graybox stand-in for Lane A's yard — this exists to drive
weather.js / skyfx.js / debris.js before M0 lands. The sail here is a MOCK
(Lane B owns the real one); it's a bare node grid so debris impulse is visible.
<br><br>drag = orbit · click = start audio
</div>
<div id="ctl"></div>
<script type="module">
import * as THREE from './vendor/three.module.js';
import { loadStorm, createWind } from './js/weather.js';
import { createSkyFx } from './js/skyfx.js';
import { createDebris } from './js/debris.js';
const canvas = document.getElementById('c');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(2, devicePixelRatio));
renderer.shadowMap.enabled = true;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x9fc4e8);
const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 500);
// --- graybox yard: 30×20 m, origin centre (stands in for Lane A's world.js) ---
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(30, 20),
new THREE.MeshStandardMaterial({ color: 0x4a7c3f, roughness: 1 }),
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
// Lane A's landed yard (THREADS): t1 (-9,2), t2 (8,-2), house edge at z=-9.9
const TREES = [{ x: -9, z: 2 }, { x: 8, z: -2 }];
for (const tr of TREES) {
const trunk = new THREE.Mesh(
new THREE.CylinderGeometry(0.2, 0.28, 4, 8),
new THREE.MeshStandardMaterial({ color: 0x5a3d24 }),
);
trunk.position.set(tr.x, 2, tr.z);
trunk.castShadow = true;
scene.add(trunk);
const canopy = new THREE.Mesh(
new THREE.SphereGeometry(3, 12, 8),
new THREE.MeshStandardMaterial({ color: 0x285f23 }),
);
canopy.position.set(tr.x, 5, tr.z);
canopy.castShadow = true;
scene.add(canopy);
}
// house edge along north (-Z), for scale
const house = new THREE.Mesh(
new THREE.BoxGeometry(30, 3.2, 1),
new THREE.MeshStandardMaterial({ color: 0x8a8f96 }),
);
house.position.set(0, 1.6, -10.4);
scene.add(house);
// the thing you're protecting — Lane A's gardenBed rect
const bed = new THREE.Mesh(
new THREE.BoxGeometry(6, 0.25, 4),
new THREE.MeshStandardMaterial({ color: 0x6b4a2f }),
);
bed.position.set(1, 0.12, 2);
scene.add(bed);
// 1.7 m reference person
const ref = new THREE.Mesh(
new THREE.CapsuleGeometry(0.25, 1.2, 4, 8),
new THREE.MeshStandardMaterial({ color: 0xffd27a }),
);
ref.position.set(2, 0.85, 2);
ref.castShadow = true;
scene.add(ref);
const player = { pos: ref.position, carrying: null, busy: false };
const sun = new THREE.DirectionalLight(0xfff4e0, 2.2);
sun.position.set(-12, 18, 6);
sun.castShadow = true;
sun.shadow.mapSize.set(1024, 1024);
scene.add(sun);
const hemi = new THREE.HemisphereLight(0xbfd8ff, 0x3a4a2a, 0.9);
scene.add(hemi);
// --- MOCK sail (Lane B owns the real cloth) — a bare node grid so we can see
// debris shove it and drive the creak/flog audio off corner loads.
const N = 9;
const nodes = [];
for (let v = 0; v < N; v++) {
for (let u = 0; u < N; u++) {
nodes.push({ x: -4 + (u / (N - 1)) * 8, y: 3.2, z: -3 + (v / (N - 1)) * 6 });
}
}
const sailGeo = new THREE.BufferGeometry();
sailGeo.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(nodes.length * 3), 3));
const sailPts = new THREE.Points(sailGeo, new THREE.PointsMaterial({ color: 0xe8c46a, size: 0.14 }));
scene.add(sailPts);
const mockSail = {
nodes,
corners: [
{ anchorId: 'h1', hw: { name: 'carabiner', rating: 9 }, load: 0, broken: false },
{ anchorId: 'h3', hw: { name: 'shackle', rating: 19 }, load: 0, broken: false },
{ anchorId: 'p1', hw: { name: 'shackle', rating: 19 }, load: 0, broken: false },
{ anchorId: 'p2', hw: { name: 'carabiner', rating: 9 }, load: 0, broken: false },
],
};
// --- weather ---
const params = new URLSearchParams(location.search);
const stormName = params.get('storm') || 'storm_02_wildnight';
const def = await loadStorm(stormName);
const wind = createWind(def);
wind.setShelters(TREES.map((t) => ({ x: t.x, z: t.z, radius: 3, strength: 0.45, length: 14 })));
const ticker = [];
const sky = createSkyFx({ scene, camera, wind, sun, hemi, onEvent: (s) => ticker.unshift(s) });
const debris = createDebris({
wind, scene, player,
onEvent: (s) => ticker.unshift(s),
onHitPlayer: (p, impact) => ticker.unshift(`KNOCKED DOWN by ${p.model} (${impact.toFixed(0)})`),
});
addEventListener('pointerdown', () => sky.unlockAudio(), { once: true });
// --- controls ---
let t = 0, playing = true, rate = 1;
const ctl = document.getElementById('ctl');
ctl.innerHTML = `
<button id="play">pause</button>
<button id="r1">1×</button><button id="r4">4×</button><button id="r0">0.25×</button>
<button id="reset">reset</button>
<button id="break">break a corner</button>
<button id="crate">throw a crate</button>
<input id="scrub" type="range" min="0" max="${def.duration}" step="0.1" value="0">
`;
const $ = (id) => document.getElementById(id);
$('play').onclick = () => { playing = !playing; $('play').textContent = playing ? 'pause' : 'play'; };
$('r1').onclick = () => { rate = 1; };
$('r4').onclick = () => { rate = 4; };
$('r0').onclick = () => { rate = 0.25; };
$('reset').onclick = () => { t = 0; debris.clear(); ticker.length = 0; mockSail.corners.forEach((c) => { c.broken = false; }); };
$('break').onclick = () => { const c = mockSail.corners.find((x) => !x.broken); if (c) { c.broken = true; ticker.unshift(`${c.hw.name} BLOWS at ${c.anchorId.toUpperCase()}!`); } };
$('crate').onclick = () => debris.spawn({ model: 'BlueCrate_v2', lateral: (Math.random() * 6 - 3), text: 'crate!' }, t);
$('scrub').oninput = (e) => { t = parseFloat(e.target.value); debris.clear(); };
let yaw = 0.7, pitch = 0.28, dist = 26, dragging = false, lx = 0, ly = 0;
addEventListener('pointerdown', (e) => { dragging = true; lx = e.clientX; ly = e.clientY; });
addEventListener('pointerup', () => { dragging = false; });
addEventListener('pointermove', (e) => {
if (!dragging) return;
yaw -= (e.clientX - lx) * 0.005; pitch = Math.min(1.3, Math.max(0.05, pitch + (e.clientY - ly) * 0.004));
lx = e.clientX; ly = e.clientY;
});
addEventListener('wheel', (e) => { dist = Math.min(60, Math.max(8, dist + e.deltaY * 0.02)); });
function resize() {
const w = innerWidth, h = innerHeight;
renderer.setSize(w, h);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
addEventListener('resize', resize); resize();
// --- loop: fixed-dt sim, rAF only drives the clock (PLAN3D §0) ---
const DT = 1 / 60;
let acc = 0, last = performance.now();
const hud = document.getElementById('hud');
const probe = new THREE.Vector3();
const w = new THREE.Vector3();
const posAttr = sailGeo.getAttribute('position');
function frame(now) {
const real = Math.min(0.1, (now - last) / 1000);
last = now;
if (playing) acc += real * rate;
while (acc >= DT) {
acc -= DT;
t += DT;
if (t > def.duration) t = 0;
// mock cloth: nodes just bob with local wind so debris has something to hit
for (const n of nodes) {
probe.set(n.x, n.y, n.z);
wind.sample(probe, t, w);
const sp = Math.hypot(w.x, w.z);
n.y += ((3.2 + Math.sin(t * 3 + n.x) * sp * 0.02) - n.y) * 0.08;
}
// mock loads so the creak layer has something to track
probe.set(0, 3.2, 0);
const sp = wind.speedAt(probe, t);
mockSail.corners.forEach((c, i) => {
c.load = c.broken ? 0 : sp * sp * 0.021 * (0.7 + i * 0.16);
});
debris.step(DT, t, { player, sail: mockSail });
sky.step(DT, t, { sail: mockSail });
}
for (let i = 0; i < nodes.length; i++) posAttr.setXYZ(i, nodes[i].x, nodes[i].y, nodes[i].z);
posAttr.needsUpdate = true;
camera.position.set(
Math.sin(yaw) * Math.cos(pitch) * dist,
Math.sin(pitch) * dist + 1.5,
Math.cos(yaw) * Math.cos(pitch) * dist,
);
camera.lookAt(0, 2, 0);
$('scrub').value = t.toFixed(1);
probe.set(0, 1.7, 0);
wind.sample(probe, t, w);
const speed = Math.hypot(w.x, w.z);
const tg = wind.gustTelegraph(t);
const worst = Math.max(...mockSail.corners.map((c) => (c.broken ? 0 : c.load / c.hw.rating)));
hud.innerHTML = `
<div><b>${def.name}</b> — ${stormName}</div>
<div>t <b>${t.toFixed(1)}</b> / ${def.duration}s (${rate}×)</div>
<div>wind <b>${speed.toFixed(1)}</b> m/s (${(speed * 3.6).toFixed(0)} km/h)</div>
<div>dir ${(wind.dirAt(t)).toFixed(2)} rad</div>
<div>rain <span class="bar"><i style="width:${wind.rainAt(t) * 100}%"></i></span></div>
<div>worst <span class="bar"><i style="width:${Math.min(100, worst * 100)}%;background:${worst > 0.8 ? '#f66' : '#3dff8b'}"></i></span></div>
<div>debris ${debris.pieces.length} audio ${sky.audio.ready ? sky.audio.state : '(click)'}</div>
<div>flash ${sky.flash.toFixed(2)}</div>
${tg ? `<div class="warn">GUST INBOUND ${tg.eta.toFixed(1)}s pow ${tg.power.toFixed(0)}</div>` : '<div>&nbsp;</div>'}
${ticker.slice(0, 3).map((s) => `<div class="bad">${s}</div>`).join('')}
`;
renderer.render(scene, camera);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
window.__bench = { wind, sky, debris, mockSail, def, get t() { return t; } };
</script>
</body>
</html>