Ladder: gentle, southerly, earlybuster×site_02, southerly×swing_lawn, WILDNIGHT×backyard, icenight×backyard (beyondSaving rides the night), soaker×site_02. Icenight brief reverts to 'less wind than last night' (true again); new Hendersons brief sells the pyrrhic stake. Law pins retitled + one new ruling pin (wildnight in, known yard, before the icenight); a.test re-pinned to the seven-shape; gradeFor clean bar derived (held >= savable) per the S16 same-commit demand. Selftest 474/0/0; verified live on :8827. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
668 lines
36 KiB
JavaScript
668 lines
36 KiB
JavaScript
/**
|
||
* 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';
|
||
|
||
/**
|
||
* 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 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 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;
|
||
|
||
/**
|
||
* 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
|
||
/** @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;
|
||
}
|