'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'); } // ⚠️ AND THE OTHER END. [SPRINT18 — the S17 filing, corrected by measuring it] // // I filed this forward in S17 as "stoneWord has no FLOOR: a def with // hail.size 0 words itself 'fine pea stones'". **That hole was already shut and // I had not checked one layer up**: validateStorm refuses `size <= 0` outright, // forecastHonest runs the validator FIRST and returns early, so a floor check // here would have been dead code. Recorded rather than quietly dropped — // filing a hole without testing the gate above it is the same mistake as // trusting a comment. // // Probing the layer that DOES pass turned up the real one, and it is worse: // `hd.size != null &&` — the validator only checks a size that is PRESENT, and // `hailSize` reads `hailDef.size ?? 1`. So a storm that hails with no authored // size passes every gate and the card prints **"marble stones"**: the middle of // the vocabulary, stated as fact, authored by nobody. Measured on the soaker — // delete one field and its real 0.45 "fine pea" becomes a confident "marble", // a stone 2.2× bigger, on the single axis the fabric bet is decided on. The // validator makes this exact argument two lines above about a hail block with // nothing to fire it: "that's a typo, not a design, so say so". // // It matters more now than in S17: the dispatch's stone line is the ONLY // description an emergency night gets, with no band beside it to hedge in. if (s.hailSeconds > 0 && def.hail?.size == null) { errors.push(`${name}: hails for ${s.hailSeconds.toFixed(1)}s but hail.size is not authored — ` + `it defaults to ${s.hailSize} and the card would state "${stoneWord(s.hailSize)} stones" ` + 'as fact on the axis the fabric bet turns on; a hailing storm must declare its stone'); } // 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; } // ───────────────────────────────────────────────────────────────────────────── // THE DISPATCH — SPRINT18 gate 1, C's seam (A's §8). // // ⚖️ **THE CALLOUT HAS NO FORECAST, AND THAT IS A WORDING PROBLEM BEFORE IT IS // A FEATURE.** Every other night in this game is sold off paper: `offerBand` // prints a forecast, and its honesty rule is the one S17 settled — the band may // be vague, it may not rule out what happens, and it may not omit a fact it // holds. An emergency callout has no paper at all. You are standing in the // weather at t=30 with a phone call and a set of eyes. // // ⚠️ **THE INVERSION, which is the whole design.** The forecast's sin is // OMISSION — the board lied about night 7 by printing two of the fields it had. // The dispatch's sin is the opposite: INVENTION. Standing in a yard you know the // wind on your back to the km/h, better than any forecast ever told you — so the // present tense is EXACT here where the card was banded. What you do not know is // everything the bureau would have told you: how long this has to run, what the // peak will be, whether a southerly is coming at t=48. Printing any of that // would be inventing paper that does not exist. // // So the read is ruled by TENSE, and the permitted set is not "less than a // forecast" — it is a different, sharper set: // · **NOW** — exact, unhedged. You are standing in it. // · **THE VISIBLE IMMINENT** — a gust front crossing the grass (DESIGN.md line // 149: "readable — a wave of motion crosses the grass and trees a beat // before it hits"). The telegraph window is 1.5 s and that is the whole // horizon you have earned by looking. // · **THE PAST, AS WRITTEN ON THE YARD** — and this is the one that makes the // design work. You were not here for the first 30 seconds, so you have no // memory of them; but hail lying in the grass and water standing on the flat // are a RECORD of them, and reading a yard for what already went wrong is // literally this game's other job type (DESIGN.md's cowboy-job line: "half // the puzzle is finding what's about to fail"). Integrals over [0, t] are // honest. Anything over (t, ∞) is not. // · **THE FUTURE** — forbidden. Not hedged, not banded. Absent. // // ⚠️ **AND THE ADMISSION IS MANDATORY, which is the subtle half.** On a forecast // card a missing hail line means THE STORM DOES NOT HAIL — `offerBand` rules it // so, deliberately, and seven nights have taught the player to read it that way. // On a dispatch a missing hail line means NOBODY KNOWS. Those two cannot look // identical on a document, and silence cannot distinguish them: a player fresh // off six forecast cards will read an absent line as a reassurance nobody gave. // So the read SAYS it has no paper, first, and says what it cannot know, last. // That is the honest answer to "what does honest mean when the honest answer is // nobody told you" — you say the words "nobody told you", on the card, in the // order a person reads. // // ⚠️ **THE IGNORANCE LINE IS UNCONDITIONAL, and it has to be.** A tempting // version says "a change may still be coming" only when `def` HAS a change event // — which would make the line's PRESENCE the very fact it claims not to have. // The same trick in reverse to `offerBand`'s "hail line iff it hails": there, // presence is information the forecast legitimately holds; here, presence would // be information the observer cannot possibly hold. Constant text, every storm, // every t. It leaks nothing because it says nothing about the weather — it is a // statement about YOUR INFORMATION, and those you may always make. // ───────────────────────────────────────────────────────────────────────────── /** Below this, no gust is "on it" — the base wind is all you are feeling. */ const LULL_GUST = 0.4; // m/s of live gust envelope /** Above this the wind is visibly swinging, rad/s, measured over ±0.5 s. */ const VEER_RATE = 0.06; /** Hail-seconds already delivered before stones are lying where you can see them. */ const HAIL_ON_GROUND = 1.5; /** Real mm of rain already delivered before water is standing on the flat. */ const WATER_STANDING = 6; /** * ⚖️ **WHAT THE SKY AND THE GRASS TELL YOU WHEN NO PAPER DOES.** * * The dispatch's weather half — A owns `emergency.dispatch` (the phone call) and * this is everything under it. Same construction as `offerBand`: an ORDERED list * of `{key, text}`, so the card MAPS it and a fact added here cannot be dropped * by a card that predates the fact. Reading order is the order a tradie takes the * yard in — what you haven't got, what's hitting you, what's coming, what has * already happened, and what you still don't know. * * Reads ONLY the permitted tenses (see the divider above). It never touches * `stormStats` — that is the whole-storm truth, the thing the bureau would have * measured, and `gustPeak`/`hailSeconds`/`changeAt` are precisely the facts a * callout is not entitled to. Pinned two ways: `storm_03_southerly` and * `storm_03b_earlybuster` are identical in every number except when the change * lands (30 s against 18 s), so a read taken BEFORE both changes must be * character-identical — and one taken between them must DIFFER, because by then * the swing is something you can feel. * * @param {object} def parsed storm JSON * @param {number} t storm seconds at which you got out of the ute (`arriveAt`) * @returns {{key:string, text:string}[]} nopaper, wind, veer?, front?|lull?, * hail?, rain?, ground?, unknown */ export function midStormRead(def, t = 0) { const f = createWindField(def); const lines = [{ key: 'nopaper', text: 'no forecast on this one — nobody briefed it' }]; const i = (v) => v.toFixed(0); // NOW, exact. Speed is the one number a body measures better than a bureau: it // is on your back. Unhedged on purpose — the contrast with a forecast card is // the point, not an oversight. lines.push({ key: 'wind', text: `${i(kmh(f.uniformSpeed(t)))} km/h on it right now` }); // NOW: is it swinging? A wind backing round is felt and seen (the trees go // first), so it is fair game — and it is the fact that matters most on an // inherited rig, because a rig built for a westerly is loaded backwards by a // southerly. Central difference over ±0.5 s: short enough to be "right now", // wide enough not to read curve noise as a swing. It reports only that it IS // swinging, never that one is COMING, and never a compass word — the def alone // carries no reference frame the player shares, and inventing one would be the // same offence as inventing the forecast. const veer = f.dirAt(t + 0.5) - f.dirAt(t - 0.5); if (Math.abs(veer) > VEER_RATE) { lines.push({ key: 'veer', text: 'the wind is backing round on you as you stand there' }); } // THE VISIBLE IMMINENT — the only future you have earned, and you earned it by // looking at grass. const tele = f.telegraph ? f.telegraph(t) : null; if (tele && Number.isFinite(tele.eta)) { lines.push({ key: 'front', text: `a gust front crossing the grass — on you in about ${tele.eta.toFixed(1)}s`, }); } else if (f.gustOnly(t) < LULL_GUST) { // NOW: nothing on it this second. DESIGN.md line 149 — "lulls are repair // windows" — and on a triage job that is the most actionable line on the // card. Honest at an instant: it says nothing about how long the lull holds, // because nothing tells you that. lines.push({ key: 'lull', text: 'nothing on it this second — if you are moving, move now' }); } // NOW: what is falling. Present tense only — no duration and no total, because // those are the bureau's numbers. if (f.hailAt(t) > 0) { lines.push({ key: 'hail', text: `${stoneWord(f.hailSize)} stones coming through` }); } const rain = f.rainMmPerHour(t); if (rain > 0.5) lines.push({ key: 'rain', text: `rain at ${i(rain)} mm/hr` }); // THE PAST, AS WRITTEN ON THE YARD. You did not see the first t seconds; the // yard did, and it kept the receipt. Integrals over [0, t] — never past t. const ground = []; let hailSoFar = 0; for (let s = 0; s <= t; s += 0.05) hailSoFar += f.hailAt(s) * 0.05; if (hailSoFar > HAIL_ON_GROUND) ground.push('stones lying in the grass'); if (f.rainDepthMm(0, t) > WATER_STANDING) ground.push('water standing on the flat'); if (ground.length) { lines.push({ key: 'ground', text: `${ground.join(' and ')} — it has been through here a while` }); } // THE ADMISSION. Unconditional, constant, and last, because it is what the // player carries into the yard. It says nothing about the weather — only about // what you were given, which is nothing. lines.push({ key: 'unknown', text: 'how long it runs, what it does next, whether it swings again — no paper, no quote. You find that out standing in it.', }); return lines; } /** * ⚖️ **THE HONESTY GATE, EXTENDED TO EMERGENCY STORMS.** A storm that cannot be * described honestly cannot be DISPATCHED either — an emergency night still gets * worded, priced, invoiced, warrantied and rep'd through the surfaces it already * had (A's §10 acceptance criterion), so `forecastHonest`'s storm-side gate * applies unchanged. Having no forecast card is not a licence for a storm nobody * can describe; it RAISES the bar, because the read is the only description there * is and there is no band to hide vagueness in. * * On top of the storm gate, three things only an ARRIVAL can get wrong: * 1. **the arrival must be inside a storm that is still running.** `arriveAt` * past the end dispatches you to weather that has already finished. * 2. **the read must not come out empty or mute.** A dispatch whose weather half * renders nothing is the offer card picking two fields all over again. * 3. **the stone must be real AT THE INSTANT you arrive** — the S17 filing, * corrected by measuring it. `size: 0` turned out to be refused by the * validator a layer up, so the floor I filed for would have been dead code. * The REACHABLE hole is `size` ABSENT: it passes every gate and defaults to * the MIDDLE of the vocabulary, so the card states "marble stones" as fact for * a stone nobody authored. See the note beside the check in `forecastHonest`. * * @param {object} def parsed storm JSON * @param {number} t the authored `arriveAt` * @param {string} [name] * @returns {{ok:boolean, errors:string[]}} */ export function dispatchHonest(def, t, name = def?.name ?? 'storm') { const base = forecastHonest(def, name); const errors = [...base.errors]; if (!base.ok) return { ok: false, errors }; const dur = def.duration ?? 90; if (!(Number.isFinite(t) && t > 0)) { errors.push(`${name}: arriveAt ${t} is not a time inside a storm — a callout arrives MID-storm`); } else if (t >= dur) { errors.push(`${name}: arriveAt ${t} is at or past the storm's end (${dur}s) — ` + 'you would be dispatched to weather that has already finished'); } else { const read = midStormRead(def, t); const keys = new Set(read.map((l) => l.key)); for (const must of ['nopaper', 'wind', 'unknown']) { if (!keys.has(must)) { errors.push(`${name}: the read at t=${t} has no '${must}' line — a dispatch that does not ` + 'say it has no paper teaches that a missing line is a reassurance'); } } if (read.some((l) => !l.text || !l.text.trim())) { errors.push(`${name}: the read at t=${t} contains an empty line`); } const f = createWindField(def); if (f.hailAt(t) > 0 && !(f.hailSize > 0)) { errors.push(`${name}: hail is falling at t=${t} with size ${f.hailSize} — ` + `the read would print "${stoneWord(f.hailSize)} stones" for stones that do not exist`); } } 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; }