Lane B S16: gate 2.2 THE RECORD — the fabric bet is on the paperwork

fabricNoteFor() (rigging.js, pure, headless-tested): one verdict sentence
when the bet provably mattered — cloth on a night whose stones pass the
2mm-weave ruling with real hail damage names the leak; membrane past the
verdict's own 80 kg pond threshold names the trade; silent everywhere a
sentence would be a guess (big stones, dry nights, unknown fabric).
scoreRun carries {fabric, fabricNote}; the invoice grows an unconditional
'fabric flown' row and the verdict prints the note under itself (existing
verdict prose byte-untouched — a.test's verdictMode pins unaffected).
Prep-summary half already shipped in the fresh-eyes review (panel line 2).
Mutations red-then-green: leak gate inverted; pond threshold dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 20:25:22 +10:00
parent c6ded2db7d
commit 5bfd843b77
4 changed files with 116 additions and 3 deletions

View File

@ -123,6 +123,9 @@ const CSS = `
letter-spacing:.05em; } letter-spacing:.05em; }
#hud-card .verdict.win { background:#12321c; border:1px solid #2c6b3c; color:#7fce6a; } #hud-card .verdict.win { background:#12321c; border:1px solid #2c6b3c; color:#7fce6a; }
#hud-card .verdict.lose { background:#3a1618; border:1px solid #7d2b2b; color:#ff8f86; } #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 ---------------------------------------------------------- */ /* --- the week ---------------------------------------------------------- */
#hud-card .pips { letter-spacing:.5em; font-size:15px; margin:-6px 0 10px; color:#3f5561; } #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)}%`], ['garden', `${r.hp.toFixed(0)}%`],
['corners intact', `${r.cornersTotal - r.cornersLost}/${r.cornersTotal}`], ['corners intact', `${r.cornersTotal - r.cornersLost}/${r.cornersTotal}`],
['what got through', r.hailBlocked], ['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'], ['hardware lost', r.bill ? `$${r.bill}` : 'none'],
].map(([k, v]) => `<div class="row"><span>${k}</span><b>${v}</b></div>`).join(''); ].map(([k, v]) => `<div class="row"><span>${k}</span><b>${v}</b></div>`).join('');
@ -1000,7 +1007,11 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
<h2>${r.subtitle}</h2> <h2>${r.subtitle}</h2>
${rows} ${rows}
${money} ${money}
<div class="verdict ${r.win ? 'win' : 'lose'}">${r.verdict}</div> <div class="verdict ${r.win ? 'win' : 'lose'}">${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 ? `<div class="fabricnote">${r.fabricNote}</div>` : ''}</div>
<button class="go">${next}</button> <button class="go">${next}</button>
${w?.client ? `<div class="terms">Payable 14 days. ${w.collateral ? 'Theyll want that made right by Friday.' : 'Thank you for your business.'}</div>` : ''} ${w?.client ? `<div class="terms">Payable 14 days. ${w.collateral ? 'Theyll want that made right by Friday.' : 'Thank you for your business.'}</div>` : ''}
</div>`; </div>`;

View File

@ -23,7 +23,7 @@ import { createPlayer } from './player.js';
import { Interact, wireYardActions } from './interact.js'; import { Interact, wireYardActions } from './interact.js';
import { createDebris } from './debris.js'; import { createDebris } from './debris.js';
import { createSkyFx } from './skyfx.js'; import { createSkyFx } from './skyfx.js';
import { createRiggingUI } from './rigging.js'; import { createRiggingUI, fabricNoteFor } from './rigging.js';
import { createHud } from './hud.js'; import { createHud } from './hud.js';
import { createWeek, NIGHTS, nightAt } from './week.js'; import { createWeek, NIGHTS, nightAt } from './week.js';
import { createGarden } from './garden.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, const { verdict, mode } = verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped,
beyondSaving: week.job.gardenBeyondSaving }); 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 { return {
fabric: { id: fabric.id, name: fabric.name },
fabricNote,
hp, hp,
cornersLost: lost.length, cornersLost: lost.length,
cornersTotal: rig.corners.length || 4, cornersTotal: rig.corners.length || 4,

View File

@ -14,6 +14,7 @@
import { HARDWARE, START_BUDGET, SPARE_COST, FIXED_DT } from './contracts.js'; import { HARDWARE, START_BUDGET, SPARE_COST, FIXED_DT } from './contracts.js';
import { SailRig, orderRing, TENSION_MIN, TENSION_MAX } from './sail.js'; import { SailRig, orderRing, TENSION_MIN, TENSION_MAX } from './sail.js';
import { hailBlockFor } from './weather.core.js';
export { START_BUDGET, SPARE_COST }; export { START_BUDGET, SPARE_COST };
export const MAX_CORNERS = 4; export const MAX_CORNERS = 4;
@ -50,6 +51,59 @@ export const FABRIC = [
]; ];
export const DEFAULT_FABRIC = FABRIC[0]; 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 clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
const OK = { ok: true }; const OK = { ok: true };

View File

@ -6,7 +6,7 @@
* js/tests/b.test.js) and node. * 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 { SailRig, TENSION_MIN, TENSION_MAX } from './sail.js';
import { HARDWARE, START_BUDGET, SPARE_COST } from './contracts.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'; 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 const RIGGING_TESTS = TESTS;
export function runRiggingSelftest() { export function runRiggingSelftest() {