The model landed in Sprint 6 and nothing ever called it, which is why this kept
getting carried: `forecastFor` existed while the card went on estimating inline.
So this is the wiring, not more API.
`forecastLines(def, lead)` turns a storm into the card's two stat lines, already
worded in A's voice. lead 0 = tonight = exact numbers (reads exactly as the card
always has); further out it hedges into ranges and prints a confidence line. The
bands always contain the truth — a forecast may be vague, never wrong, or a
player who rigs for the top of the stated range gets ambushed.
Two things the card gains beyond the bands:
- The numbers are now MEASURED. The inline estimate (`baseCurve peak + powBase
+ powRamp`) read 30 m/s for storm_02; it really gusts to 32.3, because gust
power is drawn per gust and rides a ramp. The wild night now says 116 km/h
because that is what hits you.
- The card finally MENTIONS HAIL. Hail has been the garden score since decision
13 and the forecast never said so — you were being scored on something the
card didn't tell you was coming. Now: "hail likely" on the wild night, silent
on the gentle one, "possible" when it's too far out to promise.
hud.js is Lane A's file and A is meant to build the week uninterrupted, so this
is deliberately a 3-line swap that keeps their markup, classes and wording — the
week's card rewrite can carry it or drop `forecastLines` and call `forecastFor`
directly. `lead` defaults to 0, so nothing changes until the week passes one.
Verified live at both ends: tonight renders exact; five nights receding into the
week render 0%-confidence hedges ("16–28 m/s · gusts ~72–144 km/h · hail
possible"). Selftest 265/0/0 + gate 0's 2 skips.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
170 lines
6.8 KiB
JavaScript
170 lines
6.8 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,
|
||
})));
|
||
},
|
||
|
||
/** 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;
|
||
}
|