C AND D BOTH LANDED WHILE I WAS IN GATE 2, AND BOTH CORRECT ME.
VENTURI — reverted to axis 2.1 / gain 1.5. C reached the same physics I did,
independently (|dot| means an axis is a line, mod PI), and then went one step
further and caught my reasoning: -1.08 was never a proposed AXIS. It's
dirAt(40), the southerly's HEADING once the change settles. 2.1 is the GAP'S
ORIENTATION — site geometry, which doesn't move when the wind does. They're
different quantities that sit a hair over PI apart only because the gap was
BUILT along the southerly.
So "measured beats authored" was the wrong rule for this question: I'd have
swapped a geometry fact for a weather fact because they nearly coincide, and
the site's axis would silently mean the wrong thing the day someone authors a
storm from another quarter. "A number gathered from the wrong harness" caught
both of us on the same question from opposite sides in one sprint.
Gain reverted too, and that one I simply jumped: it's a balance lever, B's audit
calls it, and C's standing offer is to drop to 1.35 only if no $80 line survives.
B hasn't run. D then measured the site winnable off honest anchors at 98% — so
the funnel isn't killing the line and the gain stays.
D's LANDMINES, both mine, both in gate 2's path:
· CALM_STORM loaded only because night one HAPPENED to be storm_01_gentle.
Gate 2 rewrote every night entry. Drop gentle and windTime() throws every
non-storm frame on "Cannot read properties of undefined (reading 'duration')"
— naming neither storms, nor calm, nor the week. Now explicit in the preload,
and the assert calls stormsToPreload() with a gentle-free ladder, because
asserting it against the shipped NIGHTS passes either way. That's how it hid.
· boot({site}) was a documented lie that failed INVERSELY — markers on the
requested yard, world on the week's. Honoured through the week now (seek to
that site's night), because a site without its night is half a game: no
client, no brief, no storm, no bank. Plus opts.bank, so D can boot the corner
block at the $237 they measured instead of night one's $80 and OFF THE JOB.
RATINGHINT — D is right that nothing reads it, and the naive fix is worse than
the bug: honest posts are built from site JSON, never touch adoptAnchor, so
their ratingHint was undefined — and `load > hw.rating * undefined` is
`load > NaN`, always false. The strongest anchors would have become UNBREAKABLE
while the carport became the only thing that can fail: the trap would look like
it works, for the wrong reason, and read as a successful playtest. Every anchor
carries a real number now (DEFAULT_RATING_HINT), pinned, so the wire is safe
before it's made. The wire itself is sail.js — B's — and the ruling is in THREADS.
selftest 306/0/0 (was 304).
624 lines
29 KiB
JavaScript
624 lines
29 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 } from './weather.js';
|
||
|
||
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||
const kmh = (ms) => ms * 3.6;
|
||
|
||
// 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; }
|
||
|
||
#hud-card { position:fixed; inset:0; z-index:30; display:none; place-items:center;
|
||
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; 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; }
|
||
|
||
/* SPRINT11 — the job sheet and the invoice. STRUCTURE ONLY, deliberately: the
|
||
letterhead and the invoice styling are Lane E's this sprint (§gate 2), and
|
||
everything below is the plainest thing that reads correctly so E can restyle
|
||
it without unpicking layout. The class names are the contract — .letterhead,
|
||
.brief, .jobsheet, .tomorrow — and E owns what they look like. */
|
||
#hud-card .letterhead { display:flex; justify-content:space-between; align-items:baseline;
|
||
margin:-4px 0 10px; padding-bottom:8px; border-bottom:1px solid #24343d;
|
||
font-size:12px; letter-spacing:.18em; text-transform:uppercase; color:#7ee0ff; }
|
||
#hud-card .letterhead .inv { color:#8ba0ad; letter-spacing:.1em; }
|
||
#hud-card .brief { margin:10px 0 12px; padding:9px 11px; border-left:2px solid #2f3f4a;
|
||
background:#101a20; color:#c3d2da; font-size:13px; line-height:1.55; font-style:italic; }
|
||
#hud-card .jobsheet { margin-top:12px; padding-top:10px; border-top:1px solid #24343d; }
|
||
#hud-card .jobsheet .row b { color:#a8f0b8; }
|
||
#hud-card .jobsheet .row.total { margin-top:6px; padding-top:8px; border-top:1px solid #24343d; }
|
||
#hud-card .jobsheet .row.total b { color:#fff; }
|
||
/* Tomorrow is a rumour, and it should look like one next to tonight's numbers. */
|
||
#hud-card .tomorrow { display:flex; gap:10px; align-items:baseline; margin-top:9px;
|
||
font-size:12px; color:#6d8494; }
|
||
#hud-card .tomorrow .tlabel { letter-spacing:.14em; text-transform:uppercase; color:#55707f; }
|
||
|
||
/* E's diptych. The art is the card; the words sit in the left third they left
|
||
clear for exactly this. */
|
||
#hud-card .endcard { background-size:cover; background-position:center right;
|
||
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>
|
||
`;
|
||
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');
|
||
|
||
// --- 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;
|
||
|
||
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();
|
||
|
||
// --- 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; },
|
||
|
||
/**
|
||
* @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; 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);
|
||
b.holder.scale.setScalar(Math.max(0.75, Math.min(3.2, dist / 7)));
|
||
|
||
const frac = clamp01((c.load || 0) / c.hw.rating);
|
||
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)}/${(c.hw.rating / 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.
|
||
const job = wk.job ?? {};
|
||
const letterhead = job.client
|
||
? `<div class="letterhead">${job.client}</div>` : '';
|
||
const brief = job.brief
|
||
? `<p class="brief">${job.brief}</p>` : '';
|
||
|
||
// BUDGET Y — and this is the actual feature, not the flavour. The fee has
|
||
// existed since Sprint 8 and the player has never seen it until the money
|
||
// was already spent: you decided what to rig without knowing what the job
|
||
// was worth, which is a reveal, not a decision. Quoting the maxima up front
|
||
// makes the shop a judgement ("is $45 of garden bonus worth a $30
|
||
// shackle?") instead of a guess.
|
||
const q = wk.quote;
|
||
const schedule = q ? `
|
||
<div class="jobsheet">
|
||
<div class="row"><span>base — the job</span><b>$${q.base}</b></div>
|
||
<div class="row"><span>garden bonus — if the bed lives</span><b>up to $${q.garden}</b></div>
|
||
<div class="row"><span>clean bonus — nothing of theirs broken</span><b>$${q.clean}</b></div>
|
||
<div class="row total"><span>the job's worth</span><b>up to $${q.total}</b></div>
|
||
</div>` : '';
|
||
|
||
// Lane C's forecast lead (SPRINT11 gate 2): tomorrow, hedged. Their
|
||
// `forecastLines(def, lead)` has carried the lead param for two sprints
|
||
// with nothing ever calling it above 0 — this is the call site.
|
||
//
|
||
// `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.
|
||
//
|
||
// ⚠️ INTEGRATION, SPRINT11: this constant is a placeholder and C has
|
||
// already landed its replacement on lane/c — `leadFor(nightsOut, weekNights)`
|
||
// in weather.js, which maps the week onto forecastFor's 0..1 domain and
|
||
// gives 0.25 for tomorrow of five (75% confidence), not my 0.6. Theirs is
|
||
// better than a guess: they verified the band RESOLVES — 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. That's the property that makes it safe to print on a card.
|
||
// At merge this becomes C's own call site, verbatim from their THREADS:
|
||
// forecastLines(tomorrowDef, leadFor(1, wk.nights))
|
||
// Not imported here yet only because leadFor is on lane/c and this branch
|
||
// would go red reaching for it.
|
||
const TOMORROW_LEAD = 0.6;
|
||
const tf = tomorrowDef ? forecastLines(tomorrowDef, TOMORROW_LEAD) : null;
|
||
const tomorrow = tf ? `
|
||
<div class="tomorrow">
|
||
<span class="tlabel">tomorrow</span>
|
||
<span class="tline">${tf.wind}${tf.confidence ? ` · ${tf.confidence}` : ''}</span>
|
||
</div>` : '';
|
||
|
||
card.innerHTML = `<div class="card jobcard">
|
||
${letterhead}
|
||
<h1>NIGHT ${wk.night} OF ${wk.nights}</h1>
|
||
<div class="pips">${pips}</div>
|
||
<h2>${(f.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}</h2>
|
||
${siteLine}
|
||
${brief}
|
||
<div class="stat">${f.wind}</div>
|
||
<div class="stat">${f.rain}${hail ? ' · hail forecast' : ''}</div>
|
||
${f.confidence ? `<div class="stat" style="color:#8ba0ad">${f.confidence}</div>` : ''}
|
||
${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>
|
||
</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;
|
||
|
||
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>
|
||
<div class="kicker">${kick}</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('')
|
||
: '';
|
||
const cleanRow = w ? (w.clean
|
||
? `<div class="row"><span>clean bonus — nothing of theirs broken</span><b>+$${w.clean}</b></div>`
|
||
: `<div class="row bad"><span>clean bonus — forfeited</span><b>$0</b></div>`) : '';
|
||
|
||
const money = w ? `
|
||
<div class="ledger">
|
||
<div class="row"><span>${w.won ? 'base — the job' : `base — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}</span><b>+$${w.fee}</b></div>
|
||
<div class="row"><span>garden bonus — ${r.hp.toFixed(0)}% of the bed</span><b>+$${w.bonus}</b></div>
|
||
${cleanRow}
|
||
<div class="row"><span>gear recovered</span><b>+$${w.refund}</b></div>
|
||
${collateralRows}
|
||
<div class="row"><span>spent on the rig</span><b>−$${w.spent}</b></div>
|
||
<div class="row total"><span>in the bank</span><b>$${w.bankBefore} → $${w.bankAfter}</b></div>
|
||
</div>` : '';
|
||
|
||
const next = !w ? 'PLAY AGAIN'
|
||
: w.outcome === 'continue' ? `NIGHT ${w.night + 1} →`
|
||
: w.outcome === 'win' ? 'SEE THE WEEK'
|
||
: 'SEE THE DAMAGE';
|
||
|
||
card.innerHTML = `<div class="card jobcard">
|
||
${w?.client ? `<div class="letterhead">${w.client}<span class="inv">invoice</span></div>` : ''}
|
||
<h1>MORNING${w ? ` · NIGHT ${w.night} OF 5` : ''}</h1>
|
||
<h2>${r.subtitle}</h2>
|
||
${rows}
|
||
${money}
|
||
<div class="verdict ${r.win ? 'win' : 'lose'}">${r.verdict}</div>
|
||
<button class="go">${next}</button>
|
||
</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();
|
||
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;
|
||
}
|