week.js: REP (stars, half-steps, x1+0.04(rep-3) fee multiplier), warrantyItems() derived from the previous settlement's rig record so quote==settle holds by construction; rep moves at dawn of the night that earned it and never reads the garden. main.js: rigRecordFor (exported value — the tally remembers what repairCorner's cleared flag forgets), night break/repair tally, rep on the forecast call. hud.js: rep under the ABN on job sheet AND invoice, warranty lines on both papers worded per spec, fee-row multiplier the night it applies, rep line under the verdict, end card speaks rep + shows warranty receipts, C's gate-3.3 forecast print seam (inert until they land). a.test: 8 new tests incl. the clean-week negative control. Selftest 426/0/0 in the browser. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1106 lines
56 KiB
JavaScript
1106 lines
56 KiB
JavaScript
/**
|
||
* 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 { REP } from './week.js';
|
||
|
||
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; }
|
||
|
||
/* --- 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. Five nights, five 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', "Five nights. Everything's still where you put it.",
|
||
"Nobody thanks you for the storm that did nothing. That's the job."],
|
||
scraped: ['FIVE NIGHTS, FIVE MORNINGS', 'You made the least wrong call five 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}/5</b></div>
|
||
<div class="row"><span>gardens held</span><b>${s.held ?? 0}/5</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],
|
||
['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 5` : ''}</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>` : ''}
|
||
<button class="go">${next}</button>
|
||
${w?.client ? `<div class="terms">Payable 14 days. ${w.collateral ? 'They’ll 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;
|
||
}
|