'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, validateSiteWind, GUST, RAIN_TIME_COMPRESSION, hailBlockFor, stormStats, forecastFor, } from './weather.core.js'; 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)}`); /** * The forecast card's two stat lines, already worded — DESIGN.md's partial * information made visible. Lane A owns the card; this owns the numbers, so the * card stays one call instead of re-deriving the storm inline. * * `lead` is how far out the night is: 0 = tonight (exact numbers, reads exactly * as the card always has), 1 = the far end of the week (wide bands, low * confidence). The bands ALWAYS contain the truth — a forecast may be vague but * it must never rule out what actually happens, or a player who rigs for the top * of the stated range gets ambushed. * * Numbers are MEASURED (stormStats), not estimated: the card's old inline * `baseCurve peak + powBase + powRamp` read 30 m/s for storm_02, which really * gusts to 32.3, because gust power is drawn per gust and rides a ramp. * * @param {object} def parsed storm JSON * @param {number} [lead] 0..1 * @returns {{name, night, wind, rain, confidence, hail, truth}} */ export function forecastLines(def, lead = 0) { const f = forecastFor(def, lead); const i = (v) => v.toFixed(0); const sustained = band(f.sustained, i); const gusts = band({ lo: kmh(f.gustPeak.lo), hi: kmh(f.gustPeak.hi) }, i); const sustainedKmh = band({ lo: kmh(f.sustained.lo), hi: kmh(f.sustained.hi) }, i); // rain reads as a word, and a vague forecast hedges across two const word = (v) => (v >= 0.8 ? 'heavy' : v >= 0.4 ? 'steady' : 'light'); const rainWord = word(f.rain.lo) === word(f.rain.hi) ? word(f.rain.hi) : `${word(f.rain.lo)}–${word(f.rain.hi)}`; const change = f.changeAt ? `· southerly change ${lead > 0 ? band(f.changeAt, i) + 's' : `at ${i(f.changeAt.lo)}s`}` : '· no change forecast'; const hail = f.hail.chance === 'none' ? '' : `· hail ${f.hail.chance}`; return { name: (def.name ?? '').replace(/_/g, ' ').toUpperCase(), night: (def.sky?.night ?? (def.sky?.darkness ?? 0) > 0.6), wind: `sustained to ${sustained} m/s (${sustainedKmh} km/h) · gusts to ~${gusts} km/h`, rain: `rain ${rainWord} ${change} ${hail}`.trim(), // Only worth showing when it isn't tonight — "CONFIDENCE 100%" is noise. confidence: lead > 0 ? `forecast confidence ${Math.round(f.confidence * 100)}%` : '', hail: f.hail, truth: f.truth, }; } // 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, }))); }, /** * A site's wind funnels (SPRINT9 site_02). Lane A: call with the site JSON's * `wind.venturi` after building the yard. Empty/absent = no funnels, so * backyard_01 reads exactly as it always has. * @param {Array<{x,z,axis,gain,radius,sharp}>} list */ setVenturi(list) { field.setVenturi(list); return wind; }, get venturi() { return field.venturi; }, /** 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; }