'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 }; } // ───────────────────────────────────────────────────────────────────────────── // THE OFFER BAND, and THE PAIRING HALF OF THE GATE (SPRINT17 gate 1) // // Landed ON TOP of forecastHonest above, by the other half of Lane C this // sprint — two sessions converged on gate 1 and split it cleanly without // meaning to. `forecastHonest` answers "can the forecast describe this STORM", // which is the storm-side gate and stays exactly as it landed. These two add // the parts it does not reach: WHAT THE CARD ACTUALLY PRINTS, and WHICH YARD // the storm may be printed over. Nothing above this line was changed. // ───────────────────────────────────────────────────────────────────────────── /** * THE HONEST BAND FOR AN OFFER CARD — an ORDERED LIST of lines, not a bag of * named fields, and the shape is the whole point. * * ⚠️ **WHY A LIST, when `forecastLines` already returns every string a card * could want.** A card that reads named fields prints the fields its author * remembered. A's board shipped `f.wind` and `f.stones` — a reasonable pair — * and that pair MEASURABLY LIES ABOUT NIGHT 7: * * | night 7's two offers | wind line | stones line | * |---|---|---| * | the soaker (scripted) | gusts to ~55 km/h | fine pea stones | * | the early buster (alt) | gusts to ~76 km/h | pea stones | * * On both printed lines the soaker is the SOFTER job. It is not: it delivers * **14.9 s of hail against 2.4 s, and 44 mm/hr of rain against 16.5**. The * soaker's whole design is that its wind reads gentle while its hail is the * trap (C, S16 gate 3.1) — so a board printing only wind and stone size * recommends the trap, in the storm's own voice, before a dollar is spent. * * The fix is not "also print rainRate". The fix is that the FORECAST decides * which facts a night carries and hands over all of them, so the next fact * added to a storm cannot be silently dropped by a card that predates it. Same * construction, and the same reasoning, as A's `stormsToPreload(nights, pool)` * split: an omission that is possible will eventually happen. * * ⚠️ **It never renders MORE than the truth either.** A hail line appears if and * only if the storm hails — "hail: none" on a hail-free night teaches that the * absence of a hail line means nobody checked. * * @param {object} def parsed storm JSON * @param {number} [lead] 0..1 — the BOARD passes 0, and only 0: both offers are * for TONIGHT. You are choosing which yard to stand in, not which night to * work, and a hedged board would invent uncertainty the job sheet contradicts * one card later. * @returns {{key:string, text:string}[]} reading order: wind, hail, rain, * change, confidence. `key` is for styling; `text` is the whole printed line. */ export function offerBand(def, lead = 0) { const f = forecastLines(def, lead); const ff = forecastFor(def, lead); const t = f.truth; const i = (v) => v.toFixed(0); const lines = [{ key: 'wind', text: f.wind }]; // Hail, with its DURATION. `chance` at lead 0 is a duration threshold wearing // a probability word (hailSeconds > 6 ⇒ 'likely'), which is honest enough on // a job sheet and far too coarse on a board where two hail nights sit side by // side: 'likely' covers both the soaker's 15 s and the ice night's 21 s, and // 'possible' covers the buster's 2.4 s. The seconds are what a tradie buys // steel against, so the seconds print. if (f.stones) { lines.push({ key: 'hail', text: `hail ${f.hail.chance} · ${f.stones} · ${bandStr(ff.hail.seconds, i)}s of it`, }); } // The rain RATE, always — the line A's card had no field for, and half of the // soaker's trap. mm/hr is the unit the ponding model reads, so the card and // the sim agree on what "heavy" means instead of each having an opinion. lines.push({ key: 'rain', text: f.rainRate }); // The change, always — including "no change forecast", which is information, // not filler. storm_03_southerly and storm_03b_earlybuster are IDENTICAL in // every other number this band prints (sustained 13.0, gustPeak 21.4/21.2, // rain 16.5 mm/hr, hail 2.4 s at size 0.70) and differ ONLY in when the change // lands, 30 s against 18 s. The board offers exactly that pair — the buster is // the drawn alternative on nights 5 and 7. Drop this line and the board offers // two jobs whose weather it has just claimed to describe, described // identically, and calls it a choice. lines.push({ key: 'change', text: t.changeAt == null ? 'no change forecast' : (lead > 0 ? `southerly change ${bandStr(ff.changeAt, i)}s` : `southerly change at ${i(t.changeAt)}s`), }); if (f.confidence) lines.push({ key: 'confidence', text: f.confidence }); return lines; } /** `band` under its own name — the module-scope helper, reused rather than retyped. */ function bandStr(b, fmt) { return band(b, fmt); } /** * ⚖️ **THE PAIRING LAW — which yards a storm may be OFFERED over.** * * `forecastHonest` above asks whether the forecast can describe a STORM. This * asks the other half of SPRINT17 gate 1's rule, the half that is a fact about * GEOMETRY × STORM rather than about wording: a band can be perfectly honest * about a storm and still be an invitation to a night nobody can work. * * ⚠️ **These three were three prose comments before they were a rule.** A's POOL * carries them as a comment listing what is "deliberately NOT here" — and this * repo's standing verdict on that arrangement is A's own, on CONSTRAINT_KIND: * an unenforced enum is decoration, and the carport shipped typed as a post for * a sprint behind a JSDoc comment saying it couldn't. A comment cannot fail when * somebody adds the sixth pool entry. Promoted to data, checked, red in the suite. * * **A — two of these three are YOUR reasons and are recorded as yours.** I have * not re-measured a separation pin or a beyond-saving flag; I have moved your * stated grounds into a structure that goes red. Reword freely — the mechanism * is what I am defending, not the prose. * * An absent key means UNRESTRICTED, deliberately: this table records refusals * somebody PAID FOR, and inventing one for a pairing nobody flew is the same * offence in the other direction. */ export const PAIRING_LAW = Object.freeze({ storm_06_soaker: { sites: ['site_02_corner_block'], owner: 'C', why: 'MEASURED (C, S16 gate 3.1, both shipped yards flown through gardenfly before the ' + 'storm shipped): backyard_01\'s buyable geometry caps hail cover over the bed at ' + '~31%, and its full-cover quads pond-tear at 3.3–3.7 kN in this mild wind on BOTH ' + 'fabrics — best membrane line 37.5 against cloth 17.3, a fabric bet with no win in ' + 'it. The soaker\'s forecast reads "fine pea stones · rain to 44 mm/hr" over there ' + 'exactly as it does over site_02, and over there that band is an invitation to a ' + 'night nobody can work. The swing lawn is not measured under it at all, which is ' + 'the same refusal for a cheaper reason.', }, storm_02_wildnight: { sites: ['backyard_01'], owner: 'A', why: 'A (S17 gate 1, POOL): its separation is PINNED to backyard_01\'s site data. Fly it ' + 'elsewhere and the pin describes a night nobody plays.', }, storm_02b_icenight: { sites: ['backyard_01'], owner: 'A', why: 'A (S17 gate 1, POOL), on A\'s S13 measurement: `gardenBeyondSaving` is a fact about ' + 'THAT BED under that ice, not a property of the storm. Offered over another yard the ' + 'night would carry the excuse without the evidence.', }, }); /** * May this storm be offered over this yard? * @returns {string|null} the refusal in English, or null when the pairing is offerable */ export function pairingRefusal(stormKey, siteKey) { const law = PAIRING_LAW[stormKey]; if (!law || !law.sites) return null; if (law.sites.includes(siteKey)) return null; return `${stormKey} over ${siteKey}: not an offerable pairing — ${law.why} ` + `(offerable: ${law.sites.join(', ')}; ruled by ${law.owner})`; } /** * Every pairing refusal in a list of candidate nights, as data. * * ⚠️ Deliberately NOT a filter. Silently dropping a bad entry leaves a morning * with one offer and no explanation — a runtime surprise wearing a fix's * clothes. The caller (c.test today; a `board.js` load-time assert the day A * wants one) decides how loud to be, and gets the measurement in the message. * * @param {Array<{id?:string, storm:string, site:string}>} pairings * @returns {{id:string, why:string}[]} empty = every pairing is offerable */ export function pairingRefusals(pairings = []) { const out = []; for (const p of pairings) { const why = pairingRefusal(p.storm, p.site); if (why) out.push({ id: p.id ?? `${p.storm}×${p.site}`, why }); } return out; } // 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; }