HardYards/web/world/js/week.js
type-two dd69a2cf6c Lane A S17 gates 0+1+2: THE JOB BOARD — two offers a morning, the chosen job IS the night
Gate 0 rulings (written, with reasoning, in THREADS):
- 0.4 hail-light balance: CANON. B's finding reproduces exactly ($4/$2 garden
  spread), but the mild-night rig market buys the STEEL and the collateral, not
  the bed — a cheap rig that fails on site_02 swings $184 and two stars against
  the same rig holding, and the cheapest HOLDING line is $40, not the $20 floor.
  Ruled for B's option (b): the separation block asks the wrong question on
  those nights; the new shape is B's and is NOT this sprint. Rain drain-weight
  lever declined at its proper price (full gauntlet re-measure, own gate).
- 0.4b gradeFor scraped/solvent >=3 STANDS on the seven-night shape, ruled on
  the arithmetic (savable 6 → solvent is 4-6 dead, which is what the copy says).
- 0.3 score-header wording consulted for B (no code from me).

Gate 1 — the board: board.js (pure data, node-testable), week.offers()/take(),
seeded from week+night via contracts' mulberry32 — no Date.now, no Math.random.
An offer carries a whole NIGHT ENTRY and take() installs it, so sheet, quote,
invoice, ledger, warranty, rep and end card all read a chosen job through the
path they already had — seven surfaces, zero changed lines. Scripted spine runs
byte-identically when nothing is taken.

Gate 2 (data half) — constraints as night data, checked enum, premium ruled
(+25% anchor ban / +20% budget cap, both UNMEASURED and flagged cut-first),
stated on the offer, the job sheet and the invoice it was paid under. ONE
calloutFee() now serves quote() and settle(). Enforcement is B's.

Two bugs found off the suite: take() stored raw pool entries (crashed quote()
on a missing pay block) → normaliseNight() split so both doors share it; and
NIGHT 1 HAD NO BOARD — the splash bypassed it — found by looking, not testing.
Pool pairings audited in the browser before shipping; both constraints checked
for soft-lock against real holding lines.

Mutation-checked ×6. One did NOT go red: "every pool storm is preloaded" passed
with the POOL source deleted, because every pool storm is also in NIGHTS — the
same coincidence that hid the calm-day bug. stormsToPreload(nights, pool) split
so it can fail. Selftest 485/0/0 (474 + 11).
2026-07-21 02:11:16 +10:00

841 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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