+ ${hail
+ ? 'Hail is what kills a garden, and cloth stops hail. Get the sail over the bed.'
+ : 'Nothing tonight can hurt the garden. Learn the anchors.'}
+
+ ${change ? `
A change means the corners that were slack all storm are the loaded ones after it.
+
` : ''}
+
+
`;
+ card.classList.add('on');
+ hud.setVisible(false);
+ card.querySelector('.go').addEventListener('click', () => { hud.hideCard(); onGo(); });
+ },
+
+ /**
+ * E's dawn tint, pasted from their THREADS note. The 2.2 s ease is the point,
+ * not the colours: the storm ends, the light comes up, and THEN you're told
+ * how you did. Sell the survival before the scoreboard.
+ */
+ setDawn(on) { dawn.classList.toggle('on', !!on); },
+
+ /**
+ * The end of the week, either way. E's diptych is on disk and their words are
+ * in THREADS — I took the primaries verbatim; the trade talking, no triumph.
+ *
+ * @param {object} s week.js settlement
+ * @param {() => void} onRestart
+ */
+ showEndCard(s, onRestart) {
+ const won = s.outcome === 'win';
+ const art = won ? 'card_win.jpg' : 'card_gameover.jpg';
+
+ // The words scale with what was actually held — reaching night five is not
+ // the same as saving five gardens, and the end card is the last thing the
+ // game says. E's primary copy for a clean week, E's own alternate for a
+ // scraped one, and a third for the ending nobody knew existed until the
+ // economy was measured: solvent, with the gardens dead behind you.
+ const COPY = {
+ clean: ['THE WEEK HELD', "Five nights. Everything's still where you put it.",
+ "Nobody thanks you for the storm that did nothing. That's the job."],
+ scraped: ['FIVE NIGHTS, FIVE MORNINGS', 'You made the least wrong call five times running.',
+ 'Some of it held. You know which bits didn\'t.'],
+ solvent: ['STILL TRADING', 'The books balanced. The garden didn\'t.',
+ 'You can be paid all week and still have nothing to point at.'],
+ gameover: ['OFF THE JOB', 'Broke, with the week still running.',
+ 'The wind never sent an invoice. Everyone else did.'],
+ };
+ const [head, sub, kick] = COPY[won ? (s.grade ?? 'clean') : 'gameover'];
+ const nights = s.night;
+
+ card.innerHTML = `
`).join('');
+ // The money, itemised. A settlement you can't read is a number you can't
+ // argue with, and arguing with it is how you learn the shop.
+ const money = w ? `
+
`;
card.classList.add('on');
hud.setVisible(false);
diff --git a/web/world/js/main.js b/web/world/js/main.js
index 38c09d8..b18c052 100644
--- a/web/world/js/main.js
+++ b/web/world/js/main.js
@@ -14,7 +14,7 @@
*/
import * as THREE from '../vendor/three.module.js';
-import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, Emitter } from './contracts.js';
+import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, SPARE_COST, Emitter } from './contracts.js';
import { createWorld } from './world.js';
import { createCameraRig } from './camera.js';
import { loadStorm, createWind } from './weather.js';
@@ -25,12 +25,17 @@ import { createDebris } from './debris.js';
import { createSkyFx } from './skyfx.js';
import { createRiggingUI } from './rigging.js';
import { createHud } from './hud.js';
+import { createWeek, NIGHTS } from './week.js';
/** The calm day the forecast and prep phases run under. */
const CALM_STORM = 'storm_01_gentle';
-/** The storms you can pick from the forecast card — this is the difficulty select. */
-const STORMS = ['storm_01_gentle', 'storm_03_southerly', 'storm_02_wildnight'];
+/**
+ * Every storm the session can run. This used to be a three-storm difficulty
+ * select; the week's ladder (week.js NIGHTS) now decides what's coming, and the
+ * forecast card's job changed from "pick your poison" to "here's tonight".
+ */
+const STORMS = NIGHTS;
/**
* How fast an unprotected garden dies, in HP per second at full rain.
@@ -547,6 +552,15 @@ export async function boot(opts = {}) {
hailBlocked: dmg.hail + dmg.rain > 0
? `${Math.round(dmg.hail)} HP to hail, ${Math.round(dmg.rain)} to rain`
: 'Nothing reached the bed.',
+ /**
+ * What you can unclip and take home: hardware still on an unbroken corner,
+ * plus a spare you never had to use. week.js refunds it at half — a shackle
+ * that rode out a gale isn't new any more. Broken gear is worth nothing,
+ * which is the whole of `bill`.
+ */
+ intactHardwareValue:
+ rig.corners.filter((c) => !c.broken).reduce((sum, c) => sum + c.hw.cost, 0)
+ + (rigging.session.spares ?? 0) * SPARE_COST,
pondPeak,
pondDumped,
verdict,
@@ -555,6 +569,40 @@ export async function boot(opts = {}) {
};
}
+ // --- the week ------------------------------------------------------------
+ // Five nights, one wallet. The forecast stops being a difficulty picker — the
+ // ladder decides what's coming and the only question left is what you rig
+ // against it with the money you have.
+ const week = createWeek();
+ let spentThisNight = 0;
+
+ /**
+ * Tonight's card. Reads the bank, not START_BUDGET.
+ *
+ * NOTE the `budget` we hand the shop: `rigging.session.spent` computes
+ * `START_BUDGET - budget` against the module constant rather than the session's
+ * own starting cash, so with a bank of $63 it reports $17 spent before you buy
+ * anything. Lane B's file, flagged in THREADS — main.js therefore tracks
+ * `spentThisNight` itself off the bank rather than trusting `.spent`.
+ */
+ function showTonight() {
+ // Reaching into Lane B's session to re-bank the shop. `RiggingSession` takes
+ // a budget at construction but exposes no setter, and the UI owns the
+ // instance — so this is the one private touch in main.js, done loudly rather
+ // than by forking their file. I've asked for `session.setBudget(n)` in
+ // THREADS; when it lands these two lines become one. (Same route
+ // `session.reset()` took: I faked it, asked, they landed it, I deleted the
+ // fake.)
+ rigging.session._startBudget = week.bank;
+ rigging.session.reset();
+ spentThisNight = 0;
+ hud.showForecast(
+ { key: week.stormKey, def: defs[week.stormKey] },
+ { night: week.night, nights: week.nights, bank: week.bank, log: week.log },
+ () => { stormKey = week.stormKey; game.setPhase('prep'); },
+ );
+ }
+
// --- the night's evidence ------------------------------------------------
// What the sail carried and what the player took on the head, so the verdict
// can point at things that actually happened rather than infer from the score.
@@ -585,10 +633,8 @@ export async function boot(opts = {}) {
garden.reset();
resetRig();
player.sim.carrying = null;
- hud.showForecast(
- STORMS.map((key) => ({ key, def: defs[key] })),
- (key) => { stormKey = key; game.setPhase('prep'); },
- );
+ hud.setDawn(false);
+ showTonight();
}
// The card belongs to the phase, not to the button that happened to open it.
// Tying its lifetime to the forecast button meant any other route into prep
@@ -603,7 +649,17 @@ export async function boot(opts = {}) {
hud.setHelp('WASD move · shift run · E repair/pickup · C brace · RMB orbit');
}
if (to === 'aftermath') {
- hud.showAftermath(scoreRun(), () => game.setPhase('forecast'));
+ // The dawn comes up BEFORE the scoreboard, on E's 2.2 s ease. Their note
+ // is right and it's the whole reason this isn't one call: the storm ends,
+ // the light returns, and only then are you told how you did. Sell the
+ // survival first.
+ hud.setDawn(true);
+ const run = scoreRun();
+ const settlement = week.settle(run, defs[week.stormKey], spentThisNight);
+ hud.showAftermath({ ...run, week: settlement }, () => {
+ if (settlement.outcome === 'continue') { week.advance(); game.setPhase('forecast'); }
+ else hud.showEndCard(settlement, () => { week.reset(); game.setPhase('forecast'); });
+ });
}
if (banner && to !== 'forecast' && to !== 'aftermath') {
@@ -619,17 +675,17 @@ export async function boot(opts = {}) {
// isn't four corners, and says so in the ticker.
if (game.phase === 'prep') {
if (!rigging.commit()) return;
+ // Off the bank, not off START_BUDGET — see the note on showTonight().
+ spentThisNight = week.bank - rigging.summary.budget;
player.sim.carrying = null;
if (rigging.summary.spares > 0) pushEvent(`spare shackle on the shed table — grab it before you need it`);
}
game.advance();
});
- // Straight into the forecast: the card is the game's front door.
- hud.showForecast(
- STORMS.map((key) => ({ key, def: defs[key] })),
- (key) => { stormKey = key; game.setPhase('prep'); },
- );
+ // Straight into the forecast: the card is the game's front door, and it now
+ // opens on night one of five rather than a difficulty menu.
+ showTonight();
// --- resize -------------------------------------------------------------
function resize() {
diff --git a/web/world/js/tests/a.test.js b/web/world/js/tests/a.test.js
index dd281a5..c829888 100644
--- a/web/world/js/tests/a.test.js
+++ b/web/world/js/tests/a.test.js
@@ -10,6 +10,7 @@ import { createCameraRig } from '../camera.js';
import { createGame, createWindRouter, verdictFor } from '../main.js';
import { orderRing } from '../sail.js';
import { loadStorm, createWind } from '../weather.js';
+import { createWeek, NIGHTS, gradeFor, BROKE_BELOW } from '../week.js';
import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js';
/** @param {import('../testkit.js').Suite} t */
@@ -51,6 +52,74 @@ export default async function run(t) {
assertEq(checkContract('game', createGame()).join('; '), '');
});
+ // --- the week (SPRINT8 gate 1) -------------------------------------------
+
+ t.test('the week is five escalating nights and the ladder never reorders', () => {
+ assertEq(NIGHTS.length, 5);
+ assertEq(NIGHTS[0], 'storm_01_gentle', 'night one must be the one that cannot hurt you');
+ assertEq(NIGHTS[4], 'storm_02b_icenight', 'night five is the ice night');
+ const w = createWeek();
+ assertEq(w.night, 1); assertEq(w.bank, 80);
+ assert(!w.isFinalNight);
+ for (let i = 0; i < 4; i++) w.advance();
+ assertEq(w.night, 5);
+ assert(w.isFinalNight, 'night five is the last');
+ w.advance();
+ assertEq(w.night, 5, 'the ladder does not walk off its own end');
+ });
+
+ t.test('money persists across nights, and the bank is next night\'s shop', () => {
+ const w = createWeek();
+ const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
+ const s = w.settle(
+ { hp: 80, win: true, collateral: [], intactHardwareValue: 60 }, def, 70,
+ );
+ assertEq(s.bankBefore, 80);
+ assertEq(s.bankAfter, 80 - 70 + s.pay, 'bank = bank − spent + pay, nothing else');
+ assertEq(s.pay, s.fee + s.bonus + s.refund - s.collateral, 'the ledger adds up as shown');
+ assertEq(s.refund, 30, 'gear comes home at half — a shackle that rode a gale is not new');
+ w.advance();
+ assertEq(w.bank, s.bankAfter, 'the bank IS the next shop');
+ });
+
+ t.test('a lost night pays a fraction of the fee, not zero and not all of it', () => {
+ const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
+ const won = createWeek().settle({ hp: 80, win: true, collateral: [], intactHardwareValue: 0 }, def, 0);
+ const lost = createWeek().settle({ hp: 80, win: false, collateral: [], intactHardwareValue: 0 }, def, 0);
+ assertLess(lost.fee, won.fee, 'losing must cost you the fee');
+ assert(lost.fee > 0, 'but not all of it — you turned up. Zero is unrecoverable, not hard');
+ });
+
+ t.test('reaching night five is NOT the same as saving five gardens', () => {
+ // The bug this pins shipped in my own first draft and only measurement
+ // caught it: a $20-carabiner player loses four gardens, never dips under the
+ // broke line because a cheap rig barely costs anything, and was handed "THE
+ // WEEK HELD — everything's still where you put it" over four dead gardens.
+ // Outlasting the week is not surviving it, and the end card is the last
+ // thing this game says to anyone.
+ assertEq(gradeFor(5), 'clean', 'five held is the only clean week');
+ assertEq(gradeFor(4), 'scraped');
+ assertEq(gradeFor(3), 'scraped');
+ assertEq(gradeFor(1), 'solvent', 'one garden out of five is not a triumph');
+ assertEq(gradeFor(0), 'solvent');
+ });
+
+ t.test('going broke ends the week, but never on the final night', () => {
+ const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
+ const ruin = { hp: 0, win: false, collateral: [{ what: 'gnome', cost: 25 }], intactHardwareValue: 0 };
+
+ const w = createWeek();
+ const s = w.settle(ruin, def, 80); // spend the lot, get almost nothing back
+ assert(s.bankAfter < BROKE_BELOW, `bank ${s.bankAfter} should be under the $${BROKE_BELOW} floor`);
+ assertEq(s.outcome, 'gameover', 'you cannot rig four corners, so the week is over');
+
+ // On the last night there is no next shop to be unable to afford, so being
+ // broke is just being broke — the week still finished.
+ const w2 = createWeek();
+ for (let i = 0; i < 4; i++) w2.advance();
+ assertEq(w2.settle(ruin, def, 80).outcome, 'win', 'night five always ends the week, not the run');
+ });
+
// --- the wind router -----------------------------------------------------
// Loaded HERE, not inside t.test(). These two were written `async` and
// Suite.test() cannot await — so they were recorded as passes while asserting
diff --git a/web/world/js/week.js b/web/world/js/week.js
new file mode 100644
index 0000000..0e69cf4
--- /dev/null
+++ b/web/world/js/week.js
@@ -0,0 +1,222 @@
+/**
+ * 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;
+}