HardYards/web/world/js/hud.js
type-two d8e7087c2c Lane D S16 gate 4.1+4.2: swing lawn re-scored (zero deltas), THE SIX-NIGHT WEEK wired
- site_03 re-scored on the post-ruling tree, S15 loop, FUNNEL ON: every number
  reproduces the S14 card to the digit (81 quads, 11 clean/16 marginal, cheapest
  honest $65 h3,t2,t2b,p1 85.3 FULL, bare 83.7, best clean 90.0, sep +6.3,
  best-reading 91.8 MARGINAL). Nothing moved; nothing to fix; B's PIN REFUSED
  (measured) card renders the finding on this yard.
- NIGHTS is six: gentle, southerly, earlybuster(s02), SOUTHERLY over the SWING
  LAWN (new site, survived storm), icenight (flag intact, no longer final),
  SOAKER over the CORNER BLOCK (C's measured pairing). Two new briefs: Ruby's
  swing set sells the crossbar trap; the Vasilaros return sells the cloth
  default the forecast argues against.
- THE STRUCTURAL CONSEQUENCE, filed loudly: six slots cannot hold five old
  storms + a survived-storm debut + the soaker. The WILD NIGHT leaves the
  rotation (every other candidate is load-bearing); storm file, tests and the
  pinned backyard separation all stand. If the eviction is wrong the fix is a
  seven-night week — one entry, a ruling.
- week asserts updated (a.test): six nights, final-night=6 both directions on
  the broke rule, three clients with night 6 a RETURN booking.
- four six-night-law pins in d.test: one new site / one new storm / never same
  night; survived-storm debut; soaker pairing (C's receipt in the message);
  beyond-saving night neither new nor final.
- hud surfaces read NIGHTS.length (splash, morning header, end card) — flagged
  for A; hardcoded 'five' would lie on every paper.
- selftest 473/0/0 (469 + 4); mutations M1 soaker→backyard, M2 debut→wildnight,
  M3 flag→final each RED with the law's own words, restored, green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 21:49:58 +10:00

1131 lines
58 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 ----------------------------------------------------- */
/* 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 ------------------------------------------------------------
/**
* 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>` : '';
// 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}
<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}</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}
${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;
}