Sprint 13: garden model exported as garden.js (B's gate-1.2 blocker) + applyMute(sky,on) as a value

- 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.
This commit is contained in:
type-two 2026-07-18 12:44:37 +10:00
parent 6cf5b07f32
commit 7acee5437b
2 changed files with 135 additions and 97 deletions

103
web/world/js/garden.js Normal file
View File

@ -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); }
},
};
}

View File

@ -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;
}