/** * 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, forecastLines, offerBand } from './weather.js'; // SPRINT18 gate 1 — a NAMESPACE import beside the named ones, for exactly one // reason: C's `midStormRead` (the dispatch's mid-storm read) lands at // integration, and a named import of a function that does not exist yet is a // hard module error rather than a graceful no-op. `weather.midStormRead?.()` is // the `setConstraints?.()` seam pattern, adapted to ES modules. Delete this // import the day the named one can carry it. import * as weather 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, fabricNoteFor } from './rigging.js'; import { createHud } from './hud.js'; import { calloutFee, createWeek, NIGHTS, nightAt } from './week.js'; import { POOL, exposureOf } from './board.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. * * ⚠️ **SPRINT17 — THE POOL JOINS THE LADDER HERE, and this is the same landmine * in a new costume.** The board can put ANY pool night on tonight, so a pool * storm that isn't preloaded is `defs[week.stormKey] === undefined` the moment * a player takes that offer — which is D's calm-day bug exactly: an invariant * ("every storm the game can run is loaded") that used to hold by coincidence, * because NIGHTS was the only source of nights. It isn't anymore. Both sources * are named here, deduped, and a.test pins that a pool-only storm survives the * list. * * ⚠️ **TWO PARAMETERS, NOT ONE CONCATENATED DEFAULT — and this assert had to be * rescued from being decoration, by mutation-checking rather than by reading * it.** The first cut took a single `nights` list defaulting to NIGHTS+POOL, * and a.test asserted "every pool storm is preloaded". That assert passed with * the POOL source deleted outright, because every storm the pool flies today * also appears in NIGHTS — the same coincidence, in the same function, that hid * the calm-day bug for a sprint. Split, the composition is testable against a * pool storm that is NOT in the ladder — the case that would actually break. * * @param {string[]} [nights] storm keys from the ladder * @param {string[]} [pool] storm keys the BOARD can put on tonight */ export function stormsToPreload( nights = NIGHTS.map((_, i) => nightAt(i).storm), pool = POOL.map((p) => p.storm), ) { return [...new Set([CALM_STORM, ...nights, ...pool])]; } 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, cut = [], 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; /** * 🚨 SPRINT18 gate 1.3 [Lane D] — **THE KNIFE'S OWN VERDICT, and without it this function calls a * deliberate cut "the hail fell where your sail wasn't".** * * That is mode `uncovered`, and it is the single worst thing the card could say to a player who * just made the game's signature decision: it blames placement on a night where the sail came down * because they cut it down. The verdict's whole charter is that it reads what ACTUALLY happened. * * It fires only when nothing of YOURS failed, and that composition rule is deliberate rather than * tidy: if a corner blew as well, that failure is the louder fact and the modes below say it * truthfully — the cut is still named on the invoice's own line. So the knife speaks when the knife * is the story, and shuts up when it isn't. * * "Nothing of theirs" is true BY CONSTRUCTION here, not by hope: `collateralWalk` prices `yours`, * and with `yours` empty the gnome's two-corner rule and every structure key are empty too. */ if (cut.length && !lost.length) { const where = cut.map((c) => c.anchorId.toUpperCase()).join(', '); return win ? { mode: 'cut-held', verdict: `YOU CUT IT LOOSE at ${where} — and the bed came through anyway. ` + 'Nothing of theirs is on the ground, and nothing of yours broke.' } : { mode: 'cut', verdict: `YOU CUT IT LOOSE at ${where}. The bed went with it — and their property didn't. ` + 'That was the trade.' }; } // 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. * * 🚨 SPRINT18 gate 1 — `theirs` marks a corner that ARRIVED BROKEN on an emergency * callout, and it is not a cosmetic flag. Without it the ledger books TOMORROW'S * WARRANTY on a cowboy's carabiner: you would drive back, unpaid, to redo work * you never did, and your standing would drop for a corner that was on the ground * before you got out of the ute. week.js's `warrantyItems()` and the rep block * both skip it. See the ruling in scoreRun. * * @param {Array} corners rig.corners at dawn * @param {Map} [tally] the night's break/repair events per anchor * @param {Set} [theirs] anchor ids that arrived broken (emergency callouts) */ export function rigRecordFor(corners, tally = new Map(), theirs = new Set()) { 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, theirs: theirs.has(c.anchorId), /** * SPRINT18 gate 1.3 [D] — you let this one go on purpose. week.js reads it exactly where it * already reads `theirs`, and for the same reason: warranty and standing are about work that * FAILED. A corner still down at dawn because you cut it booked a free callout to go and * re-hang your own decision, and docked half a star for taking it. * * NOTE it does not suppress `broke`/`brokenAtDawn`. Those two are the RECORD — the sail really * is on the ground and the client can see it — and a record that edits itself to be flattering * is the one thing this ledger has never done. */ cut: !!c.cut, }; }); } /** * WHAT AN OFFER CARD PRINTS — pure, exported, and it did not use to be either. * * ⚠️ **EXTRACTED BECAUSE IT SHIPPED A LIVE BUG THE SUITE COULD NOT SEE.** This was * an inline closure inside `boot()`, unreachable by any assert, and on night 5's * real board it advertised the emergency callout at **$57 while the dispatch one * click later quoted $114** — it had simply never been given the emergency term. * That is exactly the quote-vs-settle lie this file's own comment says the board * must not be able to tell ("the board can never be more confident than the * sheet"), and it was invisible to 535 asserts because the money lived behind a * WebGL context. Eyes on the card found it; being a function is what stops the * next one. * * The rule it now keeps by construction: **every number on an offer card comes * from the same function the job sheet and the invoice use.** `calloutFee` for the * money, C's `forecastLines`/`offerBand` for the weather, `exposureOf` for the * risk — nothing retyped here. * * @param {object} night a normalised night (week.js normaliseNight) * @param {object} ctx * @param {object} [ctx.def] the storm def; absent ⇒ the weather lines degrade away * @param {object} [ctx.site] the site JSON, for the exposure walk * @param {number} ctx.rep standing as of this morning — the fee is priced on it * @param {object[]} [ctx.remembers] this client's grudges (week.remembers) */ export function priceOffer(night, { def = null, site = null, rep = 3, remembers = [] } = {}) { const exp = exposureOf(site); return { // C's honest lines at lead 0 — tonight, exact. The same call the job sheet // makes, so the board can never be more confident than the sheet. forecast: def ? forecastLines(def, 0) : null, // SPRINT17 [C], applied by the integrator: the ORDERED band the offer card // maps, so a night's hail can never be dropped by the card picking two // fields. Night 7 read softer than the buster on wind+stones alone (55 vs 76 // km/h) while carrying 6x the hail — the board recommending its own trap. band: def ? offerBand(def, 0) : null, // 🚨 SPRINT18 gate 1 — the fourth argument IS the bug above, fixed: an // emergency pays double ON THE CARD, because the card is where the player // decides whether double is worth it. fee: def ? calloutFee(def, rep, night.constraints, !!night.emergency) : null, exposure: exp.total, exposureItems: exp.items, remembers, }; } /** * 🚨 **SPRINT18 gate 1 — ⚖️ WHO IS ANSWERABLE FOR WHICH CORNER.** The ruling that * makes an emergency invoice honest, as a pure function so that the RULING is * pinnable and not merely its consequences. * * Exported for the `rigRecordFor` reason, stated in that function's own note: * scoreRun lives inside `boot()` behind a WebGL context where no assert can reach * it, and this is exactly the kind of rule that would otherwise only ever be * "tested" by a green suite that never ran it. It was worth extracting twice over, * because rendering the first real emergency invoice found TWO bugs downstream of * this split — the verdict blaming an inherited corner, and the gnome billable off * corners the player never chose. * * **A corner that was already down when you arrived is the JOB, not your failure.** * It bills nothing, wrecks nothing, books no warranty and costs no standing — it is * the REASON there is a callout. Without the split, an emergency night charges you * $180 for the carport the cowboy was already losing, bills his $5 carabiner, docks * your standing for his corner, and books you a free warranty callout tomorrow to * go and fix his work: four surfaces, all agreeing, all wrong. * * Two lists out, deliberately, because they answer two different questions: * · `lost` — every corner down. REPORTING (the HUD's corners-intact count). It * must stay honest: reading 4/4 over a sail with three corners on the ground * would be its own lie. * · `yours` — the ones you are ANSWERABLE for. Billing, collateral, the subtitle * and the verdict read this. * * @param {Array} corners rig.corners at dawn * @param {object|null} emergency the night's `emergency` block, if it is one */ export function answerableFor(corners, emergency = null) { const theirs = new Set((emergency?.rig ?? []).filter((c) => c.broken).map((c) => c.anchorId)); const lost = (corners ?? []).filter((c) => c.broken); /** * 🚨 **SPRINT18 gate 1.3 [Lane D] — A CORNER YOU CUT IS NOT A CORNER THAT FAILED, AND WITHOUT * THIS LINE THE KNIFE IS A TRAP.** * * B's `cutAway` deliberately emits no `break` ("warranty must never chase a corner the player * chose to let go") — but every ledger surface downstream reads `c.broken`, and `_releaseCorner` * sets it for a cut exactly as it does for an overload. So on the tree as merged, taking DESIGN.md * line 165's signature decision billed you for it on five surfaces at once: the cut corner's * hardware went on `bill`, the collateral walk BILLED AND WRECKED the $180 carport you cut the * sail to save, a four-corner cut tripped the gnome's two-corner rule, `clean` was voided by the * collateral it caused, and week.js booked warranty and docked standing for every corner you let * go. The invoice would have printed *"You spent the sail to keep the steel. That is the trade the * knife is for"* directly above a $180 charge for the thing you kept. * * Found by playing it, not by a test — the same way every card bug in this repo has been found. * A is the author of both halves and neither half is wrong; nobody joined them, because the join * is a flag on an object and there was no verb setting the flag until now. * * The ruling, and it is B's extended to every surface that reads a corner: **a cut is a DECISION. * It bills nothing, wrecks nothing, books no warranty and costs no standing. Its price is the * garden — and through the garden, the night's fee — and that is enough.** `lost` still counts it * (the HUD's corners-intact read must stay honest: the sail IS on the ground), and `cut` is named * so the paper can say who put it there. */ const cut = lost.filter((c) => c.cut); return { theirs, lost, cut, yours: lost.filter((c) => !theirs.has(c.anchorId) && !c.cut), inherited: lost.filter((c) => theirs.has(c.anchorId)) .map((c) => ({ anchorId: c.anchorId, hw: c.hw?.name ?? '?' })), }; } /** * 🚨 **SPRINT18 gate 1.3 [Lane D] — THE COLLATERAL WALK, EXTRACTED.** What a set of failed corners * costs you in the client's property. Lifted OUT of scoreRun unchanged, for the two reasons * `answerableFor` and `rigRecordFor` were extracted before it, and one new one: * * · scoreRun lives inside `boot()` behind a WebGL context where no assert can reach it. * · **the knife's HUD read has to promise exactly what the invoice will honour.** The panel says * "saves the carport $180" before the cut and the invoice charges $0 after it; if those two ever * came off different arithmetic, the game would be advertising a saving. One walk, two readers — * the same discipline that made `calloutFee` one function for the board, the dispatch and the * invoice. * * PURE, and that is load-bearing: `wreckStructure()` stays at the call site. A HUD asking what a cut * would save must not swap a structure for its wreck as a side effect of the question. * * @param {Array} corners the corners to price — `yours` at dawn, or the LIVE ones for the knife * @param {object} world contracts World (anchor(), collateralFor(), gnome) * @returns {{items: {what:string,cost:number}[], keys: string[], total: number}} */ export function collateralWalk(corners, world) { const items = []; const keys = []; // The gnome is collateral if the sail came down over it (DESIGN.md: the worst debris in any storm // is your own failed work). Two corners, per the original rule. if ((corners ?? []).length >= 2 && world?.gnome) { items.push({ what: 'garden gnome', cost: world.gnome.collateralValue }); } /** * Priced per STRUCTURE, not per corner: two beams letting go is one carport gone, not $360. */ const taken = new Set(); for (const c of corners ?? []) { const key = world?.anchor ? world.anchor(c.anchorId)?.collateral : null; const priced = key && world.collateralFor ? world.collateralFor(key) : null; if (!priced || taken.has(key)) continue; taken.add(key); keys.push(key); items.push({ what: priced.label, cost: priced.cost }); } return { items, keys, total: items.reduce((s, i) => s + i.cost, 0) }; } // --------------------------------------------------------------------------- // 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 * @param {{at?:number}} [opts] 🚨 SPRINT18 gate 1 — THE EMERGENCY CALLOUT'S * ONE LINE OF ENGINE WORK. `at` opens the phase clock at that many seconds * instead of 0, which is the entire mechanism behind *"a night that starts * in storm"*: ROADMAP sized this gate as *"the storm phase already does * everything this needs except spawn you at t=30 with a rig you didn't * build"*, and this is the t=30 half. * * Everything downstream is untouched BY CONSTRUCTION rather than by care, * which is why it is one argument here and not a new phase: * · `windTime()` already returns `phaseT` during a storm, so the wind, the * sky, the hail and the storm's own authored events are all mid-flight * the moment you get out of the ute — you do not arrive at a calm yard * that then starts blowing. * · `tick()` already ends the storm at `phaseT >= STORM_LEN`, so an * emergency night is `STORM_LEN − at` long and no clock needed telling. * · `phaseChange` still fires with the same payload, so every listener * (sky rebuild, rigging deactivation, pond/tally resets) runs as usual. */ setPhase(next, { at = 0 } = {}) { if (!PHASES.includes(next)) throw new Error(`unknown phase '${next}'`); if (next === phase) return; if (!(at >= 0) || !Number.isFinite(at)) throw new Error(`setPhase('${next}', {at}) — at must be a finite number ≥ 0, got ${at}`); const from = phase; phase = next; phaseT = at; 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. // SPRINT18 gate 1.3 — D's knife rides in on the opts it already had. Every one of these is a // GETTER because `game` and `garden` are declared below the first call to this function (the // TDZ trap documented twice above); the knife reads them at frame time, when they exist. player = await createPlayer(scene, world, cameraRig, { wind, interact, getPhase: () => game.phase, getGarden: () => garden, /** One walk, two readers: what the panel promises is what the invoice honours. */ collateralAtRisk: collateralWalk, secsLeft: () => STORM_LEN - game.phaseT, }); // 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; } /** * ⚠️ **SPRINT18 gate 2 — THE FABRIC REACHES THE SIM. B FOUND IT; THIS IS THE * PICKUP, AND IT IS THE WHOLE FIX.** * * `rig.setFabric()` had exactly ONE call site in the repo — `RiggingSession * .commit(rig)` — and **the shipped commit path never reached it.** The rigging * UI's `commit()` calls `onCommit(...)`, and this function went straight to * `rig.attach()`. So `session.commit(rig)` ran in TESTS AND BENCHES ONLY, and * the sim's rig — built once at boot as `new SailRig({anchors})`, the * constructor's `porosity = 0` — **flew the WATERPROOF MEMBRANE at full wind * load every night of every playtest**, while the prep panel, the SCORE IT * card, the HUD row, the invoice and every audit number said "shade cloth". * **The F key has been moving the paperwork and never the physics**, and it was * honest exactly half the time by coincidence: pick membrane and the sim agrees * with you by accident; pick cloth — the DEFAULT — and it does not. * * B's table on the wildnight's pinned recipe, one variable: cloth 0.30 loses * **0/4**; membrane 0 loses **p2 at 3.48 then p3 at 3.90** — which is D's cold * play to the decimal, and the site's own `_playedReference`. So **the harness * that was lying was the GAME**: the bench flew the fabric the cards promised, * every audit number stands, and D's play found a real bug rather than an * artefact. It also retires C's `AUDIT.MARGIN` residual — with the fabric held * equal, bench and full game chain agree to three decimals on all four corners. * * **BOTH ROUTES, deliberately, and neither is redundant:** * · the ARGUMENT is B's filed hunk and the better contract — `dev_rigging.html` * has no session to reach into and had the identical bug. * · the SESSION FALLBACK is what makes this LIVE ON lane/a TODAY. B's * `commit()` passes the 4th argument on lane/b; on this branch `commit()` * still calls `onCommit` with three, so the argument alone would be an inert * no-op until integration — and B's own words are "until this lands the sim * still flies the membrane". A fix that waits for a merge to start working is * not a fix yet. `session.fabric` is the expression this file already reads * for the paperwork, so the physics and the invoice now read ONE source. * **AT INTEGRATION (S18, integrator): B's rigging.js is merged, the argument * now arrives on both wired routes — and the fallback STAYS.** A's filed * instruction was to delete it here; it is deliberately not deleted, and A can * revert this on sight. The reason is a third caller neither lane enumerated: * `rigSail` is exposed on the `SHADES` API, where a bench or a cold play can * still call it with three arguments. Deleting the fallback would make that * path fly the membrane while every card said cloth — the exact five-sprint bug * this fix exists to kill, reintroduced through the one door the suite cannot * see (this wire lives inside `boot()` behind a WebGL context). The fallback is * one `??` and it agrees with the argument on every wired path. */ /** * @param {object} [opts] `{at, wind}` — B's `attach` door (sail.js). Absent/`at:0` is * byte-for-byte the old behaviour, so every ordinary night and every bench is untouched. * See the emergency's note on `onCommit` for the one caller that passes it. */ async function rigSail(anchorIds, hwChoices, tension = 1.0, fabric, opts) { const cloth = fabric ?? rigging?.session?.fabric; if (cloth) rig.setFabric(cloth); rig.attach(anchorIds, hwChoices, tension, opts); 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, // SPRINT18 gate 2 — the fabric rides through with the steel. B's filed hunk, // verbatim; `fabric` is undefined on bare lane/a (their commit() passes it, // and that lands at integration) and rigSail falls back to session.fabric so // the sim flies the right cloth on both branches. See rigSail's note. /** * 🚨 SPRINT18 gate 1.3 [D] — **`{at, wind}`: THE CLOTH FINALLY ARRIVES IN THE STORM IT IS * STANDING IN.** C measured it and B fixed it and nobody wired it, so on the merged tree the one * night that starts mid-storm attached its sail at t=0: measured live by D on the shipped * emergency, **`rig.clockSkew` read 30.02** while the sky, garden, debris and player were all * thirty seconds in. B's own number for what that hides: the same rig entering the same storm * pulls **90 N at t=0 and 655 N at t=40**. The triage that this whole gate is about was being * judged against a sail feeling a seventh of the load, and the knife's own invoice line said * *"cut loose 9s in"* on a night you arrived at 30 — because the log stamps the SAIL's clock. * * The night is asked, not a flag: `week.job.emergency` is a fact the night already owns, so this * needs no new state and no ordering change (`adoptEmergencyRig` runs before `setPhase`, so * reading `game.phaseT` here would read the forecast's clock — which is how it would go wrong). * With a `wind` B's `attach` genuinely PRE-FLIES the thirty seconds rather than teleporting the * cloth into a gale, so the drape and the belly's water are real history. */ onCommit: (ids, hw, tension, fabric) => { const em = week.job?.emergency; void rigSail(ids, hw, tension, fabric, em ? { at: em.arriveAt, wind: winds[week.stormKey] } : undefined); }, 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() { /** * 🚨 **SPRINT18 gate 1 — ⚖️ THE RULING THAT MAKES AN EMERGENCY INVOICE HONEST: * A CORNER THAT WAS ALREADY DOWN WHEN YOU ARRIVED IS THE JOB, NOT YOUR * FAILURE.** It bills you nothing, wrecks nothing, books no warranty and costs * no standing — it is the REASON there is a callout. * * Without this, the shape of an emergency night is grotesque and would have * shipped looking correct: you turn up at somebody else's collapsing rig and * the invoice charges you $180 for the carport the cowboy was already losing, * bills you for his $5 carabiner, docks your standing for his corner, and then * books you a free warranty callout tomorrow to fix his work. The paper has to * split the blame, and this repo already knows how to say it — the icenight's * invoice does exactly this ("The shackle at P2 letting go is the part that's * on you"). * * Two lists, deliberately, because they answer two different questions: * · `lost` — every corner down at dawn. This is REPORTING (the HUD's * "corners intact" count), and it must stay honest: reading 4/4 over a * sail with three corners on the ground would be its own lie. * · `yours` — the ones you are ANSWERABLE for. Billing, collateral and the * subtitle read this. */ const em = week.job.emergency; const { theirs, lost, yours, cut, inherited } = answerableFor(rig.corners, em); const bill = yours.reduce((s, c) => s + c.hw.cost, 0); /** * 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. * * SPRINT18 gate 1.3 [D] — the ARITHMETIC moved out to `collateralWalk` (pure, exported, above) * so the knife's HUD read prices what it saves through this exact walk. The WRECK stays here: * a HUD asking "what would a cut save" must not swap a structure for its wreck. */ const { items: collateral, keys: wrecked } = collateralWalk(yours, world); for (const key of wrecked) 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; /** * ⚠️ **`yours`, NOT `lost` — AND THIS WAS A LIVE BUG ON THE FIRST EMERGENCY * INVOICE I RENDERED.** With `lost` the verdict narrated the corner the player * INHERITED as their failure: the ledger said *"CB1 — already on the ground when * you arrived · not billed · That corner is what the callout was for. It is not * on you"* and four lines below it the verdict said *"the carabiner at CB1 went * first and took 1 more with it."* One card, two contradictory accounts of the * same corner — the invoice arguing with itself, which is the defect class this * repo has spent five sprints hunting. The verdict speaks about work you are * answerable for; the corner count above it still reports every corner down, * because those are two different facts and both papers say so honestly. */ const { verdict, mode } = verdictFor({ hp, lost: yours, cut, win, dmg, pondPeak, pondDumped, beyondSaving: week.job.gardenBeyondSaving }); // SPRINT16 gate 2.2 [Lane B] — the fabric bet on the record. The F key // landed in the fresh-eyes review; this is the paperwork half: the invoice // carries the fabric every night (r.fabric → hud's rows), and the verdict // gets ONE extra sentence when the bet provably mattered (fabricNoteFor — // pure, thresholded, silent when a sentence would be a guess). The // FORECAST half — arguing the bet before you rig — is C's gate 3.3. const fabric = rigging.session.fabric; const fabricNote = fabricNoteFor({ fabric, hailSize: defs[week.stormKey]?.hail?.size ?? 0, dmg, pondPeak, }); return { fabric: { id: fabric.id, name: fabric.name }, fabricNote, 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: yours.length ? `${yours.map((c) => `${c.hw.name} at ${c.anchorId.toUpperCase()}`).join(', ')} let go.` // SPRINT18 gate 1.3 [D] — a cut sail must not be headlined "Every corner held." The card // would then carry that line, a CUT LOOSE row in the ledger and a cut verdict underneath: // three accounts of one night, one of them false. `cut` before `inherited` because on the // shipped emergency both are true and the cut is the thing the PLAYER did. : cut.length ? `You cut it loose at ${cut.map((c) => c.anchorId.toUpperCase()).join(', ')}.` : inherited.length // An emergency you held: nothing of yours went, and the corner that was // already down stays named rather than quietly counted as a hold. ? `Nothing of yours let go. ${inherited.map((c) => c.anchorId.toUpperCase()).join(', ')} was on the ground when you got there.` : '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`. */ /** * 🚨 SPRINT18 gate 1 — ⚖️ RULED: **ON AN EMERGENCY, THE INTACT HARDWARE IS * NOT YOURS TO UNCLIP.** It is the cowboy's steel in the client's yard, and * you leave it there. Only the spare out of your own ute comes home. * * This is also the check that keeps `emergencyRate: 2.0` from being free * money: without it, a double-rate callout would ALSO hand you half of * somebody else's four rated shackles at dawn, and the biggest earner in the * game would be turning up to other people's disasters and salvaging them. */ /** * SPRINT18 gate 1.3 [D] — **"LOSE THE SAIL, SAVE THE ANCHORS" IS A LEDGER LINE, NOT A * SLOGAN.** `!c.broken` alone excluded a CUT corner from the refund, so the knife also quietly * took your steel: you cut the cloth, and the shackle still bolted to the post — undamaged, * unclippable in thirty seconds at dawn — paid you nothing. DESIGN.md line 165 promises the * opposite in the same breath as the verb, and `bill` already agrees (a cut corner is not * `yours`, so its hardware is not written off). This is the other half of that sentence. * On an EMERGENCY it changes nothing — none of that steel is yours to unclip either way. */ intactHardwareValue: em ? (rigging.session.spares ?? 0) * SPARE_COST : rig.corners.filter((c) => !c.broken || c.cut).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, theirs), /** SPRINT18 gate 1 — their corners, still down, named on the invoice and unbilled. */ inherited, /** SPRINT18 gate 1 — D's knife. Tallied off rig.events; see the cutAway note. */ cutAways: cutAwayLog.slice(), }; } // --- 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`. */ /** * SPRINT17 gate 1 — THE MORNING: the board, then the job sheet. * * Site JSON is needed for the exposure number on an offer whose yard we have * NOT loaded (and must not load — loading it would rebuild the world behind * the card for a job the player might not take). `loadSite` is a small JSON * fetch with no GLBs, cached here so a week of boards costs three requests. * * The cache is keyed on site name and never invalidated on purpose: a site * JSON cannot change inside a run, and a stale-cache bug on a number the * player makes money decisions from would be worth more than the three * requests it saves. * @type {Map} */ const siteJson = new Map(); async function siteJsonFor(name) { if (!siteJson.has(name)) siteJson.set(name, await loadSite(name)); return siteJson.get(name); } /** * Price every offer on this morning's board. * * The fee comes from `week.quote()` on a WEEK VIEW OF THAT NIGHT, not from a * formula retyped here — so the number on the offer card is the number the * job sheet quotes and the invoice pays, by construction rather than by two * developers agreeing. (The board showing $68 and the sheet then saying $57 * would be the quote-vs-settle lie moved one card earlier, which is exactly * the class of bug the ledger's derivation was built to make impossible.) */ async function pricedOffers() { for (const o of week.offers()) await siteJsonFor(o.night.site); return week.offers((night) => priceOffer(night, { def: defs[night.storm], site: siteJson.get(night.site), rep: week.rep, // ⚖️ SPRINT18 gate 0, clause 3 — THE STOOD-UP CLIENT REMEMBERS. Injected // with the rest of the per-offer facts because the memory is week state and // board.js is pure: same seam as the fee and the exposure, and the card // renders nothing when the list is empty (the degradation rule every other // injected field follows). A client you left waiting on Tuesday who is on // the board again on Thursday is the whole of "your work follows you". remembers: week.remembers(night.client), })); } /** * The morning, in order: the board offers two jobs, the player takes one, and * ONLY THEN does the world load — because which yard to build is the thing * they just decided. Everything downstream (`showTonight`) is untouched: it * reads `week.site` / `week.job` / `week.quote()` exactly as it did when the * week was a script, which is the whole point of installing the taken job as * the night rather than bolting a parallel concept beside it. */ function showMorning() { return pricedOffers().then((offers) => new Promise((resolve) => { hud.showBoard(offers, { night: week.night, nights: week.nights, bank: week.bank, log: week.log, rep: week.rep, }, (offer) => { week.take(offer); showTonight().then(resolve); }); })); } /** * 🚨 SPRINT18 gate 1 — **THE RIG YOU DIDN'T BUILD.** Someone else's work, * installed from `night.emergency.rig` (B's data) before the storm opens. * * Through the session's own PUBLIC MOVES, exactly as `resetRig()` is, and for * the same reason: the economy stays the single source of truth and nothing * here reaches into B's fields. Two things worth stating out loud: * * **1. THE UTE CARRIES ONE SPARE. ⚖️ RULED (A).** You had no prep phase, so you * bought nothing — but a tradie's ute has a shackle rolling around in it, and * without one, triage is a verb with no object: you would arrive at a broken * corner unable to do the only thing the job is for. It is FREE, and the way it * is made free is visible arithmetic rather than a private door: bank the * session at `week.bank + SPARE_COST`, buy the spare, and the session's budget * lands back on exactly `week.bank`. `spentThisNight` therefore reads $0, which * is the truth — there was no shop. (B: if you would rather expose a * `grantSpare()`, say so in THREADS and I will move to it.) * * **2. THE PRE-BROKEN CORNER IS B'S DOOR TO OPEN.** `broken: true` needs more * than a flag — sail.js's own comment says so: a break must also hand the node * its mass back (`invMass`), dump the pond and emit, or the corner stays welded * in mid-air instead of flying. There is no public `breakCorner`, so this calls * one feature-detected (`setConstraints?.()`'s pattern) and the night does not * depend on it: the authored corner is a $5 carabiner on the carport beam, which * is ~264 N effective under a southerly, so it fails within seconds THROUGH THE * REAL OVERLOAD PATH whether or not the door exists. Emergent beats poked. */ async function adoptEmergencyRig(em) { const s = rigging.session; resetRig(); /** * ⚠️ **THE ARITHMETIC, AND THE BUG IT FIXES — CAUGHT LIVE, NOT BY A TEST.** The * first cut banked the session at `week.bank + SPARE_COST` and the real game came * up reading **budget $60 against a bank of $80**: the session had charged me $20 * for the cowboy's four carabiners. The LEDGER was fine (`spentThisNight` is * forced to 0 on the ROLL) but the SHOP was lying by $20 — and `run.budgetLeft`, * plus every refusal B prices off `budget`, reads that number. * * You arrive having spent NOTHING: their steel is not yours, you did not buy it, * and the ute's spare is free. So the session is pre-banked by exactly what * adopting the night is about to cost it and the budget lands back on * `week.bank`. Computed FROM THE RIG DATA rather than hardcoded, so B can author * rated shackles into a cowboy's rig without this line silently going wrong. */ const theirCost = (em.rig ?? []).reduce( (sum, c) => sum + (HARDWARE.find((h) => h.name === c.hw)?.cost ?? 0), 0); s.setBudget(week.bank + theirCost + SPARE_COST); for (const c of em.rig ?? []) { s.rig(c.anchorId); const hw = HARDWARE.find((h) => h.name === c.hw); if (hw) s.setHardware(c.anchorId, hw); } s.setSpares(1); if (!rigging.commit()) { // Loud, not silent: an emergency whose rig would not commit is a night with // no sail in it, and the player would be standing in a storm wondering what // they were called out for. throw new Error(`main: the emergency rig would not commit — ${em.rig?.length ?? 0} corners ` + `on ${week.site}. Check every anchorId exists in that yard's site JSON.`); } /** * Corners that ARRIVED gone. * * ⚠️ **SPRINT18 gate 1.3 [D] — THE DOOR IS CALLED `failCorner`, AND FOR THIS WHOLE SPRINT THIS * LINE CALLED A METHOD THAT DOES NOT EXIST.** B opened it ([B] gate 1 §4) under the name * `failCorner`; this asked for `breakCorner?.()`, and an optional call to a renamed method is a * silent no-op that reads exactly like a call — **the `rigging.setWorld` lesson this file quotes * elsewhere, arriving through the door built to prevent it.** Measured live: * `typeof rig.failCorner === 'function'`, `typeof rig.breakCorner === 'undefined'`, and the * corner every card called "ON THE GROUND" was flying. * * A's fallback reasoning ("the authored corner is a $5 carabiner on the carport beam, so it fails * within seconds through the real overload path") was doing the work and hid the break — but it * only holds for A's placeholder rig. Under B's canon set the arrived-broken corner is `tr1b`, a * carabiner on a 0.88 branch that does NOT self-destruct, so the dispatch would have promised a * corner on the ground and the sim would have flown it all night. Still feature-detected, because * the pattern is right; only the name was wrong. */ for (const c of em.rig ?? []) { if (c.broken) rig.failCorner?.(c.anchorId); } } 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); // SPRINT17 gate 2 — B's filed wiring line, landed at the boundary B named // (the setBudget pattern: the night's terms arrive with the night's bank). // Runs AFTER week.take(), so these are the CHOSEN job's constraints — a // callout's client speaks here, not the spine's. The `?? []` is // load-bearing and B's entry says why: setConstraints survives the // session's reset() (so "play again" replays the same client), which makes // the empty array the ONLY thing that clears the previous client's terms — // drop it and the Vasilaros cap follows you to the Hendersons'. // `?.` because lane/b owns the method and it lands at integration; until // then this is a documented no-op (the D lesson: a `?.()` LOOKS like a // call) and constraints stay paper-only, priced but unenforced — the seam // contract's own interim state. Integrator: after merge, take a // constrained offer and watch the session refuse in the ticker; if it // doesn't, this line is the first suspect. rigging.session.setConstraints?.(week.job?.constraints ?? []); spentThisNight = 0; /** * 🚨 SPRINT18 gate 1 — **THE FORK: A DISPATCH INSTEAD OF A JOB SHEET.** The one * branch this whole gate needed in the phase flow, and it is deliberately the * last thing in the morning rather than a new phase: everything above this line * (the yard, the bank, the client's terms) is identical for both job types, * because an emergency IS a job — it just isn't a quote. * * Two orderings in here are load-bearing: * · `stormKey` is set BEFORE the phase change, exactly as the ordinary path * does on its GO button, because `phaseChange` points the wind router at * `winds[stormKey]`. Set it after and you would stand in a calm yard while * the card said gale. * · **the rig is adopted on the BUTTON, not before the card.** The forecast * phase's own handler runs `showMorning().then(() => { garden.reset(); * resetRig(); … })`, and `resetRig()` would strip an emergency rig installed * any earlier — a night with no sail in it, and the player standing in a * storm wondering what they were called out for. It is also better fiction: * you roll, and THEN you are there. */ const em = week.job.emergency; if (em) { stormKey = week.stormKey; const emDef = defs[week.stormKey]; hud.showDispatch( { job: week.job, quote: week.quote(emDef), site: siteMeta[week.site], /** * C's seam (THREADS [A] 2026-07-25 §8): what the sky and the grass tell * you when no paper briefed you. Called through the NAMESPACE import on * purpose — a named import of a function lane/c has not exported yet is a * hard module error, not a graceful no-op, so `weather.midStormRead?.()` * is the `setConstraints?.()` pattern adapted to ES modules. Until it * lands the card prints one honest italic line saying nobody briefed you. */ read: emDef ? (weather.midStormRead?.(emDef, em.arriveAt) ?? null) : null, }, { night: week.night, nights: week.nights, bank: week.bank, rep: week.rep }, () => { adoptEmergencyRig(em).then(() => { spentThisNight = 0; // there was no shop; the ute's spare is free. game.setPhase('storm', { at: em.arriveAt }); }); }, ); return; } // 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, // ⚖️ SPRINT18 gate 0 — and if you have stood THIS client up before, the // sheet says so under their brief. The board's card is where the memory // changes a decision; the sheet is where it changes the room you walk // into. Same week ledger, read by name. remembers: week.remembers(week.job?.client), }, () => { 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} */ 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++; }); /** * 🚨 SPRINT18 gate 1 — **D'S KNIFE, THE TALLY AND THE PAPER HALF.** DESIGN.md * line 165: *"knife (cut a sail loose — lose the sail, save the anchors)"*, and * the signature decision under load: **release, cut, or ride it out.** * * The seam agreed in THREADS [A] 2026-07-25 §9: **D owns the verb, the input and * the HUD read; I own the tally, the settlement field and the line on the * paperwork.** D emits `cutAway` on `rig.events` with `{t, corners, reason?}` — * the same Emitter `break`/`repair`/`pondDump` already ride, so this needed no * new wiring on either side. Subscribed once (the Emitter survives a re-rig) and * cleared at storm start, exactly like nightTally above: a cut on the calm day * is not the night's story. * * Principle 2, no exceptions — a verb with no line on the paperwork is a verb the * game does not admit to. Until D's input lands this log stays empty and every * surface renders nothing, which is the degradation rule rather than a stub. * @type {{t:number, corners:string[], reason:string|null}[]} */ const cutAwayLog = []; rig.events.on('cutAway', (e) => { const corners = Array.isArray(e?.corners) ? [...e.corners] : (e?.anchorId ? [e.anchorId] : []); cutAwayLog.push({ t: e?.t ?? game.phaseT, corners, reason: e?.reason ?? null }); pushEvent(`sail cut loose${corners.length ? ` at ${corners.map((c) => String(c).toUpperCase()).join(', ')}` : ''}`); }); // --- 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(); cutAwayLog.length = 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. // SPRINT17: the board comes first, and the world rebuild rides the PICK // rather than the phase change — the yard to build is not known until // the player has chosen a job. The resets still land after the rebuild // (same .then sequencing, one card earlier in the chain). showMorning().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. // SPRINT18 gate 1.3 — X is on the help line because a key nobody advertises is a key nobody // presses, and the knife is the one verb the game has to offer BEFORE the moment it is needed. // "hold X cut sail" rather than "X cut sail": it is a hold, and the difference matters when the // window is six seconds long. hud.setHelp(`WASD move · shift run · E repair/pickup · hold X cut sail · 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. // // ⚠️ SPRINT17 — THE FIRST MORNING GETS A BOARD TOO, and for an hour it did // not. The board hangs off the forecast PHASE CHANGE, and night one never // has one: the game boots already IN 'forecast', so this line is the only // route into the first morning. Going through showTonight() made night 1 the // one night of the week with no choice on it. Found by LOOKING at the running // game, not by a test — every board assert operates on week.offers() and all // seven mornings were correct; the CARD CHAIN was wrong at the single join no // assert was watching. (Both card bugs in this repo's history were invisible // to a green suite. This is the third, and it says the same thing.) // // `splash:false` still goes straight to the scripted night, and that is the // documented harness door rather than an oversight: a bench that asked for a // night wants the night, not a card waiting to be clicked. D — your playtest // harness is unchanged; the REAL path is the one with the board on it. 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(() => showMorning()); } // --- 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; }