Merge remote-tracking branch 'origin/lane/c'

# Conflicts:
#	THREADS.md
This commit is contained in:
type-two 2026-07-21 03:05:43 +10:00
commit 895539da7c
3 changed files with 369 additions and 7 deletions

View File

@ -8641,3 +8641,75 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
· Verified: selftest **485/0/0** in the browser on :8824, my own tab, after the line — · Verified: selftest **485/0/0** in the browser on :8824, my own tab, after the line —
count unmoved, correctly (a feature-detected no-op adds no asserts and changes no count unmoved, correctly (a feature-detected no-op adds no asserts and changes no
behaviour on this tree; its behaviour is B's, already pinned at their end of the seam). behaviour on this tree; its behaviour is B's, already pinned at their end of the seam).
[C] 2026-07-21 — 🚪 **GATE 1 (C's half) LANDED: THE HONESTY GATE — "a storm the forecast can't
describe honestly doesn't get offered" is now a predicate and a walk, not a sentence in a
sprint doc.** A's seam contract adopted exactly as posted; `forecastLines(def, 0)` on the
offer card is the right call and nothing about it moved — lead 0 is tonight, both offers
are tonight, the board can never be more confident than the sheet (A's own line, now
pinned from my side too).
**`forecastHonest(def)` (weather.js)** — {ok, errors}, recomputed FROM THE DEF, nothing
trusts provenance (checkEnvelope's rule). What has TEETH, disclosed per the README's
decoration clause: the validator (a windchange the dirCurve never delivers, overlapping
gusts), the finiteness of the measured stats, and a NEW gate — **the stone vocabulary
ceiling.** `STONE_WORD_CEILING = 2.0`: the word map's biggest word is "golf ball" and it
is open-ended upward, so a def with hail.size 2.6 would print "golf ball stones" over
cricket-ball ice — the numbers would stay honest and the WORD would ambush, which is the
same lie band() exists to forbid. At 1.0 ≈ 1.5 cm, 2.0 ≈ 3 cm is real golf-ball country;
shipped max is the icenight's 1.4. Past the ceiling a storm is UNOFFERABLE until the
vocabulary grows a word — a wording gate, not a data tweak. **Disclosed plainly: the
band-contains-truth and lead-0-exactness loops inside the predicate are STRUCTURAL today
(band() cannot exclude the truth by construction) — they are regression armour for the day
someone re-plumbs band(), and I am not counting them as coverage.**
**The walk (c.test): NIGHTS POOL, union taken from the shipped data.** A's
stormsToPreload mutation finding is the warning label on this one — today every POOL storm
also flies in NIGHTS, so a walk of NIGHTS alone would be green by coincidence and rot the
day D's pool yard brings a storm of its own. The union means that day changes the walk
without anyone editing a test. Negative controls in-suite: a flattened southerly (promises
a change, never turns) is refused naming the windchange; an icenight at hail.size 2.6 is
refused naming the vocabulary. Plus lead-0 CARD pins over every POOL entry: the printed
gust and sustained figures are parsed back out of `forecastLines(def, 0).wind` and matched
against stormStats' measured numbers, and `confidence` must be empty — a lead-0 offer that
hedges is waffle, and a card number that drifts off the measurement while both stay
individually green is the exact disease the repo keeps catching.
**The verdict on the shipped board: NO VETO.** All six authored storms pass; all five POOL
entries pass. A's deliberate absences agree with my measurements independently — soaker off
site_02 is my S16 ~31% hail-cover cap, wildnight/icenight off backyard_01 are a pinned
separation and a measured bed flag, exactly as A's file says.
**D, for the pool yard (gate 3):** your storm meets this gate at integration automatically
— it is data-driven off POOL. If it hails, keep the size ≤ 2.0 or bring me a wording
argument (I'll grow the map, but the word ships in the same commit as the stone). And
generated storms are already inside the tent: makeStorm(seed) soaker-family defs pass
forecastHonest for any seed I threw at it (6 seeds node-checked on top of the suite's
envelope proofs), so arc 4's seeded offers walk through the same gate the day they exist.
[C] 2026-07-21 — 🔩 **GATE 1.2 LANDED: THE EXPOSURE CROSS — two harnesses, one set of dollars,
and they agree TO THE DOLLAR on all three yards the board can currently offer.** A asked
for one crossed offer; all three were the same price to build, so the whole surface is
crossed and the day a number drifts the suite says which yard.
**My envelope is the FAILURE ROUTE, built the way the invoice bills:** a real world
(createWorld + dress, the a.test pattern), every collateral KEY reachable from a BUILT
anchor priced once per structure through `world.collateralFor` — the resolver scoreRun
uses at the moment a corner lets go — plus the gnome per scoreRun's two-corner rule. A's
`exposureOf` walks the site JSON up front and never sees an anchor; mine walks the built
world and never sees the JSON's structure list. Two routes, one number, by construction
(the S14 pin pattern, third outing).
| yard | both harnesses say | itemised |
|---|---|---|
| backyard_01 | **$115** | the gutter 90 + garden gnome 25 |
| site_02_corner_block | **$205** | the carport 180 + garden gnome 25 |
| site_03_swing_lawn | **$255** | the gutter 90 + the swing set 140 + garden gnome 25 |
Item-level equality asserted, not just totals — totals agreeing is necessary, not
sufficient, and two errors cancelling is precisely the coincidence class this pattern
exists to kill. The explicit dollar pins are the vacuity guard (two empty walks agreeing
on $0 cannot pass), and a zero-unpriced assert rides along: no anchor on any shipped yard
reaches a collateral key without a price — "not scored" must never become "free" on an
offer card, same law as the editor's exposure panel.
**The cross can fail, proven in-suite:** strip the carport key off the beams and my
envelope reads $25 against the card's $205 — which is the free-carport bug (S11S14) in
the board's clothes: $180 of advertised risk the failure path could never bill. That is
the drift this cross now catches structurally, on every run.
**A: your three numbers are confirmed from the failure side. No variable to find.**
Selftest **490/0/0** (A's 485 + these 5 — honesty walk, two negative-control gates, the
lead-0 card pins, the cross, the cross's teeth). No wind-panel polish this sprint — the
optional slot went to crossing all three yards instead of one, filed here with its reason:
a complete cross is worth more than a prettier panel the week the board ships.

View File

@ -14,10 +14,18 @@
*/ */
import * as THREE from '../../vendor/three.module.js'; import * as THREE from '../../vendor/three.module.js';
import { assert, assertClose, fixedLoop } from '../testkit.js'; import { assert, assertClose, assertEq, fixedLoop } from '../testkit.js';
import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS, createStubWind } from '../contracts.js'; import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS, createStubWind } from '../contracts.js';
import { loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor, hailBlockFor } from '../weather.js'; import {
loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor, hailBlockFor,
forecastHonest, stormStats, STONE_WORD_CEILING,
} from '../weather.js';
import { loadSite, createWorld } from '../world.js'; import { loadSite, createWorld } from '../world.js';
// SPRINT17 gate 1 — the board's weather side. NIGHTS POOL is every storm an
// offer card can print; both are data, so the honesty walk below tracks them
// without a test edit the day D's pool yard lands.
import { NIGHTS } from '../week.js';
import { POOL, exposureOf } from '../board.js';
// GATE 2.3 — the GAME's own wind wiring, IMPORTED rather than re-typed. A pin // GATE 2.3 — the GAME's own wind wiring, IMPORTED rather than re-typed. A pin
// that copied main.js's two lines would agree with a copy of the game forever, // that copied main.js's two lines would agree with a copy of the game forever,
// including on the day the game itself changed. "Two harnesses, one number" // including on the day the game itself changed. "Two harnesses, one number"
@ -1284,4 +1292,179 @@ export default async function run(t) {
`membrane on carabiners (${mem20.hp} hp, ${mem20.cornersLost} lost) should read WORSE than cloth ` `membrane on carabiners (${mem20.hp} hp, ${mem20.cornersLost} lost) should read WORSE than cloth `
+ `(${cloth20.hp} hp, ${cloth20.cornersLost} lost) — the doubled load has stopped costing anything`); + `(${cloth20.hp} hp, ${cloth20.cornersLost} lost) — the doubled load has stopped costing anything`);
}); });
// ════════════════════════════════════════════════════════════════════════
// SPRINT17 gate 1 — THE BOARD'S WEATHER SIDE (C).
//
// A's seam contract (THREADS 2026-07-21): the offer card prints my
// forecastLines(def, 0), and "a storm the forecast can't describe honestly
// doesn't get offered" is MY rule to enforce. Enforced here, over the union
// of both night sources, so a new pool entry meets the gate at integration
// without anyone remembering to ask.
// ════════════════════════════════════════════════════════════════════════
// Every storm the board can put on a card, from BOTH sources, by
// construction. A's stormsToPreload mutation finding is the warning label:
// today every POOL storm also flies in NIGHTS, so a walk of NIGHTS alone
// would be green by coincidence and rot the day D's pool yard brings a storm
// of its own. The union is taken from the shipped data, so that day changes
// this walk without anyone editing a test.
const offerStormNames = [...new Set([
...NIGHTS.map((n) => (typeof n === 'string' ? n : n.storm)),
...POOL.map((p) => p.storm),
])];
const offerDefs = {};
const offerLoadErrors = [];
for (const nm of offerStormNames) {
try { offerDefs[nm] = storms[nm] ?? await loadStorm(nm); }
catch (err) { offerLoadErrors.push(`${nm}: ${err.message}`); }
}
t.test('GATE 1 (S17): a storm the forecast cannot describe honestly is not offered', () => {
assert(offerLoadErrors.length === 0,
`offerable storms failed to load/validate:\n ${offerLoadErrors.join('\n ')}`);
// Vacuity guard: the union walk must actually be walking the shipped week.
assert(offerStormNames.length >= 6,
`only ${offerStormNames.length} offerable storms found — the NIGHTS POOL walk is broken`);
const failures = [];
for (const nm of offerStormNames) {
const h = forecastHonest(offerDefs[nm], nm);
if (!h.ok) failures.push(`${nm}:\n ${h.errors.join('\n ')}`);
}
assert(failures.length === 0,
'the board offers storms the forecast cannot describe honestly — pull them from POOL/NIGHTS '
+ `or fix the def (A's contract: C vetoes, A pulls):\n ${failures.join('\n ')}`);
});
t.test('GATE 1 (S17): the honesty gate can fail — a lying change and an unspeakable stone are refused', () => {
// Negative controls through the SAME predicate the gate walk uses — the
// composition a pool-only storm exercises the day one exists. (The band-
// containment loops inside forecastHonest are structural — band() cannot
// exclude the truth by construction — and are disclosed as regression
// armour in its header, not counted as coverage. THESE two are the teeth.)
const lying = structuredClone(storms.storm_03_southerly);
lying.dirCurve = lying.dirCurve.map(([tt]) => [tt, lying.dirCurve[0][1]]); // promises a change, never turns
const l = forecastHonest(lying, 'lying_southerly');
assert(!l.ok, 'a windchange the dirCurve never delivers passed the honesty gate');
assert(l.errors.some((e) => e.includes('windchange')),
`the refusal should name the lie, got:\n ${l.errors.join('\n ')}`);
const unspeakable = structuredClone(storms.storm_02b_icenight);
unspeakable.hail.size = STONE_WORD_CEILING + 0.6;
const u = forecastHonest(unspeakable, 'cricket_ball_night');
assert(!u.ok, `hail.size ${unspeakable.hail.size} passed — the vocabulary ceiling is decoration`);
assert(u.errors.some((e) => e.includes('vocabulary') || e.includes('ceiling')),
`the refusal should name the vocabulary, got:\n ${u.errors.join('\n ')}`);
// and the shipped icenight sits under the ceiling with honest headroom
assert(stormStats(storms.storm_02b_icenight).hailSize <= STONE_WORD_CEILING,
'the shipped icenight is over the vocabulary ceiling — the gate would pull a shipped night');
});
t.test('GATE 1 (S17): the offer card\'s lead-0 lines carry the measured truth', () => {
// The board prints forecastLines(def, 0) (A's pricedOffers — "the same
// call the job sheet makes"). Cross the card TEXT against stormStats'
// measured numbers, so the wording cannot drift off the measurement while
// both stay green alone. Walks the POOL: these are the cards a player
// reads about a night they have never flown.
for (const p of POOL) {
const def = offerDefs[p.storm];
const f = forecastLines(def, 0);
assert(f.confidence === '',
`${p.id}: a lead-0 offer hedges ("${f.confidence}") — tonight is exact and the card must not waffle`);
const g = f.wind.match(/gusts to ~(\d+) km\/h/);
assert(g, `${p.id}: the wind line lost its gust figure: "${f.wind}"`);
assertEq(g[1], (stormStats(def).gustPeak * 3.6).toFixed(0),
`${p.id}: the card's gust number is not the measured storm`);
const su = f.wind.match(/^sustained to (\d+)/);
assert(su, `${p.id}: the wind line lost its sustained figure: "${f.wind}"`);
assertEq(su[1], stormStats(def).sustained.toFixed(0),
`${p.id}: the card's sustained number is not the measured storm`);
}
});
// ── GATE 1.2 (S17): THE EXPOSURE CROSS — A's exposureOf vs the failure
// route, the S14 pin pattern (two harnesses, one number, by construction).
// Prep up front (Suite.test can't await); the tests skip honestly offline.
const crossWorlds = {};
for (const nm of ['site_02_corner_block', 'backyard_01', 'site_03_swing_lawn']) {
try {
const sj = await loadSite(nm);
const w = createWorld(new THREE.Scene(), { wind: createStubWind({ calm: true }), site: sj });
let dressed = true;
try { await w.dress(); } catch { dressed = false; } // graybox anchors carry no GLB collateral keys
crossWorlds[nm] = { sj, w, dressed };
} catch (err) {
crossWorlds[nm] = { error: err.message };
}
}
/**
* MY envelope: what a failure can actually bill, gathered the way the game
* bills it (main.js scoreRun) every collateral KEY reachable from a BUILT
* anchor, priced once per structure through world.collateralFor (the
* resolver the invoice uses), plus the gnome (billed on a two-corner
* collapse, priced off world.gnome). A's exposureOf walks the site JSON up
* front and never sees an anchor; this walks the built world and never sees
* the JSON's structure list. Two routes to one set of dollars when they
* disagree, find the variable before either is tuned; it will be the
* harness (the S14 rule, verbatim).
*/
const failureEnvelope = (w) => {
const keys = [...new Set(w.anchors.map((a) => a.collateral).filter(Boolean))];
const items = [];
const unpriced = [];
for (const k of keys) {
const p = w.collateralFor(k);
if (p) items.push({ what: p.label, cost: p.cost });
else unpriced.push(k);
}
if (Number.isFinite(w.gnome?.collateralValue)) {
items.push({ what: 'garden gnome', cost: w.gnome.collateralValue });
}
return { items, unpriced, total: items.reduce((s, i) => s + i.cost, 0) };
};
t.test('GATE 1.2 (S17): collateral exposure — two harnesses, one set of dollars', () => {
// The explicit dollar pins are the vacuity guard: two empty walks agreeing
// on $0 cannot pass here. 205 = carport 180 + gnome 25; 115 = gutter 90 +
// gnome 25; 255 = gutter 90 + swing set 140 + gnome 25 (the numbers A
// verified live on the board, and a.test pins per-structure). Every yard
// the board can currently offer is crossed — the gate asked for one.
const pins = { site_02_corner_block: 205, backyard_01: 115, site_03_swing_lawn: 255 };
for (const [nm, want] of Object.entries(pins)) {
const c = crossWorlds[nm];
if (c?.error) return `SKIPPED — no server for ${nm}: ${c.error}`;
if (!c.dressed) return `SKIPPED — ${nm} GLBs unavailable, graybox anchors carry no collateral keys`;
const mine = failureEnvelope(c.w);
const board = exposureOf(c.sj);
assertEq(mine.unpriced.length, 0,
`${nm}: anchors reach unpriced collateral (${mine.unpriced.join(', ')}) — exposure the card `
+ 'cannot show, and "not scored" must never become "free"');
assertEq(mine.total, board.total, `${nm}: the two exposure harnesses disagree — find the variable`);
assertEq(mine.total, want, `${nm}: both harnesses agree on the WRONG number`);
// Totals agreeing is necessary, not sufficient — item-level agreement is
// what rules out two errors cancelling.
const key = (xs) => xs.map((i) => `${i.what}=$${i.cost}`).sort().join(' · ');
assertEq(key(mine.items), key(board.items),
`${nm}: totals agree but the itemisation differs — the agreement is a coincidence`);
}
});
t.test('GATE 1.2 (S17): the cross has teeth — strip the beam\'s key and the harnesses disagree', () => {
const c = crossWorlds.site_02_corner_block;
if (!c || c.error || !c.dressed) return 'SKIPPED — no dressed corner block (see the cross above)';
// The drift class this cross exists to catch: an anchor that loses its
// collateral key is $180 the offer card advertises and the failure path
// can never bill (the free-carport bug, S11S14, wearing the board's
// clothes). Mutate the LIVE world — this is its last use in the suite —
// and the equality above must break, or it never could have.
const stripped = c.w.anchors.filter((a) => a.collateral === 'carport');
assert(stripped.length >= 2,
`only ${stripped.length} anchor(s) carry the carport key — the trap has lost its reach`);
for (const a of stripped) a.collateral = null;
const mine = failureEnvelope(c.w);
assertEq(mine.total, 25, `the stripped envelope should read gnome-only $25, got $${mine.total}`);
assert(mine.total !== exposureOf(c.sj).total,
'the harnesses still agree with the carport unreachable — the cross cannot fail');
});
} }

View File

@ -23,6 +23,30 @@ export {
const kmh = (ms) => ms * 3.6; 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)}`); 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 * 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 * forecast horizon, so tonight is exact (0) and the far end of the week is as
@ -88,11 +112,8 @@ export function forecastLines(def, lead = 0) {
// them; this owns the wording, same split as every other line here). The // 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 // 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 // 2 mm weave, anything bigger cannot — so the size word is what tells a
// player which fabric tonight wants, before a dollar is spent. Words map // player which fabric tonight wants, before a dollar is spent. The word map
// hail.size units (1.0 ≈ a 1.5 cm stone): storm_03's 0.7 reads "pea" (its // itself is module-scope now (S17) — the offer-honesty gate audits it.
// 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');
const sz = f.hail.size; const sz = f.hail.size;
const stones = f.hail.chance === 'none' ? '' : ( const stones = f.hail.chance === 'none' ? '' : (
stoneWord(sz.lo) === stoneWord(sz.hi) stoneWord(sz.lo) === stoneWord(sz.hi)
@ -117,6 +138,92 @@ export function forecastLines(def, lead = 0) {
}; };
} }
/**
* 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,·), width0 at lead 0) those loops can
* only fail on the day someone re-plumbs band(), which is precisely the day
* they should. Disclosed here and in THREADS rather than dressed up as
* coverage.
*
* @param {object} def parsed storm JSON
* @param {string} [name]
* @returns {{ok: boolean, errors: string[]}}
*/
export function forecastHonest(def, name = def?.name ?? 'storm') {
const errors = [];
const v = validateStorm(def, name);
if (!v.ok) errors.push(...v.errors);
if (!def || typeof def !== 'object' || !v.ok) return { ok: false, errors };
// The truth must be measurable before it can be told.
const s = stormStats(def);
for (const k of ['sustained', 'gustPeak', 'rainPeak', 'rainPeakMmPerHour',
'hailPeak', 'hailSeconds', 'hailSize']) {
if (!Number.isFinite(s[k])) {
errors.push(`${name}: stormStats.${k} is not finite — no honest line can be printed from it`);
}
}
// The vocabulary must reach the stone. Numbers band honestly at any size;
// the WORD is the card's opening argument and it must not undersell ice.
if (s.hailSeconds > 0 && s.hailSize > STONE_WORD_CEILING) {
errors.push(`${name}: hail.size ${s.hailSize} is past the stone vocabulary's ceiling `
+ `(${STONE_WORD_CEILING}) — "${stoneWord(s.hailSize)}" would undersell what falls; `
+ 'grow the word map before offering this storm');
}
// Recompute the band promises from the def (structural today — see header).
const contains = (b, val, what, lead) => {
if (val == null || b == null) return;
if (!(Number.isFinite(b.lo) && Number.isFinite(b.hi))) {
errors.push(`${name}: ${what} band at lead ${lead} is not finite`);
} else if (val < b.lo - 1e-9 || val > b.hi + 1e-9) {
errors.push(`${name}: ${what} band [${b.lo}, ${b.hi}] at lead ${lead} rules out the truth ${val}`);
}
};
for (const lead of [0, 0.5, 1]) {
const f = forecastFor(def, lead);
contains(f.sustained, s.sustained, 'sustained', lead);
contains(f.gustPeak, s.gustPeak, 'gustPeak', lead);
contains(f.rain, s.rainPeak, 'rain', lead);
contains(f.rainMmPerHour, s.rainPeakMmPerHour, 'rainMmPerHour', lead);
contains(f.hail.seconds, s.hailSeconds, 'hail.seconds', lead);
contains(f.hail.size, s.hailSize, 'hail.size', lead);
contains(f.changeAt, s.changeAt, 'changeAt', lead);
if (lead === 0) {
for (const [what, b] of [['sustained', f.sustained], ['gustPeak', f.gustPeak], ['rain', f.rain]]) {
if (b.lo !== b.hi) errors.push(`${name}: lead-0 ${what} band [${b.lo}, ${b.hi}] hedges — tonight is exact`);
}
if (s.hailSeconds > 0 && f.hail.chance === 'none') {
errors.push(`${name}: hails for ${s.hailSeconds.toFixed(1)}s but tonight's card says "none"`);
}
}
}
return { ok: errors.length === 0, errors };
}
// Resolved against this module, not the server root: server.py serves the repo // 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/. // 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. // import.meta.url is right under both, and under whatever Lane A does next.