HardYards/web/world/js/weather.js
type-two 8554de7824 Lane C S18 gate 1: the dispatch — midStormRead, and the honesty gate over storms nobody forecast
A's §8 seam, built to their shape: an ordered {key, text} list the card maps, the
offerBand construction, reachable through weather.midStormRead?.() under a
namespace import.

THE INVERSION IS THE DESIGN. offerBand's sin is OMISSION — the board lied about
night 7 by printing two of the fields it held. A dispatch's sin is INVENTION, and
the consequence is that the read is SHARPER than a forecast, not vaguer: standing
in the yard you know the wind on your back to the km/h, so the present tense is
exact where the offer card was banded. What you do not have is the bureau's
numbers — how long it runs, the peak, whether a southerly lands at t=48.

So the read is ruled by TENSE. Permitted: NOW (exact, unhedged); THE VISIBLE
IMMINENT (a gust front crossing the grass — DESIGN.md line 149, a 1.5 s telegraph
horizon earned by looking); and THE PAST AS WRITTEN ON THE YARD — at t=30 you have
no memory of the storm because you were driving, but hail lying in the grass and
water standing on the flat are a record you did not have to be present for.
Integrals over [0, t] are honest; over (t, ∞) they are not. The future is
forbidden outright.

And the admission is mandatory: on an offer card a missing hail line means the
storm does not hail, on a dispatch it means nobody knows, and silence cannot tell
those apart. The read says so first and last, in constant words — the ignorance
line is unconditional because a line that appears only when there is something to
not-know becomes the very fact it claims not to have.

dispatchHonest(def, t) extends the gate: forecastHonest unchanged (no forecast
card is not a licence for an indescribable storm — it raises the bar, since the
read is the only description there is), plus the three things only an arrival can
get wrong. My own S17 filing was wrong and testing it said so: hail.size 0 was
already refused by validateStorm a layer up. The reachable hole is size ABSENT —
it passes every gate and defaults to the MIDDLE of the vocabulary, so the soaker
minus one field prints "marble stones" for its real 0.45 fine pea.

Pins +5 (527 -> 532). The strongest states the rule rather than policing a
vocabulary: THE READ AT t MAY NOT MOVE WHEN THE STORM'S FUTURE BEYOND t MOVES,
with a vacuity guard proving the mutations really do change the storm. Four
mutations red and restored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 19:37:45 +10:00

783 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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.33.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;
}