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:
parent
bfe87632e0
commit
71278da837
@ -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 ? 'They’ll want that made right by Friday.' : 'Thank you for your business.'}</div>` : ''}
|
||||
</div>`;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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;
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user