${[
@@ -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.',
+ },
];
/**