A venturi is a shelter's opposite — a gap between buildings SPEEDS the wind up when it blows along the gap's axis. Where a tree shadow depends on being downwind of the tree, a venturi depends on the wind being ALIGNED with a fixed axis the SITE owns, so it fires only when the storm's direction swings to match: the corner block is calm until the southerly comes through, then it screams. That interplay is exactly SPRINT9's brief — funnel geometry is the site's, the wind that funnels is the storm's. Extends the existing shelter system rather than bolting on a parallel one: speedAt/vecAt/verticalAt all route through one localHoriz, which now folds venturiFactor in alongside spatialFactor and shelterFactor. The downdraft rides local speed, so a funnelled wind gets a proportionally stronger downdraft for free — physically right. `setVenturi(list)` on the wind + router (A's "add it to the router too" rule; the tripwire is green). Inert on an empty list, so backyard_01 is byte-identical — asserted. Multiplier is ALWAYS ≥ 1 (a venturi accelerates, never shelters), and it's C1-continuous through the change, so it can't snap a corner — both asserted (aligned wind boosts 1.5×, crosswind exactly 1.0×, max frame jump 0.50 m/s). Data lives in site JSON (A's schema, arriving with the extraction) — this is the physics + the setter, ready to wire. Proposed shape flagged for A in THREADS. Also fixed a stale comment: storm_02's _gusts_comment still said "HELD at 0.12 for now" from Sprint 3 while the value has been 0.45 since Sprint 4 — a lying comment in a repo that reads comments as canon. Now records the real history and the pending paired-0.40 flip. Selftest 278/0/0; node 55/0/0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
179 lines
7.1 KiB
JavaScript
179 lines
7.1 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 };
|
||
|
||
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;
|
||
}
|