HardYards/web/world/js/week.js
m3ultra 52bdcd0a5e The week: five nights, one wallet, and an ending that tells the truth
SPRINT8 gate 1, carried since Sprint 6. week.js is a thin wrapper over the phase
machine, not a new system: it answers three questions between rounds — which
storm is tonight, how much money is there, and is it over. Zero THREE, pure data,
so the ladder and the money are testable at no cost.

The forecast card stops being a difficulty picker and becomes NIGHT n OF 5 with
the ladder as pips, tonight's storm, and the bank. Aftermath itemises the
settlement (fee / bonus / gear recovered / collateral / spent → bank), and E's
dawn tint comes up on their 2.2s ease BEFORE the scoreboard — their note is right
that you sell the survival first. E's diptych and copy are wired verbatim.

Two economy decisions, both made because measuring the week exposed them:

A lost night pays a FRACTION of the fee. Zero is unrecoverable and reads as
punishment; full pay means failure only costs the broken shackle and the bank
only ever climbs. Gear comes back at HALF — a full refund makes hardware free, so
the only sink is breakage and "broke = game over" becomes unreachable. Half is
also honest: a shackle that rode out a gale has been fatigued, which DESIGN.md
already says.

And the one that matters: REACHING night five is not SURVIVING it. My first draft
said it was, and playing the week found it — a $20-carabiner player loses four
gardens, 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. The end card is the last thing this game says; it doesn't
get to lie the way the aftermath verdict used to. Words now scale with gardens
held: clean (5), scraped (3-4, E's own alternate copy), solvent (0-2, mine —
"STILL TRADING · The books balanced. The garden didn't." E never wrote words for
that ending because nobody knew the game could produce it).

Two economy questions I could NOT settle from a harness and have documented
rather than quietly tuned: a competent week runs the bank 80 → 350, so night five
stops being a decision; and a floor-scraping week may never actually go broke.
My bad-player model handed back a fixed $30 of gear whatever it spent, which
flattered the bottom of the curve — the same class of mistake this repo keeps
catching me in. Both want a human playing five nights.

Selftest 270 pass; the single red is gate 0's balance line, which is B+D's this
sprint and was already red on main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:13:44 +10:00

223 lines
9.6 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.
*/
export const NIGHTS = [
'storm_01_gentle',
'storm_03_southerly',
'storm_03b_earlybuster',
'storm_02_wildnight',
'storm_02b_icenight',
];
/**
* 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,
};
/**
* 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 NIGHTS[index]; },
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);
const bonus = Math.round((Math.max(0, run.hp) / 100) * PAY.gardenBonusMax);
// 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);
const pay = fee + bonus + refund - 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, collateral, pay, spent,
bankBefore, bankAfter: bank, outcome, night: week.night, held,
grade: gradeFor(held), stormKey: week.stormKey, won: !!run.win };
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;
}