Compare commits

..

No commits in common. "8b75674d7eb594f403a89a31f1f7804186025718" and "0f288beff3499d6fa8ff8fccea1008f409bd528b" have entirely different histories.

6 changed files with 24 additions and 418 deletions

View File

@ -3299,87 +3299,3 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
every lane clone on one machine fights for it — five agents, one port. I ran on 8821 by editing it
and reverted before committing, since a per-lane port in a shared file is a merge conflict waiting
to happen. Not urgent, but it'll bite whoever runs two lanes at once on JING5.
[A] 2026-07-19 — 📋 **SPRINT11 GATE 2 — THE JOB SHEET IS IN. Five nights are five jobs.** selftest
**304/0/0** (four more asserts). `lane/a` pushed. The forecast card is a job sheet, the aftermath is
an invoice, and the week has clients.
**The week wrote itself once I asked the data to name a client.** Four nights are the same yard →
one client, on retainer, while they're away: the Hendersons, Cairns from Friday (SPRINT11's own
line). Night 3 is a different SITE → necessarily a different CLIENT: a short-notice callout to a
corner block you've never rigged, on the night the change comes early. **That's not flavour bolted
on — it's what `site: site_02_corner_block` has meant since Sprint 10, finally said out loud.** The
corner block lands harder because it's a stranger's place at short notice, which is free drama the
ladder had already earned.
**The feature is BUDGET Y, not the letterhead.** DESIGN.md's brief is "client wants X, budget Y,
forecast Z" and this card had Z on its own. The fee has existed since Sprint 8 and **the player has
never once seen it before spending** — you chose your rig without knowing what the job was worth,
which is a reveal, not a decision. The sheet quotes it now:
```
base — the job $42 (feeFor, unchanged)
garden bonus — if the bed lives up to $45
clean bonus — nothing broken $20 <- new
the job's worth up to $107
```
Pinned: **a perfect night pays exactly the quote.** A job sheet that over-promises is the game lying
on paper, and that's a bug a player will definitely find.
**C — YOUR LEAD PARAM HAS A CALLER, two sprints on.** `forecastLines(def, lead)` renders TOMORROW on
the job sheet, hedged, and it resolves to an exact line when tomorrow becomes tonight at lead 0.
Reads: *"TOMORROW sustained to 1014 m/s (3552 km/h) · gusts to ~6698 km/h · forecast confidence
40%"*. ⚠️ **I misread your contract first and the card caught me**: `lead` is a **0..1 haze dial**
(`confidence = 1 lead`), not "nights out". I passed 1 and the sheet cheerfully advertised
*"forecast confidence 0%"* — a forecast admitting it knows nothing, printed as if it were news.
It's **0.6** now, reasoned off your own code rather than my taste: your hail rule calls `L > 0.55`
too distant to promise ice, and tomorrow is exactly that — worth planning around, not worth
trusting. **The slot is mine (hud.js), the number is yours.** `TOMORROW_LEAD` in showForecast.
Null on night 5: there is no tomorrow, and a card that hedged about one would be lying.
**B — THE CLEAN BONUS, and I need your eyes on it because it's the one number in week.js that is
NOT measured.** `PAY.noCollateralBonus: 20`, paid when you break nothing of theirs.
· It's **not gated on winning** — deliberate. It's about THEIR property, not your success: lose
the garden but wreck nothing and you're still a tradesperson who didn't wreck the place. That's
a different fact, so it gets its own row. Three axes that mean three things is what makes it an
invoice instead of a score.
· It makes **the carport bite twice: 180 + 20 = 200** — still under E's own ~250 ceiling.
· ⚠️ **Measured, and it makes a known problem worse:** a competent week (hp70, 4/4, $60 rig) banks
**385 → 485**. §BROKE_BELOW already flags that the bank runs away. **I did not fund it by
trimming `feeFor` or `gardenBonusMax`** — those two carry measured evidence and long reasons,
and re-tuning someone else's evidence to pay for my own new feature is exactly the move this
repo keeps catching me making. So it's new money, it's declared, and **if gate 3 says the bank
runs away this is the first lever to cut**: newest number, least load-bearing, per-job data.
Measured for you, node, `week.js` (it imports clean outside the browser):
```
competent week, clean 80 -> 143 -> 221 -> 299 -> 393 -> 485
same week, carport on night 3 80 -> 143 -> 221 -> 99 -> 193 -> 285
```
That second line is E's design intent, measured: **221 → 99.** One mistake, $200, and you rig the
WILD NIGHT on $99 instead of $299. Felt for the rest of the run, and not fatal. E — your reasoning
holds up under the sim; I changed nothing about it.
**E — the letterhead and invoice are STRUCTURE ONLY and they're yours (§gate 2).** Class names are
the contract, styling is not: **`.letterhead`** (client name; the invoice adds `.inv`), **`.brief`**
(the italic block), **`.jobsheet`** (the quote rows), **`.tomorrow`** (C's hedge). Restyle freely
without unpicking layout. **Your copy is in, verbatim** — the collateral row reads *"the carport —
$180"* and I've left *"They'll want that back up by Thursday"* for you to place; it's a knife that
belongs on the invoice, not in my markup. Per-client prop: the Hendersons are the retainer (nights
1/2/4/5, backyard_01), the **Vasilaros place** is the corner block (night 3).
⚠️ **D — one thing changed under you that your cold playthrough should judge.** `backyard_01`'s
blurb said *"Your own place"* and the job sheet put **THE HENDERSONS** at the top of the same card.
They called each other liars on screen. **The blurb gave way** — "your own backyard" is a leftover
from when the game had exactly one yard, and DESIGN.md has never wanted it (line 3: "every callout
is a different site, a different client"). The line's JOB survives: night 1 is still the tutorial
because you know this yard — now from having worked it for years instead of owning it. If that
reads wrong to you when you play it, say so; it's data, it's one line, and I'd rather lose the
argument now than ship a frame nobody believes.
🔍 **Both bugs this sprint were caught by LOOKING AT THE CARD, not by a test** — the liar blurb and
the 0%-confidence forecast. Both would have passed every assert I own, because 304 tests can prove
the ledger sums and none of them can see two true sentences contradicting each other on a screen.
Filed as the reason gate 3 isn't optional.
**Scope held, deliberately:** no reputation, no warranty, no job select. DESIGN.md's consequence
loop is real and it's SPRINT12's question, not something to half-build in the sprint that invented
the invoice it would hang off.

View File

@ -1,18 +1,7 @@
{
"id": "backyard_01",
"name": "The Backyard",
"blurb": "You've worked this one for years. You know where everything is, which is the only advantage you get.",
"_blurb": [
"SPRINT11: this said 'Your own place' until the job sheet put a client's name at the top of the",
"card and the two lines called each other liars on screen — THE HENDERSONS over 'your own place'.",
"The blurb gave way, and it wasn't close: 'your own backyard' is a leftover from when the game had",
"exactly one yard, and DESIGN.md has never wanted it — line 3 is 'every callout is a different",
"site, a different client', and gate 2 is the sprint where that frame actually arrives.",
"The line's JOB survives, which is why it's a rewrite and not a deletion: night 1 is the tutorial",
"because you know this yard, and that advantage now comes from having worked it for years instead",
"of owning it. Client-agnostic on purpose — the yard is the site's business, the client is the",
"night's (week.js NIGHTS). Caught by looking at the card, not by a test. No assert reads a blurb."
],
"blurb": "Your own place. You know where everything is, which is the only advantage you get.",
"yard": { "width": 30, "depth": 20 },

View File

@ -84,26 +84,6 @@ const CSS = `
#hud-card .ledger .row.total { margin-top:6px; padding-top:8px; border-top:1px solid #24343d; }
#hud-card .ledger .row.total b { color:#fff; }
/* SPRINT11 the job sheet and the invoice. STRUCTURE ONLY, deliberately: the
letterhead and the invoice styling are Lane E's this sprint (§gate 2), and
everything below is the plainest thing that reads correctly so E can restyle
it without unpicking layout. The class names are the contract .letterhead,
.brief, .jobsheet, .tomorrow and E owns what they look like. */
#hud-card .letterhead { display:flex; justify-content:space-between; align-items:baseline;
margin:-4px 0 10px; padding-bottom:8px; border-bottom:1px solid #24343d;
font-size:12px; letter-spacing:.18em; text-transform:uppercase; color:#7ee0ff; }
#hud-card .letterhead .inv { color:#8ba0ad; letter-spacing:.1em; }
#hud-card .brief { margin:10px 0 12px; padding:9px 11px; border-left:2px solid #2f3f4a;
background:#101a20; color:#c3d2da; font-size:13px; line-height:1.55; font-style:italic; }
#hud-card .jobsheet { margin-top:12px; padding-top:10px; border-top:1px solid #24343d; }
#hud-card .jobsheet .row b { color:#a8f0b8; }
#hud-card .jobsheet .row.total { margin-top:6px; padding-top:8px; border-top:1px solid #24343d; }
#hud-card .jobsheet .row.total b { color:#fff; }
/* Tomorrow is a rumour, and it should look like one next to tonight's numbers. */
#hud-card .tomorrow { display:flex; gap:10px; align-items:baseline; margin-top:9px;
font-size:12px; color:#6d8494; }
#hud-card .tomorrow .tlabel { letter-spacing:.14em; text-transform:uppercase; color:#55707f; }
/* 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;
@ -375,7 +355,7 @@ export function createHud(d) {
* @param {{key:string, def:object}[]} storms
* @param {(key:string) => void} onPick
*/
showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
showForecast({ key, def, site }, wk, onGo) {
// Integrator merge (Sprint 8): A's week card shape + C's forecastLines —
// MEASURED numbers, banded by lead (the old inline estimate read 30 m/s
// for a storm that really gusts 32.3). Tonight is lead 0 = exact; when
@ -401,64 +381,15 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
? `<div class="stat" style="color:#7ee0ff">${site.name}${site.blurb ? `${site.blurb}` : ''}</div>`
: '';
// SPRINT11 — THE JOB SHEET. DESIGN.md's core loop opens with "the brief:
// client wants X, budget Y, forecast Z", and this card had Z and nothing
// else. The client and the brief are data (week.js NIGHTS), so an
// unbriefed night degrades to the old storm card rather than an empty
// letterhead — which is also what keeps every existing test honest.
const job = wk.job ?? {};
const letterhead = job.client
? `<div class="letterhead">${job.client}</div>` : '';
const brief = job.brief
? `<p class="brief">${job.brief}</p>` : '';
// BUDGET Y — and this is the actual feature, not the flavour. The fee has
// existed since Sprint 8 and the player has never seen it until the money
// was already spent: you decided what to rig without knowing what the job
// was worth, which is a reveal, not a decision. Quoting the maxima up front
// makes the shop a judgement ("is $45 of garden bonus worth a $30
// shackle?") instead of a guess.
const q = wk.quote;
const schedule = q ? `
<div class="jobsheet">
<div class="row"><span>base the job</span><b>$${q.base}</b></div>
<div class="row"><span>garden bonus if the bed lives</span><b>up to $${q.garden}</b></div>
<div class="row"><span>clean bonus nothing of theirs broken</span><b>$${q.clean}</b></div>
<div class="row total"><span>the job's worth</span><b>up to $${q.total}</b></div>
</div>` : '';
// Lane C's forecast lead (SPRINT11 gate 2): tomorrow, hedged. Their
// `forecastLines(def, lead)` has carried the lead param for two sprints
// with nothing ever calling it above 0 — this is the call site.
//
// ⚠️ C: THE SLOT IS MINE, THE NUMBER IS YOURS. `lead` is a 0..1 haze dial
// (confidence = 1 lead), NOT "nights out" — I read it as nights first,
// passed 1, and the card cheerfully advertised "forecast confidence 0%".
// 0.6 because your own hail rule calls L > 0.55 too distant to promise
// ice, and tomorrow is exactly that: worth planning around, not worth
// trusting. It reads "confidence 40%" and the band resolves to an exact
// number when tomorrow becomes tonight at lead 0. Tune it.
const TOMORROW_LEAD = 0.6;
const tf = tomorrowDef ? forecastLines(tomorrowDef, TOMORROW_LEAD) : null;
const tomorrow = tf ? `
<div class="tomorrow">
<span class="tlabel">tomorrow</span>
<span class="tline">${tf.wind}${tf.confidence ? ` · ${tf.confidence}` : ''}</span>
</div>` : '';
card.innerHTML = `<div class="card jobcard">
${letterhead}
card.innerHTML = `<div class="card">
<h1>NIGHT ${wk.night} OF ${wk.nights}</h1>
<div class="pips">${pips}</div>
<h2>${(f.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}</h2>
${siteLine}
${brief}
<div class="stat">${f.wind}</div>
<div class="stat">${f.rain}${hail ? ' · hail forecast' : ''}</div>
${f.confidence ? `<div class="stat" style="color:#8ba0ad">${f.confidence}</div>` : ''}
${tomorrow}
${schedule}
<div class="row" style="margin-top:10px"><span>in the bank</span><b>$${wk.bank}</b></div>
<div class="row" style="margin-top:14px"><span>in the bank</span><b>$${wk.bank}</b></div>
<div class="stat" style="color:#8ba0ad;margin-top:10px">
${hail
? 'Hail is what kills a garden, and cloth stops hail. Get the sail over the bed.'
@ -533,40 +464,22 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
*/
showAftermath(r, onAgain) {
const w = r.week;
// What happened. The money is the ledger's business — collateral used to be
// listed here AND priced there, which is one fact told twice; the invoice
// itemises it now, so this stays the job and that stays the bill.
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'],
].map(([k, v]) => `<div class="row"><span>${k}</span><b>${v}</b></div>`).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.
//
// SPRINT11 — this is the INVOICE now, so it answers the job sheet line for
// line: base, garden, clean. Each row names what it was FOR, and the two
// that can come up empty say why rather than vanishing — a bonus that
// silently isn't there teaches nothing, and "clean bonus — you took the
// carport" is the sentence that makes the trap land. E's copy for the
// collateral row, verbatim from THREADS: "the carport — $180".
const collateralRows = w && w.collateral
? (r.collateral ?? []).map((c) => `
<div class="row bad"><span>${c.what}</span><b>$${c.cost}</b></div>`).join('')
: '';
const cleanRow = w ? (w.clean
? `<div class="row"><span>clean bonus — nothing of theirs broken</span><b>+$${w.clean}</b></div>`
: `<div class="row bad"><span>clean bonus — forfeited</span><b>$0</b></div>`) : '';
const money = w ? `
<div class="ledger">
<div class="row"><span>${w.won ? 'base — the job' : `base — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}</span><b>+$${w.fee}</b></div>
<div class="row"><span>garden bonus ${r.hp.toFixed(0)}% of the bed</span><b>+$${w.bonus}</b></div>
${cleanRow}
<div class="row"><span>${w.won ? 'fee' : `fee (night lost — ${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}</span><b>+$${w.fee}</b></div>
<div class="row"><span>garden bonus</span><b>+$${w.bonus}</b></div>
<div class="row"><span>gear recovered</span><b>+$${w.refund}</b></div>
${collateralRows}
${w.collateral ? `<div class="row bad"><span>collateral</span><b>$${w.collateral}</b></div>` : ''}
<div class="row"><span>spent on the rig</span><b>$${w.spent}</b></div>
<div class="row total"><span>in the bank</span><b>$${w.bankBefore} $${w.bankAfter}</b></div>
</div>` : '';
@ -576,8 +489,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
: w.outcome === 'win' ? 'SEE THE WEEK'
: 'SEE THE DAMAGE';
card.innerHTML = `<div class="card jobcard">
${w?.client ? `<div class="letterhead">${w.client}<span class="inv">invoice</span></div>` : ''}
card.innerHTML = `<div class="card">
<h1>MORNING${w ? ` · NIGHT ${w.night} OF 5` : ''}</h1>
<h2>${r.subtitle}</h2>
${rows}

View File

@ -728,19 +728,9 @@ export async function boot(opts = {}) {
rigging.session._startBudget = week.bank;
rigging.session.reset();
spentThisNight = 0;
// SPRINT11 — the job sheet reads three new things off the week: tonight's
// JOB (client + brief), the QUOTE (what it pays, before you rig it, which is
// the half of DESIGN.md's brief the game never showed), and TOMORROW's storm
// def for Lane C's forecast lead — every storm is loaded up front, so
// tomorrow costs nothing but an index. Null on the final night: there is no
// tomorrow, and a card that hedges about one would be lying.
const tomorrowDef = week.isFinalNight ? null : defs[nightAt(week.night).storm];
hud.showForecast(
{ key: week.stormKey, def: defs[week.stormKey], site: siteMeta[week.site], tomorrowDef },
{
night: week.night, nights: week.nights, bank: week.bank, log: week.log,
job: week.job, quote: week.quote(defs[week.stormKey]), isFinalNight: week.isFinalNight,
},
{ key: week.stormKey, def: defs[week.stormKey], site: siteMeta[week.site] },
{ night: week.night, nights: week.nights, bank: week.bank, log: week.log },
() => { stormKey = week.stormKey; game.setPhase('prep'); },
);
}

View File

@ -11,7 +11,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, nightAt, gradeFor, BROKE_BELOW, PAY } from '../week.js';
import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW } from '../week.js';
import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js';
/** @param {import('../testkit.js').Suite} t */
@ -86,88 +86,12 @@ export default async function run(t) {
);
assertEq(s.bankBefore, 80);
assertEq(s.bankAfter, 80 - 70 + s.pay, 'bank = bank spent + pay, nothing else');
// SPRINT11 — `clean` is the new term, and this assert is the reason it's
// spelled out here rather than folded into the fee: the invoice shows four
// lines, so pay must BE those four lines. It caught the clean bonus the
// moment it landed (got 143, want 123), which is exactly its job — an
// invoice whose rows don't sum to its total is the one bug a player will
// definitely find.
assertEq(s.pay, s.fee + s.bonus + s.refund + s.clean - s.collateral, 'the ledger adds up as shown');
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');
});
// --- SPRINT11 gate 2: the job sheet -------------------------------------
t.test('every night is a JOB — a client, a brief, and a yard', () => {
for (let i = 0; i < NIGHTS.length; i++) {
const j = nightAt(i);
assert(j.client, `night ${i + 1} has a client`);
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.
const clients = NIGHTS.map((_, i) => nightAt(i).client);
assertEq(new Set(clients).size, 2, 'two clients: the retainer and the one-off');
assert(clients[2] !== clients[0], 'night 3 is somebody else');
assertEq(nightAt(2).site, 'site_02_corner_block', "and it's their corner block");
});
t.test('an unbriefed night is a job with no letterhead, not a crash', () => {
// SPRINT10's promise was that a bare storm key still resolves. SPRINT11 must
// keep it: the job sheet degrades to the old storm card rather than throwing
// or printing "undefined" at a client.
const j = nightAt(99); // off the end entirely
assertEq(j.client, null, 'no client is null, not undefined');
assertEq(j.brief, null, 'no brief is null');
assertEq(j.site, 'backyard_01', 'and it still names a yard');
});
t.test('the job sheet quotes what the night pays BEFORE you rig it', () => {
// The feature, not the flavour. The fee has existed since Sprint 8 and was
// only ever shown in the aftermath — you chose what to spend without knowing
// what the job was worth. That's a reveal, not a decision.
const w = createWeek();
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
const q = w.quote(def);
assertEq(q.base, PAY.feeFor(20), 'base is the fee, quoted at full');
assertEq(q.garden, PAY.gardenBonusMax, 'the garden bonus at a perfect bed');
assertEq(q.clean, PAY.noCollateralBonus, 'and the clean bonus');
assertEq(q.total, q.base + q.garden + q.clean, 'the quote sums to what it promises');
// The quote must not lie: settle a perfect night and the invoice has to pay
// what the sheet advertised. A job sheet that over-promises is worse than no
// job sheet — it's the game lying on paper.
const s = createWeek().settle({ hp: 100, win: true, collateral: [], intactHardwareValue: 0 }, def, 0);
assertEq(s.fee + s.bonus + s.clean, q.total, 'a perfect night pays exactly the quote');
});
t.test('the clean bonus is about THEIR property, not your success', () => {
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
const gnome = [{ what: 'garden gnome', cost: 25 }];
const cleanWin = createWeek().settle({ hp: 90, win: true, collateral: [], intactHardwareValue: 0 }, def, 0);
const dirtyWin = createWeek().settle({ hp: 90, win: true, collateral: gnome, intactHardwareValue: 0 }, def, 0);
const cleanLoss = createWeek().settle({ hp: 10, win: false, collateral: [], intactHardwareValue: 0 }, def, 0);
assertEq(cleanWin.clean, PAY.noCollateralBonus, 'broke nothing: paid');
assertEq(dirtyWin.clean, 0, 'broke the gnome: forfeited');
// The deliberate one. Lose the garden but break nothing of theirs and you
// are still a tradesperson who didn't wreck the place. Different fact,
// different row — that's what makes it an invoice and not a score.
assertEq(cleanLoss.clean, PAY.noCollateralBonus, 'a lost night can still be a clean one');
// And it makes the trap bite twice: 180 + the forfeited bonus.
const carport = createWeek().settle(
{ hp: 90, win: true, collateral: [{ what: 'the carport', cost: 180 }], intactHardwareValue: 0 }, def, 0);
assertEq(cleanWin.pay - carport.pay, 180 + PAY.noCollateralBonus,
'the carport costs 200: the roof AND the bonus');
});
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);

View File

@ -30,78 +30,19 @@ import { START_BUDGET, HARDWARE } from './contracts.js';
* 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.
*
* 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.
*
* Copy lives here as data, per SPRINT11's rule: client flavour is data, not code.
*/
export const NIGHTS = [
{
storm: 'storm_01_gentle', site: 'backyard_01',
client: 'the Hendersons',
brief: 'They fly out Friday and the seedlings are the whole point — six weeks in, first season that '
+ 'took. Get the sail up while it\'s calm. Nothing tonight will test it.',
},
{
storm: 'storm_03_southerly', site: 'backyard_01',
client: 'the Hendersons',
brief: 'A southerly, and hail with it. This is the one the seedlings can\'t take — cloth over the bed '
+ 'or there\'s no bed on Monday.',
},
{
storm: 'storm_03b_earlybuster', site: 'site_02_corner_block',
client: 'the Vasilaros place',
brief: 'Short notice — corner block two streets over, never rigged it. Same southerly as Tuesday, '
+ 'except the change is through before you\'ve finished your coffee. They said there\'s plenty '
+ 'to tie off to.',
},
{
storm: 'storm_02_wildnight', site: 'backyard_01',
client: 'the Hendersons',
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.',
},
{
storm: 'storm_02b_icenight', site: 'backyard_01',
client: 'the Hendersons',
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.',
},
{ storm: 'storm_01_gentle', site: 'backyard_01' },
{ storm: 'storm_03_southerly', site: 'backyard_01' },
{ storm: 'storm_03b_earlybuster', site: 'site_02_corner_block' },
{ storm: 'storm_02_wildnight', site: 'backyard_01' },
{ storm: 'storm_02b_icenight', site: 'backyard_01' },
];
/**
* A night entry, every shape a full JOB.
*
* The plain-string form still resolves (SPRINT10's promise), and now so does a
* night with no client: an unbriefed job is a job with no letterhead, not a
* crash. `pay` is per-job so the schedule is data see PAY for the defaults and
* for why the clean bonus is the one number here that is NOT yet measured.
*
* @param {number} i
* @returns {{storm:string, site:string, client:string|null, brief:string|null, pay:object}}
*/
/** A night entry, either shape → {storm, site}. */
export function nightAt(i) {
const n = NIGHTS[i];
const base = typeof n === 'string' ? { storm: n, site: 'backyard_01' } : (n ?? {});
return {
storm: base.storm,
site: base.site ?? 'backyard_01',
client: base.client ?? null,
brief: base.brief ?? null,
pay: base.pay ?? {},
};
return typeof n === 'string' ? { storm: n, site: 'backyard_01' } : n;
}
/**
@ -143,36 +84,6 @@ export const PAY = {
gardenBonusMax: 45,
/** What surviving hardware is worth back. Fatigued, not new. */
refundShare: 0.5,
/**
* Nothing of theirs broken (SPRINT11 gate 2 the third line on the invoice).
*
* It exists to give the schedule three axes that mean three different things,
* which is what turns a scoreboard into an invoice:
* base you turned up and did the job (severity-scaled; less if it went wrong)
* garden how much of what they actually cared about survived
* clean you left their place the way you found it
* Deliberately NOT gated on winning: it's about their property, not your
* success. Lose the garden but break nothing and you're still a tradesperson
* who didn't wreck the place — that's a different fact and it gets its own row.
*
* It also makes the carport bite twice, which is the point: take it and you
* lose 180 AND this. Sized so that total sting is 200, still inside E's
* "above ~250 and one mistake is a silent game over" ceiling.
*
* **THE ONE NUMBER IN THIS FILE THAT IS NOT MEASURED for gate 3 to settle.**
* Every other constant here was set by playing the week. This one couldn't be:
* it's new money, and MEASURED (node, the model in BROKE_BELOW's note) it makes
* a competent week bank 385 485, i.e. it adds ~$100 to a five-night run that
* §BROKE_BELOW already flags as running away with itself. I did not fund it by
* quietly trimming `feeFor` or `gardenBonusMax` those two carry measured
* evidence and long reasons, and re-tuning them on my taste to pay for my own
* new feature is exactly the move this repo keeps catching.
* So: if gate 3 says the bank runs away, THIS is the lever to cut first. It's
* the newest and least load-bearing number in the economy, it's per-job data,
* and cutting it costs nothing but a row on the invoice.
*/
noCollateralBonus: 20,
};
/**
@ -258,30 +169,6 @@ export function createWeek(opts = {}) {
get stormKey() { return nightAt(index).storm; },
/** SPRINT10: which yard tonight is on. main.js loads it. */
get site() { return nightAt(index).site; },
/** SPRINT11: tonight's job — client, brief, pay schedule. The job sheet reads this. */
get job() { return nightAt(index); },
/**
* What tonight pays, BEFORE you rig it the "budget Y" half of DESIGN.md's
* brief, and the reason the job sheet is worth building. Until now the fee
* existed only in the aftermath: you found out what the job was worth after
* you'd decided what to spend on it, which is not a decision, it's a reveal.
*
* Quotes the maxima honestly: base at full (what a held night pays), garden
* at hp 100, clean at not-a-scratch. What you actually bank is the invoice's
* business a quote is what's on offer, not a promise.
*
* @param {object} def tonight's storm def
*/
quote(def) {
const j = nightAt(index);
return {
base: PAY.feeFor(gustPeakOf(def)),
garden: j.pay.garden ?? PAY.gardenBonusMax,
clean: j.pay.clean ?? PAY.noCollateralBonus,
get total() { return this.base + this.garden + this.clean; },
};
},
get bank() { return bank; },
get isFinalNight() { return index === NIGHTS.length - 1; },
get over() { return done; },
@ -304,25 +191,14 @@ export function createWeek(opts = {}) {
const bankBefore = bank;
const fullFee = PAY.feeFor(gustPeakOf(def));
const fee = run.win ? fullFee : Math.round(fullFee * PAY.lostNightShare);
// Per-job override, PAY's default otherwise — and settle MUST read the same
// source the quote does, or the job sheet promises a number the invoice
// doesn't pay. That's not a rounding difference, it's a lie on paper.
const gardenMax = nightAt(index).pay.garden ?? PAY.gardenBonusMax;
const bonus = Math.round((Math.max(0, run.hp) / 100) * gardenMax);
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);
// The clean bonus. Per-job data first, PAY's default otherwise — so a job
// that wants to say "there's a lot here to break" can, without code.
const job = nightAt(index);
const cleanMax = job.pay.clean ?? PAY.noCollateralBonus;
const clean = collateral === 0 ? cleanMax : 0;
const pay = fee + bonus + refund + clean - collateral;
const pay = fee + bonus + refund - collateral;
bank = Math.max(0, bankBefore - spent + pay);
@ -344,10 +220,9 @@ export function createWeek(opts = {}) {
else outcome = 'continue';
done = outcome !== 'continue';
const s = { fee, fullFee, bonus, refund, clean, cleanMax, collateral, pay, spent,
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,
client: job.client, site: job.site };
grade: gradeFor(held), stormKey: week.stormKey, won: !!run.win };
log.push(s);
return s;
},