diff --git a/web/world/js/tests/weather.selftest.js b/web/world/js/tests/weather.selftest.js index 79a4f90..f49ca8f 100644 --- a/web/world/js/tests/weather.selftest.js +++ b/web/world/js/tests/weather.selftest.js @@ -12,7 +12,7 @@ import { createWindField, validateStorm, gustEnvelope, GUST, RAIN_TIME_COMPRESSION, - stormStats, forecastFor, + stormStats, forecastFor, hailBlockFor, } from '../weather.core.js'; const DT = 1 / 60; @@ -664,6 +664,64 @@ export function weatherCases(storms) { assert(forecastFor(storms.storm_03_southerly, 0).hail.chance === 'possible', 'storm_03 hails mildly'); }); + // ---- 13. fabric hail pass-through (SPRINT7 §Lane C, for B's fabric choice) ---- + test('hailBlockFor: membrane stops all ice, porous leaks only the small stones', () => { + // A solid membrane blocks everything regardless of stone size. + for (const size of [0.4, 0.7, 1.0, 1.3, 1.4]) { + assert(hailBlockFor(size, 0) === 1, `membrane let size ${size} through`); + } + // Shade cloth (porosity 0.3) fully blocks the wild-night stones (1.3+) but + // leaks the finest pea hail — the honest, size-gated difference. + assert(hailBlockFor(1.3, 0.3) > 0.99, 'shade cloth failed to stop a 1.3 storm stone'); + assert(hailBlockFor(1.4, 0.3) > 0.99, 'shade cloth failed to stop a 1.4 ice-night stone'); + const pea = hailBlockFor(0.7, 0.3); + assert(pea > 0.4 && pea < 0.9, `shade cloth blocks ${pea.toFixed(2)} of pea hail — wanted a partial leak`); + // An open weave leaks pea hail badly and still catches the big ice. + assert(hailBlockFor(0.7, 0.5) < 0.3, 'an open weave should let most pea hail through'); + assert(hailBlockFor(1.3, 0.5) > 0.8, 'even an open weave should mostly stop a 1.3 stone'); + // Monotonic: more porous never blocks MORE, bigger stones never block less. + for (const size of [0.6, 1.0, 1.4]) { + assert(hailBlockFor(size, 0.5) <= hailBlockFor(size, 0.3) + 1e-9, 'more porous blocked more hail'); + } + for (const por of [0.3, 0.5]) { + assert(hailBlockFor(0.5, por) <= hailBlockFor(1.2, por) + 1e-9, 'a bigger stone passed more easily'); + } + metrics['fabric.shadecloth.blocksPeaHail'] = +pea.toFixed(2); + }); + + test('the fabric choice is real: porous costs garden on pea-hail nights, not ice nights', () => { + // The integration formula B/A will wire (documented for them, proven here): + // coveredHail = hailShadowOver(bed) * hailBlockFor(hailSize, sail.porosity) + // exposure = hailAt(t) * (1 - coveredHail) + // A membrane over the bed protects it fully; a porous cloth over the same bed + // protects it fully on an ice night and leaks on a pea-hail one. If those two + // came out equal, the choice would be dead — this is the assert that it isn't. + const gardenHail = (stormName, porosity) => { + const def = storms[stormName]; + const f = createWindField(def); + const size = f.hailSize; + let dmg = 0; + const COVER = 1; // bed fully under the cloth + for (let t = 0; t <= f.duration; t += DT) { + const covered = COVER * hailBlockFor(size, porosity); + dmg += f.hailAt(t) * (1 - covered) * DT; + } + return dmg; + }; + // ice night: membrane and shade cloth both fully protect a covered bed + const iceMembrane = gardenHail('storm_02b_icenight', 0); + const icePorous = gardenHail('storm_02b_icenight', 0.3); + assert(Math.abs(iceMembrane - icePorous) < 0.2 && iceMembrane < 0.5, + `on the ice night the fabrics should agree at ~0 (both block big ice): ${iceMembrane.toFixed(2)} vs ${icePorous.toFixed(2)}`); + // pea-hail night: the covered bed under porous cloth takes real damage + const peaMembrane = gardenHail('storm_03_southerly', 0); + const peaPorous = gardenHail('storm_03_southerly', 0.3); + metrics['fabric.storm03.gardenHail.membrane'] = +peaMembrane.toFixed(2); + metrics['fabric.storm03.gardenHail.porous'] = +peaPorous.toFixed(2); + assert(peaMembrane < 0.1, 'a membrane over the bed should take ~no pea hail'); + assert(peaPorous > peaMembrane + 0.3, `porous should leak real pea hail: ${peaPorous.toFixed(2)} vs ${peaMembrane.toFixed(2)}`); + }); + return { cases, metrics }; } diff --git a/web/world/js/weather.core.js b/web/world/js/weather.core.js index 9bdc461..cc72bf5 100644 --- a/web/world/js/weather.core.js +++ b/web/world/js/weather.core.js @@ -188,6 +188,34 @@ export function hailBurstEnvelope(dt, ramp, hold, fade, peak) { return 0; } +/** + * What fraction of hail a cloth of this porosity STOPS (0..1). Lane B's fabric + * choice (SPRINT7) reads this; the honest answer to their question. + * + * The ruling first: porosity is about AIR and WATER, not ice. A knitted shade + * cloth's gaps are ~1-3 mm; a damaging hailstone is 6-45 mm, so it can't pass a + * mesh an order of magnitude finer than itself — porous and membrane block the + * big stones identically. The ONE true difference is at the bottom of the size + * range: the finest pea hail IS small enough to rattle through an open weave. + * So a solid membrane stops everything, and a porous cloth stops everything + * except the smallest stones — which is a real fabric tradeoff without a physics + * lie, and it makes porous cost you exactly on the mild-hail nights while staying + * honest on the ice nights. + * + * `size` is in hail.size units (1.0 ≈ a 1.5 cm stone; storms run 0.7 pea → 1.4). + * `porosity` matches SailRig's (0 = membrane, ~0.3 = knitted shade cloth). + */ +export function hailBlockFor(size, porosity = 0) { + if (!(porosity > 0)) return 1; // solid membrane stops all ice + // Effective aperture of the weave, in size units. A 70%-shade knit (porosity + // ~0.3) reads ~0.6 — it leaks only the finest hail; a very open 0.5 weave + // reads ~1.0 and lets small stones through too. + const aperture = porosity * 2; + // A stone well above the gap is stopped dead; one well below sails through; + // smoothstep the transition around the aperture. + return smoothstep(aperture * 0.5, aperture * 1.5, size); +} + // ---------- the field ---------- /** * @param {object} def parsed storm JSON (see data/storms/*.json) diff --git a/web/world/js/weather.js b/web/world/js/weather.js index 37e5506..45fb323 100644 --- a/web/world/js/weather.js +++ b/web/world/js/weather.js @@ -10,9 +10,12 @@ // determinism rule can't be broken by accident. import * as THREE from '../vendor/three.module.js'; -import { createWindField, validateStorm, GUST, RAIN_TIME_COMPRESSION } from './weather.core.js'; +import { + createWindField, validateStorm, GUST, RAIN_TIME_COMPRESSION, + hailBlockFor, stormStats, forecastFor, +} from './weather.core.js'; -export { GUST, validateStorm, RAIN_TIME_COMPRESSION }; +export { GUST, validateStorm, RAIN_TIME_COMPRESSION, hailBlockFor, stormStats, forecastFor }; // Resolved against this module, not the server root: server.py serves the repo // root (so the 2D prototype stays reachable), but the demo bench serves web/.