Lane A S16 gate 1: the ledger — work history on the settlement, warranty at quote time, rep on the letterhead

week.js: REP (stars, half-steps, x1+0.04(rep-3) fee multiplier), warrantyItems()
derived from the previous settlement's rig record so quote==settle holds by
construction; rep moves at dawn of the night that earned it and never reads the
garden. main.js: rigRecordFor (exported value — the tally remembers what
repairCorner's cleared flag forgets), night break/repair tally, rep on the
forecast call. hud.js: rep under the ABN on job sheet AND invoice, warranty
lines on both papers worded per spec, fee-row multiplier the night it applies,
rep line under the verdict, end card speaks rep + shows warranty receipts,
C's gate-3.3 forecast print seam (inert until they land). a.test: 8 new tests
incl. the clean-week negative control. Selftest 426/0/0 in the browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 20:10:06 +10:00
parent bfe87632e0
commit 71278da837
4 changed files with 546 additions and 16 deletions

View File

@ -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) {
<div class="docket">
<div class="kind">JOB SHEET</div>
<div class="no">JOB ${docketNo(wk.night)} · ${ABN}</div>
${wk.rep != null ? `<div class="rep">standing ${repFmt(wk.rep)}</div>` : ''}
</div>
</div>
<div class="billto">
@ -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) => `
<div class="row bad"><span>warranty ${it.anchorId.toUpperCase()}, ${it.hw}, no charge to the client</span><b>$${it.cost}</b></div>
<div class="cond">your corner, down at dawn${it.addr ? `${it.addr}` : ''} · parts at shop price, labour on you</div>`).join('');
const schedule = q ? `
<div class="sect">WHAT IT PAYS</div>
<div class="sched">
<div class="row headline"><span>callout fee</span><b>$${q.base}</b></div>
<div class="cond">the night, rigged and stood up</div>
<div class="row headline"><span>callout fee${feeTag}</span><b>$${q.base}</b></div>
<div class="cond">the night, rigged and stood up${q.feeMult !== 1 ? ' — priced on the letterhead number' : ''}</div>
<div class="row"><span>garden bonus</span><b>up to $${q.garden}</b></div>
<div class="cond">${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"}</div>
<div class="row"><span>clean site</span><b>$${q.clean}</b></div>
<div class="cond">nothing of theirs broken</div>
<div class="cond">nothing of theirs broken</div>${warrantyRows}
<div class="row max"><span>the night, done right</span><b>$${q.total}</b></div>
</div>` : '';
@ -831,6 +865,13 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
${siteLine}
<div class="stat">${f.wind}</div>
<div class="stat">${f.rain}${hail ? ' · hail forecast' : ''}</div>
${/* 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 ? `<div class="stat">${f.stones}</div>` : ''}
${f.rainRate ? `<div class="stat">${f.rainRate}</div>` : ''}
${f.confidence ? `<div class="stat" style="color:#8ba0ad">${f.confidence}</div>` : ''}
${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 = `<div class="card endcard ${won ? 'win' : 'lose'}"
style="background-image:url(./models/textures/${art})">
<div class="endtext">
@ -895,7 +951,9 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
<div class="row"><span>nights survived</span><b>${nights}/5</b></div>
<div class="row"><span>gardens held</span><b>${s.held ?? 0}/5</b></div>
<div class="row"><span>in the bank</span><b>$${s.bankAfter}</b></div>
<div class="kicker">${kick}</div>
${s.rep != null ? `<div class="row"><span>reputation</span><b>${repFmt(s.rep)}</b></div>` : ''}
${s.warrantiesToDate ? `<div class="row"><span>warranty jobs booked</span><b>${s.warrantiesToDate}</b></div>` : ''}
<div class="kicker">${kick}${repKick}</div>
<button class="go">${won ? 'ANOTHER WEEK' : 'TRY AGAIN'}</button>
</div>
</div>`;
@ -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) => `
<div class="row bad"><span>warranty ${it.anchorId.toUpperCase()}, ${it.hw}, no charge to the client</span><b>$${it.cost}</b></div>`).join('') : '';
const invFeeTag = w && w.feeMult && w.feeMult !== 1 ? ` · standing ×${w.feeMult.toFixed(2)}` : '';
const money = w ? `
<div class="ledger">
<div class="row"><span>${w.won ? 'callout fee' : `callout fee — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}</span><b>+$${w.fee}</b></div>
<div class="row"><span>${w.won ? 'callout fee' : `callout fee — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}${invFeeTag}</span><b>+$${w.fee}</b></div>
<div class="row"><span>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) {
}</span><b>+$${w.bonus}</b></div>
${cleanRow}
<div class="row"><span>gear recovered</span><b>+$${w.refund}</b></div>
${collateralRows}
${collateralRows}${invWarrantyRows}
<div class="row bad"><span>spent on the rig</span><b>$${w.spent}</b></div>
<div class="row due ${due < 0 ? 'neg' : ''}"><span>AMOUNT DUE</span><b>${due < 0 ? '' : '+'}$${Math.abs(due)}</b></div>
<div class="row total"><span>in the bank</span><b>$${w.bankBefore} $${w.bankAfter}</b></div>
@ -987,6 +1052,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
<div class="docket">
<div class="kind">TAX INVOICE</div>
<div class="no">INV ${docketNo(w.night)} · ${ABN}</div>
${w.rep != null ? `<div class="rep">standing ${repFmt(w.rep)}</div>` : ''}
</div>
</div>
<div class="billto">
@ -1001,6 +1067,12 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
${rows}
${money}
<div class="verdict ${r.win ? 'win' : 'lose'}">${r.verdict}</div>
${/* 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 ? `<div class="repline">reputation ${repFmt(w.repBefore)}${repFmt(w.rep)}${
w.repReasons?.length ? `${w.repReasons.join(' · ')}` : ''}</div>` : ''}
<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>` : ''}
</div>`;

View File

@ -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<string,{broke:number,repaired:number}>} [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<string, {broke:number, repaired:number}>} */
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);

View File

@ -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', () => {

View File

@ -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;
},