/** * SHADES — the week. Lane A owns this file. * (SPRINT16 gate 4: the NIGHTS ladder and the two new briefs are Lane D's * edits, by the sprint's explicit grant — A owns everything else here.) * * Seven nights, one wallet. This is deliberately a thin wrapper around * the phase machine rather than a new system: main.js already knows how to run * forecast → prep → storm → aftermath, and the week's whole job is to answer * three questions between rounds — which storm is tonight, how much money do you * have, and is it over. * * Zero imports beyond contracts, and no THREE: the money and the ladder of * nights are pure data, so they're testable at fixed cost with no browser. The * only thing that touches the screen is main.js reading these numbers. */ import { START_BUDGET, HARDWARE } from './contracts.js'; import { offersFor, premiumFor } from './board.js'; /** * The ladder. Escalating, and each rung introduces one new idea rather than just * more wind — that's Lane C's authoring, not my ordering: * 1 Sea Breeze gust 10.5, no hail — the tutorial: nothing can hurt you * 2 Southerly Buster gust 20.0, hail — the change, and hail, arrive * 3 Early Buster gust 20.0, hail — same teeth, change comes EARLY * 4 Southerly again gust 20.0, hail — the storm you know, the yard you don't * 5 Wild Night gust 30.0, hail — the marquee storm: the most wind of the week * 6 Ice Night gust 28.5, hail — less wind, worse ice, and the bed is already lost * 7 Pea-Hail Soaker gust 15.2, fine hail — the night the default fabric is wrong * Night 3 is the trap: it looks like night 2 on the forecast card and isn't. * * SPRINT10: a night is now a STORM and a SITE. Night 3 moves to the corner block * — the same "looks like night 2 and isn't" beat, sharpened: it's a new yard * you've never rigged, its carport is a trap, 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. 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 the week; you are working its 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 the * backyard nights are the same yard, on retainer, through a bad week. A * different site is necessarily a different client. * * SPRINT16 gate 4 (Lane D, by the sprint's explicit grant): SIX nights. The * swing lawn enters the rotation at night 4 under the southerly — the ONE * variable rule (ROADMAP principle 5), applied literally: at its debut the only * new thing is the yard, because the storm is Tuesday's, already survived. The * soaker closes the week over the corner block — a yard night 3 taught — because * C MEASURED the pairing (THREADS [C] 2026-07-20): the backyard's buyable * geometry caps hail cover over the bed at ~31%, so over there the membrane * bet has no win in it. Exactly one night is the new site (4), exactly one * flies the new storm (the closer), and they are not the same night. * * ⚠️ THE STRUCTURAL CONSEQUENCE, SAID OUT LOUD (D, filed in THREADS for John's * gate 6): six slots cannot hold seven nights. Five shipped storms + the * soaker is six distinct storms; the swing lawn's debut must REPEAT a storm * the player already survived; so exactly one shipped storm leaves the week — * and every other candidate is load-bearing (gentle = the tutorial and the * calm-day preload; southerly = the hail intro and the early buster's * referent; early buster = site_02's debut, which the soaker's pairing needs; * icenight = the beyond-saving flag, canon). THE WILD NIGHT (storm_02) was out * of the rotation for exactly one integration. The honest fix named in that * filing was a SEVEN-night week — one entry re-added here, a ruling, not a * refactor. * * ⚖️ SPRINT17 gate 0.1 (D, wiring JOHN'S RULING, 2026-07-21): SEVEN NIGHTS — * the wildnight comes back. The slot is between the swing lawn and the * icenight, and the one-variable law places it, not taste: the wildnight has * flown the backyard since Sprint 4 (the pinned separation lives in THAT * yard's site data), so at night 5 the player is back on a yard they have * rigged three times and the storm is the only new thing. The slot also keeps * every S16 law intact by construction — the swing lawn still debuts under a * survived storm (night 4), the icenight is still neither new nor final * (night 6, its dawn breaks book warranty on the soaker's morning), and the * week's arc reads full / full / full / new-yard / won-but-tattered / * the-designed-loss / the-fabric-bet: the pyrrhic win now sits directly * before the night that cannot be won, which is the escalation the six-night * week had to fake with wording. The icenight's brief reverts to its pre-S16 * sentence ("less wind than last night") because that fact is TRUE again — * wildnight peaks 30.0, icenight 28.5. gradeFor's clean bar moves with the * ladder in this same commit (my own S16 filing's demand — see gradeFor). * * Copy lives here as data, per SPRINT11's rule: client flavour is data, not code. */ export const NIGHTS = [ // SPRINT12: `addr` is the second line of E's bill-to block — a letterhead // bills somebody somewhere. Street names are the site's fiction (E's kit // drafted both), and the addr carries the yard so the invoice, which has no // site line of its own, still says where the night happened. { storm: 'storm_01_gentle', site: 'backyard_01', client: 'the Hendersons', addr: '14 Kurrajong St — the backyard', 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', addr: '14 Kurrajong St — the backyard', 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', addr: '2 Bight Rd — the corner block', 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.', }, { // SPRINT16 gate 4 — THE NEW SITE, and nothing else new: Tuesday's southerly // over D's swing lawn. The brief does what "plenty to tie off to" did for // the carport — the client's own voice sells the trap (the crossbar between // the swing apexes is a dead-level rail and NOT an anchor; the apexes are // 0.45 steel that bills $140) — and the kid gives the bed its reason. storm: 'storm_03_southerly', site: 'site_03_swing_lawn', client: 'the Delaneys', addr: '31 Ferndale Ave — the swing lawn', brief: 'Same southerly as Tuesday, over a lawn you\'ve never rigged. The swing set is Ruby\'s — ' + 'her dad reckons the crossbar\'s bolted right through, tie to that and save yourself a ' + 'post. The veggie bed is her school project, so it comes home alive.', }, { // SPRINT17 gate 0.1 — THE WILDNIGHT, RESTORED (John's ruling: seven // nights). Back at the Hendersons', the yard the player has rigged three // times, so the storm is the only new thing — the one-variable law at the // marquee slot. This is the pyrrhic-win canon (the pinned separation: // best buyable line WINS at ~55-64 hp, tattered; bare bed loses), and the // brief sells exactly that stake in the clients' own voice: they're // ringing from Cairns about the BED, and they price the sail as the thing // that spends itself protecting it. "The garden made it; the sail didn't" // is the ending this night is FOR — the copy promises it as a win. storm: 'storm_02_wildnight', site: 'backyard_01', client: 'the Hendersons', addr: '14 Kurrajong St — the backyard', brief: 'The Hendersons rang from Cairns — the forecast made the news up there. This is the big ' + 'one, the night the whole week was pointing at. Their words: they\'d rather fly home to a ' + 'wrecked sail over a living bed than a tidy yard and a dead one. Spend the sail if you ' + 'have to. Save the garden.', }, { storm: 'storm_02b_icenight', site: 'backyard_01', client: 'the Hendersons', addr: '14 Kurrajong St — the backyard', // SPRINT17 gate 0.1 wording note (D): "Less wind than last night" is the // pre-S16 sentence, back because the FACT is back — the wildnight (gust // 30.0) sits at night 5 again, immediately ahead of this one (28.5). The // S16 six-night week had no louder night before this, so the brief said // "the most wind of the week" for one integration; the sentence moves // with the facts, both directions. The teaching half — level with the // player that the bed is already lost — is untouched, as ever. 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. Nobody\'s pretending the seedlings ride this one out — keep ' + 'the sail on its feet and the ice off the windows.', // SPRINT13 gate 1.4 — A's ruling, DESIGN CANON: the icenight's garden is // BEYOND SAVING, by design, at any price (ruled at night 5; the S17 // seven-night ladder flies it night 6 — the flag rides the NIGHT, not the // slot). Measured game-true (B's pay table and // A's probe, agreeing): bare bed 0.0 DEAD; best buyable garden 27.7; best // clean 15.3 — every line in the game sits under WIN's hp >= 50, wallet or // bank. The week's arc WANTS this: full / full / full, won-but-tattered // (night 5, the pinned separation, restored S17), then the night you cannot save — the // icenight is where the job stops being about the bed and becomes about // your steel (the line that would hold it needs $105 the shop caps at // 3.20 kN shackles against 3.22/3.25 peaks — C's few-hundredths whisker, // kept on purpose). This flag exists so surfaces TEACH the loss instead of // implying a better rig existed: verdictFor reads it (main.js) and stops // blaming placement; the brief above levels with the player before they // spend a dollar. If a retune ever makes this night winnable, delete this // flag in the same commit or the verdict lies in the other direction. gardenBeyondSaving: true, }, { // SPRINT16 gate 4 — THE NEW STORM, over a yard night 3 taught. C's pairing // receipt (THREADS [C] 2026-07-20): the soaker MUST fly the corner block — // the backyard caps hail cover at ~31% and the bet has no win there. The // brief sells the WRONG default the way the carport got sold: fine hail // through a 2 mm weave is exactly the night cloth loses, and the client is // the one telling you it's easy. The forecast (C's stones/rain lines) is // where the truth argues back — the brief stays in character. storm: 'storm_06_soaker', site: 'site_02_corner_block', client: 'the Vasilaros place', addr: '2 Bight Rd — the corner block', brief: 'The Vasilaros place again — they asked for you by name this time. Hardly any wind in it, ' + 'just rain and a fine rattle of hail; the kind of night you could rig in your sleep. ' + 'Whatever you flew last time will do, they said.', }, ]; /** * A night entry, every shape → a full JOB. **The one normaliser**, SPRINT17. * * 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. * * ⚠️ **SPRINT17 — WHY THIS IS A SEPARATE FUNCTION FROM `nightAt`, and it is not * tidiness.** The board can install a POOL entry as tonight's night, and a pool * entry is hand-authored data with exactly the same optional fields a NIGHTS * entry has. The first cut of `week.take()` stored `{...offer.night}` raw, and * the board probe found it in one call: a pool entry with no `pay` block threw * `Cannot read properties of undefined (reading 'garden')` out of quote() — * i.e. a chosen job was NOT the same shape as a scripted one, which is the * single promise gate 1 makes. Both doors normalise HERE now, so "a taken night * is indistinguishable from an authored night" is a fact about the code rather * than a thing two call sites both have to remember. * * @param {object|string} entry a NIGHTS entry, a pool entry, or a bare storm key */ export function normaliseNight(entry) { const base = typeof entry === 'string' ? { storm: entry, site: 'backyard_01' } : (entry ?? {}); return { storm: base.storm, site: base.site ?? 'backyard_01', client: base.client ?? null, addr: base.addr ?? null, brief: base.brief ?? null, pay: base.pay ?? {}, // False by default on purpose: a night is presumed savable unless its data // says otherwise, so a new night can never inherit the icenight's excuse. gardenBeyondSaving: base.gardenBeyondSaving ?? false, /** * SPRINT17 gate 2 — CLIENT CONSTRAINTS, as data on the night. * * Empty by default, and an EMPTY ARRAY rather than null on purpose: every * consumer (the job sheet, the invoice, the offer card, B's session) wants * to iterate this, and a null that four surfaces each have to remember to * guard is three surfaces away from a crash. The scripted spine ships * unconstrained — constraints arrive on the board's callouts — so this * field is `[]` on all seven nights and nothing about the campaign moves. */ constraints: base.constraints ?? [], }; } /** * Tonight's authored night, by index into the ladder. * @param {number} i * @returns {{storm:string, site:string, client:string|null, brief:string|null, pay:object}} */ export function nightAt(i) { return normaliseNight(NIGHTS[i]); } /** * The callout fee, and the ONE place it is computed. SPRINT17. * * Extracted because gate 2 adds a third term (the constraint premium) to a * formula that quote() and settle() were each spelling out for themselves. Two * copies of a two-term formula is a latent divergence; two copies of a * three-term formula is the quote-vs-settle lie with a longer fuse — and the * sheet promising a premium the invoice doesn't pay is exactly the failure the * warranty derivation was built to make impossible. So: one function, both * callers, rounded ONCE at the end. * * Order matters and is fixed here: severity → standing → the client's squeeze. * The premium is a fraction of the fee you'd otherwise be paid, so it compounds * on the rep multiplier rather than on the raw fee — a ★5 tradie is paid more * for accepting the same ban, which is the right way round. * * @param {object} def tonight's storm def * @param {number} rep standing AS OF THIS MORNING * @param {object[]} [constraints] tonight's client constraints */ export function calloutFee(def, rep, constraints = []) { return Math.round(PAY.feeFor(gustPeakOf(def)) * REP.multiplier(rep) * (1 + premiumFor(constraints))); } /** * The economy, in one place because it is the thing most likely to need tuning * once somebody actually plays five nights in a row. * * Shape (SPRINT8 §gate 1): pay = fee by severity + garden bonus + intact * hardware refund − collateral. Two of those needed a judgement call and both * are the difference between a week with stakes and a week you cannot lose: * * - **A lost night pays a fraction of the fee, not zero and not all of it.** * Zero is unrecoverable and reads as punishment; full pay means failure costs * only the broken shackle, and the bank only ever climbs. DESIGN.md's warranty * callout is the source: you turn up, you're just not getting paid for it. * - **Hardware comes back at half.** Full refund makes gear effectively free — * the only sink left is breakage, the bank grows every night, and "broke = * game over" becomes unreachable. Half is also the honest number: a shackle * that rode out a gale has been fatigued, its pin worked loose, and DESIGN.md * already says flogging unscrews unmoused pins. You can re-use it; you can't * call it new. */ export const PAY = { /** * Client's fee for the job, scaled off the storm's worst gust. * * The constants are set by the shape of the week, not by taste — measured by * playing all five nights at fixed skill. A fee has to be worth roughly * two-thirds of a good rig ($75): any smaller and one bad night is instant * game over (at `8 + gust` a disaster night paid $13 against a $75 rig and * ended the week on night ONE, which is a punishment, not a difficulty); any * larger and the bank runs away and night five is trivially affordable. * At `25 + 1.6·gust` a disaster leaves you at ~$26 — alive, but rigging the * next night on carabiners, which is the death spiral doing its job. */ feeFor: (gustPeak) => Math.round(25 + gustPeak * 1.6), /** A lost night still pays this share — you turned up, it just went wrong. */ lostNightShare: 0.35, /** Garden bonus at hp=100. Scales linearly; this is the main earner. */ 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). * * 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) * 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, }; /** * 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 the icenight'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. * * ⚠️ MEASURED, OPEN, AND FOR A PLAYTEST TO SETTLE — not a guess, and written * here because the numbers are only visible once you run five nights: * * 1. **A competent week runs away with the money.** Holding 4/4 at hp 70 every * night takes the bank 80 → 116 → 167 → 218 → 285 → 350. By night three you * can already afford four rated shackles ($120) and the wild night stops * being a decision. The fee is the lever; it is deliberately NOT tuned down * yet, because the fix might equally be that later nights should cost more * to rig (a bigger sail, a second bed) — that's Sprint 9's content question, * not a constant I should quietly pick tonight. * 2. **A floor-scraping week may never actually go broke.** At $20 a cheap rig * earns back roughly what it costs, so the bank can sit just above this line * indefinitely, losing every garden. The grade fixes what the END CARD says * about that (see gradeFor) but not whether 'gameover' is reachable at all * from a slow bleed rather than one disaster. * * I could not settle either from the harness: my own bad-player model handed * back a fixed $30 of intact hardware no matter what it spent, which flattered * the bottom of the curve and is exactly the class of mistake this repo has * caught me making all project. Both need a human playing five nights. */ export const BROKE_BELOW = HARDWARE[0].cost * 4; /** * SPRINT17 — the default week seed. * * A CONSTANT, not a clock. Every board in the shipped game is drawn from this * and the night index, so the campaign has one fixed sequence of offers that * the selftest can pin and John can play twice and recognise. `createWeek({seed})` * is the door for a different week — which is where replay comes from in a * later sprint, and it costs nothing to leave open now. */ export const DEFAULT_WEEK_SEED = 1; /** * How the week is remembered, by gardens actually held. * * SPRINT8 gave Lane A the win wording, and this is the ruling: the end card is * the last thing SHADES says to a player, so it does not get to lie the way the * aftermath verdict used to. Three grades, and E's copy carries two of them — * their primary for a clean week, their own alternate for a scraped one. The * third is mine, because E never wrote words for "you stayed solvent while the * client's garden died four times", and until this was measured nobody knew the * game could even produce that ending. * * SPRINT17 gate 0.1 (D, in A's file, flagged — revert-and-tell-me standard): * the clean bar is now DERIVED, not the constant 5. My own S16 filing said a * seven-night week needs gradeFor re-ruled in the same commit as the ladder, * and this is that commit. The S16 coincidence — gradeFor(5)='clean' landing * exactly on the six-night week's best outcome (5 won + the designed loss) — * breaks at seven nights: held>=5 would print "THE WEEK HELD — everything's * still where you put it" over a savable garden that died, which is the end * card lying, the one thing it exists not to do. So 'clean' is the RULE the * constant was standing in for: every garden the week let you save. The * scraped/solvent boundary (>=3) is untouched — moving it is a taste call on * A's wording and there's no lie in leaving it (3 of 6 savable is still "the * least wrong call most nights"); re-rule it if you want, it's yours. * * @param {number} held gardens saved, 0..savable */ export function gradeFor(held) { const savable = NIGHTS.filter((_, i) => !nightAt(i).gardenBeyondSaving).length; if (held >= savable) return 'clean'; if (held >= 3) return 'scraped'; return 'solvent'; } /** Worst gust a storm def can throw — the severity signal the fee reads. */ export function gustPeakOf(def) { const base = Math.max(...def.baseCurve.map((p) => p[1])); const g = def.gusts ?? {}; return base + (g.powBase ?? 0) + (g.powRamp ?? 0); } /** * @typedef {object} Settlement * @property {number} fee client's payment for the job * @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 − 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 */ /** * @param {object} [opts] * @param {number} [opts.bank=START_BUDGET] */ export function createWeek(opts = {}) { const startBank = opts.bank ?? START_BUDGET; let index = 0; // 0-based into NIGHTS let bank = startBank; let done = false; let rep = REP.START; // SPRINT16 — the number on the letterhead /** * SPRINT17 gate 1 — the week's SEED. Every morning's board is drawn from * this and the night index, and from nothing else: no Date.now, no * Math.random (contracts.js §Determinism, and the selftest depends on it — * an offer pool that reads the clock is a suite that goes red on a Tuesday). * Same seed, same week, same two offers, forever. */ const seed = opts.seed ?? DEFAULT_WEEK_SEED; /** * SPRINT17 gate 1 — TAKEN JOBS, keyed by night index. * * This is the whole mechanism behind "the chosen job IS the night", and it is * an OVERRIDE rather than a replacement on purpose. `jobAt()` falls through to * the authored `nightAt()` whenever nothing was taken, so: * · the scripted week runs byte-identically to the pre-board game (474 * asserts stand on that, and none of them know this map exists); * · a taken alternative is a night entry of exactly the same SHAPE, so * every per-night surface downstream — sheet, quote, invoice, ledger, * warranty, rep, end card — reads it through the path it already had. * Nothing consults "was this scripted?" to decide how to do its job, which * is the property that makes the board impossible to half-wire. * @type {Map} */ const taken = new Map(); const jobAt = (i) => taken.get(i) ?? nightAt(i); /** @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; }, get nights() { return NIGHTS.length; }, get stormKey() { return jobAt(index).storm; }, /** SPRINT10: which yard tonight is on. main.js loads it. */ get site() { return jobAt(index).site; }, /** SPRINT11: tonight's job — client, brief, pay schedule. The job sheet reads this. */ get job() { return jobAt(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 = jobAt(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 { // SPRINT17 gate 2 — ONE fee function, shared with settle(), so the // constraint premium cannot reach the sheet without reaching the // invoice. (Before this it was two hand-written copies of the same // formula agreeing by luck; adding a third term to both by hand is how // that luck runs out.) base: calloutFee(def, rep, j.constraints), garden: j.pay.garden ?? PAY.gardenBonusMax, clean: j.pay.clean ?? PAY.noCollateralBonus, feeMult, rep, warranty, warrantyTotal, /** * SPRINT17 gate 2 — the constraints the job is quoted UNDER, on the * sheet, and repeated verbatim on the invoice they were paid under * (hud.js prints both off this same array). `premium` is what they * added to the fee, so the sheet can show the client's squeeze and the * client's compensation as the two facts they are. */ constraints: j.constraints ?? [], premium: premiumFor(j.constraints), /** * 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. * * Deliberately a FLAG and not a zero. The comment in settle() below is * the rule — quote and settle must read the same source or the sheet * promises a number the invoice doesn't pay — and zeroing this would * break it in the opposite direction: settle still pays the bonus * proportionally (hp is low on a beyond-saving night, not zero), so a * quoted $0 would be its own lie on paper. The NUMBER is honest. What * was missing is its CONDITION, which on this night is "and the ice * takes the bed whatever you rig". The job sheet's own idiom is that * every amount carries its condition underneath; this is a night where * the condition is the entire story and the schedule was the last place * still not telling it. */ beyondSaving: j.gardenBeyondSaving ?? false, // 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; }, }; }, /** * SPRINT17 gate 1 — THIS MORNING'S BOARD. Two offers: tonight's scripted * night, and one alternative drawn deterministically from the pool. * * @param {(night:object) => object} [priced] optional per-offer pricing / * forecast / exposure, injected by main.js because it owns the storm defs * and the site JSON. board.js stays pure; the money is computed HERE, off * the same rep and the same calloutFee() the sheet and invoice use, so an * offer cannot advertise a fee the job sheet then contradicts. */ offers(priced = null) { return offersFor({ scripted: nightAt(index), weekSeed: seed, nightIndex: index, priced, }); }, /** * TAKE ONE. The other vanishes — no backlog, no decline cost (SPRINT18 * decides whether saying no costs you; SPRINT17 deliberately does not * charge for a choice it hasn't designed the consequences of). * * Idempotent per night and only forward: taking twice on the same morning * is the same take, and there is no untake. That's not a limitation, it's * the point — the board is a commitment, and a player who could re-open it * after reading the job sheet would be shopping, not choosing. * * @param {object} offer one of `offers()` * @returns {object} the night now installed for tonight */ take(offer) { if (!offer?.night) throw new Error('week.take: needs an offer with a .night (board.js offersFor)'); if (!taken.has(index)) { // Stamped with the offer id so the settlement — and therefore the // invoice and the end card — can say the job came off the board and // WHICH job it was. Non-enumerable so it can never leak into a night's // data shape and make a taken night structurally different from a // scripted one: `jobAt()` must hand back something indistinguishable. // normaliseNight, NOT a spread — see its docstring. A pool entry is // hand-authored data with the same optional fields a NIGHTS entry has, // and a raw one crashes quote() on its missing `pay` block. const night = normaliseNight(offer.night); Object.defineProperty(night, '_offerId', { value: offer.id, enumerable: false }); taken.set(index, night); } return taken.get(index); }, /** Tonight's taken offer id, or null if the player is on the spine. */ get takenOffer() { return taken.get(index)?._offerId ?? null; }, /** SPRINT17 — the week's draw seed, for the board and for a.test's pins. */ get seed() { return seed; }, 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; // SPRINT17: a restarted week re-offers the same board (the seed is fixed // for the run), so clearing the takes is what makes "play it again" // actually replayable rather than handing you last run's choices. taken.clear(); return week; }, /** * Settle the night just played and move the ladder on. * * @param {object} run main.js's scoreRun() result * @param {object} def tonight's storm def (for severity) * @param {number} spent what prep actually cost out of the bank * @returns {Settlement} */ settle(run, def, spent) { const bankBefore = bank; // 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); // SPRINT17 gate 2: the SAME calloutFee() the quote printed, constraints // and all — quote==settle by construction, extended to the premium the // same way it was extended to the warranty deduction. const fullFee = calloutFee(def, rep, jobAt(index).constraints); 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 // doesn't pay. That's not a rounding difference, it's a lie on paper. const gardenMax = jobAt(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); // 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 = jobAt(index); const cleanMax = job.pay.clean ?? PAY.noCollateralBonus; const clean = collateral === 0 ? cleanMax : 0; // 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); const held = log.filter((n) => n.won).length + (run.win ? 1 : 0); // Outcome is decided AFTER the money moves: you can win the last night and // still be broke, and the week should say you finished it. // // Reaching night five is NOT winning, and the first draft of this said it // was. Measured: a player who rigs $20 of carabiners every night loses // four gardens out of five, never dips under the broke line because a // cheap rig barely costs anything, and got handed "THE WEEK HELD — // everything's still where you put it" over four dead gardens. Outlasting // the week isn't surviving it. So the WORDS scale with what you actually // held (see grade); the outcome only says whether you're still trading. let outcome; if (week.isFinalNight) outcome = 'win'; else if (bank < BROKE_BELOW) outcome = 'gameover'; 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, // B's pool item: the invoice needs this to explain a small garden bonus // as the night's design rather than the player's failure. Carried on the // 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, // SPRINT17 — the invoice repeats the constraint it was PAID UNDER // (gate 2's requirement, and the reason this rides the settlement // rather than being looked up: the invoice for night 3 must state // night 3's terms even when you're reading it on night 5's morning). // `taken` says the job came off the board — the ledger records that // you CHOSE this, which is the whole of arc 2 in one boolean. constraints: job.constraints ?? [], premium: premiumFor(job.constraints), taken: taken.has(index) ? (taken.get(index)._offerId ?? true) : false, // 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; }, /** Move to the next night. No-op once the week is done. */ advance() { if (!done && index < NIGHTS.length - 1) index++; return week; }, }; return week; }