Paste E's letterhead/invoice kit into hud.js; rule HARD YARDS + sequential ABN

Gate 3.1: the CUT block from tools/jobsheet verbatim — letterhead, billto,
brief, sched, .void, AMOUNT DUE, terms. Docket numbers shared across the
pair (JOB/INV 1040+night); week.js nights carry an addr. Cards scroll when
taller than the window (safe center + overflow). Both rulings adopted and
pinned by DOM asserts; selftest 317/0/0.
This commit is contained in:
type-two 2026-07-17 20:27:18 +10:00
parent 8decac0626
commit 3d89ce8fe8
4 changed files with 368 additions and 38 deletions

View File

@ -3966,3 +3966,46 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
· **gate 3 — John plays the week. FIVE sprints standing, and for the first time the excuse is · **gate 3 — John plays the week. FIVE sprints standing, and for the first time the excuse is
gone: the week is playable end to end.** §BROKE_BELOW watch: if the bank runs away, A's gone: the week is playable end to end.** §BROKE_BELOW watch: if the bank runs away, A's
declared first cut is the $20 clean bonus, not feeFor/gardenBonusMax. declared first cut is the $20 clean bonus, not feeFor/gardenBonusMax.
[A] 2026-07-17 — 📄 **E's letterhead/invoice kit is PASTED (SPRINT12 gate 3.1), and both rulings are made.**
The CUT block went into hud.js verbatim — CSS and markup shape both — replacing my Sprint 11
placeholder rules that were holding the seat for exactly this. `.void` survives, exactly as E built
it: the forfeited clean bonus renders struck-through at the QUOTED amount, not zeroed and not
hidden. Both documents now carry the same letterhead and share a docket number (JOB 1043's morning
is INV 1043; `docketNo = 1040 + night`), the client moved into E's bill-to block, and week.js grew
an `addr` per night ("14 Kurrajong St — the backyard" / "2 Bight Rd — the corner block") because a
letterhead bills somebody somewhere. AMOUNT DUE landed with it: pay spent, 20px, red when
negative — the invoice finally has a conclusion, and the bank line under it stays because "what the
night was worth" and "where that leaves you" are different facts. Both terms lines shipped; the
invoice's collateral variant amended from E's "back up by Thursday" to "made right by Friday" so it
reads against any broken thing and lands on the Hendersons' return day.
**RULING — the masthead is HARD YARDS, adopted as proposed.** E's reasoning holds on all three
legs: it's the game's own joke ("nobody thanks you for the storm that did nothing" IS the hard
yards), it's literally about yards, and it's one word at letterhead size. Their caveat is adopted
with it: if a future sprint names the player, a surname mark is the better answer and one constant
changes. Not renaming the outfit in a juice pass either.
**RULING — the ABN stays sequential, and this is now pinned by an assert.** Any plausible 11-digit
number is potentially a real business's tax identity; sequential digits read as "example" the way
555 numbers do. Deliberate, not lazy — a.test now FAILS if someone "fixes" it realistic.
Two things the paste taught, both logged as warnings elsewhere and both ignored by me anyway:
· **The card CSS is a template literal and I put backticks in a comment** ("engines that don't
know `safe`"). node --check PASSED — the stray pair re-paired with a later literal into
accidentally-valid JS — and the browser's import of hud.js died on "Unexpected identifier
'safe'", which took boot down with it. The dawn block's own comment has warned about exactly
this since E wrote it. Caught by the browser, not the linter; `node -e "import(...)"` is the
honest check and --check is not.
· **E's kit made the tall cards taller than a short window** — at 800×450 the RIG IT button was
below the fold of an unscrollable overlay, a soft-lock with good typography. #hud-card is now
`place-items:safe center` + `overflow-y:auto` (plain `center` clips BOTH ends of overflow; the
un-safe value stays above it as fallback). Seen in the pane, invisible to every assert.
Verified by LOOKING, per the standing rule: job sheet night 1 and the carport invoice both rendered
in the browser against E's four preview PNGs — letterhead, bill-to, struck $20, red $130 due,
terms lines, all there. Plus two new a.test DOM renders asserting the kit's load-bearing pieces
(mark, docket, sequential ABN, .void at quoted amount WITH line-through actually drawing, due
arithmetic, neg class) — because both of last sprint's card bugs were invisible to a suite that
never rendered a card. hud.dispose() also stops leaking the dawn div. selftest **317/0/0**
(315 + the two card renders).

View File

@ -20,6 +20,33 @@ import { forecastLines, leadFor } from './weather.js';
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const kmh = (ms) => ms * 3.6; const kmh = (ms) => ms * 3.6;
/**
* SPRINT12 gate 3 the letterhead, and the two rulings E left with it
* (tools/jobsheet/index.html; full reasoning in THREADS [A]).
*
* RULING 1 the masthead is HARD YARDS, adopted as proposed. It's the joke the
* game already tells ("nobody thanks you for the storm that did nothing") said
* as a business name, it's literally about yards, and it's one mark at
* letterhead size. E's own caveat stands with it: the day the player gets a
* name, a surname mark is the better answer, and this constant is the only
* thing that changes.
*
* RULING 2 the ABN stays sequential, exactly as E shipped it. Any plausible
* 11-digit number is potentially a REAL business's tax identity, and we are not
* printing a stranger's ABN on a game card. Sequential digits read as "example"
* the way 555 phone numbers do deliberate, not lazy. DO NOT "fix" this into
* something realistic; that is the bug, not the polish.
*/
const BUSINESS = { mark: 'HARD YARDS', trade: 'landscaping · shade · drainage' };
const ABN = 'ABN 12 345 678 901';
/**
* Docket numbers. 1040 + night: a running count off a small outfit's pad, and
* the job sheet and its invoice share the number JOB 1043's morning is
* INV 1043, which is how the two documents answer each other line for line.
*/
const docketNo = (night) => 1040 + night;
// Load bar colours: green → amber → red. The amber band is deliberately wide; // Load bar colours: green → amber → red. The amber band is deliberately wide;
// a corner at 60% in a lull is one gust from 100% and the player should feel // a corner at 60% in a lull is one gust from 100% and the player should feel
// that before the bar turns red on them. // that before the bar turns red on them.
@ -49,11 +76,19 @@ const CSS = `
color:#93a6b2; opacity:.85; white-space:nowrap; } color:#93a6b2; opacity:.85; white-space:nowrap; }
#hud-card { position:fixed; inset:0; z-index:30; display:none; place-items:center; #hud-card { position:fixed; inset:0; z-index:30; display:none; place-items:center;
/* SPRINT12: E's letterhead + brief + conditions made the tall cards taller
than a short window, and a RIG IT button below the fold with no scroll is a
soft-lock with good typography. 'safe center' keeps the top reachable when
the card overflows (plain 'center' clips BOTH ends); the value above is the
fallback for engines that don't know 'safe'. Seen, not reasoned: the button
was gone at 800x450. (And no backticks in this comment this whole CSS
block is a JS template literal, as the dawn block already warns.) */
place-items:safe center; overflow-y:auto;
background:#060a0dc4; backdrop-filter:blur(3px); background:#060a0dc4; backdrop-filter:blur(3px);
font:13px/1.7 ui-monospace,SFMono-Regular,Menlo,monospace; color:#dde5ea; } font:13px/1.7 ui-monospace,SFMono-Regular,Menlo,monospace; color:#dde5ea; }
#hud-card.on { display:grid; } #hud-card.on { display:grid; }
#hud-card .card { background:#0d1418; border:1px solid #2f3f4a; border-radius:9px; #hud-card .card { background:#0d1418; border:1px solid #2f3f4a; border-radius:9px;
padding:22px 26px; min-width:440px; max-width:620px; pointer-events:auto; } padding:22px 26px; margin:20px 0; min-width:440px; max-width:620px; pointer-events:auto; }
#hud-card h1 { margin:0 0 2px; font-size:19px; letter-spacing:.16em; color:#fff; } #hud-card h1 { margin:0 0 2px; font-size:19px; letter-spacing:.16em; color:#fff; }
#hud-card h2 { margin:0 0 16px; font-size:12px; font-weight:400; color:#8ba0ad; letter-spacing:.04em; } #hud-card h2 { margin:0 0 16px; font-size:12px; font-weight:400; color:#8ba0ad; letter-spacing:.04em; }
#hud-card .row { display:flex; justify-content:space-between; gap:18px; padding:3px 0; #hud-card .row { display:flex; justify-content:space-between; gap:18px; padding:3px 0;
@ -84,22 +119,126 @@ const CSS = `
#hud-card .ledger .row.total { margin-top:6px; padding-top:8px; border-top:1px solid #24343d; } #hud-card .ledger .row.total { margin-top:6px; padding-top:8px; border-top:1px solid #24343d; }
#hud-card .ledger .row.total b { color:#fff; } #hud-card .ledger .row.total b { color:#fff; }
/* SPRINT11 the job sheet and the invoice. STRUCTURE ONLY, deliberately: the /* SPRINT12 E's letterhead/invoice kit, pasted from tools/jobsheet/index.html
letterhead and the invoice styling are Lane E's this sprint (§gate 2), and (between the CUT markers, verbatim). Sprint 11's placeholder rules for
everything below is the plainest thing that reads correctly so E can restyle .letterhead/.brief are gone E designed against those class names and this
it without unpicking layout. The class names are the contract .letterhead, is the styling they were holding the seat for. E's design commentary kept:
.brief, .jobsheet, .tomorrow and E owns what they look like. */ the reasoning is why the rules look like this. */
#hud-card .letterhead { display:flex; justify-content:space-between; align-items:baseline;
margin:-4px 0 10px; padding-bottom:8px; border-bottom:1px solid #24343d; /* --- the letterhead ----------------------------------------------------
font-size:12px; letter-spacing:.18em; text-transform:uppercase; color:#7ee0ff; } One business, two documents. This is the whole trick of the sprint: the
#hud-card .letterhead .inv { color:#8ba0ad; letter-spacing:.1em; } job sheet and the invoice carry the SAME letterhead, so they read as a
#hud-card .brief { margin:10px 0 12px; padding:9px 11px; border-left:2px solid #2f3f4a; pair of papers off the same outfit's printer rather than as two game
background:#101a20; color:#c3d2da; font-size:13px; line-height:1.55; font-style:italic; } screens. The player's business is the constant; the client changes every
#hud-card .jobsheet { margin-top:12px; padding-top:10px; border-top:1px solid #24343d; } night. That's the framing DESIGN.md opens with "you run a small
#hud-card .jobsheet .row b { color:#a8f0b8; } landscaping outfit" finally showing up on the glass.
#hud-card .jobsheet .row.total { margin-top:6px; padding-top:8px; border-top:1px solid #24343d; }
#hud-card .jobsheet .row.total b { color:#fff; } Sits above the existing .card content, inside the same .card box. */
/* Tomorrow is a rumour, and it should look like one next to tonight's numbers. */ #hud-card .letterhead {
display: flex; align-items: baseline; justify-content: space-between;
gap: 16px; margin: -4px 0 14px; padding-bottom: 10px;
border-bottom: 2px solid #2f3f4a;
}
#hud-card .letterhead .mark {
font-weight: 800; font-size: 15px; letter-spacing: .22em; color: #fff;
white-space: nowrap;
}
/* The trade line does the work a logo would. Lowercase and spaced out so it
reads as printed rule, not as a heading competing with the mark. */
#hud-card .letterhead .trade {
font-size: 10px; letter-spacing: .13em; color: #6d818e; margin-top: 3px;
}
#hud-card .letterhead .docket { text-align: right; white-space: nowrap; }
#hud-card .letterhead .docket .kind {
font-weight: 700; font-size: 11px; letter-spacing: .18em; color: #8ba0ad;
}
#hud-card .letterhead .docket .no { font-size: 10px; color: #55677a; letter-spacing: .06em; }
/* --- bill-to / job-for block -------------------------------------------
Deliberately the same component on both documents. On the job sheet it's
who you're working for; on the invoice it's who's paying. Same box, and
the fact that it doesn't move between the two is the joke landing. */
#hud-card .billto { margin: 0 0 12px; }
#hud-card .billto .who { font-weight: 700; color: #fff; letter-spacing: .04em; }
#hud-card .billto .addr { font-size: 11px; color: #6d818e; }
/* --- the brief ---------------------------------------------------------
The client's own words. Italic and rule-marked because it is the one bit
of text on the card that isn't the game talking to the player — it's a
person who wants their seedlings alive. It should feel quoted, and it
should be the thing the player's eye lands on after the letterhead,
because it is the only place the stakes are ever said out loud. */
#hud-card .brief {
margin: 0 0 14px; padding: 9px 0 9px 12px;
border-left: 2px solid #3f5561;
color: #c3d0d9; font-style: italic; line-height: 1.6;
}
#hud-card .brief .attrib {
display: block; margin-top: 5px; font-style: normal;
font-size: 10px; letter-spacing: .1em; color: #55677a;
}
/* --- section rules ------------------------------------------------------ */
#hud-card .sect {
margin: 16px 0 7px; font-size: 10px; font-weight: 700; letter-spacing: .18em;
color: #55677a; border-bottom: 1px solid #1c262d; padding-bottom: 5px;
}
/* --- the pay schedule (job sheet) --------------------------------------
This is a QUOTE, not a receipt: it's what the night is worth if you do it
right. It reuses your .row shape so it lines up with everything else, but
the money is dimmed these are amounts you have NOT earned yet, and they
should not read with the same confidence as the bank line. The conditions
are the point; the numbers are just what the conditions are worth. */
#hud-card .sched .row b { color: #9fb3bf; font-weight: 700; }
#hud-card .sched .cond {
font-size: 10px; color: #55677a; letter-spacing: .02em;
padding: 0 0 5px; border-bottom: 1px solid #1c262d; margin-top: -2px;
}
#hud-card .sched .row.headline b { color: #fff; }
#hud-card .sched .row.max { border-bottom: 0; margin-top: 6px; padding-top: 7px;
border-top: 1px solid #24343d; }
#hud-card .sched .row.max span { color: #8ba0ad; }
#hud-card .sched .row.max b { color: #ffd9a3; }
/* --- the invoice -------------------------------------------------------
Your .ledger already IS an invoice; it just never said so. These rules sit
ON TOP of it paste them and .ledger keeps every behaviour it has, it
just gains a rule above the total and a due line under it.
.earned/.bad you already colour. The one thing I've added is .void: a
bonus that was on the job sheet and did NOT survive the night. Struck
through, not hidden. The player must see the money they didn't get sitting
in the same column as the money they did a bonus you never see fail is a
bonus you never learn to protect. This is the single highest-value line in
the whole handover, and it's four CSS declarations. */
#hud-card .ledger .row.void span { color: #55677a; }
#hud-card .ledger .row.void b {
color: #55677a; font-weight: 400; text-decoration: line-through;
text-decoration-color: #7d2b2b; text-decoration-thickness: 1px;
}
/* The amount due. An invoice's whole rhetorical shape is that everything
above is argument and this is the conclusion. Give it the weight. */
#hud-card .ledger .row.due {
margin-top: 8px; padding-top: 9px; border-top: 2px solid #2f3f4a; border-bottom: 0;
align-items: baseline;
}
#hud-card .ledger .row.due span { color: #8ba0ad; letter-spacing: .12em; font-weight: 700;
font-size: 11px; }
#hud-card .ledger .row.due b { color: #fff; font-size: 20px; letter-spacing: .02em; }
#hud-card .ledger .row.due.neg b { color: #ff8f86; }
/* The terms line. Pure flavour, and the cheapest joke on the card: a real
invoice's most boring sentence, printed under a night that nearly killed
you. Left in as its own class so you can delete it in one line if it
doesn't land. */
#hud-card .terms {
margin-top: 10px; font-size: 10px; color: #45566a; letter-spacing: .04em;
}
/* ---- end of E's kit ----------------------------------------------------- */
/* Tomorrow is a rumour, and it should look like one next to tonight's numbers.
(Mine, not E's — the kit predates the tomorrow line and doesn't restyle it.) */
#hud-card .tomorrow { display:flex; gap:10px; align-items:baseline; margin-top:9px; #hud-card .tomorrow { display:flex; gap:10px; align-items:baseline; margin-top:9px;
font-size:12px; color:#6d8494; } font-size:12px; color:#6d8494; }
#hud-card .tomorrow .tlabel { letter-spacing:.14em; text-transform:uppercase; color:#55707f; } #hud-card .tomorrow .tlabel { letter-spacing:.14em; text-transform:uppercase; color:#55707f; }
@ -406,11 +545,28 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
// else. The client and the brief are data (week.js NIGHTS), so an // 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 // unbriefed night degrades to the old storm card rather than an empty
// letterhead — which is also what keeps every existing test honest. // letterhead — which is also what keeps every existing test honest.
//
// SPRINT12 — the letterhead is E's kit now: the outfit's mark on the
// left, the docket on the right, the client demoted to a bill-to block —
// because the business is the constant and the client changes nightly.
const job = wk.job ?? {}; const job = wk.job ?? {};
const letterhead = job.client const letterhead = job.client ? `
? `<div class="letterhead">${job.client}</div>` : ''; <div class="letterhead">
<div>
<div class="mark">${BUSINESS.mark}</div>
<div class="trade">${BUSINESS.trade}</div>
</div>
<div class="docket">
<div class="kind">JOB SHEET</div>
<div class="no">JOB ${docketNo(wk.night)} · ${ABN}</div>
</div>
</div>
<div class="billto">
<div class="who">${job.client}</div>
${job.addr ? `<div class="addr">${job.addr}</div>` : ''}
</div>` : '';
const brief = job.brief const brief = job.brief
? `<p class="brief">${job.brief}</p>` : ''; ? `<div class="brief">${job.brief}</div>` : '';
// BUDGET Y — and this is the actual feature, not the flavour. The fee has // 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 // existed since Sprint 8 and the player has never seen it until the money
@ -418,13 +574,22 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
// was worth, which is a reveal, not a decision. Quoting the maxima up front // 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 // makes the shop a judgement ("is $45 of garden bonus worth a $30
// shackle?") instead of a guess. // shackle?") instead of a guess.
// E's schedule shape: each amount carries its CONDITION underneath,
// because the conditions are the point — the numbers are just what the
// conditions are worth. The garden's condition says "scales", honestly:
// E's preview copy read "bed above 80% at dawn" and the mechanic pays
// linearly, and a quote that misstates its own terms is a lie on paper.
const q = wk.quote; const q = wk.quote;
const schedule = q ? ` const schedule = q ? `
<div class="jobsheet"> <div class="sect">WHAT IT PAYS</div>
<div class="row"><span>base the job</span><b>$${q.base}</b></div> <div class="sched">
<div class="row"><span>garden bonus if the bed lives</span><b>up to $${q.garden}</b></div> <div class="row headline"><span>callout fee</span><b>$${q.base}</b></div>
<div class="row"><span>clean bonus nothing of theirs broken</span><b>$${q.clean}</b></div> <div class="cond">the night, rigged and stood up</div>
<div class="row total"><span>the job's worth</span><b>up to $${q.total}</b></div> <div class="row"><span>garden bonus</span><b>up to $${q.garden}</b></div>
<div class="cond">paid on what's left of the bed at dawn</div>
<div class="row"><span>clean site</span><b>$${q.clean}</b></div>
<div class="cond">nothing of theirs broken</div>
<div class="row max"><span>the night, done right</span><b>$${q.total}</b></div>
</div>` : ''; </div>` : '';
// Lane C's forecast lead (SPRINT11 gate 2): tomorrow, hedged. Their // Lane C's forecast lead (SPRINT11 gate 2): tomorrow, hedged. Their
@ -447,13 +612,18 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
<span class="tline">${tf.wind}${tf.confidence ? ` · ${tf.confidence}` : ''}</span> <span class="tline">${tf.wind}${tf.confidence ? ` · ${tf.confidence}` : ''}</span>
</div>` : ''; </div>` : '';
// E's document order: letterhead, who it's for, their words, THEN the
// night — the paper establishes whose job this is before it says what the
// weather will do about it. The terms line under the button is E's
// cheapest joke and their best: quoted on the forecast, weather is not a
// variation, printed above a night that may cost you a carport.
card.innerHTML = `<div class="card jobcard"> card.innerHTML = `<div class="card jobcard">
${letterhead} ${letterhead}
${brief}
<h1>NIGHT ${wk.night} OF ${wk.nights}</h1> <h1>NIGHT ${wk.night} OF ${wk.nights}</h1>
<div class="pips">${pips}</div> <div class="pips">${pips}</div>
<h2>${(f.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}</h2> <h2>${(f.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}</h2>
${siteLine} ${siteLine}
${brief}
<div class="stat">${f.wind}</div> <div class="stat">${f.wind}</div>
<div class="stat">${f.rain}${hail ? ' · hail forecast' : ''}</div> <div class="stat">${f.rain}${hail ? ' · hail forecast' : ''}</div>
${f.confidence ? `<div class="stat" style="color:#8ba0ad">${f.confidence}</div>` : ''} ${f.confidence ? `<div class="stat" style="color:#8ba0ad">${f.confidence}</div>` : ''}
@ -469,6 +639,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
A change means the corners that were slack all storm are the loaded ones after it. A change means the corners that were slack all storm are the loaded ones after it.
</div>` : ''} </div>` : ''}
<button class="go">RIG IT</button> <button class="go">RIG IT</button>
${job.client ? '<div class="terms">Quoted on the forecast at time of callout. Weather is not a variation.</div>' : ''}
</div>`; </div>`;
card.classList.add('on'); card.classList.add('on');
hud.setVisible(false); hud.setVisible(false);
@ -557,18 +728,30 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
? (r.collateral ?? []).map((c) => ` ? (r.collateral ?? []).map((c) => `
<div class="row bad"><span>${c.what}</span><b>$${c.cost}</b></div>`).join('') <div class="row bad"><span>${c.what}</span><b>$${c.cost}</b></div>`).join('')
: ''; : '';
// SPRINT12 — E's .void: the forfeited bonus is STRUCK THROUGH, not zeroed.
// The quoted $${cleanMax} stays on the paper with a line through it — the
// player must see the money they didn't get sitting in the same column as
// the money they did. A bonus you never see fail is a bonus you never
// learn to protect. (E's words; the single highest-value line in the kit.)
const cleanRow = w ? (w.clean const cleanRow = w ? (w.clean
? `<div class="row"><span>clean bonus — nothing of theirs broken</span><b>+$${w.clean}</b></div>` ? `<div class="row"><span>clean site</span><b>+$${w.clean}</b></div>`
: `<div class="row bad"><span>clean bonus — forfeited</span><b>$0</b></div>`) : ''; : `<div class="row void"><span>clean site</span><b>$${w.cleanMax ?? 0}</b></div>`) : '';
// AMOUNT DUE is what tonight's paper actually says: everything above it is
// argument, this is the conclusion. pay spent, so it goes negative the
// night you take the carport with you — and the bank line under it stays,
// because "what this night was worth" and "where that leaves you" are two
// different facts.
const due = w ? w.pay - w.spent : 0;
const money = w ? ` const money = w ? `
<div class="ledger"> <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>${w.won ? 'callout fee' : `callout 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 ${r.hp.toFixed(0)}% of the bed</span><b>+$${w.bonus}</b></div> <div class="row"><span>garden bonus ${r.hp.toFixed(0)}% of the bed</span><b>+$${w.bonus}</b></div>
${cleanRow} ${cleanRow}
<div class="row"><span>gear recovered</span><b>+$${w.refund}</b></div> <div class="row"><span>gear recovered</span><b>+$${w.refund}</b></div>
${collateralRows} ${collateralRows}
<div class="row"><span>spent on the rig</span><b>$${w.spent}</b></div> <div class="row bad"><span>spent on the rig</span><b>$${w.spent}</b></div>
<div class="row due ${due < 0 ? 'neg' : ''}"><span>AMOUNT DUE</span><b>${due < 0 ? '' : '+'}$${Math.abs(due)}</b></div>
<div class="row total"><span>in the bank</span><b>$${w.bankBefore} $${w.bankAfter}</b></div> <div class="row total"><span>in the bank</span><b>$${w.bankBefore} $${w.bankAfter}</b></div>
</div>` : ''; </div>` : '';
@ -577,14 +760,35 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
: w.outcome === 'win' ? 'SEE THE WEEK' : w.outcome === 'win' ? 'SEE THE WEEK'
: 'SEE THE DAMAGE'; : 'SEE THE DAMAGE';
// The same letterhead as the job sheet, same docket number, and the kind
// flips to TAX INVOICE — one outfit, two papers, one night apart. The
// terms line: E's flavour when your failure broke something of theirs,
// the world's most boring sentence otherwise.
const letterhead = w?.client ? `
<div class="letterhead">
<div>
<div class="mark">${BUSINESS.mark}</div>
<div class="trade">${BUSINESS.trade}</div>
</div>
<div class="docket">
<div class="kind">TAX INVOICE</div>
<div class="no">INV ${docketNo(w.night)} · ${ABN}</div>
</div>
</div>
<div class="billto">
<div class="who">${w.client}</div>
${w.addr ? `<div class="addr">${w.addr}</div>` : ''}
</div>` : '';
card.innerHTML = `<div class="card jobcard"> card.innerHTML = `<div class="card jobcard">
${w?.client ? `<div class="letterhead">${w.client}<span class="inv">invoice</span></div>` : ''} ${letterhead}
<h1>MORNING${w ? ` · NIGHT ${w.night} OF 5` : ''}</h1> <h1>MORNING${w ? ` · NIGHT ${w.night} OF 5` : ''}</h1>
<h2>${r.subtitle}</h2> <h2>${r.subtitle}</h2>
${rows} ${rows}
${money} ${money}
<div class="verdict ${r.win ? 'win' : 'lose'}">${r.verdict}</div> <div class="verdict ${r.win ? 'win' : 'lose'}">${r.verdict}</div>
<button class="go">${next}</button> <button class="go">${next}</button>
${w?.client ? `<div class="terms">Payable 14 days. ${w.collateral ? 'Theyll want that made right by Friday.' : 'Thank you for your business.'}</div>` : ''}
</div>`; </div>`;
card.classList.add('on'); card.classList.add('on');
hud.setVisible(false); hud.setVisible(false);
@ -600,7 +804,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
get cardOpen() { return card.classList.contains('on'); }, get cardOpen() { return card.classList.contains('on'); },
dispose() { dispose() {
root.remove(); card.remove(); style.remove(); root.remove(); card.remove(); style.remove(); dawn.remove();
d.scene.remove(barGroup); d.scene.remove(barGroup);
quad.dispose(); quad.dispose();
for (const b of bars) { for (const b of bars) {

View File

@ -12,6 +12,7 @@ import { CALM_STORM, createGame, createWindRouter, stormsToPreload, verdictFor }
import { orderRing } from '../sail.js'; import { orderRing } from '../sail.js';
import { loadStorm, createWind } from '../weather.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, PAY } from '../week.js';
import { createHud } from '../hud.js';
import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js'; import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js';
/** @param {import('../testkit.js').Suite} t */ /** @param {import('../testkit.js').Suite} t */
@ -124,6 +125,7 @@ export default async function run(t) {
for (let i = 0; i < NIGHTS.length; i++) { for (let i = 0; i < NIGHTS.length; i++) {
const j = nightAt(i); const j = nightAt(i);
assert(j.client, `night ${i + 1} has a client`); assert(j.client, `night ${i + 1} has a client`);
assert(j.addr, `night ${i + 1} has an address — a letterhead bills somebody somewhere`);
assert(j.brief && j.brief.length > 20, `night ${i + 1} has a brief worth reading`); assert(j.brief && j.brief.length > 20, `night ${i + 1} has a brief worth reading`);
assert(j.site, `night ${i + 1} has a yard`); assert(j.site, `night ${i + 1} has a yard`);
} }
@ -143,6 +145,7 @@ export default async function run(t) {
// or printing "undefined" at a client. // or printing "undefined" at a client.
const j = nightAt(99); // off the end entirely const j = nightAt(99); // off the end entirely
assertEq(j.client, null, 'no client is null, not undefined'); assertEq(j.client, null, 'no client is null, not undefined');
assertEq(j.addr, null, 'no address is null');
assertEq(j.brief, null, 'no brief is null'); assertEq(j.brief, null, 'no brief is null');
assertEq(j.site, 'backyard_01', 'and it still names a yard'); assertEq(j.site, 'backyard_01', 'and it still names a yard');
}); });
@ -264,6 +267,81 @@ export default async function run(t) {
assertEq(cb.work, 'bracket', 'the carport beam is a bracket job — needs the ladder'); assertEq(cb.work, 'bracket', 'the carport beam is a bracket job — needs the ladder');
}); });
// --- SPRINT12 gate 3: E's letterhead/invoice kit, rendered ----------------
// Both of Sprint 11's card bugs were invisible to the suite because nothing
// ever RENDERED a card and looked. These do the minimum of that in DOM: the
// kit's contract classes must exist on a real render AND their load-bearing
// CSS must actually draw. Eyes on the browser are still required — a DOM
// assert can't see a letterhead with buttons through it.
t.test('SPRINT12: the invoice wears the letterhead, and the dead bonus is struck through', () => {
if (typeof document === 'undefined') return 'SKIPPED — no DOM';
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
// Night 3, the carport night: collateral forfeits the clean bonus, so this
// one render exercises the void, the neg AMOUNT DUE, and the docket number.
const wk = createWeek();
wk.advance(); wk.advance();
const w = wk.settle({ hp: 86, win: true,
collateral: [{ what: 'the carport', cost: 180 }], intactHardwareValue: 12 }, def, 71);
const hud = createHud({ scene: new THREE.Scene() });
try {
hud.showAftermath({ hp: 86, cornersLost: 1, cornersTotal: 4, hailBlocked: 'some hail',
bill: 34, collateral: [{ what: 'the carport', cost: 180 }],
subtitle: 'test', verdict: 'test', win: true, week: w }, () => {});
const q = (sel) => document.querySelector(`#hud-card ${sel}`);
assertEq(q('.letterhead .mark')?.textContent, 'HARD YARDS', "the outfit's mark (ruling: adopted)");
const no = q('.letterhead .docket .no')?.textContent ?? '';
assert(no.includes('INV 1043'), `docket says INV 1043, got "${no}"`);
// The ruling is that the ABN STAYS sequential — it reads as "example" the
// way 555 numbers do, and a realistic one is a stranger's tax identity.
assert(no.includes('ABN 12 345 678 901'), `the sequential ABN survives, got "${no}"`);
assertEq(q('.billto .who')?.textContent, 'the Vasilaros place', 'the bill-to block names the client');
// E's .void — quoted, not paid, and still on the paper.
const voidB = q('.ledger .row.void b');
assert(voidB, 'the forfeited clean bonus renders as .void, not as $0 or missing');
assertEq(voidB.textContent, `$${w.cleanMax}`, 'struck at the QUOTED amount');
assert(getComputedStyle(voidB).textDecorationLine.includes('line-through'),
'and the strike actually draws — the class without its CSS is decoration');
// AMOUNT DUE is the conclusion: pay spent, negative the night you take
// the carport with you, and it must SAY so.
const due = w.pay - 71;
const dueB = q('.ledger .row.due b');
assert(dueB, 'the invoice concludes with AMOUNT DUE');
assertEq(dueB.textContent, `${due < 0 ? '' : '+'}$${Math.abs(due)}`, 'due = pay spent');
assertEq(q('.ledger .row.due')?.classList.contains('neg'), due < 0, 'a negative due reads as one');
} finally { hud.dispose(); }
});
t.test('SPRINT12: the job sheet carries the same letterhead, a quote, and the terms line', () => {
if (typeof document === 'undefined') return 'SKIPPED — no DOM';
// The same outfit, the same night, the other document: JOB 1043 in the
// morning becomes INV 1043 — the pair answering each other is the design.
const wk = createWeek();
wk.advance(); wk.advance();
const hud = createHud({ scene: new THREE.Scene() });
try {
hud.showForecast(
{ key: 'storm_03b_earlybuster', def: earlyBusterDef, site: null, tomorrowDef: null },
{ night: wk.night, nights: wk.nights, log: [], job: wk.job,
quote: wk.quote(earlyBusterDef), bank: wk.bank }, () => {});
const q = (sel) => document.querySelector(`#hud-card ${sel}`);
assertEq(q('.letterhead .mark')?.textContent, 'HARD YARDS', 'same mark as the invoice');
assertEq(q('.letterhead .docket .kind')?.textContent, 'JOB SHEET', 'but this paper is the quote');
assert((q('.letterhead .docket .no')?.textContent ?? '').includes('JOB 1043'),
'and it shares the docket number with its invoice');
assert(q('.billto .addr'), 'the bill-to block says where the job is');
assertEq(document.querySelectorAll('#hud-card .sched .cond').length, 3,
'three pay lines, each carrying its condition');
assert((q('.terms')?.textContent ?? '').includes('not a variation'),
"the terms line: quoted on the forecast, weather is not a variation");
} finally { hud.dispose(); }
});
// --- SPRINT11 gate 1: the three rulings, each pinned ---------------------- // --- SPRINT11 gate 1: the three rulings, each pinned ----------------------
t.test('ruling: the carport is typed as a carport, not smuggled in as a post', () => { t.test('ruling: the carport is typed as a carport, not smuggled in as a post', () => {

View File

@ -48,34 +48,38 @@ import { START_BUDGET, HARDWARE } from './contracts.js';
* Copy lives here as data, per SPRINT11's rule: client flavour is data, not code. * Copy lives here as data, per SPRINT11's rule: client flavour is data, not code.
*/ */
export const NIGHTS = [ export const NIGHTS = [
// SPRINT12: `addr` is the second line of E's bill-to block — a letterhead
// bills somebody somewhere. Street names are the site's fiction (E's kit
// drafted both), and the addr carries the yard so the invoice, which has no
// site line of its own, still says where the night happened.
{ {
storm: 'storm_01_gentle', site: 'backyard_01', storm: 'storm_01_gentle', site: 'backyard_01',
client: 'the Hendersons', client: 'the Hendersons', addr: '14 Kurrajong St — the backyard',
brief: 'They fly out Friday and the seedlings are the whole point — six weeks in, first season that ' 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.', + 'took. Get the sail up while it\'s calm. Nothing tonight will test it.',
}, },
{ {
storm: 'storm_03_southerly', site: 'backyard_01', storm: 'storm_03_southerly', site: 'backyard_01',
client: 'the Hendersons', client: 'the Hendersons', addr: '14 Kurrajong St — the backyard',
brief: 'A southerly, and hail with it. This is the one the seedlings can\'t take — cloth over the bed ' 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.', + 'or there\'s no bed on Monday.',
}, },
{ {
storm: 'storm_03b_earlybuster', site: 'site_02_corner_block', storm: 'storm_03b_earlybuster', site: 'site_02_corner_block',
client: 'the Vasilaros place', client: 'the Vasilaros place', addr: '2 Bight Rd — the corner block',
brief: 'Short notice — corner block two streets over, never rigged it. Same southerly as Tuesday, ' 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 ' + 'except the change is through before you\'ve finished your coffee. They said there\'s plenty '
+ 'to tie off to.', + 'to tie off to.',
}, },
{ {
storm: 'storm_02_wildnight', site: 'backyard_01', storm: 'storm_02_wildnight', site: 'backyard_01',
client: 'the Hendersons', 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 ' 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.', + 'they\'re watching, because Friday they will be.',
}, },
{ {
storm: 'storm_02b_icenight', site: 'backyard_01', storm: 'storm_02b_icenight', site: 'backyard_01',
client: 'the Hendersons', 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. ' 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.', + 'Last night before they land.',
}, },
@ -99,6 +103,7 @@ export function nightAt(i) {
storm: base.storm, storm: base.storm,
site: base.site ?? 'backyard_01', site: base.site ?? 'backyard_01',
client: base.client ?? null, client: base.client ?? null,
addr: base.addr ?? null,
brief: base.brief ?? null, brief: base.brief ?? null,
pay: base.pay ?? {}, pay: base.pay ?? {},
}; };
@ -347,7 +352,7 @@ export function createWeek(opts = {}) {
const s = { fee, fullFee, bonus, refund, clean, cleanMax, collateral, pay, spent, const s = { fee, fullFee, bonus, refund, clean, cleanMax, collateral, pay, spent,
bankBefore, bankAfter: bank, outcome, night: week.night, held, 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 }; client: job.client, addr: job.addr, site: job.site };
log.push(s); log.push(s);
return s; return s;
}, },