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; +}