- forecastHonest(def): "a storm the forecast can't describe honestly doesn't get offered" as a predicate, not a sentence — validator + measurable stats + the stone vocabulary ceiling (STONE_WORD_CEILING 2.0: past it, "golf ball" undersells the ice and the word ambushes where the band can't). The band- containment recomputes are disclosed as structural regression armour, not counted as coverage. - c.test walks NIGHTS ∪ POOL from the shipped data (the union, per A's stormsToPreload finding — a pool-only storm is covered the day it exists). Negative controls: a lying windchange, a cricket-ball stone, both refused by name. Lead-0 card pins: the offer card's gust/sustained figures parsed back out and matched against stormStats; a lead-0 offer must not hedge. - The exposure cross (the S14 pin pattern, third outing): my failure-route envelope (built world, anchor collateral keys through world.collateralFor, the gnome per scoreRun) vs A's exposureOf — item-level equality on ALL THREE yards: backyard 115, corner block 205, swing lawn 255. Zero unpriced keys. Teeth proven in-suite: strip the carport key and the cross reds. Selftest 490/0/0 (A's 485 + 5), browser-verified; board verified live on :8826 — both cards print the honest lead-0 lines and the crossed dollars. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
361 lines
16 KiB
JavaScript
361 lines
16 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)}`);
|
||
|
||
// SPRINT16 gate 3.3's word map, lifted to module scope in S17 so the honesty
|
||
// gate below audits THE map the card prints from — a predicate checking a copy
|
||
// of the vocabulary would agree with the copy forever, which is the exact drift
|
||
// this repo keeps getting bitten by. Words map hail.size units (1.0 ≈ a 1.5 cm
|
||
// stone): storm_03's 0.7 reads "pea" (its own comment's word), the soaker's
|
||
// 0.45 "fine pea", the wild night's 1.3 and the ice night's 1.4 read "golf
|
||
// balls" — the sizes cloth stops dead.
|
||
const stoneWord = (v) => (v < 0.6 ? 'fine pea' : v <= 0.9 ? 'pea' : v <= 1.2 ? 'marble' : 'golf ball');
|
||
|
||
/**
|
||
* Where the stone vocabulary stops being honest. "golf ball" is the map's
|
||
* biggest word and it is open-ended upward — a storm whose stones measure 2.6
|
||
* would still print "golf ball stones", underselling ice the way the numbers
|
||
* are forbidden to. The numeric bands stay truthful at any size (band() can't
|
||
* lie), but the card leads with the WORD, and a word that rules out what
|
||
* actually falls is the same ambush as a band that does. 2.0 is where the line
|
||
* sits: at 1.0 ≈ 1.5 cm, 2.0 ≈ 3 cm is real golf-ball territory, so the word
|
||
* holds all the way up to it; past it we are in cricket-ball country the map
|
||
* has no word for. Shipped max is the ice night's 1.4 — real headroom, hard
|
||
* ceiling. A storm above this is unofferable until the vocabulary grows a word
|
||
* for it, and that is a wording sprint, not a data tweak.
|
||
*/
|
||
export const STONE_WORD_CEILING = 2.0;
|
||
|
||
/**
|
||
* 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}`;
|
||
|
||
// SPRINT16 gate 3.3 — the stone and the rain RATE join the card (A prints
|
||
// them; this owns the wording, same split as every other line here). The
|
||
// stone is the fabric bet's whole argument: pea hail rattles through a
|
||
// 2 mm weave, anything bigger cannot — so the size word is what tells a
|
||
// player which fabric tonight wants, before a dollar is spent. The word map
|
||
// itself is module-scope now (S17) — the offer-honesty gate audits it.
|
||
const sz = f.hail.size;
|
||
const stones = f.hail.chance === 'none' ? '' : (
|
||
stoneWord(sz.lo) === stoneWord(sz.hi)
|
||
? `${stoneWord(sz.hi)} stones`
|
||
: `${stoneWord(sz.lo)}–${stoneWord(sz.hi)} stones`
|
||
);
|
||
|
||
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(),
|
||
// The gate-3.3 lines, separate fields so A's card can place them without
|
||
// re-deriving anything. Empty string = nothing to print, same rule as
|
||
// `confidence` below; both bands contain the truth at every lead.
|
||
rainRate: `rain to ${band(f.rainMmPerHour, i)} mm/hr`,
|
||
stones,
|
||
// 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,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* SPRINT17 gate 1 — CAN THE FORECAST DESCRIBE THIS STORM HONESTLY?
|
||
*
|
||
* The board's rule, stated in A's seam contract and enforced here: *a storm the
|
||
* forecast can't describe honestly doesn't get offered.* An offer card leads
|
||
* with my lines — if those lines can't tell the truth about a def, the def has
|
||
* no business on the board, however winnable the pairing is. c.test walks
|
||
* NIGHTS ∪ POOL through this, so a new pool entry (D's yard, arc-4 seeds)
|
||
* meets the gate at integration without anyone remembering to ask.
|
||
*
|
||
* Same contract shape as validateStorm: {ok, errors}, errors in English,
|
||
* recomputed FROM THE DEF — nothing here trusts the caller's provenance, so a
|
||
* hand-edited def fails on its own merits (the checkEnvelope rule).
|
||
*
|
||
* What has TEETH here, disclosed plainly (README: an assert that cannot fail
|
||
* is decoration):
|
||
* · validateStorm — structural lies (a windchange the dirCurve never
|
||
* delivers, overlapping gusts, a flatlining tail) really do fail.
|
||
* · the stone vocabulary ceiling — a def with stones past STONE_WORD_CEILING
|
||
* really does fail (the icenight at hail.size 2.6 is the negative control).
|
||
* · finiteness of the measured stats — a curve that produces NaN fails.
|
||
* What is STRUCTURAL and rides along as regression armour, not as a live gate:
|
||
* band-contains-truth and lead-0 exactness are guaranteed by forecastFor's
|
||
* band() today (lo=min(v,·), hi=max(v,·), width→0 at lead 0) — those loops can
|
||
* only fail on the day someone re-plumbs band(), which is precisely the day
|
||
* they should. Disclosed here and in THREADS rather than dressed up as
|
||
* coverage.
|
||
*
|
||
* @param {object} def parsed storm JSON
|
||
* @param {string} [name]
|
||
* @returns {{ok: boolean, errors: string[]}}
|
||
*/
|
||
export function forecastHonest(def, name = def?.name ?? 'storm') {
|
||
const errors = [];
|
||
const v = validateStorm(def, name);
|
||
if (!v.ok) errors.push(...v.errors);
|
||
if (!def || typeof def !== 'object' || !v.ok) return { ok: false, errors };
|
||
|
||
// The truth must be measurable before it can be told.
|
||
const s = stormStats(def);
|
||
for (const k of ['sustained', 'gustPeak', 'rainPeak', 'rainPeakMmPerHour',
|
||
'hailPeak', 'hailSeconds', 'hailSize']) {
|
||
if (!Number.isFinite(s[k])) {
|
||
errors.push(`${name}: stormStats.${k} is not finite — no honest line can be printed from it`);
|
||
}
|
||
}
|
||
|
||
// The vocabulary must reach the stone. Numbers band honestly at any size;
|
||
// the WORD is the card's opening argument and it must not undersell ice.
|
||
if (s.hailSeconds > 0 && s.hailSize > STONE_WORD_CEILING) {
|
||
errors.push(`${name}: hail.size ${s.hailSize} is past the stone vocabulary's ceiling `
|
||
+ `(${STONE_WORD_CEILING}) — "${stoneWord(s.hailSize)}" would undersell what falls; `
|
||
+ 'grow the word map before offering this storm');
|
||
}
|
||
|
||
// Recompute the band promises from the def (structural today — see header).
|
||
const contains = (b, val, what, lead) => {
|
||
if (val == null || b == null) return;
|
||
if (!(Number.isFinite(b.lo) && Number.isFinite(b.hi))) {
|
||
errors.push(`${name}: ${what} band at lead ${lead} is not finite`);
|
||
} else if (val < b.lo - 1e-9 || val > b.hi + 1e-9) {
|
||
errors.push(`${name}: ${what} band [${b.lo}, ${b.hi}] at lead ${lead} rules out the truth ${val}`);
|
||
}
|
||
};
|
||
for (const lead of [0, 0.5, 1]) {
|
||
const f = forecastFor(def, lead);
|
||
contains(f.sustained, s.sustained, 'sustained', lead);
|
||
contains(f.gustPeak, s.gustPeak, 'gustPeak', lead);
|
||
contains(f.rain, s.rainPeak, 'rain', lead);
|
||
contains(f.rainMmPerHour, s.rainPeakMmPerHour, 'rainMmPerHour', lead);
|
||
contains(f.hail.seconds, s.hailSeconds, 'hail.seconds', lead);
|
||
contains(f.hail.size, s.hailSize, 'hail.size', lead);
|
||
contains(f.changeAt, s.changeAt, 'changeAt', lead);
|
||
if (lead === 0) {
|
||
for (const [what, b] of [['sustained', f.sustained], ['gustPeak', f.gustPeak], ['rain', f.rain]]) {
|
||
if (b.lo !== b.hi) errors.push(`${name}: lead-0 ${what} band [${b.lo}, ${b.hi}] hedges — tonight is exact`);
|
||
}
|
||
if (s.hailSeconds > 0 && f.hail.chance === 'none') {
|
||
errors.push(`${name}: hails for ${s.hailSeconds.toFixed(1)}s but tonight's card says "none"`);
|
||
}
|
||
}
|
||
}
|
||
|
||
return { ok: errors.length === 0, errors };
|
||
}
|
||
|
||
// 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;
|
||
}
|