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"}
clean site$${q.clean}
-
nothing of theirs broken
+
nothing of theirs broken
${warrantyRows}
the night, done right$${q.total}
` : ''; @@ -831,6 +865,13 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) { ${siteLine}
${f.wind}
${f.rain}${hail ? ' · hail forecast' : ''}
+ ${/* 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 = `
@@ -895,7 +951,9 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
nights survived${nights}/5
gardens held${s.held ?? 0}/5
in the bank$${s.bankAfter}
-
${kick}
+ ${s.rep != null ? `
reputation${repFmt(s.rep)}
` : ''} + ${s.warrantiesToDate ? `
warranty jobs booked${s.warrantiesToDate}
` : ''} +
${kick}${repKick}
`; @@ -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}
`).join('') : ''; + const invFeeTag = w && w.feeMult && w.feeMult !== 1 ? ` · standing ×${w.feeMult.toFixed(2)}` : ''; const money = w ? `
-
${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}
${cleanRow}
gear recovered+$${w.refund}
- ${collateralRows} + ${collateralRows}${invWarrantyRows}
spent on the rig−$${w.spent}
AMOUNT DUE${due < 0 ? '−' : '+'}$${Math.abs(due)}
in the bank$${w.bankBefore} → $${w.bankAfter}
@@ -987,6 +1052,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
TAX INVOICE
INV ${docketNo(w.night)} · ${ABN}
+ ${w.rep != null ? `
standing ${repFmt(w.rep)}
` : ''}
@@ -1001,6 +1067,12 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) { ${rows} ${money}
${r.verdict}
+ ${/* 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 ? `
reputation ${repFmt(w.repBefore)} → ${repFmt(w.rep)}${ + w.repReasons?.length ? ` — ${w.repReasons.join(' · ')}` : ''}
` : ''} ${w?.client ? `
Payable 14 days. ${w.collateral ? 'They’ll want that made right by Friday.' : 'Thank you for your business.'}
` : ''}
`; diff --git a/web/world/js/main.js b/web/world/js/main.js index c1d763d..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; },