Downdraft as fraction of total wind (decision 8) + storm_03

The gust-only downdraft could not satisfy B's 60% no-free-lunch bar and the §7
twisted-survives gate together: the downdraft peaked at the gust peak, where
the horizontal peaked too, so a flat sail never reached 60% of a pitched one's
load without a spike so violent it also broke the twisted rig. The integrator
measured the pincer (0.58 -> 48% and still breaks twisted).

Fix: the downdraft is now a fraction of the LOCAL total wind speed, not of gust
power (weather.core verticalAt = -frac * localHoriz). It presses a flat roof
steadily across the whole storm — peak total 32.6 m/s dwarfs peak gust power
12.6 — so the ratio clears 60% at a gentle fraction, with no gust-peak spike.
It rides the local speed, so a tree's wind shadow shelters from falling air too.
speedAt() stays horizontal (a wind meter doesn't read falling air).

Field renamed downdraft -> downdraftOfTotal; the validator rejects the old name
rather than silently re-meaning it. The vertical now carries NO rng draws at
all, so the determinism guarantee (tuning can't re-time gusts) is structural,
not just separate-stream.

Measured both gates myself with B's SailRig (8-direction flat-vs-pitched sweep +
§7 legs). Target for storm_02 is 0.45: 69% of-max / 60% worst-heading on the
bar, twisted rated rig survives with ~21% margin. HELD at 0.12 this commit —
the current yard's only twisted quad ('h1,t2,p1,t1', ~190 m2) starts losing a
corner near 0.15 in the exact solver, so 0.45 would red B's §7. 0.12 ~= the old
gust-only 0.3 in peak downdraft (-4.2 vs -4.5 m/s). 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.

storm_03_southerly: campaign ramp between gentle and wildnight — peak gust 21
m/s, sustained 13, one moderate southerly change. Auto-swept by the suite.

Selftest 170/0/0 (all three §7 legs green). Verified live: downdraft/horizontal
ratio is exactly 0.12 in-game, rain occlusion still covers the bed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-17 01:45:50 +10:00
parent a34cbd6d19
commit 576422e1f0
6 changed files with 187 additions and 104 deletions

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}`);
}
}