From 7acee5437b07a7a91b8d06526549fd9eca3ab64d Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 12:44:37 +1000 Subject: [PATCH] Sprint 13: garden model exported as garden.js (B's gate-1.2 blocker) + applyMute(sky,on) as a value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createGarden / GARDEN_DRAIN / HAIL_WEIGHT / RAIN_WEIGHT move from main.js module-locals to garden.js, unchanged — the audit must predict the sim off the sim's OWN code, not a drifting copy. GARDEN_DRAIN's comment now carries the gate-1.4 ruling: the constant does not move; it owns the bare-bed arc. - M-mute application extracted to exported applyMute(sky, on): calls C's sky.setMute when the tap exists, reports honestly when it does not, so the selftest can fail the wiring instead of trusting a ?.() no-op. --- web/world/js/garden.js | 103 ++++++++++++++++++++++++++++++++ web/world/js/main.js | 129 ++++++++++------------------------------- 2 files changed, 135 insertions(+), 97 deletions(-) create mode 100644 web/world/js/garden.js diff --git a/web/world/js/garden.js b/web/world/js/garden.js new file mode 100644 index 0000000..8f736b3 --- /dev/null +++ b/web/world/js/garden.js @@ -0,0 +1,103 @@ +/** + * garden.js — the garden model, one shared copy. [Lane A] + * + * SPRINT13 gate 1.2 — B's export ask, granted. These were module-locals in + * main.js, which made the audit's garden prediction impossible without copying + * three constants "C is about to change this very sprint" (B's words) — the + * exact drift sweep.js exists to prevent on the winnability side. Now main.js + * and tools/site_audit read the SAME model, so "same number, two harnesses" + * is structural rather than aspirational. B: the TEMPORARY COPY in + * garden_probe.html can die now. + * + * Nothing here changed in the move — this is a mechanical lift. The one thing + * that DID change this sprint is the comment on GARDEN_DRAIN, because the + * gate-1 rulings resolved what it is for. + */ + +/** + * How fast an unprotected garden dies, in HP per second at full exposure. + * + * SPRINT13 gate 1.4 RULING: **this constant does not move.** My old comment + * here said "the lever that actually needs moving is the shadow geometry, not + * this" — C measured the geometry and it was never wrong (probe2: raycast and + * rasteriser agree at 1.0000). What was wrong was the LINE the yard offered: + * the canonical p1..p4 covers 25% of what hail sees, and no drain value can + * separate a 25% line from a bare bed because the drain scales BOTH sides. + * The separation now comes from anchor placement (backyard_01's p5, its + * `separation` block, and the a.test assert that flies it), which is where it + * always had to come from. + * + * What 0.9 actually owns is the week's bare-bed arc — the difficulty curve of + * neglect, measured: gentle 97.6 · southerly 83.7 · buster 82.6 · wildnight + * 35.7 · icenight 0.0. Move this and all five nights retune at once; the + * wildnight bare bed must stay under the win line (< 50) for the pinned + * separation target to mean anything, and the early nights must stay + * survivable-by-default or night 1 stops being a tutorial. Retunes go through + * THREADS with that table, not through this number quietly. + */ +export const GARDEN_DRAIN = 0.9; + +/** + * Decision 13's weights. Hail is what actually kills a garden and cloth honestly + * stops it (stones fall ≤20° off vertical; rain leans 73° in a gale and walks + * straight under the sail — Lane C's numbers). Rain is demoted to a drizzle of + * damage so the night still costs you something when it isn't hailing. + * + * SPRINT6 gate 1 lists these first among the balance levers. + */ +export const HAIL_WEIGHT = 5.0; +export const RAIN_WEIGHT = 0.25; + +/** + * The garden: the thing you are actually protecting, and the only score that + * matters. Deliberately not inside hud.js — the HUD reads, it doesn't decide. + * + * It keeps its damage split by CAUSE, and that isn't bookkeeping for its own + * sake: the aftermath verdict is the game's entire feedback channel, and a + * verdict that guesses teaches the wrong lesson. A garden that only knows it + * went from 100 to 39 cannot tell a player whether their hardware let go or + * their perfectly-held sail simply wasn't over the bed — and those are opposite + * mistakes with opposite fixes. + * + * @param {{ setPlants(state: string): void }} world + */ +export function createGarden(world) { + let hp = 100; + let state = 'full'; + let byHail = 0; + let byRain = 0; + + return { + get hp() { return hp; }, + get state() { return state; }, + /** HP lost to each cause this run. The verdict reads these. */ + get damage() { return { hail: byHail, rain: byRain }; }, + + reset() { hp = 100; state = 'full'; byHail = 0; byRain = 0; world.setPlants('full'); }, + + /** + * Decision 13, and the two terms stay SEPARATE on the way in rather than + * being pre-summed by the caller — that sum is exactly the information the + * verdict needs and can never recover afterwards. + * + * @param {number} dt + * @param {number} hail 0..1 sky.gardenHailExposure(bed, t) + * @param {number} rain 0..1 sky.gardenExposure(bed, t) + */ + step(dt, hail, rain) { + const dHail = HAIL_WEIGHT * hail * GARDEN_DRAIN * dt; + const dRain = RAIN_WEIGHT * rain * GARDEN_DRAIN * dt; + const total = Math.min(hp, dHail + dRain); + if (total > 0) { + // Attribute proportionally, so the split still adds up on the last tick + // when the garden bottoms out at 0 mid-step. + const scale = total / (dHail + dRain); + byHail += dHail * scale; + byRain += dRain * scale; + hp -= total; + } + const next = hp > 66 ? 'full' : hp > 33 ? 'tattered' : 'dead'; + if (next !== state) { state = next; world.setPlants(next); } + }, + }; +} diff --git a/web/world/js/main.js b/web/world/js/main.js index 4bb614f..e183081 100644 --- a/web/world/js/main.js +++ b/web/world/js/main.js @@ -26,6 +26,7 @@ import { createSkyFx } from './skyfx.js'; import { createRiggingUI } from './rigging.js'; import { createHud } from './hud.js'; import { createWeek, NIGHTS, nightAt } from './week.js'; +import { createGarden } from './garden.js'; /** The calm day the forecast and prep phases run under. */ export const CALM_STORM = 'storm_01_gentle'; @@ -148,90 +149,33 @@ export function canPlayHere(mm = typeof matchMedia === 'function' ? matchMedia : } /** - * How fast an unprotected garden dies, in HP per second at full rain. + * 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). * - * Decision 7: drain is rain × (1 − rain shadow), NOT sun coverage. At night the - * sun shadow is a number about nothing, and storm_02 is a wildnight. + * 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. * - * ⚠️ This number is doing less work than it looks like it is, and the reason is - * worth reading before retuning it. Measured over storm_02 with a rig that holds - * 4/4 corners all night: sun coverage over the bed stays ~50%, but the RAIN - * shadow decays 0.38 → 0.04 as the wind builds, averaging 0.23. Driving rain - * blows under the sail and the shadow walks off the bed — which is Lane C's - * model being right about weather, not a bug. - * - * The consequence is that a perfectly rigged bed only ever sits ~23% drier than - * a bare one, so NO value here separates good rigging from none; at 1.6 both - * ended dead, and the spread stays ~20 points at any setting. 0.9 is chosen to - * put a good rig around 60% (a win) and a bare bed just under 50% (a loss), so - * the loop is playable and scores something — but the lever that actually needs - * moving is the shadow geometry, not this. Flagged for Lane C in THREADS. + * @param {{ setMute?: (on: boolean) => void } | null} sky + * @param {boolean} on + * @returns {boolean} true iff a real bus received the state */ -const GARDEN_DRAIN = 0.9; - -/** - * Decision 13's weights. Hail is what actually kills a garden and cloth honestly - * stops it (stones fall ≤20° off vertical; rain leans 73° in a gale and walks - * straight under the sail — Lane C's numbers). Rain is demoted to a drizzle of - * damage so the night still costs you something when it isn't hailing. - * - * SPRINT6 gate 1 lists these first among the balance levers. - */ -const HAIL_WEIGHT = 5.0; -const RAIN_WEIGHT = 0.25; - -/** - * The garden: the thing you are actually protecting, and the only score that - * matters. Deliberately not inside hud.js — the HUD reads, it doesn't decide. - * - * It keeps its damage split by CAUSE, and that isn't bookkeeping for its own - * sake: the aftermath verdict is the game's entire feedback channel, and a - * verdict that guesses teaches the wrong lesson. A garden that only knows it - * went from 100 to 39 cannot tell a player whether their hardware let go or - * their perfectly-held sail simply wasn't over the bed — and those are opposite - * mistakes with opposite fixes. - */ -function createGarden(world) { - let hp = 100; - let state = 'full'; - let byHail = 0; - let byRain = 0; - - return { - get hp() { return hp; }, - get state() { return state; }, - /** HP lost to each cause this run. The verdict reads these. */ - get damage() { return { hail: byHail, rain: byRain }; }, - - reset() { hp = 100; state = 'full'; byHail = 0; byRain = 0; world.setPlants('full'); }, - - /** - * Decision 13, and the two terms stay SEPARATE on the way in rather than - * being pre-summed by the caller — that sum is exactly the information the - * verdict needs and can never recover afterwards. - * - * @param {number} dt - * @param {number} hail 0..1 sky.gardenHailExposure(bed, t) - * @param {number} rain 0..1 sky.gardenExposure(bed, t) - */ - step(dt, hail, rain) { - const dHail = HAIL_WEIGHT * hail * GARDEN_DRAIN * dt; - const dRain = RAIN_WEIGHT * rain * GARDEN_DRAIN * dt; - const total = Math.min(hp, dHail + dRain); - if (total > 0) { - // Attribute proportionally, so the split still adds up on the last tick - // when the garden bottoms out at 0 mid-step. - const scale = total / (dHail + dRain); - byHail += dHail * scale; - byRain += dRain * scale; - hp -= total; - } - const next = hp > 66 ? 'full' : hp > 33 ? 'tattered' : 'dead'; - if (next !== state) { state = next; world.setPlants(next); } - }, - }; +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. * @@ -1052,27 +996,18 @@ export async function boot(opts = {}) { } /** - * M — mute. - * - * ⚠️ The bus is Lane C's and it does not have a tap yet. `createAudio()` holds - * a `master` gain inside skyfx.js's closure and the only thing it exposes is - * `unlockAudio()`, so there is nothing here for main.js to turn down. Asked in - * THREADS: `setMute(on)` on the skyfx API, one line against their master gain. - * - * Until it lands this returns FALSE, and the HUD asks before it advertises M - * (see hud.setAudioMuteAvailable). That is deliberate and it is D's lesson from - * the night-3 soft-lock: `rigging.setWorld?.(world)` sat in this same file for - * a sprint doing NOTHING, silently, because `?.()` on a missing method is a - * no-op that looks like a call. Shipping `sky.setMute?.(on)` behind a key the - * splash promises would be the same bug with a keycap on it — on a public URL, - * where the person pressing M is a stranger who just wants the noise to stop. - * So the key is wired, the state is real, and the UI tells the truth about - * whether it does anything. It lights up by itself the day C lands the tap. + * 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 = typeof sky?.setMute === 'function'; - if (bus) sky.setMute(muted); + const bus = applyMute(sky, muted); hud.setMuted(muted); return bus; }