diff --git a/web/world/data/sites/backyard_01.json b/web/world/data/sites/backyard_01.json index e9b5283..a084960 100644 --- a/web/world/data/sites/backyard_01.json +++ b/web/world/data/sites/backyard_01.json @@ -1,7 +1,18 @@ { "id": "backyard_01", "name": "The Backyard", - "blurb": "Your own place. You know where everything is, which is the only advantage you get.", + "blurb": "You've worked this one for years. You know where everything is, which is the only advantage you get.", + "_blurb": [ + "SPRINT11: this said 'Your own place' until the job sheet put a client's name at the top of the", + "card and the two lines called each other liars on screen — THE HENDERSONS over 'your own place'.", + "The blurb gave way, and it wasn't close: 'your own backyard' is a leftover from when the game had", + "exactly one yard, and DESIGN.md has never wanted it — line 3 is 'every callout is a different", + "site, a different client', and gate 2 is the sprint where that frame actually arrives.", + "The line's JOB survives, which is why it's a rewrite and not a deletion: night 1 is the tutorial", + "because you know this yard, and that advantage now comes from having worked it for years instead", + "of owning it. Client-agnostic on purpose — the yard is the site's business, the client is the", + "night's (week.js NIGHTS). Caught by looking at the card, not by a test. No assert reads a blurb." + ], "yard": { "width": 30, "depth": 20 }, diff --git a/web/world/js/hud.js b/web/world/js/hud.js index 6a2c77e..33cd52d 100644 --- a/web/world/js/hud.js +++ b/web/world/js/hud.js @@ -84,6 +84,26 @@ const CSS = ` #hud-card .ledger .row.total { margin-top:6px; padding-top:8px; border-top:1px solid #24343d; } #hud-card .ledger .row.total b { color:#fff; } +/* SPRINT11 — the job sheet and the invoice. STRUCTURE ONLY, deliberately: the + letterhead and the invoice styling are Lane E's this sprint (§gate 2), and + everything below is the plainest thing that reads correctly so E can restyle + it without unpicking layout. The class names are the contract — .letterhead, + .brief, .jobsheet, .tomorrow — and E owns what they look like. */ +#hud-card .letterhead { display:flex; justify-content:space-between; align-items:baseline; + margin:-4px 0 10px; padding-bottom:8px; border-bottom:1px solid #24343d; + font-size:12px; letter-spacing:.18em; text-transform:uppercase; color:#7ee0ff; } +#hud-card .letterhead .inv { color:#8ba0ad; letter-spacing:.1em; } +#hud-card .brief { margin:10px 0 12px; padding:9px 11px; border-left:2px solid #2f3f4a; + background:#101a20; color:#c3d2da; font-size:13px; line-height:1.55; font-style:italic; } +#hud-card .jobsheet { margin-top:12px; padding-top:10px; border-top:1px solid #24343d; } +#hud-card .jobsheet .row b { color:#a8f0b8; } +#hud-card .jobsheet .row.total { margin-top:6px; padding-top:8px; border-top:1px solid #24343d; } +#hud-card .jobsheet .row.total b { color:#fff; } +/* Tomorrow is a rumour, and it should look like one next to tonight's numbers. */ +#hud-card .tomorrow { display:flex; gap:10px; align-items:baseline; margin-top:9px; + font-size:12px; color:#6d8494; } +#hud-card .tomorrow .tlabel { letter-spacing:.14em; text-transform:uppercase; color:#55707f; } + /* E's diptych. The art is the card; the words sit in the left third they left clear for exactly this. */ #hud-card .endcard { background-size:cover; background-position:center right; @@ -355,7 +375,7 @@ export function createHud(d) { * @param {{key:string, def:object}[]} storms * @param {(key:string) => void} onPick */ -showForecast({ key, def, site }, wk, onGo) { +showForecast({ key, def, site, tomorrowDef }, wk, onGo) { // Integrator merge (Sprint 8): A's week card shape + C's forecastLines — // MEASURED numbers, banded by lead (the old inline estimate read 30 m/s // for a storm that really gusts 32.3). Tonight is lead 0 = exact; when @@ -381,15 +401,64 @@ showForecast({ key, def, site }, wk, onGo) { ? `
${site.name}${site.blurb ? ` — ${site.blurb}` : ''}
` : ''; - card.innerHTML = `
+ // SPRINT11 — THE JOB SHEET. DESIGN.md's core loop opens with "the brief: + // client wants X, budget Y, forecast Z", and this card had Z and nothing + // else. The client and the brief are data (week.js NIGHTS), so an + // unbriefed night degrades to the old storm card rather than an empty + // letterhead — which is also what keeps every existing test honest. + const job = wk.job ?? {}; + const letterhead = job.client + ? `
${job.client}
` : ''; + const brief = job.brief + ? `

${job.brief}

` : ''; + + // BUDGET Y — and this is the actual feature, not the flavour. The fee has + // existed since Sprint 8 and the player has never seen it until the money + // was already spent: you decided what to rig without knowing what the job + // was worth, which is a reveal, not a decision. Quoting the maxima up front + // makes the shop a judgement ("is $45 of garden bonus worth a $30 + // shackle?") instead of a guess. + const q = wk.quote; + const schedule = q ? ` +
+
base — the job$${q.base}
+
garden bonus — if the bed livesup to $${q.garden}
+
clean bonus — nothing of theirs broken$${q.clean}
+
the job's worthup to $${q.total}
+
` : ''; + + // Lane C's forecast lead (SPRINT11 gate 2): tomorrow, hedged. Their + // `forecastLines(def, lead)` has carried the lead param for two sprints + // with nothing ever calling it above 0 — this is the call site. + // + // ⚠️ C: THE SLOT IS MINE, THE NUMBER IS YOURS. `lead` is a 0..1 haze dial + // (confidence = 1 − lead), NOT "nights out" — I read it as nights first, + // passed 1, and the card cheerfully advertised "forecast confidence 0%". + // 0.6 because your own hail rule calls L > 0.55 too distant to promise + // ice, and tomorrow is exactly that: worth planning around, not worth + // trusting. It reads "confidence 40%" and the band resolves to an exact + // number when tomorrow becomes tonight at lead 0. Tune it. + const TOMORROW_LEAD = 0.6; + const tf = tomorrowDef ? forecastLines(tomorrowDef, TOMORROW_LEAD) : null; + const tomorrow = tf ? ` +
+ tomorrow + ${tf.wind}${tf.confidence ? ` · ${tf.confidence}` : ''} +
` : ''; + + card.innerHTML = `
+ ${letterhead}

NIGHT ${wk.night} OF ${wk.nights}

${pips}

${(f.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}

${siteLine} + ${brief}
${f.wind}
${f.rain}${hail ? ' · hail forecast' : ''}
${f.confidence ? `
${f.confidence}
` : ''} -
in the bank$${wk.bank}
+ ${tomorrow} + ${schedule} +
in the bank$${wk.bank}
${hail ? 'Hail is what kills a garden, and cloth stops hail. Get the sail over the bed.' @@ -464,22 +533,40 @@ showForecast({ key, def, site }, wk, onGo) { */ showAftermath(r, onAgain) { const w = r.week; + // What happened. The money is the ledger's business — collateral used to be + // listed here AND priced there, which is one fact told twice; the invoice + // itemises it now, so this stays the job and that stays the bill. const rows = [ ['garden', `${r.hp.toFixed(0)}%`], ['corners intact', `${r.cornersTotal - r.cornersLost}/${r.cornersTotal}`], ['what got through', r.hailBlocked], ['hardware lost', r.bill ? `$${r.bill}` : 'none'], - ['collateral', r.collateral.length ? r.collateral.map((c) => `${c.what} ($${c.cost})`).join(', ') : 'none'], ].map(([k, v]) => `
${k}${v}
`).join(''); // The money, itemised. A settlement you can't read is a number you can't // argue with, and arguing with it is how you learn the shop. + // + // SPRINT11 — this is the INVOICE now, so it answers the job sheet line for + // line: base, garden, clean. Each row names what it was FOR, and the two + // that can come up empty say why rather than vanishing — a bonus that + // silently isn't there teaches nothing, and "clean bonus — you took the + // carport" is the sentence that makes the trap land. E's copy for the + // collateral row, verbatim from THREADS: "the carport — $180". + const collateralRows = w && w.collateral + ? (r.collateral ?? []).map((c) => ` +
${c.what}−$${c.cost}
`).join('') + : ''; + const cleanRow = w ? (w.clean + ? `
clean bonus — nothing of theirs broken+$${w.clean}
` + : `
clean bonus — forfeited$0
`) : ''; + const money = w ? `
-
${w.won ? 'fee' : `fee (night lost — ${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}+$${w.fee}
-
garden bonus+$${w.bonus}
+
${w.won ? 'base — the job' : `base — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}+$${w.fee}
+
garden bonus — ${r.hp.toFixed(0)}% of the bed+$${w.bonus}
+ ${cleanRow}
gear recovered+$${w.refund}
- ${w.collateral ? `
collateral−$${w.collateral}
` : ''} + ${collateralRows}
spent on the rig−$${w.spent}
in the bank$${w.bankBefore} → $${w.bankAfter}
` : ''; @@ -489,7 +576,8 @@ showForecast({ key, def, site }, wk, onGo) { : w.outcome === 'win' ? 'SEE THE WEEK' : 'SEE THE DAMAGE'; - card.innerHTML = `
+ card.innerHTML = `
+ ${w?.client ? `
${w.client}invoice
` : ''}

MORNING${w ? ` · NIGHT ${w.night} OF 5` : ''}

${r.subtitle}

${rows} diff --git a/web/world/js/main.js b/web/world/js/main.js index 74b1eb5..5a63bd2 100644 --- a/web/world/js/main.js +++ b/web/world/js/main.js @@ -728,9 +728,19 @@ export async function boot(opts = {}) { rigging.session._startBudget = week.bank; rigging.session.reset(); spentThisNight = 0; + // SPRINT11 — the job sheet reads three new things off the week: tonight's + // JOB (client + brief), the QUOTE (what it pays, before you rig it, which is + // the half of DESIGN.md's brief the game never showed), and TOMORROW's storm + // def for Lane C's forecast lead — every storm is loaded up front, so + // tomorrow costs nothing but an index. Null on the final night: there is no + // tomorrow, and a card that hedges about one would be lying. + const tomorrowDef = week.isFinalNight ? null : defs[nightAt(week.night).storm]; hud.showForecast( - { key: week.stormKey, def: defs[week.stormKey], site: siteMeta[week.site] }, - { night: week.night, nights: week.nights, bank: week.bank, log: week.log }, + { key: week.stormKey, def: defs[week.stormKey], site: siteMeta[week.site], tomorrowDef }, + { + night: week.night, nights: week.nights, bank: week.bank, log: week.log, + job: week.job, quote: week.quote(defs[week.stormKey]), isFinalNight: week.isFinalNight, + }, () => { stormKey = week.stormKey; game.setPhase('prep'); }, ); } diff --git a/web/world/js/tests/a.test.js b/web/world/js/tests/a.test.js index 8bc33c7..9753ec5 100644 --- a/web/world/js/tests/a.test.js +++ b/web/world/js/tests/a.test.js @@ -11,7 +11,7 @@ import { createCameraRig } from '../camera.js'; import { createGame, createWindRouter, verdictFor } from '../main.js'; import { orderRing } from '../sail.js'; import { loadStorm, createWind } from '../weather.js'; -import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW } from '../week.js'; +import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY } from '../week.js'; import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js'; /** @param {import('../testkit.js').Suite} t */ @@ -86,12 +86,88 @@ export default async function run(t) { ); assertEq(s.bankBefore, 80); assertEq(s.bankAfter, 80 - 70 + s.pay, 'bank = bank − spent + pay, nothing else'); - assertEq(s.pay, s.fee + s.bonus + s.refund - s.collateral, 'the ledger adds up as shown'); + // SPRINT11 — `clean` is the new term, and this assert is the reason it's + // spelled out here rather than folded into the fee: the invoice shows four + // lines, so pay must BE those four lines. It caught the clean bonus the + // 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'); 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'); }); + // --- SPRINT11 gate 2: the job sheet ------------------------------------- + + t.test('every night is a JOB — a client, a brief, and a yard', () => { + for (let i = 0; i < NIGHTS.length; i++) { + const j = nightAt(i); + assert(j.client, `night ${i + 1} has a client`); + assert(j.brief && j.brief.length > 20, `night ${i + 1} has a brief worth reading`); + assert(j.site, `night ${i + 1} has a yard`); + } + // The week's shape, which is DATA and not decoration: four nights on + // retainer at one yard while they're away, and ONE short-notice callout to + // a stranger's corner block. Night 3 landing differently is the whole point + // of the ladder — a different site MEANS a different client. + const clients = NIGHTS.map((_, i) => nightAt(i).client); + assertEq(new Set(clients).size, 2, 'two clients: the retainer and the one-off'); + assert(clients[2] !== clients[0], 'night 3 is somebody else'); + assertEq(nightAt(2).site, 'site_02_corner_block', "and it's their corner block"); + }); + + t.test('an unbriefed night is a job with no letterhead, not a crash', () => { + // SPRINT10's promise was that a bare storm key still resolves. SPRINT11 must + // keep it: the job sheet degrades to the old storm card rather than throwing + // or printing "undefined" at a client. + const j = nightAt(99); // off the end entirely + assertEq(j.client, null, 'no client is null, not undefined'); + assertEq(j.brief, null, 'no brief is null'); + assertEq(j.site, 'backyard_01', 'and it still names a yard'); + }); + + t.test('the job sheet quotes what the night pays BEFORE you rig it', () => { + // The feature, not the flavour. The fee has existed since Sprint 8 and was + // only ever shown in the aftermath — you chose what to spend without knowing + // what the job was worth. That's a reveal, not a decision. + const w = createWeek(); + const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } }; + const q = w.quote(def); + assertEq(q.base, PAY.feeFor(20), 'base is the fee, quoted at full'); + assertEq(q.garden, PAY.gardenBonusMax, 'the garden bonus at a perfect bed'); + assertEq(q.clean, PAY.noCollateralBonus, 'and the clean bonus'); + assertEq(q.total, q.base + q.garden + q.clean, 'the quote sums to what it promises'); + + // The quote must not lie: settle a perfect night and the invoice has to pay + // what the sheet advertised. A job sheet that over-promises is worse than no + // job sheet — it's the game lying on paper. + const s = createWeek().settle({ hp: 100, win: true, collateral: [], intactHardwareValue: 0 }, def, 0); + assertEq(s.fee + s.bonus + s.clean, q.total, 'a perfect night pays exactly the quote'); + }); + + t.test('the clean bonus is about THEIR property, not your success', () => { + const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } }; + const gnome = [{ what: 'garden gnome', cost: 25 }]; + + const cleanWin = createWeek().settle({ hp: 90, win: true, collateral: [], intactHardwareValue: 0 }, def, 0); + const dirtyWin = createWeek().settle({ hp: 90, win: true, collateral: gnome, intactHardwareValue: 0 }, def, 0); + const cleanLoss = createWeek().settle({ hp: 10, win: false, collateral: [], intactHardwareValue: 0 }, def, 0); + + assertEq(cleanWin.clean, PAY.noCollateralBonus, 'broke nothing: paid'); + assertEq(dirtyWin.clean, 0, 'broke the gnome: forfeited'); + // The deliberate one. Lose the garden but break nothing of theirs and you + // are still a tradesperson who didn't wreck the place. Different fact, + // different row — that's what makes it an invoice and not a score. + assertEq(cleanLoss.clean, PAY.noCollateralBonus, 'a lost night can still be a clean one'); + + // And it makes the trap bite twice: 180 + the forfeited bonus. + const carport = createWeek().settle( + { hp: 90, win: true, collateral: [{ what: 'the carport', cost: 180 }], intactHardwareValue: 0 }, def, 0); + assertEq(cleanWin.pay - carport.pay, 180 + PAY.noCollateralBonus, + 'the carport costs 200: the roof AND the bonus'); + }); + t.test('a lost night pays a fraction of the fee, not zero and not all of it', () => { const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } }; const won = createWeek().settle({ hp: 80, win: true, collateral: [], intactHardwareValue: 0 }, def, 0); diff --git a/web/world/js/week.js b/web/world/js/week.js index a47d65c..c81a71f 100644 --- a/web/world/js/week.js +++ b/web/world/js/week.js @@ -30,19 +30,78 @@ import { START_BUDGET, HARDWARE } from './contracts.js'; * and its storm carries the southerly change that makes the venturi scream. The * plain-string form is still accepted (defaults to backyard_01) so nothing that * reads NIGHTS[i] as a storm key breaks. + * + * SPRINT11: a night is now a JOB. Same ladder, same yards — what's new is the + * frame DESIGN.md has had from line one: *"work arrives as callouts — each one a + * site with existing conditions, a client brief, a budget, and a forecast + * window."* You are not surviving five nights; you are working five jobs. + * + * The week that fell out of the data, once it was asked to name a client: the + * Hendersons are away (SPRINT11's own line — they leave for Cairns), so four of + * the five nights are the same yard, on retainer, through a bad week. Night 3 is + * a different site, so it is necessarily a different client — a short-notice + * callout to a yard you have never rigged, on the night the change comes early. + * That is not flavour bolted on: it is what `site: site_02_corner_block` already + * MEANT, finally said out loud. The corner block lands harder because it's a + * stranger's place and you're there at short notice. + * + * Copy lives here as data, per SPRINT11's rule: client flavour is data, not code. */ export const NIGHTS = [ - { storm: 'storm_01_gentle', site: 'backyard_01' }, - { storm: 'storm_03_southerly', site: 'backyard_01' }, - { storm: 'storm_03b_earlybuster', site: 'site_02_corner_block' }, - { storm: 'storm_02_wildnight', site: 'backyard_01' }, - { storm: 'storm_02b_icenight', site: 'backyard_01' }, + { + storm: 'storm_01_gentle', site: 'backyard_01', + client: 'the Hendersons', + brief: 'They fly out Friday and the seedlings are the whole point — six weeks in, first season that ' + + 'took. Get the sail up while it\'s calm. Nothing tonight will test it.', + }, + { + storm: 'storm_03_southerly', site: 'backyard_01', + client: 'the Hendersons', + brief: 'A southerly, and hail with it. This is the one the seedlings can\'t take — cloth over the bed ' + + 'or there\'s no bed on Monday.', + }, + { + storm: 'storm_03b_earlybuster', site: 'site_02_corner_block', + client: 'the Vasilaros place', + brief: 'Short notice — corner block two streets over, never rigged it. Same southerly as Tuesday, ' + + 'except the change is through before you\'ve finished your coffee. They said there\'s plenty ' + + 'to tie off to.', + }, + { + storm: 'storm_02_wildnight', site: 'backyard_01', + client: 'the Hendersons', + brief: 'Back at the Hendersons\', and this is the night the whole week was pointing at. Rig it like ' + + 'they\'re watching, because Friday they will be.', + }, + { + storm: 'storm_02b_icenight', site: 'backyard_01', + client: 'the Hendersons', + brief: 'Less wind than last night and more ice, which is worse for the bed and easier on the rig. ' + + 'Last night before they land.', + }, ]; -/** A night entry, either shape → {storm, site}. */ +/** + * A night entry, every shape → a full JOB. + * + * The plain-string form still resolves (SPRINT10's promise), and now so does a + * night with no client: an unbriefed job is a job with no letterhead, not a + * crash. `pay` is per-job so the schedule is data — see PAY for the defaults and + * for why the clean bonus is the one number here that is NOT yet measured. + * + * @param {number} i + * @returns {{storm:string, site:string, client:string|null, brief:string|null, pay:object}} + */ export function nightAt(i) { const n = NIGHTS[i]; - return typeof n === 'string' ? { storm: n, site: 'backyard_01' } : n; + const base = typeof n === 'string' ? { storm: n, site: 'backyard_01' } : (n ?? {}); + return { + storm: base.storm, + site: base.site ?? 'backyard_01', + client: base.client ?? null, + brief: base.brief ?? null, + pay: base.pay ?? {}, + }; } /** @@ -84,6 +143,36 @@ export const PAY = { gardenBonusMax: 45, /** What surviving hardware is worth back. Fatigued, not new. */ refundShare: 0.5, + + /** + * Nothing of theirs broken (SPRINT11 gate 2 — the third line on the invoice). + * + * 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) + * garden — how much of what they actually cared about survived + * clean — you left their place the way you found it + * Deliberately NOT gated on winning: it's about their property, not your + * success. Lose the garden but break nothing and you're still a tradesperson + * who didn't wreck the place — that's a different fact and it gets its own row. + * + * It also makes the carport bite twice, which is the point: take it and you + * lose 180 AND this. Sized so that total sting is 200, still inside E's + * "above ~250 and one mistake is a silent game over" ceiling. + * + * ⚠️ **THE ONE NUMBER IN THIS FILE THAT IS NOT MEASURED — for gate 3 to settle.** + * Every other constant here was set by playing the week. This one couldn't be: + * it's new money, and MEASURED (node, the model in BROKE_BELOW's note) it makes + * a competent week bank 385 → 485, i.e. it adds ~$100 to a five-night run that + * §BROKE_BELOW already flags as running away with itself. I did not fund it by + * quietly trimming `feeFor` or `gardenBonusMax` — those two carry measured + * evidence and long reasons, and re-tuning them on my taste to pay for my own + * new feature is exactly the move this repo keeps catching. + * So: if gate 3 says the bank runs away, THIS is the lever to cut first. It's + * the newest and least load-bearing number in the economy, it's per-job data, + * and cutting it costs nothing but a row on the invoice. + */ + noCollateralBonus: 20, }; /** @@ -169,6 +258,30 @@ export function createWeek(opts = {}) { get stormKey() { return nightAt(index).storm; }, /** SPRINT10: which yard tonight is on. main.js loads it. */ get site() { return nightAt(index).site; }, + /** SPRINT11: tonight's job — client, brief, pay schedule. The job sheet reads this. */ + get job() { return nightAt(index); }, + + /** + * What tonight pays, BEFORE you rig it — the "budget Y" half of DESIGN.md's + * brief, and the reason the job sheet is worth building. Until now the fee + * existed only in the aftermath: you found out what the job was worth after + * you'd decided what to spend on it, which is not a decision, it's a reveal. + * + * Quotes the maxima honestly: base at full (what a held night pays), garden + * at hp 100, clean at not-a-scratch. What you actually bank is the invoice's + * business — a quote is what's on offer, not a promise. + * + * @param {object} def tonight's storm def + */ + quote(def) { + const j = nightAt(index); + return { + base: PAY.feeFor(gustPeakOf(def)), + garden: j.pay.garden ?? PAY.gardenBonusMax, + clean: j.pay.clean ?? PAY.noCollateralBonus, + get total() { return this.base + this.garden + this.clean; }, + }; + }, get bank() { return bank; }, get isFinalNight() { return index === NIGHTS.length - 1; }, get over() { return done; }, @@ -191,14 +304,25 @@ export function createWeek(opts = {}) { const bankBefore = bank; const fullFee = PAY.feeFor(gustPeakOf(def)); const fee = run.win ? fullFee : Math.round(fullFee * PAY.lostNightShare); - const bonus = Math.round((Math.max(0, run.hp) / 100) * PAY.gardenBonusMax); + // 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 + // doesn't pay. That's not a rounding difference, it's a lie on paper. + const gardenMax = nightAt(index).pay.garden ?? PAY.gardenBonusMax; + const bonus = Math.round((Math.max(0, run.hp) / 100) * gardenMax); // Only hardware still on an unbroken corner comes home, and at half. const intact = (run.intactHardwareValue ?? 0); const refund = Math.round(intact * PAY.refundShare); const collateral = (run.collateral ?? []).reduce((s, c) => s + c.cost, 0); - const pay = fee + bonus + refund - collateral; + + // The clean bonus. Per-job data first, PAY's default otherwise — so a job + // that wants to say "there's a lot here to break" can, without code. + const job = nightAt(index); + const cleanMax = job.pay.clean ?? PAY.noCollateralBonus; + const clean = collateral === 0 ? cleanMax : 0; + + const pay = fee + bonus + refund + clean - collateral; bank = Math.max(0, bankBefore - spent + pay); @@ -220,9 +344,10 @@ export function createWeek(opts = {}) { else outcome = 'continue'; done = outcome !== 'continue'; - const s = { fee, fullFee, bonus, refund, collateral, pay, spent, + 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 }; + grade: gradeFor(held), stormKey: week.stormKey, won: !!run.win, + client: job.client, site: job.site }; log.push(s); return s; },