diff --git a/THREADS.md b/THREADS.md index 43691ce..bee66c7 100644 --- a/THREADS.md +++ b/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, 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. diff --git a/web/world/js/editor_storm.js b/web/world/js/editor_storm.js index 9311808..e0a6e1c 100644 --- a/web/world/js/editor_storm.js +++ b/web/world/js/editor_storm.js @@ -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 }; diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index 38a20ce..fd70a10 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -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`); + }); } diff --git a/web/world/js/tests/weather.selftest.js b/web/world/js/tests/weather.selftest.js index f71e5c2..15c3c4c 100644 --- a/web/world/js/tests/weather.selftest.js +++ b/web/world/js/tests/weather.selftest.js @@ -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'); } } diff --git a/web/world/js/weather.core.js b/web/world/js/weather.core.js index b21cd0a..4351df6 100644 --- a/web/world/js/weather.core.js +++ b/web/world/js/weather.core.js @@ -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, }; } diff --git a/web/world/js/weather.js b/web/world/js/weather.js index a635f47..502c144 100644 --- a/web/world/js/weather.js +++ b/web/world/js/weather.js @@ -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,