diff --git a/web/world/js/hud.js b/web/world/js/hud.js index 2f1cd78..c6d173a 100644 --- a/web/world/js/hud.js +++ b/web/world/js/hud.js @@ -19,7 +19,15 @@ import { forecastLines, leadFor } from './weather.js'; // SPRINT16 — the end card compares the final rep against neutral. A constant, // not state: the HUD still only READS. week.js has no DOM and no THREE, so // this import costs nothing it didn't already cost main.js. -import { REP } from './week.js'; +import { NIGHTS, REP } from './week.js'; + +// SPRINT16 gate 4 (Lane D, flagged for A): the week is data — NIGHTS.length — +// and these surfaces used to hardcode "five". A six-night week whose splash, +// morning header and end card still said 5 would be the paper lying, so every +// count below reads the ladder. The word map keeps the splash's prose voice; +// an unmapped length falls back to the digit, which is honest if inelegant. +const NIGHT_COUNT = NIGHTS.length; +const NIGHT_WORD = { 5: 'Five', 6: 'Six', 7: 'Seven' }[NIGHT_COUNT] ?? String(NIGHT_COUNT); const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); const kmh = (ms) => ms * 3.6; @@ -533,7 +541,7 @@ export function createHud(d) {

Rig shade sails, then keep the client's garden alive - through the storm. Five nights, five jobs, one wallet.

+ through the storm. ${NIGHT_WORD} nights, ${NIGHT_WORD.toLowerCase()} jobs, one wallet.

THE CONTROLS
${[ @@ -921,9 +929,10 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) { // 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.", + clean: ['THE WEEK HELD', `${NIGHT_WORD} 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.', + scraped: [`${NIGHT_WORD.toUpperCase()} NIGHTS, ${NIGHT_WORD.toUpperCase()} MORNINGS`, + `You made the least wrong call ${NIGHT_WORD.toLowerCase()} 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.'], @@ -953,8 +962,8 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {

${head}

${sub}

-
nights survived${nights}/5
-
gardens held${s.held ?? 0}/5
+
nights survived${nights}/${NIGHT_COUNT}
+
gardens held${s.held ?? 0}/${NIGHT_COUNT}
in the bank$${s.bankAfter}
${s.rep != null ? `
reputation${repFmt(s.rep)}
` : ''} ${s.warrantiesToDate ? `
warranty jobs booked${s.warrantiesToDate}
` : ''} @@ -1071,7 +1080,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) { card.innerHTML = `
${letterhead} -

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

+

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

${r.subtitle}

${rows} ${money} diff --git a/web/world/js/tests/a.test.js b/web/world/js/tests/a.test.js index c520034..5473f4f 100644 --- a/web/world/js/tests/a.test.js +++ b/web/world/js/tests/a.test.js @@ -215,17 +215,25 @@ export default async function run(t) { // --- the week (SPRINT8 gate 1) ------------------------------------------- - t.test('the week is five escalating nights, each a storm and a site', () => { - assertEq(NIGHTS.length, 5); + t.test('the week is six escalating nights, each a storm and a site', () => { + // SPRINT16 gate 4 (D's wiring): five → six. The swing lawn debuts night 4 + // under Tuesday's southerly; the soaker closes the week over the corner + // block. The deeper pins — one-variable law, survived-storm debut, the + // measured soaker pairing — live in d.test.js next to the wiring's owner. + assertEq(NIGHTS.length, 6); // SPRINT10: a night is {storm, site} now. nightAt() normalises either shape. assertEq(nightAt(0).storm, 'storm_01_gentle', 'night one must be the one that cannot hurt you'); - assertEq(nightAt(4).storm, 'storm_02b_icenight', 'night five is the ice night'); + assertEq(nightAt(3).storm, 'storm_03_southerly', 'night four repeats the southerly — the yard is the only new thing'); + assertEq(nightAt(3).site, 'site_03_swing_lawn', 'night four is the swing lawn'); + assertEq(nightAt(4).storm, 'storm_02b_icenight', 'night five is still the ice night'); + assertEq(nightAt(5).storm, 'storm_06_soaker', 'night six is the fabric bet'); + assertEq(nightAt(5).site, 'site_02_corner_block', "and it flies over the corner block — C's measured pairing"); assertEq(nightAt(2).site, 'site_02_corner_block', 'night three moves to the corner block'); - assertEq(nightAt(0).site, 'backyard_01', 'the rest are the backyard'); + assertEq(nightAt(0).site, 'backyard_01', 'the retainer nights are the backyard'); // SPRINT13 gate 1.4: the unsavable-garden flag is night 5's alone, and a // night without it is presumed savable — new nights can't inherit the excuse. assertEq(nightAt(4).gardenBeyondSaving, true, 'night 5 is beyond saving BY DESIGN — canon, ruled'); - for (let i = 0; i < 4; i++) assertEq(nightAt(i).gardenBeyondSaving, false, `night ${i + 1} is savable`); + for (const i of [0, 1, 2, 3, 5]) assertEq(nightAt(i).gardenBeyondSaving, false, `night ${i + 1} is savable`); const w = createWeek(); assertEq(w.night, 1); assertEq(w.bank, 80); assertEq(w.site, 'backyard_01', 'week.site tracks the ladder'); @@ -234,9 +242,12 @@ export default async function run(t) { assertEq(w.site, 'site_02_corner_block', 'and reaches the corner block on night three'); for (let i = 0; i < 2; i++) w.advance(); assertEq(w.night, 5); - assert(w.isFinalNight, 'night five is the last'); + assert(!w.isFinalNight, 'the ice night is no longer the last — its dawn breaks have a morning to book on now'); w.advance(); - assertEq(w.night, 5, 'the ladder does not walk off its own end'); + assertEq(w.night, 6); + assert(w.isFinalNight, 'night six is the last'); + w.advance(); + assertEq(w.night, 6, 'the ladder does not walk off its own end'); }); t.test('money persists across nights, and the bank is next night\'s shop', () => { @@ -292,14 +303,15 @@ export default async function run(t) { assert(j.brief && j.brief.length > 20, `night ${i + 1} has a brief worth reading`); assert(j.site, `night ${i + 1} has a yard`); } - // The week's shape, which is DATA and not decoration: four nights on - // retainer at one yard while they're away, and ONE short-notice callout to - // a stranger's corner block. Night 3 landing differently is the whole point - // of the ladder — a different site MEANS a different client. + // The week's shape, which is DATA and not decoration: the Hendersons' + // retainer, one short-notice callout to a stranger's corner block, the + // Delaneys' swing lawn — and the corner block calling BACK, which is + // SPRINT16's whole thesis (your work follows you) written into the ladder. const clients = NIGHTS.map((_, i) => nightAt(i).client); - assertEq(new Set(clients).size, 2, 'two clients: the retainer and the one-off'); + assertEq(new Set(clients).size, 3, 'three clients: the retainer, the one-off, and the swing lawn'); assert(clients[2] !== clients[0], 'night 3 is somebody else'); assertEq(nightAt(2).site, 'site_02_corner_block', "and it's their corner block"); + assertEq(clients[5], clients[2], 'night six is the corner block client again — a return booking, not a stranger'); }); t.test('an unbriefed night is a job with no letterhead, not a crash', () => { @@ -386,10 +398,17 @@ export default async function run(t) { 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. + // broke is just being broke — the week still finished. SPRINT16: the last + // night is SIX now, and night five going broke is a real game over again — + // both directions pinned so the re-ordering can't quietly move the rule. 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'); + for (let i = 0; i < NIGHTS.length - 1; i++) w2.advance(); + assert(w2.isFinalNight, 'precondition: the walk reached the final night'); + assertEq(w2.settle(ruin, def, 80).outcome, 'win', 'the final night always ends the week, not the run'); + const w3 = createWeek(); + for (let i = 0; i < 4; i++) w3.advance(); + assertEq(w3.settle(ruin, def, 80).outcome, 'gameover', + 'night five is not the final night any more — going broke there ends the run'); }); // Gate 2 acceptance: both sites load from data, and the corner block is not @@ -711,15 +730,15 @@ export default async function run(t) { t.test('SPRINT16 negative control: a clean week books ZERO warranty items and ends rep-positive', () => { const w = createWeek(); let items = 0; - for (let n = 0; n < 5; n++) { + for (let n = 0; n < NIGHTS.length; n++) { items += w.quote(s16def).warranty.length; // counted off the sheet… items += w.settle(cleanRun(), s16def, 0).warranty.length; // …and the invoice w.advance(); } assertEq(items, 0, 'no sheet and no invoice ever carried a warranty line'); assert(w.rep > REP.START, `the week ends rep-positive (★${w.rep})`); - assertEq(w.rep, REP.MAX, 'five clean nights ride the clamp to the top of the scale'); - assertEq(w.log[4].warrantiesToDate, 0, 'and the end card has no receipts to show'); + assertEq(w.rep, REP.MAX, 'six clean nights ride the clamp to the top of the scale'); + assertEq(w.log[NIGHTS.length - 1].warrantiesToDate, 0, 'and the end card has no receipts to show'); w.reset(); assertEq(w.rep, REP.START, 'reset() is a new outfit — rep goes back to neutral'); }); diff --git a/web/world/js/tests/d.test.js b/web/world/js/tests/d.test.js index 42ff55c..1d62968 100644 --- a/web/world/js/tests/d.test.js +++ b/web/world/js/tests/d.test.js @@ -20,6 +20,9 @@ import { needsLadder as realNeedsLadder, createLadder } from '../ladder.js'; import { assert, assertEq, assertClose, assertLess, fixedLoop } from '../testkit.js'; import { FIXED_DT } from '../contracts.js'; import { loadStorm, createWind } from '../weather.js'; +// SPRINT16 gate 4: the six-night law pins at the bottom of this file read the +// ladder. week.js is contracts-only underneath, so this stays headless-safe. +import { NIGHTS, nightAt } from '../week.js'; const DT = FIXED_DT; @@ -1135,4 +1138,61 @@ export default async function run(t) { assertEq(repaired, 0, 're-rig fires with a spare'); assertEq(p.carrying, null, 'and consumes it'); }); + + // ================================================================ SPRINT16 gate 4 + // THE SIX-NIGHT LAW, pinned next to its owner. a.test pins the ladder's + // shape (which storm, which site, which slot); these pin the RULES the + // ordering must obey, so a future re-order that keeps six nights but breaks + // the law goes red with the law's own words. Every threshold here is + // ROADMAP principle 5 or a measured THREADS receipt, not taste. + + t.test('SPRINT16 six-night law: exactly one new site, exactly one new storm, never the same night', () => { + const nights = NIGHTS.map((_, i) => nightAt(i)); + const PRE_S16_SITES = ['backyard_01', 'site_02_corner_block']; + const PRE_S16_STORMS = ['storm_01_gentle', 'storm_02_wildnight', 'storm_02b_icenight', + 'storm_03_southerly', 'storm_03b_earlybuster']; + const newSiteNights = nights.filter((n) => !PRE_S16_SITES.includes(n.site)); + const newStormNights = nights.filter((n) => !PRE_S16_STORMS.includes(n.storm)); + assertEq(newSiteNights.length, 1, 'exactly ONE night is the new site'); + assertEq(newSiteNights[0].site, 'site_03_swing_lawn'); + assertEq(newStormNights.length, 1, 'exactly ONE night flies the new storm'); + assertEq(newStormNights[0].storm, 'storm_06_soaker'); + assert(newSiteNights[0] !== newStormNights[0], + 'and they are NOT the same night — a player who loses must be able to name what beat them'); + }); + + t.test('SPRINT16 six-night law: the new site debuts under a storm already survived', () => { + const idx = NIGHTS.findIndex((_, i) => nightAt(i).site === 'site_03_swing_lawn'); + assert(idx > 0, 'the swing lawn is in the rotation and is not night one'); + const debut = nightAt(idx); + const earlier = NIGHTS.slice(0, idx).map((_, i) => nightAt(i).storm); + assert(earlier.includes(debut.storm), + `night ${idx + 1} flies ${debut.storm}, which must have flown on an earlier night — ` + + 'at the swing lawn\'s debut the yard is the ONLY new variable'); + }); + + t.test("SPRINT16 six-night law: the soaker flies the corner block — C's measured pairing", () => { + // THREADS [C] 2026-07-20: the backyard's buyable geometry caps hail cover + // over the bed at ~31% — best membrane line there reads 37.5, a bet with + // no win in it. The soaker over anything but site_02 needs a re-measure + // FIRST; this pin is where a re-pairing finds that out. + const soaker = NIGHTS.map((_, i) => nightAt(i)).find((n) => n.storm === 'storm_06_soaker'); + assert(soaker, 'the soaker is in the week'); + assertEq(soaker.site, 'site_02_corner_block', + 'storm_06 over any other yard is unmeasured — fly gardenfly over the new pairing before moving this'); + const debutIdx = NIGHTS.findIndex((_, i) => nightAt(i).site === soaker.site); + const soakerIdx = NIGHTS.findIndex((_, i) => nightAt(i).storm === 'storm_06_soaker'); + assert(debutIdx < soakerIdx, 'and the yard was taught on an earlier night — the storm is the only new variable'); + }); + + t.test('SPRINT16 six-night law: the beyond-saving night is neither new nor final', () => { + const flagged = NIGHTS.map((_, i) => nightAt(i)).filter((n) => n.gardenBeyondSaving); + assertEq(flagged.length, 1, 'exactly one designed loss in the week'); + assertEq(flagged[0].storm, 'storm_02b_icenight', 'and it is the ice night — the flag is a measured fact about'); + assertEq(flagged[0].site, 'backyard_01', 'THIS yard under THIS storm (bare 0.0, best buyable 27.7, win at 50)'); + const idx = NIGHTS.findIndex((_, i) => nightAt(i).gardenBeyondSaving); + assert(idx !== NIGHTS.length - 1, + 'the designed loss must not be the final night: its dawn breaks need a morning to book warranty on ' + + '(A filed the night-5-final gap in THREADS — the six-night order is what closes it)'); + }); } diff --git a/web/world/js/week.js b/web/world/js/week.js index 42966e9..ba6723c 100644 --- a/web/world/js/week.js +++ b/web/world/js/week.js @@ -1,7 +1,9 @@ /** * SHADES — the week. Lane A owns this file. + * (SPRINT16 gate 4: the NIGHTS ladder and the two new briefs are Lane D's + * edits, by the sprint's explicit grant — A owns everything else here.) * - * Five nights, one yard, one wallet. This is deliberately a thin wrapper around + * Six nights, 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 @@ -20,30 +22,51 @@ import { START_BUDGET, HARDWARE } from './contracts.js'; * 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 + * 4 Southerly again gust 20.0, hail — the storm you know, the yard you don't + * 5 Ice Night gust 28.5, hail — the most wind of the week, and worse ice + * 6 Pea-Hail Soaker gust 15.2, fine hail — the night the default fabric is wrong * Night 3 is the trap: it looks like night 2 on the forecast card and isn't. * - * SPRINT10: a night is now a STORM and a SITE. Nights 1/2/4/5 are the backyard; - * night 3 moves to the corner block — the same "looks like night 2 and isn't" - * beat, sharpened: it's a new yard you've never rigged, its carport is a trap, - * and its storm carries the southerly change that makes the venturi scream. The - * plain-string form is still accepted (defaults to backyard_01) so nothing that - * reads NIGHTS[i] as a storm key breaks. + * SPRINT10: a night is now a STORM and a SITE. Night 3 moves to the corner block + * — the same "looks like night 2 and isn't" beat, sharpened: it's a new yard + * you've never rigged, its carport is a trap, and its storm carries the + * southerly change that makes the venturi scream. The plain-string form is + * still accepted (defaults to backyard_01) so nothing that reads NIGHTS[i] as + * a storm key breaks. * - * SPRINT11: a night is now a JOB. Same ladder, same yards — what's new is the - * frame DESIGN.md has had from line one: *"work arrives as callouts — each one a - * site with existing conditions, a client brief, a budget, and a forecast - * window."* You are not surviving five nights; you are working five jobs. + * SPRINT11: a night is now a JOB. What's new is the frame DESIGN.md has had + * from line one: *"work arrives as callouts — each one a site with existing + * conditions, a client brief, a budget, and a forecast window."* You are not + * surviving the week; you are working its jobs. * * The week that fell out of the data, once it was asked to name a client: the - * Hendersons are away (SPRINT11's own line — they leave for Cairns), so four of - * the five nights are the same yard, on retainer, through a bad week. Night 3 is - * a different site, so it is necessarily a different client — a short-notice - * callout to a yard you have never rigged, on the night the change comes early. - * That is not flavour bolted on: it is what `site: site_02_corner_block` already - * MEANT, finally said out loud. The corner block lands harder because it's a - * stranger's place and you're there at short notice. + * Hendersons are away (SPRINT11's own line — they leave for Cairns), so the + * backyard nights are the same yard, on retainer, through a bad week. A + * different site is necessarily a different client. + * + * SPRINT16 gate 4 (Lane D, by the sprint's explicit grant): SIX nights. The + * swing lawn enters the rotation at night 4 under the southerly — the ONE + * variable rule (ROADMAP principle 5), applied literally: at its debut the only + * new thing is the yard, because the storm is Tuesday's, already survived. The + * soaker flies night 6 over the corner block — a yard night 3 taught — because + * C MEASURED the pairing (THREADS [C] 2026-07-20): the backyard's buyable + * geometry caps hail cover over the bed at ~31%, so over there the membrane + * bet has no win in it. Exactly one night is the new site (4), exactly one + * flies the new storm (6), and they are not the same night. + * + * ⚠️ THE STRUCTURAL CONSEQUENCE, SAID OUT LOUD (D, filed in THREADS for John's + * gate 6): six slots cannot hold seven nights. Five shipped storms + the + * soaker is six distinct storms; the swing lawn's debut must REPEAT a storm + * the player already survived; so exactly one shipped storm leaves the week — + * and every other candidate is load-bearing (gentle = the tutorial and the + * calm-day preload; southerly = the hail intro and the early buster's + * referent; early buster = site_02's debut, which the soaker's pairing needs; + * icenight = the beyond-saving flag, canon). THE WILD NIGHT (storm_02) is out + * of the rotation. Its storm file, its tests, and the pinned backyard + * separation all stand — it is un-flown by the week, not deleted. If that + * eviction is wrong, the honest fix is a SEVEN-night week (arc 2's own range + * is "five-to-seven"), which is one entry re-added here — a ruling, not a + * refactor. * * Copy lives here as data, per SPRINT11's rule: client flavour is data, not code. */ @@ -72,17 +95,29 @@ export const NIGHTS = [ + 'to tie off to.', }, { - storm: 'storm_02_wildnight', site: 'backyard_01', - client: 'the Hendersons', addr: '14 Kurrajong St — the backyard', - brief: 'Back at the Hendersons\', and this is the night the whole week was pointing at. Rig it like ' - + 'they\'re watching, because Friday they will be.', + // SPRINT16 gate 4 — THE NEW SITE, and nothing else new: Tuesday's southerly + // over D's swing lawn. The brief does what "plenty to tie off to" did for + // the carport — the client's own voice sells the trap (the crossbar between + // the swing apexes is a dead-level rail and NOT an anchor; the apexes are + // 0.45 steel that bills $140) — and the kid gives the bed its reason. + storm: 'storm_03_southerly', site: 'site_03_swing_lawn', + client: 'the Delaneys', addr: '31 Ferndale Ave — the swing lawn', + brief: 'Same southerly as Tuesday, over a lawn you\'ve never rigged. The swing set is Ruby\'s — ' + + 'her dad reckons the crossbar\'s bolted right through, tie to that and save yourself a ' + + 'post. The veggie bed is her school project, so it comes home alive.', }, { storm: 'storm_02b_icenight', site: 'backyard_01', client: 'the Hendersons', addr: '14 Kurrajong St — the backyard', - brief: 'Less wind than last night and more ice, which is worse for the bed and easier on the rig. ' - + 'Last night before they land. Nobody\'s pretending the seedlings ride this one out — keep ' - + 'the sail on its feet and the ice off the windows.', + // SPRINT16 gate 4 wording note (D): this brief used to open "Less wind than + // last night" — true when the wild night (gust 30) sat at night 4. The wild + // night left the six-night rotation (see the ladder comment), so the ice + // night is now the most wind the week throws; the sentence moved with the + // facts. The teaching half — level with the player that the bed is already + // lost — is untouched. + brief: 'The most wind of the week and golf-ball ice with it. Last night before they land. ' + + 'Nobody\'s pretending the seedlings ride this one out — keep the sail on its feet and ' + + 'the ice off the windows.', // SPRINT13 gate 1.4 — A's ruling, DESIGN CANON: night 5's garden is BEYOND // SAVING, by design, at any price. Measured game-true (B's pay table and // A's probe, agreeing): bare bed 0.0 DEAD; best buyable garden 27.7; best @@ -99,6 +134,20 @@ export const NIGHTS = [ // flag in the same commit or the verdict lies in the other direction. gardenBeyondSaving: true, }, + { + // SPRINT16 gate 4 — THE NEW STORM, over a yard night 3 taught. C's pairing + // receipt (THREADS [C] 2026-07-20): the soaker MUST fly the corner block — + // the backyard caps hail cover at ~31% and the bet has no win there. The + // brief sells the WRONG default the way the carport got sold: fine hail + // through a 2 mm weave is exactly the night cloth loses, and the client is + // the one telling you it's easy. The forecast (C's stones/rain lines) is + // where the truth argues back — the brief stays in character. + storm: 'storm_06_soaker', site: 'site_02_corner_block', + client: 'the Vasilaros place', addr: '2 Bight Rd — the corner block', + brief: 'The Vasilaros place again — they asked for you by name this time. Hardly any wind in it, ' + + 'just rain and a fine rattle of hail; the kind of night you could rig in your sleep. ' + + 'Whatever you flew last time will do, they said.', + }, ]; /**