The week's two storm variants + forecast uncertainty
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 <noreply@anthropic.com>
This commit is contained in:
parent
a53837208d
commit
40be89c238
52
web/world/data/storms/storm_02b_icenight.json
Normal file
52
web/world/data/storms/storm_02b_icenight.json
Normal file
@ -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 }
|
||||
}
|
||||
45
web/world/data/storms/storm_03b_earlybuster.json
Normal file
45
web/world/data/storms/storm_03b_earlybuster.json
Normal file
@ -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 }
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user