HardYards/web/world/js/main.js

1012 lines
45 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 — boot, game loop, phase machine. Lane A owns this file.
*
* This is the assembly point: every other lane's module is proven in isolation,
* and this file is where they become one game. Two rules make that possible and
* are worth not breaking:
*
* - **Nothing auto-runs on import.** index.html calls boot(). That keeps
* createGame() importable from selftest.html, which must never construct a
* WebGLRenderer.
* - **The loop is a fixed-dt accumulator.** Sim modules only ever see FIXED_DT,
* never a real frame delta. That is the whole reason selftest can fast-forward
* a 90 s storm in milliseconds and get the numbers the player got.
*/
import * as THREE from '../vendor/three.module.js';
import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, SPARE_COST, Emitter } from './contracts.js';
import { createWorld, loadSite } from './world.js';
import { createCameraRig } from './camera.js';
import { loadStorm, createWind } from './weather.js';
import { SailRig, createSailView } from './sail.js';
import { createPlayer } from './player.js';
import { Interact, wireYardActions } from './interact.js';
import { createDebris } from './debris.js';
import { createSkyFx } from './skyfx.js';
import { createRiggingUI } from './rigging.js';
import { createHud } from './hud.js';
import { createWeek, NIGHTS, nightAt } from './week.js';
/** The calm day the forecast and prep phases run under. */
export const CALM_STORM = 'storm_01_gentle';
/**
* The distinct storm keys the session preloads. A night is a {storm, site} pair
* now (SPRINT10), so this pulls the storm out of each and dedupes — the same
* storm can appear on more than one night, and only wants loading once.
*
* CALM_STORM is in the list EXPLICITLY (SPRINT11, Lane D's landmine) because
* prep and forecast run under it whether or not any night does. It used to load
* only because night one happened to be `storm_01_gentle` — an invariant nothing
* stated and nothing checked. Take gentle out of NIGHTS and `calmWind` is
* undefined, `windTime()` throws every non-storm frame, and the game dies at boot
* on "Cannot read properties of undefined (reading 'duration')" — a message that
* mentions neither storms, nor calm, nor the week. D lost ten minutes to it
* rewriting NIGHTS[0] for a harness, and gate 2 is the sprint that rewrote every
* night entry. Same species as the enum: an invariant nothing checks.
*
* A function, and exported, so the invariant IS checkable: the test feeds it a
* ladder with no gentle storm anywhere and asserts the calm day survives. Assert
* it against the shipped NIGHTS instead and it passes whether or not the fix is
* there — night one is gentle today, so the bug hides. That assert would be
* decoration, which is the exact failure mode this repo keeps naming.
*
* @param {string[]} [nights] storm keys, defaults to the shipped ladder
*/
export function stormsToPreload(nights = NIGHTS.map((_, i) => nightAt(i).storm)) {
return [...new Set([CALM_STORM, ...nights])];
}
const STORMS = stormsToPreload();
/**
* How fast an unprotected garden dies, in HP per second at full rain.
*
* Decision 7: drain is rain × (1 rain shadow), NOT sun coverage. At night the
* sun shadow is a number about nothing, and storm_02 is a wildnight.
*
* ⚠️ This number is doing less work than it looks like it is, and the reason is
* worth reading before retuning it. Measured over storm_02 with a rig that holds
* 4/4 corners all night: sun coverage over the bed stays ~50%, but the RAIN
* shadow decays 0.38 → 0.04 as the wind builds, averaging 0.23. Driving rain
* blows under the sail and the shadow walks off the bed — which is Lane C's
* model being right about weather, not a bug.
*
* The consequence is that a perfectly rigged bed only ever sits ~23% drier than
* a bare one, so NO value here separates good rigging from none; at 1.6 both
* ended dead, and the spread stays ~20 points at any setting. 0.9 is chosen to
* put a good rig around 60% (a win) and a bare bed just under 50% (a loss), so
* the loop is playable and scores something — but the lever that actually needs
* moving is the shadow geometry, not this. Flagged for Lane C in THREADS.
*/
const GARDEN_DRAIN = 0.9;
/**
* Decision 13's weights. Hail is what actually kills a garden and cloth honestly
* stops it (stones fall ≤20° off vertical; rain leans 73° in a gale and walks
* straight under the sail — Lane C's numbers). Rain is demoted to a drizzle of
* damage so the night still costs you something when it isn't hailing.
*
* SPRINT6 gate 1 lists these first among the balance levers.
*/
const HAIL_WEIGHT = 5.0;
const RAIN_WEIGHT = 0.25;
/**
* The garden: the thing you are actually protecting, and the only score that
* matters. Deliberately not inside hud.js — the HUD reads, it doesn't decide.
*
* It keeps its damage split by CAUSE, and that isn't bookkeeping for its own
* sake: the aftermath verdict is the game's entire feedback channel, and a
* verdict that guesses teaches the wrong lesson. A garden that only knows it
* went from 100 to 39 cannot tell a player whether their hardware let go or
* their perfectly-held sail simply wasn't over the bed — and those are opposite
* mistakes with opposite fixes.
*/
function createGarden(world) {
let hp = 100;
let state = 'full';
let byHail = 0;
let byRain = 0;
return {
get hp() { return hp; },
get state() { return state; },
/** HP lost to each cause this run. The verdict reads these. */
get damage() { return { hail: byHail, rain: byRain }; },
reset() { hp = 100; state = 'full'; byHail = 0; byRain = 0; world.setPlants('full'); },
/**
* Decision 13, and the two terms stay SEPARATE on the way in rather than
* being pre-summed by the caller — that sum is exactly the information the
* verdict needs and can never recover afterwards.
*
* @param {number} dt
* @param {number} hail 0..1 sky.gardenHailExposure(bed, t)
* @param {number} rain 0..1 sky.gardenExposure(bed, t)
*/
step(dt, hail, rain) {
const dHail = HAIL_WEIGHT * hail * GARDEN_DRAIN * dt;
const dRain = RAIN_WEIGHT * rain * GARDEN_DRAIN * dt;
const total = Math.min(hp, dHail + dRain);
if (total > 0) {
// Attribute proportionally, so the split still adds up on the last tick
// when the garden bottoms out at 0 mid-step.
const scale = total / (dHail + dRain);
byHail += dHail * scale;
byRain += dRain * scale;
hp -= total;
}
const next = hp > 66 ? 'full' : hp > 33 ? 'tattered' : 'dead';
if (next !== state) { state = next; world.setPlants(next); }
},
};
}
/**
* Why the night went the way it did — read from what ACTUALLY happened.
*
* This is the whole of the game's feedback channel, and it was lying: any run
* ending under 50 read "the rain found what you skimped on", including a run
* that held 4/4 corners and skimped on nothing. That is not a typo-grade bug.
* DESIGN.md wants every disaster to replay in the player's head as "…the
* shackle, I knew about the shackle" — a verdict that blames the wrong thing
* teaches the opposite of the lesson the storm just spent 90 seconds giving.
*
* So: pick from the failure modes the run can prove, in the order the player
* most needs to hear them. The two garden losses are deliberately different
* sentences, because they are opposite mistakes — hardware you under-bought
* versus a rig that held perfectly and simply wasn't over the bed.
*
* @returns {{verdict: string, mode: string}}
*/
export function verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped }) {
// SPRINT12 — B's flag, actioned: "weakest link that went first" reduces on
// the EFFECTIVE rating (rating × ratingHint), matching sail.js's failure
// line and B's summary.weakest tie-break fix. On the bare rating a rated
// shackle on the carport beam (6.5 × 0.22 = 1.43 kN effective) out-ranks a
// carabiner on honest steel and the verdict names the wrong culprit — this
// sentence exists to teach which steel lied.
const eff = (c) => c.hw.rating * (c.anchor?.ratingHint ?? 1);
const worst = lost.length
? lost.reduce((a, c) => (a && eff(a) <= eff(c) ? a : c))
: null;
const named = worst ? `${worst.hw.name} at ${worst.anchorId.toUpperCase()}` : '';
const hailKilled = dmg.hail > dmg.rain;
// SPRINT9 decision 1. This case had to come FIRST because it did not used to
// exist: `lost >= 2` was unconditionally a cascade-and-a-loss, and under the
// new rule it can be the best night the game has. A sail that tore itself
// apart while the bed came through is DESIGN.md's actual story — the firework
// you paid for — and the aftermath's hardware bill is where that payment goes.
if (win && lost.length >= 2) {
return { mode: 'pyrrhic',
verdict: `THE GARDEN MADE IT. The sail didn't — the ${named} went first and took `
+ `${lost.length - 1} more with it. That is what the sail was for.` };
}
if (!win && lost.length >= 2) {
return { mode: 'cascade',
verdict: `THE SAIL LOST. The ${named} went first — and took ${lost.length - 1} more with it.` };
}
if (lost.length === 1 && !win) {
return { mode: 'corner',
verdict: `THE SAIL LOST. One corner short: the ${named} let go.` };
}
if (!win && hailKilled) {
// The lesson the old verdict destroyed. Every corner held; the rig was
// simply not over the thing it was paid to protect.
return { mode: 'uncovered',
verdict: 'THE GARDEN IS GONE — and every corner held. The hail fell where your sail wasn\'t.' };
}
if (!win) {
return { mode: 'rain',
verdict: 'THE GARDEN IS GONE. Not dramatic — just a long night of rain on open ground.' };
}
if (pondDumped > 50) {
return { mode: 'broomed',
verdict: `THE GARDEN MADE IT. You put ${Math.round(pondDumped)} kg of water on your own head to do it.` };
}
if (pondPeak > 80) {
return { mode: 'ponded',
verdict: 'THE GARDEN MADE IT — with a belly full of water. That flat spot will find you.' };
}
if (lost.length === 1) {
return { mode: 'held-one-down',
verdict: `THE GARDEN MADE IT. The ${named} let go and the rest carried it.` };
}
if (hp >= 85) {
return { mode: 'clean', verdict: 'THE GARDEN MADE IT — not a leaf out of place.' };
}
return { mode: 'mostly', verdict: 'THE GARDEN MADE IT. Mostly.' };
}
// ---------------------------------------------------------------------------
// Phase machine
// ---------------------------------------------------------------------------
/**
* forecast → prep → storm → aftermath → forecast.
* @returns {import('./contracts.js').Game}
*/
export function createGame() {
const emitter = new Emitter();
let phase = 'forecast';
let phaseT = 0;
return {
get phase() { return phase; },
/** Seconds since this phase began. The storm clock. */
get phaseT() { return phaseT; },
on(type, fn) { return emitter.on(type, fn); },
/** @param {'forecast'|'prep'|'storm'|'aftermath'} next */
setPhase(next) {
if (!PHASES.includes(next)) throw new Error(`unknown phase '${next}'`);
if (next === phase) return;
const from = phase;
phase = next;
phaseT = 0;
emitter.emit('phaseChange', { from, to: next });
},
/** Advance to the next phase in loop order. */
advance() {
this.setPhase(PHASES[(PHASES.indexOf(phase) + 1) % PHASES.length]);
},
tick(dt) {
phaseT += dt;
// The storm is on a timer; every other phase waits for the player.
if (phase === 'storm' && phaseT >= STORM_LEN) this.setPhase('aftermath');
},
};
}
// ---------------------------------------------------------------------------
// Wind router
// ---------------------------------------------------------------------------
/**
* One wind object whose identity never changes, delegating to whichever storm
* is currently running.
*
* Every consumer binds to wind exactly once, at construction — the yard closes
* over it for tree sway, createPlayer takes it in opts, createDebris reads its
* event stream. So swapping storm_01 for storm_02 at the phase change has to be
* a re-point, not a re-wire, or half the game would still be sampling the calm
* day while the other half is in a gale.
*
* Shelters are applied to every storm rather than just the active one: they
* describe the yard's trees, which don't stop existing when the weather turns.
*
* ⚠️ **This delegation list is hand-maintained, and that has already cost us
* once.** When Lane C added rainMmPerHour/rainDepthMm for ponding, this router
* didn't forward them: every test still passed, because tests hold a real wind
* while only the GAME holds the router — so B's ponding would have been green
* across the board and done nothing in the actual yard. The integrator caught it
* by hand at merge.
*
* Anything new on the wind contract must be added here too. `js/tests/a.test.js`
* has a tripwire that diffs this object against a real wind and fails naming
* whatever is missing, so the next omission is a red test rather than a system
* that silently isn't plugged in.
*
* @param {object[]} all every wind this session can switch between
*/
export function createWindRouter(all) {
let active = all[0];
const router = {
/** The wind currently in force. Assign through use(). */
get active() { return active; },
use(w) { active = w; return router; },
sample: (pos, t, out) => active.sample(pos, t, out),
speedAt: (pos, t) => active.speedAt(pos, t),
gustTelegraph: (t) => active.gustTelegraph(t),
eventsBetween: (a, b) => active.eventsBetween(a, b),
rainAt: (t) => active.rainAt(t),
rainMmPerHour: (t) => active.rainMmPerHour(t),
rainDepthMm: (a, b) => active.rainDepthMm(a, b),
hailAt: (t) => active.hailAt(t), // SPRINT5 decision 13 — the garden score hangs off this
dirAt: (t) => active.dirAt(t),
setShelters(list) {
for (const w of all) w.setShelters(list);
return router;
},
setSheltersFromTrees(trees, o = {}) {
return router.setShelters(trees.map((tr) => ({
x: tr.pos ? tr.pos.x : tr.x,
z: tr.pos ? tr.pos.z : tr.z,
radius: o.radius ?? tr.radius ?? 3,
strength: o.strength ?? 0.45,
length: o.length ?? 14,
})));
},
setVenturi(list) { // SPRINT9 site_02 — funnels are site geometry
for (const w of all) w.setVenturi(list);
return router;
},
get venturi() { return active.venturi; },
get hailSize() { return active.hailSize; }, // SPRINT5 decision 13
get duration() { return active.duration; },
get gusts() { return active.gusts; },
get def() { return active.def; },
get seed() { return active.seed; },
get core() { return active.core; },
};
return router;
}
// ---------------------------------------------------------------------------
// Debris models
// ---------------------------------------------------------------------------
/**
* Lane E's crates and tubs, keyed by the names storm JSON spawns and debris.js
* has radii for. A browser can't glob a directory, so the list is explicit —
* and it should stay matched to MODEL_SPEC in debris.js (Lane C's ask: tell them
* rather than fighting the radii).
*
* Missing files are not fatal: debris.js falls back to a graybox box per piece,
* which is exactly the degrade-quietly behaviour Lane C designed for.
*/
const DEBRIS_MODELS = ['BlueCrate_v2', 'BlackTub_v2', 'WhiteTub_v2', 'WoodenBin_v2'];
async function loadDebrisModels() {
const { GLTFLoader } = await import('../vendor/addons/loaders/GLTFLoader.js');
const loader = new GLTFLoader();
const out = {};
await Promise.all(DEBRIS_MODELS.map(async (name) => {
try {
const gltf = await loader.loadAsync(`./models/debris/${name}.glb`);
gltf.scene.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
out[name] = gltf.scene;
} catch (err) {
console.warn(`[main] debris model ${name} unavailable, using graybox:`, err.message);
}
}));
return out;
}
// ---------------------------------------------------------------------------
// Boot
// ---------------------------------------------------------------------------
/**
* @param {object} [opts]
* @param {HTMLCanvasElement} [opts.canvas]
*/
export async function boot(opts = {}) {
const canvas = opts.canvas ?? document.getElementById('c');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
const scene = new THREE.Scene();
// --- 1. weather ---------------------------------------------------------
// Every storm loads up front: the forecast card has to read their shapes to
// sell them before the player has agreed to face one.
const defs = Object.fromEntries(
await Promise.all(STORMS.map(async (k) => [k, await loadStorm(k)])),
);
const winds = Object.fromEntries(Object.entries(defs).map(([k, def]) => [k, createWind(def)]));
const calmWind = winds[CALM_STORM];
let stormKey = 'storm_02_wildnight';
const wind = createWindRouter(Object.values(winds));
// --- world & camera -----------------------------------------------------
// SPRINT10: the yard is data, and the week hands over a different one per
// night — night three is the corner block. `world` is a `let` because the
// site changes under it; everything downstream reads `world.*` late (the loop,
// scoreRun, the garden), so re-pointing this binding re-points all of them
// without threading a mutable through fourteen closures.
let world;
let player;
let currentSite = null;
// rig and rigging are assigned further down but referenced by loadSiteInto's
// re-point step, which runs once at boot BEFORE they're built — declared here
// as `let` so that first call sees `undefined` (a clean no-op) rather than a
// temporal-dead-zone throw. Boot builds them against the same fresh world.
let rig;
let rigging;
const cameraRig = createCameraRig(canvas);
const interact = new Interact();
// Site blurbs for the forecast card, so a new yard announces itself. Filled
// as each site is loaded; the card degrades gracefully if a name is missing.
const siteMeta = {};
/**
* Build (or rebuild) the yard for a site. Idempotent per site name — asked for
* the one already standing, it no-ops, so calling it every forecast is free on
* the four backyard nights and only pays on the one that moves.
*
* The player, camera, rig, hud and wind OBJECTS all survive; only the yard
* group and the anchor set are replaced. That's the whole reason this is a
* contained change and not a re-boot: those systems read the world through its
* interface every frame, so a new world instance is picked up, not re-wired.
*/
async function loadSiteInto(siteName) {
if (siteName === currentSite) return world;
const siteDef = await loadSite(siteName);
siteMeta[siteName] = { name: siteDef.name, blurb: siteDef.blurb };
if (world) { world.dispose(); player?.dispose?.(); }
world = createWorld(scene, { wind, site: siteDef });
await world.dress();
currentSite = siteName;
cameraRig.setSolids(world.solids);
cameraRig.setGround(world.heightAt);
// Lane C: local wind effects are per-yard. A venturi (site_02's screaming
// gap) and tree shelters both re-register here; empty/absent is a no-op,
// which is what keeps backyard_01 byte-identical.
wind.setVenturi?.(siteDef.wind?.venturi ?? []);
wind.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree'));
// The player is rebuilt with the yard, not re-pointed: createPlayer captures
// world into its ground clamp, solid collider, ladder and broom, none of
// which have a setter (Lane D's modules). A site change only happens at a
// forecast, never mid-storm, so a clean rebuild is honest and cheap.
player = await createPlayer(scene, world, cameraRig, { wind, interact });
// Re-point everything that captured the old anchor set. Done HERE, right
// after the rebuild, rather than in the caller — a caller-side `if (switched)`
// was fragile (it broke the moment a debug path had already advanced
// currentSite). If the world was rebuilt, these are stale, full stop.
// · rig / session: created at boot, hold anchors by reference.
// · rigging UI markers: Lane B's file builds clickable markers from
// world.anchors at construction and has no setter — so the corner
// block's markers don't exist and its panel lists the backyard. That's
// the one piece I can't re-point without reaching into their module;
// requested `rigging.setWorld(world)` in THREADS. Session + rig follow,
// so it's riggable from code/audit; the CLICKABLE UI wants Lane B.
// (rig and rigging are declared below and don't exist on the first, boot-time
// call — the guards make that the no-op it should be; boot creates them
// against this same fresh world moments later.)
if (rig) rig.anchors = world.anchors;
if (rigging) {
rigging.session.anchors = world.anchors;
rigging.setWorld?.(world);
}
return world;
}
// The opening yard. `opts.site` is honoured by seeking the WEEK to that site's
// night (see below, where the week exists) — showTonight() then loads it as
// that night's site, which is why this can just load it directly here: the two
// agree by construction now instead of racing. Before SPRINT11 they didn't,
// and the markers ended up on a different yard than the world.
await loadSiteInto(opts.site ?? nightAt(0).site);
// --- 3. sail ------------------------------------------------------------
rig = new SailRig({ anchors: world.anchors });
let sailView = null;
/**
* Attach the cloth across 4 anchors and (re)build its view.
*
* The order here is load-bearing. createSailView reads rig.pos and rig.tris,
* which don't exist until attach() allocates them in _build() — build the view
* first and it throws on an undefined array. A re-rig can also change the grid,
* so the view has to be rebuilt rather than reused. Both facts make this the
* single door that boot and Lane B's picking adapter should come through.
*
* Re-wiring interact each time is deliberate: its targets close over corner
* objects and attach() makes a fresh corners array, so stale closures would
* point at corners the sim no longer steps. The ids are stable, so this
* replaces the old targets rather than stacking duplicates.
*/
async function rigSail(anchorIds, hwChoices, tension = 1.0) {
rig.attach(anchorIds, hwChoices, tension);
if (sailView) {
scene.remove(sailView);
sailView.traverse((o) => { o.geometry?.dispose(); o.material?.dispose(); });
}
sailView = await createSailView(rig);
scene.add(sailView);
wireYardActions(interact, { sailRig: rig, world });
return sailView;
}
// Until Lane B's prep-phase picking adapter lands (SPRINT2 §B.3), rig a
// default quad so the yard has a live sail and Lane D has something to
// repair. Deliberately the prototype's AUTO loadout — one dodgy carabiner
// corner. It also spans most of the yard, which is the 70192 m² problem
// decision 2 fixes in step 6, not a fault in the cloth.
const game = createGame();
const garden = createGarden(world);
// --- clocks -------------------------------------------------------------
// Two of them, and the distinction matters. `simT` is wall-clock seconds since
// boot. `windT` is STORM time — storm JSON is authored with t=0 at the storm's
// first gust, so it's phase time during the storm, and off-storm it wraps the
// calm day around its own duration so the breeze keeps breathing however long
// you spend rigging. Every sim module samples windT; nothing samples simT.
let simT = 0;
let windT = 0;
let acc = 0;
function windTime() {
if (game.phase === 'storm') return game.phaseT;
return simT % Math.max(1, calmWind.duration);
}
// --- 4. sky, audio, debris ---------------------------------------------
const events = [];
const pushEvent = (text) => {
events.push({ t: game.phaseT, text });
if (events.length > 4) events.shift();
};
const debris = createDebris({
wind,
scene,
heightAt: world.heightAt,
// knockdown(t, dirX, dirZ) — the first arg is the sim clock, NOT the impact
// magnitude. Passing `impact` here would jam ~40 into the state machine's
// start time and the player would never get up. The piece's own velocity is
// the direction, so you fall the way the crate was travelling.
onHitPlayer: (piece) => player.sim.knockdown(windT, piece.vx, piece.vz),
onEvent: pushEvent,
});
debris.setModels(await loadDebrisModels());
// skyfx reads the storm's `sky` block at construction (darkness, cloud scroll,
// night), so it is rebuilt when the storm changes rather than re-pointed like
// wind. dispose() hands world.sun/world.hemi back exactly as they were, which
// is what makes that safe to do mid-session.
let sky = null;
let audioUnlocked = false;
function makeSky() {
if (sky) sky.dispose();
sky = createSkyFx({
scene,
camera: cameraRig.object,
wind,
sun: world.sun,
hemi: world.hemi,
onEvent: pushEvent,
});
if (audioUnlocked) sky.unlockAudio();
return sky;
}
makeSky();
// Browsers won't start an AudioContext without a gesture. Without this the
// storm is silent, and half of DESIGN.md's threat model is audible.
const unlock = () => {
if (audioUnlocked) return;
audioUnlocked = true;
sky?.unlockAudio();
removeEventListener('pointerdown', unlock);
removeEventListener('keydown', unlock);
};
addEventListener('pointerdown', unlock);
addEventListener('keydown', unlock);
// --- 5. the face --------------------------------------------------------
const banner = document.getElementById('banner');
const hud = createHud({
scene, camera: cameraRig.object, game, world, wind, player, rig, garden, events,
getSky: () => sky,
});
// Lane B's picking adapter. `panel:false` — their built-in panel is a fine
// bench, but hud.js owns the screen now, so the two shouldn't both draw.
rigging = await createRiggingUI({
scene,
camera: cameraRig.object,
domElement: canvas,
world,
onCommit: (ids, hw, tension) => { void rigSail(ids, hw, tension); },
onMessage: pushEvent,
panel: true,
});
/**
* Clear last round's rig so "play again" is a new job, not a continuation.
*
* Without this you inherit the previous round's corners AND its spent budget —
* a second round starts at $35 with three corners already hung, and the four
* anchors you click get silently ignored because the session is already full.
* That is not a subtle failure; it makes the second round unplayable.
*
* Done through the session's own public moves rather than by reaching into its
* fields: unrig() refunds the corner's CURRENT hardware and setSpares(0)
* refunds the spare, so the budget walks back to $80 on its own and the
* economy stays the single source of truth. (Lane B: a `session.reset()` would
* say this better than five lines of mine — asked in THREADS.)
*/
function resetRig() {
const s = rigging.session;
for (const p of [...s.picks]) s.unrig(p.anchorId);
s.setSpares(0);
s.setTension(1.0);
}
/** What the storm cost, assembled at the moment it ends. */
function scoreRun() {
const lost = rig.corners.filter((c) => c.broken);
const bill = lost.reduce((s, c) => s + c.hw.cost, 0);
const collateral = [];
// The gnome is collateral if the sail came down over it. DESIGN.md: the
// worst debris in any storm is your own failed work.
if (lost.length >= 2) collateral.push({ what: 'garden gnome', cost: world.gnome.collateralValue });
/**
* SPRINT11 — the carport trap finally BILLS (§gate 1).
*
* E shipped the trap in Sprint 10 and said it plainly: the anchors say
* `collateral:"carport"` but nothing said what a carport COSTS, so nothing
* scored it. Until now you could tie 25 m2 to the worst steel in the game,
* lose it, and pay for a $15 shackle. The temptation was real and the
* consequence was decoration — the site's whole thesis, missing its verb.
*
* ONE broken corner is enough, unlike the gnome's two. The gnome needs the
* sail to come down over it; the carport doesn't need the sail at all. That
* beam is rated 0.22 and holds a roof — let go of it under load and the roof
* is what leaves. Requiring a second failure would say the first one was
* free, which is the lie this ruling exists to delete.
*
* Priced per STRUCTURE, not per corner: two beams letting go is one carport
* gone, not $360. It bills once and the wreck swaps once.
*/
const taken = new Set();
for (const c of lost) {
const key = world.anchor(c.anchorId)?.collateral;
const priced = key ? world.collateralFor(key) : null;
if (!priced || taken.has(key)) continue;
taken.add(key);
collateral.push({ what: priced.label, cost: priced.cost });
world.wreckStructure(key); // false offline/graybox — the bill still lands
}
const s = rigging.summary;
const hp = garden.hp;
/**
* SPRINT9 decision 1 — Lane A's ruling on the pyrrhic win. It was
* `hp >= 50 && lost.length < 2`; the corner clause is gone.
*
* **The garden is the client's. The sail is yours.** Saving the bed with a
* rig that tore itself apart is the job done expensively — not a failure.
* DESIGN.md doesn't call a cascade a loss, it calls it "a firework you paid
* for", and *paid for* is the whole point: the aftermath already bills you
* for the broken hardware, the collateral, and a week's fee you didn't earn
* cleanly. Gating the win on corners bills you twice for the same night and
* then lies about it — which is the same species as the verdict that used to
* tell a 4/4 hold they'd skimped.
*
* B and D both arrived here independently with the numbers, and B checked
* what it does NOT break: cheap rigs still lose (4× carabiner → hp 38), rigs
* that miss the bed still lose (hp 36), and $80 still cannot buy immunity.
* The corners are priced, not gated.
*/
const win = hp >= 50;
const dmg = garden.damage;
const { verdict, mode } = verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped });
return {
hp,
cornersLost: lost.length,
cornersTotal: rig.corners.length || 4,
bill,
collateral,
budgetLeft: Math.max(0, s.budget - bill - collateral.reduce((a, c) => a + c.cost, 0)),
win,
subtitle: lost.length
? `${lost.map((c) => `${c.hw.name} at ${c.anchorId.toUpperCase()}`).join(', ')} let go.`
: 'Every corner held.',
/** Decision 13's headline: how much of the bed's damage the cloth stopped. */
hailBlocked: dmg.hail + dmg.rain > 0
? `${Math.round(dmg.hail)} HP to hail, ${Math.round(dmg.rain)} to rain`
: 'Nothing reached the bed.',
/**
* What you can unclip and take home: hardware still on an unbroken corner,
* plus a spare you never had to use. week.js refunds it at half — a shackle
* that rode out a gale isn't new any more. Broken gear is worth nothing,
* which is the whole of `bill`.
*/
intactHardwareValue:
rig.corners.filter((c) => !c.broken).reduce((sum, c) => sum + c.hw.cost, 0)
+ (rigging.session.spares ?? 0) * SPARE_COST,
pondPeak,
pondDumped,
verdict,
/** Which failure mode the verdict is speaking from. Asserted in a.test.js. */
verdictMode: mode,
};
}
// --- the week ------------------------------------------------------------
// Five nights, one wallet. The forecast stops being a difficulty picker — the
// ladder decides what's coming and the only question left is what you rig
// against it with the money you have.
// `opts.bank` — a debug boot's wallet (SPRINT11, for Lane D). Seeking to a
// night doesn't EARN the nights before it, so `boot({site:'site_02...'})` alone
// puts you on night three with night one's $80 and the shop reads OFF THE JOB —
// which is precisely the harness artefact D had to explain away in their
// playtest ("the $0 above is MY harness's fault, not your balance"). They
// measured the real night-three bank at $237. `boot({site, bank:237})` is that
// run, honestly, without rewriting NIGHTS to get it.
const week = createWeek({ bank: opts.bank });
let spentThisNight = 0;
/**
* `boot({site})` — honoured through the WEEK, which is the only way it can be
* true (SPRINT11, Lane D's bug 2).
*
* It was a documented lie: :468 loaded opts.site, then showTonight() re-ran
* `loadSiteInto(week.site)` and overwrote it — but the markers had been built
* in between, off the requested site. So it failed INVERSELY, markers on one
* yard and world on another, from the first frame. Worse than not working: the
* hook anyone debugging a site reaches for first handed them a hybrid.
*
* Seeking the week is the fix rather than forcing the site, because a site
* without its night is half a game — no client, no brief, no storm, no bank.
* Night 3 debugged at night 1's $80 reads "OFF THE JOB" and tells you nothing
* about the corner block; at its own night it's the $237 bank D measured. D
* had to rewrite NIGHTS[0] to get a coherent cold boot; this is that, honestly.
*/
if (opts.site) {
const i = NIGHTS.findIndex((_, n) => nightAt(n).site === opts.site);
if (i < 0) throw new Error(`main: boot({site:'${opts.site}'}) — no night in NIGHTS uses that site`);
for (let n = 0; n < i; n++) week.advance();
}
/**
* Tonight's card. Reads the bank, not START_BUDGET.
*
* NOTE the `budget` we hand the shop: `rigging.session.spent` computes
* `START_BUDGET - budget` against the module constant rather than the session's
* own starting cash, so with a bank of $63 it reports $17 spent before you buy
* anything. Lane B's file, flagged in THREADS — main.js therefore tracks
* `spentThisNight` itself off the bank rather than trusting `.spent`.
*/
async function showTonight() {
// SPRINT10: tonight's yard. loadSiteInto no-ops when the site is unchanged
// (the four backyard nights) and rebuilds + re-points everything holding the
// old anchor set when it isn't (night three). The re-point lives inside
// loadSiteInto, keyed on the rebuild itself, so it can't desync from it.
await loadSiteInto(week.site);
// B's setBudget (SPRINT11): re-bank + reset in one named call. This was
// main.js's one private touch into their session (`_startBudget` + reset(),
// asked in THREADS, landed, fake deleted — same route reset() took).
rigging.session.setBudget(week.bank);
spentThisNight = 0;
// SPRINT11 — the job sheet reads three new things off the week: tonight's
// JOB (client + brief), the QUOTE (what it pays, before you rig it, which is
// the half of DESIGN.md's brief the game never showed), and TOMORROW's storm
// def for Lane C's forecast lead — every storm is loaded up front, so
// tomorrow costs nothing but an index. Null on the final night: there is no
// tomorrow, and a card that hedges about one would be lying.
const tomorrowDef = week.isFinalNight ? null : defs[nightAt(week.night).storm];
hud.showForecast(
{ key: week.stormKey, def: defs[week.stormKey], site: siteMeta[week.site], tomorrowDef },
{
night: week.night, nights: week.nights, bank: week.bank, log: week.log,
job: week.job, quote: week.quote(defs[week.stormKey]), isFinalNight: week.isFinalNight,
},
() => { stormKey = week.stormKey; game.setPhase('prep'); },
);
}
// --- the night's evidence ------------------------------------------------
// What the sail carried and what the player took on the head, so the verdict
// can point at things that actually happened rather than infer from the score.
let pondPeak = 0;
let pondDumped = 0;
// Subscribed once: rigSail() calls attach() on the same rig object, so the
// Emitter survives a re-rig. Lane B tags a dump with `reason` when the water
// left because something failed (corner break, belly tear) and leaves it off
// when the player poked it out with the broom — which is the difference
// between "the sail lost its water" and "you wore it".
rig.events.on('pondDump', (e) => {
if (!e.reason) pondDumped += e.kg;
});
// --- phases -------------------------------------------------------------
game.on('phaseChange', ({ to }) => {
// Prep and forecast happen on the calm day; the storm you picked only
// arrives when you say go.
wind.use(to === 'storm' ? winds[stormKey] : calmWind);
makeSky();
events.length = 0;
rigging.setActive(to === 'prep');
if (to === 'storm') { pondPeak = 0; pondDumped = 0; }
if (to === 'forecast') {
hud.setDawn(false);
// showTonight rebuilds the yard when the site changes, so it MUST run
// before the resets that touch the world (garden.reset → world.setPlants)
// or the player (rebuilt inside it). Awaited via .then so the resets land
// on the new world/player, not the old one mid-swap. This is the only
// phase transition that can rebuild the scene, so it's the only one that
// has to sequence like this.
showTonight().then(() => {
garden.reset();
resetRig();
player.sim.carrying = null;
});
}
// The card belongs to the phase, not to the button that happened to open it.
// Tying its lifetime to the forecast button meant any other route into prep
// (a debug jump, a future timer, "play again" landing somewhere new) left a
// card floating over a live game, swallowing input.
if (to === 'prep' || to === 'storm') hud.hideCard();
if (to === 'prep') {
hud.setHelp('click an anchor to rig · click again to cycle hardware · shift-click to remove · [ ] tension · S spare · ENTER when you have four');
}
if (to === 'storm') {
hud.setHelp('WASD move · shift run · E repair/pickup · C brace · RMB orbit');
}
if (to === 'aftermath') {
// The dawn comes up BEFORE the scoreboard, on E's 2.2 s ease. Their note
// is right and it's the whole reason this isn't one call: the storm ends,
// the light returns, and only then are you told how you did. Sell the
// survival first.
hud.setDawn(true);
const run = scoreRun();
const settlement = week.settle(run, defs[week.stormKey], spentThisNight);
hud.showAftermath({ ...run, week: settlement }, () => {
if (settlement.outcome === 'continue') { week.advance(); game.setPhase('forecast'); }
else hud.showEndCard(settlement, () => { week.reset(); game.setPhase('forecast'); });
});
}
if (banner && to !== 'forecast' && to !== 'aftermath') {
banner.textContent = to.toUpperCase();
banner.style.opacity = '1';
setTimeout(() => { banner.style.opacity = '0'; }, 1400);
}
});
addEventListener('keydown', (e) => {
if (e.key !== 'Enter' || hud.cardOpen) return;
// Enter means ONE thing: commit the rig and start the night. It used to
// fall through to game.advance() in every phase, which made Enter DURING
// a storm skip straight to a perfect invoice — the storm never ran, every
// corner "held", full pay. Found in the SPRINT12 QA pass, live on the
// public deploy. Forecast/aftermath advance through their cards (cardOpen
// catches those above); nothing else may advance the phase machine.
if (game.phase !== 'prep') return;
if (!rigging.commit()) return;
// Off the bank, not off START_BUDGET — see the note on showTonight().
spentThisNight = week.bank - rigging.summary.budget;
player.sim.carrying = null;
if (rigging.summary.spares > 0) pushEvent(`spare shackle on the shed table — grab it before you need it`);
game.advance();
});
// Straight into the forecast: the card is the game's front door, and it now
// opens on night one of five rather than a difficulty menu.
showTonight();
// --- resize -------------------------------------------------------------
function resize() {
const w = canvas.clientWidth || innerWidth;
const h = canvas.clientHeight || innerHeight;
renderer.setSize(w, h, false);
cameraRig.resize(w, h);
}
addEventListener('resize', resize);
resize();
// --- loop ---------------------------------------------------------------
const clock = new THREE.Clock();
const dev = document.getElementById('dev');
let frames = 0, fpsT = 0, fps = 0;
function step(dt) {
game.tick(dt);
simT += dt;
windT = windTime();
world.update(dt, windT);
player.update(dt, windT);
rig.step(dt, wind, windT, debris);
debris.step(dt, windT, { player: player.sim, sail: rig });
sky?.step(dt, windT, { sail: rig });
rigging.update(dt, windT);
// Decision 7: what hurts the garden is weather the sail didn't stop. Only
// during the storm — the calm day's drizzle is scenery.
//
// Through Lane C's combined helper rather than my own rain × (1 shadow):
// it's the same arithmetic, it's their term to own, and SPRINT5 decision 13
// extends exactly this shape (+ gardenHailExposure) — so when hail lands
// this is one added term here, not a rewrite.
if (game.phase === 'storm') {
// Decision 13 (SPRINT5): hail is the headline garden threat — it falls
// steep, so the sail blocks it and the score finally rewards rigging
// (C proved 4.4× separation). Rain stays as the small honest drain that
// walks under a sail in a gale. Weights chosen so storm_02 unprotected
// loses ~50 HP to its hail bursts (11.4 hail-seconds × 5.0 × 0.9/s) and
// ~10 to rain, while a bed-covering rig cuts the hail term ~4.4×.
const rainExp = sky?.gardenExposure ? sky.gardenExposure(world.gardenBed, windT) : 0;
const hailExp = sky?.gardenHailExposure ? sky.gardenHailExposure(world.gardenBed, windT) : 0;
// Passed separately, not pre-summed: the split is what makes the verdict
// able to tell "your hardware went" from "your sail wasn't over the bed".
garden.step(dt, hailExp, rainExp);
// Pond evidence for the aftermath. Peak is what the sail carried at its
// worst; dumped is what the player took on the head with the broom. Both
// are things the verdict can honestly point at.
if (typeof rig.pondMass === 'function') {
pondPeak = Math.max(pondPeak, rig.pondMass());
}
}
}
function frame() {
// Clamped so a background tab or a breakpoint doesn't make the sim try to
// catch up over thousands of steps and lock the page.
const raw = Math.min(0.25, clock.getDelta());
acc += raw;
let guard = 0;
while (acc >= FIXED_DT && guard++ < 60) {
step(FIXED_DT);
acc -= FIXED_DT;
}
cameraRig.update(raw, player.pos);
sailView?.update();
renderer.render(scene, cameraRig.object);
hud.update(raw, windT);
frames++; fpsT += raw;
if (fpsT >= 0.5) { fps = frames / fpsT; frames = 0; fpsT = 0; }
if (dev) dev.textContent = `${fps.toFixed(0)} fps · ${game.phase} ${game.phaseT.toFixed(1)}s · t ${simT.toFixed(0)}s · debris ${debris.pieces.length}`;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
// Handy for poking at the world from the console, and for the selftest-free
// hand checks the sprint's acceptance actually turns on.
const api = {
renderer, scene, cameraRig, game, wind, rig, rigSail,
// world and player are rebuilt on a site change (SPRINT10), so these are
// getters — a captured reference would be last night's yard.
get world() { return world; },
get player() { return player; },
get currentSite() { return currentSite; },
week, loadSiteInto,
get sailView() { return sailView; },
debris, interact, events, hud, rigging, garden, defs, winds,
get stormKey() { return stormKey; },
set stormKey(k) { stormKey = k; },
scoreRun,
get sky() { return sky; },
get simT() { return simT; },
windTime,
calmWind,
/**
* Drive the sim by hand at fixed dt, and draw on demand. rAF is throttled to
* a standstill in a hidden tab, so these are the only honest way to
* fast-forward a storm or capture one from a headless browser — which is
* exactly what this sprint's "90 s storm_02 run captured" acceptance needs.
* Same code path the rAF loop uses; no test-only branch to drift.
*/
step,
render() {
sailView?.update();
renderer.render(scene, cameraRig.object);
},
};
globalThis.SHADES = api;
return api;
}