Lane C S16: gate 3 lands — the fabric bet pinned, forecast argues it, storm in the lists; selftest 438/0/0
Gate 3.1 pins (c.test): the soaker over site_02 through gardenfly, porosity the only variable — bare <30 (17.5), best cloth line <50 (42.3, still >bare: the leak is partial), same $75 ring on membrane >90 FULL (96.5) with lost<2 and cost<=80, $60 membrane ring wins too (68.8), separation >40 (54.2). The inverse guard: membrane on carabiners reads WORSE than cloth (19.1/3 lost vs 24.5/2) — the F key is a bet, not an upgrade. Gate 3.3: forecastLines grows stones + rainRate (worded, banded); stormStats.hailSize + forecastFor hail.size band, spread 0.30, nesting/truth-holding pinned in the resolving loop; existing card fields byte-identical (new band draws last). storm_06 in STORM_KEYS + suite STORMS (sync assert is the tripwire). Envelope tool flown: throat 21.55 @ 59.2, q1 17.78 (rated slot), q2 wind-coldest yet cloth-side second-hottest — the pond's signature isolated between harnesses. Mutations, separate runs, red-then-green: gardenfly seam hardcoded -> both 3.1 pins red; STORM_KEYS drop -> list-sync red with diff; stormgen rng -> byte-equal red. Browser selftest 438/0/0 (418 + 4 storm honesty + 12 stormgen + 4 gate-3). THREADS: gate entries + D pairing receipt + B seam/finding notes.
This commit is contained in:
parent
d6af6ba5a2
commit
d03712ae47
124
THREADS.md
124
THREADS.md
@ -7382,3 +7382,127 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
|
|||||||
**SPRINT16 — TEETH — FIRES NOW: its own header's condition ("fires when 15 integrates") is met,
|
**SPRINT16 — TEETH — FIRES NOW: its own header's condition ("fires when 15 integrates") is met,
|
||||||
its pins stand on post-ruling numbers as designed, and the swing lawn is waiting to become a
|
its pins stand on post-ruling numbers as designed, and the swing lawn is waiting to become a
|
||||||
real night.**
|
real night.**
|
||||||
|
[C] 2026-07-20 — 🧊 **GATE 3.1 LANDED — STORM_06 IS REAL AND THE F KEY IS FINALLY A NIGHT. D: the
|
||||||
|
pairing is measured, not preferred — the soaker MUST debut over the corner block.** Selftest
|
||||||
|
438/0/0 (418 baseline + 20: 4 per-storm honesty gates for the new storm, 12 generator cases,
|
||||||
|
4 gate-3 pins). All numbers post-ruling, flown through gardenfly — THE audit chain, dressed
|
||||||
|
anchors, live sway, funnel state in every header.
|
||||||
|
|
||||||
|
**THE STORM: storm_06_soaker ("Pea-Hail Soaker", rating 3).** Personality, measured
|
||||||
|
(stormStats + fabric_bet probe): sustained 9.2 m/s · gust peak 15.2 (55 km/h — the mildest
|
||||||
|
wind of any hail night) · rain peak 44 mm/hr, raining from minute one to minute ninety ·
|
||||||
|
hail 14.9 s-equivalent at size 0.45 · soft change at t=40 (front wall rises, same tell as
|
||||||
|
every buster). The thesis in one line: hailBlockFor(0.45, cloth 0.30) = 15.6% — these
|
||||||
|
stones pass a 2 mm weave; membrane blocks 100%.
|
||||||
|
|
||||||
|
**THE BET'S SHAPE (site_02 × soaker, same $75 ring q1..q4 rated-on-q1, ONE variable):**
|
||||||
|
· bare bed 17.5 DEAD (byHail 66.9)
|
||||||
|
· best cloth line measured, any price: **42.3 TATTERED — a LOSS**, 7.7 under the win line
|
||||||
|
(and visibly above bare: the cloth leaks, it isn't absent)
|
||||||
|
· SAME line, membrane: **96.5 FULL** — Δ**54.2 HP on the F key alone**
|
||||||
|
· $60 shackle ring, membrane: 68.8 FULL — the bet doesn't need the last dollar
|
||||||
|
· $20 carabiner ring INVERTS: membrane 19.1 (3 lost) vs cloth 24.5 (2 lost) — membrane's
|
||||||
|
load cost is real; the F key without steel is worse than nothing. Pinned as the guard.
|
||||||
|
Two hot corners after the change — q1 (venturi, 4.2 kN cloth) takes the rated; q2 (the new
|
||||||
|
downwind pond corner, 3.2-3.4 kN) is the broom's job and may let go late in a broom-less
|
||||||
|
harness (gardenfly can't broom; D, your play is the acceptance here). Retune history in the
|
||||||
|
storm's own comments: 17.2 hail-seconds was a wall (bare 7.2), 55 mm/hr drowned the fabric
|
||||||
|
lesson in a rain lesson — both walked back to the one-thing storm.
|
||||||
|
|
||||||
|
**The envelope tool flew it (storm-side receipts, site_02 × soaker, dressed anchors):**
|
||||||
|
throat probe 21.55 m/s @ 59.2 s (the funnel fires on the settled post-change wind, as
|
||||||
|
authored) · q1 17.78 @ 71.9 — the hottest post, which is why the rated shackle lives there
|
||||||
|
· q3 16.34 · q4 16.29 · tr1 16.08 · **q2 15.63 — the WIND-coldest post, and the cloth-side
|
||||||
|
harness's second-hottest corner (3.2-3.4 kN)**. That disagreement is not an error, it is
|
||||||
|
the measurement: the wind harness deliberately knows no water, so q2's rank inversion
|
||||||
|
between the two harnesses is the pond's signature, isolated. Two harnesses, one night,
|
||||||
|
each telling the half it owns.
|
||||||
|
|
||||||
|
**Mutation receipts (each red-then-green in the browser harness, separate runs):**
|
||||||
|
(1) gardenfly's porosity seam hardcoded back to 0.30 → BOTH pins red with their own
|
||||||
|
diagnostics: "the SAME $75 ring on MEMBRANE reads 42.3 tattered — want >90 FULL" and
|
||||||
|
"membrane on carabiners (24.5 hp, 2 lost) should read WORSE than cloth (24.5 hp, 2 lost) —
|
||||||
|
the doubled load has stopped costing anything". (2) storm_06 dropped from STORM_KEYS →
|
||||||
|
the list-sync assert red with the two lists diffed in the message. (3) stormgen's rng
|
||||||
|
swapped for Math.random → the byte-equal case red. All restored, final run green.
|
||||||
|
|
||||||
|
**D — THE PAIRING (SPRINT16 one-variable rule), with the receipt:** I flew the soaker over
|
||||||
|
BOTH shipped yards before claiming this. backyard_01 cannot host it: its buyable geometry
|
||||||
|
caps hail cover over the bed at ~31% (measured — the p1..p4 quad's flown shadow), and its
|
||||||
|
full-cover quads (p4,p5,p1,p2) pond-tear at 3.3-3.7 kN even in this mild wind, BOTH fabrics.
|
||||||
|
Membrane can't save what the sail can't reach: best membrane line there reads 37.5 vs cloth
|
||||||
|
17.3 — a bet with no win in it. So: **storm_06 flies over site_02 (the yard night 3
|
||||||
|
taught), and your swing lawn debuts under a storm the player has survived** — southerly or
|
||||||
|
buster, your call, you own NIGHTS and the order. If you want the soaker anywhere else, we
|
||||||
|
re-measure first; the storm's JSON says this out loud.
|
||||||
|
|
||||||
|
**Files:** data/storms/storm_06_soaker.json (tuned, receipts in comments) ·
|
||||||
|
tools/fabric_bet/probe.html (the measurement harness — same line both fabrics through
|
||||||
|
gardenfly, funnel state in header) · STORM_KEYS + c.test STORMS grown (the list-sync assert
|
||||||
|
is the tripwire) · pins in c.test.js (thresholds carry margin, digits live here and in the
|
||||||
|
storm's comments).
|
||||||
|
|
||||||
|
[C] 2026-07-20 — 🎲 **GATE 3.2 LANDED — makeStorm(seed): storms are draws from authored
|
||||||
|
envelopes now, and the envelope can say no.** stormgen.js, pure (weather.core only, runs in
|
||||||
|
the node one-second loop).
|
||||||
|
|
||||||
|
· **The grammar:** an envelope is DATA — every knob a [lo,hi] range, curve knot times fixed
|
||||||
|
per family, telegraph fixed (honesty is not tunable), gust shape owned by weather.core.
|
||||||
|
One family ships as groundwork: `soaker_family`, ranged around storm_06's measured
|
||||||
|
personality. The generator draws in a PINNED order from one mulberry32(seed) stream; the
|
||||||
|
def carries its seed so buildGustTimeline downstream is seeded by the same number.
|
||||||
|
· **The change cannot lie by construction:** dirCurve is BUILT from the drawn change time,
|
||||||
|
so the windchange event and the physics agree for every seed — the validator's drift trap,
|
||||||
|
made structural, and asserted through createWindField for sampled seeds.
|
||||||
|
· **checkEnvelope(def) recomputes membership FROM THE DEF** — it never trusts the
|
||||||
|
generator, so a hand-edited def fails on its own merits. It also runs validateStorm:
|
||||||
|
inside-the-envelope implies loadable.
|
||||||
|
· **Selftest (12 cases):** same seed → byte-equal JSON; 100 seeds → all validator-green AND
|
||||||
|
all inside the envelope (gustPeak spread 12.8-17.2 — the ranges are real, the family stays
|
||||||
|
mild); four named escape routes go red with the escape in the message (base speed over
|
||||||
|
ceiling, pea family throwing ice, overlapping gusts, change re-timed); a storm that
|
||||||
|
VALIDATES but is off-family goes red (checkEnvelope is not the validator in a hat); two
|
||||||
|
fixed-seed generated storms ride the SAME telegraph/continuity/validator gates as authored
|
||||||
|
ones; and a generated storm flies the storm_envelope harness deterministically, hint
|
||||||
|
ranking intact. Mutation: rng → Math.random, byte-equal case red, restored, green.
|
||||||
|
· **No shipped night uses it** — ROADMAP arc 4 eats this; that sprint prices it.
|
||||||
|
|
||||||
|
[C] 2026-07-20 — 📇 **GATE 3.3 LANDED — the stone and the rain rate are on the card. A: two new
|
||||||
|
fields, wired and worded, yours to print.** `forecastLines(def, lead)` now returns
|
||||||
|
`stones` ('fine pea stones' / 'pea stones' / 'marble stones' / 'golf ball stones', '' for a
|
||||||
|
hail-free storm) and `rainRate` ('rain to 44 mm/hr', banded with a dash when hedged).
|
||||||
|
Underneath: stormStats grew `hailSize`, forecastFor's hail block grew `size` — a band like
|
||||||
|
every other number, spread 0.30 (radar sees how big a cell is before it knows how long it
|
||||||
|
sits on you), truth-holding and nesting pinned in the same resolving loop as
|
||||||
|
sustained/gustPeak/rain. Word map calibrated to shipped storms: storm_03's 0.7 reads "pea"
|
||||||
|
(its own comment's word), the soaker's 0.45 "fine pea", wildnight 1.3 and icenight 1.4
|
||||||
|
"golf ball" — the sizes cloth stops dead. Every existing forecast field is byte-identical
|
||||||
|
(the new band draws LAST from the seeded stream), so your current card renders unchanged
|
||||||
|
until you print the new lines. The soaker's card at lead 0: "fine pea stones · rain to
|
||||||
|
44 mm/hr · hail likely · sustained to 9 m/s" — the F key, legible before a dollar is spent,
|
||||||
|
which is gate 4.3's "is night 6's bet legible from the forecast alone" question given its
|
||||||
|
fair chance.
|
||||||
|
|
||||||
|
[C] 2026-07-20 — 🔧 **B — TWO SEAM NOTES AND A FINDING WITH TEETH, posted before either of us
|
||||||
|
ships a number the other invalidates (the S13 lesson, prospectively).**
|
||||||
|
|
||||||
|
· **flyGarden grew `porosity` (default 0.30 — every pre-S16 number unmoved).** Your file;
|
||||||
|
I landed the smallest door so the membrane pins ride THE driver instead of a copy, and
|
||||||
|
your S15 note ("membrane-balance flights ride the same driver unchanged") read like an
|
||||||
|
invitation. Contest at integration if you want a different shape — the pins don't care
|
||||||
|
which door, only that there's one.
|
||||||
|
· **THE FINDING: ponding is fabric-blind.** `_applyPonding` never reads porosity — water
|
||||||
|
intake is `_nodeFlat` geometry only, so cloth and membrane pond IDENTICALLY today, while
|
||||||
|
setFabric's own comment ("an open weave sheds the water it can't hold — so a porous cloth
|
||||||
|
also ponds far less") and the FABRIC blurb ("sheds water") promise otherwise. Measured
|
||||||
|
consequence on my night: the $75 ring's q2 peaks 3.24 kN on cloth vs 3.32 on membrane —
|
||||||
|
the gap is wind term only. The comments are writing cheques the sim doesn't cash.
|
||||||
|
**I did NOT touch it** — sail.js is yours, and rigging.js's membrane-pricing comment
|
||||||
|
("if Lane A ever raises rain's drain weight, membrane earns a price") is the same
|
||||||
|
neighbourhood: if you make porosity shed water, membrane's pond cost becomes real,
|
||||||
|
storm_06's numbers move (q2 becomes membrane-only-hot — arguably BETTER design), and the
|
||||||
|
free-fabric ruling may want re-opening. My re-measure is one probe page load
|
||||||
|
(tools/fabric_bet/probe.html); flag me the sprint you touch it.
|
||||||
|
· Your heal (gate 2.1) sits below flyGarden's chunk seam per your own S15 note — my pins
|
||||||
|
fly non-diverging rigs, so zero interaction expected; if your heal moves ANY gate-3 pin,
|
||||||
|
the heal is the suspect, not the storm.
|
||||||
|
|||||||
@ -59,6 +59,7 @@ export const STORM_KEYS = [
|
|||||||
'storm_02b_icenight',
|
'storm_02b_icenight',
|
||||||
'storm_03_southerly',
|
'storm_03_southerly',
|
||||||
'storm_03b_earlybuster',
|
'storm_03b_earlybuster',
|
||||||
|
'storm_06_soaker',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DEFAULT_SEL = { storm: 'storm_03_southerly', time: 40 };
|
export const DEFAULT_SEL = { storm: 'storm_03_southerly', time: 40 };
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
import * as THREE from '../../vendor/three.module.js';
|
import * as THREE from '../../vendor/three.module.js';
|
||||||
import { assert, assertClose, fixedLoop } from '../testkit.js';
|
import { assert, assertClose, fixedLoop } from '../testkit.js';
|
||||||
import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS, createStubWind } from '../contracts.js';
|
import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS, createStubWind } from '../contracts.js';
|
||||||
import { loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor } from '../weather.js';
|
import { loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor, hailBlockFor } from '../weather.js';
|
||||||
import { loadSite, createWorld } from '../world.js';
|
import { loadSite, createWorld } from '../world.js';
|
||||||
// GATE 2.3 — the GAME's own wind wiring, IMPORTED rather than re-typed. A pin
|
// 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,
|
// that copied main.js's two lines would agree with a copy of the game forever,
|
||||||
@ -30,6 +30,11 @@ import { createSkyFx, RainShadow } from '../skyfx.js';
|
|||||||
import { SailRig, HARDWARE } from '../sail.js';
|
import { SailRig, HARDWARE } from '../sail.js';
|
||||||
import { weatherCases } from './weather.selftest.js';
|
import { weatherCases } from './weather.selftest.js';
|
||||||
import { ENVELOPE_TESTS } from '../../../../tools/storm_envelope/envelope.selftest.js';
|
import { ENVELOPE_TESTS } from '../../../../tools/storm_envelope/envelope.selftest.js';
|
||||||
|
// SPRINT16 gate 3: the membrane night's pin flies THE audit chain (gardenfly),
|
||||||
|
// and the seeded generator must satisfy the storm-side envelope harness.
|
||||||
|
import { flyGarden, hardwareByName } from '../../../../tools/site_audit/gardenfly.js';
|
||||||
|
import { stormEnvelope } from '../../../../tools/storm_envelope/envelope.js';
|
||||||
|
import { makeStorm } from '../stormgen.js';
|
||||||
|
|
||||||
// Keep in step with data/storms/. The node runner globs the directory, so this
|
// Keep in step with data/storms/. The node runner globs the directory, so this
|
||||||
// list going stale shows up here first — as it did when storm_03 landed and the
|
// list going stale shows up here first — as it did when storm_03 landed and the
|
||||||
@ -39,6 +44,9 @@ const STORMS = [
|
|||||||
// SPRINT6: the week's two variants — night 3 (same force, half the warning)
|
// SPRINT6: the week's two variants — night 3 (same force, half the warning)
|
||||||
// and night 5 (less wind, 2.8× the hail).
|
// and night 5 (less wind, 2.8× the hail).
|
||||||
'storm_03b_earlybuster', 'storm_02b_icenight',
|
'storm_03b_earlybuster', 'storm_02b_icenight',
|
||||||
|
// SPRINT16 gate 3.1: the pea-hail soaker — the first night the default
|
||||||
|
// fabric is the wrong answer. Its membrane-vs-cloth pin is below.
|
||||||
|
'storm_06_soaker',
|
||||||
];
|
];
|
||||||
|
|
||||||
/** @param {import('../testkit.js').Suite} t */
|
/** @param {import('../testkit.js').Suite} t */
|
||||||
@ -719,13 +727,17 @@ export default async function run(t) {
|
|||||||
['rain', (s) => s.rainPeak],
|
['rain', (s) => s.rainPeak],
|
||||||
['rainMmPerHour', (s) => s.rainPeakMmPerHour],
|
['rainMmPerHour', (s) => s.rainPeakMmPerHour],
|
||||||
];
|
];
|
||||||
|
// gate 3.3: the stone-size band obeys the same law as every other number
|
||||||
|
// on the card — nested, truth-holding, exact at lead 0. It lives at
|
||||||
|
// f.hail.size, so the key row carries its own accessor.
|
||||||
|
keys.push(['hail.size', (s) => s.hailSize, (f) => f.hail.size]);
|
||||||
for (const [name, def] of Object.entries(storms)) {
|
for (const [name, def] of Object.entries(storms)) {
|
||||||
for (const [k, truthOf] of keys) {
|
for (const [k, truthOf, getBand] of keys) {
|
||||||
let prev = null;
|
let prev = null;
|
||||||
// walk the week backwards: the far end -> tonight
|
// walk the week backwards: the far end -> tonight
|
||||||
for (let i = 40; i >= 0; i--) {
|
for (let i = 40; i >= 0; i--) {
|
||||||
const f = forecastFor(def, i / 40);
|
const f = forecastFor(def, i / 40);
|
||||||
const b = f[k];
|
const b = getBand ? getBand(f) : f[k];
|
||||||
const truth = truthOf(f.truth);
|
const truth = truthOf(f.truth);
|
||||||
assert(b.lo <= truth + 1e-9 && b.hi >= truth - 1e-9,
|
assert(b.lo <= truth + 1e-9 && b.hi >= truth - 1e-9,
|
||||||
`${name}.${k} @lead ${i / 40}: band ${b.lo}–${b.hi} rules out the truth ${truth}`);
|
`${name}.${k} @lead ${i / 40}: band ${b.lo}–${b.hi} rules out the truth ${truth}`);
|
||||||
@ -738,8 +750,9 @@ export default async function run(t) {
|
|||||||
}
|
}
|
||||||
// and it must ARRIVE: tonight is the truth, not merely a narrow band
|
// and it must ARRIVE: tonight is the truth, not merely a narrow band
|
||||||
const exact = forecastFor(def, 0);
|
const exact = forecastFor(def, 0);
|
||||||
assert(exact[k].lo === exact[k].hi,
|
const eb = getBand ? getBand(exact) : exact[k];
|
||||||
`${name}.${k}: tonight should be exact, got ${exact[k].lo}–${exact[k].hi}`);
|
assert(eb.lo === eb.hi,
|
||||||
|
`${name}.${k}: tonight should be exact, got ${eb.lo}–${eb.hi}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1093,4 +1106,182 @@ export default async function run(t) {
|
|||||||
+ 'different quantities that merely nearly coincide, and conflating them is the '
|
+ 'different quantities that merely nearly coincide, and conflating them is the '
|
||||||
+ 'mistake THREADS records A and C both nearly making');
|
+ 'mistake THREADS records A and C both nearly making');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// SPRINT16 GATE 3.3 — the stone and the rain rate join the card
|
||||||
|
t.test('gate 3.3: forecast stones and rain rate — worded, banded, exact tonight, never NaN', () => {
|
||||||
|
const soaker = storms.storm_06_soaker;
|
||||||
|
const tonight = forecastLines(soaker, 0);
|
||||||
|
// tonight is exact: the soaker's truth, in the fabric bet's own words
|
||||||
|
assert(tonight.stones === 'fine pea stones',
|
||||||
|
`the soaker's stones read "${tonight.stones}" — size 0.45 is the fine pea the cloth leaks, `
|
||||||
|
+ 'and the card is where the player learns that before spending a dollar');
|
||||||
|
assert(tonight.rainRate === 'rain to 44 mm/hr',
|
||||||
|
`the soaker's rain rate reads "${tonight.rainRate}" — want the exact 44 mm/hr at lead 0`);
|
||||||
|
assert(/hail likely/.test(tonight.rain),
|
||||||
|
`the soaker (14.9 hail-seconds) should call its hail likely: "${tonight.rain}"`);
|
||||||
|
// the sizes cloth stops dead read as the big words
|
||||||
|
assert(forecastLines(storms.storm_02_wildnight, 0).stones === 'golf ball stones',
|
||||||
|
`wild night stones read "${forecastLines(storms.storm_02_wildnight, 0).stones}"`);
|
||||||
|
assert(forecastLines(storms.storm_03_southerly, 0).stones === 'pea stones',
|
||||||
|
`southerly stones read "${forecastLines(storms.storm_03_southerly, 0).stones}" — 0.7 is `
|
||||||
|
+ "storm_03's own comment's word");
|
||||||
|
// a hail-free storm advertises nothing
|
||||||
|
assert(forecastLines(storms.storm_01_gentle, 0).stones === '',
|
||||||
|
'the gentle storm advertised stones it does not have');
|
||||||
|
// far out, the rate hedges as a band (the stone word may or may not span
|
||||||
|
// two words — the BAND honesty is pinned in the resolving test above)
|
||||||
|
const far = forecastLines(soaker, 1);
|
||||||
|
assert(/–/.test(far.rainRate), `a week out the rain rate should hedge, got "${far.rainRate}"`);
|
||||||
|
assert(/fine pea/.test(far.stones), `the soaker a week out still forecasts pea-family stones, got "${far.stones}"`);
|
||||||
|
// no NaN/undefined reaches the card, any storm, any lead
|
||||||
|
for (const [name, d] of Object.entries(storms)) {
|
||||||
|
for (const lead of [0, 0.5, 1]) {
|
||||||
|
const f = forecastLines(d, lead);
|
||||||
|
for (const k of ['stones', 'rainRate']) {
|
||||||
|
assert(typeof f[k] === 'string' && !/NaN|undefined/.test(f[k]),
|
||||||
|
`${name} @lead ${lead}: ${k} reads "${f[k]}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// SPRINT16 GATE 3.2 — the generator satisfies the storm-side harness
|
||||||
|
t.test('gate 3.2: a generated storm flies the storm_envelope harness, deterministically', () => {
|
||||||
|
const anchors = [
|
||||||
|
{ id: 'a', type: 'post', pos: { x: 0, y: 4, z: 0 } },
|
||||||
|
{ id: 'b', type: 'post', pos: { x: 5, y: 4, z: 3 }, ratingHint: 0.45 },
|
||||||
|
];
|
||||||
|
const r1 = stormEnvelope({ anchors, stormDef: makeStorm(7) });
|
||||||
|
const r2 = stormEnvelope({ anchors, stormDef: makeStorm(7) });
|
||||||
|
for (const r of r1.rows) {
|
||||||
|
assert(Number.isFinite(r.peak) && r.peak > 0, `vacuous envelope row for ${r.id}: peak ${r.peak}`);
|
||||||
|
assert(Number.isFinite(r.dose) && r.dose > 0, `vacuous dose for ${r.id}`);
|
||||||
|
assert(r.tPeak >= 0 && r.tPeak <= 90, `${r.id} peaked outside the storm at t=${r.tPeak}`);
|
||||||
|
}
|
||||||
|
r1.rows.forEach((r, i) => {
|
||||||
|
const s = r2.rows[i];
|
||||||
|
assert(r.peak === s.peak && r.tPeak === s.tPeak && r.dose === s.dose,
|
||||||
|
`same seed, two envelope runs disagree at ${r.id}: ${r.peak} vs ${s.peak}`);
|
||||||
|
});
|
||||||
|
// hint reaches the ranking exactly as it does for authored storms
|
||||||
|
assert(r1.rows[0].id === 'b',
|
||||||
|
`equal-ish wind, hint 0.45 vs 1.0 — 'b' must rank first, got ${r1.rows.map((r) => r.id).join(',')}`);
|
||||||
|
// and the seed is a real variable through the harness, not just the JSON
|
||||||
|
const other = stormEnvelope({ anchors, stormDef: makeStorm(8) });
|
||||||
|
assert(other.rows.some((r, i) => r.peak !== r1.rows[i].peak),
|
||||||
|
'seeds 7 and 8 measured identically through the harness — the seed is not reaching the wind');
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// SPRINT16 GATE 3.1 — THE MEMBRANE NIGHT, PINNED THROUGH THE REAL CHAIN
|
||||||
|
//
|
||||||
|
// storm_06_soaker exists to make the F key matter: pea stones under the
|
||||||
|
// cloth aperture, heavy rain, mild wind. The pin flies gardenfly — THE
|
||||||
|
// audit scoring chain (windForSite → attach → skyfx → garden.js), dressed
|
||||||
|
// site_02 anchors, live sway, funnel ON — with porosity the ONLY variable
|
||||||
|
// between the cloth and membrane flights. Numbers measured 2026-07-20
|
||||||
|
// (fabric_bet probe, post-ruling tree): bare 17.5 · cloth $75 ring 42.3 ·
|
||||||
|
// membrane $75 ring 96.5 · membrane $60 ring 68.8. Thresholds pin the
|
||||||
|
// BOUNDARIES the night stakes, with margin, not the digits.
|
||||||
|
const soakerDef = storms.storm_06_soaker;
|
||||||
|
const soakerSite = site02; // loaded above for gate 2.3
|
||||||
|
let soakerFlights = null;
|
||||||
|
{
|
||||||
|
// garden_probe's proxy pattern: world.anchors fly THEMSELVES, and `use`
|
||||||
|
// re-points the sway closures at the storm actually flying (the frozen-
|
||||||
|
// tree landmine — tr1/tr1b are the pin's own corners, they must sway).
|
||||||
|
let cur = createStubWind({ calm: true });
|
||||||
|
const proxy = {
|
||||||
|
sample: (p, t2, o) => cur.sample(p, t2, o || new THREE.Vector3()),
|
||||||
|
speedAt: (p, t2) => cur.speedAt(p, t2),
|
||||||
|
rainAt: (t2) => (cur.rainAt ? cur.rainAt(t2) : 0),
|
||||||
|
rainMmPerHour: (t2) => (cur.rainMmPerHour ? cur.rainMmPerHour(t2) : 0),
|
||||||
|
gustTelegraph: (t2) => (cur.gustTelegraph ? cur.gustTelegraph(t2) : null),
|
||||||
|
eventsBetween: (a, b) => (cur.eventsBetween ? cur.eventsBetween(a, b) : []),
|
||||||
|
setSheltersFromTrees() {},
|
||||||
|
};
|
||||||
|
const use = (w) => { cur = w; };
|
||||||
|
try {
|
||||||
|
const world = createWorld(new THREE.Scene(), { wind: proxy, site: structuredClone(soakerSite) });
|
||||||
|
await world.dress(); // graybox would move the tree corners — fail loud instead
|
||||||
|
const bed = world.gardenBed;
|
||||||
|
const ring = ['q1', 'q2', 'q3', 'q4'];
|
||||||
|
const hw75 = ['rated shackle', 'shackle', 'shackle', 'shackle'].map(hardwareByName);
|
||||||
|
const hw60 = ['shackle', 'shackle', 'shackle', 'shackle'].map(hardwareByName);
|
||||||
|
const hw20 = Array(4).fill(hardwareByName('carabiner'));
|
||||||
|
const common = { anchors: world.anchors, bed, stormDef: soakerDef, siteDef: soakerSite, use };
|
||||||
|
soakerFlights = {
|
||||||
|
bare: flyGarden({ ...common }),
|
||||||
|
cloth75: flyGarden({ ...common, ids: ring, hw: hw75, porosity: 0.30 }),
|
||||||
|
mem75: flyGarden({ ...common, ids: ring, hw: hw75, porosity: 0 }),
|
||||||
|
mem60: flyGarden({ ...common, ids: ring, hw: hw60, porosity: 0 }),
|
||||||
|
cloth20: flyGarden({ ...common, ids: ring, hw: hw20, porosity: 0.30 }),
|
||||||
|
mem20: flyGarden({ ...common, ids: ring, hw: hw20, porosity: 0 }),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
soakerFlights = { error: err };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.test('GATE 3.1: the soaker flips the fabric bet — membrane wins the night cloth loses', () => {
|
||||||
|
if (soakerFlights?.error) throw soakerFlights.error;
|
||||||
|
const { bare, cloth75, mem75, mem60 } = soakerFlights;
|
||||||
|
|
||||||
|
// vacuity guards first: the night must actually threaten, through hail
|
||||||
|
assert(bare.byHail > 40,
|
||||||
|
`bare bed took only ${bare.byHail} HP of hail — the soaker has stopped soaking and this pin proves nothing`);
|
||||||
|
assert(bare.hp < 30, `bare bed survived at ${bare.hp} — the night has no teeth`);
|
||||||
|
|
||||||
|
// the primitive the whole night stands on: these stones pass a 2 mm weave
|
||||||
|
const size = soakerDef.hail.size;
|
||||||
|
assert(hailBlockFor(size, 0.30) < 0.30,
|
||||||
|
`cloth blocks ${(hailBlockFor(size, 0.30) * 100).toFixed(0)}% of size-${size} stones — the soaker's `
|
||||||
|
+ 'hail has drifted above the weave aperture and the bet is dead');
|
||||||
|
assert(hailBlockFor(size, 0) === 1, 'membrane stopped blocking — hailBlockFor broke');
|
||||||
|
|
||||||
|
// CLOTH LOSES: the best measured cloth line in the yard (this ring, $75)
|
||||||
|
// ends under the win line — the default fabric is the wrong answer.
|
||||||
|
assert(cloth75.hp < 50,
|
||||||
|
`the $75 ring on CLOTH reads ${cloth75.hp} — over the win line, so the default fabric wins the `
|
||||||
|
+ 'membrane night and storm_06 is a decoration (retune the hail spectrum, per the gate)');
|
||||||
|
// ...but visibly beats bare: the cloth is leaking, not absent
|
||||||
|
assert(cloth75.hp > bare.hp + 10,
|
||||||
|
`cloth ${cloth75.hp} vs bare ${bare.hp} — cloth is doing nothing; the leak should be partial`);
|
||||||
|
|
||||||
|
// MEMBRANE WINS, same line, one variable: the F key is the night
|
||||||
|
assert(mem75.hp > 90 && mem75.state === 'full',
|
||||||
|
`the SAME $75 ring on MEMBRANE reads ${mem75.hp} ${mem75.state} — want >90 FULL (measured 96.5)`);
|
||||||
|
assert(mem75.cornersLost < 2,
|
||||||
|
`membrane $75 lost ${mem75.cornersLost} corners — main.js's win rule (lost<2) fails, the load is not affordable`);
|
||||||
|
assert(mem75.cost <= 80, `the membrane line costs $${mem75.cost} — the $80 wallet cannot buy the night's answer`);
|
||||||
|
|
||||||
|
// and the bet is affordable a tier down: the $60 ring also wins on membrane
|
||||||
|
// (measured 68.8 FULL, one pond corner late — the broom's job in play)
|
||||||
|
assert(mem60.hp > 60 && mem60.cornersLost < 2,
|
||||||
|
`the $60 ring on membrane reads ${mem60.hp} with ${mem60.cornersLost} lost — the bet should not `
|
||||||
|
+ 'need the last dollar of the wallet');
|
||||||
|
|
||||||
|
// THE SEPARATION, pinned: same line, same steel, fabric alone is worth
|
||||||
|
// most of a garden (measured Δ54.2 — threshold carries real margin)
|
||||||
|
assert(mem75.hp - cloth75.hp > 40,
|
||||||
|
`membrane-vs-cloth separation ${(mem75.hp - cloth75.hp).toFixed(1)} HP on the same $75 line — `
|
||||||
|
+ 'under 40, the F key has stopped being the night');
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('GATE 3.1 guard: membrane is not a free win — on cheap steel it tears', () => {
|
||||||
|
// The inverse control, so the night cannot rot into "always press F":
|
||||||
|
// the doubled load must make membrane WORSE than cloth on carabiners.
|
||||||
|
// (Measured: cloth $20 ring 24.5 hp / 2 lost · membrane $20 ring 19.1 hp
|
||||||
|
// / 3 lost.) Without this, fabric would be a one-way upgrade and the bet
|
||||||
|
// a formality — the same decoration failure from the other side.
|
||||||
|
if (soakerFlights?.error) throw soakerFlights.error;
|
||||||
|
const { cloth20, mem20 } = soakerFlights;
|
||||||
|
assert(cloth20.cornersLost > 0 || mem20.cornersLost > 0,
|
||||||
|
'neither cheap ring lost a corner — carabiners hold the soaker and this control proves nothing');
|
||||||
|
assert(mem20.cornersLost > cloth20.cornersLost || mem20.hp < cloth20.hp,
|
||||||
|
`membrane on carabiners (${mem20.hp} hp, ${mem20.cornersLost} lost) should read WORSE than cloth `
|
||||||
|
+ `(${cloth20.hp} hp, ${cloth20.cornersLost} lost) — the doubled load has stopped costing anything`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -715,6 +715,7 @@ export function weatherCases(storms) {
|
|||||||
inside(f.gustPeak, s.gustPeak, 'gustPeak');
|
inside(f.gustPeak, s.gustPeak, 'gustPeak');
|
||||||
inside(f.rain, s.rainPeak, 'rain');
|
inside(f.rain, s.rainPeak, 'rain');
|
||||||
inside(f.hail.seconds, s.hailSeconds, 'hailSeconds');
|
inside(f.hail.seconds, s.hailSeconds, 'hailSeconds');
|
||||||
|
inside(f.hail.size, s.hailSize, 'hailSize'); // gate 3.3: the stone is a band too
|
||||||
if (s.changeAt != null) inside(f.changeAt, s.changeAt, 'changeAt');
|
if (s.changeAt != null) inside(f.changeAt, s.changeAt, 'changeAt');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -638,7 +638,10 @@ export function stormStats(def) {
|
|||||||
const stats = {
|
const stats = {
|
||||||
sustained, gustPeak, rainPeak,
|
sustained, gustPeak, rainPeak,
|
||||||
rainPeakMmPerHour: rainPeak * (def.rain?.peakMmPerHour ?? DEFAULT_PEAK_MM_PER_HOUR),
|
rainPeakMmPerHour: rainPeak * (def.rain?.peakMmPerHour ?? DEFAULT_PEAK_MM_PER_HOUR),
|
||||||
hailPeak, hailSeconds,
|
// Stone size (SPRINT16 gate 3.3): the fabric bet argues on the forecast,
|
||||||
|
// and the stone is the argument — a pea leaks through cloth, ice doesn't.
|
||||||
|
// 0 when the storm never hails, matching hailSeconds' convention.
|
||||||
|
hailPeak, hailSeconds, hailSize: f.hailSize,
|
||||||
changeAt: change ? change.t : null,
|
changeAt: change ? change.t : null,
|
||||||
};
|
};
|
||||||
_statsCache.set(def, stats);
|
_statsCache.set(def, stats);
|
||||||
@ -648,6 +651,9 @@ export function stormStats(def) {
|
|||||||
/** How wide each number's band runs at lead=1, as a fraction of the value. */
|
/** How wide each number's band runs at lead=1, as a fraction of the value. */
|
||||||
const FORECAST_SPREAD = {
|
const FORECAST_SPREAD = {
|
||||||
sustained: 0.30, gustPeak: 0.35, rain: 0.45, changeAt: 0.22, hailSeconds: 0.7,
|
sustained: 0.30, gustPeak: 0.35, rain: 0.45, changeAt: 0.22, hailSeconds: 0.7,
|
||||||
|
// Stone size hedges less than duration: radar sees how big the cell is long
|
||||||
|
// before it knows how long it will sit on you.
|
||||||
|
hailSize: 0.30,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -692,7 +698,14 @@ export function forecastFor(def, lead = 0) {
|
|||||||
rain: band(s.rainPeak, FORECAST_SPREAD.rain),
|
rain: band(s.rainPeak, FORECAST_SPREAD.rain),
|
||||||
rainMmPerHour: band(s.rainPeakMmPerHour, FORECAST_SPREAD.rain),
|
rainMmPerHour: band(s.rainPeakMmPerHour, FORECAST_SPREAD.rain),
|
||||||
changeAt: s.changeAt == null ? null : band(s.changeAt, FORECAST_SPREAD.changeAt),
|
changeAt: s.changeAt == null ? null : band(s.changeAt, FORECAST_SPREAD.changeAt),
|
||||||
hail: { chance, seconds: band(s.hailSeconds, FORECAST_SPREAD.hailSeconds) },
|
hail: {
|
||||||
|
chance,
|
||||||
|
seconds: band(s.hailSeconds, FORECAST_SPREAD.hailSeconds),
|
||||||
|
// SPRINT16 gate 3.3 — the stone joins the card. Band, not a promise:
|
||||||
|
// contains the truth at every lead, exact at lead 0 (band() guarantees
|
||||||
|
// both). {lo:0, hi:0} for a storm that never hails.
|
||||||
|
size: band(s.hailSize, FORECAST_SPREAD.hailSize),
|
||||||
|
},
|
||||||
truth: s,
|
truth: s,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -84,11 +84,32 @@ export function forecastLines(def, lead = 0) {
|
|||||||
: '· no change forecast';
|
: '· no change forecast';
|
||||||
const hail = f.hail.chance === 'none' ? '' : `· hail ${f.hail.chance}`;
|
const hail = f.hail.chance === 'none' ? '' : `· hail ${f.hail.chance}`;
|
||||||
|
|
||||||
|
// SPRINT16 gate 3.3 — the stone and the rain RATE join the card (A prints
|
||||||
|
// them; this owns the wording, same split as every other line here). The
|
||||||
|
// stone is the fabric bet's whole argument: pea hail rattles through a
|
||||||
|
// 2 mm weave, anything bigger cannot — so the size word is what tells a
|
||||||
|
// player which fabric tonight wants, before a dollar is spent. Words map
|
||||||
|
// hail.size units (1.0 ≈ a 1.5 cm stone): storm_03's 0.7 reads "pea" (its
|
||||||
|
// own comment's word), the soaker's 0.45 "fine pea", the wild night's 1.3
|
||||||
|
// and the ice night's 1.4 read "golf balls" — the sizes cloth stops dead.
|
||||||
|
const stoneWord = (v) => (v < 0.6 ? 'fine pea' : v <= 0.9 ? 'pea' : v <= 1.2 ? 'marble' : 'golf ball');
|
||||||
|
const sz = f.hail.size;
|
||||||
|
const stones = f.hail.chance === 'none' ? '' : (
|
||||||
|
stoneWord(sz.lo) === stoneWord(sz.hi)
|
||||||
|
? `${stoneWord(sz.hi)} stones`
|
||||||
|
: `${stoneWord(sz.lo)}–${stoneWord(sz.hi)} stones`
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: (def.name ?? '').replace(/_/g, ' ').toUpperCase(),
|
name: (def.name ?? '').replace(/_/g, ' ').toUpperCase(),
|
||||||
night: (def.sky?.night ?? (def.sky?.darkness ?? 0) > 0.6),
|
night: (def.sky?.night ?? (def.sky?.darkness ?? 0) > 0.6),
|
||||||
wind: `sustained to ${sustained} m/s (${sustainedKmh} km/h) · gusts to ~${gusts} km/h`,
|
wind: `sustained to ${sustained} m/s (${sustainedKmh} km/h) · gusts to ~${gusts} km/h`,
|
||||||
rain: `rain ${rainWord} ${change} ${hail}`.trim(),
|
rain: `rain ${rainWord} ${change} ${hail}`.trim(),
|
||||||
|
// The gate-3.3 lines, separate fields so A's card can place them without
|
||||||
|
// re-deriving anything. Empty string = nothing to print, same rule as
|
||||||
|
// `confidence` below; both bands contain the truth at every lead.
|
||||||
|
rainRate: `rain to ${band(f.rainMmPerHour, i)} mm/hr`,
|
||||||
|
stones,
|
||||||
// Only worth showing when it isn't tonight — "CONFIDENCE 100%" is noise.
|
// Only worth showing when it isn't tonight — "CONFIDENCE 100%" is noise.
|
||||||
confidence: lead > 0 ? `forecast confidence ${Math.round(f.confidence * 100)}%` : '',
|
confidence: lead > 0 ? `forecast confidence ${Math.round(f.confidence * 100)}%` : '',
|
||||||
hail: f.hail,
|
hail: f.hail,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user