Compare commits
2 Commits
0f288beff3
...
8b75674d7e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b75674d7e | ||
|
|
6bcc2540cf |
84
THREADS.md
84
THREADS.md
@ -3299,3 +3299,87 @@ 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 10–14 m/s (35–52 km/h) · gusts to ~66–98 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.
|
||||
|
||||
@ -1,7 +1,18 @@
|
||||
{
|
||||
"id": "backyard_01",
|
||||
"name": "The Backyard",
|
||||
"blurb": "Your own place. You know where everything is, which is the only advantage you get.",
|
||||
"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."
|
||||
],
|
||||
|
||||
"yard": { "width": 30, "depth": 20 },
|
||||
|
||||
|
||||
@ -84,6 +84,26 @@ 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;
|
||||
@ -355,7 +375,7 @@ export function createHud(d) {
|
||||
* @param {{key:string, def:object}[]} storms
|
||||
* @param {(key:string) => void} onPick
|
||||
*/
|
||||
showForecast({ key, def, site }, wk, onGo) {
|
||||
showForecast({ key, def, site, tomorrowDef }, 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
|
||||
@ -381,15 +401,64 @@ showForecast({ key, def, site }, wk, onGo) {
|
||||
? `<div class="stat" style="color:#7ee0ff">${site.name}${site.blurb ? ` — ${site.blurb}` : ''}</div>`
|
||||
: '';
|
||||
|
||||
card.innerHTML = `<div class="card">
|
||||
// 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}
|
||||
<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>` : ''}
|
||||
<div class="row" style="margin-top:14px"><span>in the bank</span><b>$${wk.bank}</b></div>
|
||||
${tomorrow}
|
||||
${schedule}
|
||||
<div class="row" style="margin-top:10px"><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.'
|
||||
@ -464,22 +533,40 @@ showForecast({ key, def, site }, 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 ? '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>${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>gear recovered</span><b>+$${w.refund}</b></div>
|
||||
${w.collateral ? `<div class="row bad"><span>collateral</span><b>−$${w.collateral}</b></div>` : ''}
|
||||
${collateralRows}
|
||||
<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>` : '';
|
||||
@ -489,7 +576,8 @@ showForecast({ key, def, site }, wk, onGo) {
|
||||
: w.outcome === 'win' ? 'SEE THE WEEK'
|
||||
: 'SEE THE DAMAGE';
|
||||
|
||||
card.innerHTML = `<div class="card">
|
||||
card.innerHTML = `<div class="card jobcard">
|
||||
${w?.client ? `<div class="letterhead">${w.client}<span class="inv">invoice</span></div>` : ''}
|
||||
<h1>MORNING${w ? ` · NIGHT ${w.night} OF 5` : ''}</h1>
|
||||
<h2>${r.subtitle}</h2>
|
||||
${rows}
|
||||
|
||||
@ -728,9 +728,19 @@ 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] },
|
||||
{ night: week.night, nights: week.nights, bank: week.bank, log: week.log },
|
||||
{ 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,
|
||||
},
|
||||
() => { stormKey = week.stormKey; game.setPhase('prep'); },
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 } from '../week.js';
|
||||
import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY } from '../week.js';
|
||||
import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js';
|
||||
|
||||
/** @param {import('../testkit.js').Suite} t */
|
||||
@ -86,12 +86,88 @@ export default async function run(t) {
|
||||
);
|
||||
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');
|
||||
// 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.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);
|
||||
|
||||
@ -30,19 +30,78 @@ 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' },
|
||||
{ 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' },
|
||||
{
|
||||
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.',
|
||||
},
|
||||
];
|
||||
|
||||
/** A night entry, either shape → {storm, site}. */
|
||||
/**
|
||||
* 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}}
|
||||
*/
|
||||
export function nightAt(i) {
|
||||
const n = NIGHTS[i];
|
||||
return typeof n === 'string' ? { storm: n, site: 'backyard_01' } : n;
|
||||
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 ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@ -84,6 +143,36 @@ 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,
|
||||
};
|
||||
|
||||
/**
|
||||
@ -169,6 +258,30 @@ 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; },
|
||||
@ -191,14 +304,25 @@ export function createWeek(opts = {}) {
|
||||
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);
|
||||
// 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);
|
||||
|
||||
// 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;
|
||||
|
||||
// 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;
|
||||
|
||||
bank = Math.max(0, bankBefore - spent + pay);
|
||||
|
||||
@ -220,9 +344,10 @@ export function createWeek(opts = {}) {
|
||||
else outcome = 'continue';
|
||||
done = outcome !== 'continue';
|
||||
|
||||
const s = { fee, fullFee, bonus, refund, collateral, pay, spent,
|
||||
const s = { fee, fullFee, bonus, refund, clean, cleanMax, collateral, pay, spent,
|
||||
bankBefore, bankAfter: bank, outcome, night: week.night, held,
|
||||
grade: gradeFor(held), stormKey: week.stormKey, won: !!run.win };
|
||||
grade: gradeFor(held), stormKey: week.stormKey, won: !!run.win,
|
||||
client: job.client, site: job.site };
|
||||
log.push(s);
|
||||
return s;
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user