Merge remote-tracking branch 'origin/lane/c'
# Conflicts: # THREADS.md
This commit is contained in:
commit
400fffa9ec
276
THREADS.md
276
THREADS.md
@ -10037,3 +10037,279 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
|
||||
checks the types the round-trip fixture actually PLACES, not every row in PALETTE, so a hidden
|
||||
row adds no assert and reddens nothing. **Integrator: no action — the two halves fit at merge
|
||||
with no third commit.**
|
||||
[C] 2026-07-25 — 🚨 **A, B — STOP AND READ THIS BEFORE YOU WIRE GATE 1: `setPhase('storm', {at:30})`
|
||||
DOES NOT MOVE THE CLOTH. Measured on YOUR OWN shipped emergency** (night 5,
|
||||
`storm_03_southerly`, `arriveAt: 30`): main.js passes `windT=30` on every frame and the sail
|
||||
samples **t = 0.000 .. 0.983**. `rig.t` reads 1.0 after one second of an emergency night.
|
||||
|
||||
| at the moment you get out of the ute | the sky, garden, debris, player | the SAIL |
|
||||
|---|---|---|
|
||||
| wind on the fabric | 9.074 m/s | **4.468 m/s** |
|
||||
| → load, going with v² | 1.0× | **0.24× — a 4.12× understatement** |
|
||||
| rain driving the pond | 5.000 mm/hr | **0.054 mm/hr — 93× light** |
|
||||
|
||||
A — your §4 says *"everything downstream is untouched by construction — `windTime()` already
|
||||
returns `phaseT`"*. That is exactly right for everything that takes `t` as an ARGUMENT, and I
|
||||
have pinned it: the wind field is closed-form and I measured it order-independent (read t=30
|
||||
cold, or after walking the whole 0→30 ladder, or after scrambling 90/0/45/12/88/30/1/-5 —
|
||||
byte-identical, on every storm, on five observables). Your phase entry is sound and the whole
|
||||
callout stands on that property. **But `SailRig.step(dt, wind, t, debris)` accepts `t` and
|
||||
never reads it** — sail.js:426 passes `this.t`, its own fixed-step clock, and `attach()` zeroes
|
||||
it. So the one object in the yard that can FAIL is the one object that never hears about
|
||||
`arriveAt`.
|
||||
|
||||
**This is SPRINT13's bug arriving through the new door, and sail.js's own comment names the
|
||||
precondition gate 1 breaks.** That comment (sail.js:189-218) closes with: *"commit → attach →
|
||||
game.advance() → storm all land in a single keypress, so t=0 at attach IS t=0 at the first
|
||||
storm frame."* True for every night this repo has ever shipped. An emergency night has no
|
||||
commit, no prep and no keypress, and t=0 at attach is then t=30 at the first storm frame. The
|
||||
SPRINT13 measurement was a 30-50% weaker storm and it was called a determinism break; this one
|
||||
is 4.12× on load and 93× on rain.
|
||||
|
||||
**B — THE FIX IS ONE LINE AND IT IS YOURS.** Do NOT make `_substep` read the passed `t` — that
|
||||
reintroduces ragged sampling and throws away the reason `this.t` exists. Seed the clock
|
||||
instead: `attach(anchorIds, hwChoices, tension, {at = 0})` → `this.t = at`, and the fixed-step
|
||||
clock carries on from 30 with every byte-equality guarantee intact. A can then hand `arriveAt`
|
||||
through at the same boundary it hands the inherited rig through.
|
||||
|
||||
⚠️ **AND THE HARDER HALF, B, which the one line does NOT fix: a rig with `rig.t = 30` still has
|
||||
NO 30 seconds of history** — flat, dry, unstressed cloth in a 9 m/s wind, zero ponded water,
|
||||
zero corner overload, `peakLoad` 0. Two honest options and it is your ruling:
|
||||
· **author it** — A's `broken: true` per corner, and the arrival state is a declared fiction.
|
||||
Cheap, and it means your triage measurement is made against a rig nobody flew.
|
||||
· **fly it** — warm the inherited rig through the real storm 0→30 on the fixed ladder, and let
|
||||
the corner break because the storm broke it. The pre-broken corner stops being a flag and
|
||||
becomes a receipt; A's §2 note that it "cannot be done from public moves alone" dissolves,
|
||||
because nothing breaks it but the weather. `debris.enterAt()` is the precedent and my pin is
|
||||
the shape of the assert. It also answers your gate-1 question — *"is the inherited rig
|
||||
triageable"* — against the storm the player actually stands in.
|
||||
I have no vote on which; I do have a hard vote that the number you measure triage against must
|
||||
come from a rig that has heard of `arriveAt`. **⚠️ Your gate-2 job (the two harnesses disagree
|
||||
on the wildnight) is in this blast radius**: `balance.test.js`'s `fly()` already carries the
|
||||
same skew — `prep()` advances `rig.t` to ~14 s on the calm settle, then the storm loop passes
|
||||
`t = i*FIXED_DT` which is DISCARDED, so the cloth flies from t≈14 while `sky` flies from t=0.
|
||||
If you are hunting a variable between two harnesses, that is a variable between two harnesses.
|
||||
|
||||
[C] 2026-07-25 — 🔒 **JOB 2 LANDED: DETERMINISM AT A NON-ZERO START, PINNED — and two live breaks
|
||||
found in my own file on the way.** Selftest **527/0/0** (+5 on 522: 2 in weather.selftest.js so
|
||||
the node runner carries them too, 3 in c.test.js).
|
||||
|
||||
**The result, in one line: the WEATHER is exact at any start and always was; the YARD's history
|
||||
is not, and one part of it was silently lying about that.** Three classes, and the entry has to
|
||||
treat them differently — this is the ruling I want on the record because every future
|
||||
mid-storm consumer will meet it:
|
||||
1. **closed-form in t** — the wind field, rain rate, hail rate, gust timeline, skyfx's garden
|
||||
exposure. Teleportable, verified. Order-independence was ALREADY pinned in S-something
|
||||
(`sample order independent`, one storm, `vecAt` only) — I broadened it to every storm and
|
||||
five scalar observables and added the exact callout ladder, but I did not discover it and
|
||||
will not claim it.
|
||||
2. **should be a pure function of its own event, and wasn't** — debris crate jitter. Fixed.
|
||||
3. **a genuine path integral** — the leaf layer, cloth shape, ponded water, corner overload,
|
||||
player exposure. Cannot be teleported by anybody, ever. Run it or author it, and say which.
|
||||
|
||||
**⚠️ TWO LIVE BREAKS ON MAIN, neither needing the callout to fire.** Measured, then fixed:
|
||||
| | |
|
||||
|---|---|
|
||||
| same storm twice through one debris module | night 1 crate y **1.582 m**, night 2 y **1.165 m** |
|
||||
| entry at t=45, past the wildnight's t=38 event | later crates wore the SKIPPED crate's y/spin/phase — stream 5 draws behind |
|
||||
One cause: jitter came off a shared stream whose POSITION encoded history. `clear()` rewound
|
||||
`leafDist`/`leafEma` and not the PRNG, so **night 2 of the shipped seven-night week has never
|
||||
flown night 1's crates** — main.js builds one debris module at boot. Nothing looked, because no
|
||||
test had ever asserted a crate's jitter at all. Keyed on `ev.t` now; a crate is a pure function
|
||||
of its event, and both breaks close structurally rather than by a rewind call somebody has to
|
||||
remember. **Today's data hid the second one**: the earliest debris event in ANY shipped storm is
|
||||
t=38, so an `arriveAt: 30` entry skips nothing and reads green by coincidence — the same
|
||||
calm-day vacuity as A's `stormsToPreload` and my own S17 pairing walk. It fires at
|
||||
`arriveAt: 45`, and A's §3 says the entry time is authored.
|
||||
|
||||
**`debris.enterAt(t0, dt)` — the arrival runs the ladder, it does not skip it.** For class 3.
|
||||
Measured: a t=0 run has **6 leaves** crossing the grass at t=30, a cold entry has **0**, and it
|
||||
stays becalmed ~7 s while the 2.5 s EMA climbs off zero. DESIGN.md line 149 makes that moving
|
||||
grass the gust front's tell and gate 1's premise is that the grass is the only briefing you
|
||||
get — so losing it for seven seconds robs exactly the read the dispatch asks the player to
|
||||
make. **D — this is your lull/repair-window read too**: the tell is live from the first frame
|
||||
of an emergency now.
|
||||
|
||||
⚠️ **THE SMALLEST LESSON AND MY FAVOURITE.** I wrote `enterAt` as `i * dt`. Pin went red: 6
|
||||
leaves in both yards, in different places. **`i * dt` is the more ACCURATE ladder and that is
|
||||
precisely why it is wrong** — main.js's `phaseT += dt`, testkit's `fixedLoop` and sail.js's
|
||||
`this.t += SIM_DT` all accumulate. Over 1800 steps the two ladders drift 4.2e-13 s apart, pass
|
||||
a different `t` on **1770 of 1800** steps, and first disagree at step **six**
|
||||
(0.09999999999999999 against 0.1). A number gathered from a harness a hair BETTER than the
|
||||
game's is still a number from the wrong harness. `enterAt` accumulates.
|
||||
|
||||
**MUTATIONS — four, all red, all restored.** (1) jitter back to a shared stream → both crate
|
||||
cases red with the exact numbers above. (2) `enterAt` teleports → 0 leaves against 6. (3)
|
||||
`enterAt` multiplies instead of accumulating → 6 against 6, in different places. (4) a resume
|
||||
cursor on `uniformSpeed` (a plausible perf win — the scan is sorted and already breaks early)
|
||||
→ the scrambled sub-case red at 14.6 m/s against 18.576, a 21% understatement of the gust peak,
|
||||
and the backwards-scan case red on storm_01.
|
||||
|
||||
**⚠️ DISCLOSED — an assert I wrote that COULD NOT FAIL, caught by mutating it rather than
|
||||
reading it.** My first cut of the second case called `stormStats(def)` twice, before and after
|
||||
sampling, and asserted the fields matched. `stormStats` memoises per def in a WeakMap: the
|
||||
second call hands back **the same object**, so it was comparing an object to itself and was
|
||||
green by identity. Rewritten as what it was actually reaching for — scan the field BACKWARDS
|
||||
from the end and insist the peak matches what the dispatch quotes. Two harnesses, one number,
|
||||
in the one scan order no production code uses. That version catches mutation 4; the first
|
||||
version caught nothing.
|
||||
|
||||
[C] 2026-07-25 — 🚨⚖️ **GATE 1 (C's half) LANDED: THE DISPATCH — `midStormRead(def, t)`, and the
|
||||
ruling on what HONEST means when the honest answer is "nobody told you".** Built against A's
|
||||
§8 exactly (ordered `{key, text}`, the `offerBand` construction, reachable through
|
||||
`weather.midStormRead?.()` under a namespace import — verified on that exact call path).
|
||||
Selftest **532/0/0** (+5 on 527, all in c.test.js).
|
||||
|
||||
**THE INVERSION IS THE WHOLE DESIGN, and it took measuring the other card to see it.**
|
||||
`offerBand`'s sin is OMISSION — the board lied about night 7 by printing two of the fields it
|
||||
held. **A dispatch's sin is the opposite: INVENTION.** And the consequence is not that the
|
||||
read is *vaguer* than a forecast. It is SHARPER. Standing in the yard you know the wind on
|
||||
your back to the km/h, better than any bureau ever told you — so the present tense is EXACT
|
||||
here, where the offer card was banded. What you do not have is everything the paper would
|
||||
have said: how long this runs, what the peak will be, whether a southerly lands at t=48.
|
||||
|
||||
So the read is ruled by **TENSE**, and I'll defend these four lines as the gate:
|
||||
· **NOW** — exact, unhedged. You are standing in it.
|
||||
· **THE VISIBLE IMMINENT** — a gust front crossing the grass. DESIGN.md line 149 makes it
|
||||
readable and the telegraph window is 1.5 s; that is the entire horizon, and you earned it
|
||||
by looking at grass rather than at paper.
|
||||
· **THE PAST, AS WRITTEN ON THE YARD** — the one that makes the design work, and the answer
|
||||
to the problem I got stuck on for a while: at t=30 you have NO MEMORY of the storm, so
|
||||
"it's easing" is not available to you — you were driving. But **hail lying in the grass and
|
||||
water standing on the flat are a record of the last 30 seconds that you did not have to be
|
||||
present for.** Reading a yard for what has already gone wrong is literally this game's other
|
||||
job type (DESIGN.md's cowboy line: "half the puzzle is finding what's about to fail").
|
||||
Integrals over [0, t] are honest; over (t, ∞) they are not. That is the whole rule.
|
||||
· **THE FUTURE** — forbidden. Not hedged, not banded. Absent.
|
||||
|
||||
⚠️ **AND THE ADMISSION IS MANDATORY — the subtle half, and the bit I'd argue hardest for.**
|
||||
On an offer 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 tell them apart** — a player fresh off six forecast cards reads an absent line
|
||||
as a reassurance nobody gave. So the read SAYS it, first (`nopaper`) and last (`unknown`).
|
||||
The honest answer to "what does honest look like with no paper" is that you print the words
|
||||
"nobody briefed it", on the card, in the order a person reads.
|
||||
|
||||
⚠️ **THE IGNORANCE LINE IS UNCONDITIONAL, and it has to be.** The tempting version says "and
|
||||
it may still swing" only when the def HAS a change event — which makes the line's PRESENCE
|
||||
the very fact it claims not to have. Exactly `offerBand`'s "hail line iff it hails" run
|
||||
backwards: there, presence is information the forecast legitimately holds; here it 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.
|
||||
|
||||
**THE LINES:** `nopaper` · `wind` · `veer?` · `front?`|`lull?` · `hail?` · `rain?` · `ground?`
|
||||
· `unknown`. Every one reachable on shipped data (pinned; 5–6 storms each), and the gentle
|
||||
night — no hail, no change — must NOT grow a hail, ground or veer line, which is the case that
|
||||
catches a read manufacturing weather to fill a card.
|
||||
|
||||
**A — YOUR NIGHT 5, RENDERED. And `arriveAt: 30` is a better number than you may know:**
|
||||
`storm_03_southerly`'s windchange event is AT t=30, and its dirCurve is swinging −0.21 rad/s
|
||||
right there. So the dispatch's most dramatic line is free, honest and observable —
|
||||
· no forecast on this one — nobody briefed it
|
||||
· 32 km/h on it right now
|
||||
· **the wind is backing round on you as you stand there**
|
||||
· nothing on it this second — if you are moving, move now
|
||||
· rain at 5 mm/hr
|
||||
· how long it runs, what it does next, whether it swings again — no paper, no quote.
|
||||
You find that out standing in it.
|
||||
The veer and the lull firing together is not a contradiction, it is a southerly change: it
|
||||
goes quiet and comes back from the other quarter. A lull that is also a trap, and the card
|
||||
tells the truth about both and lets the player decide. The soaker at t=30 reads
|
||||
`stones lying in the grass — it has been through here a while` instead: past tense, off the
|
||||
yard, on the storm whose whole trap is hail.
|
||||
|
||||
⚠️ **WHAT I DELIBERATELY DID NOT PRINT: a compass word.** `midStormRead(def, t)` has a def and
|
||||
no site, and direction only means something against a reference frame the player shares. The
|
||||
fiction calls the change a "southerly" but there is no dir→compass map in this repo, and
|
||||
inventing one to make a card read nicer is the same offence as inventing the forecast. The
|
||||
read reports THAT it is swinging, never which way and never that one is coming. **B/D — the
|
||||
yard-relative version ("it is loading the north-west corner") is the useful one and it needs
|
||||
the rig; it is yours if you want it, and I will not guess at it from a storm def.**
|
||||
|
||||
**THE HONESTY GATE, EXTENDED — `dispatchHonest(def, t)`.** My brief: a storm that cannot be
|
||||
described honestly cannot be dispatched either. `forecastHonest`'s storm-side gate applies
|
||||
UNCHANGED — an emergency night is still worded, priced, invoiced, warrantied and rep'd through
|
||||
A's §10 surfaces, and having no forecast card is not a licence for an indescribable storm; it
|
||||
RAISES the bar, because the read is the only description there is and there is no band to hide
|
||||
vagueness in. On top, three things only an ARRIVAL can get wrong: the arrival must be inside a
|
||||
storm still running (`arriveAt` 0, `= duration` and past it are all refused — three negative
|
||||
controls); the read must not come out mute (the `nopaper`/`wind`/`unknown` lines are required,
|
||||
so a dispatch cannot be silent about its own silence); and the stone must be real at the
|
||||
instant you arrive.
|
||||
|
||||
⚠️ **MY OWN S17 FILING WAS WRONG, AND I FOUND OUT BY TESTING IT INSTEAD OF IMPLEMENTING IT.**
|
||||
I filed forward: *"`stoneWord` has no FLOOR — a def with `hail.size: 0` words itself 'fine pea
|
||||
stones'"*, with a suggested one-liner. **That hole was already shut one layer up:**
|
||||
`validateStorm` refuses `size <= 0`, and `forecastHonest` runs the validator FIRST and returns
|
||||
early — so the check I filed for would have been dead code, and I never checked the gate above
|
||||
the gate. Recorded rather than quietly dropped: filing a hole without probing the layer 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.** The validator
|
||||
reads `hd.size != null &&` — it only checks a size that is PRESENT — and `hailSize` reads
|
||||
`hailDef.size ?? 1`. So **a storm that hails with no authored `hail.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. Now
|
||||
refused, with the validator's own words for the neighbouring case as the precedent ("that's a
|
||||
typo, not a design, so say so"). The ceiling still bites at 2.6 — pinned, so the new check did
|
||||
not displace the old one.
|
||||
|
||||
**THE PIN I'M PROUDEST OF, because it states the rule instead of policing a vocabulary: THE
|
||||
READ AT t MAY NOT MOVE WHEN THE STORM'S FUTURE BEYOND t MOVES.** Mutate a def's future — flip
|
||||
the dirCurve tail past the bracketing point, drop the hail bursts that have not started, move
|
||||
the events — and the read at t must be character-identical, on every storm at t = 8, 20 and 30.
|
||||
Carries its own vacuity guard: the same mutated defs are read again LATER than t and must
|
||||
differ there, or the invariant is being satisfied by a no-op mutation rather than by an honest
|
||||
read. ⚠️ **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 and their
|
||||
instantaneous wind differ at every t. They are twins on the FORECAST's numbers, not on the
|
||||
sim's — a trap I set for myself in S17 and nearly walked into in S18.
|
||||
|
||||
**MUTATIONS — four, all red, all restored.** (1) the read leaks `changeAt` → the future-
|
||||
invariant case AND the forecast-fact case, independently. (2) the ignorance line made
|
||||
conditional on a pending change → the unconditional case, plus the future-invariant case
|
||||
caught it too, which is two pins disagreeing with the same lie for different reasons. (3) the
|
||||
absent-size check removed → the gate case, naming "marble stones" in the message. (4) the hail
|
||||
line made unconditional → the gentle night grows hail it does not have.
|
||||
|
||||
[C] 2026-07-25 — ✅ **CLOSING: THE HONESTY GATE NOW WALKS A's `EMERGENCIES` — both arms — and my
|
||||
count is final at 533/0/0** (+11 on the 522 baseline: +5 the non-zero-start pin, +5 the
|
||||
dispatch, +1 the emergency walk).
|
||||
|
||||
NIGHTS ∪ POOL grew a third member this sprint and it is the member with the most to hide: no
|
||||
forecast card, no quote, a storm entered at a `t` nobody walked up to. Each entry is checked
|
||||
on BOTH arms — `dispatchHonest(def, arriveAt)` for the storm-and-instant, and
|
||||
`pairingRefusal(storm, site)` for the yard, because a read can be flawless about a storm and
|
||||
still be an invitation to a night nobody can work. **That is worse on a callout than on an
|
||||
offer**: the board's bad pairing you can decline, and a dispatch you are already standing in.
|
||||
|
||||
**A — verified against your real tree, out of band, before you merge:** I ran my gate over
|
||||
`origin/lane/a`'s `board.js`. `corner_block_emergency` (night 5, `storm_03_southerly` ×
|
||||
`site_02_corner_block`, `arriveAt: 30`) passes both arms — `dispatchHonest` OK, pairing legal.
|
||||
The walk reads `board.EMERGENCIES` through a **namespace import** so c.test.js does not
|
||||
hard-error in the window where your spine is on lane/a and not in my tree — your own reasoning
|
||||
for `weather.midStormRead?.()`, handed back. It also always walks a FIXTURE mirroring your §6,
|
||||
so it is exercised on known data today instead of reading green off an absent import. Three
|
||||
vacuity guards: the pairing arm (soaker × backyard_01 must refuse), the storm arm (a hailing
|
||||
storm with no authored size must refuse), and a late arrival (`arriveAt` 95 into a 90 s storm).
|
||||
|
||||
**WHAT I OWE NOBODY AND FLAG ANYWAY — three, in priority order:**
|
||||
1. ⚠️ **B/A: the sail clock. Nothing has changed about it and it is still the sprint's biggest
|
||||
number** — 4.12× on load, 93× on rain, measured on A's own shipped emergency. See my filing
|
||||
above. Gate 1 can be wired, demoed and merged without noticing, because the sky, the garden
|
||||
and the debris all move correctly and only the cloth is in the wrong storm. It will read as
|
||||
"triage is easy" rather than as a bug.
|
||||
2. **A: `stormsToPreload` is still green by coincidence** and it is now MY coincidence too —
|
||||
the emergency's storm is `storm_03_southerly`, which is already in NIGHTS, so a pool-only
|
||||
or emergency-only storm still never bites. Carried from S17's [I], unchanged.
|
||||
3. **D: the lull is a real read now and it is yours to spend.** `midStormRead`'s `lull` line
|
||||
("nothing on it this second — if you are moving, move now") is the repair-window read off
|
||||
DESIGN.md line 149, and `debris.enterAt` means the moving grass is live from the first frame
|
||||
of an emergency instead of seven seconds in. On `storm_03_southerly` at `arriveAt: 30` the
|
||||
lull and the veer fire TOGETHER, which is not a contradiction — it is a southerly change,
|
||||
going quiet before it comes back from the other quarter. A repair window that is also a
|
||||
trap, printed honestly, and the knife decision sits right on top of it.
|
||||
|
||||
@ -46,7 +46,39 @@ export function createDebris(o = {}) {
|
||||
// contracts.js documents world.heightAt as the thing Lane C bounces debris off.
|
||||
// Flat fallback so this still runs against a graybox yard.
|
||||
const groundAt = o.heightAt || (() => o.groundY ?? 0);
|
||||
const rand = rng(((wind && wind.seed) || 1) ^ 0x5eed1e);
|
||||
|
||||
/**
|
||||
* A crate's jitter is keyed on THE EVENT, not on a shared stream. [SPRINT18]
|
||||
*
|
||||
* This was `const rand = rng(seed ^ 0x5eed1e)` — one stream, drawn from inside
|
||||
* spawn(). A stream's POSITION encodes how many pieces have already spawned,
|
||||
* so history leaked into every later crate, and it cost us two determinism
|
||||
* breaks measured on the wildnight (crate heights, m):
|
||||
*
|
||||
* the same storm twice through one module night 1 y 1.582 night 2 y 1.165
|
||||
* (main.js builds ONE debris module at boot and clear()s it per phase
|
||||
* change; clear() rewound leafDist/leafEma and NOT this stream, so night 2
|
||||
* of the seven-night week flew different crates than night 1 — live on
|
||||
* main today, no emergency callout required.)
|
||||
* an entry at t=45, past the t=38 event t=0 run y 0.937 sx 0.394 ph 4.413
|
||||
* t=45 y 1.623 sx 0.581 ph 0.839
|
||||
* (every crate after a skipped event wore the SKIPPED crate's clothes: the
|
||||
* stream sat 5 draws behind. Skipping history corrupted the future, not
|
||||
* just the present — the thing that makes a mid-storm arrival unpinnable.)
|
||||
*
|
||||
* Keyed on `ev.t` (stable authored data, unique per event in every shipped
|
||||
* storm) so the crate at t=38 is the same crate whether you watched the first
|
||||
* 38 seconds or arrived at 30. Manual spawns — A's debug keys, tuning — have no
|
||||
* `ev.t` and fall back to the spawn time, which differs per keypress.
|
||||
*
|
||||
* This is the same move weather.core.js's header argues for: determinism
|
||||
* STRUCTURAL, not a promise. A crate is now a pure function of its event.
|
||||
*/
|
||||
const seedBase = (((wind && wind.seed) || 1) ^ 0x5eed1e) | 0;
|
||||
const eventRng = (ev, t) => {
|
||||
const key = Math.round((Number.isFinite(ev.t) ? ev.t : t) * 1000);
|
||||
return rng((seedBase ^ Math.imul(key, 374761393)) | 0);
|
||||
};
|
||||
|
||||
const pieces = [];
|
||||
const w = new THREE.Vector3();
|
||||
@ -122,6 +154,7 @@ export function createDebris(o = {}) {
|
||||
function spawn(ev, t) {
|
||||
const spec = { ...(MODEL_SPEC[ev.model] || DEFAULT_SPEC) };
|
||||
if (Number.isFinite(ev.mass)) spec.mass = ev.mass;
|
||||
const rand = eventRng(ev, t); // this crate's own stream — see eventRng above
|
||||
|
||||
// upwind of the yard, offset sideways, so it crosses the whole thing
|
||||
const d = wind.dirAt(t);
|
||||
@ -282,6 +315,57 @@ export function createDebris(o = {}) {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* ARRIVE MID-STORM at t0. [SPRINT18 — the emergency callout's entry]
|
||||
*
|
||||
* The storm ran; you just weren't there. Three classes of state, and only one
|
||||
* of them can be teleported:
|
||||
*
|
||||
* 1. closed-form in t — the wind field, rain rate, hail rate. Measured
|
||||
* order-independent (sample t=30 cold, or after walking 0→30, or after
|
||||
* scrambling 90/0/45/12/88/30: byte-identical). Needs nothing.
|
||||
* 2. a pure function of its own event — crate jitter, as of eventRng above.
|
||||
* Needs nothing, now.
|
||||
* 3. a genuine path integral — `leafDist` (metres travelled downwind) and
|
||||
* `leafEma` (2.5 s smoothed speed). These CANNOT be teleported, and
|
||||
* pretending otherwise is what made the arrival read wrong: measured on
|
||||
* the wildnight, a t=0 run has 6 leaves crossing the grass at t=30 and a
|
||||
* cold entry has 0 — and stays becalmed for ~7 s while the EMA climbs
|
||||
* off zero. DESIGN.md line 149 makes the moving grass the gust front's
|
||||
* tell, and gate 1's whole premise is that the sky and the grass are the
|
||||
* only briefing a callout gets. Losing the tell for the first seven
|
||||
* seconds robs exactly the read the dispatch is asking the player to make.
|
||||
*
|
||||
* So class 3 is RUN, not skipped: this is `fixedLoop(t0, dt, step)` and
|
||||
* nothing more — the same ladder, the same code path, no shortcut. It is
|
||||
* cheap because the ambient layer is two scalars and a wind sample (crates
|
||||
* spawn, fly and despawn honestly on the way past). Do NOT "optimise" this
|
||||
* into a teleport; the pin in c.test.js exists to catch that, and the
|
||||
* negative control beside it records what the teleport actually costs.
|
||||
*
|
||||
* ⚠️ The ladder ACCUMULATES — `t += dt`, not `i * dt`. I wrote it as `i * dt`
|
||||
* first (exact multiplication, no drift) and the pin went red: 6 leaves in
|
||||
* both yards, in different places. `i * dt` is the MORE ACCURATE ladder and
|
||||
* that is precisely why it is wrong — main.js's `phaseT += dt`, testkit's
|
||||
* `fixedLoop` and sail.js's `this.t += SIM_DT` all accumulate, so a warm-up
|
||||
* that multiplies reproduces a storm the game never flies. Measured: over
|
||||
* 1800 steps the two ladders drift 4.2e-13 s apart in total, they pass a
|
||||
* different `t` on 1770 of those 1800 steps, and they first disagree at step
|
||||
* SIX (0.09999999999999999 against 0.1). A number gathered from a harness
|
||||
* that is a hair better than the game's is still a number from the wrong
|
||||
* harness — the class of bug this repo hunts, in its smallest possible form.
|
||||
*
|
||||
* @param {number} t0 storm time to arrive at, seconds
|
||||
* @param {number} dt fixed step — pass contracts.FIXED_DT, the same ladder
|
||||
* the caller will keep stepping on
|
||||
*/
|
||||
enterAt(t0, dt) {
|
||||
const steps = Math.round(t0 / dt);
|
||||
let t = 0;
|
||||
for (let i = 0; i < steps; i++) { debris.step(dt, t, {}); t += dt; }
|
||||
return t;
|
||||
},
|
||||
|
||||
/** Drop everything (phase change, restart). */
|
||||
clear() {
|
||||
for (const p of pieces) despawn(p);
|
||||
|
||||
@ -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
|
||||
@ -29,6 +32,11 @@ import { loadSite, createWorld } from '../world.js';
|
||||
// without a test edit the day D's pool yard lands.
|
||||
import { NIGHTS, nightAt } from '../week.js';
|
||||
import { POOL, exposureOf } from '../board.js';
|
||||
// SPRINT18 gate 1: A's EMERGENCIES table read through a NAMESPACE import, so this
|
||||
// file survives the window where A's spine is on lane/a and not yet in my tree —
|
||||
// a named import of a not-yet-exported symbol is a hard module error, not a
|
||||
// graceful no-op. A's own reasoning for `weather.midStormRead?.()`, applied back.
|
||||
import * as board from '../board.js';
|
||||
// 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,
|
||||
// including on the day the game itself changed. "Two harnesses, one number"
|
||||
@ -208,6 +216,138 @@ export default async function run(t) {
|
||||
assert(gale.leafCount === 0, 'clear() left leaves flying into the aftermath card');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SPRINT18 gate 1 — THE MID-STORM ENTRY, debris half.
|
||||
//
|
||||
// The emergency callout arrives at t≈30. weather.selftest.js pins that the WIND
|
||||
// is the same storm however you got to t0 (it is closed-form, measured
|
||||
// order-independent). Debris was not, and it cost two determinism breaks that
|
||||
// I measured before writing a line — both recorded in debris.js beside the fix:
|
||||
//
|
||||
// · the same storm flown twice through one module spawned DIFFERENT crates
|
||||
// (wildnight crate height 1.582 m then 1.165 m). main.js builds one debris
|
||||
// module at boot and clear()s it per phase change; clear() rewound the leaf
|
||||
// accumulators and not the piece PRNG. Live on main today — night 2 of the
|
||||
// seven-night week never flew night 1's crates, and no test looked.
|
||||
// · an entry at t=45 (past the wildnight's t=38 event) gave every LATER crate
|
||||
// the skipped crate's height, spin axis and phase — the stream sat 5 draws
|
||||
// behind. Skipping history corrupted the FUTURE, not just the present.
|
||||
//
|
||||
// Both had one cause: a crate's jitter came off a shared stream whose POSITION
|
||||
// encoded how much history had happened. It is now keyed on the event itself.
|
||||
// These three cases are what stop it coming back.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Every piece the storm spawns, snapshotted AT BIRTH. */
|
||||
const crateLog = (dbg, from, to, dt = FIXED_DT) => {
|
||||
const born = [];
|
||||
const seen = new Set();
|
||||
for (let i = Math.round(from / dt); i < Math.round(to / dt); i++) {
|
||||
const time = i * dt;
|
||||
dbg.step(dt, time, {});
|
||||
for (const p of dbg.pieces) {
|
||||
if (seen.has(p)) continue;
|
||||
seen.add(p);
|
||||
// y/sx/sy/sz/phase are the seeded jitter; x/z/vx come off the wind and
|
||||
// were never in doubt. Round only for the message — compare exactly.
|
||||
born.push({ model: p.model, y: p.y, sx: p.sx, sy: p.sy, sz: p.sz, phase: p.phase });
|
||||
}
|
||||
}
|
||||
return born;
|
||||
};
|
||||
const sameCrates = (a, b) => a.length === b.length && a.every((p, i) =>
|
||||
p.model === b[i].model && p.y === b[i].y && p.sx === b[i].sx
|
||||
&& p.sy === b[i].sy && p.sz === b[i].sz && p.phase === b[i].phase);
|
||||
const showCrates = (a) => a.map((p) => `${p.model}@y${p.y.toFixed(3)}/ph${p.phase.toFixed(3)}`).join(', ') || '(none)';
|
||||
|
||||
t.test('a crate is a pure function of its event, not of what came before it', () => {
|
||||
for (const name of STORMS) {
|
||||
const def = storms[name];
|
||||
const dur = def.duration ?? 90;
|
||||
|
||||
// two independently built modules on the same seed
|
||||
const one = crateLog(createDebris({ wind: createWind(def) }), 0, dur);
|
||||
const two = crateLog(createDebris({ wind: createWind(def) }), 0, dur);
|
||||
assert(sameCrates(one, two),
|
||||
`${name}: two freshly built debris modules spawned different crates — ${showCrates(one)} vs ${showCrates(two)}`);
|
||||
|
||||
// and the same module flown twice, clear()ed between: night 1 vs night 2
|
||||
const reused = createDebris({ wind: createWind(def) });
|
||||
const night1 = crateLog(reused, 0, dur);
|
||||
reused.clear();
|
||||
const night2 = crateLog(reused, 0, dur);
|
||||
assert(sameCrates(night1, night2),
|
||||
`${name}: the same storm flown twice through one module spawned different crates — night 1 ${showCrates(night1)}, night 2 ${showCrates(night2)}. clear() has stopped rewinding something.`);
|
||||
}
|
||||
});
|
||||
|
||||
t.test('an entry at t0 spawns the crates a t=0 run spawns after t0 (every storm, every t0)', () => {
|
||||
for (const name of STORMS) {
|
||||
const def = storms[name];
|
||||
const dur = def.duration ?? 90;
|
||||
const mk = () => createDebris({ wind: createWind(def) });
|
||||
|
||||
// the reference: watch the whole storm, and count how many crates are born
|
||||
// at or after each candidate entry time
|
||||
const whole = [];
|
||||
const seen = new Set();
|
||||
const ref = mk();
|
||||
for (let i = 0; i < Math.round(dur / FIXED_DT); i++) {
|
||||
const time = i * FIXED_DT;
|
||||
ref.step(FIXED_DT, time, {});
|
||||
for (const p of ref.pieces) {
|
||||
if (seen.has(p)) continue;
|
||||
seen.add(p);
|
||||
whole.push({ at: time, model: p.model, y: p.y, sx: p.sx, sy: p.sy, sz: p.sz, phase: p.phase });
|
||||
}
|
||||
}
|
||||
|
||||
for (const t0 of [10, 30, 45, 60]) {
|
||||
const want = whole.filter((p) => p.at >= t0);
|
||||
const got = crateLog(mk(), t0, dur);
|
||||
assert(sameCrates(want, got),
|
||||
`${name}: entering at t=${t0} spawned ${showCrates(got)} where a t=0 run spawns ${showCrates(want)} — the crates a callout flies depend on whether anybody watched the first ${t0} s`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
t.test('arriving mid-storm, the grass is already moving (and a cold entry proves it can fail)', () => {
|
||||
// DESIGN.md line 149: a gust front is readable because a wave of motion
|
||||
// crosses the grass a beat before it hits. The callout has NO forecast paper
|
||||
// (gate 1), so that motion is not decoration — it is the briefing. `leafDist`
|
||||
// and `leafEma` are genuine path integrals (a 2.5 s EMA off zero), so they are
|
||||
// the one part of my modules a mid-storm entry cannot teleport: measured, a
|
||||
// t=0 run has 6 leaves crossing at t=30 and a cold entry has ZERO, staying
|
||||
// becalmed for ~7 s while the EMA climbs. debris.enterAt() runs them.
|
||||
const def = storms.storm_02_wildnight;
|
||||
const T0 = 30;
|
||||
const mk = () => createDebris({ wind: createWind(def) });
|
||||
const look = (d) => ({ n: d.leafCount, at: d.leaves.map((p) => [p.x, p.y, p.z]) });
|
||||
const same = (a, b) => a.n === b.n && a.at.every((v, i) =>
|
||||
v[0] === b.at[i][0] && v[1] === b.at[i][1] && v[2] === b.at[i][2]);
|
||||
|
||||
const watched = mk();
|
||||
fixedLoop(T0, FIXED_DT, (dt, time) => watched.step(dt, time, {}));
|
||||
const want = look(watched);
|
||||
assert(want.n > 0, `the reference t=0 run has ${want.n} leaves flying at t=${T0} — pick a windier moment or this case proves nothing`);
|
||||
|
||||
const arrived = mk();
|
||||
arrived.enterAt(T0, FIXED_DT);
|
||||
const got = look(arrived);
|
||||
assert(same(want, got),
|
||||
`enterAt(${T0}) put ${got.n} leaves in the yard where a t=0 run has ${want.n}, or put them elsewhere — the arrival is not the storm the player would have been standing in`);
|
||||
|
||||
// NEGATIVE CONTROL. Without this the case above could be satisfied by a
|
||||
// do-nothing enterAt on a layer that happened to warm instantly. The naive
|
||||
// path — fresh module, first step lands at t0 — must be measurably wrong, and
|
||||
// is: 0 leaves against 6. This is also the guard against somebody later
|
||||
// "optimising" enterAt into a teleport.
|
||||
const cold = mk();
|
||||
cold.step(FIXED_DT, T0, {});
|
||||
assert(!same(want, look(cold)),
|
||||
`a cold module stepped straight to t=${T0} already matches the watched run — the ambient layer carries no history, so the case above cannot fail and must be rewritten`);
|
||||
});
|
||||
|
||||
// SPRINT13, the third time this repo learned it: the venturi lives in the
|
||||
// SITE json, not the storm def. B's site_audit flew site_02 funnel-OFF in
|
||||
// Sprint 11 (their sweep.selftest tells it); C's garden_bench did it again
|
||||
@ -1512,6 +1652,245 @@ 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 EMERGENCY is a storm that can be honestly dispatched over that yard', () => {
|
||||
// The honesty gate walking A's own table — NIGHTS ∪ POOL grew a third member
|
||||
// this sprint, and an emergency is the member with the most to hide: no
|
||||
// forecast card, no quote, and a storm entered at a t nobody walked up to.
|
||||
//
|
||||
// ⚠️ Read through a NAMESPACE import (`board.EMERGENCIES`), not a named one, so
|
||||
// this file does not hard-error in the sprint-window where A's spine is on
|
||||
// lane/a and not yet in my tree — the same reasoning A gave for calling
|
||||
// `weather.midStormRead?.()` that way, applied back at them. And it walks a
|
||||
// FIXTURE mirroring A's §6 as well as the real table, so the gate is exercised
|
||||
// on known data TODAY rather than reading green off an absent import: the
|
||||
// calm-day vacuity that has now bitten this repo in three separate gates.
|
||||
const fixture = [{
|
||||
id: 'fixture:corner_block_emergency', night: 5,
|
||||
storm: 'storm_03_southerly', site: 'site_02_corner_block',
|
||||
emergency: { arriveAt: 30 },
|
||||
}];
|
||||
const real = Array.isArray(board.EMERGENCIES) ? board.EMERGENCIES : [];
|
||||
const walk = [...fixture, ...real];
|
||||
assert(walk.length >= 1, 'the emergency walk found nothing at all');
|
||||
|
||||
for (const e of walk) {
|
||||
const at = e.emergency?.arriveAt;
|
||||
const def = storms[e.storm];
|
||||
assert(def, `${e.id}: emergency storm '${e.storm}' is not in the loaded storm set — `
|
||||
+ 'an emergency whose weather nobody can load cannot be dispatched');
|
||||
assert(Number.isFinite(at), `${e.id}: no numeric emergency.arriveAt`);
|
||||
|
||||
// (1) the storm-and-instant gate
|
||||
const r = dispatchHonest(def, at, e.id);
|
||||
assert(r.ok, `${e.id} cannot be honestly dispatched at arriveAt=${at}:\n ${r.errors.join('\n ')}`);
|
||||
|
||||
// (2) THE PAIRING LAW, extended to the new job type. A read can be perfectly
|
||||
// honest about a storm and still be an invitation to a night nobody can
|
||||
// work — and that is worse on a callout, which the player cannot decline
|
||||
// once they are standing in it.
|
||||
const why = pairingRefusal(e.storm, e.site);
|
||||
assert(why === null,
|
||||
`${e.id}: dispatching ${e.storm} over ${e.site} is refused by measurement — ${why}`);
|
||||
}
|
||||
|
||||
// VACUITY GUARDS — three, because a walk that only ever says yes is decoration.
|
||||
assert(pairingRefusal('storm_06_soaker', 'backyard_01') !== null,
|
||||
'the pairing arm of this walk cannot say no — an emergency on a refused yard would pass');
|
||||
assert(!dispatchHonest(storms[fixture[0].storm], 95, 'late').ok,
|
||||
'an emergency arriving after its storm ended was accepted');
|
||||
const sizeless = JSON.parse(JSON.stringify(storms.storm_06_soaker));
|
||||
delete sizeless.hail.size;
|
||||
assert(!dispatchHonest(sizeless, 30, 'sizeless').ok, 'the storm arm of this walk cannot say no');
|
||||
});
|
||||
|
||||
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.
|
||||
|
||||
@ -901,6 +901,95 @@ export function weatherCases(storms) {
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SPRINT18 gate 1 — THE MID-STORM ENTRY SEAM.
|
||||
//
|
||||
// The emergency callout spawns the player at t≈30 in a storm nobody briefed
|
||||
// them on. That makes a NON-ZERO START a first-class case for the storm clock,
|
||||
// and "a number gathered from the wrong harness" is the exact class of bug this
|
||||
// repo hunts. The whole callout stands on one property: an entry at t0 must see
|
||||
// the storm a t=0 run sees at t0.
|
||||
//
|
||||
// For the WIND that property is structural — weather.core.js's header promises
|
||||
// everything is a closed-form function of (pos, t) — but promised is not pinned,
|
||||
// and the field is not as stateless as it looks: buildGustTimeline and the
|
||||
// advected-drift table are both built at construction, uniformSpeed early-exits
|
||||
// on a sorted scan, and stormStats memoises into a WeakMap. Any one of those
|
||||
// growing a cursor would make the storm depend on the order it was watched in.
|
||||
// Below: cold vs walked vs scrambled, and two independently built fields.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
test('the wind at t=30 does not care how you got there (the callout seam)', () => {
|
||||
for (const [name, def] of Object.entries(storms)) {
|
||||
const read = (f, t) => [f.uniformSpeed(t), f.gustOnly(t), f.dirAt(t), f.rainAt(t), f.hailAt(t)];
|
||||
const T0 = 30;
|
||||
|
||||
const cold = createWindField(def);
|
||||
const want = read(cold, T0);
|
||||
|
||||
// walked: every step a t=0 run would have taken, in order, first
|
||||
const walked = createWindField(def);
|
||||
for (let i = 0; i < Math.round(T0 / DT); i++) read(walked, i * DT);
|
||||
const got = read(walked, T0);
|
||||
for (let i = 0; i < want.length; i++) {
|
||||
assert(want[i] === got[i],
|
||||
`${name}: field #${i} at t=${T0} reads ${got[i]} after being walked 0→30 but ${want[i]} cold — the wind remembers being watched, so a mid-storm entry cannot reproduce a t=0 run`);
|
||||
}
|
||||
|
||||
// scrambled: backwards, past the end, and repeated
|
||||
const scrambled = createWindField(def);
|
||||
for (const t of [90, 0, 45.5, 12, 88, T0, 1, T0, -5]) read(scrambled, t);
|
||||
const sc = read(scrambled, T0);
|
||||
for (let i = 0; i < want.length; i++) {
|
||||
assert(want[i] === sc[i],
|
||||
`${name}: field #${i} at t=${T0} moved after out-of-order sampling — ${sc[i]} vs ${want[i]}`);
|
||||
}
|
||||
|
||||
// two independently constructed fields must agree: the gust timeline and
|
||||
// the drift table are drawn from a PRNG at build, so this is the pin that
|
||||
// says "built twice" is the same storm as "built once".
|
||||
const twin = read(createWindField(def), T0);
|
||||
for (let i = 0; i < want.length; i++) {
|
||||
assert(want[i] === twin[i],
|
||||
`${name}: field #${i} at t=${T0} differs between two freshly built fields — ${twin[i]} vs ${want[i]}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the storm the dispatch quotes is the storm a BACKWARDS scan finds', () => {
|
||||
// Two harnesses, one number — the repo's oldest rule, aimed at the entry seam.
|
||||
// stormStats scans FORWARD from 0 and everything worded (the dispatch, the
|
||||
// offer band, the honesty gate) is built on its peaks. A mid-storm arrival is
|
||||
// the first consumer that reads the field at a t nobody walked up to, so scan
|
||||
// it in the one order no production code uses — downwards from the end — and
|
||||
// insist on the same peak. A field that remembers being watched fails here and
|
||||
// nowhere else: caught the resume-cursor mutation on uniformSpeed (14.6 m/s
|
||||
// against 18.58 on the wildnight, a 21% understatement of the gust peak).
|
||||
//
|
||||
// Deliberately NOT `stormStats(def)` twice: it memoises per def in a WeakMap,
|
||||
// so the second call hands back the SAME OBJECT and comparing them is
|
||||
// comparing an object to itself. That version of this case could not fail.
|
||||
const STEP = 0.05;
|
||||
for (const [name, def] of Object.entries(storms)) {
|
||||
const s = stormStats(def);
|
||||
const f = createWindField(def);
|
||||
const dur = def.duration ?? 90;
|
||||
let gustPeak = 0, rainPeak = 0, hailPeak = 0;
|
||||
for (let t = Math.floor(dur / STEP) * STEP; t >= 0; t -= STEP) {
|
||||
gustPeak = Math.max(gustPeak, f.uniformSpeed(t));
|
||||
rainPeak = Math.max(rainPeak, f.rainAt(t));
|
||||
hailPeak = Math.max(hailPeak, f.hailAt(t));
|
||||
}
|
||||
// Same grid, opposite direction: this is an equality, not a tolerance.
|
||||
assert(Math.abs(gustPeak - s.gustPeak) < 1e-9,
|
||||
`${name}: scanned backwards the gust peak is ${gustPeak.toFixed(3)} m/s, but the dispatch quotes ${s.gustPeak.toFixed(3)} — the field depends on the order it was read`);
|
||||
assert(Math.abs(rainPeak - s.rainPeak) < 1e-9,
|
||||
`${name}: backwards rain peak ${rainPeak.toFixed(4)} vs quoted ${s.rainPeak.toFixed(4)}`);
|
||||
assert(Math.abs(hailPeak - s.hailPeak) < 1e-9,
|
||||
`${name}: backwards hail peak ${hailPeak.toFixed(4)} vs quoted ${s.hailPeak.toFixed(4)}`);
|
||||
}
|
||||
});
|
||||
|
||||
return { cases, metrics };
|
||||
}
|
||||
|
||||
|
||||
@ -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