diff --git a/web/world/js/main.js b/web/world/js/main.js index 6d6fda2..8862900 100644 --- a/web/world/js/main.js +++ b/web/world/js/main.js @@ -65,9 +65,18 @@ function createGarden(world) { get hp() { return hp; }, get state() { return state; }, reset() { hp = 100; state = 'full'; world.setPlants('full'); }, - /** @param {number} rain 0..1 @param {number} dry 0..1 fraction kept dry by the sail */ - step(dt, rain, dry) { - if (rain > 0) hp = Math.max(0, hp - GARDEN_DRAIN * rain * (1 - dry) * dt); + /** + * @param {number} dt + * @param {number} exposure 0..1 — how hard the bed is being hit right now. + * 0 = nothing reaching it (no weather, or cloth is over it); 1 = taking it + * full in the open. Lane C's sky.gardenExposure() computes it. + * + * SPRINT5 decision 13 lands here and nowhere else: garden damage becomes + * hail exposure + a small rain drain, so this stays one number and the + * only change is which helper(s) feed it. + */ + step(dt, exposure) { + if (exposure > 0) hp = Math.max(0, hp - GARDEN_DRAIN * exposure * dt); const next = hp > 66 ? 'full' : hp > 33 ? 'tattered' : 'dead'; if (next !== state) { state = next; world.setPlants(next); } }, @@ -134,9 +143,21 @@ export function createGame() { * 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 */ -function createWindRouter(all) { +export function createWindRouter(all) { let active = all[0]; const router = { @@ -515,12 +536,15 @@ export async function boot(opts = {}) { sky?.step(dt, windT, { sail: rig }); rigging.update(dt, windT); - // Decision 7: what kills the garden is rain that the sail didn't stop. - // Only during the storm — the calm day's drizzle is scenery. + // 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') { - const rain = wind.rainAt(windT); - const dry = sky?.rainShadowOver ? sky.rainShadowOver(world.gardenBed) : 0; - garden.step(dt, rain, dry); + garden.step(dt, sky?.gardenExposure ? sky.gardenExposure(world.gardenBed, windT) : 0); } } diff --git a/web/world/js/tests/a.test.js b/web/world/js/tests/a.test.js index 1c5d426..8c73afb 100644 --- a/web/world/js/tests/a.test.js +++ b/web/world/js/tests/a.test.js @@ -7,8 +7,9 @@ import * as THREE from '../../vendor/three.module.js'; import { FIXED_DT, STORM_LEN, YARD, checkContract, createStubWind } from '../contracts.js'; import { createWorld, heightAt } from '../world.js'; import { createCameraRig } from '../camera.js'; -import { createGame } from '../main.js'; +import { createGame, createWindRouter } from '../main.js'; import { orderRing } from '../sail.js'; +import { loadStorm, createWind } from '../weather.js'; import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js'; /** @param {import('../testkit.js').Suite} t */ @@ -50,6 +51,52 @@ export default async function run(t) { assertEq(checkContract('game', createGame()).join('; '), ''); }); + // --- the wind router ----------------------------------------------------- + + t.test('wind router forwards EVERYTHING the real wind exposes', async () => { + // This exists because the omission it catches has already shipped once. + // + // Lane C added rainMmPerHour/rainDepthMm for ponding; main.js's router — a + // hand-maintained delegation list — didn't forward them. Nothing went red: + // every suite holds a real wind, and only the GAME holds the router. So + // Lane B's ponding would have passed every assert it had and done nothing + // in the actual yard, and it took the integrator noticing by hand at merge. + // + // The class of bug is worse than the instance. Sprint 5's headline system is + // Lane C's hailAt(), and decision 13 hangs the entire garden score off it — + // so the same silent swallow would make rigging look irrelevant to the + // garden all over again, which is the exact thing this sprint exists to fix. + // Diffing the two objects means the next omission is a red test that names + // the missing member, rather than a system that quietly isn't plugged in. + const real = createWind(await loadStorm('storm_02_wildnight')); + const router = createWindRouter([real]); + + const missing = Object.keys(real).filter((k) => !(k in router)); + assert(missing.length === 0, + `the wind router swallows ${missing.join(', ')} — add ${missing.length > 1 ? 'them' : 'it'} ` + + `to createWindRouter in main.js, or the game silently runs without ${missing.length > 1 ? 'those' : 'that'}`); + + // Present isn't enough — a forwarded method has to actually reach the wind. + const wrongType = Object.keys(real).filter((k) => typeof real[k] === 'function' && typeof router[k] !== 'function'); + assert(wrongType.length === 0, `router has ${wrongType.join(', ')} but not as callable(s)`); + }); + + t.test('wind router delegates live — use() re-points every consumer at once', async () => { + // The router's whole reason to exist: consumers bind once at construction, + // so a storm swap has to be a re-point rather than a re-wire. + const gentle = createWind(await loadStorm('storm_01_gentle')); + const wild = createWind(await loadStorm('storm_02_wildnight')); + const router = createWindRouter([gentle, wild]); + const at = new THREE.Vector3(0, 0, 0); + + router.use(gentle); + const calm = router.speedAt(at, 60); + router.use(wild); + const gale = router.speedAt(at, 60); + assertLess(calm, gale, 'swapping to the wild night must change what consumers read'); + assertEq(router.def, wild.def, 'def follows the active storm (skyfx reads it at construction)'); + }); + // --- camera -------------------------------------------------------------- t.test('camera keeps a clear line to the player from every angle', () => {