HardYards/web/world/js/week.js
type-two 19c39acc8d Lane B S14: collateral exposure on the card + the night-5 invoice line (pool item)
COLLATERAL ON THE CARD. 'Winnable at $80' is an incomplete sentence on a yard
where letting a corner go also bills $180 of carport, so the card now prices
it — through world.collateralFor(), the one resolver (A's structKey fix), never
a second copy of that lookup in a panel. Deduped per STRUCTURE the way main.js
bills ('two beams letting go is one carport gone'): site_02's four carport
anchors read $180 total, not $720. The bill also rides on the margin-flag line
itself, because 'knife-edge' and 'knife-edge AND $180' are different authoring
decisions. An unpriced label renders UNPRICED — not scored, in red, never
'free' — which is the gutter bug's shape and the lie the card exists to prevent.

Verified A's structKey fix rather than assuming it: an editor-shaped carport
(id s1 + collateralKey) prices to $180, a second one (s2) also $180, and the
negative control (id s1, no collateralKey) reads null = not scored. My first
test reported a false alarm because I renamed a SHIPPED spec's id without the
palette's collateralKey — A's editor writes that field explicitly, so the
editor path was never broken.

THE NIGHT-5 INVOICE LINE (pool item, offered S13, accepted). Night 5's garden
is beyond saving by design, and the job sheet was the last surface still
dangling 'up to $45 / paid on what's left of the bed at dawn' — a maximum
nobody can earn, phrased as a rigging problem the player could solve.

NO ECONOMY CHANGE, deliberately. week.js's own comment forbids quote and settle
diverging ('the job sheet promises a number the invoice doesn't pay'), and
zeroing the quote would break that in the opposite direction — settle really
does pay the bonus proportionally. So the NUMBER stays and its CONDITION tells
the truth, which is the job sheet's own idiom:
  nights 1-4  'paid on what's left of the bed at dawn'        (unchanged)
  night 5     'scales with the bed at dawn — and tonight the ice takes it
               whatever you rig'
and the ledger names it too, because an unexplained small number next to
'night lost' reads as the player's failure:
  night 4  'garden bonus — 18% of the bed          +$8'
  night 5  'garden bonus — 18% of the bed, beyond saving from the start  +$8'

Verified in the running game on all five nights: flag fires on 5 only, every
quoted and paid figure identical (garden $45, total $136, bonus $8).

Selftest 374 passed / 0 failed / 0 skipped.
2026-07-18 16:29:41 +10:00

409 lines
20 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.
*
* Five nights, one yard, one wallet. This is deliberately a thin wrapper around
* the phase machine rather than a new system: main.js already knows how to run
* forecast → prep → storm → aftermath, and the week's whole job is to answer
* three questions between rounds — which storm is tonight, how much money do you
* have, and is it over.
*
* Zero imports beyond contracts, and no THREE: the money and the ladder of
* nights are pure data, so they're testable at fixed cost with no browser. The
* only thing that touches the screen is main.js reading these numbers.
*/
import { START_BUDGET, HARDWARE } from './contracts.js';
/**
* The ladder. Escalating, and each rung introduces one new idea rather than just
* more wind — that's Lane C's authoring, not my ordering:
* 1 Sea Breeze gust 10.5, no hail — the tutorial: nothing can hurt you
* 2 Southerly Buster gust 20.0, hail — the change, and hail, arrive
* 3 Early Buster gust 20.0, hail — same teeth, change comes EARLY
* 4 Wild Night gust 30.0, hail — the one the whole repo tuned for
* 5 Ice Night gust 28.5, hail — less wind, more ice
* Night 3 is the trap: it looks like night 2 on the forecast card and isn't.
*
* SPRINT10: a night is now a STORM and a SITE. Nights 1/2/4/5 are the backyard;
* night 3 moves to the corner block — the same "looks like night 2 and isn't"
* beat, sharpened: it's a new yard you've never rigged, its carport is a trap,
* and its storm carries the southerly change that makes the venturi scream. The
* plain-string form is still accepted (defaults to backyard_01) so nothing that
* reads NIGHTS[i] as a storm key breaks.
*
* SPRINT11: a night is now a JOB. Same ladder, same yards — what's new is the
* frame DESIGN.md has had from line one: *"work arrives as callouts — each one a
* site with existing conditions, a client brief, a budget, and a forecast
* window."* You are not surviving five nights; you are working five jobs.
*
* The week that fell out of the data, once it was asked to name a client: the
* Hendersons are away (SPRINT11's own line — they leave for Cairns), so four of
* the five nights are the same yard, on retainer, through a bad week. Night 3 is
* a different site, so it is necessarily a different client — a short-notice
* callout to a yard you have never rigged, on the night the change comes early.
* That is not flavour bolted on: it is what `site: site_02_corner_block` already
* MEANT, finally said out loud. The corner block lands harder because it's a
* stranger's place and you're there at short notice.
*
* Copy lives here as data, per SPRINT11's rule: client flavour is data, not code.
*/
export const NIGHTS = [
// SPRINT12: `addr` is the second line of E's bill-to block — a letterhead
// bills somebody somewhere. Street names are the site's fiction (E's kit
// drafted both), and the addr carries the yard so the invoice, which has no
// site line of its own, still says where the night happened.
{
storm: 'storm_01_gentle', site: 'backyard_01',
client: 'the Hendersons', addr: '14 Kurrajong St — the backyard',
brief: 'They fly out Friday and the seedlings are the whole point — six weeks in, first season that '
+ 'took. Get the sail up while it\'s calm. Nothing tonight will test it.',
},
{
storm: 'storm_03_southerly', site: 'backyard_01',
client: 'the Hendersons', addr: '14 Kurrajong St — the backyard',
brief: 'A southerly, and hail with it. This is the one the seedlings can\'t take — cloth over the bed '
+ 'or there\'s no bed on Monday.',
},
{
storm: 'storm_03b_earlybuster', site: 'site_02_corner_block',
client: 'the Vasilaros place', addr: '2 Bight Rd — the corner block',
brief: 'Short notice — corner block two streets over, never rigged it. Same southerly as Tuesday, '
+ 'except the change is through before you\'ve finished your coffee. They said there\'s plenty '
+ 'to tie off to.',
},
{
storm: 'storm_02_wildnight', site: 'backyard_01',
client: 'the Hendersons', addr: '14 Kurrajong St — the backyard',
brief: 'Back at the Hendersons\', and this is the night the whole week was pointing at. Rig it like '
+ 'they\'re watching, because Friday they will be.',
},
{
storm: 'storm_02b_icenight', site: 'backyard_01',
client: 'the Hendersons', addr: '14 Kurrajong St — the backyard',
brief: 'Less wind than last night and more ice, which is worse for the bed and easier on the rig. '
+ 'Last night before they land. Nobody\'s pretending the seedlings ride this one out — keep '
+ 'the sail on its feet and the ice off the windows.',
// SPRINT13 gate 1.4 — A's ruling, DESIGN CANON: night 5's garden is BEYOND
// SAVING, by design, at any price. Measured game-true (B's pay table and
// A's probe, agreeing): bare bed 0.0 DEAD; best buyable garden 27.7; best
// clean 15.3 — every line in the game sits under WIN's hp >= 50, wallet or
// bank. The week's arc WANTS this: full / full / full, won-but-tattered
// (night 4, the pinned separation), then the night you cannot save — the
// icenight is where the job stops being about the bed and becomes about
// your steel (the line that would hold it needs $105 the shop caps at
// 3.20 kN shackles against 3.22/3.25 peaks — C's few-hundredths whisker,
// kept on purpose). This flag exists so surfaces TEACH the loss instead of
// implying a better rig existed: verdictFor reads it (main.js) and stops
// blaming placement; the brief above levels with the player before they
// spend a dollar. If a retune ever makes night 5 winnable, delete this
// flag in the same commit or the verdict lies in the other direction.
gardenBeyondSaving: true,
},
];
/**
* A night entry, every shape → a full JOB.
*
* The plain-string form still resolves (SPRINT10's promise), and now so does a
* night with no client: an unbriefed job is a job with no letterhead, not a
* crash. `pay` is per-job so the schedule is data — see PAY for the defaults and
* for why the clean bonus is the one number here that is NOT yet measured.
*
* @param {number} i
* @returns {{storm:string, site:string, client:string|null, brief:string|null, pay:object}}
*/
export function nightAt(i) {
const n = NIGHTS[i];
const base = typeof n === 'string' ? { storm: n, site: 'backyard_01' } : (n ?? {});
return {
storm: base.storm,
site: base.site ?? 'backyard_01',
client: base.client ?? null,
addr: base.addr ?? null,
brief: base.brief ?? null,
pay: base.pay ?? {},
// False by default on purpose: a night is presumed savable unless its data
// says otherwise, so a new night can never inherit the icenight's excuse.
gardenBeyondSaving: base.gardenBeyondSaving ?? false,
};
}
/**
* The economy, in one place because it is the thing most likely to need tuning
* once somebody actually plays five nights in a row.
*
* Shape (SPRINT8 §gate 1): pay = fee by severity + garden bonus + intact
* hardware refund collateral. Two of those needed a judgement call and both
* are the difference between a week with stakes and a week you cannot lose:
*
* - **A lost night pays a fraction of the fee, not zero and not all of it.**
* Zero is unrecoverable and reads as punishment; full pay means failure costs
* only the broken shackle, and the bank only ever climbs. DESIGN.md's warranty
* callout is the source: you turn up, you're just not getting paid for it.
* - **Hardware comes back at half.** Full refund makes gear effectively free —
* the only sink left is breakage, the bank grows every night, and "broke =
* game over" becomes unreachable. Half is also the honest number: a shackle
* that rode out a gale has been fatigued, its pin worked loose, and DESIGN.md
* already says flogging unscrews unmoused pins. You can re-use it; you can't
* call it new.
*/
export const PAY = {
/**
* Client's fee for the job, scaled off the storm's worst gust.
*
* The constants are set by the shape of the week, not by taste — measured by
* playing all five nights at fixed skill. A fee has to be worth roughly
* two-thirds of a good rig ($75): any smaller and one bad night is instant
* game over (at `8 + gust` a disaster night paid $13 against a $75 rig and
* ended the week on night ONE, which is a punishment, not a difficulty); any
* larger and the bank runs away and night five is trivially affordable.
* At `25 + 1.6·gust` a disaster leaves you at ~$26 — alive, but rigging the
* next night on carabiners, which is the death spiral doing its job.
*/
feeFor: (gustPeak) => Math.round(25 + gustPeak * 1.6),
/** A lost night still pays this share — you turned up, it just went wrong. */
lostNightShare: 0.35,
/** Garden bonus at hp=100. Scales linearly; this is the main earner. */
gardenBonusMax: 45,
/** What surviving hardware is worth back. Fatigued, not new. */
refundShare: 0.5,
/**
* Nothing of theirs broken (SPRINT11 gate 2 — the third line on the invoice).
*
* It exists to give the schedule three axes that mean three different things,
* which is what turns a scoreboard into an invoice:
* base — you turned up and did the job (severity-scaled; less if it went wrong)
* garden — how much of what they actually cared about survived
* clean — you left their place the way you found it
* Deliberately NOT gated on winning: it's about their property, not your
* success. Lose the garden but break nothing and you're still a tradesperson
* who didn't wreck the place — that's a different fact and it gets its own row.
*
* It also makes the carport bite twice, which is the point: take it and you
* lose 180 AND this. Sized so that total sting is 200, still inside E's
* "above ~250 and one mistake is a silent game over" ceiling.
*
* ⚠️ **THE ONE NUMBER IN THIS FILE THAT IS NOT MEASURED — for gate 3 to settle.**
* Every other constant here was set by playing the week. This one couldn't be:
* it's new money, and MEASURED (node, the model in BROKE_BELOW's note) it makes
* a competent week bank 385 → 485, i.e. it adds ~$100 to a five-night run that
* §BROKE_BELOW already flags as running away with itself. I did not fund it by
* quietly trimming `feeFor` or `gardenBonusMax` — those two carry measured
* evidence and long reasons, and re-tuning them on my taste to pay for my own
* new feature is exactly the move this repo keeps catching.
* So: if gate 3 says the bank runs away, THIS is the lever to cut first. It's
* the newest and least load-bearing number in the economy, it's per-job data,
* and cutting it costs nothing but a row on the invoice.
*/
noCollateralBonus: 20,
};
/**
* Below this you cannot rig four corners at all — four carabiners is the floor.
*
* ⚠️ MEASURED, OPEN, AND FOR A PLAYTEST TO SETTLE — not a guess, and written
* here because the numbers are only visible once you run five nights:
*
* 1. **A competent week runs away with the money.** Holding 4/4 at hp 70 every
* night takes the bank 80 → 116 → 167 → 218 → 285 → 350. By night three you
* can already afford four rated shackles ($120) and the wild night stops
* being a decision. The fee is the lever; it is deliberately NOT tuned down
* yet, because the fix might equally be that later nights should cost more
* to rig (a bigger sail, a second bed) — that's Sprint 9's content question,
* not a constant I should quietly pick tonight.
* 2. **A floor-scraping week may never actually go broke.** At $20 a cheap rig
* earns back roughly what it costs, so the bank can sit just above this line
* indefinitely, losing every garden. The grade fixes what the END CARD says
* about that (see gradeFor) but not whether 'gameover' is reachable at all
* from a slow bleed rather than one disaster.
*
* I could not settle either from the harness: my own bad-player model handed
* back a fixed $30 of intact hardware no matter what it spent, which flattered
* the bottom of the curve and is exactly the class of mistake this repo has
* caught me making all project. Both need a human playing five nights.
*/
export const BROKE_BELOW = HARDWARE[0].cost * 4;
/**
* How the week is remembered, by gardens actually held.
*
* SPRINT8 gave Lane A the win wording, and this is the ruling: the end card is
* the last thing SHADES says to a player, so it does not get to lie the way the
* aftermath verdict used to. Three grades, and E's copy carries two of them —
* their primary for a clean week, their own alternate for a scraped one. The
* third is mine, because E never wrote words for "you stayed solvent while the
* client's garden died four times", and until this was measured nobody knew the
* game could even produce that ending.
*
* @param {number} held gardens saved, 0..5
*/
export function gradeFor(held) {
if (held >= 5) return 'clean';
if (held >= 3) return 'scraped';
return 'solvent';
}
/** Worst gust a storm def can throw — the severity signal the fee reads. */
export function gustPeakOf(def) {
const base = Math.max(...def.baseCurve.map((p) => p[1]));
const g = def.gusts ?? {};
return base + (g.powBase ?? 0) + (g.powRamp ?? 0);
}
/**
* @typedef {object} Settlement
* @property {number} fee client's payment for the job
* @property {number} bonus garden bonus
* @property {number} refund intact hardware recovered, at half
* @property {number} collateral what your failure broke of theirs
* @property {number} pay fee + bonus + refund collateral
* @property {number} bankBefore
* @property {number} bankAfter
* @property {'continue'|'win'|'gameover'} outcome
*/
/**
* @param {object} [opts]
* @param {number} [opts.bank=START_BUDGET]
*/
export function createWeek(opts = {}) {
const startBank = opts.bank ?? START_BUDGET;
let index = 0; // 0-based into NIGHTS
let bank = startBank;
let done = false;
/** @type {Settlement[]} */
const log = [];
const week = {
/** 1-based, for humans. "Night 3 of 5". */
get night() { return index + 1; },
get nights() { return NIGHTS.length; },
get stormKey() { return nightAt(index).storm; },
/** SPRINT10: which yard tonight is on. main.js loads it. */
get site() { return nightAt(index).site; },
/** SPRINT11: tonight's job — client, brief, pay schedule. The job sheet reads this. */
get job() { return nightAt(index); },
/**
* What tonight pays, BEFORE you rig it — the "budget Y" half of DESIGN.md's
* brief, and the reason the job sheet is worth building. Until now the fee
* existed only in the aftermath: you found out what the job was worth after
* you'd decided what to spend on it, which is not a decision, it's a reveal.
*
* Quotes the maxima honestly: base at full (what a held night pays), garden
* at hp 100, clean at not-a-scratch. What you actually bank is the invoice's
* business — a quote is what's on offer, not a promise.
*
* @param {object} def tonight's storm def
*/
quote(def) {
const j = nightAt(index);
return {
base: PAY.feeFor(gustPeakOf(def)),
garden: j.pay.garden ?? PAY.gardenBonusMax,
clean: j.pay.clean ?? PAY.noCollateralBonus,
/**
* SPRINT14 (B's pool item, offered S13 and accepted): the job sheet has
* to say when the garden bonus is money the night cannot pay.
*
* Deliberately a FLAG and not a zero. The comment in settle() below is
* the rule — quote and settle must read the same source or the sheet
* promises a number the invoice doesn't pay — and zeroing this would
* break it in the opposite direction: settle still pays the bonus
* proportionally (hp is low on a beyond-saving night, not zero), so a
* quoted $0 would be its own lie on paper. The NUMBER is honest. What
* was missing is its CONDITION, which on this night is "and the ice
* takes the bed whatever you rig". The job sheet's own idiom is that
* every amount carries its condition underneath; this is a night where
* the condition is the entire story and the schedule was the last place
* still not telling it.
*/
beyondSaving: j.gardenBeyondSaving ?? false,
get total() { return this.base + this.garden + this.clean; },
};
},
get bank() { return bank; },
get isFinalNight() { return index === NIGHTS.length - 1; },
get over() { return done; },
get log() { return log; },
reset() {
index = 0; bank = startBank; done = false; log.length = 0;
return week;
},
/**
* Settle the night just played and move the ladder on.
*
* @param {object} run main.js's scoreRun() result
* @param {object} def tonight's storm def (for severity)
* @param {number} spent what prep actually cost out of the bank
* @returns {Settlement}
*/
settle(run, def, spent) {
const bankBefore = bank;
const fullFee = PAY.feeFor(gustPeakOf(def));
const fee = run.win ? fullFee : Math.round(fullFee * PAY.lostNightShare);
// Per-job override, PAY's default otherwise — and settle MUST read the same
// source the quote does, or the job sheet promises a number the invoice
// doesn't pay. That's not a rounding difference, it's a lie on paper.
const gardenMax = nightAt(index).pay.garden ?? PAY.gardenBonusMax;
const bonus = Math.round((Math.max(0, run.hp) / 100) * gardenMax);
// Only hardware still on an unbroken corner comes home, and at half.
const intact = (run.intactHardwareValue ?? 0);
const refund = Math.round(intact * PAY.refundShare);
const collateral = (run.collateral ?? []).reduce((s, c) => s + c.cost, 0);
// The clean bonus. Per-job data first, PAY's default otherwise — so a job
// that wants to say "there's a lot here to break" can, without code.
const job = nightAt(index);
const cleanMax = job.pay.clean ?? PAY.noCollateralBonus;
const clean = collateral === 0 ? cleanMax : 0;
const pay = fee + bonus + refund + clean - collateral;
bank = Math.max(0, bankBefore - spent + pay);
const held = log.filter((n) => n.won).length + (run.win ? 1 : 0);
// Outcome is decided AFTER the money moves: you can win the last night and
// still be broke, and the week should say you finished it.
//
// Reaching night five is NOT winning, and the first draft of this said it
// was. Measured: a player who rigs $20 of carabiners every night loses
// four gardens out of five, never dips under the broke line because a
// cheap rig barely costs anything, and got handed "THE WEEK HELD —
// everything's still where you put it" over four dead gardens. Outlasting
// the week isn't surviving it. So the WORDS scale with what you actually
// held (see grade); the outcome only says whether you're still trading.
let outcome;
if (week.isFinalNight) outcome = 'win';
else if (bank < BROKE_BELOW) outcome = 'gameover';
else outcome = 'continue';
done = outcome !== 'continue';
const s = { fee, fullFee, bonus, refund, clean, cleanMax, collateral, pay, spent,
bankBefore, bankAfter: bank, outcome, night: week.night, held,
grade: gradeFor(held), stormKey: week.stormKey, won: !!run.win,
// B's pool item: the invoice needs this to explain a small garden bonus
// as the night's design rather than the player's failure. Carried on the
// settlement so the HUD never has to reach back into week state.
gardenBeyondSaving: job.gardenBeyondSaving ?? false,
gardenMax,
client: job.client, addr: job.addr, site: job.site };
log.push(s);
return s;
},
/** Move to the next night. No-op once the week is done. */
advance() {
if (!done && index < NIGHTS.length - 1) index++;
return week;
},
};
return week;
}