HardYards/web/world/js/hud.js
type-two dd69a2cf6c Lane A S17 gates 0+1+2: THE JOB BOARD — two offers a morning, the chosen job IS the night
Gate 0 rulings (written, with reasoning, in THREADS):
- 0.4 hail-light balance: CANON. B's finding reproduces exactly ($4/$2 garden
  spread), but the mild-night rig market buys the STEEL and the collateral, not
  the bed — a cheap rig that fails on site_02 swings $184 and two stars against
  the same rig holding, and the cheapest HOLDING line is $40, not the $20 floor.
  Ruled for B's option (b): the separation block asks the wrong question on
  those nights; the new shape is B's and is NOT this sprint. Rain drain-weight
  lever declined at its proper price (full gauntlet re-measure, own gate).
- 0.4b gradeFor scraped/solvent >=3 STANDS on the seven-night shape, ruled on
  the arithmetic (savable 6 → solvent is 4-6 dead, which is what the copy says).
- 0.3 score-header wording consulted for B (no code from me).

Gate 1 — the board: board.js (pure data, node-testable), week.offers()/take(),
seeded from week+night via contracts' mulberry32 — no Date.now, no Math.random.
An offer carries a whole NIGHT ENTRY and take() installs it, so sheet, quote,
invoice, ledger, warranty, rep and end card all read a chosen job through the
path they already had — seven surfaces, zero changed lines. Scripted spine runs
byte-identically when nothing is taken.

Gate 2 (data half) — constraints as night data, checked enum, premium ruled
(+25% anchor ban / +20% budget cap, both UNMEASURED and flagged cut-first),
stated on the offer, the job sheet and the invoice it was paid under. ONE
calloutFee() now serves quote() and settle(). Enforcement is B's.

Two bugs found off the suite: take() stored raw pool entries (crashed quote()
on a missing pay block) → normaliseNight() split so both doors share it; and
NIGHT 1 HAD NO BOARD — the splash bypassed it — found by looking, not testing.
Pool pairings audited in the browser before shipping; both constraints checked
for soft-lock against real holding lines.

Mutation-checked ×6. One did NOT go red: "every pool storm is preloaded" passed
with the POOL source deleted, because every pool storm is also in NIGHTS — the
same coincidence that hid the calm-day bug. stormsToPreload(nights, pool) split
so it can fail. Selftest 485/0/0 (474 + 11).
2026-07-21 02:11:16 +10:00

1301 lines
67 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* SHADES — the face. Lane A owns this file.
*
* Everything the game already simulates is invisible to a stranger without this:
* loads, the gust warning, whether the garden is actually getting hit. So the
* rule here is that the HUD only ever READS. It takes no decisions, owns no
* state worth keeping, and if you deleted it the sim would run identically —
* which is also why it can be this chatty without anything drifting.
*
* Two things earn their place as world-anchored rather than screen-anchored:
* the corner load bars (a number in a corner of the screen can't tell you WHICH
* shackle is about to go, and that's the whole "…the shackle, I knew about the
* shackle" moment) and the repair prompt, which Lane D already owns.
*/
import * as THREE from '../vendor/three.module.js';
import { STORM_LEN } from './contracts.js';
import { forecastLines, leadFor } from './weather.js';
// SPRINT16 — the end card compares the final rep against neutral. A constant,
// not state: the HUD still only READS. week.js has no DOM and no THREE, so
// this import costs nothing it didn't already cost main.js.
import { NIGHTS, REP } from './week.js';
// SPRINT16 gate 4 (Lane D, flagged for A): the week is data — NIGHTS.length —
// and these surfaces used to hardcode "five". A six-night week whose splash,
// morning header and end card still said 5 would be the paper lying, so every
// count below reads the ladder. The word map keeps the splash's prose voice;
// an unmapped length falls back to the digit, which is honest if inelegant.
const NIGHT_COUNT = NIGHTS.length;
const NIGHT_WORD = { 5: 'Five', 6: 'Six', 7: 'Seven' }[NIGHT_COUNT] ?? String(NIGHT_COUNT);
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const kmh = (ms) => ms * 3.6;
/**
* 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;
/**
* SPRINT16 gate 1 — the letterhead's number, formatted once. Stars, one
* decimal (week.js REP moves in half-steps, so the decimal is always honest).
* It prints under the ABN on job sheet AND invoice — E's letterhead contract:
* the business is the constant, and this is the one number the business has.
*/
const repFmt = (r) => `${Number(r).toFixed(1)}`;
// 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
// that before the bar turns red on them.
const BAR_OK = 0x6ee06e, BAR_WARN = 0xffc24a, BAR_HOT = 0xff5b4a, BAR_DEAD = 0x7a2f2f;
const CSS = `
#hud { position:fixed; inset:0; pointer-events:none; z-index:10;
font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; color:#dde5ea;
text-shadow:0 1px 3px #000a; user-select:none; }
#hud .panel { position:absolute; background:#0d1418d9; border:1px solid #2c3a44;
border-radius:6px; padding:8px 10px; }
#hud-top { top:10px; left:50%; transform:translateX(-50%); text-align:center; min-width:280px; }
#hud-wind { font-size:15px; font-weight:700; letter-spacing:.04em; }
#hud-clock { color:#8ba0ad; }
#hud-gust { position:absolute; top:78px; left:50%; transform:translateX(-50%);
font:700 20px/1 ui-monospace,Menlo,monospace; letter-spacing:.16em; color:#ffd27a;
background:#3a2a0ce0; border:1px solid #7a5a1a; border-radius:5px; padding:8px 16px;
opacity:0; transition:opacity .12s; }
#hud-gust.on { opacity:1; }
#hud-garden { bottom:12px; left:12px; min-width:210px; }
#hud-bar { height:9px; background:#00000066; border-radius:5px; overflow:hidden; margin-top:5px; }
#hud-bar i { display:block; height:100%; width:100%; background:#6ee06e; transition:width .2s,background .3s; }
#hud-carry { bottom:12px; right:12px; text-align:right; }
#hud-events { position:absolute; bottom:70px; left:12px; max-width:420px; }
#hud-events div { color:#ffd27a; margin-top:2px; }
#hud-help { position:absolute; bottom:12px; left:50%; transform:translateX(-50%);
color:#93a6b2; opacity:.85; white-space:nowrap; }
/* SPRINT13 gate 3 — pause and mute.
The veil sits UNDER #hud-card (z 30) so a card always wins: pausing can never
hide the job sheet or the invoice. It is a veil and not a card because the
yard behind it is the point — you paused to LOOK at something. */
#hud-pause { position:fixed; inset:0; z-index:20; display:none; place-items:center;
background:#060a0d99; backdrop-filter:blur(2px); pointer-events:none; }
#hud-pause.on { display:grid; }
#hud-pause .word { font:700 34px/1 ui-monospace,Menlo,monospace; letter-spacing:.34em;
color:#dde5ea; text-indent:.34em; }
#hud-pause .sub { margin-top:10px; text-align:center; color:#8ba0ad; letter-spacing:.1em; }
/* Muted has to be visible from anywhere or the player thinks the sound broke. */
#hud-mute { position:absolute; top:12px; right:12px; display:none;
padding:4px 9px; border:1px solid #3a4a56; border-radius:4px;
color:#8ba0ad; letter-spacing:.16em; font-size:11px; }
#hud-mute.on { display:block; }
#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);
font:13px/1.7 ui-monospace,SFMono-Regular,Menlo,monospace; color:#dde5ea; }
#hud-card.on { display:grid; }
#hud-card .card { background:#0d1418; border:1px solid #2f3f4a; border-radius:9px;
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 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;
border-bottom:1px solid #1c262d; }
#hud-card .row b { font-weight:700; color:#fff; }
#hud-card .storm { display:block; width:100%; text-align:left; margin:7px 0; padding:10px 12px;
background:#121c23; border:1px solid #2f3f4a; border-radius:6px; color:#dde5ea; cursor:pointer;
font:inherit; transition:border-color .15s,background .15s; }
#hud-card .storm:hover { border-color:#7ee0ff; background:#16242c; }
#hud-card .storm .name { font-weight:700; color:#fff; letter-spacing:.08em; }
#hud-card .storm .stat { color:#8ba0ad; }
#hud-card .go { margin-top:16px; padding:9px 18px; background:#1d3d2a; border:1px solid #3f7a52;
border-radius:6px; color:#a8f0b8; cursor:pointer; font:inherit; font-weight:700; letter-spacing:.1em; }
#hud-card .go:hover { background:#255033; }
#hud-card .verdict { margin:14px 0 0; padding:10px 12px; border-radius:6px; font-weight:700;
letter-spacing:.05em; }
#hud-card .verdict.win { background:#12321c; border:1px solid #2c6b3c; color:#7fce6a; }
#hud-card .verdict.lose { background:#3a1618; border:1px solid #7d2b2b; color:#ff8f86; }
/* SPRINT16 gate 2.2 [B] — the fabric sentence under the verdict, quieter than
the verdict itself: it is a footnote about the bet, not a second ruling.
Sibling of .verdict (same pattern as the repline), so the verdict div and
its a.test-pinned prose stay byte-untouched. */
#hud-card .fabricnote { margin:8px 0 0; font-size:11px; color:#8ba0ad; letter-spacing:.04em; }
/* --- the week ---------------------------------------------------------- */
#hud-card .pips { letter-spacing:.5em; font-size:15px; margin:-6px 0 10px; color:#3f5561; }
#hud-card .pip.now { color:#7ee0ff; }
#hud-card .pip.held { color:#7fce6a; }
#hud-card .pip.lost { color:#ff8f86; }
#hud-card .ledger { margin-top:14px; padding-top:10px; border-top:1px solid #24343d; }
#hud-card .ledger .row b { color:#a8f0b8; }
#hud-card .ledger .row.bad b { color:#ff8f86; }
#hud-card .ledger .row.total { margin-top:6px; padding-top:8px; border-top:1px solid #24343d; }
#hud-card .ledger .row.total b { color:#fff; }
/* SPRINT12 — E's letterhead/invoice kit, pasted from tools/jobsheet/index.html
(between the CUT markers, verbatim). Sprint 11's placeholder rules for
.letterhead/.brief are gone — E designed against those class names and this
is the styling they were holding the seat for. E's design commentary kept:
the reasoning is why the rules look like this. */
/* --- the letterhead ----------------------------------------------------
One business, two documents. This is the whole trick of the sprint: the
job sheet and the invoice carry the SAME letterhead, so they read as a
pair of papers off the same outfit's printer rather than as two game
screens. The player's business is the constant; the client changes every
night. That's the framing DESIGN.md opens with — "you run a small
landscaping outfit" — finally showing up on the glass.
Sits above the existing .card content, inside the same .card box. */
#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; }
/* SPRINT16 — the reputation line, under the ABN (the spec's words). Slightly
brighter than the docket number because it is the one thing in this corner
that MOVES, and a number that moves is a number the eye should find. */
#hud-card .letterhead .docket .rep { font-size: 10px; color: #8ba0ad; letter-spacing: .08em; margin-top: 2px; }
/* SPRINT16 — the invoice speaks rep: one quiet line under the verdict, where
the movement and its reason live. Not part of the ledger because it is not
money — it is the thing that PRICES tomorrow's money. */
#hud-card .repline { margin: 8px 0 0; font-size: 11px; color: #8ba0ad; letter-spacing: .04em; }
/* --- 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 splash (SPRINT13 gate 3) ---------------------------------------
Built ON E's letterhead rather than beside it: the masthead is the game's
name, so the front door is the same sheet of paper as everything else. The
only rules here are the ones the letterhead doesn't already own. */
#hud-card .splash .letterhead { border-bottom-width: 2px; padding-bottom: 12px; }
#hud-card .splash .mark { font-size: 30px; }
#hud-card .premise { margin: 14px 0 4px; color: #dde5ea; font-size: 14px; line-height: 1.6; }
#hud-card .fineprint { margin: 14px 0 0; color: #6d8494; font-style: italic; line-height: 1.55; }
#hud-card .keys { display: grid; gap: 3px; }
#hud-card .keyrow { display: flex; gap: 12px; align-items: baseline; padding: 2px 0; }
#hud-card .keyrow kbd {
flex: none; min-width: 62px; text-align: center; padding: 3px 7px;
background: #16222a; border: 1px solid #33454f; border-bottom-width: 2px;
border-radius: 4px; color: #cfe0ea; font: inherit; font-size: 11px;
}
#hud-card .keyrow span { color: #93a6b2; }
#hud-card .splash .go { margin-top: 18px; width: 100%; padding: 12px; font-size: 14px; }
/* --- 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 ----------------------------------------------------- */
/* --- SPRINT17 gate 1: THE JOB BOARD ------------------------------------
A new card in the paper chain, and it has to meet the invoice's standard —
so it is built out of the kit rather than beside it: same letterhead, same
.row/.cond rhythm in the offer body, same terms line at the foot.
The one NEW idea it needs is that an offer is a THING YOU PICK, so each one
is a button (the .storm affordance the old storm-select card used, widened
into a panel). Two offers, stacked, tonight's scripted job first — see
board.js offersFor() for why that order is fixed and not seeded.
The constrained offer gets a visible mark, because gate 2's whole point is
that taking a constrained job is a CHOICE: an amber left border and the
client's own words in italic. A player must not be able to take one without
having been told, and "it was in the fee" is not being told. */
#hud-card .offer { display:block; width:100%; text-align:left; margin:9px 0;
padding:13px 15px; background:#121c23; border:1px solid #2f3f4a; border-radius:7px;
color:#dde5ea; cursor:pointer; font:inherit;
transition:border-color .15s, background .15s; }
#hud-card .offer:hover { border-color:#7ee0ff; background:#16242c; }
#hud-card .offer .who { font-weight:700; color:#fff; letter-spacing:.04em; }
#hud-card .offer .addr { font-size:11px; color:#6d818e; margin-bottom:7px; }
#hud-card .offer .tag { float:right; font-size:10px; letter-spacing:.14em;
color:#55707f; text-transform:uppercase; }
#hud-card .offer .line { display:flex; justify-content:space-between; gap:14px;
font-size:12px; color:#8ba0ad; padding:2px 0; }
#hud-card .offer .line b { color:#dde5ea; font-weight:700; }
/* The two numbers a tradie decides on. The fee is the reason to say yes and
the exposure is the reason to say no, so they are weighted against each
other rather than both being grey: money in, money at risk. */
#hud-card .offer .fee b { color:#ffd9a3; }
#hud-card .offer .risk b { color:#ff8f86; }
#hud-card .offer.constrained { border-left:3px solid #ffc24a; }
#hud-card .offer .says { margin-top:8px; padding-left:9px; border-left:2px solid #3f5561;
font-size:11px; font-style:italic; color:#c8b78a; line-height:1.5; }
#hud-card .offer .cons { font-size:10px; letter-spacing:.12em; text-transform:uppercase;
color:#ffc24a; margin-top:7px; }
#hud-card .board-note { margin-top:12px; font-size:11px; color:#6d8494; }
/* 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;
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;
max-width:none; width:min(92vw,900px); min-height:min(70vh,520px);
display:flex; align-items:center; padding:0; border:1px solid #24343d; }
#hud-card .endcard .endtext { width:min(46%,380px); padding:28px 26px;
background:linear-gradient(to right, rgba(9,14,18,.92) 65%, rgba(9,14,18,0)); }
#hud-card .endcard h1 { font-size:26px; }
#hud-card .endcard .kicker { margin:16px 0 0; color:#8ba0ad; font-style:italic; line-height:1.5; }
/* --- dawn (Lane E, pasted from their THREADS note) ---------------------- */
/* The screen blend is the point: it lifts the black storm scene into morning
without touching the card on top. The 2.2s ease matters more than the
colours. (No backticks in here — this whole block is a JS template literal.) */
#dawn { position:fixed; inset:0; pointer-events:none; opacity:0; z-index:5;
transition:opacity 2.2s ease; mix-blend-mode:screen;
background:linear-gradient(to top,
rgba(255,178,102,.30) 0%, /* the sun that finally turned up */
rgba(255,150,96,.16) 22%,
rgba(120,132,168,.10) 55%,
rgba(38,48,84,.16) 100%); } /* night still up there */
#dawn.on { opacity:1; }
`;
/**
* @param {object} d
* @param {THREE.Scene} d.scene
* @param {THREE.Camera} d.camera
* @param {object} d.game the phase machine
* @param {object} d.world
* @param {object} d.wind the router
* @param {object} d.player
* @param {object} d.rig SailRig
* @param {object} d.garden {hp}
* @param {() => object} d.getSky skyfx is rebuilt per storm, so read it late
* @param {() => number} d.getWindTime
* @param {{text:string,t:number}[]} d.events
*/
export function createHud(d) {
const style = document.createElement('style');
style.textContent = CSS;
document.head.appendChild(style);
const root = document.createElement('div');
root.id = 'hud';
root.innerHTML = `
<div class="panel" id="hud-top">
<div id="hud-wind">--</div>
<div id="hud-clock">--</div>
</div>
<div id="hud-gust">GUST INCOMING</div>
<div class="panel" id="hud-garden">
<div><span id="hud-garden-label">GARDEN</span> <b id="hud-garden-pct">100%</b></div>
<div id="hud-bar"><i></i></div>
<div id="hud-shade" style="color:#8ba0ad"></div>
</div>
<div class="panel" id="hud-carry" style="display:none"></div>
<div id="hud-events"></div>
<div id="hud-help"></div>
<div id="hud-mute">MUTED</div>
<div id="hud-pause"><div class="word">PAUSED</div><div class="sub">P to carry on</div></div>
`;
document.body.appendChild(root);
const card = document.createElement('div');
card.id = 'hud-card';
document.body.appendChild(card);
// The morning. Sits UNDER the card and over the frozen storm scene — see the
// `screen` blend in the CSS, which lifts the black night into dawn without
// touching the card on top of it. Lane E shipped this as ten lines rather than
// a ninth texture nobody loads, and they were right.
const dawn = document.createElement('div');
dawn.id = 'dawn';
document.body.appendChild(dawn);
const $ = (id) => root.querySelector(id);
const elWind = $('#hud-wind'), elClock = $('#hud-clock'), elGust = $('#hud-gust');
const elPct = $('#hud-garden-pct'), elBar = $('#hud-bar i'), elShade = $('#hud-shade');
const elGardenLabel = $('#hud-garden-label');
const elCarry = $('#hud-carry'), elEvents = $('#hud-events'), elHelp = $('#hud-help');
const elPause = $('#hud-pause'), elMute = $('#hud-mute');
/** Set by main.js once it has asked skyfx whether the bus has a tap. */
let muteAvailable = false;
// --- world-anchored corner load bars ------------------------------------
// A bar per corner, floating at the corner it describes. Geometry rather than
// a redrawn canvas: the fill is a scaled quad, so animating it is free and a
// flogging corner's bar can track it at 60 Hz without touching a texture.
const barGroup = new THREE.Group();
barGroup.visible = false;
d.scene.add(barGroup);
const quad = new THREE.PlaneGeometry(1, 1);
quad.translate(0.5, 0, 0); // pivot on the left edge, so scale.x grows rightward
const BAR_W = 0.9, BAR_H = 0.1;
/**
* When two corner bars count as "on top of each other", in clip space (NDC
* spans -1..1 across the viewport), and how far apart to stack them.
*
* Measured off the collision the QA pass actually hit — CB1/CB2 on the carport
* beam, viewed from where a player stands to rig them. A bar is ~0.9 world
* units wide and scaled to hold constant screen size, so these are roughly
* "one bar wide, one line tall" and don't need to track the bar's dimensions.
* Y is the tighter of the two on purpose: labels stack vertically, so a pair
* side-by-side is already readable and only a vertical overlap is mush.
*/
const BAR_NDC_X = 0.10, BAR_NDC_Y = 0.045, BAR_STACK_Y = 0.34;
const bars = [];
function ensureBars(n) {
while (bars.length < n) {
const holder = new THREE.Group();
const bg = new THREE.Mesh(quad, new THREE.MeshBasicMaterial({
color: 0x0a1016, transparent: true, opacity: 0.75, depthTest: false,
}));
bg.scale.set(BAR_W, BAR_H, 1);
bg.position.x = -BAR_W / 2;
const fill = new THREE.Mesh(quad, new THREE.MeshBasicMaterial({
color: BAR_OK, depthTest: false,
}));
fill.position.x = -BAR_W / 2;
fill.scale.set(0.001, BAR_H * 0.72, 1);
const label = makeLabel();
label.position.y = 0.19;
holder.add(bg, fill, label);
holder.renderOrder = 999;
barGroup.add(holder);
bars.push({ holder, fill, label, lastText: '' });
}
for (let i = 0; i < bars.length; i++) bars[i].holder.visible = i < n;
}
function makeLabel() {
const c = document.createElement('canvas');
c.width = 256; c.height = 64;
const tex = new THREE.CanvasTexture(c);
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true }));
sp.scale.set(1.5, 0.375, 1);
sp.userData.ctx = c.getContext('2d');
sp.userData.tex = tex;
return sp;
}
function drawLabel(sprite, text, color) {
const g = sprite.userData.ctx;
g.clearRect(0, 0, 256, 64);
g.font = 'bold 30px ui-monospace, Menlo, monospace';
g.textAlign = 'center';
g.textBaseline = 'middle';
g.lineWidth = 6;
g.strokeStyle = '#0a1016';
g.strokeText(text, 128, 32);
g.fillStyle = color;
g.fillText(text, 128, 32);
sprite.userData.tex.needsUpdate = true;
}
// Labels are the only per-frame texture cost here, so they redraw on a slow
// tick and only when the shown value actually changes.
let labelT = 0;
const _p = new THREE.Vector3();
const _q = new THREE.Quaternion();
const _cam = new THREE.Vector3();
// Corner-bar de-collision (SPRINT13). Scratch + a per-frame record of where
// each bar landed in clip space, so bars that typeset on top of each other can
// be stacked instead. Pooled rather than reallocated — this runs every frame —
// and each entry carries `live`, because a corner that has no position this
// frame must not leave last frame's ghost for the others to stack against.
const _ndc = new THREE.Vector3();
const _placed = [];
const placedSlot = (i) => (_placed[i] || (_placed[i] = { x: 0, y: 0, live: false }));
// --- helpers ------------------------------------------------------------
const barColor = (frac, broken) => (broken ? BAR_DEAD : frac > 0.85 ? BAR_HOT : frac > 0.55 ? BAR_WARN : BAR_OK);
function cornerPos(i) {
if (d.rig.cornerPos) return d.rig.cornerPos(i);
const c = d.rig.corners[i];
return c?.anchor?.pos ?? null;
}
const hud = {
/** Set false while a card is up — the storm HUD shouldn't peer through it. */
setVisible(on) { root.style.display = on ? '' : 'none'; },
setHelp(text) { elHelp.textContent = text; },
/** P — the pause veil. Render keeps running; main.js stops the accumulator. */
setPaused(on) { elPause.classList.toggle('on', !!on); },
/** M — reads the state main.js owns. Only shown once the bus is real. */
setMuted(on) { elMute.classList.toggle('on', !!on); },
/**
* Does Lane C's audio bus have a mute tap yet? main.js asks skyfx and tells
* us. Until it does, M is not advertised on the splash or the help line —
* a promised key that does nothing is worse than no key, and this game is
* public now. Flips itself on the day C lands `setMute`.
*/
setAudioMuteAvailable(on) { muteAvailable = !!on; },
/** The comfort keys, listed only where they're real. See setAudioMuteAvailable. */
comfortKeysHint() { return muteAvailable ? 'P pause · M mute' : 'P pause'; },
/**
* THE FRONT DOOR (SPRINT13 gate 3).
*
* partly.party's arcade can send a stranger straight here, and before this
* they landed on a job sheet: an invoice-looking card from a business they'd
* never heard of, quoting money for a job nobody had explained, with the
* controls appearing only later on prep's help line. The premise of the game
* was never once stated on the glass.
*
* It's one more `.card` — not a menu system. There is nothing to configure:
* one week, five jobs, one button. E's letterhead does the work, because the
* masthead IS the game's name and the joke lands before the premise does.
*
* @param {() => void} onStart
*/
showSplash(onStart) {
card.innerHTML = `<div class="card splash">
<div class="letterhead">
<div>
<div class="mark">${BUSINESS.mark}</div>
<div class="trade">${BUSINESS.trade}</div>
</div>
</div>
<p class="premise">Rig shade sails, then keep the client's garden alive
through the storm. ${NIGHT_WORD} nights, ${NIGHT_WORD.toLowerCase()} jobs, one wallet.</p>
<div class="sect">THE CONTROLS</div>
<div class="keys">
${[
['WASD', 'walk the yard'],
['mouse', 'orbit the camera'],
['click', 'pick an anchor · again to unpick'],
['[ ]', 'tension the rig — tighter holds shape, and breaks harder'],
['S', 'buy a spare shackle'],
['F', 'choose the fabric — cloth breathes, membrane stops the rain'],
['E', 'repair a blown corner (ladder if it is up high)'],
['C', 'brace against the wind'],
['Enter', 'commit the rig and start the night'],
...(muteAvailable ? [['P · M', 'pause · mute']] : [['P', 'pause']]),
].map(([k, v]) => `<div class="keyrow"><kbd>${k}</kbd><span>${v}</span></div>`).join('')}
</div>
<p class="fineprint">The forecast is a band, not a promise. Nothing you
tie to is as strong as it looks.</p>
<button class="go">START THE WEEK</button>
</div>`;
card.classList.add('on');
hud.setVisible(false);
card.querySelector('.go').addEventListener('click', () => { hud.hideCard(); onStart(); });
},
/**
* The phone visitor. A public URL gets phones — DESIGN.md's game is WASD and
* a mouse orbit, so there is no touch story to tell and pretending otherwise
* would waste their time. Before this they got a dead canvas and no reason.
*
* No START: this is the one card without a way through, on purpose. Sending
* someone into a yard they cannot walk is worse than telling them plainly.
*/
showTouchNotice() {
card.innerHTML = `<div class="card splash">
<div class="letterhead">
<div>
<div class="mark">${BUSINESS.mark}</div>
<div class="trade">${BUSINESS.trade}</div>
</div>
</div>
<p class="premise">${BUSINESS.mark} needs a keyboard and a mouse —
you walk the yard and orbit the camera to rig a sail, and there's no
honest way to do that with a thumb.</p>
<p class="fineprint">Grab a laptop and come back. It'll be here.</p>
</div>`;
card.classList.add('on');
hud.setVisible(false);
},
/**
* @param {number} dt
* @param {number} t wind/storm time
*/
update(dt, t) {
const phase = d.game.phase;
const storm = phase === 'storm';
// --- wind + clock ---
const speed = d.wind.speedAt(d.player.pos, t);
elWind.textContent = `${speed.toFixed(1)} m/s ${kmh(speed).toFixed(0)} km/h`;
elWind.style.color = speed > 25 ? '#ff8f86' : speed > 15 ? '#ffc24a' : '#dde5ea';
elClock.textContent = storm
? `STORM ${Math.max(0, STORM_LEN - d.game.phaseT).toFixed(0)}s left`
: phase.toUpperCase();
// --- gust telegraph ---
// The contract promises >=1.2 s of warning; this is where the player
// actually gets to spend it.
const tel = storm ? d.wind.gustTelegraph(t) : null;
elGust.classList.toggle('on', !!tel);
if (tel) elGust.textContent = `GUST INCOMING ${tel.eta.toFixed(1)}s · ${kmh(tel.power).toFixed(0)} km/h`;
// --- garden ---
const hp = d.garden.hp;
elPct.textContent = `${hp.toFixed(0)}%`;
elBar.style.width = `${clamp01(hp / 100) * 100}%`;
elBar.style.background = hp > 66 ? '#6ee06e' : hp > 33 ? '#ffc24a' : '#ff5b4a';
// Decision 7: rain shadow is what matters at night, sun coverage by day.
// Showing whichever is load-bearing right now stops the number being a
// fact about nothing.
const sky = d.getSky?.();
if (storm && sky?.rainShadowOver) {
const dry = sky.rainShadowOver(d.world.gardenBed);
elGardenLabel.textContent = 'GARDEN';
elShade.textContent = `${(dry * 100).toFixed(0)}% kept dry by the sail`;
} else {
const shade = d.rig.rigged ? d.rig.coverageOver(d.world.gardenBed, d.world.sunDir, d.world.heightAt) : 0;
elGardenLabel.textContent = 'GARDEN';
elShade.textContent = d.rig.rigged ? `${(shade * 100).toFixed(0)}% in shade` : 'no sail rigged';
}
// --- carried item ---
const carrying = d.player.carrying;
elCarry.style.display = carrying ? '' : 'none';
if (carrying) elCarry.textContent = `carrying: ${carrying}`;
// --- event ticker ---
const live = d.events.filter((e) => t - e.t < 6 || e.t > t);
elEvents.innerHTML = live.slice(-4).map((e) => `<div>${e.text}</div>`).join('');
// --- corner bars ---
barGroup.visible = d.rig.rigged && (storm || phase === 'prep');
if (!barGroup.visible) return;
ensureBars(d.rig.corners.length);
labelT += dt;
const redraw = labelT >= 0.12;
if (redraw) labelT = 0;
d.camera.getWorldQuaternion(_q);
d.camera.getWorldPosition(_cam);
for (let i = 0; i < d.rig.corners.length; i++) {
const c = d.rig.corners[i];
const b = bars[i];
const p = cornerPos(i);
if (!p) { b.holder.visible = false; placedSlot(i).live = false; continue; }
_p.set(p.x, p.y, p.z);
b.holder.position.copy(_p);
b.holder.position.y += 0.45;
b.holder.quaternion.copy(_q);
// Hold roughly constant screen size. A house corner is ~18 m away when
// you're standing at the posts, and at that range a world-sized bar is
// a few unreadable pixels — which defeats the entire point of putting it
// out there rather than in a corner of the screen. Clamped so it doesn't
// balloon when you walk right up to a corner to repair it.
const dist = _p.distanceTo(_cam);
const scale = Math.max(0.75, Math.min(3.2, dist / 7));
b.holder.scale.setScalar(scale);
/**
* Stack bars that would typeset on top of each other. [B's flag, SPRINT13]
*
* The QA pass: "CB1/CB2 died together and typeset on top of each other".
* The carport's two beam anchors are 2.82 m apart and the sail hangs
* between them, so from most of the yard their bars land in the same
* handful of pixels — and they are exactly the pair most likely to blow
* together, because they share a beam and a 0.22 ratingHint. The one
* moment the HUD has to be legible ("both beam corners went, that's why
* the sail is on the lawn") was the one moment it turned to mush.
*
* Screen space, not world space: whether two labels collide is a fact
* about the camera, and two corners a metre apart are unreadable from
* the fence and perfectly clear from underneath. Compared at the anchor
* point rather than after nudging, so the test is order-independent and
* a stack of three can't chase itself up the screen. Deterministic —
* corner index order, no clock — and O(n²) on n=4.
*
* The offset rides `scale` so the gap is constant on screen at any range,
* the same reason the bar itself is scaled.
*/
_ndc.copy(b.holder.position).project(d.camera);
const onScreen = _ndc.z < 1; // behind the camera projects to nonsense
let clash = 0;
if (onScreen) {
for (let k = 0; k < i; k++) {
const o = _placed[k];
if (!o || !o.live) continue;
if (Math.abs(_ndc.x - o.x) < BAR_NDC_X && Math.abs(_ndc.y - o.y) < BAR_NDC_Y) clash++;
}
}
const slot = placedSlot(i);
slot.x = _ndc.x; slot.y = _ndc.y; slot.live = onScreen;
if (clash) b.holder.position.y += clash * BAR_STACK_Y * scale;
// SPRINT12 — B's flag, actioned: the bar reads the EFFECTIVE rating,
// rating × ratingHint, because that's the number sail.js fails on now.
// Off the bare rating a fascia corner showed 35% margin it didn't have
// and blew mid-green — the HUD's one job at that moment is the "…the
// shackle, I KNEW about the shackle" beat, and a bar that lies about
// the margin steals it. The label prints the same effective number:
// load-vs-what-it-breaks-at, one truth, both places.
const effRating = c.hw.rating * (c.anchor?.ratingHint ?? 1);
const frac = clamp01((c.load || 0) / effRating);
b.fill.scale.x = Math.max(0.001, (c.broken ? 1 : frac) * BAR_W);
b.fill.material.color.setHex(barColor(frac, c.broken));
if (redraw) {
const text = c.broken
? `${c.anchorId.toUpperCase()} BLOWN`
: `${c.anchorId.toUpperCase()} ${((c.load || 0) / 1000).toFixed(1)}/${(effRating / 1000).toFixed(1)}kN`;
if (text !== b.lastText) {
b.lastText = text;
drawLabel(b.label, text, c.broken ? '#ff8f86' : frac > 0.85 ? '#ffb1ab' : '#dde5ea');
}
}
}
},
// --- cards ------------------------------------------------------------
/**
* SPRINT17 gate 1 — THE JOB BOARD. The morning, before the job sheet.
*
* ROADMAP arc 2's opening move: *"mornings offer N callouts, you take one.
* Pay, risk, client and site visible before you commit."* Two offers,
* tonight's scripted night first, and taking either one makes it the night.
*
* **What an offer card shows, and why it is exactly this list** — SPRINT17
* gate 1.2 asks for "what a tradie would demand before saying yes", which
* is not a feature list, it's a job interview:
* · WHO and WHERE — the client and the address, off the same night data
* the letterhead bills. A job you can't name is a job you don't take.
* · WHAT THE WEATHER IS DOING — C's honest forecast band at lead 0, the
* same call the job sheet makes, so the board cannot be more confident
* than the sheet it leads to.
* · THE FEE — what you're paid, premium included, priced through the
* SAME calloutFee() the sheet quotes and the invoice pays.
* · THE RISK — collateral exposure: what of theirs your failure can
* bill you for. This is the number that makes the choice a choice; a
* board that showed only the fee would be advertising, not quoting.
* · THE CLIENT'S CONSTRAINTS, in the client's own words, marked — so
* taking a constrained job is a decision and never a surprise.
*
* The pricing is injected (main.js owns the storm defs and site JSON);
* anything absent simply doesn't render, which is the same degradation rule
* the job sheet's forecast lines follow. The card works with nothing but
* client/site/brief, and that is what lets it be tested without a server.
*
* @param {object[]} offers board.js offersFor(), already priced
* @param {object} wk {night, nights, bank, log, rep}
* @param {(offer:object) => void} onTake
*/
showBoard(offers, wk, onTake) {
const pips = Array.from({ length: wk.nights }, (_, i) => {
const done = i < wk.night - 1;
const now = i === wk.night - 1;
const held = done && wk.log?.[i]?.won;
return `<span class="pip ${now ? 'now' : done ? (held ? 'held' : 'lost') : ''}">${
now ? '◆' : done ? (held ? '●' : '○') : '·'}</span>`;
}).join('');
const offerHtml = (o, i) => {
const n = o.night;
const cons = o.constraints ?? [];
const f = o.forecast;
return `
<button class="offer${cons.length ? ' constrained' : ''}" data-offer="${i}">
<span class="tag">${o.kind === 'scripted' ? 'tonight, as booked' : 'callout'}</span>
<div class="who">${n.client ?? 'no client'}</div>
<div class="addr">${n.addr ?? ''}</div>
${f?.wind ? `<div class="line"><span>${f.wind}</span></div>` : ''}
${f?.stones ? `<div class="line"><span>${f.stones}</span></div>` : ''}
${o.fee != null ? `<div class="line fee"><span>the job pays${
o.premium ? ` · +${Math.round(o.premium * 100)}% on their terms` : ''
}</span><b>$${o.fee}</b></div>` : ''}
${o.exposure != null ? `<div class="line risk"><span>their property at risk${
o.exposureItems?.length ? `${o.exposureItems.map((x) => x.what).join(', ')}` : ''
}</span><b>$${o.exposure}</b></div>` : ''}
${cons.length ? `<div class="cons">⚖ ${cons.map((c) => c.label).join(' · ')}</div>` : ''}
${cons.map((c) => `<div class="says">“${c.says}”</div>`).join('')}
</button>`;
};
card.innerHTML = `<div class="card jobcard">
<div class="letterhead">
<div>
<div class="mark">${BUSINESS.mark}</div>
<div class="trade">${BUSINESS.trade}</div>
</div>
<div class="docket">
<div class="kind">THE BOARD</div>
<div class="no">MORNING · NIGHT ${wk.night} OF ${wk.nights}</div>
${wk.rep != null ? `<div class="rep">standing ${repFmt(wk.rep)}</div>` : ''}
</div>
</div>
<h1>TWO JOBS ON OFFER</h1>
<div class="pips">${pips}</div>
<h2>Take one. The other goes to somebody else.</h2>
${offers.map(offerHtml).join('')}
<div class="row" style="margin-top:12px"><span>in the bank</span><b>$${wk.bank}</b></div>
<div class="board-note">Both are tonight. You cannot work two yards in one storm.</div>
<div class="terms">Fees quoted on the forecast at time of callout. Weather is not a variation.</div>
</div>`;
card.classList.add('on');
hud.setVisible(false);
for (const el of card.querySelectorAll('.offer')) {
el.addEventListener('click', () => {
hud.hideCard();
onTake(offers[Number(el.dataset.offer)]);
});
}
},
/**
* The forecast. Its job is to sell the dread and, incidentally, to be the
* difficulty select for free — three authored storms already bracket the
* range, so letting the player pick one IS the choice.
*
* @param {{key:string, def:object}[]} storms
* @param {(key:string) => void} onPick
*/
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
// the week wants to show tomorrow's card, pass its lead and it hedges.
const f = forecastLines(def, 0);
const change = (def.events ?? []).find((e) => e.type === 'windchange');
const night = (def.sky?.darkness ?? 0) > 0.6;
const hail = !!def.hail;
// The ladder, as pips. You can see what you've survived and how far is
// left — which is most of what makes night four frightening.
const pips = Array.from({ length: wk.nights }, (_, i) => {
const done = i < wk.night - 1;
const now = i === wk.night - 1;
const held = done && wk.log[i]?.won;
return `<span class="pip ${now ? 'now' : done ? (held ? 'held' : 'lost') : ''}">${
now ? '◆' : done ? (held ? '●' : '○') : '·'}</span>`;
}).join('');
// A different yard announces itself — the corner block is somewhere you've
// never rigged, and that's most of what makes night three land.
const siteLine = site
? `<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.
//
// 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 letterhead = job.client ? `
<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>
${wk.rep != null ? `<div class="rep">standing ${repFmt(wk.rep)}</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
? `<div class="brief">${job.brief}</div>` : '';
// SPRINT17 gate 2 — THE CLIENT'S TERMS, on the sheet, in their words.
// Under the brief and above the weather on purpose: the constraint is
// part of what they're asking for, not a footnote to it, and the player
// has to have read it BEFORE the schedule tells them what it pays. The
// invoice repeats this block verbatim off the settlement (a constraint
// the invoice forgot would be the paper losing the terms it was paid
// under, which is gate 2's specific requirement).
const cons = job.constraints ?? [];
const constraintBlock = cons.length ? `
<div class="sect">THE CLIENT'S TERMS</div>
<div class="sched">
${cons.map((c) => `
<div class="row"><span>${c.label}</span><b>${
c.kind === 'budgetCap' ? `$${c.cap} cap` : 'refused'}</b></div>
<div class="cond">“${c.says}”</div>`).join('')}
</div>` : '';
// 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.
// 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;
// SPRINT16 — the multiplier is on the sheet the night it applies, and
// only then: ×1.00 every night would teach that the number is decoration.
// The warranty lines are the other half of gate 1's paper: booked by last
// dawn's broken corners, worded as the spec words them ("no charge to the
// client" IS the sentence — unpaid labour is the point), priced at the
// parts' shop price, and already inside q.total so the sheet's conclusion
// is the deducted number the invoice will pay. Quote==settle is week.js's
// construction; this card just refuses to hide the deduction.
const feeTag = q && q.feeMult !== 1 ? ` · standing ×${q.feeMult.toFixed(2)}` : '';
const warrantyRows = (q?.warranty ?? []).map((it) => `
<div class="row bad"><span>warranty — ${it.anchorId.toUpperCase()}, ${it.hw}, no charge to the client</span><b>$${it.cost}</b></div>
<div class="cond">your corner, down at dawn${it.addr ? `${it.addr}` : ''} · parts at shop price, labour on you</div>`).join('');
const schedule = q ? `
<div class="sect">WHAT IT PAYS</div>
<div class="sched">
<div class="row headline"><span>callout fee${feeTag}</span><b>$${q.base}</b></div>
<div class="cond">the night, rigged and stood up${q.feeMult !== 1 ? ' — priced on the letterhead number' : ''}</div>
<div class="row"><span>garden bonus</span><b>up to $${q.garden}</b></div>
<div class="cond">${q.beyondSaving
// SPRINT14 — B's pool item. On a gardenBeyondSaving night the bed
// cannot reach the win line at any price (week.js's flag, A's S13
// ruling), so "paid on what's left of the bed at dawn" quotes a
// maximum nobody can earn and reads as a rigging problem the
// player could solve. The AMOUNT stays honest — settle() really
// does pay this proportionally, and zeroing it here would make the
// sheet lie in the other direction — but the CONDITION now says
// what kind of night this is, up front, where the brief already
// levels with you. The steel is still yours to save.
? 'scales with the bed at dawn — and tonight the ice takes it whatever you rig'
: "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>${warrantyRows}
<div class="row max"><span>the night, done right</span><b>$${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.
//
// `lead` is a 0..1 haze dial (confidence = 1 lead), NOT "nights out" — I
// read it as nights first, passed 1, and the card advertised "forecast
// confidence 0%": a forecast admitting it knows nothing, printed as news.
//
// C's `leadFor` maps "N nights out" onto that dial — 0.25 for tomorrow of
// five (75% confidence). Safe to print because the band RESOLVES: verified
// 4020 samples, 0 violations — tomorrow's band always contains tonight's,
// so the number tightens toward the truth and never rules out what it
// previously allowed. (Integrated at SPRINT11 merge; was a 0.6 placeholder.)
const tf = tomorrowDef ? forecastLines(tomorrowDef, leadFor(1, wk.nights)) : null;
const tomorrow = tf ? `
<div class="tomorrow">
<span class="tlabel">tomorrow</span>
<span class="tline">${tf.wind}${tf.confidence ? ` · ${tf.confidence}` : ''}</span>
</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">
${letterhead}
${brief}
${constraintBlock}
<h1>NIGHT ${wk.night} OF ${wk.nights}</h1>
<div class="pips">${pips}</div>
<h2>${(f.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}</h2>
${siteLine}
<div class="stat">${f.wind}</div>
<div class="stat">${f.rain}${hail ? ' · hail forecast' : ''}</div>
${/* SPRINT16 — C's gate 3.3 seam (THREADS [A] seam contract): when
forecastLines exposes ready-to-print stone-size and rain-rate
lines, the sheet prints them verbatim; until then this renders
nothing. The bet (cloth vs membrane) is argued HERE or nowhere —
the forecast is the only place the player chooses from. */''}
${f.stones ? `<div class="stat">${f.stones}</div>` : ''}
${f.rainRate ? `<div class="stat">${f.rainRate}</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="stat" style="color:#8ba0ad;margin-top:10px">
${hail
? 'Hail is what kills a garden, and cloth stops hail. Get the sail over the bed.'
: 'Nothing tonight can hurt the garden. Learn the anchors.'}
</div>
${change ? `<div class="stat" style="color:#8ba0ad">
A change means the corners that were slack all storm are the loaded ones after it.
</div>` : ''}
<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>`;
card.classList.add('on');
hud.setVisible(false);
card.querySelector('.go').addEventListener('click', () => { hud.hideCard(); onGo(); });
},
/**
* E's dawn tint, pasted from their THREADS note. The 2.2 s ease is the point,
* not the colours: the storm ends, the light comes up, and THEN you're told
* how you did. Sell the survival before the scoreboard.
*/
setDawn(on) { dawn.classList.toggle('on', !!on); },
/**
* The end of the week, either way. E's diptych is on disk and their words are
* in THREADS — I took the primaries verbatim; the trade talking, no triumph.
*
* @param {object} s week.js settlement
* @param {() => void} onRestart
*/
showEndCard(s, onRestart) {
const won = s.outcome === 'win';
const art = won ? 'card_win.jpg' : 'card_gameover.jpg';
// The words scale with what was actually held — reaching night five is not
// the same as saving five gardens, and the end card is the last thing the
// game says. E's primary copy for a clean week, E's own alternate for a
// scraped one, and a third for the ending nobody knew existed until the
// economy was measured: solvent, with the gardens dead behind you.
const COPY = {
clean: ['THE WEEK HELD', `${NIGHT_WORD} nights. Everything's still where you put it.`,
"Nobody thanks you for the storm that did nothing. That's the job."],
scraped: [`${NIGHT_WORD.toUpperCase()} NIGHTS, ${NIGHT_WORD.toUpperCase()} MORNINGS`,
`You made the least wrong call ${NIGHT_WORD.toLowerCase()} times running.`,
'Some of it held. You know which bits didn\'t.'],
solvent: ['STILL TRADING', 'The books balanced. The garden didn\'t.',
'You can be paid all week and still have nothing to point at.'],
gameover: ['OFF THE JOB', 'Broke, with the week still running.',
'The wind never sent an invoice. Everyone else did.'],
};
const [head, sub, kick] = COPY[won ? (s.grade ?? 'clean') : 'gameover'];
const nights = s.night;
// SPRINT16 — the end of the week speaks rep. A week that ends
// broke-but-clean and a week that ends rich-with-warranties are
// different endings (the gate's own words), and the difference is the
// number the next client sees — so the last thing the game says carries
// it. Keyed on where the letterhead number LANDED relative to neutral
// (REP.START), crossed with whether you're still trading; the row above
// it shows the warranty count so "rich" has its receipts.
const repKick = s.rep == null ? '' : s.rep > REP.START
? (won ? ' The name on the letterhead is worth more than it was on Monday.'
: ' The book says broke. The number says the phone rings again.')
: s.rep < REP.START
? (won ? ' The money came in. The mornings after are what gets remembered.'
: ' And word of the mornings after got there first.')
: '';
card.innerHTML = `<div class="card endcard ${won ? 'win' : 'lose'}"
style="background-image:url(./models/textures/${art})">
<div class="endtext">
<h1>${head}</h1>
<h2>${sub}</h2>
<div class="row"><span>nights survived</span><b>${nights}/${NIGHT_COUNT}</b></div>
<div class="row"><span>gardens held</span><b>${s.held ?? 0}/${NIGHT_COUNT}</b></div>
<div class="row"><span>in the bank</span><b>$${s.bankAfter}</b></div>
${s.rep != null ? `<div class="row"><span>reputation</span><b>${repFmt(s.rep)}</b></div>` : ''}
${s.warrantiesToDate ? `<div class="row"><span>warranty jobs booked</span><b>${s.warrantiesToDate}</b></div>` : ''}
<div class="kicker">${kick}${repKick}</div>
<button class="go">${won ? 'ANOTHER WEEK' : 'TRY AGAIN'}</button>
</div>
</div>`;
card.classList.add('on');
hud.setVisible(false);
card.querySelector('.go').addEventListener('click', () => { hud.hideCard(); onRestart(); });
},
/**
* @param {object} r {hp, cornersLost, cornersTotal, bill, collateral, budgetLeft, win}
* @param {() => void} onAgain
*/
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],
// SPRINT16 gate 2.2 [Lane B] — the fabric bet is on the record: the
// choice the player made at the F key, printed on the same paper as
// what it did or didn't stop. Unconditional — the record is the point.
...(r.fabric ? [['fabric flown', r.fabric.name]] : []),
['hardware lost', r.bill ? `$${r.bill}` : '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('')
: '';
// 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
? `<div class="row"><span>clean site</span><b>+$${w.clean}</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;
// SPRINT16 — the invoice answers the sheet's warranty lines word for
// word (same items, same derivation in week.js), and the fee carries the
// same standing multiplier the sheet quoted. The ledger must keep
// summing exactly as shown — a.test pins pay to these rows.
const invWarrantyRows = w ? (w.warranty ?? []).map((it) => `
<div class="row bad"><span>warranty — ${it.anchorId.toUpperCase()}, ${it.hw}, no charge to the client</span><b>$${it.cost}</b></div>`).join('') : '';
const invFeeTag = w && w.feeMult && w.feeMult !== 1 ? ` · standing ×${w.feeMult.toFixed(2)}` : '';
const money = w ? `
<div class="ledger">
<div class="row"><span>${w.won ? 'callout fee' : `callout fee — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}${invFeeTag}${
// SPRINT17 gate 2 — the fee row names the premium it was paid at.
// The constraint moved this number, so the row that shows the
// number is where it has to be said; a premium that only appeared
// in the total would be money with no reason printed next to it.
w.premium ? ` · +${Math.round(w.premium * 100)}% on their terms` : ''
}</span><b>+$${w.fee}</b></div>
<div class="row"><span>garden bonus — ${r.hp.toFixed(0)}% of the bed${
// SPRINT14 — B's pool item, the invoice half. On a beyond-saving
// night this row is always small, and next to "callout fee — night
// lost" it reads as the player's failure. It isn't: the bed was
// gone at the forecast (week.js gardenBeyondSaving). Naming that
// HERE, in the ledger, matters more than in the verdict prose —
// the ledger is the part players re-read, and an unexplained small
// number is the most persuasive lie on the card.
w.gardenBeyondSaving ? ', beyond saving from the start' : ''
}</span><b>+$${w.bonus}</b></div>
${cleanRow}
<div class="row"><span>gear recovered</span><b>+$${w.refund}</b></div>
${collateralRows}${invWarrantyRows}
<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>` : '';
const next = !w ? 'PLAY AGAIN'
: w.outcome === 'continue' ? `NIGHT ${w.night + 1}`
: w.outcome === 'win' ? 'SEE THE WEEK'
: '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>
${w.rep != null ? `<div class="rep">standing ${repFmt(w.rep)}</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">
${letterhead}
<h1>MORNING${w ? ` · NIGHT ${w.night} OF ${w.nights ?? NIGHT_COUNT}` : ''}</h1>
<h2>${r.subtitle}</h2>
${rows}
${/* SPRINT17 gate 2 — THE INVOICE REPEATS THE CONSTRAINT IT WAS PAID
UNDER. The spec's own requirement, and it earns its place: the
invoice is the record, and a record of a constrained job that
doesn't say it was constrained has lost the one term that shaped
every decision on it. Off the settlement (week.js carries
s.constraints), so it stays true when you re-read night 3's
invoice on night 5's morning. */''}
${w?.constraints?.length ? `
<div class="sect">PAID UNDER THESE TERMS</div>
<div class="sched">
${w.constraints.map((c) => `
<div class="row"><span>${c.label}</span><b>${
c.kind === 'budgetCap' ? `$${c.cap} cap` : 'refused'}</b></div>
<div class="cond">“${c.says}”</div>`).join('')}
</div>` : ''}
${money}
<div class="verdict ${r.win ? 'win' : 'lose'}">${r.verdict}</div>
${/* SPRINT16 — the verdict speaks rep: the movement AND its reason, so
the number on the letterhead is never a number that changed for no
stated cause. week.js writes the reasons (including the flat
"broke, but repaired by dawn" case) — this line just reads them. */''}
${w && w.rep != null ? `<div class="repline">reputation ${repFmt(w.repBefore)}${repFmt(w.rep)}${
w.repReasons?.length ? `${w.repReasons.join(' · ')}` : ''}</div>` : ''}
${/* SPRINT16 gate 2.2 [B] — when the fabric bet provably mattered the
verdict page says so, one quiet line under the verdict (same
pattern as A's repline: the verdict div itself stays byte-
untouched). fabricNoteFor's thresholds decide; null = silent.
(Keep-both at integration, per B's own merge notes — the two
lines are siblings by design.) */''}
${r.fabricNote ? `<div class="fabricnote">${r.fabricNote}</div>` : ''}
<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>`;
card.classList.add('on');
hud.setVisible(false);
card.querySelector('.go').addEventListener('click', () => { hud.hideCard(); onAgain(); });
},
hideCard() {
card.classList.remove('on');
card.innerHTML = '';
hud.setVisible(true);
},
get cardOpen() { return card.classList.contains('on'); },
dispose() {
root.remove(); card.remove(); style.remove(); dawn.remove();
d.scene.remove(barGroup);
quad.dispose();
for (const b of bars) {
b.fill.material.dispose();
b.label.material.map?.dispose();
b.label.material.dispose();
}
},
};
return hud;
}