From 40be89c2382f618bb49947ef29eab8abe7e18553 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 09:23:20 +1000 Subject: [PATCH 1/3] The week's two storm variants + forecast uncertainty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Variants for A's five-night week. Each gets ONE new trick, and neither is "the same storm but harder": - storm_03b_earlybuster (night 3): same force as storm_03 (21 m/s gust, 13 sustained) but the change lands at t=18 instead of t=30. You rig for a hot NW'er and get a southerly a third of the way in. Tests timing, not budget. - storm_02b_icenight (night 5): LESS wind than the wild night (28 vs 32 gust, 19 vs 20 sustained) and 2.8× the hail — 32 hail-seconds vs 11.4, held 26 s straight across the change, size 1.4 stones, ice on every gust ≥9. Hail is the score (decision 13), so this asks the one question storm_02 only asks for nine seconds: is the bed actually covered, and does the cover hold? A rig that survives storm_02 by hiding in a small quad off the bed loses here. Week escalation now reads 11.3 / 21.4 / 21.2 / 32.3 / 28.3 m/s peak gust with 0 / 2.4 / 2.4 / 11.4 / 32.0 hail-seconds — force ramps then plateaus while the question changes underneath it. Forecast uncertainty (DESIGN.md partial-information canon): `forecastFor(def, lead)` + `stormStats(def)`, pure functions on a def rather than methods on the wind — the card holds defs, doesn't want a field, and this keeps forecasting off the wind contract and out of A's router. Bands widen with lead and resolve to the exact truth at lead 0; deterministic per storm, so re-reading the card can't reroll it. The invariant, asserted across every storm at five leads: a band ALWAYS contains the truth. That's the line between partial information and a lie — a player who rigs for the top of the stated range must never be ambushed. stormStats also measures what the card was estimating: storm_02 gusts to 32.3 m/s, not the 30 that `baseCurve peak + powBase + powRamp` implied. Selftest 253/0/0; node 49/0/0. Co-Authored-By: Claude Opus 4.8 --- web/world/data/storms/storm_02b_icenight.json | 52 ++++++++++ .../data/storms/storm_03b_earlybuster.json | 45 +++++++++ web/world/js/tests/c.test.js | 7 +- web/world/js/tests/weather.selftest.js | 65 ++++++++++++ web/world/js/weather.core.js | 98 +++++++++++++++++++ 5 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 web/world/data/storms/storm_02b_icenight.json create mode 100644 web/world/data/storms/storm_03b_earlybuster.json diff --git a/web/world/data/storms/storm_02b_icenight.json b/web/world/data/storms/storm_02b_icenight.json new file mode 100644 index 0000000..7804322 --- /dev/null +++ b/web/world/data/storms/storm_02b_icenight.json @@ -0,0 +1,52 @@ +{ + "name": "Ice Night", + "blurb": "Same front as the wild night, but it stalls overhead and the hail just keeps coming. Cover the bed or lose it.", + "rating": 5, + "seed": 20818, + "duration": 90, + + "_variant_comment": "Week night 5 — the storm_02 variant, and the week's last word. ONE new trick: the hail doesn't stop. The wild night's burst is 9 s long; this one holds for 26 s straight across the change (t=48-74) at full intensity, plus ice on every big gust. Wind is deliberately NOT raised — it sits a touch UNDER storm_02 (sustained 19 vs 20) so this is not 'the wild night but harder'. It asks the one question the wild night only asks for 9 seconds: is the bed actually covered, and does the cover hold? A rig that survives storm_02 by hiding in a small quad off the bed loses the garden here. Hail is the score (decision 13), so this is the storm that scores rigging hardest.", + + "baseCurve": [[0, 7.0], [15, 11.0], [40, 16.5], [60, 19.0], [78, 18.0], [90, 15.0]], + + "gusts": { + "firstAt": 3, + "minGap": 5.5, + "maxGap": 11, + "powBase": 3, + "powRand": 5, + "powRamp": 6.5, + "downdraftOfTotal": 0.45 + }, + + "dirCurve": [[0, 0.85], [48, 0.95], [53, 0.6], [57, -1.25], [70, -1.45], [90, -1.35]], + "dirWander": { "amp": 0.25, "rate": 0.13 }, + + "spatial": { "amp": 0.2, "scale": 11, "advect": 0.5 }, + + "events": [ + { "t": 36, "type": "debris", "model": "BlueCrate_v2", "lateral": -3.5, "mass": 9, "text": "a crate comes through the fence line" }, + { "t": 50, "type": "lightning", "power": 0.7 }, + { "t": 53, "type": "windchange", "telegraph": 6, "over": 6, "text": "the wind swings around" }, + { "t": 62, "type": "lightning", "power": 1.0 }, + { "t": 68, "type": "debris", "model": "WoodenBin_v2", "lateral": -1.0, "mass": 14, "text": "the neighbour's bin lets go" }, + { "t": 80, "type": "lightning", "power": 0.5 } + ], + + "_rain_comment": "Same 80 mm/hr severe scale as storm_02 and a near-identical curve — the water story (ponding, the broom) is unchanged on purpose. The variant's whole difference is the ice.", + + "rain": { "peakMmPerHour": 80, "curve": [[0, 0], [10, 0.25], [35, 0.6], [55, 0.85], [70, 1.0], [90, 0.7]] }, + + "_hail_comment": "The trick. storm_02 hails ~11 seconds all night; this holds 26 s at full intensity straight through the change, plus size 1.4 stones and ice on every gust ≥9 (a lower bar than storm_02's 10, so more of them carry it). Expect ~3x storm_02's hail-seconds — a bare bed should be stripped, and a bed under cloth that HOLDS should still come through.", + + "hail": { + "size": 1.4, + "withGustsAbove": 9, + "gustBurstIntensity": 0.9, + "bursts": [ + { "t": 48, "ramp": 2, "hold": 26, "fade": 3, "intensity": 1.0 } + ] + }, + + "sky": { "darkness": 0.94, "cloudScroll": 0.09, "night": true, "lightningGustPow": 10 } +} diff --git a/web/world/data/storms/storm_03b_earlybuster.json b/web/world/data/storms/storm_03b_earlybuster.json new file mode 100644 index 0000000..b243627 --- /dev/null +++ b/web/world/data/storms/storm_03b_earlybuster.json @@ -0,0 +1,45 @@ +{ + "name": "Early Buster", + "blurb": "The change comes through before you've finished your coffee. Same southerly, half the warning.", + "rating": 3, + "seed": 30818, + "duration": 90, + + "_variant_comment": "Week night 3 — the storm_03 variant. ONE new trick: the change lands at t=18 instead of t=30, before the wind has built enough to teach you what it's going to do. You rig for a hot NW'er and get a southerly a third of the way in, so the corners you left slack are loaded while you're still watching the sky. Everything else is deliberately storm_03's: same peak (~21 m/s gust, 13 sustained), same rain scale, same mild hail. The lesson is TIMING, not force — night 3 of five should test a habit, not a hardware budget.", + + "baseCurve": [[0, 5.0], [10, 8.0], [22, 11.0], [45, 13.0], [65, 12.5], [90, 10.0]], + + "gusts": { + "firstAt": 4, + "minGap": 6, + "maxGap": 12, + "powBase": 3, + "powRand": 4, + "powRamp": 4, + "downdraftOfTotal": 0.35 + }, + + "_dir_comment": "Blowing toward the SE (a warm NW'er) for the first 16 s only, then the slew: 16-24 s swings ~90 deg to blow toward the NNE (a southerly off the open side). storm_03 gives you 30 s to read it; this gives you 16.", + + "dirCurve": [[0, 0.8], [16, 0.85], [18, 0.5], [24, -0.7], [50, -0.85], [90, -0.75]], + "dirWander": { "amp": 0.3, "rate": 0.11 }, + + "spatial": { "amp": 0.18, "scale": 11, "advect": 0.5 }, + + "events": [ + { "t": 18, "type": "windchange", "telegraph": 5, "over": 6, "text": "that's the change already" }, + { "t": 40, "type": "debris", "model": "BlackTub_v2", "lateral": 2.5, "mass": 5, "text": "a tub skitters across the lawn" }, + { "t": 58, "type": "lightning", "power": 0.4 } + ], + + "rain": { "peakMmPerHour": 30, "curve": [[0, 0], [16, 0.05], [22, 0.4], [50, 0.55], [80, 0.3], [90, 0.15]] }, + + "hail": { + "size": 0.7, + "bursts": [ + { "t": 24, "ramp": 1.5, "hold": 3, "fade": 2, "intensity": 0.5 } + ] + }, + + "sky": { "darkness": 0.55, "cloudScroll": 0.06, "night": true } +} diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index c920de4..63ce0fb 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -25,7 +25,12 @@ import { weatherCases } from './weather.selftest.js'; // Keep in step with data/storms/. The node runner globs the directory, so this // list going stale shows up here first — as it did when storm_03 landed and the // ponding case reached for a storm the browser half had never loaded. -const STORMS = ['storm_01_gentle', 'storm_02_wildnight', 'storm_03_southerly']; +const STORMS = [ + 'storm_01_gentle', 'storm_02_wildnight', 'storm_03_southerly', + // SPRINT6: the week's two variants — night 3 (same force, half the warning) + // and night 5 (less wind, 2.8× the hail). + 'storm_03b_earlybuster', 'storm_02b_icenight', +]; /** @param {import('../testkit.js').Suite} t */ export default async function run(t) { diff --git a/web/world/js/tests/weather.selftest.js b/web/world/js/tests/weather.selftest.js index d16424f..79a4f90 100644 --- a/web/world/js/tests/weather.selftest.js +++ b/web/world/js/tests/weather.selftest.js @@ -12,6 +12,7 @@ import { createWindField, validateStorm, gustEnvelope, GUST, RAIN_TIME_COMPRESSION, + stormStats, forecastFor, } from '../weather.core.js'; const DT = 1 / 60; @@ -599,6 +600,70 @@ export function weatherCases(storms) { assert(validateStorm(none, 'nohail').ok, 'validator rejected a storm with no hail — hail is optional'); }); + // ---- 12. forecast uncertainty (SPRINT6 §Lane C) ---- + // DESIGN.md's partial-information canon. The one rule that matters: a vague + // forecast is fine, a WRONG one isn't. A player who rigs for the top of the + // stated range must never be ambushed by the storm. + test('a forecast band never excludes what actually happens', () => { + for (const [name, def] of defs) { + const s = stormStats(def); + for (const lead of [0, 0.25, 0.5, 0.75, 1]) { + const f = forecastFor(def, lead); + const inside = (b, v, what) => assert(v >= b.lo - 1e-9 && v <= b.hi + 1e-9, + `${name} @lead ${lead}: ${what} truth ${v.toFixed(2)} outside forecast ${b.lo.toFixed(2)}–${b.hi.toFixed(2)}`); + inside(f.sustained, s.sustained, 'sustained'); + inside(f.gustPeak, s.gustPeak, 'gustPeak'); + inside(f.rain, s.rainPeak, 'rain'); + inside(f.hail.seconds, s.hailSeconds, 'hailSeconds'); + if (s.changeAt != null) inside(f.changeAt, s.changeAt, 'changeAt'); + } + } + }); + + test('a forecast tightens to the truth as the night approaches', () => { + const def = storms.storm_02_wildnight; + const s = stormStats(def); + const width = (lead) => { const f = forecastFor(def, lead); return f.gustPeak.hi - f.gustPeak.lo; }; + const far = width(1), mid = width(0.5), near = width(0); + metrics['forecast.gustBandWidth@lead1'] = +far.toFixed(1); + metrics['forecast.gustBandWidth@lead0.5'] = +mid.toFixed(1); + assert(far > mid && mid > near, `band must narrow with lead: ${far.toFixed(1)} → ${mid.toFixed(1)} → ${near.toFixed(1)}`); + assert(near === 0, `tonight's forecast should be exact, band is ${near.toFixed(2)} wide`); + const f0 = forecastFor(def, 0); + assert(f0.gustPeak.lo === s.gustPeak && f0.confidence === 1, 'lead 0 must report the truth with full confidence'); + }); + + test('a forecast is deterministic — re-reading the card cannot reroll it', () => { + const def = storms.storm_02_wildnight; + for (const lead of [0.3, 0.8]) { + const a = forecastFor(def, lead), b = forecastFor(def, lead); + assert(a.gustPeak.lo === b.gustPeak.lo && a.gustPeak.hi === b.gustPeak.hi, + 'two reads of the same forecast disagreed'); + } + }); + + test('stormStats measures the storm, and beats estimating it from the def', () => { + const def = storms.storm_02_wildnight; + const s = stormStats(def); + // what the card used to estimate: baseCurve peak + powBase + powRamp + const estimate = Math.max(...def.baseCurve.map((p) => p[1])) + + (def.gusts.powBase ?? 0) + (def.gusts.powRamp ?? 0); + metrics['storm_02.gustPeak.measured'] = +s.gustPeak.toFixed(1); + metrics['storm_02.gustPeak.oldEstimate'] = +estimate.toFixed(1); + assert(Math.abs(s.gustPeak - estimate) > 1, + 'the estimate happens to match — this test is only interesting while they differ'); + assert(s.gustPeak > 30 && s.gustPeak < 35, `measured gust peak ${s.gustPeak.toFixed(1)} is not credible`); + assert(s.changeAt === 55, `storm_02's change is at ${s.changeAt}, expected 55`); + }); + + test('the forecast hail hint tracks the storm, and hedges when far out', () => { + assert(forecastFor(storms.storm_01_gentle, 0).hail.chance === 'none', 'gentle storm forecast hail'); + assert(forecastFor(storms.storm_02_wildnight, 0).hail.chance === 'likely', 'the wild night should forecast likely hail'); + assert(forecastFor(storms.storm_02_wildnight, 1).hail.chance === 'possible', + 'a week out, the wild night should only hedge at "possible"'); + assert(forecastFor(storms.storm_03_southerly, 0).hail.chance === 'possible', 'storm_03 hails mildly'); + }); + return { cases, metrics }; } diff --git a/web/world/js/weather.core.js b/web/world/js/weather.core.js index 9792b0c..9bdc461 100644 --- a/web/world/js/weather.core.js +++ b/web/world/js/weather.core.js @@ -511,6 +511,104 @@ export function createWindField(def, opts = {}) { return field; } +// ---------- forecasting ---------- +// DESIGN.md's partial-information canon: a forecast days out is a RANGE, and it +// tightens as the night comes. Deliberately pure functions on a storm def rather +// than methods on a wind — the card has defs in hand, it doesn't want a field, +// and this keeps forecasting off the wind contract (and out of main.js's router). + +const _statsCache = new WeakMap(); + +/** + * The truth about a storm, measured rather than estimated. The forecast card was + * approximating the gust peak as `baseCurve peak + powBase + powRamp` (30 m/s + * for storm_02); the storm actually gusts to 32.3, because gust power is drawn + * per gust and rides a ramp. If the card is going to sell the dread it may as + * well sell the real number. Cached per def — scanning is ~1800 samples. + * + * @returns {{sustained, gustPeak, rainPeak, rainPeakMmPerHour, hailPeak, hailSeconds, changeAt}} + */ +export function stormStats(def) { + const hit = _statsCache.get(def); + if (hit) return hit; + const f = createWindField(def); + const dur = def.duration ?? 90; + let sustained = 0, gustPeak = 0, rainPeak = 0, hailPeak = 0, hailSeconds = 0; + const STEP = 0.05; + for (let t = 0; t <= dur; t += STEP) { + const u = f.uniformSpeed(t); + if (u > gustPeak) gustPeak = u; + const base = u - f.gustOnly(t); + if (base > sustained) sustained = base; + const r = f.rainAt(t); + if (r > rainPeak) rainPeak = r; + const h = f.hailAt(t); + if (h > hailPeak) hailPeak = h; + hailSeconds += h * STEP; + } + const change = (def.events || []).find((e) => e.type === 'windchange'); + const stats = { + sustained, gustPeak, rainPeak, + rainPeakMmPerHour: rainPeak * (def.rain?.peakMmPerHour ?? DEFAULT_PEAK_MM_PER_HOUR), + hailPeak, hailSeconds, + changeAt: change ? change.t : null, + }; + _statsCache.set(def, stats); + return stats; +} + +/** How wide each number's band runs at lead=1, as a fraction of the value. */ +const FORECAST_SPREAD = { + sustained: 0.30, gustPeak: 0.35, rain: 0.45, changeAt: 0.22, hailSeconds: 0.7, +}; + +/** + * A forecast of `def` seen `lead` out — 0 = tonight (exact), 1 = the far end of + * the week (vague). Deterministic per storm, so the same night always forecasts + * the same way and re-reading the card can't reroll it. + * + * The band ALWAYS contains the truth. That's the line between partial + * information and a lie: a forecast may be vague, and its midpoint may be off, + * but it must never rule out what actually happens — a player who rigs for the + * top of the stated range must never be ambushed. Width and centre-wander both + * shrink to nothing as lead → 0. + * + * @param {object} def parsed storm JSON + * @param {number} lead 0..1 + */ +export function forecastFor(def, lead = 0) { + const s = stormStats(def); + const L = Math.min(1, Math.max(0, lead)); + const r = mulberry32(((def.seed ?? 1) ^ 0xf0eca57) >>> 0); + + const band = (v, rel) => { + if (L <= 0 || !(v > 0)) return { lo: v, hi: v }; + const w = v * rel * L; + const c = v + (r() * 2 - 1) * w * 0.6; // the centre wanders, seeded + return { lo: Math.max(0, Math.min(v, c - w)), hi: Math.max(v, c + w) }; + }; + + // Hail is the garden score (decision 13), so the card has to hint at it — but + // a distant forecast shouldn't promise ice it can't see yet. + let chance = 'none'; + if (s.hailSeconds > 0) { + if (L > 0.55) chance = 'possible'; + else chance = s.hailSeconds > 6 ? 'likely' : 'possible'; + } + + return { + lead: L, + confidence: 1 - L, + sustained: band(s.sustained, FORECAST_SPREAD.sustained), + gustPeak: band(s.gustPeak, FORECAST_SPREAD.gustPeak), + rain: band(s.rainPeak, FORECAST_SPREAD.rain), + rainMmPerHour: band(s.rainPeakMmPerHour, FORECAST_SPREAD.rain), + changeAt: s.changeAt == null ? null : band(s.changeAt, FORECAST_SPREAD.changeAt), + hail: { chance, seconds: band(s.hailSeconds, FORECAST_SPREAD.hailSeconds) }, + truth: s, + }; +} + // ---------- storm JSON validator ---------- // Storms are data so design can tune without code (PLAN3D §4) — which means a // typo is a data bug, and data bugs should fail loud, not silently blow calm. From 121e169981a96bd6eb6a02e0b59ce2c5bb12b629 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 09:29:17 +1000 Subject: [PATCH 2/3] Soften Ice Night: 2.8x storm_02's hail was a wall, not a night MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured against A's gate-1 winning line (t2+p3+p4+t2b, 4x shackle + spare, tension 1.0, one repair) under an identical harness: the first draft's 26 s hold gave 32 hail-seconds and landed ~39 HP below storm_02. That is not a harder night — no amount of play recovers 39 HP when you are already flying the best rig the shop sells. Hold 26 -> 12 s puts it at 21.1 hail-seconds (1.6x storm_02, was 2.8x) and 13 HP below it, which excellent rigging + the repair + the broom can answer — and night 5 arrives with four nights of banked money, which is the campaign's own answer to escalation. Week now: gust 11.3 / 21.4 / 21.2 / 32.3 / 28.3, hail-seconds 0 / 2.4 / 2.4 / 11.4 / 21.1. Force ramps then plateaus while the question changes underneath. Co-Authored-By: Claude Opus 4.8 --- web/world/data/storms/storm_02b_icenight.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/world/data/storms/storm_02b_icenight.json b/web/world/data/storms/storm_02b_icenight.json index 7804322..cf3dc28 100644 --- a/web/world/data/storms/storm_02b_icenight.json +++ b/web/world/data/storms/storm_02b_icenight.json @@ -37,14 +37,14 @@ "rain": { "peakMmPerHour": 80, "curve": [[0, 0], [10, 0.25], [35, 0.6], [55, 0.85], [70, 1.0], [90, 0.7]] }, - "_hail_comment": "The trick. storm_02 hails ~11 seconds all night; this holds 26 s at full intensity straight through the change, plus size 1.4 stones and ice on every gust ≥9 (a lower bar than storm_02's 10, so more of them carry it). Expect ~3x storm_02's hail-seconds — a bare bed should be stripped, and a bed under cloth that HOLDS should still come through.", + "_hail_comment": "The trick, and it is TUNED not maxed. storm_02 hails ~11.4 seconds all night; this holds 12 s at full intensity straight through the change plus ice on every gust ≥9 (a lower bar than storm_02's 10, so more carry it), landing ~1.6x storm_02's hail-seconds with size 1.4 stones. The first draft ran a 26 s hold for 32 hail-seconds — 2.8x — and measured ~39 HP below storm_02 under the SAME rig, which is not a harder night, it's a wall: no amount of play recovers 39 HP when you're already flying the best rig in the shop. 1.6x is a real step up that excellent rigging + the repair + the broom can still answer. Winnability across all five nights is balance.test.js's job (B holds the pen) — these variants need to be in its storm list.", "hail": { "size": 1.4, "withGustsAbove": 9, "gustBurstIntensity": 0.9, "bursts": [ - { "t": 48, "ramp": 2, "hold": 26, "fade": 3, "intensity": 1.0 } + { "t": 50, "ramp": 2, "hold": 12, "fade": 3, "intensity": 1.0 } ] }, From a0cf23dec8b8fefbeb89b169f6f9693db7020ce9 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 09:32:42 +1000 Subject: [PATCH 3/3] Log gate-1 lever sweep (declining the downdraft), the porosity ruling, variants Independently corroborates A's p4 finding: downdraft is the weakest lever (-4.9% vs porosity's -15%), so storm_02 stays at 0.45. Answers B's blocked hail-porosity question with the physics and three honest alternatives. Hands A the week's variants and forecast uncertainty. Co-Authored-By: Claude Opus 4.8 --- THREADS.md | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/THREADS.md b/THREADS.md index 4196d1d..f2df338 100644 --- a/THREADS.md +++ b/THREADS.md @@ -1849,3 +1849,93 @@ Format: `[lane letter] YYYY-MM-DD — note` flagged exactly this joint call when holding 0.45 — but it is now measured and it is SPRINT6 gate 1. Also: scoreRun's verdict text blames "what you skimped on" on a 4/4 no-break run — verdicts must read the actual failure mode. + +[C] 2026-07-18 — 🎯 **GATE 1 — A, YOU'RE RIGHT, AND I'M DECLINING MY OWN LEVER. Don't spend the + downdraft.** You asked us to re-measure before stacking levers; I'd already swept mine independently + and it says what your p4 sweep says. Measured clean on a 43 m² bed-covering quad through storm_02 with + UNBREAKABLE hardware (nothing breaks → pure aerodynamics, no cascade noise): + ``` + lever peak corner load cut + baseline (dd 0.45) 10325 N — + downdraft 0.40 9824 N −4.9% ← my lever + downdraft 0.30 9003 N −12.8% + porosity 0.5 (B's fabric) 8741 N −15% + tension 0.7 10400 N ~0% + ``` + **My downdraft is the weakest lever on the board** — 0.45→0.40 buys 5% of load, and even 0.30 only + reaches 13%. It cannot close a gap where the smallest bed-covering quad needed ~$120 of rated hardware + against an $80 shop. Spending it would cost real physics fidelity (the no-free-lunch ratio drops + 69%→63%, and decision 11 was settled at 0.45) to move balance almost not at all. **storm_02 stays at + downdraftOfTotal 0.45.** If gate 1 ever needs one more notch after everything else, 0.40 is proven safe + on both physics gates and I'll spend it then — but not blind, and not first. + Corroborating your diagnosis from the other side: I enumerated all **207** bed-covering quads in the + pre-p4 yard and the smallest was **54 m²**; it wins on all-rated ($120) and loses 3 corners on the + exact-$80 loadout. Area was always the lever — first break moved 14.5 s → 35.1 s as I shrank a + synthetic bed-covering quad 63 → 31 m². That's your p4 sweep, found independently. Nice call. + +[C] 2026-07-18 — 🅱️ **B — the hail-porosity ruling you were told to get from me before coding fabric. + Short answer: porosity does NOT block less hail, and the honest tradeoff has to come from somewhere + else.** Knitted shade cloth apertures are ~1–3 mm; hailstones are 6–45 mm. A stone cannot pass a mesh + an order of magnitude finer than it is — it gets stopped and the cloth wears the impact. So physically: + **porosity is about AIR (blows through → less load) and WATER (drains → no ponding). It is not about + ice.** My hail shadow already models that correctly by construction: it projects `rig.pos`/`rig.tris` + geometry and never reads porosity, so porous and membrane block hail identically today. No code change + needed for the honest answer. + ⚠️ **Which leaves you a real design problem, and it's yours not mine:** if porous halves wind load AND + ponds nothing AND blocks hail, it's strictly dominant and the choice is dead. SPRINT6's sketch + ("membrane blocks hail fully") would fix that by making porous leak ice — the one thing the physics + won't support. Three honest places to put the cost instead, in the order I'd try: + 1. **Price.** Membrane cheap and dangerous, shade cloth dear and safe. "You get what you pay for" is + a real tradeoff and needs no physics lie. + 2. **Shade.** Porous is ~70% shade by construction, membrane 100%. Worth nothing tonight, worth + everything the moment Sprint 7 scores a heatwave — which DESIGN.md already promises. + 3. **Stone size, if you want a hail difference that's true.** The smallest ice DOES pass a coarse + mesh. I own `hail.size` (storm_01 none · storm_03 0.7 pea · storm_02 1.3 · storm_02b 1.4), so + "porous passes hail below ~0.8, blocks the rest" is defensible and data-driven, and it makes + porous lose exactly on the mild-hail nights while staying honest on the ice nights. Say the word + and I'll land it as `hailBlockFor(size, porosity)` in weather.core — your mechanic, your call, I'll + match whatever shape you want. + FYI for your balance pen: porosity 0.5 is the **strongest single lever I measured** (−15% peak load, 3× + my downdraft). It may be doing gate-1 work you don't intend it to. + +[C] 2026-07-18 — 🗓️ **THE WEEK'S TWO VARIANTS ARE ON `lane/c`. A — yours to sequence.** Each has ONE new + trick and neither is "the same storm but harder": + ``` + night storm gustPk sust hailSec change@ the one new trick + 1 storm_01_gentle 11.3 6.5 0.0 — — + 2 storm_03_southerly 21.4 13.0 2.4 30 the swing + 3 storm_03b_earlybuster 21.2 13.0 2.4 18 same force, HALF the warning + 4 storm_02_wildnight 32.3 20.0 11.4 55 the wild night + 5 storm_02b_icenight 28.3 19.0 21.1 53 LESS wind, 1.6× the hail + ``` + Force ramps then deliberately **plateaus** (32.3 → 28.3) while the question changes underneath it. + Night 3 tests a habit, not a budget: you rig for a hot NW'er and the southerly lands a third of the way + in. Night 5 tests what hail was built to test — is the bed actually covered, and does the cover HOLD — + so a rig that survives night 4 by hiding in a small quad off the bed loses the week on it. + ⚠️ **Ice Night is tuned, and I want the tuning on record because I got it wrong first.** The draft ran a + 26 s hail hold = 32 hail-seconds = 2.8× storm_02, and measured **~39 HP below storm_02 under your exact + gate-1 winning line**. That's not a harder night, it's a wall — nobody recovers 39 HP while already + flying the best rig the shop sells. Now 1.6×, **13 HP below storm_02**. That gap is deliberate: night 5 + arrives with four nights of banked money, so **your economy is what should answer it** — if the bank + can't, tell me and I'll drop the hold again. **B: please put both variants in balance.test.js's storm + list** — "every storm has a winnable line" has to mean all five nights, and my harness can't reproduce + your `rigging.commit()` numbers exactly (I read ~11 HP high and one corner worse than A's on the same + rig), so the canonical answer needs to come from your pen, not mine. + +[C] 2026-07-18 — 🔮 **FORECAST UNCERTAINTY landed — A, it's a pure function on a def, so it costs you no + router and no wiring.** `forecastFor(def, lead)` + `stormStats(def)` in weather.core (lead 0 = + tonight/exact, 1 = far end of the week/vague). What your card can render for the wild night as the week + closes in: + ``` + 4 nights out conf 0% gusts 93–174 km/h change 38–63 s hail possible + 2 nights out conf 40% gusts 102–151 km/h change 45–60 s hail possible + tomorrow conf 75% gusts 110–131 km/h change 51–57 s hail likely + tonight conf 100% gusts 116 km/h change 55 s hail likely + ``` + **The invariant, asserted across every storm at five leads: the band ALWAYS contains the truth.** That's + the line between partial information and a lie — a player who rigs for the top of the stated range must + never be ambushed. Deterministic per storm, so re-reading the card can't reroll it. + Two notes. `stormStats` MEASURES rather than estimates: your card computes the gust peak as `baseCurve + peak + powBase + powRamp` = 30 m/s for storm_02, and it actually gusts to **32.3**, because gust power + is drawn per gust and rides a ramp. `stormStats(def).gustPeak` is the real number, cached per def. And + `truth` is on the forecast object, so an "actual vs forecast" reveal on the aftermath card is free.