/** * SHADES — the week. Lane A owns this file. * * Five nights, one yard, 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'; /** * 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 Wild Night gust 30.0, hail — the one the whole repo tuned for * 5 Ice Night gust 28.5, hail — less wind, more ice * 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. Nights 1/2/4/5 are the backyard; * 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. 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 = [ // 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.', }, { storm: 'storm_02_wildnight', site: 'backyard_01', client: 'the Hendersons', addr: '14 Kurrajong St — the backyard', 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', addr: '14 Kurrajong St — the backyard', 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: night 5's garden is BEYOND // SAVING, by design, at any price. 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 4, the pinned separation), 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 night 5 winnable, delete this // flag in the same commit or the verdict lies in the other direction. gardenBeyondSaving: true, }, ]; /** * 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]; const base = typeof n === 'string' ? { storm: n, site: 'backyard_01' } : (n ?? {}); 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, }; } /** * 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). * * 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, }; /** * 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; /** * 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. * * @param {number} held gardens saved, 0..5 */ export function gradeFor(held) { if (held >= 5) 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 * @property {number} bankBefore * @property {number} bankAfter * @property {'continue'|'win'|'gameover'} outcome */ /** * @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; /** @type {Settlement[]} */ const log = []; const week = { /** 1-based, for humans. "Night 3 of 5". */ get night() { return index + 1; }, get nights() { return NIGHTS.length; }, 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, /** * 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, get total() { return this.base + this.garden + this.clean; }, }; }, get bank() { return bank; }, 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; 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; const fullFee = PAY.feeFor(gustPeakOf(def)); 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 = 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); // 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); 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'; 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 }; 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; }