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>
This commit is contained in:
parent
ffe5ca2fbf
commit
8554de7824
@ -22,6 +22,9 @@ import {
|
||||
// SPRINT17, the second half of gate 1 (see weather.js's divider): what the
|
||||
// card PRINTS, and which yard a storm may be printed over.
|
||||
offerBand, PAIRING_LAW, pairingRefusal, pairingRefusals,
|
||||
// SPRINT18 gate 1 — the dispatch: the read with no paper behind it, and the
|
||||
// honesty gate extended to the storms nobody forecast.
|
||||
midStormRead, dispatchHonest,
|
||||
} from '../weather.js';
|
||||
import { loadSite, createWorld } from '../world.js';
|
||||
// SPRINT17 gate 1 — the board's weather side. NIGHTS ∪ POOL is every storm an
|
||||
@ -1644,6 +1647,194 @@ export default async function run(t) {
|
||||
+ 'nobody flew is the same offence in the other direction');
|
||||
});
|
||||
|
||||
// ═══ SPRINT18 GATE 1 — THE DISPATCH: what an honest read is when no paper comes ═══
|
||||
//
|
||||
// `offerBand`'s sin was OMISSION. The dispatch's sin is INVENTION, and these
|
||||
// cases are aimed at that inversion. The sharpest of them states the rule as an
|
||||
// invariant rather than as a vocabulary check: A READ AT t MAY NOT MOVE WHEN THE
|
||||
// STORM'S FUTURE BEYOND t MOVES. If it does, the card is printing something
|
||||
// nobody standing in that yard could know.
|
||||
//
|
||||
// ⚠️ NOT pinned by diffing storm_03_southerly against storm_03b_earlybuster,
|
||||
// which was my first instinct and is wrong: THREADS has them "identical in every
|
||||
// number the band prints", but they carry DIFFERENT SEEDS (30717/30818), so their
|
||||
// gust timelines differ and their instantaneous wind differs at every t. They are
|
||||
// twins on the FORECAST's numbers, not on the sim's. Isolating the variable means
|
||||
// constructing it, so these cases mutate a def's future and hold everything else.
|
||||
const AT = 30; // A's authored arriveAt (§1), the number ROADMAP names
|
||||
|
||||
/** A copy of `def` whose FUTURE past `t` is different and whose present is not. */
|
||||
const futureChanged = (def, t) => {
|
||||
const d = JSON.parse(JSON.stringify(def));
|
||||
// Curve points strictly after the first point at/after t cannot affect the
|
||||
// value AT t (piecewise-linear interpolation there brackets t). Move those.
|
||||
for (const key of ['dirCurve', 'baseCurve']) {
|
||||
const c = d[key];
|
||||
if (!Array.isArray(c)) continue;
|
||||
let first = c.findIndex((p) => p[0] >= t);
|
||||
if (first < 0) continue;
|
||||
for (let i = first + 1; i < c.length; i++) c[i][1] = -(c[i][1] ?? 0) * 1.7 - 0.3;
|
||||
}
|
||||
// A hail burst that has not started by t cannot be falling at t.
|
||||
if (d.hail && Array.isArray(d.hail.bursts)) {
|
||||
d.hail.bursts = d.hail.bursts.filter((b) => b.t <= t);
|
||||
d.hail.bursts.push({ t: t + 5, ramp: 0.5, hold: 9, fade: 1, intensity: 1 });
|
||||
}
|
||||
// Events are announcements; the read never reads them. Move them anyway.
|
||||
if (Array.isArray(d.events)) d.events = d.events.filter((e) => e.t <= t);
|
||||
return d;
|
||||
};
|
||||
|
||||
t.test('GATE 1: the mid-storm read cannot know the future (change the future, the read holds)', () => {
|
||||
const same = (a, b) => a.length === b.length
|
||||
&& a.every((l, i) => l.key === b[i].key && l.text === b[i].text);
|
||||
let futuresActuallyChanged = 0;
|
||||
for (const name of STORMS) {
|
||||
const def = storms[name];
|
||||
for (const t of [8, 20, AT]) {
|
||||
const now = midStormRead(def, t);
|
||||
const altered = midStormRead(futureChanged(def, t), t);
|
||||
assert(same(now, altered),
|
||||
`${name}: the read at t=${t} MOVED when only the storm's future moved.\n was: `
|
||||
+ `${now.map((l) => l.key + '=' + l.text).join(' | ')}\n now: `
|
||||
+ `${altered.map((l) => l.key + '=' + l.text).join(' | ')}\n `
|
||||
+ 'a dispatch that reads past t is inventing the paper it says it has not got');
|
||||
// VACUITY GUARD: the mutation must actually alter the storm, or the case
|
||||
// above is comparing a def to itself. Read them both LATER than t.
|
||||
const later = Math.min((def.duration ?? 90) - 1, t + 12);
|
||||
if (!same(midStormRead(def, later), midStormRead(futureChanged(def, t), later))) {
|
||||
futuresActuallyChanged++;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert(futuresActuallyChanged >= STORMS.length,
|
||||
`only ${futuresActuallyChanged} of ${STORMS.length * 3} mutations changed the storm at all — `
|
||||
+ 'the invariant above is being satisfied by a no-op mutation, not by an honest read');
|
||||
});
|
||||
|
||||
t.test('GATE 1: the read never carries a forecast-only fact, by key or by number', () => {
|
||||
for (const name of STORMS) {
|
||||
const def = storms[name];
|
||||
const s = stormStats(def);
|
||||
for (const t of [8, AT, 60]) {
|
||||
const read = midStormRead(def, t);
|
||||
for (const l of read) {
|
||||
// the offer band's forward-looking keys have no business on a dispatch
|
||||
assert(l.key !== 'change' && l.key !== 'confidence',
|
||||
`${name} @${t}: the read carries a '${l.key}' line — that is forecast paper`);
|
||||
}
|
||||
const all = read.map((l) => l.text).join(' ');
|
||||
// the two numbers only a bureau could hand you
|
||||
if (s.changeAt != null && s.changeAt > t) {
|
||||
assert(!all.includes(String(Math.round(s.changeAt))),
|
||||
`${name} @${t}: the read prints ${Math.round(s.changeAt)} and the change lands at `
|
||||
+ `${s.changeAt} — nobody told you when it swings`);
|
||||
}
|
||||
if (s.hailSeconds > 0) {
|
||||
assert(!all.includes(`${s.hailSeconds.toFixed(0)}s of it`),
|
||||
`${name} @${t}: the read prints the storm's TOTAL hail duration — that is the forecast's number`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
t.test('GATE 1: the admission is unconditional — every storm, every t, same words', () => {
|
||||
// The subtle half of the design. On an offer card a missing hail line means
|
||||
// THE STORM DOES NOT HAIL. On a dispatch it means NOBODY KNOWS. Silence cannot
|
||||
// tell those apart, so the read says so out loud — and it must say it in the
|
||||
// SAME words every time, because a line that appears only when there is
|
||||
// something to not-know becomes the very fact it claims not to have.
|
||||
const texts = { nopaper: new Set(), unknown: new Set() };
|
||||
let reads = 0;
|
||||
for (const name of STORMS) {
|
||||
for (let t = 1; t < (storms[name].duration ?? 90); t += 7) {
|
||||
const read = midStormRead(storms[name], t);
|
||||
reads++;
|
||||
assert(read[0].key === 'nopaper',
|
||||
`${name} @${t}: the read does not OPEN with the no-forecast line — it frames every line under it`);
|
||||
assert(read[read.length - 1].key === 'unknown',
|
||||
`${name} @${t}: the read does not CLOSE with what you cannot know`);
|
||||
for (const l of read) if (texts[l.key]) texts[l.key].add(l.text);
|
||||
}
|
||||
}
|
||||
assert(reads > 60, `only ${reads} reads sampled — widen the grid`);
|
||||
for (const k of ['nopaper', 'unknown']) {
|
||||
assert(texts[k].size === 1,
|
||||
`the '${k}' line has ${texts[k].size} different wordings across the shipped storms — `
|
||||
+ `it is conditional on something, and its presence is then information the observer `
|
||||
+ `cannot have:\n ${[...texts[k]].join('\n ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
t.test('GATE 1: the honesty gate covers emergency storms — a storm nobody can describe cannot be dispatched', () => {
|
||||
// "A storm that cannot be described honestly cannot be DISPATCHED either."
|
||||
// The storm-side gate applies unchanged; the arrival adds its own three.
|
||||
for (const name of STORMS) {
|
||||
const r = dispatchHonest(storms[name], AT, name);
|
||||
assert(r.ok, `${name} cannot be honestly dispatched at t=${AT}:\n ${r.errors.join('\n ')}`);
|
||||
}
|
||||
|
||||
// NEGATIVE CONTROLS — four, because a gate that only ever says yes is decoration.
|
||||
const base = storms.storm_03_southerly;
|
||||
assert(!dispatchHonest(base, 0, 'x').ok,
|
||||
'arriveAt 0 was accepted — that is not a mid-storm arrival, it is an ordinary night');
|
||||
assert(!dispatchHonest(base, base.duration ?? 90, 'x').ok,
|
||||
'arriveAt at the storm\'s end was accepted — you would be dispatched to finished weather');
|
||||
assert(!dispatchHonest(base, 500, 'x').ok, 'arriveAt past the end was accepted');
|
||||
|
||||
// THE S17 FILING, CORRECTED BY MEASUREMENT. I filed "stoneWord has no floor,
|
||||
// size 0 words itself fine pea" — and that hole was already shut one layer up:
|
||||
// validateStorm refuses size <= 0, so this is the case proving the gate above
|
||||
// does the work, not a floor check in forecastHonest.
|
||||
const zero = JSON.parse(JSON.stringify(storms.storm_06_soaker));
|
||||
zero.hail.size = 0;
|
||||
assert(!dispatchHonest(zero, AT, 'zero').ok, 'hail.size 0 was dispatched');
|
||||
|
||||
// ⚠️ THE REAL HOLE, found by probing the layer that DOES pass: the validator
|
||||
// only checks a size that is PRESENT (`hd.size != null &&`), and hailSize reads
|
||||
// `size ?? 1`. So a hailing storm with NO authored size prints "marble stones"
|
||||
// — the middle of the vocabulary, stated as fact, authored by nobody. On the
|
||||
// soaker that turns a real 0.45 "fine pea" into a stone 2.2x bigger, on the one
|
||||
// axis the fabric bet is decided on.
|
||||
const sizeless = JSON.parse(JSON.stringify(storms.storm_06_soaker));
|
||||
delete sizeless.hail.size;
|
||||
assert(stormStats(sizeless).hailSeconds > 1,
|
||||
'the sizeless fixture does not actually hail — this case would prove nothing');
|
||||
const sr = dispatchHonest(sizeless, AT, 'sizeless');
|
||||
assert(!sr.ok, 'a storm that hails with no authored hail.size was dispatched — the card '
|
||||
+ 'would state "marble stones" as fact for a stone nobody wrote');
|
||||
assert(sr.errors.some((e) => /hail\.size is not authored/.test(e)),
|
||||
`the refusal does not say WHY: ${sr.errors.join('; ')}`);
|
||||
// and the ceiling still bites, so the new check did not displace it
|
||||
const boulders = JSON.parse(JSON.stringify(storms.storm_06_soaker));
|
||||
boulders.hail.size = 2.6;
|
||||
assert(!dispatchHonest(boulders, AT, 'boulders').ok,
|
||||
'a 2.6 stone was dispatched — the vocabulary ceiling stopped biting');
|
||||
});
|
||||
|
||||
t.test('GATE 1: every line of the read is reachable, and each one is earned', () => {
|
||||
// A vocabulary with an unreachable word is a vocabulary that has never been
|
||||
// read. Walk the shipped storms and prove each key fires somewhere — and that
|
||||
// the two conditional-on-weather keys do NOT fire where the weather is absent.
|
||||
const where = {};
|
||||
for (const name of STORMS) {
|
||||
for (let t = 0.5; t < (storms[name].duration ?? 90); t += 0.25) {
|
||||
for (const l of midStormRead(storms[name], t)) (where[l.key] ??= []).push(name);
|
||||
}
|
||||
}
|
||||
for (const k of ['nopaper', 'wind', 'unknown', 'lull', 'front', 'rain', 'veer', 'hail', 'ground']) {
|
||||
assert(where[k]?.length, `no shipped storm ever produces a '${k}' line — it is unreachable vocabulary`);
|
||||
}
|
||||
// storm_01_gentle is the calm-day preload: no hail, no change. It must not
|
||||
// grow a hail line or a swing, and this is the case that would catch a read
|
||||
// that manufactures weather to fill a card.
|
||||
const gentle = new Set();
|
||||
for (let t = 0.5; t < 90; t += 0.25) for (const l of midStormRead(storms.storm_01_gentle, t)) gentle.add(l.key);
|
||||
assert(!gentle.has('hail') && !gentle.has('ground'),
|
||||
'the gentle night reads hail or stones on the ground, and it has neither');
|
||||
assert(!gentle.has('veer'), 'the gentle night reads a wind swing, and it has no change');
|
||||
});
|
||||
|
||||
// ── 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.
|
||||
|
||||
@ -192,6 +192,33 @@ export function forecastHonest(def, name = def?.name ?? 'storm') {
|
||||
+ `(${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) => {
|
||||
@ -404,6 +431,221 @@ export function pairingRefusals(pairings = []) {
|
||||
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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user