Ship forecast uncertainty to the card (carried twice — now visible)
The model landed in Sprint 6 and nothing ever called it, which is why this kept
getting carried: `forecastFor` existed while the card went on estimating inline.
So this is the wiring, not more API.
`forecastLines(def, lead)` turns a storm into the card's two stat lines, already
worded in A's voice. lead 0 = tonight = exact numbers (reads exactly as the card
always has); further out it hedges into ranges and prints a confidence line. The
bands always contain the truth — a forecast may be vague, never wrong, or a
player who rigs for the top of the stated range gets ambushed.
Two things the card gains beyond the bands:
- The numbers are now MEASURED. The inline estimate (`baseCurve peak + powBase
+ powRamp`) read 30 m/s for storm_02; it really gusts to 32.3, because gust
power is drawn per gust and rides a ramp. The wild night now says 116 km/h
because that is what hits you.
- The card finally MENTIONS HAIL. Hail has been the garden score since decision
13 and the forecast never said so — you were being scored on something the
card didn't tell you was coming. Now: "hail likely" on the wild night, silent
on the gentle one, "possible" when it's too far out to promise.
hud.js is Lane A's file and A is meant to build the week uninterrupted, so this
is deliberately a 3-line swap that keeps their markup, classes and wording — the
week's card rewrite can carry it or drop `forecastLines` and call `forecastFor`
directly. `lead` defaults to 0, so nothing changes until the week passes one.
Verified live at both ends: tonight renders exact; five nights receding into the
week render 0%-confidence hedges ("16–28 m/s · gusts ~72–144 km/h · hail
possible"). Selftest 265/0/0 + gate 0's 2 skips.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
807c02fb9e
commit
756994d98e
@ -15,6 +15,7 @@
|
||||
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { STORM_LEN } from './contracts.js';
|
||||
import { forecastLines } from './weather.js';
|
||||
|
||||
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
const kmh = (ms) => ms * 3.6;
|
||||
@ -313,18 +314,19 @@ export function createHud(d) {
|
||||
* @param {(key:string) => void} onPick
|
||||
*/
|
||||
showForecast(storms, onPick) {
|
||||
const rows = storms.map(({ key, def }) => {
|
||||
const peak = Math.max(...def.baseCurve.map((p) => p[1]));
|
||||
const gustPeak = peak + (def.gusts?.powBase ?? 0) + (def.gusts?.powRamp ?? 0);
|
||||
const rainPeak = Math.max(...(def.rain?.curve ?? [[0, 0]]).map((p) => p[1]));
|
||||
const change = (def.events ?? []).find((e) => e.type === 'windchange');
|
||||
const night = (def.sky?.darkness ?? 0) > 0.6;
|
||||
// Numbers via Lane C's forecastLines(def, lead): MEASURED rather than
|
||||
// estimated (the old inline `baseCurve peak + powBase + powRamp` read 30
|
||||
// m/s for storm_02, which really gusts to 32.3), and banded by how far out
|
||||
// the night is. `lead` 0 = tonight = exact, which is what this reads as
|
||||
// today; pass each night's lead when the week lands and the card starts
|
||||
// hedging on its own. Same wording as before — only the numbers changed.
|
||||
const rows = storms.map(({ key, def, lead = 0 }) => {
|
||||
const f = forecastLines(def, lead);
|
||||
return `<button class="storm" data-key="${key}">
|
||||
<div class="name">${(def.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}</div>
|
||||
<div class="stat">sustained to ${peak.toFixed(0)} m/s (${kmh(peak).toFixed(0)} km/h)
|
||||
· gusts to ~${kmh(gustPeak).toFixed(0)} km/h</div>
|
||||
<div class="stat">rain ${rainPeak >= 0.8 ? 'heavy' : rainPeak >= 0.4 ? 'steady' : 'light'}
|
||||
${change ? `· southerly change at ${change.t}s` : '· no change forecast'}</div>
|
||||
<div class="name">${f.name || key.replace(/_/g, ' ').toUpperCase()}${f.night ? ' · NIGHT' : ''}</div>
|
||||
<div class="stat">${f.wind}</div>
|
||||
<div class="stat">${f.rain}</div>
|
||||
${f.confidence ? `<div class="stat" style="color:#8ba0ad">${f.confidence}</div>` : ''}
|
||||
</button>`;
|
||||
}).join('');
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import { assert, fixedLoop } from '../testkit.js';
|
||||
import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS } from '../contracts.js';
|
||||
import { loadStorm, createWind } from '../weather.js';
|
||||
import { loadStorm, createWind, forecastLines } from '../weather.js';
|
||||
import { createDebris } from '../debris.js';
|
||||
import { createSkyFx, RainShadow } from '../skyfx.js';
|
||||
import { SailRig, HARDWARE } from '../sail.js';
|
||||
@ -384,6 +384,36 @@ export default async function run(t) {
|
||||
`the camera changed the rain shadow: ${headless.rainShadow} vs ${viewed.rainShadow}`);
|
||||
});
|
||||
|
||||
// The card's copy, not just the model — this is what a player actually reads.
|
||||
t.test('forecastLines: tonight is exact, a week out hedges, never a bare NaN', () => {
|
||||
const def = storms.storm_02_wildnight;
|
||||
|
||||
const tonight = forecastLines(def, 0);
|
||||
assert(tonight.name === 'WILD NIGHT', `name reads "${tonight.name}"`);
|
||||
assert(tonight.night === true, 'the wild night should read as a NIGHT');
|
||||
assert(!/–/.test(tonight.wind), `tonight's wind should be exact, got "${tonight.wind}"`);
|
||||
assert(tonight.confidence === '', 'tonight should not print a confidence line — 100% is noise');
|
||||
assert(/hail likely/.test(tonight.rain), `tonight should call the hail: "${tonight.rain}"`);
|
||||
|
||||
const far = forecastLines(def, 1);
|
||||
assert(/–/.test(far.wind), `a week out should hedge with a range, got "${far.wind}"`);
|
||||
assert(/confidence/.test(far.confidence), 'a distant forecast should state its confidence');
|
||||
|
||||
// no NaN/undefined can reach the card, for any storm at any lead
|
||||
for (const [name, d] of Object.entries(storms)) {
|
||||
for (const lead of [0, 0.5, 1]) {
|
||||
const f = forecastLines(d, lead);
|
||||
for (const k of ['name', 'wind', 'rain', 'confidence']) {
|
||||
assert(typeof f[k] === 'string' && !/NaN|undefined/.test(f[k]),
|
||||
`${name} @lead ${lead}: ${k} reads "${f[k]}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// a hail-free storm must not advertise hail
|
||||
assert(!/hail/.test(forecastLines(storms.storm_01_gentle, 0).rain),
|
||||
'the gentle storm advertised hail it does not have');
|
||||
});
|
||||
|
||||
t.test('every storm in data/storms/ loads and validates', () => {
|
||||
// loadStorm throws on invalid, so reaching here with all of them is the pass
|
||||
assert(Object.keys(storms).length === STORMS.length, 'a storm failed to load');
|
||||
|
||||
@ -17,6 +17,58 @@ import {
|
||||
|
||||
export { GUST, validateStorm, 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)}`);
|
||||
|
||||
/**
|
||||
* 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}`;
|
||||
|
||||
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(),
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user