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:
parent
a34cbd6d19
commit
576422e1f0
@ -14,7 +14,7 @@
|
|||||||
"powBase": 2,
|
"powBase": 2,
|
||||||
"powRand": 3,
|
"powRand": 3,
|
||||||
"powRamp": 2,
|
"powRamp": 2,
|
||||||
"downdraft": 0.18
|
"downdraftOfTotal": 0.25
|
||||||
},
|
},
|
||||||
|
|
||||||
"dirCurve": [[0, 0.9], [45, 1.0], [90, 1.15]],
|
"dirCurve": [[0, 0.9], [45, 1.0], [90, 1.15]],
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
"baseCurve": [[0, 7.0], [15, 11.0], [40, 17.0], [60, 20.0], [78, 19.0], [90, 16.0]],
|
"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": {
|
"gusts": {
|
||||||
"firstAt": 3,
|
"firstAt": 3,
|
||||||
@ -20,7 +20,7 @@
|
|||||||
"powBase": 3,
|
"powBase": 3,
|
||||||
"powRand": 5,
|
"powRand": 5,
|
||||||
"powRamp": 7,
|
"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]],
|
"dirCurve": [[0, 0.85], [50, 0.95], [55, 0.6], [59, -1.25], [70, -1.45], [90, -1.35]],
|
||||||
|
|||||||
38
web/world/data/storms/storm_03_southerly.json
Normal file
38
web/world/data/storms/storm_03_southerly.json
Normal 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 }
|
||||||
|
}
|
||||||
@ -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');
|
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
|
// This assert once read `a.y === 0` — "wind is horizontal". SPRINT2 decision 3
|
||||||
// decision 3 made that false on purpose: gusts now descend, which is what makes
|
// made that false on purpose (gusts descend, so a flat sail pays); SPRINT3
|
||||||
// a flat sail pay. Keeping the useful half — y is downward-or-zero, never up,
|
// decision 8 made the descent a fraction of TOTAL wind, so it's present
|
||||||
// and never garbage — so player shove and rain angle can still trust the sign.
|
// whenever it's windy, not only in gusts. Keeping the invariants that consumers
|
||||||
t.test('vertical wind is downward-only, and only during gusts', () => {
|
// (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 wind = createWind(storms.storm_02_wildnight);
|
||||||
const pos = new THREE.Vector3(0, 1.7, 0);
|
const pos = new THREE.Vector3(0, 1.7, 0);
|
||||||
const v = new THREE.Vector3();
|
const v = new THREE.Vector3();
|
||||||
@ -66,12 +68,13 @@ export default async function run(t) {
|
|||||||
fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
|
fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
|
||||||
wind.sample(pos, time, v);
|
wind.sample(pos, time, v);
|
||||||
assert(v.y <= 1e-9, `wind blew UP (y=${v.y.toFixed(3)}) at t=${time.toFixed(2)}`);
|
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');
|
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
|
// the wind meter must stay horizontal — falling air shouldn't spike the HUD
|
||||||
const calm = wind.speedAt(pos, 0.5);
|
const s = wind.sample(pos, 0.5);
|
||||||
assert(Math.abs(calm - Math.hypot(wind.sample(pos, 0.5).x, wind.sample(pos, 0.5).z)) < 1e-9,
|
assert(Math.abs(wind.speedAt(pos, 0.5) - Math.hypot(s.x, s.z)) < 1e-9,
|
||||||
'speedAt() is not the horizontal magnitude of sample()');
|
'speedAt() is not the horizontal magnitude of sample()');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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');
|
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) ----
|
// ---- 9. vertical structure (SPRINT3 decision 8: fraction of TOTAL) ----
|
||||||
// Cloth pressure goes with dot(wind, normal). A flat horizontal panel's normal
|
// Cloth pressure goes with dot(wind, normal). A flat panel's normal points at
|
||||||
// points at the sky, so in a perfectly horizontal wind that dot is ~0 and the
|
// the sky, so in a purely horizontal wind that dot is ~0 and "lie it flat and
|
||||||
// cheapest winning rig is "lie it flat and ignore the storm" — the opposite of
|
// ignore the storm" wins — the opposite of the game. The downdraft is now a
|
||||||
// the game. Gust fronts are descending air, and descending air hits a flat
|
// fraction of the LOCAL total wind speed (was: gust power), so a flat roof is
|
||||||
// panel square on. Lane B owns the cloth-side assert; these are the wind side.
|
// pressed whenever it's windy, not only at gust peaks. Lane B owns the
|
||||||
test('gusts carry a downdraft, and still air does not', () => {
|
// cloth-side no-free-lunch assert; these are the wind side.
|
||||||
const f = createWindField(storms.storm_02_wildnight);
|
test('downdraft is a fixed fraction of the local horizontal speed', () => {
|
||||||
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', () => {
|
|
||||||
const def = storms.storm_02_wildnight;
|
const def = storms.storm_02_wildnight;
|
||||||
const f = createWindField(def);
|
const f = createWindField(def);
|
||||||
const frac = def.gusts.downdraft;
|
const frac = def.gusts.downdraftOfTotal;
|
||||||
for (const g of f.gusts) {
|
assert(Math.abs(f.downFrac - frac) < 1e-12, `field downFrac ${f.downFrac} != json ${frac}`);
|
||||||
assert(g.down >= frac * 0.6 - 1e-9 && g.down <= frac * 1.4 + 1e-9,
|
const out = { x: 0, y: 0, z: 0 };
|
||||||
`gust at t=${g.t0.toFixed(1)} has down=${g.down.toFixed(3)}, outside 0.6–1.4× of ${frac}`);
|
let peakDown = 0;
|
||||||
// minGap >= GUST.TOTAL means gusts never overlap, so at hold it's exactly this gust
|
for (const p of PROBES) {
|
||||||
const atHold = f.gustVertical(g.t0 + 3);
|
for (let t = 0; t <= f.duration; t += DT) {
|
||||||
assert(Math.abs(atHold - -(g.pow * g.down)) < 1e-9,
|
f.vecAt(p.x, p.z, t, out);
|
||||||
`at gust hold vertical is ${atHold.toFixed(3)}, want ${(-g.pow * g.down).toFixed(3)}`);
|
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));
|
const def = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||||||
def.gusts.downdraft = 0;
|
def.gusts.downdraftOfTotal = 0;
|
||||||
const f = createWindField(def);
|
const f = createWindField(def);
|
||||||
const out = { x: 0, y: 0, z: 0 };
|
const out = { x: 0, y: 0, z: 0 };
|
||||||
for (let t = 0; t <= f.duration; t += 0.05) {
|
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', () => {
|
test('downdraft does not re-time the storm', () => {
|
||||||
// The vertical draws from its own RNG stream precisely so that adding or
|
// The vertical carries NO rng draws of its own now (it's a pure function of
|
||||||
// tuning it can't shift gust times or powers. Lane A hand-drove storm_02 and
|
// local speed), so tuning it cannot possibly shift gust times or powers. Lane
|
||||||
// watched the carabiner blow at t=45.4 and p2 cascade at t=56; a downdraft
|
// A hand-drove storm_02 and watched the carabiner blow at t=45.4 and p2
|
||||||
// tweak silently moving those would be a nasty way to lose an afternoon.
|
// 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 base = storms.storm_02_wildnight;
|
||||||
const a = createWindField(base);
|
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));
|
const d = JSON.parse(JSON.stringify(base));
|
||||||
d.gusts.downdraft = dd;
|
d.gusts.downdraftOfTotal = dd;
|
||||||
const b = createWindField(d);
|
const b = createWindField(d);
|
||||||
assert(a.gusts.length === b.gusts.length, `downdraft ${dd} changed the gust count`);
|
assert(a.gusts.length === b.gusts.length, `downdraft ${dd} changed the gust count`);
|
||||||
a.gusts.forEach((g, i) => {
|
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)}`);
|
`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`);
|
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 f = createWindField(storms.storm_02_wildnight);
|
||||||
const out = { x: 0, y: 0, z: 0 };
|
const out = { x: 0, y: 0, z: 0 };
|
||||||
let bestRatio = 0, atT = 0;
|
for (const p of PROBES) {
|
||||||
for (let t = 0; t <= f.duration; t += DT) {
|
for (const t of [12, 40, 60, 75.3]) {
|
||||||
f.vecAt(0, 0, t, out);
|
f.vecAt(p.x, p.z, t, out);
|
||||||
const horiz = Math.hypot(out.x, out.z);
|
assert(Math.abs(f.speedAt(p.x, p.z, t) - Math.hypot(out.x, out.z)) < 1e-9,
|
||||||
if (horiz < 1) continue;
|
`speedAt != horizontal magnitude of sample at t=${t}`);
|
||||||
const r = Math.abs(out.y) / horiz;
|
}
|
||||||
if (r > bestRatio) { bestRatio = r; atT = 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']) {
|
for (const dd of [-0.1, 1.5, NaN, 'lots']) {
|
||||||
const d = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
const d = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||||||
d.gusts.downdraft = dd;
|
d.gusts.downdraftOfTotal = dd;
|
||||||
const { ok } = validateStorm(d, 'broken');
|
assert(!validateStorm(d, 'broken').ok, `validator ACCEPTED downdraftOfTotal = ${dd}`);
|
||||||
assert(!ok, `validator ACCEPTED downdraft = ${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 };
|
return { cases, metrics };
|
||||||
|
|||||||
@ -130,29 +130,19 @@ function sampleAngleCurve(curve, t) {
|
|||||||
|
|
||||||
// ---------- gust timeline ----------
|
// ---------- gust timeline ----------
|
||||||
// Prototype: pow = 12 + rand*16 + 10*p, next = t + 5 + rand*7. Same shape, from JSON.
|
// 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) {
|
export function buildGustTimeline(def, seed) {
|
||||||
const g = def.gusts || {};
|
const g = def.gusts || {};
|
||||||
const rng = mulberry32(seed >>> 0);
|
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 minGap = g.minGap ?? 5, maxGap = g.maxGap ?? 12;
|
||||||
const downFrac = g.downdraft ?? DEFAULT_DOWNDRAFT;
|
|
||||||
const out = [];
|
const out = [];
|
||||||
let t = g.firstAt ?? 3;
|
let t = g.firstAt ?? 3;
|
||||||
// hard cap: a malformed gap can't spin us forever
|
// hard cap: a malformed gap can't spin us forever
|
||||||
while (t < def.duration && out.length < 512) {
|
while (t < def.duration && out.length < 512) {
|
||||||
const p = def.duration > 0 ? t / def.duration : 0;
|
const p = def.duration > 0 ? t / def.duration : 0;
|
||||||
const pow = (g.powBase ?? 12) + rng() * (g.powRand ?? 16) + (g.powRamp ?? 10) * p;
|
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
|
out.push({ t0: t, pow, rampAt: t + GUST.TELEGRAPH, endAt: t + GUST.TOTAL });
|
||||||
// are a proper little downburst. 0.6–1.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 });
|
|
||||||
t += minGap + rng() * Math.max(0, maxGap - minGap);
|
t += minGap + rng() * Math.max(0, maxGap - minGap);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@ -174,6 +164,11 @@ export function createWindField(def, opts = {}) {
|
|||||||
const wander = def.dirWander || {};
|
const wander = def.dirWander || {};
|
||||||
const wAmp = wander.amp ?? 0.25, wRate = wander.rate ?? 0.13;
|
const wAmp = wander.amp ?? 0.25, wRate = wander.rate ?? 0.13;
|
||||||
const nSeed = (seed ^ 0x9e3779b9) | 0;
|
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 = [];
|
let shelters = [];
|
||||||
|
|
||||||
@ -202,24 +197,37 @@ export function createWindField(def, opts = {}) {
|
|||||||
return sampleAngleCurve(def.dirCurve, t) + wAmp * Math.sin(t * wRate);
|
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
|
* Why a horizontal sail must pay: cloth pressure goes with dot(wind, normal),
|
||||||
* is perfectly horizontal, and a horizontal sail is a free lunch: cloth
|
* a flat panel's normal points at the sky, so in a purely horizontal wind the
|
||||||
* pressure goes with dot(wind, normal), a flat panel's normal points at the
|
* dot is ~0 and "lie it flat and ignore the storm" wins — the opposite of the
|
||||||
* sky, and the dot product is ~0 no matter how hard it blows. So the cheapest
|
* game. A descending component hits a flat panel square on.
|
||||||
* 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.
|
* 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) {
|
function verticalAt(x, z, t) {
|
||||||
let v = 0;
|
if (downFrac <= 0) return 0;
|
||||||
for (let i = 0; i < gusts.length; i++) {
|
return -downFrac * localHoriz(x, z, t);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- noise drift ----
|
// ---- noise drift ----
|
||||||
@ -318,27 +326,22 @@ export function createWindField(def, opts = {}) {
|
|||||||
* The cheap path — no allocation.
|
* The cheap path — no allocation.
|
||||||
*/
|
*/
|
||||||
speedAt(x, z, t) {
|
speedAt(x, z, t) {
|
||||||
const uni = uniformSpeed(t);
|
return localHoriz(x, z, 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;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
dirAt,
|
dirAt,
|
||||||
uniformSpeed,
|
uniformSpeed,
|
||||||
gustOnly,
|
gustOnly,
|
||||||
gustVertical,
|
verticalAt,
|
||||||
|
get downFrac() { return downFrac; },
|
||||||
|
|
||||||
/** Writes wind velocity (m/s) into out {x,y,z}. Ground plane is XZ, +Y up. */
|
/** Writes wind velocity (m/s) into out {x,y,z}. Ground plane is XZ, +Y up. */
|
||||||
vecAt(x, z, t, out) {
|
vecAt(x, z, t, out) {
|
||||||
const uni = uniformSpeed(t);
|
|
||||||
const d = dirAt(t);
|
const d = dirAt(t);
|
||||||
const dirX = Math.cos(d), dirZ = Math.sin(d);
|
const dirX = Math.cos(d), dirZ = Math.sin(d);
|
||||||
const m = spatialFactor(x, z, t) * shelterFactor(x, z, dirX, dirZ);
|
const s = localHoriz(x, z, t);
|
||||||
let s = uni * m;
|
|
||||||
if (s < 0) s = 0;
|
|
||||||
out.x = dirX * s;
|
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;
|
out.z = dirZ * s;
|
||||||
return out;
|
return out;
|
||||||
},
|
},
|
||||||
@ -415,9 +418,14 @@ export function validateStorm(def, name = 'storm') {
|
|||||||
// Overlapping gusts stack, and a stacked telegraph is unreadable to the player.
|
// 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 (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');
|
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) {
|
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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user