diff --git a/web/world/js/hud.js b/web/world/js/hud.js
index 3f46295..68ca9d6 100644
--- a/web/world/js/hud.js
+++ b/web/world/js/hud.js
@@ -16,6 +16,10 @@
import * as THREE from '../vendor/three.module.js';
import { STORM_LEN } from './contracts.js';
import { forecastLines, leadFor } from './weather.js';
+// SPRINT16 — the end card compares the final rep against neutral. A constant,
+// not state: the HUD still only READS. week.js has no DOM and no THREE, so
+// this import costs nothing it didn't already cost main.js.
+import { REP } from './week.js';
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const kmh = (ms) => ms * 3.6;
@@ -47,6 +51,14 @@ const ABN = 'ABN 12 345 678 901';
*/
const docketNo = (night) => 1040 + night;
+/**
+ * SPRINT16 gate 1 — the letterhead's number, formatted once. Stars, one
+ * decimal (week.js REP moves in half-steps, so the decimal is always honest).
+ * It prints under the ABN on job sheet AND invoice — E's letterhead contract:
+ * the business is the constant, and this is the one number the business has.
+ */
+const repFmt = (r) => `★ ${Number(r).toFixed(1)}`;
+
// Load bar colours: green → amber → red. The amber band is deliberately wide;
// a corner at 60% in a lull is one gust from 100% and the player should feel
// that before the bar turns red on them.
@@ -169,6 +181,15 @@ const CSS = `
font-weight: 700; font-size: 11px; letter-spacing: .18em; color: #8ba0ad;
}
#hud-card .letterhead .docket .no { font-size: 10px; color: #55677a; letter-spacing: .06em; }
+/* SPRINT16 — the reputation line, under the ABN (the spec's words). Slightly
+ brighter than the docket number because it is the one thing in this corner
+ that MOVES, and a number that moves is a number the eye should find. */
+#hud-card .letterhead .docket .rep { font-size: 10px; color: #8ba0ad; letter-spacing: .08em; margin-top: 2px; }
+
+/* SPRINT16 — the invoice speaks rep: one quiet line under the verdict, where
+ the movement and its reason live. Not part of the ledger because it is not
+ money — it is the thing that PRICES tomorrow's money. */
+#hud-card .repline { margin: 8px 0 0; font-size: 11px; color: #8ba0ad; letter-spacing: .04em; }
/* --- bill-to / job-for block -------------------------------------------
Deliberately the same component on both documents. On the job sheet it's
@@ -753,6 +774,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
JOB SHEET
JOB ${docketNo(wk.night)} · ${ABN}
+ ${wk.rep != null ? `
standing ${repFmt(wk.rep)}
` : ''}
@@ -774,11 +796,23 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
// E's preview copy read "bed above 80% at dawn" and the mechanic pays
// linearly, and a quote that misstates its own terms is a lie on paper.
const q = wk.quote;
+ // SPRINT16 — the multiplier is on the sheet the night it applies, and
+ // only then: ×1.00 every night would teach that the number is decoration.
+ // The warranty lines are the other half of gate 1's paper: booked by last
+ // dawn's broken corners, worded as the spec words them ("no charge to the
+ // client" IS the sentence — unpaid labour is the point), priced at the
+ // parts' shop price, and already inside q.total so the sheet's conclusion
+ // is the deducted number the invoice will pay. Quote==settle is week.js's
+ // construction; this card just refuses to hide the deduction.
+ const feeTag = q && q.feeMult !== 1 ? ` · standing ×${q.feeMult.toFixed(2)}` : '';
+ const warrantyRows = (q?.warranty ?? []).map((it) => `
+
warranty — ${it.anchorId.toUpperCase()}, ${it.hw}, no charge to the client−$${it.cost}
+
your corner, down at dawn${it.addr ? ` — ${it.addr}` : ''} · parts at shop price, labour on you
`).join('');
const schedule = q ? `
WHAT IT PAYS
-
callout fee$${q.base}
-
the night, rigged and stood up
+
callout fee${feeTag}$${q.base}
+
the night, rigged and stood up${q.feeMult !== 1 ? ' — priced on the letterhead number' : ''}
garden bonusup to $${q.garden}
${q.beyondSaving
// SPRINT14 — B's pool item. On a gardenBeyondSaving night the bed
@@ -793,7 +827,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
? 'scales with the bed at dawn — and tonight the ice takes it whatever you rig'
: "paid on what's left of the bed at dawn"}
+ ${/* SPRINT16 — C's gate 3.3 seam (THREADS [A] seam contract): when
+ forecastLines exposes ready-to-print stone-size and rain-rate
+ lines, the sheet prints them verbatim; until then this renders
+ nothing. The bet (cloth vs membrane) is argued HERE or nowhere —
+ the forecast is the only place the player chooses from. */''}
+ ${f.stones ? `
${f.stones}
` : ''}
+ ${f.rainRate ? `
${f.rainRate}
` : ''}
${f.confidence ? `
${f.confidence}
` : ''}
${tomorrow}
${schedule}
@@ -887,6 +928,21 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
const [head, sub, kick] = COPY[won ? (s.grade ?? 'clean') : 'gameover'];
const nights = s.night;
+ // SPRINT16 — the end of the week speaks rep. A week that ends
+ // broke-but-clean and a week that ends rich-with-warranties are
+ // different endings (the gate's own words), and the difference is the
+ // number the next client sees — so the last thing the game says carries
+ // it. Keyed on where the letterhead number LANDED relative to neutral
+ // (REP.START), crossed with whether you're still trading; the row above
+ // it shows the warranty count so "rich" has its receipts.
+ const repKick = s.rep == null ? '' : s.rep > REP.START
+ ? (won ? ' The name on the letterhead is worth more than it was on Monday.'
+ : ' The book says broke. The number says the phone rings again.')
+ : s.rep < REP.START
+ ? (won ? ' The money came in. The mornings after are what gets remembered.'
+ : ' And word of the mornings after got there first.')
+ : '';
+
card.innerHTML = `
`;
@@ -948,9 +1006,16 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
// because "what this night was worth" and "where that leaves you" are two
// different facts.
const due = w ? w.pay - w.spent : 0;
+ // SPRINT16 — the invoice answers the sheet's warranty lines word for
+ // word (same items, same derivation in week.js), and the fee carries the
+ // same standing multiplier the sheet quoted. The ledger must keep
+ // summing exactly as shown — a.test pins pay to these rows.
+ const invWarrantyRows = w ? (w.warranty ?? []).map((it) => `
+
warranty — ${it.anchorId.toUpperCase()}, ${it.hw}, no charge to the client−$${it.cost}
${w.won ? 'callout fee' : `callout fee — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}+$${w.fee}
+
${w.won ? 'callout fee' : `callout fee — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}${invFeeTag}+$${w.fee}
garden bonus — ${r.hp.toFixed(0)}% of the bed${
// SPRINT14 — B's pool item, the invoice half. On a beyond-saving
// night this row is always small, and next to "callout fee — night
@@ -963,7 +1028,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
}+$${w.bonus}
+ ${/* SPRINT16 — the verdict speaks rep: the movement AND its reason, so
+ the number on the letterhead is never a number that changed for no
+ stated cause. week.js writes the reasons (including the flat
+ "broke, but repaired by dawn" case) — this line just reads them. */''}
+ ${w && w.rep != null ? `
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..6e275b3 100644
--- a/web/world/js/main.js
+++ b/web/world/js/main.js
@@ -272,6 +272,52 @@ export function verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped, beyondSav
return { mode: 'mostly', verdict: 'THE GARDEN MADE IT. Mostly.' };
}
+/**
+ * SPRINT16 gate 1 — the work history, per corner at dawn: what the ledger
+ * keeps. The night already computed every field; this is the one place they're
+ * assembled, so week.js can store them (settlement.rig) and tomorrow's sheet
+ * can book warranty off them. Shapes per the THREADS seam contract:
+ *
+ * broke it let go at least once tonight. Read off the EVENT TALLY,
+ * not the corner flag — repairCorner() clears `broken`, so by
+ * dawn the flag has forgotten every break that was fixed.
+ * repaired broke, and was standing again at dawn (D's hold-E). NOT a
+ * warranty item, by spec: fixing it mid-storm is the job.
+ * brokenAtDawn still down when the storm ended — books tomorrow's warranty
+ * line and takes tonight's rep hit. Riding it out broken is
+ * the thing the ledger exists to price.
+ *
+ * `repaired` and `brokenAtDawn` partition `broke` — a corner that broke twice
+ * and was fixed once is brokenAtDawn, not repaired. hw/hwCost are what was on
+ * the corner AT DAWN (a spare-repaired corner reads the spare, honestly — that
+ * is the steel the client's yard is wearing tomorrow). effRating is
+ * rating × ratingHint, the number sail.js actually fails on — the same truth
+ * the HUD bars and the verdict print, third surface, one formula.
+ *
+ * A value, exported, for the Enter-guard reason: scoreRun lives inside boot()
+ * behind a WebGL context where no assert can reach it, and the ledger's
+ * partition semantics are exactly the kind of rule that would otherwise only
+ * ever be "tested" by a green suite that never ran it.
+ *
+ * @param {Array} corners rig.corners at dawn
+ * @param {Map} [tally] the night's break/repair events per anchor
+ */
+export function rigRecordFor(corners, tally = new Map()) {
+ return corners.map((c) => {
+ const tal = tally.get(c.anchorId);
+ const broke = (tal?.broke ?? 0) > 0 || !!c.broken;
+ return {
+ anchorId: c.anchorId,
+ hw: c.hw.name,
+ hwCost: c.hw.cost,
+ effRating: Math.round(c.hw.rating * (c.anchor?.ratingHint ?? 1)),
+ broke,
+ repaired: broke && !c.broken,
+ brokenAtDawn: !!c.broken,
+ };
+ });
+}
+
// ---------------------------------------------------------------------------
// Phase machine
// ---------------------------------------------------------------------------
@@ -868,6 +914,8 @@ export async function boot(opts = {}) {
verdict,
/** Which failure mode the verdict is speaking from. Asserted in a.test.js. */
verdictMode: mode,
+ /** SPRINT16 gate 1 — the work history. See rigRecordFor above. */
+ rigRecord: rigRecordFor(rig.corners, nightTally),
};
}
@@ -940,6 +988,10 @@ export async function boot(opts = {}) {
{
night: week.night, nights: week.nights, bank: week.bank, log: week.log,
job: week.job, quote: week.quote(defs[week.stormKey]), isFinalNight: week.isFinalNight,
+ // SPRINT16 — the letterhead's number. The quote carries it too
+ // (quote.rep), but the sheet renders a letterhead even on nights whose
+ // quote degrades away, so it rides the week block explicitly.
+ rep: week.rep,
},
() => { stormKey = week.stormKey; game.setPhase('prep'); },
);
@@ -960,6 +1012,22 @@ export async function boot(opts = {}) {
if (!e.reason) pondDumped += e.kg;
});
+ // SPRINT16 gate 1 — the night's break/repair tally, per anchor. The ledger's
+ // `repaired` bit cannot be read off the corners alone: repairCorner() clears
+ // `broken`, so at dawn a fixed corner looks identical to one that never went.
+ // Same pattern as pondDumped above (subscribe once, reset at storm start) and
+ // for the same reason: the Emitter survives re-rigs, and pre-storm events —
+ // a divergence break on the calm day, say — are not the night's story.
+ /** @type {Map} */
+ const nightTally = new Map();
+ const tallyFor = (id) => {
+ let t = nightTally.get(id);
+ if (!t) { t = { broke: 0, repaired: 0 }; nightTally.set(id, t); }
+ return t;
+ };
+ rig.events.on('break', (e) => { tallyFor(e.anchorId).broke++; });
+ rig.events.on('repair', (e) => { tallyFor(e.anchorId).repaired++; });
+
// --- phases -------------------------------------------------------------
game.on('phaseChange', ({ to }) => {
// Prep and forecast happen on the calm day; the storm you picked only
@@ -980,7 +1048,7 @@ export async function boot(opts = {}) {
if (paused) { paused = false; hud.setPaused(false); }
if (muted) setMuted(true);
- if (to === 'storm') { pondPeak = 0; pondDumped = 0; }
+ if (to === 'storm') { pondPeak = 0; pondDumped = 0; nightTally.clear(); }
if (to === 'forecast') {
hud.setDawn(false);
diff --git a/web/world/js/tests/a.test.js b/web/world/js/tests/a.test.js
index 6b21392..3a68007 100644
--- a/web/world/js/tests/a.test.js
+++ b/web/world/js/tests/a.test.js
@@ -13,14 +13,14 @@ import { createWorld, heightAt, loadSite, validateSite } from '../world.js';
import { createCameraRig, spawnYawFor } from '../camera.js';
import {
CALM_STORM, accumulate, applyMute, canPlayHere, createGame, createWindRouter, enterCommits,
- stormsToPreload, verdictFor,
+ rigRecordFor, stormsToPreload, verdictFor,
} from '../main.js';
import { createGarden } from '../garden.js';
import { RiggingSession } from '../rigging.js';
import { createSkyFx } from '../skyfx.js';
import { SailRig, orderRing } from '../sail.js';
import { loadStorm, createWind } from '../weather.js';
-import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY } from '../week.js';
+import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY, REP } from '../week.js';
import { createHud } from '../hud.js';
import { PALETTE, boundsFaults, emptyTemplate, exportSiteJSON, placeEntry } from '../editor.js';
import { assert, assertClose, assertEq, assertLess, fixedLoop } from '../testkit.js';
@@ -253,7 +253,10 @@ export default async function run(t) {
// moment it landed (got 143, want 123), which is exactly its job — an
// invoice whose rows don't sum to its total is the one bug a player will
// definitely find.
- assertEq(s.pay, s.fee + s.bonus + s.refund + s.clean - s.collateral, 'the ledger adds up as shown');
+ // SPRINT16: − warrantyTotal joins the sum (0 on a history-less week, so
+ // this line is the formula's pin, not a behaviour change here).
+ assertEq(s.pay, s.fee + s.bonus + s.refund + s.clean - s.collateral - s.warrantyTotal,
+ 'the ledger adds up as shown');
assertEq(s.refund, 30, 'gear comes home at half — a shackle that rode a gale is not new');
w.advance();
assertEq(w.bank, s.bankAfter, 'the bank IS the next shop');
@@ -534,6 +537,233 @@ export default async function run(t) {
assert(b.collateralValue == null, 'the bike is unpriced BY RULING — build the fall first');
});
+ // =========================================================================
+ // SPRINT16 gate 1 — THE LEDGER: work history, warranty, reputation
+ // =========================================================================
+ // The fixtures speak the seam contract's shapes (THREADS [A] 2026-07-20):
+ // rc() is one corner of the rig record, cleanRun() a night nothing went
+ // wrong on. s16def matches the def the older week tests use — feeFor(20).
+
+ const s16def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
+ const rc = (anchorId, over = {}) => ({
+ anchorId, hw: 'shackle', hwCost: 15, effRating: 3200,
+ broke: false, repaired: false, brokenAtDawn: false, ...over,
+ });
+ const cleanRun = (over = {}) => ({
+ hp: 80, win: true, collateral: [], intactHardwareValue: 0,
+ rigRecord: [rc('p1'), rc('p2'), rc('p3'), rc('p4')], ...over,
+ });
+
+ t.test('SPRINT16: the work history — the tally remembers what the corner flag forgets', () => {
+ // rigRecordFor is the value scoreRun calls (the Enter-guard lesson: the
+ // closure inside boot() is unreachable, the rule is not). The trap it
+ // exists to dodge: repairCorner() clears `broken`, so at dawn a corner
+ // that broke and was fixed is indistinguishable from one that never went —
+ // the EVENT tally is the only honest witness.
+ const mk = (id, hw, hint, broken) => ({ anchorId: id, hw, broken, anchor: { ratingHint: hint } });
+ const shackle = { name: 'shackle', cost: 15, rating: 3200 };
+ const carab = { name: 'carabiner', cost: 5, rating: 1200 };
+ const tally = new Map([
+ ['q2', { broke: 1, repaired: 1 }], // broke, fixed — flag forgot, tally didn't
+ ['q3', { broke: 2, repaired: 1 }], // broke twice, fixed once — still down
+ ]);
+ const recs = rigRecordFor([
+ mk('q1', carab, 0.22, false), // held all night on the lying beam
+ mk('q2', shackle, 1, false), // hw at dawn = the spare, honestly
+ mk('q3', shackle, 1, true),
+ mk('q4', shackle, 1, true), // no tally entry at all (divergence-shaped) — still recorded
+ ], tally);
+ const by = (id) => recs.find((r) => r.anchorId === id);
+
+ assertEq(by('q1').broke, false, 'a corner that held is not a break');
+ assertEq(by('q1').effRating, Math.round(1200 * 0.22),
+ 'effRating is the number sail.js fails on — rating × hint, not the bare rating');
+ assertEq(by('q2').broke, true, 'the tally remembers the break the cleared flag forgot');
+ assertEq(by('q2').repaired, true, 'and standing at dawn = repaired');
+ assertEq(by('q2').brokenAtDawn, false);
+ assertEq(by('q3').repaired, false, 'broke twice, fixed once: brokenAtDawn, not repaired');
+ assertEq(by('q3').brokenAtDawn, true);
+ assertEq(by('q4').broke, true, 'a broken corner with no tally entry is still a break — belt AND braces');
+ for (const r of recs) {
+ assert(!(r.repaired && r.brokenAtDawn), `${r.anchorId}: repaired and brokenAtDawn must partition broke`);
+ assertEq(r.repaired || r.brokenAtDawn, r.broke, `${r.anchorId}: every break ends exactly one way`);
+ }
+ // …and the week KEEPS it: the settlement carries the record verbatim.
+ const w = createWeek();
+ const s = w.settle(cleanRun({ rigRecord: recs }), s16def, 0);
+ assertEq(s.rig, recs, 'settlement.rig IS the work history — the ledger keeps what the night computed');
+ });
+
+ t.test('SPRINT16: warranty fires ONLY on corners still broken at dawn — a repair books nothing', () => {
+ const w = createWeek();
+ w.settle(cleanRun({ rigRecord: [
+ rc('q2', { broke: true, repaired: true }), // mid-storm repair: NOT a warranty item (spec)
+ rc('q3', { broke: true, brokenAtDawn: true }), // rode it out broken: THE item
+ rc('p1'), rc('p2'),
+ ] }), s16def, 0);
+ w.advance();
+ const q = w.quote(s16def);
+ assertEq(q.warranty.length, 1, 'one item: the repaired corner books nothing');
+ assertEq(q.warranty[0].anchorId, 'q3');
+ assertEq(q.warranty[0].hw, 'shackle');
+ assertEq(q.warranty[0].cost, 15, "the broken hardware's shop price — parts; the labour is the point, unpaid");
+ assertEq(q.warrantyTotal, 15);
+ assert(q.warranty[0].addr, 'the item says whose yard it came from');
+ // The item lives ONE morning: settle tonight clean, and tomorrow books none.
+ w.settle(cleanRun(), s16def, 0);
+ w.advance();
+ assertEq(w.quote(s16def).warranty.length, 0, 'a warranty line does not haunt the week');
+ });
+
+ t.test('SPRINT16: quote==settle holds WITH a warranty line — the deduction is at quote time', () => {
+ const w = createWeek();
+ w.settle(cleanRun({ rigRecord: [
+ rc('q3', { broke: true, brokenAtDawn: true }), rc('p1'), rc('p2'), rc('p3'),
+ ] }), s16def, 0);
+ w.advance();
+ const q = w.quote(s16def);
+ assert(q.warrantyTotal > 0, 'precondition: tonight carries the deduction');
+ assertEq(q.total, q.base + q.garden + q.clean - q.warrantyTotal,
+ 'the quote CONCLUDES at the deducted number — the sheet does not hide it');
+ const s = w.settle(cleanRun({ hp: 100 }), s16def, 0);
+ assertEq(s.warrantyTotal, q.warrantyTotal, 'same items, same source — derived, never stored twice');
+ assertEq(s.fee, q.base, 'the fee is the quoted fee');
+ assertEq(s.fee + s.bonus + s.clean - s.warrantyTotal, q.total,
+ 'a perfect night pays exactly what the sheet quoted — the invariant, with teeth in it');
+ assertEq(s.pay, s.fee + s.bonus + s.refund + s.clean - s.collateral - s.warrantyTotal,
+ 'and the invoice rows still sum to the total shown');
+ });
+
+ t.test('SPRINT16: rep — clean up, dawn-broken down, collateral down harder; the garden is NOT an input', () => {
+ const s1 = createWeek().settle(cleanRun(), s16def, 0);
+ assertEq(s1.repBefore, REP.START, 'the outfit starts neutral');
+ assertEq(s1.rep, REP.START + REP.CLEAN, 'a clean night moves the letterhead number up');
+ assert(s1.repReasons.includes('clean night'), 'with its reason stated');
+
+ const s2 = createWeek().settle(cleanRun({ rigRecord: [
+ rc('p1', { broke: true, repaired: true }), rc('p2'), rc('p3'), rc('p4'),
+ ] }), s16def, 0);
+ assertEq(s2.repDelta, 0, 'broke-but-repaired is NEUTRAL: not clean, not a warranty — fixing it IS the job');
+ assert(s2.repReasons.join(' ').includes('repaired by dawn'), 'and the flat line says why');
+
+ const s3 = createWeek().settle(cleanRun({ rigRecord: [
+ rc('p1', { broke: true, brokenAtDawn: true }), rc('p2'), rc('p3'), rc('p4'),
+ ] }), s16def, 0);
+ assertEq(s3.repDelta, REP.WARRANTY, 'riding it out broken costs the number');
+
+ const s4 = createWeek().settle(cleanRun({
+ collateral: [{ what: 'the carport', cost: 180 }],
+ }), s16def, 0);
+ assertEq(s4.repDelta, REP.COLLATERAL, 'their property beats your hardware');
+ assertLess(s4.repDelta, s3.repDelta, 'collateral hits HARDER than a blown corner — the spec\'s word');
+
+ // The pin that matters most: same rig, opposite garden — identical movement.
+ // hp and win are absent from the movement BY CONSTRUCTION, which is what
+ // makes night 5's designed loss free without an exemption to go stale.
+ const a1 = createWeek().settle(cleanRun({ hp: 100, win: true }), s16def, 0);
+ const b1 = createWeek().settle(cleanRun({ hp: 0, win: false }), s16def, 0);
+ assertEq(a1.repDelta, b1.repDelta, 'rep never reads the garden');
+ });
+
+ t.test("SPRINT16: night 5's designed loss costs NO rep — a clean rig still earns it", () => {
+ const w = createWeek();
+ for (let i = 0; i < 4; i++) w.advance();
+ assertEq(nightAt(4).gardenBeyondSaving, true, 'precondition: the flagged night');
+ // Garden lost by design, steel held, nothing of theirs broken: that was
+ // the whole job tonight (the verdict's own words), and the number says so.
+ const s = w.settle(cleanRun({ hp: 0, win: false }), s16def, 0);
+ assertEq(s.repDelta, REP.CLEAN,
+ 'a designed loss the ledger punished would be the verdict lying again — it goes UP');
+ });
+
+ t.test('SPRINT16: rep prices the fee, and the multiplier is on the sheet the night it applies', () => {
+ const w = createWeek();
+ w.settle(cleanRun(), s16def, 0); // → ★3.5
+ w.advance();
+ const q = w.quote(s16def);
+ assertEq(q.rep, 3.5);
+ assertEq(q.feeMult, REP.multiplier(3.5));
+ assertEq(q.feeMult, 1.02, 'half-steps land EXACTLY on two decimals — the printed number is the number used');
+ assertEq(q.base, Math.round(PAY.feeFor(20) * 1.02), 'the fee is priced on the standing');
+
+ if (typeof document !== 'undefined') {
+ const hud = createHud({ scene: new THREE.Scene() });
+ try {
+ hud.showForecast(
+ { key: 'storm_03b_earlybuster', def: earlyBusterDef, site: null, tomorrowDef: null },
+ { night: w.night, nights: w.nights, log: [], job: w.job,
+ quote: q, bank: w.bank, rep: w.rep }, () => {});
+ const doc = (sel) => document.querySelector(`#hud-card ${sel}`);
+ assertEq(doc('.docket .rep')?.textContent, 'standing ★ 3.5',
+ 'the number is on the letterhead, under the ABN — E\'s contract');
+ assert((doc('.sched .row.headline span')?.textContent ?? '').includes('×1.02'),
+ 'the multiplier prints on the fee row the night it applies');
+ } finally { hud.dispose(); }
+ }
+ // …and settle pays the multiplied fee the sheet quoted.
+ const s = w.settle(cleanRun({ hp: 100 }), s16def, 0);
+ assertEq(s.fee, q.base, 'trust became visible money, and the invoice honoured it');
+ assertEq(s.feeMult, q.feeMult);
+ // Neutral control: a fresh week prints no multiplier tag at all.
+ assertEq(createWeek().quote(s16def).feeMult, 1, 'neutral standing is ×1.00 — and the sheet stays quiet about it');
+ });
+
+ t.test('SPRINT16 negative control: a clean week books ZERO warranty items and ends rep-positive', () => {
+ const w = createWeek();
+ let items = 0;
+ for (let n = 0; n < 5; n++) {
+ items += w.quote(s16def).warranty.length; // counted off the sheet…
+ items += w.settle(cleanRun(), s16def, 0).warranty.length; // …and the invoice
+ w.advance();
+ }
+ assertEq(items, 0, 'no sheet and no invoice ever carried a warranty line');
+ assert(w.rep > REP.START, `the week ends rep-positive (★${w.rep})`);
+ assertEq(w.rep, REP.MAX, 'five clean nights ride the clamp to the top of the scale');
+ assertEq(w.log[4].warrantiesToDate, 0, 'and the end card has no receipts to show');
+ w.reset();
+ assertEq(w.rep, REP.START, 'reset() is a new outfit — rep goes back to neutral');
+ });
+
+ t.test('SPRINT16: the invoice carries the warranty line and speaks rep; the endings differ', () => {
+ if (typeof document === 'undefined') return 'SKIPPED — no DOM';
+ const w = createWeek();
+ w.settle(cleanRun({ rigRecord: [
+ rc('q3', { broke: true, brokenAtDawn: true }), rc('p1'), rc('p2'), rc('p3'),
+ ] }), s16def, 0);
+ w.advance();
+ const s = w.settle(cleanRun({ hp: 90 }), s16def, 0);
+
+ const hud = createHud({ scene: new THREE.Scene() });
+ try {
+ hud.showAftermath({ hp: 90, cornersLost: 0, cornersTotal: 4, hailBlocked: 'x',
+ bill: 0, collateral: [], subtitle: 'test', verdict: 'test', win: true, week: s }, () => {});
+ const doc = (sel) => document.querySelector(`#hud-card ${sel}`);
+ const ledger = doc('.ledger')?.textContent ?? '';
+ assert(ledger.includes('warranty — Q3, shackle, no charge to the client'),
+ `the warranty line, worded as the spec words it — got: "${ledger}"`);
+ assert(ledger.includes('−$15'), 'and priced at the parts\' shop price');
+ assert((doc('.docket .rep')?.textContent ?? '').includes('★'),
+ 'rep is under the ABN on the invoice too — both papers, one number');
+ const repline = doc('.repline')?.textContent ?? '';
+ assert(repline.includes('reputation'), 'the verdict speaks rep');
+ assert(repline.includes('clean night'), `and states the reason: "${repline}"`);
+ hud.hideCard();
+
+ // The two endings the gate names, rendered and compared: they must not
+ // read the same, and rich-with-warranties must show its receipts.
+ hud.showEndCard({ ...s, outcome: 'gameover', rep: 4.5, warrantiesToDate: 0, grade: 'solvent' }, () => {});
+ const brokeClean = document.querySelector('#hud-card .kicker')?.textContent ?? '';
+ hud.hideCard();
+ hud.showEndCard({ ...s, outcome: 'win', rep: 1.5, warrantiesToDate: 4, grade: 'clean', held: 5 }, () => {});
+ const richWarranties = document.querySelector('#hud-card .kicker')?.textContent ?? '';
+ const endtext = document.querySelector('#hud-card .endtext')?.textContent ?? '';
+ assert(endtext.includes('warranty jobs booked'), 'rich-with-warranties shows its receipts');
+ assert(endtext.includes('reputation'), 'and the number is on the last card the game shows');
+ assert(brokeClean.length > 0 && richWarranties.length > 0 && brokeClean !== richWarranties,
+ 'broke-but-clean and rich-with-warranties are DIFFERENT endings and must read as such');
+ } finally { hud.dispose(); }
+ });
+
// --- SPRINT11 gate 1: the three rulings, each pinned ----------------------
t.test('ruling: the carport is typed as a carport, not smuggled in as a post', () => {
diff --git a/web/world/js/week.js b/web/world/js/week.js
index 4ef8000..42966e9 100644
--- a/web/world/js/week.js
+++ b/web/world/js/week.js
@@ -171,6 +171,11 @@ export const PAY = {
/**
* Nothing of theirs broken (SPRINT11 gate 2 — the third line on the invoice).
*
+ * SPRINT16 note: `noCollateralBonus` is no longer the only unmeasured number
+ * here — REP below joined it, with the same rule and the same cut-first
+ * status. This one keeps seniority: it's pure new money every night, where
+ * REP's swing is a percentage of a fee you had to earn.
+ *
* It exists to give the schedule three axes that mean three different things,
* which is what turns a scoreboard into an invoice:
* base — you turned up and did the job (severity-scaled; less if it went wrong)
@@ -199,6 +204,54 @@ export const PAY = {
noCollateralBonus: 20,
};
+/**
+ * SPRINT16 gate 1 — REPUTATION. One number on the letterhead, and it moves pay.
+ *
+ * The unit is STARS, 0..5 in half-star steps, neutral start at 3.0 — ruled in
+ * THREADS [A] 2026-07-20: stars because the spec asks for client-speak, and
+ * half-steps because every number the letterhead prints stays exact (no float
+ * drift on a business document).
+ *
+ * Movement is settled at DAWN OF THE NIGHT THAT EARNED IT, not the morning the
+ * warranty line books — the rep and the warranty item come from the same dawn
+ * inspection of the same rig record, they just surface one morning apart:
+ * · clean night (zero breaks, zero collateral): +CLEAN
+ * · each corner of yours still broken at dawn: −WARRANTY (it books tomorrow)
+ * · each collateral event: −COLLATERAL (down harder)
+ * A broke-but-repaired-by-dawn night is NEUTRAL by design: not clean (it
+ * broke), not a warranty (you fixed it mid-storm on your own ladder — that IS
+ * the job). And rep NEVER reads the garden — hp/win/gardenBeyondSaving are not
+ * inputs, so night 5's designed loss cannot cost rep by construction rather
+ * than by exemption. a.test pins that from both directions.
+ *
+ * Money: `multiplier(rep)` scales the callout fee at QUOTE TIME — quote() and
+ * settle() both read rep-as-of-morning, and rep only updates after settle has
+ * computed the fee, so the sheet and the invoice agree by ordering, not by
+ * discipline. 0.04/star is chosen over the rounder-sounding 0.05 because every
+ * half-step lands exactly on two decimals (×0.88 .. ×1.08) — the sheet prints
+ * the multiplier, and a printed number that isn't the number used is the
+ * quote-vs-settle lie wearing smaller type.
+ *
+ * ⚠️ UNMEASURED, same status as noCollateralBonus above: the swing is bounded
+ * (±12/8% of fee ≈ $6..9 a night) so it cannot mint a runaway week by itself,
+ * but nobody has played six nights under it yet. If gate 6's play says the
+ * economy tilts, CLEAN and the multiplier slope are levers, cut second after
+ * the clean bonus.
+ */
+export const REP = {
+ START: 3.0,
+ MIN: 0,
+ MAX: 5,
+ /** Clean night: no breaks (repaired or not), nothing of theirs broken. */
+ CLEAN: 0.5,
+ /** Per corner of yours still broken at dawn — the warranty ledger's rep half. */
+ WARRANTY: -0.5,
+ /** Per collateral event. Down harder: their property beats your hardware. */
+ COLLATERAL: -1.0,
+ /** Callout-fee multiplier at rep r. Exact at 2dp for every half-step. */
+ multiplier: (r) => 1 + 0.04 * (r - 3),
+};
+
/**
* Below this you cannot rig four corners at all — four carabiners is the floor.
*
@@ -257,10 +310,19 @@ export function gustPeakOf(def) {
* @property {number} bonus garden bonus
* @property {number} refund intact hardware recovered, at half
* @property {number} collateral what your failure broke of theirs
- * @property {number} pay fee + bonus + refund − collateral
+ * @property {number} pay fee + bonus + refund − collateral − warrantyTotal
* @property {number} bankBefore
* @property {number} bankAfter
* @property {'continue'|'win'|'gameover'} outcome
+ * @property {object[]} rig SPRINT16 — the work history: one entry per corner
+ * of the committed rig at dawn {anchorId, hw, hwCost, effRating, broke,
+ * repaired, brokenAtDawn}. main.js's scoreRun assembles it; the week keeps it.
+ * @property {object[]} warranty SPRINT16 — the items deducted TONIGHT (booked by
+ * last dawn's brokenAtDawn corners) {anchorId, hw, cost, night, site, addr, client}
+ * @property {number} warrantyTotal
+ * @property {number} feeMult rep multiplier the fee was priced at
+ * @property {number} rep after tonight's movement (repBefore/repDelta/repReasons too)
+ * @property {number} warrantiesToDate cumulative items this week, for the end card
*/
/**
@@ -272,9 +334,32 @@ export function createWeek(opts = {}) {
let index = 0; // 0-based into NIGHTS
let bank = startBank;
let done = false;
+ let rep = REP.START; // SPRINT16 — the number on the letterhead
/** @type {Settlement[]} */
const log = [];
+ /**
+ * Tonight's warranty items — corners of YOURS the last dawn found still
+ * broken. Derived from the previous settlement's rig record, never stored a
+ * second time: quote() and settle() both call THIS, so the sheet and the
+ * invoice read the same items by construction (the week's own invariant —
+ * they never diverge — extended to the new line the same way the gardenMax
+ * comment in settle() demands).
+ *
+ * `cost` is the broken hardware's shop price. The labour is free ON PURPOSE
+ * — "no charge to the client" is the whole sentence, and unpaid labour is
+ * what a warranty IS. A mid-storm repair books nothing (repaired corners
+ * are not in the filter); riding it out broken is the item.
+ */
+ function warrantyItems() {
+ const prev = log[log.length - 1];
+ if (!prev) return [];
+ return (prev.rig ?? []).filter((c) => c.brokenAtDawn).map((c) => ({
+ anchorId: c.anchorId, hw: c.hw, cost: c.hwCost,
+ night: prev.night, site: prev.site, addr: prev.addr, client: prev.client,
+ }));
+ }
+
const week = {
/** 1-based, for humans. "Night 3 of 5". */
get night() { return index + 1; },
@@ -299,10 +384,22 @@ export function createWeek(opts = {}) {
*/
quote(def) {
const j = nightAt(index);
+ // SPRINT16: the fee is priced on your standing, and the deduction for
+ // last dawn's broken corners is on the quote — AT QUOTE TIME, so settle
+ // pays exactly what the sheet said. Both read the same sources settle
+ // does (rep before tonight moves it; warrantyItems() off the log), which
+ // is the invariant doing the work, not a matched pair of formulas.
+ const feeMult = REP.multiplier(rep);
+ const warranty = warrantyItems();
+ const warrantyTotal = warranty.reduce((s, i) => s + i.cost, 0);
return {
- base: PAY.feeFor(gustPeakOf(def)),
+ base: Math.round(PAY.feeFor(gustPeakOf(def)) * feeMult),
garden: j.pay.garden ?? PAY.gardenBonusMax,
clean: j.pay.clean ?? PAY.noCollateralBonus,
+ feeMult,
+ rep,
+ warranty,
+ warrantyTotal,
/**
* SPRINT14 (B's pool item, offered S13 and accepted): the job sheet has
* to say when the garden bonus is money the night cannot pay.
@@ -320,16 +417,22 @@ export function createWeek(opts = {}) {
* still not telling it.
*/
beyondSaving: j.gardenBeyondSaving ?? false,
- get total() { return this.base + this.garden + this.clean; },
+ // The quote's conclusion carries the warranty deduction — the sheet
+ // promises the deducted number, and settle pays it. A quote that hid
+ // the deduction until the invoice would be the divergence in reverse.
+ get total() { return this.base + this.garden + this.clean - this.warrantyTotal; },
};
},
get bank() { return bank; },
+ /** SPRINT16 — the letterhead's number. Stars, 0..5, half-steps. */
+ get rep() { return rep; },
get isFinalNight() { return index === NIGHTS.length - 1; },
get over() { return done; },
get log() { return log; },
reset() {
index = 0; bank = startBank; done = false; log.length = 0;
+ rep = REP.START;
return week;
},
@@ -343,7 +446,12 @@ export function createWeek(opts = {}) {
*/
settle(run, def, spent) {
const bankBefore = bank;
- const fullFee = PAY.feeFor(gustPeakOf(def));
+ // SPRINT16: rep prices the fee, and it MUST be tonight's morning rep —
+ // the one the quote printed. rep is updated at the bottom of this
+ // function, after the money is computed, so quote and settle agree by
+ // ordering. Round after the multiplier, exactly as quote() does.
+ const feeMult = REP.multiplier(rep);
+ const fullFee = Math.round(PAY.feeFor(gustPeakOf(def)) * feeMult);
const fee = run.win ? fullFee : Math.round(fullFee * PAY.lostNightShare);
// Per-job override, PAY's default otherwise — and settle MUST read the same
// source the quote does, or the job sheet promises a number the invoice
@@ -363,7 +471,13 @@ export function createWeek(opts = {}) {
const cleanMax = job.pay.clean ?? PAY.noCollateralBonus;
const clean = collateral === 0 ? cleanMax : 0;
- const pay = fee + bonus + refund + clean - collateral;
+ // SPRINT16 — warranty. The SAME derivation the quote used (same function,
+ // same log state — the log only grows at the bottom of this call), so the
+ // deduction the sheet promised is the deduction the invoice takes.
+ const warranty = warrantyItems();
+ const warrantyTotal = warranty.reduce((s, i) => s + i.cost, 0);
+
+ const pay = fee + bonus + refund + clean - collateral - warrantyTotal;
bank = Math.max(0, bankBefore - spent + pay);
@@ -385,6 +499,41 @@ export function createWeek(opts = {}) {
else outcome = 'continue';
done = outcome !== 'continue';
+ // SPRINT16 — reputation moves at dawn of the night that earned it (the
+ // THREADS ruling): the same dawn inspection that will book tomorrow's
+ // warranty lines moves the number tonight. NOTE what is absent: hp, win,
+ // gardenBeyondSaving. Rep never reads the garden, so a designed loss
+ // cannot cost it BY CONSTRUCTION — the flag needs no exemption here, and
+ // a.test pins that from both directions.
+ const rigRec = run.rigRecord ?? [];
+ const brokeCount = rigRec.filter((c) => c.broke).length;
+ const dawnBroken = rigRec.filter((c) => c.brokenAtDawn).length;
+ const collateralEvents = (run.collateral ?? []).length;
+ const repBefore = rep;
+ let repDelta = 0;
+ /** @type {string[]} */
+ const repReasons = [];
+ if (brokeCount === 0 && collateralEvents === 0) {
+ repDelta += REP.CLEAN;
+ repReasons.push('clean night');
+ } else if (dawnBroken === 0 && collateralEvents === 0) {
+ // Broke, but every corner was standing again by dawn: NEUTRAL, and the
+ // invoice's rep line says why rather than printing an unexplained flat
+ // number. Fixing your own steel mid-storm is the job — it just isn't a
+ // clean night, and it isn't a warranty either.
+ repReasons.push('broke, but repaired by dawn');
+ }
+ if (dawnBroken > 0) {
+ repDelta += REP.WARRANTY * dawnBroken;
+ repReasons.push(`${dawnBroken} corner${dawnBroken > 1 ? 's' : ''} down at dawn`);
+ }
+ if (collateralEvents > 0) {
+ repDelta += REP.COLLATERAL * collateralEvents;
+ repReasons.push(`broke ${collateralEvents > 1 ? 'things' : 'something'} of theirs`);
+ }
+ rep = Math.min(REP.MAX, Math.max(REP.MIN, rep + repDelta));
+ repDelta = rep - repBefore; // what actually moved, after the clamp
+
const s = { fee, fullFee, bonus, refund, clean, cleanMax, collateral, pay, spent,
bankBefore, bankAfter: bank, outcome, night: week.night, held,
grade: gradeFor(held), stormKey: week.stormKey, won: !!run.win,
@@ -393,7 +542,18 @@ export function createWeek(opts = {}) {
// settlement so the HUD never has to reach back into week state.
gardenBeyondSaving: job.gardenBeyondSaving ?? false,
gardenMax,
- client: job.client, addr: job.addr, site: job.site };
+ client: job.client, addr: job.addr, site: job.site,
+ // SPRINT16 — the ledger. The rig record IS the work history (the night
+ // computed all of it; the week just keeps it), the warranty items are
+ // tonight's sheet answered on the invoice, and the rep block is the
+ // letterhead's number with its receipts. warrantiesToDate feeds the
+ // end card's rich-with-warranties reading without the HUD walking the
+ // log itself.
+ rig: rigRec,
+ warranty, warrantyTotal, feeMult,
+ rep, repBefore, repDelta, repReasons,
+ warrantiesToDate: log.reduce((n, x) => n + (x.warranty?.length ?? 0), 0)
+ + warranty.length };
log.push(s);
return s;
},