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:
type-two 2026-07-20 20:33:10 +10:00
parent d6af6ba5a2
commit d03712ae47
6 changed files with 358 additions and 7 deletions

View File

@ -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,
its pins stand on post-ruling numbers as designed, and the swing lawn is waiting to become a
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.

View File

@ -59,6 +59,7 @@ export const STORM_KEYS = [
'storm_02b_icenight',
'storm_03_southerly',
'storm_03b_earlybuster',
'storm_06_soaker',
];
export const DEFAULT_SEL = { storm: 'storm_03_southerly', time: 40 };

View File

@ -16,7 +16,7 @@
import * as THREE from '../../vendor/three.module.js';
import { assert, assertClose, fixedLoop } from '../testkit.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';
// 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,
@ -30,6 +30,11 @@ import { createSkyFx, RainShadow } from '../skyfx.js';
import { SailRig, HARDWARE } from '../sail.js';
import { weatherCases } from './weather.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
// 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)
// and night 5 (less wind, 2.8× the hail).
'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 */
@ -719,13 +727,17 @@ export default async function run(t) {
['rain', (s) => s.rainPeak],
['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 [k, truthOf] of keys) {
for (const [k, truthOf, getBand] of keys) {
let prev = null;
// walk the week backwards: the far end -> tonight
for (let i = 40; i >= 0; i--) {
const f = forecastFor(def, i / 40);
const b = f[k];
const b = getBand ? getBand(f) : f[k];
const truth = truthOf(f.truth);
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}`);
@ -738,8 +750,9 @@ export default async function run(t) {
}
// and it must ARRIVE: tonight is the truth, not merely a narrow band
const exact = forecastFor(def, 0);
assert(exact[k].lo === exact[k].hi,
`${name}.${k}: tonight should be exact, got ${exact[k].lo}${exact[k].hi}`);
const eb = getBand ? getBand(exact) : exact[k];
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 '
+ '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`);
});
}

View File

@ -715,6 +715,7 @@ export function weatherCases(storms) {
inside(f.gustPeak, s.gustPeak, 'gustPeak');
inside(f.rain, s.rainPeak, 'rain');
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');
}
}

View File

@ -638,7 +638,10 @@ export function stormStats(def) {
const stats = {
sustained, gustPeak, rainPeak,
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,
};
_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. */
const FORECAST_SPREAD = {
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),
rainMmPerHour: band(s.rainPeakMmPerHour, FORECAST_SPREAD.rain),
changeAt: s.changeAt == null ? null : band(s.changeAt, FORECAST_SPREAD.changeAt),
hail: { chance, seconds: band(s.hailSeconds, FORECAST_SPREAD.hailSeconds) },
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,
};
}

View File

@ -84,11 +84,32 @@ export function forecastLines(def, lead = 0) {
: '· no change forecast';
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 {
name: (def.name ?? '').replace(/_/g, ' ').toUpperCase(),
night: (def.sky?.night ?? (def.sky?.darkness ?? 0) > 0.6),
wind: `sustained to ${sustained} m/s (${sustainedKmh} km/h) · gusts to ~${gusts} km/h`,
rain: `rain ${rainWord} ${change} ${hail}`.trim(),
// 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.
confidence: lead > 0 ? `forecast confidence ${Math.round(f.confidence * 100)}%` : '',
hail: f.hail,