Add validateSiteWind — the site JSON's wind block fails loud (SPRINT10)

A builds world.js FROM DATA this sprint, and a site's `wind` block (venturi
funnels, later per-site shelter overrides) is hand-authored data like a storm —
so it validates like one. setVenturi already clamps defensively, but a clamp
hides a typo where the repo's ethos is to name it: a gain < 1 (someone reaching
for a shelter), a funnel with no throat coordinates, an axis typed "south". A
calls this at site load next to validateStorm. A site with no wind block, an
empty block, or an empty venturi list all validate — the funnel is opt-in.

Node 56/0/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-17 15:30:00 +10:00
parent 63beec87a7
commit 5cb838b314
3 changed files with 50 additions and 3 deletions

View File

@ -11,7 +11,7 @@
// call it with the fetched storm defs.
import {
createWindField, validateStorm, gustEnvelope, GUST, RAIN_TIME_COMPRESSION,
createWindField, validateStorm, validateSiteWind, gustEnvelope, GUST, RAIN_TIME_COMPRESSION,
stormStats, forecastFor, hailBlockFor,
} from '../weather.core.js';
@ -324,6 +324,24 @@ export function weatherCases(storms) {
assert(maxRatio > 1.3, `funnel didn't strengthen the downdraft (max ${maxRatio.toFixed(2)}×) — it should ride local speed`);
});
test('validateSiteWind: a good funnel passes, a bad one fails loud', () => {
const ok = validateSiteWind({ venturi: [{ x: -6, z: 4, axis: -1.08, gain: 1.4, radius: 5, sharp: 3 }] }, 's');
assert(ok.ok, `a valid venturi was rejected: ${ok.errors.join('; ')}`);
assert(validateSiteWind(null, 's').ok, 'a site with no wind block should validate');
assert(validateSiteWind({}, 's').ok, 'an empty wind block should validate');
assert(validateSiteWind({ venturi: [] }, 's').ok, 'an empty venturi list should validate');
for (const [label, bad] of [
['gain < 1 (a venturi speeds up, never slows)', { venturi: [{ x: 0, z: 0, gain: 0.7 }] }],
['gain absurd', { venturi: [{ x: 0, z: 0, gain: 9 }] }],
['no x,z', { venturi: [{ axis: 1, gain: 1.4 }] }],
['axis not finite', { venturi: [{ x: 0, z: 0, axis: 'south' }] }],
['radius <= 0', { venturi: [{ x: 0, z: 0, radius: 0 }] }],
['venturi not an array', { venturi: { x: 0 } }],
]) {
assert(!validateSiteWind(bad, 's').ok, `validateSiteWind accepted a bad funnel: ${label}`);
}
});
test('an empty venturi list is a perfect no-op (backyard_01 is untouched)', () => {
const def = storms.storm_02_wildnight;
const bare = createWindField(def);

View File

@ -807,3 +807,29 @@ export function validateStorm(def, name = 'storm') {
return { ok: errors.length === 0, errors };
}
// ---------- site wind validator (SPRINT10 site-as-data) ----------
// A site's `wind` block is hand-authored data like a storm, so it fails loud
// the same way. Lane A calls this at site load; setVenturi clamps defensively
// too, but a clamp hides a typo and this names it. Wind personality that lives
// in site JSON: venturi funnels (and later, per-site shelter overrides).
export function validateSiteWind(wind, name = 'site') {
const errors = [];
const bad = (m) => errors.push(`${name}.wind: ${m}`);
if (wind == null) return { ok: true, errors }; // a site with no wind block is fine
if (typeof wind !== 'object') { bad('must be an object'); return { ok: false, errors }; }
if (wind.venturi != null) {
if (!Array.isArray(wind.venturi)) bad('venturi must be an array of funnel zones');
else wind.venturi.forEach((v, i) => {
const at = `venturi[${i}]`;
if (!Number.isFinite(v.x) || !Number.isFinite(v.z)) bad(`${at} needs finite x,z (the throat centre)`);
if (v.axis != null && !Number.isFinite(v.axis)) bad(`${at}.axis must be radians (the direction the gap runs)`);
if (v.gain != null && !(v.gain >= 1)) bad(`${at}.gain must be >= 1 — a venturi speeds wind UP; use shelters to slow it (got ${v.gain})`);
if (v.gain != null && v.gain > 3) bad(`${at}.gain ${v.gain} is a wind tunnel, not a gap — cap ~2`);
if (v.radius != null && !(v.radius > 0)) bad(`${at}.radius must be > 0 metres`);
if (v.sharp != null && !(v.sharp >= 1)) bad(`${at}.sharp must be >= 1 (alignment falloff exponent)`);
});
}
return { ok: errors.length === 0, errors };
}

View File

@ -11,11 +11,14 @@
import * as THREE from '../vendor/three.module.js';
import {
createWindField, validateStorm, GUST, RAIN_TIME_COMPRESSION,
createWindField, validateStorm, validateSiteWind, GUST, RAIN_TIME_COMPRESSION,
hailBlockFor, stormStats, forecastFor,
} from './weather.core.js';
export { GUST, validateStorm, RAIN_TIME_COMPRESSION, hailBlockFor, stormStats, forecastFor };
export {
GUST, validateStorm, validateSiteWind, RAIN_TIME_COMPRESSION,
hailBlockFor, stormStats, forecastFor,
};
const kmh = (ms) => ms * 3.6;
const band = (b, fmt) => (b.hi - b.lo < 0.05 ? fmt(b.lo) : `${fmt(b.lo)}${fmt(b.hi)}`);