HardYards/web/world/js/weather.js
m3ultra 0d05cb1936 Land hailBlockFor(size, porosity) — the honest fabric-hail rule for B
B asked for the hail ruling before coding fabric choice; this is it as code.
The physics: porosity is about AIR (blows through → less wind load) and WATER
(drains → no ponding), 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 the finest pea hail, which IS small enough to rattle
through an open weave. So: membrane stops everything; porous stops everything
except the smallest stones.

Verified across the storm sizes: shade cloth (0.3) fully blocks the wild-night
1.3/1.4 stones and leaks 26% of storm_03's 0.7 pea hail; an open 0.5 weave
leaks 90% of pea hail but still catches the big ice. That makes the fabric
choice cost you exactly on the mild-hail nights and stay honest on the ice
nights — a real tradeoff without a physics lie.

The assert also proves the integration end to end so nobody wires it blind: a
membrane-covered bed takes ~0 hail on storm_03, a porous-covered one takes 0.62
— the choice is genuinely non-trivial. Formula for whoever wires the garden
drain: coveredHail = hailShadowOver(bed) * hailBlockFor(hailSize, sail.porosity).

Pure helper (no THREE), exported through weather.js alongside stormStats/
forecastFor; NOT on the wind contract, so no router change. Selftest 263/0/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 11:30:37 +10:00

118 lines
4.4 KiB
JavaScript

'use strict';
// SHADES — Lane C — weather: the wind field everyone samples.
//
// Implements the contracts.js wind surface (PLAN3D §4):
// wind.sample(pos, t) -> Vector3 m/s, includes gusts & local effects
// wind.gustTelegraph(t) -> {eta, dir, power} | null
//
// All the maths lives in weather.core.js (pure, no imports). This file is just
// the THREE adapter + storm loading, so the sim stays node-testable and the
// determinism rule can't be broken by accident.
import * as THREE from '../vendor/three.module.js';
import {
createWindField, validateStorm, GUST, RAIN_TIME_COMPRESSION,
hailBlockFor, stormStats, forecastFor,
} from './weather.core.js';
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/.
// import.meta.url is right under both, and under whatever Lane A does next.
const STORM_DIR = new URL('../data/storms', import.meta.url).href;
/** Fetch + validate a storm def. Throws loud on bad data — storms are content. */
export async function loadStorm(name, dir = STORM_DIR) {
const url = `${dir}/${name}.json`;
const res = await fetch(url);
if (!res.ok) throw new Error(`weather: cannot load ${url} (${res.status})`);
const def = await res.json();
const { ok, errors } = validateStorm(def, name);
if (!ok) throw new Error(`weather: ${url} is invalid:\n ${errors.join('\n ')}`);
return def;
}
/**
* @param {object} def parsed storm JSON
* @param {object} [opts] {seed} — same seed + same def = same storm, every run
* @returns the `wind` object from contracts.js
*/
export function createWind(def, opts = {}) {
const field = createWindField(def, opts);
const scratch = { x: 0, y: 0, z: 0 };
const wind = {
/**
* Wind velocity at a world position, m/s.
* @param {THREE.Vector3} pos
* @param {number} t storm time, seconds
* @param {THREE.Vector3} [out] pass one to avoid allocating — sail.js
* samples per-face per-frame, so this matters
*/
sample(pos, t, out) {
const v = out || new THREE.Vector3();
field.vecAt(pos.x, pos.z, t, scratch);
return v.set(scratch.x, scratch.y, scratch.z);
},
/** Scalar speed — for HUD, rain, grass. Cheaper than sample(); no allocation. */
speedAt(pos, t) {
return field.speedAt(pos.x, pos.z, t);
},
/** {eta, dir, power} while a gust is inbound but hasn't risen yet, else null. */
gustTelegraph(t) {
return field.telegraph(t);
},
/**
* Register wind shadows (trees, house). Lane A: call after the yard is built.
* Until then there are simply no shadows — nothing breaks.
* @param {Array<{x,z,radius,strength,length}>} list
*/
setShelters(list) { field.setShelters(list); return wind; },
/** Convenience: take shadows straight off world.anchors' tree entries. */
setSheltersFromTrees(trees, o = {}) {
return wind.setShelters(trees.map((tr) => ({
x: tr.pos ? tr.pos.x : tr.x,
z: tr.pos ? tr.pos.z : tr.z,
radius: o.radius ?? tr.radius ?? 3,
strength: o.strength ?? 0.45,
length: o.length ?? 14,
})));
},
/** Storm events fired in (a,b] — poll with (t-dt, t). Deterministic. */
eventsBetween(a, b) { return field.eventsBetween(a, b); },
/** 0..1 rain intensity — drives drop count and opacity. */
rainAt(t) { return field.rainAt(t); },
/** 0..1 hail intensity (SPRINT5 decision 13). Zero for a hail-free storm. */
hailAt(t) { return field.hailAt(t); },
/** Stone-size scalar — audio pitch, visual scale, damage weight. */
get hailSize() { return field.hailSize; },
/** Rain rate in real-world mm/hr. Ponding (decision 10) reads this. */
rainMmPerHour(t) { return field.rainMmPerHour(t); },
/** Real-world mm of water delivered over (t0,t1]. Multiply by
* RAIN_TIME_COMPRESSION cloth-side — see weather.core. */
rainDepthMm(t0, t1) { return field.rainDepthMm(t0, t1); },
/** Direction (radians, XZ plane from +X toward +Z) ignoring local effects. */
dirAt(t) { return field.dirAt(t); },
get duration() { return field.duration; },
get gusts() { return field.gusts; },
get def() { return field.def; },
get seed() { return field.seed; },
core: field,
};
return wind;
}