diff --git a/web/world/js/hud.js b/web/world/js/hud.js
index 3f46295..a1adb68 100644
--- a/web/world/js/hud.js
+++ b/web/world/js/hud.js
@@ -123,6 +123,9 @@ const CSS = `
letter-spacing:.05em; }
#hud-card .verdict.win { background:#12321c; border:1px solid #2c6b3c; color:#7fce6a; }
#hud-card .verdict.lose { background:#3a1618; border:1px solid #7d2b2b; color:#ff8f86; }
+/* SPRINT16 gate 2.2 [B] — the fabric sentence under the verdict, quieter than
+ the verdict itself: it is a footnote about the bet, not a second ruling. */
+#hud-card .verdict .fabricnote { margin-top:8px; font-weight:400; font-size:12px; opacity:.85; }
/* --- the week ---------------------------------------------------------- */
#hud-card .pips { letter-spacing:.5em; font-size:15px; margin:-6px 0 10px; color:#3f5561; }
@@ -917,6 +920,10 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
['garden', `${r.hp.toFixed(0)}%`],
['corners intact', `${r.cornersTotal - r.cornersLost}/${r.cornersTotal}`],
['what got through', r.hailBlocked],
+ // SPRINT16 gate 2.2 [Lane B] — the fabric bet is on the record: the
+ // choice the player made at the F key, printed on the same paper as
+ // what it did or didn't stop. Unconditional — the record is the point.
+ ...(r.fabric ? [['fabric flown', r.fabric.name]] : []),
['hardware lost', r.bill ? `$${r.bill}` : 'none'],
].map(([k, v]) => `
${r.verdict}${
+ // gate 2.2's second half: when the fabric bet provably mattered the
+ // verdict says so (fabricNoteFor's thresholds decide; null = silent,
+ // and the existing verdict prose is byte-untouched either way).
+ r.fabricNote ? `
${r.fabricNote}
` : ''}
${w?.client ? `
Payable 14 days. ${w.collateral ? 'They’ll want that made right by Friday.' : 'Thank you for your business.'}
` : ''}
`;
diff --git a/web/world/js/main.js b/web/world/js/main.js
index c1d763d..d82753c 100644
--- a/web/world/js/main.js
+++ b/web/world/js/main.js
@@ -23,7 +23,7 @@ import { createPlayer } from './player.js';
import { Interact, wireYardActions } from './interact.js';
import { createDebris } from './debris.js';
import { createSkyFx } from './skyfx.js';
-import { createRiggingUI } from './rigging.js';
+import { createRiggingUI, fabricNoteFor } from './rigging.js';
import { createHud } from './hud.js';
import { createWeek, NIGHTS, nightAt } from './week.js';
import { createGarden } from './garden.js';
@@ -839,7 +839,22 @@ export async function boot(opts = {}) {
const { verdict, mode } = verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped,
beyondSaving: week.job.gardenBeyondSaving });
+ // SPRINT16 gate 2.2 [Lane B] — the fabric bet on the record. The F key
+ // landed in the fresh-eyes review; this is the paperwork half: the invoice
+ // carries the fabric every night (r.fabric → hud's rows), and the verdict
+ // gets ONE extra sentence when the bet provably mattered (fabricNoteFor —
+ // pure, thresholded, silent when a sentence would be a guess). The
+ // FORECAST half — arguing the bet before you rig — is C's gate 3.3.
+ const fabric = rigging.session.fabric;
+ const fabricNote = fabricNoteFor({
+ fabric,
+ hailSize: defs[week.stormKey]?.hail?.size ?? 0,
+ dmg, pondPeak,
+ });
+
return {
+ fabric: { id: fabric.id, name: fabric.name },
+ fabricNote,
hp,
cornersLost: lost.length,
cornersTotal: rig.corners.length || 4,
diff --git a/web/world/js/rigging.js b/web/world/js/rigging.js
index add616c..f7da5c3 100644
--- a/web/world/js/rigging.js
+++ b/web/world/js/rigging.js
@@ -14,6 +14,7 @@
import { HARDWARE, START_BUDGET, SPARE_COST, FIXED_DT } from './contracts.js';
import { SailRig, orderRing, TENSION_MIN, TENSION_MAX } from './sail.js';
+import { hailBlockFor } from './weather.core.js';
export { START_BUDGET, SPARE_COST };
export const MAX_CORNERS = 4;
@@ -50,6 +51,59 @@ export const FABRIC = [
];
export const DEFAULT_FABRIC = FABRIC[0];
+/**
+ * SPRINT16 gate 2.2 — the verdict's fabric sentence, ONLY when the bet
+ * provably mattered. The invoice records the fabric every night (the record is
+ * unconditional); this is the extra line the aftermath gets to say about it,
+ * and it must never guess: dmg.hail can't be split into through-the-weave vs
+ * fell-beside-the-sail after the fact, so the note speaks only when the
+ * MODEL's own numbers make the claim safe.
+ *
+ * · Cloth on a night whose stones pass the weave (hailBlockFor < 1 — C's
+ * 2 mm-weave ruling) with real hail damage: name the leak. That's the
+ * spec's own case, "a membrane night survived in cloth should say what the
+ * leak cost". The phrasing claims what is true — that much hail REACHED
+ * the bed and the membrane stops those stones — not that membrane would
+ * have saved exactly that many HP (some of it may have fallen past the
+ * sail's edge, which no fabric fixes).
+ * · Membrane that actually ponded (the HUD's own >80 kg 'ponded' bar): name
+ * the trade — the pond IS the membrane's price, and the verdict prose for
+ * ponded/broomed nights doesn't say which cloth bought it.
+ * · Everything else: null. Big-hail nights (both fabrics block 100%), dry
+ * nights, clean wins — the row on the invoice is the whole record.
+ *
+ * Pure, headless-testable; thresholds are data on the function so the asserts
+ * and the wiring read the same numbers.
+ *
+ * @param {object} o
+ * @param {object} o.fabric the FABRIC entry flown (session.fabric / summary.fabric)
+ * @param {number} [o.hailSize] storm def's hail.size (1.0 ≈ 1.5 cm stone); 0/absent = no hail tonight
+ * @param {object} [o.dmg] garden.damage {hail, rain}, HP
+ * @param {number} [o.pondPeak] peak kg of water the sail held tonight
+ * @returns {string|null} one sentence for the aftermath card, or null
+ */
+export function fabricNoteFor({ fabric, hailSize = 0, dmg = { hail: 0, rain: 0 }, pondPeak = 0 } = {}) {
+ if (!fabric || !FABRIC.some((f) => f.id === fabric.id)) return null;
+ if (fabric.porosity > 0) {
+ const leaks = hailSize > 0 && hailBlockFor(hailSize, fabric.porosity) < 1;
+ if (leaks && (dmg.hail ?? 0) >= fabricNoteFor.LEAK_MATTERS_HP) {
+ return `Flown in ${fabric.name} — stones this fine rattle through the weave, and `
+ + `${Math.round(dmg.hail)} HP of hail reached the bed. The membrane stops every stone.`;
+ }
+ return null;
+ }
+ if ((pondPeak ?? 0) > fabricNoteFor.POND_MATTERS_KG) {
+ return `Flown in ${fabric.name} — it stopped the stones and the rain, and held `
+ + `${Math.round(pondPeak)} kg of what it stopped. That is the membrane's price.`;
+ }
+ return null;
+}
+/** The leak has to have cost real HP before the verdict brings it up. */
+fabricNoteFor.LEAK_MATTERS_HP = 5;
+/** verdictFor's own 'ponded' threshold (main.js pondPeak > 80), same number —
+ * the note and the verdict must agree on when a pond is worth a sentence. */
+fabricNoteFor.POND_MATTERS_KG = 80;
+
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
const OK = { ok: true };
diff --git a/web/world/js/rigging.selftest.js b/web/world/js/rigging.selftest.js
index 018c5a9..1f0d041 100644
--- a/web/world/js/rigging.selftest.js
+++ b/web/world/js/rigging.selftest.js
@@ -6,7 +6,7 @@
* js/tests/b.test.js) and node.
*/
-import { RiggingSession, FABRIC } from './rigging.js';
+import { RiggingSession, FABRIC, fabricNoteFor } from './rigging.js';
import { SailRig, TENSION_MIN, TENSION_MAX } from './sail.js';
import { HARDWARE, START_BUDGET, SPARE_COST } from './contracts.js';
@@ -359,6 +359,39 @@ test('fabric is a bet the F key can actually place (SPRINT15 — the dead mechan
return 'cloth -> membrane -> cloth, $0 both ways, hessian refused';
});
+// --- SPRINT16 gate 2.2: the fabric on the record ---------------------------
+// The invoice row is unconditional wiring (main.js/hud.js, verified live);
+// what this pins is the VERDICT's fabric sentence — fabricNoteFor speaks only
+// when the bet provably mattered, and stays silent everywhere a sentence
+// would be a guess. Storm hail sizes are the shipped ones: southerly pea hail
+// 0.7 (cloth blocks 74% — hailBlockFor's measured number), wild night 1.3 and
+// ice night 1.4 (both fabrics block 100%).
+
+test('fabricNoteFor: speaks only when the bet mattered, silent when a sentence would guess', () => {
+ const [CLOTH, MEMBRANE] = FABRIC;
+ // the spec's case: a pea-hail night flown in cloth, hail cost the bed real HP
+ const leak = fabricNoteFor({ fabric: CLOTH, hailSize: 0.7, dmg: { hail: 22, rain: 3 } });
+ assert(leak && leak.includes(CLOTH.name), `leak note must name the fabric flown, got: ${leak}`);
+ assert(leak.includes('22'), `leak note must say what the leak cost, got: ${leak}`);
+ // big stones cannot pass the weave — cloth blocked 100%, hail damage came
+ // from where the sail wasn't, and blaming the weave for it would be a lie
+ assert(fabricNoteFor({ fabric: CLOTH, hailSize: 1.3, dmg: { hail: 40, rain: 5 } }) === null,
+ 'cloth blamed for big-stone hail — hailBlockFor(1.3, 0.3) is 1.0, nothing leaked');
+ // a leak that cost under the threshold is not worth a verdict sentence
+ assert(fabricNoteFor({ fabric: CLOTH, hailSize: 0.7, dmg: { hail: fabricNoteFor.LEAK_MATTERS_HP - 1, rain: 0 } }) === null,
+ 'a trivial leak got a sentence — the threshold is not being read');
+ // membrane's price is the pond, and only a pond the verdict itself would name
+ const pond = fabricNoteFor({ fabric: MEMBRANE, hailSize: 0.7, dmg: { hail: 0, rain: 2 }, pondPeak: 240 });
+ assert(pond && pond.includes(MEMBRANE.name) && pond.includes('240'), `pond note must name membrane and the kg held, got: ${pond}`);
+ assert(fabricNoteFor({ fabric: MEMBRANE, hailSize: 0.7, dmg: { hail: 0, rain: 2 }, pondPeak: 40 }) === null,
+ 'membrane got a sentence over a pond the verdict would not even mention');
+ // no hail data (a dry night), unknown fabric, missing fabric: silence, not a throw
+ assert(fabricNoteFor({ fabric: CLOTH, dmg: { hail: 30, rain: 0 } }) === null, 'no hail tonight but the weave got blamed');
+ assert(fabricNoteFor({ fabric: { id: 'hessian', name: 'hessian', porosity: 0.5 } }) === null, 'unknown fabric must be silent');
+ assert(fabricNoteFor({}) === null && fabricNoteFor() === null, 'missing fabric must be silent, not a throw');
+ return `leak note fires at 0.7-size stones + >=${fabricNoteFor.LEAK_MATTERS_HP} HP; pond note at >${fabricNoteFor.POND_MATTERS_KG} kg; all else silent`;
+});
+
export const RIGGING_TESTS = TESTS;
export function runRiggingSelftest() {