From 52bdcd0a5e685a99cbcf0617be7dceab47598362 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 13:13:44 +1000 Subject: [PATCH] The week: five nights, one wallet, and an ending that tells the truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/world/js/hud.js | 174 +++++++++++++++++++++++---- web/world/js/main.js | 82 +++++++++++-- web/world/js/tests/a.test.js | 69 +++++++++++ web/world/js/week.js | 222 +++++++++++++++++++++++++++++++++++ 4 files changed, 510 insertions(+), 37 deletions(-) create mode 100644 web/world/js/week.js diff --git a/web/world/js/hud.js b/web/world/js/hud.js index 0fd1b67..f711c3b 100644 --- a/web/world/js/hud.js +++ b/web/world/js/hud.js @@ -71,6 +71,40 @@ const CSS = ` letter-spacing:.05em; } #hud-card .verdict.win { background:#12321c; border:1px solid #2c6b3c; color:#7fce6a; } #hud-card .verdict.lose { background:#3a1618; border:1px solid #7d2b2b; color:#ff8f86; } + +/* --- the week ---------------------------------------------------------- */ +#hud-card .pips { letter-spacing:.5em; font-size:15px; margin:-6px 0 10px; color:#3f5561; } +#hud-card .pip.now { color:#7ee0ff; } +#hud-card .pip.held { color:#7fce6a; } +#hud-card .pip.lost { color:#ff8f86; } +#hud-card .ledger { margin-top:14px; padding-top:10px; border-top:1px solid #24343d; } +#hud-card .ledger .row b { color:#a8f0b8; } +#hud-card .ledger .row.bad b { color:#ff8f86; } +#hud-card .ledger .row.total { margin-top:6px; padding-top:8px; border-top:1px solid #24343d; } +#hud-card .ledger .row.total b { color:#fff; } + +/* E's diptych. The art is the card; the words sit in the left third they left + clear for exactly this. */ +#hud-card .endcard { background-size:cover; background-position:center right; + max-width:none; width:min(92vw,900px); min-height:min(70vh,520px); + display:flex; align-items:center; padding:0; border:1px solid #24343d; } +#hud-card .endcard .endtext { width:min(46%,380px); padding:28px 26px; + background:linear-gradient(to right, rgba(9,14,18,.92) 65%, rgba(9,14,18,0)); } +#hud-card .endcard h1 { font-size:26px; } +#hud-card .endcard .kicker { margin:16px 0 0; color:#8ba0ad; font-style:italic; line-height:1.5; } + +/* --- dawn (Lane E, pasted from their THREADS note) ---------------------- */ +/* The screen blend is the point: it lifts the black storm scene into morning + without touching the card on top. The 2.2s ease matters more than the + colours. (No backticks in here — this whole block is a JS template literal.) */ +#dawn { position:fixed; inset:0; pointer-events:none; opacity:0; z-index:5; + transition:opacity 2.2s ease; mix-blend-mode:screen; + background:linear-gradient(to top, + rgba(255,178,102,.30) 0%, /* the sun that finally turned up */ + rgba(255,150,96,.16) 22%, + rgba(120,132,168,.10) 55%, + rgba(38,48,84,.16) 100%); } /* night still up there */ +#dawn.on { opacity:1; } `; /** @@ -115,6 +149,14 @@ export function createHud(d) { card.id = 'hud-card'; document.body.appendChild(card); + // The morning. Sits UNDER the card and over the frozen storm scene — see the + // `screen` blend in the CSS, which lifts the black night into dawn without + // touching the card on top of it. Lane E shipped this as ten lines rather than + // a ninth texture nobody loads, and they were right. + const dawn = document.createElement('div'); + dawn.id = 'dawn'; + document.body.appendChild(dawn); + const $ = (id) => root.querySelector(id); const elWind = $('#hud-wind'), elClock = $('#hud-clock'), elGust = $('#hud-gust'); const elPct = $('#hud-garden-pct'), elBar = $('#hud-bar i'), elShade = $('#hud-shade'); @@ -312,35 +354,100 @@ export function createHud(d) { * @param {{key:string, def:object}[]} storms * @param {(key:string) => void} onPick */ - showForecast(storms, onPick) { - const rows = storms.map(({ key, def }) => { - const peak = Math.max(...def.baseCurve.map((p) => p[1])); - const gustPeak = peak + (def.gusts?.powBase ?? 0) + (def.gusts?.powRamp ?? 0); - const rainPeak = Math.max(...(def.rain?.curve ?? [[0, 0]]).map((p) => p[1])); - const change = (def.events ?? []).find((e) => e.type === 'windchange'); - const night = (def.sky?.darkness ?? 0) > 0.6; - return ``; + showForecast({ key, def }, wk, onGo) { + const peak = Math.max(...def.baseCurve.map((p) => p[1])); + const gustPeak = peak + (def.gusts?.powBase ?? 0) + (def.gusts?.powRamp ?? 0); + const rainPeak = Math.max(...(def.rain?.curve ?? [[0, 0]]).map((p) => p[1])); + const change = (def.events ?? []).find((e) => e.type === 'windchange'); + const night = (def.sky?.darkness ?? 0) > 0.6; + const hail = !!def.hail; + + // The ladder, as pips. You can see what you've survived and how far is + // left — which is most of what makes night four frightening. + const pips = Array.from({ length: wk.nights }, (_, i) => { + const done = i < wk.night - 1; + const now = i === wk.night - 1; + const held = done && wk.log[i]?.won; + return `${ + now ? '◆' : done ? (held ? '●' : '○') : '·'}`; }).join(''); card.innerHTML = `
-

FORECAST

-

90 seconds of storm. Pick your night, then rig for it.

- ${rows} -
+

NIGHT ${wk.night} OF ${wk.nights}

+
${pips}
+

${(def.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}

+
sustained to ${peak.toFixed(0)} m/s (${kmh(peak).toFixed(0)} km/h) + · gusts to ~${kmh(gustPeak).toFixed(0)} km/h
+
rain ${rainPeak >= 0.8 ? 'heavy' : rainPeak >= 0.4 ? 'steady' : 'light'}${ + hail ? ' · hail forecast' : ''} + ${change ? `· change at ${change.t}s` : '· no change forecast'}
+
in the bank$${wk.bank}
+
+ ${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 = `
+
+

${head}

+

${sub}

+
nights survived${nights}/5
+
gardens held${s.held ?? 0}/5
+
in the bank$${s.bankAfter}
+
${kick}
+
`; card.classList.add('on'); hud.setVisible(false); - for (const b of card.querySelectorAll('.storm')) { - b.addEventListener('click', () => { hud.hideCard(); onPick(b.dataset.key); }); - } + card.querySelector('.go').addEventListener('click', () => { hud.hideCard(); onRestart(); }); }, /** @@ -348,20 +455,39 @@ export function createHud(d) { * @param {() => void} onAgain */ showAftermath(r, onAgain) { + const w = r.week; const rows = [ ['garden', `${r.hp.toFixed(0)}%`], ['corners intact', `${r.cornersTotal - r.cornersLost}/${r.cornersTotal}`], + ['what got through', r.hailBlocked], ['hardware lost', r.bill ? `$${r.bill}` : 'none'], ['collateral', r.collateral.length ? r.collateral.map((c) => `${c.what} ($${c.cost})`).join(', ') : 'none'], - ['budget left', `$${r.budgetLeft}`], ].map(([k, v]) => `
${k}${v}
`).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 ? ` +
+
${w.won ? 'fee' : `fee (night lost — ${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}+$${w.fee}
+
garden bonus+$${w.bonus}
+
gear recovered+$${w.refund}
+ ${w.collateral ? `
collateral−$${w.collateral}
` : ''} +
spent on the rig−$${w.spent}
+
in the bank$${w.bankBefore} → $${w.bankAfter}
+
` : ''; + + const next = !w ? 'PLAY AGAIN' + : w.outcome === 'continue' ? `NIGHT ${w.night + 1} →` + : w.outcome === 'win' ? 'SEE THE WEEK' + : 'SEE THE DAMAGE'; + card.innerHTML = `
-

AFTERMATH

+

MORNING${w ? ` · NIGHT ${w.night} OF 5` : ''}

${r.subtitle}

${rows} + ${money}
${r.verdict}
- +
`; 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; +}