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]) => `
${k}${v}
`).join(''); @@ -1000,7 +1007,11 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {

${r.subtitle}

${rows} ${money} -
${r.verdict}
+
${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() {