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:
parent
6cf5b07f32
commit
7acee5437b
103
web/world/js/garden.js
Normal file
103
web/world/js/garden.js
Normal 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); }
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user