D's live replay caught landmine 1: bench.js read def.wind.venturi off the STORM def where a venturi never lives (it is SITE data, main.js:453), and the probes never attempted it — every site_02 bench number was funnel-off, the third harness to make B's Sprint-11 mistake. Verifying the fix exposed landmine 2: the tools remapped anchors to a static sway:()=>pos, freezing the gum tree whose sway is the dynamic load a tr1 rig eats (ablation on the $80 line's q4: 1.02 funnel-off/static, 1.03 funnel-on/static, 1.24 funnel-on/real anchors). Backyard numbers matched D's UI to the decimal precisely because p1..p4 are posts — both landmines are no-ops there. Fix: windForSite(stormDef, siteDef, anchors) in weather.js is the one shared wind builder (venturi + shelters, exactly main.js's wiring); all garden_bench tools build wind through it, fly world.anchors themselves via a re-pointable wind proxy, print the venturi in their headers, and drop the phantom 12 s settle (commit->attach->storm is one keypress, and the settle skewed storm sampling via SailRig's internal clock). c.test.js pins the helper with B's strictly-raises assert against the real shipped funnel (mutation-checked). Corrected numbers: $80 line wildnight 91.9 with q4 at 1.24-vs-1.2 rating - a knife edge the real game falls off (D UI: q3+q4 break, 39.8 TATTERED; D's UI is canonical there, and a corner over its rating is not a safe line either way). Buster verdict stands (95.2 bench / 97.9 D live). Icenight 9 DEAD 2/4. Bench still under-reads UI peaks ~5-15% on site_02 - residual in the pool, stated in THREADS. Separation-target site_02 example withdrawn to A. Selftest 341/0/0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
233 lines
9.7 KiB
JavaScript
233 lines
9.7 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, 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)}`);
|
||
|
||
/**
|
||
* How vague a night reads when it is `nightsOut` nights away — the week IS the
|
||
* forecast horizon, so tonight is exact (0) and the far end of the week is as
|
||
* vague as this game forecasts (1). Linear, because that maps the week onto
|
||
* forecastFor's documented 0..1 domain with nothing invented in between.
|
||
*
|
||
* For a five-night week: tomorrow reads at lead 0.25 — 75% confidence, a
|
||
* sustained band about ±8% wide. Visibly hedged, still worth rigging to.
|
||
*
|
||
* Lane A: this is the job sheet's missing argument. `forecastLines(def, 0)` is
|
||
* tonight; `forecastLines(tomorrowDef, leadFor(1, wk.nights))` is tomorrow's
|
||
* band. Nights beyond the week (or a one-night week) clamp to 1 and 0.
|
||
*
|
||
* @param {number} nightsOut 0 = tonight, 1 = tomorrow
|
||
* @param {number} [weekNights] nights in the week (5)
|
||
* @returns {number} lead, 0..1
|
||
*/
|
||
export function leadFor(nightsOut, weekNights = 5) {
|
||
if (!(nightsOut > 0)) return 0;
|
||
const horizon = weekNights - 1;
|
||
if (!(horizon > 0)) return 1; // a one-night week: anything but tonight is a guess
|
||
return Math.min(1, nightsOut / horizon);
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
/**
|
||
* The wind a YARD actually flies — storm def + the SITE's local effects,
|
||
* wired exactly as main.js does at every site load (setVenturi from
|
||
* siteDef.wind.venturi, tree shelters from the anchors). SPRINT13:
|
||
*
|
||
* THREE harnesses have now measured site_02 with the funnel switched OFF by
|
||
* building wind from the storm def alone — B's site_audit (Sprint 11, their
|
||
* sweep.selftest tells the story), C's garden_bench (Sprint 13, where it
|
||
* flipped the $80-line headline: 91.5 FULL funnel-off vs 39.8 TATTERED in
|
||
* D's live replay), and probe4, which never attempted it. The venturi lives
|
||
* in the SITE json, not the storm, so `def.wind.venturi` off a storm def
|
||
* LOOKS right and never fires. Any tool measuring a yard builds its wind
|
||
* HERE, or it is measuring a yard that doesn't ship. The strictly-raises
|
||
* assert in c.test.js goes red if the setVenturi line is ever dropped.
|
||
*
|
||
* @param {object} stormDef parsed storm JSON
|
||
* @param {object} [siteDef] parsed site JSON (loadSite) — its wind.venturi list
|
||
* @param {Array} [anchors] world anchors; trees become wind shelters
|
||
* @param {object} [opts] forwarded to createWind ({seed})
|
||
*/
|
||
export function windForSite(stormDef, siteDef, anchors = [], opts = {}) {
|
||
const wind = createWind(stormDef, opts);
|
||
wind.setVenturi(siteDef?.wind?.venturi ?? []);
|
||
wind.setSheltersFromTrees(anchors.filter((a) => a.type === 'tree'));
|
||
return wind;
|
||
}
|
||
|
||
/**
|
||
* @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;
|
||
}
|