/**
 * 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).
   *
   * 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 night 5's designed loss cannot cost rep by construction rather
 * than by exemption. a.test pins that from both directions.
 *
 * Money: `multiplier(rep)` scales the callout fee at QUOTE TIME — quote() and
 * settle() both read rep-as-of-morning, and rep only updates after settle has
 * computed the fee, so the sheet and the invoice agree by ordering, not by
 * discipline. 0.04/star is chosen over the rounder-sounding 0.05 because every
 * half-step lands exactly on two decimals (×0.88 .. ×1.08) — the sheet prints
 * the multiplier, and a printed number that isn't the number used is the
 * quote-vs-settle lie wearing smaller type.
 *
 * ⚠️ UNMEASURED, same status as noCollateralBonus above: the swing is bounded
 * (±12/8% of fee ≈ $6..9 a night) so it cannot mint a runaway week by itself,
 * but nobody has played six nights under it yet. If gate 6's play says the
 * economy tilts, CLEAN and the multiplier slope are levers, cut second after
 * the clean bonus.
 */
export const REP = {
  START: 3.0,
  MIN: 0,
  MAX: 5,
  /** Clean night: no breaks (repaired or not), nothing of theirs broken. */
  CLEAN: 0.5,
  /** Per corner of yours still broken at dawn — the warranty ledger's rep half. */
  WARRANTY: -0.5,
  /** Per collateral event. Down harder: their property beats your hardware. */
  COLLATERAL: -1.0,
  /** Callout-fee multiplier at rep r. Exact at 2dp for every half-step. */
  multiplier: (r) => 1 + 0.04 * (r - 3),
};

/**
 * Below this you cannot rig four corners at all — four carabiners is the floor.
 *
 * ⚠️ 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 − 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
  /** @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 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);
      // SPRINT16: the fee is priced on your standing, and the deduction for
      // last dawn's broken corners is on the quote — AT QUOTE TIME, so settle
      // pays exactly what the sheet said. Both read the same sources settle
      // does (rep before tonight moves it; warrantyItems() off the log), which
      // is the invariant doing the work, not a matched pair of formulas.
      const feeMult = REP.multiplier(rep);
      const warranty = warrantyItems();
      const warrantyTotal = warranty.reduce((s, i) => s + i.cost, 0);
      return {
        base: Math.round(PAY.feeFor(gustPeakOf(def)) * feeMult),
        garden: j.pay.garden ?? PAY.gardenBonusMax,
        clean: j.pay.clean ?? PAY.noCollateralBonus,
        feeMult,
        rep,
        warranty,
        warrantyTotal,
        /**
         * SPRINT14 (B's pool item, offered S13 and accepted): the job sheet has
         * to say when the garden bonus is money the night cannot pay.
         *
         * 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; },
      };
    },
    get bank() { return bank; },
    /** SPRINT16 — the letterhead's number. Stars, 0..5, half-steps. */
    get rep() { return rep; },
    get isFinalNight() { return index === NIGHTS.length - 1; },
    get over() { return done; },
    get log() { return log; },

    reset() {
      index = 0; bank = startBank; done = false; log.length = 0;
      rep = REP.START;
      return week;
    },

    /**
     * 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);
      const fullFee = Math.round(PAY.feeFor(gustPeakOf(def)) * feeMult);
      const fee = run.win ? fullFee : Math.round(fullFee * PAY.lostNightShare);
      // Per-job override, PAY's default otherwise — and settle MUST read the same
      // source the quote does, or the job sheet promises a number the invoice
      // 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;

      // 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,
        // 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;
}
