+ ${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;
},