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>
1319 lines
61 KiB
JavaScript
1319 lines
61 KiB
JavaScript
/**
|
||
* 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, spawnYawFor } 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';
|
||
import { createGarden } from './garden.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();
|
||
|
||
/**
|
||
* May Enter commit the rig right now? The ONLY route into `game.advance()` that
|
||
* a key may take.
|
||
*
|
||
* A function, and exported, because it is a security-shaped rule on a PUBLIC
|
||
* game and the handler that used to hold it is inside boot(), behind a canvas
|
||
* and a WebGL context — i.e. somewhere no assert can reach. That's precisely how
|
||
* the exploit shipped: `addEventListener('keydown', …)` fell through to
|
||
* `game.advance()` in EVERY phase, so Enter DURING a storm jumped straight to a
|
||
* perfect invoice — the storm never ran, "every corner held", full pay, clean
|
||
* bonus, +$90 banked on partly.party where strangers could find it. The
|
||
* integrator hit it in a QA pass, not a test, because there was no seam to test.
|
||
*
|
||
* So the rule is a value now. Enter means ONE thing: commit the rig and start
|
||
* the night.
|
||
* · `prep` only — forecast and aftermath advance through their own cards, and
|
||
* a storm advances when it ENDS. Nothing else may move the phase machine.
|
||
* · not while a card is open — the card owns the keyboard, and its button is
|
||
* the only way through.
|
||
*
|
||
* @param {string} phase game.phase
|
||
* @param {boolean} cardOpen hud.cardOpen
|
||
*/
|
||
export function enterCommits(phase, cardOpen) {
|
||
return phase === 'prep' && !cardOpen;
|
||
}
|
||
|
||
/**
|
||
* Can this device play HARD YARDS at all? (SPRINT13 gate 3 — the touch notice.)
|
||
*
|
||
* The question is NOT "is this a touchscreen", and getting that wrong is the
|
||
* whole reason this is a function with a comment. `(pointer: coarse)` asks what
|
||
* the PRIMARY pointer is, so a touchscreen laptop — finger on the glass, mouse
|
||
* on the desk, perfectly able to play — answers "coarse" and gets locked out by
|
||
* a courtesy card. That's a worse bug than the dead canvas it replaces, because
|
||
* it takes the game away from someone who could play it.
|
||
*
|
||
* `(any-pointer: fine)` asks the question that actually matters: is there a
|
||
* mouse, trackpad or stylus attached AT ALL? A phone says no. A tablet says no.
|
||
* A hybrid laptop says yes and plays. Detection errs toward LETTING PEOPLE IN:
|
||
* an old browser that doesn't know the query returns `matches: false` for both,
|
||
* and the `?? true` means an unknown device gets the game rather than a lecture.
|
||
*
|
||
* Keyboard can't be feature-detected at all — no browser exposes "is there a
|
||
* keyboard" — so a fine pointer is the honest proxy, and the notice says what we
|
||
* need in words rather than pretending to know.
|
||
*/
|
||
/**
|
||
* The fixed-dt accumulator, as a value: how many steps does this frame owe, and
|
||
* what's left over? (SPRINT13 gate 3 — P.)
|
||
*
|
||
* Pulled out of `frame()` for the same reason `enterCommits` was pulled out of
|
||
* the keydown handler: `frame()` is only ever called by requestAnimationFrame,
|
||
* and rAF does not fire in a hidden tab — so a pause "test" driven through the
|
||
* real loop sits there measuring a sim that was already frozen and reports
|
||
* success. I wrote that probe, and it passed: simT advanced 0 while paused, and
|
||
* 0 while running. The pause rule is a value now, so it can be checked by
|
||
* something other than luck.
|
||
*
|
||
* PAUSED DRAINS THE ACCUMULATOR rather than leaving it standing. `acc` holds up
|
||
* to one FIXED_DT of unspent real time; carrying it across a pause spends it on
|
||
* the first frame after resume — a free sixtieth of a second of storm nobody
|
||
* asked for, which is invisible right up until two runs of a deterministic sim
|
||
* disagree about a gust.
|
||
*
|
||
* @param {number} acc unspent seconds carried from last frame
|
||
* @param {number} raw this frame's real delta, already clamped by the caller
|
||
* @param {boolean} paused
|
||
* @param {number} [max] step ceiling — a breakpoint must not run 4000 steps
|
||
* @returns {{steps:number, acc:number}}
|
||
*/
|
||
export function accumulate(acc, raw, paused, max = 60) {
|
||
if (paused) return { steps: 0, acc: 0 };
|
||
let left = acc + raw;
|
||
let steps = 0;
|
||
while (left >= FIXED_DT && steps < max) { steps++; left -= FIXED_DT; }
|
||
return { steps, acc: left };
|
||
}
|
||
|
||
export function canPlayHere(mm = typeof matchMedia === 'function' ? matchMedia : null) {
|
||
if (!mm) return true; // no matchMedia (node, old): let them in
|
||
try {
|
||
return mm('(any-pointer: fine)').matches ?? true;
|
||
} catch {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Push the mute state at C's audio bus, honestly. Returns whether a bus took
|
||
* it — false means the flag is UI-only on this tree and the HUD must not
|
||
* advertise M (hud.setAudioMuteAvailable reads the same feature-detect).
|
||
*
|
||
* A value rather than a closure so the selftest can fail it (the Enter-guard
|
||
* lesson): the wiring bug this pins is calling `sky.setMute?.()` and never
|
||
* noticing it no-ops, or muting the flag and forgetting the bus after makeSky()
|
||
* hands back a fresh skyfx at a phase change.
|
||
*
|
||
* @param {{ setMute?: (on: boolean) => void } | null} sky
|
||
* @param {boolean} on
|
||
* @returns {boolean} true iff a real bus received the state
|
||
*/
|
||
export function applyMute(sky, on) {
|
||
const bus = typeof sky?.setMute === 'function';
|
||
if (bus) sky.setMute(!!on);
|
||
return bus;
|
||
}
|
||
|
||
/*
|
||
* The garden model — GARDEN_DRAIN, HAIL_WEIGHT, RAIN_WEIGHT, createGarden —
|
||
* moved to garden.js (SPRINT13 gate 1.2, B's export ask): the audit must
|
||
* predict the sim's garden off the sim's OWN code, and module-locals here made
|
||
* that impossible without a drifting copy. The gate-1.4 ruling on GARDEN_DRAIN
|
||
* (it does not move, and why) lives on the constant itself, over there.
|
||
*/
|
||
|
||
/**
|
||
* 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, beyondSaving = false }) {
|
||
// 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.` };
|
||
}
|
||
// SPRINT13 gate 1.4 — A's night-5 ruling (week.js gardenBeyondSaving): on a
|
||
// night whose data says the garden cannot reach the win line at ANY price,
|
||
// every "you could have rigged better" sentence below is a lie, and the
|
||
// uncovered/rain modes are the worst liars — they blame placement on a night
|
||
// where no placement wins. This branch comes before every loss mode: the
|
||
// garden's fate was sealed at the forecast; only the STEEL's fate was the
|
||
// player's. Note it deliberately does NOT fire on a win — if a retune ever
|
||
// makes the flagged night winnable, the flag is stale and week.js says to
|
||
// delete it, not to trust this to paper over it.
|
||
if (!win && beyondSaving) {
|
||
if (lost.length >= 1) {
|
||
return { mode: 'beyond-saving',
|
||
verdict: `THE BED WAS BEYOND SAVING — no sail in the shop stops this much ice. `
|
||
+ `The ${named} letting go is the part that's on you.` };
|
||
}
|
||
return { mode: 'beyond-saving',
|
||
verdict: 'THE BED WAS BEYOND SAVING — no sail in the shop stops this much ice. '
|
||
+ 'Yours held anyway. That was the whole job tonight, and you did it.' };
|
||
}
|
||
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.' };
|
||
}
|
||
|
||
/**
|
||
* SPRINT16 gate 1 — the work history, per corner at dawn: what the ledger
|
||
* keeps. The night already computed every field; this is the one place they're
|
||
* assembled, so week.js can store them (settlement.rig) and tomorrow's sheet
|
||
* can book warranty off them. Shapes per the THREADS seam contract:
|
||
*
|
||
* broke it let go at least once tonight. Read off the EVENT TALLY,
|
||
* not the corner flag — repairCorner() clears `broken`, so by
|
||
* dawn the flag has forgotten every break that was fixed.
|
||
* repaired broke, and was standing again at dawn (D's hold-E). NOT a
|
||
* warranty item, by spec: fixing it mid-storm is the job.
|
||
* brokenAtDawn still down when the storm ended — books tomorrow's warranty
|
||
* line and takes tonight's rep hit. Riding it out broken is
|
||
* the thing the ledger exists to price.
|
||
*
|
||
* `repaired` and `brokenAtDawn` partition `broke` — a corner that broke twice
|
||
* and was fixed once is brokenAtDawn, not repaired. hw/hwCost are what was on
|
||
* the corner AT DAWN (a spare-repaired corner reads the spare, honestly — that
|
||
* is the steel the client's yard is wearing tomorrow). effRating is
|
||
* rating × ratingHint, the number sail.js actually fails on — the same truth
|
||
* the HUD bars and the verdict print, third surface, one formula.
|
||
*
|
||
* A value, exported, for the Enter-guard reason: scoreRun lives inside boot()
|
||
* behind a WebGL context where no assert can reach it, and the ledger's
|
||
* partition semantics are exactly the kind of rule that would otherwise only
|
||
* ever be "tested" by a green suite that never ran it.
|
||
*
|
||
* @param {Array} corners rig.corners at dawn
|
||
* @param {Map<string,{broke:number,repaired:number}>} [tally] the night's break/repair events per anchor
|
||
*/
|
||
export function rigRecordFor(corners, tally = new Map()) {
|
||
return corners.map((c) => {
|
||
const tal = tally.get(c.anchorId);
|
||
const broke = (tal?.broke ?? 0) > 0 || !!c.broken;
|
||
return {
|
||
anchorId: c.anchorId,
|
||
hw: c.hw.name,
|
||
hwCost: c.hw.cost,
|
||
effRating: Math.round(c.hw.rating * (c.anchor?.ratingHint ?? 1)),
|
||
broke,
|
||
repaired: broke && !c.broken,
|
||
brokenAtDawn: !!c.broken,
|
||
};
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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;
|
||
// Same reason, same trap (SPRINT13): loadSiteInto now refreshes the camera's
|
||
// solid set, and that reads sailView — on a first call that happens at boot,
|
||
// before any cloth exists. Declared up here with the others so it reads
|
||
// `undefined` (no cloth yet: use the yard's solids) instead of throwing TDZ
|
||
// from a line whose only crime was being honest about what the camera needs.
|
||
// Caught by the page going blank, which is the loudest a TDZ ever gets.
|
||
let sailView = null;
|
||
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?.(); }
|
||
|
||
// SPRINT14 — the phantom sail, landed. Last night's cloth does not haunt
|
||
// tonight's prep: the view dies WITH the yard it was rigged in, not when
|
||
// the next commit happens to replace it. Before `refreshCameraSolids()`
|
||
// deliberately — that call reads `sailView`, so leaving it a beat later
|
||
// would re-register a disposed mesh as a camera collider on the new site.
|
||
disposeSailView();
|
||
// …and the rig STATE goes with the view, or four kN corner labels keep
|
||
// floating over the new yard on their own (the hud draws them off
|
||
// `rig.rigged`, which B's fix resets on attach — the seam D named). B: if
|
||
// `SailRig` ever grows a real `detach()`, it wins; until then this is the
|
||
// same direct re-point as `rig.anchors` two lines down, and it is simply
|
||
// true — a rig attached to a yard that no longer exists is not rigged.
|
||
if (rig) { if (rig.detach) rig.detach(); else rig.rigged = false; }
|
||
|
||
world = createWorld(scene, { wind, site: siteDef });
|
||
await world.dress();
|
||
currentSite = siteName;
|
||
|
||
refreshCameraSolids();
|
||
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 });
|
||
|
||
// SPRINT13 gate 2.5 — the opening frame, per YARD rather than per game.
|
||
// Done here because the spawn is here: the player and the yard are rebuilt
|
||
// together, so the frame that introduces them is a property of the site, not
|
||
// a constant. site_02 spawns somewhere else with its posts somewhere else,
|
||
// and a yaw hand-tuned against the backyard would frame the corner block by
|
||
// luck. Obstacles come from the site's own data — every vertical thing a
|
||
// pole-through-the-head could be.
|
||
cameraRig.yaw = spawnYawFor(
|
||
player.pos,
|
||
{ x: world.gardenBed.x, z: world.gardenBed.z },
|
||
[
|
||
...(siteDef.posts ?? []),
|
||
...(siteDef.trees ?? []),
|
||
...(siteDef.structures ?? []),
|
||
].map((o) => ({ x: o.x, z: o.z })),
|
||
);
|
||
|
||
// 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 });
|
||
|
||
/**
|
||
* What the camera may not pass through: the yard's solids, PLUS the cloth.
|
||
*
|
||
* SPRINT13 gate 2.5 — "in aftermath the dead draped sail can swallow the
|
||
* camera whole" (QA pass). The camera has collided with the house since
|
||
* Sprint 2 for exactly this reason, and the sail was simply never in the list:
|
||
* while it is up it hangs above head height and nothing notices, but a sail
|
||
* that has FAILED lies in the yard at head height, which is the one moment the
|
||
* player most wants to look at it.
|
||
*
|
||
* Called from both rebuilds, because they invalidate the list independently:
|
||
* loadSiteInto() makes new world.solids, rigSail() makes a new cloth. Either
|
||
* one alone leaves the camera holding a mesh that was disposed.
|
||
*
|
||
* The cloth's bounding sphere is recomputed in sailView.update() every frame,
|
||
* so the raycast reads live geometry rather than the shape it had at rig time.
|
||
* Cheap: the default grid is 10x10, so ~162 triangles against a whole house.
|
||
*/
|
||
function refreshCameraSolids() {
|
||
cameraRig.setSolids(sailView ? [...world.solids, sailView] : world.solids);
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
/**
|
||
* Take the cloth off the glass and free it. SPRINT14 — the phantom sail.
|
||
*
|
||
* This teardown used to be open-coded inside `rigSail` and NOWHERE else,
|
||
* which meant the only thing that could ever remove a sail from the scene was
|
||
* rigging the next one. So night 3's committed rig — cloth, and its kN corner
|
||
* labels, with `rig.t` still at 90.8 — hung in mid-air over the Hendersons'
|
||
* backyard through night 4's forecast and prep until the new commit
|
||
* re-attached (D's sighting, Sprint 13; I ruled the view half mine and filed
|
||
* it rather than landing a UI-lifecycle change I hadn't watched in play).
|
||
*
|
||
* Two call sites now, one disposal: a re-rig replaces the cloth, and a SITE
|
||
* CHANGE ends it. Also disposes the material's texture, which the open-coded
|
||
* version missed — `traverse` disposes geometry and material but a material's
|
||
* `.map` is a separate GPU object, and the weave was leaking one per re-rig.
|
||
*/
|
||
function disposeSailView() {
|
||
if (!sailView) return;
|
||
scene.remove(sailView);
|
||
sailView.traverse((o) => {
|
||
o.geometry?.dispose();
|
||
o.material?.map?.dispose();
|
||
o.material?.dispose();
|
||
});
|
||
sailView = null;
|
||
}
|
||
|
||
async function rigSail(anchorIds, hwChoices, tension = 1.0) {
|
||
rig.attach(anchorIds, hwChoices, tension);
|
||
disposeSailView();
|
||
sailView = await createSailView(rig);
|
||
scene.add(sailView);
|
||
refreshCameraSolids(); // the new cloth; the one it replaced is disposed
|
||
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 70–192 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;
|
||
/** SPRINT13 gate 3 — the front door's two comfort keys. Neither is sim state: */
|
||
let paused = false; // P: the accumulator stops; nothing deterministic sees a dt
|
||
let muted = false; // M: Lane C's bus, once it has a tap (see setMuted)
|
||
|
||
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:true` is DELIBERATE and the old comment
|
||
// here ("panel:false — hud.js owns the screen") described an intent that
|
||
// never happened: hud.js never grew a prep table, so B's panel is the only
|
||
// prep UI there is. If hud.js ever takes prep over, flip this to false in
|
||
// the same commit — two panels drawing at once is the bug the old comment
|
||
// was worried about, and it was half right: the flag and the comment
|
||
// disagreed for two sprints and nobody could tell which one was the plan.
|
||
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,
|
||
beyondSaving: week.job.gardenBeyondSaving });
|
||
|
||
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,
|
||
/** SPRINT16 gate 1 — the work history. See rigRecordFor above. */
|
||
rigRecord: rigRecordFor(rig.corners, nightTally),
|
||
};
|
||
}
|
||
|
||
// --- 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,
|
||
// SPRINT16 — the letterhead's number. The quote carries it too
|
||
// (quote.rep), but the sheet renders a letterhead even on nights whose
|
||
// quote degrades away, so it rides the week block explicitly.
|
||
rep: week.rep,
|
||
},
|
||
() => { 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;
|
||
});
|
||
|
||
// SPRINT16 gate 1 — the night's break/repair tally, per anchor. The ledger's
|
||
// `repaired` bit cannot be read off the corners alone: repairCorner() clears
|
||
// `broken`, so at dawn a fixed corner looks identical to one that never went.
|
||
// Same pattern as pondDumped above (subscribe once, reset at storm start) and
|
||
// for the same reason: the Emitter survives re-rigs, and pre-storm events —
|
||
// a divergence break on the calm day, say — are not the night's story.
|
||
/** @type {Map<string, {broke:number, repaired:number}>} */
|
||
const nightTally = new Map();
|
||
const tallyFor = (id) => {
|
||
let t = nightTally.get(id);
|
||
if (!t) { t = { broke: 0, repaired: 0 }; nightTally.set(id, t); }
|
||
return t;
|
||
};
|
||
rig.events.on('break', (e) => { tallyFor(e.anchorId).broke++; });
|
||
rig.events.on('repair', (e) => { tallyFor(e.anchorId).repaired++; });
|
||
|
||
// --- 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');
|
||
|
||
// A pause never survives the phase it was taken in. Leaving `paused` true on
|
||
// the way out of a storm would carry it into the next night, where P is
|
||
// inert (nothing but the storm has a clock) — so the flag would be stuck on,
|
||
// unreachable, and the following storm would open frozen with no way to
|
||
// unfreeze it. That's a soft-lock built out of a comfort feature, which is
|
||
// the same shape as the night-3 one D found: a state nothing could clear.
|
||
// makeSky() also hands back a fresh skyfx, so the mute has to be re-applied
|
||
// or muting silently expires at the phase boundary.
|
||
if (paused) { paused = false; hud.setPaused(false); }
|
||
if (muted) setMuted(true);
|
||
|
||
if (to === 'storm') { pondPeak = 0; pondDumped = 0; nightTally.clear(); }
|
||
|
||
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') {
|
||
// P is only offered where it does something (the storm is the one clock
|
||
// that doesn't wait), and M only once C's bus has a tap — hud owns that
|
||
// question, because a help line that lists a dead key is a help line that
|
||
// teaches a stranger the game is broken.
|
||
hud.setHelp(`WASD move · shift run · E repair/pickup · C brace · RMB orbit · ${hud.comfortKeysHint()}`);
|
||
}
|
||
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') return;
|
||
if (!enterCommits(game.phase, hud.cardOpen)) 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();
|
||
});
|
||
|
||
/**
|
||
* P — pause. Storm only, and that is not a limitation, it's the whole scope:
|
||
* forecast and aftermath are already paused by construction (their cards are
|
||
* up and the sim isn't running), and prep has no clock. The storm is the only
|
||
* ninety seconds in the game that don't wait for you, which is exactly why a
|
||
* stranger on a public URL needs a way to stop it.
|
||
*/
|
||
function setPaused(on) {
|
||
if (game.phase !== 'storm') on = false; // nothing else has a clock to stop
|
||
if (on === paused) return paused;
|
||
paused = on;
|
||
hud.setPaused(paused);
|
||
return paused;
|
||
}
|
||
|
||
/**
|
||
* M — mute. The tap is C's `sky.setMute(on)` (landed lane/c 8a3dc32, with the
|
||
* pre-unlock case: M pressed on the splash before the first gesture builds
|
||
* the audio graph is remembered and honoured by unlock()). The application is
|
||
* `applyMute` — a value, so a.test can fail it — and the feature-detect stays
|
||
* because on any tree where the tap is absent, `?.()` on a missing method is
|
||
* a no-op that looks like a call (D's rigging.setWorld lesson). The HUD asks
|
||
* before it advertises M (hud.setAudioMuteAvailable): the key lights up on
|
||
* exactly the trees where it does something, and nowhere else.
|
||
*/
|
||
function setMuted(on) {
|
||
muted = !!on;
|
||
const bus = applyMute(sky, muted);
|
||
hud.setMuted(muted);
|
||
return bus;
|
||
}
|
||
|
||
addEventListener('keydown', (e) => {
|
||
if (hud.cardOpen) return; // a card owns the keyboard
|
||
const k = e.key.toLowerCase();
|
||
if (k === 'p') setPaused(!paused);
|
||
else if (k === 'm') setMuted(!muted);
|
||
});
|
||
|
||
// SPRINT13 gate 3 — the front door, in front of the forecast.
|
||
//
|
||
// The job sheet used to be the first thing a stranger saw: an invoice-shaped
|
||
// card from a business they'd never heard of, quoting money for a job nobody
|
||
// had explained. It's a good SECOND card. partly.party's arcade can drop
|
||
// someone here cold, so the game says what it is first.
|
||
//
|
||
// `opts.splash === false` skips it — the selftest, the dev benches and D's
|
||
// playtest harness all boot straight into a night, and none of them should
|
||
// have to click through a door.
|
||
hud.setAudioMuteAvailable(typeof sky?.setMute === 'function');
|
||
if (!canPlayHere()) {
|
||
hud.showTouchNotice(); // no way through, on purpose: see hud.showTouchNotice
|
||
} else if (opts.splash === false) {
|
||
showTonight();
|
||
} else {
|
||
hud.showSplash(() => 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();
|
||
let frames = 0, fpsT = 0, fps = 0;
|
||
|
||
/**
|
||
* The dev line: fps, phase, sim clock, debris count.
|
||
*
|
||
* On by default for anyone developing (localhost, and every lane clone is
|
||
* localhost) and for anyone who asks with `?dev=1` — D's playtests read it,
|
||
* and taking it away to tidy a public page would cost more than it saves.
|
||
* Off everywhere else, which today means partly.party: it's the only thing on
|
||
* the glass that talks to us instead of the player.
|
||
*/
|
||
const devWanted = (() => {
|
||
try {
|
||
if (new URLSearchParams(location.search).has('dev')) return true;
|
||
return /^(localhost|127\.0\.0\.1|\[::1\])$/.test(location.hostname);
|
||
} catch { return false; }
|
||
})();
|
||
const dev = devWanted ? document.getElementById('dev') : null;
|
||
dev?.classList.add('on');
|
||
|
||
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());
|
||
|
||
// SPRINT13 gate 3 — P pauses the ACCUMULATOR, not the frame: the sim stops
|
||
// dead (no step(), so no dt reaches anything deterministic) while the render
|
||
// keeps going, which is what lets the pause veil sit over a frozen yard
|
||
// instead of a black screen. The rule itself is `accumulate` — a value, and
|
||
// tested, because nothing here is reachable from a test.
|
||
const a = accumulate(acc, raw, paused);
|
||
for (let i = 0; i < a.steps; i++) step(FIXED_DT);
|
||
acc = a.acc;
|
||
|
||
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; }
|
||
// SPRINT14 pool (D's nit, Sprint 13): this counted `pieces` only, so it read
|
||
// "debris 0" while seven leaves streamed through frame — the line was
|
||
// telling a playtester the storm was empty at the exact moment C's ambient
|
||
// leaves were the best "this is a gale" tell on the glass. They are two
|
||
// populations with two lifetimes (events vs. a recycled ambient pool), so
|
||
// they get two numbers rather than one merged count that could never be
|
||
// reconciled against either. `leafCount` is C's own accessor, built for
|
||
// this.
|
||
if (dev) {
|
||
dev.textContent = `${fps.toFixed(0)} fps · ${game.phase} ${game.phaseT.toFixed(1)}s`
|
||
+ ` · t ${simT.toFixed(0)}s · debris ${debris.pieces.length} · leaves ${debris.leafCount}`;
|
||
}
|
||
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;
|
||
}
|