diff --git a/web/world/js/tests/weather.selftest.js b/web/world/js/tests/weather.selftest.js index b4bd053..5b5da0e 100644 --- a/web/world/js/tests/weather.selftest.js +++ b/web/world/js/tests/weather.selftest.js @@ -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); diff --git a/web/world/js/weather.core.js b/web/world/js/weather.core.js index 1b2e478..b21cd0a 100644 --- a/web/world/js/weather.core.js +++ b/web/world/js/weather.core.js @@ -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 }; +} diff --git a/web/world/js/weather.js b/web/world/js/weather.js index b49b92d..5b70807 100644 --- a/web/world/js/weather.js +++ b/web/world/js/weather.js @@ -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)}`);