diff --git a/web/world/js/main.js b/web/world/js/main.js index 33c25c5..38c09d8 100644 --- a/web/world/js/main.js +++ b/web/world/js/main.js @@ -54,35 +54,129 @@ const STORMS = ['storm_01_gentle', 'storm_03_southerly', 'storm_02_wildnight']; */ 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; }, - reset() { hp = 100; state = 'full'; world.setPlants('full'); }, + /** 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'); }, + /** - * @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. + * 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. * - * 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. + * @param {number} dt + * @param {number} hail 0..1 sky.gardenHailExposure(bed, t) + * @param {number} rain 0..1 sky.gardenExposure(bed, t) */ - step(dt, exposure) { - if (exposure > 0) hp = Math.max(0, hp - GARDEN_DRAIN * exposure * dt); + 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); } }, }; } +/** + * 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, win, dmg, pondPeak, pondDumped }) { + const worst = lost.length + ? lost.reduce((a, c) => (a && a.hw.rating <= c.hw.rating ? a : c)) + : null; + const named = worst ? `${worst.hw.name} at ${worst.anchorId.toUpperCase()}` : ''; + const hailKilled = dmg.hail > dmg.rain; + + if (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.' }; +} + // --------------------------------------------------------------------------- // Phase machine // --------------------------------------------------------------------------- @@ -435,6 +529,9 @@ export async function boot(opts = {}) { const s = rigging.summary; const hp = garden.hp; const win = hp >= 50 && lost.length < 2; + const dmg = garden.damage; + const { verdict, mode } = verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped }); + return { hp, cornersLost: lost.length, @@ -446,13 +543,33 @@ export async function boot(opts = {}) { subtitle: lost.length ? `${lost.map((c) => `${c.hw.name} at ${c.anchorId.toUpperCase()}`).join(', ')} let go.` : 'Every corner held.', - verdict: hp >= 85 && !lost.length ? 'THE GARDEN MADE IT — not a leaf out of place.' - : win ? 'THE GARDEN MADE IT. Mostly.' - : hp < 50 ? 'THE GARDEN IS GONE. The rain found what you skimped on.' - : 'THE SAIL LOST. Warranty callout in the rain for you.', + /** 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.', + pondPeak, + pondDumped, + verdict, + /** Which failure mode the verdict is speaking from. Asserted in a.test.js. */ + verdictMode: mode, }; } + // --- 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; + }); + // --- phases ------------------------------------------------------------- game.on('phaseChange', ({ to }) => { // Prep and forecast happen on the calm day; the storm you picked only @@ -462,6 +579,8 @@ export async function boot(opts = {}) { events.length = 0; rigging.setActive(to === 'prep'); + if (to === 'storm') { pondPeak = 0; pondDumped = 0; } + if (to === 'forecast') { garden.reset(); resetRig(); @@ -554,7 +673,16 @@ export async function boot(opts = {}) { // ~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; - garden.step(dt, hailExp * 5.0 + rainExp * 0.25); + // 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()); + } } } diff --git a/web/world/js/tests/a.test.js b/web/world/js/tests/a.test.js index 8c73afb..4c808a0 100644 --- a/web/world/js/tests/a.test.js +++ b/web/world/js/tests/a.test.js @@ -7,7 +7,7 @@ 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, createWindRouter } from '../main.js'; +import { createGame, createWindRouter, verdictFor } from '../main.js'; import { orderRing } from '../sail.js'; import { loadStorm, createWind } from '../weather.js'; import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js'; @@ -97,6 +97,68 @@ export default async function run(t) { assertEq(router.def, wild.def, 'def follows the active storm (skyfx reads it at construction)'); }); + // --- verdicts tell the truth (SPRINT6 gate 1) ---------------------------- + + t.test('a run that held every corner is never told it skimped', () => { + // The bug this pins shipped and was caught by playing, not by asserting: + // any run under 50 hp read "the rain found what you skimped on", including + // Lane B's twisted quad that held 4/4 and skimped on nothing. The verdict is + // the game's whole feedback channel — DESIGN.md wants every disaster to + // replay as "…the shackle, I knew about the shackle", and blaming the wrong + // thing teaches the exact opposite of the lesson the storm just gave. + const heldAll = verdictFor({ + hp: 39, lost: [], win: false, + dmg: { hail: 48, rain: 13 }, pondPeak: 0, pondDumped: 0, + }); + assertEq(heldAll.mode, 'uncovered', + 'a 4/4 hold that lost the garden to hail is a COVERAGE failure, not a hardware one'); + assert(!/skimp/i.test(heldAll.verdict), + `verdict accused a player who broke nothing of skimping: "${heldAll.verdict}"`); + assert(/held/i.test(heldAll.verdict), + `verdict should credit the corners that held: "${heldAll.verdict}"`); + }); + + t.test('verdict names the weakest link that actually let go', () => { + // "…the shackle. I knew about the shackle." The whole point is that the + // player recognises the corner they gambled on. + const carabiner = { anchorId: 'p1', hw: { name: 'carabiner', rating: 1200, cost: 5 } }; + const shackle = { anchorId: 'h3', hw: { name: 'shackle', rating: 3200, cost: 15 } }; + + const cascade = verdictFor({ + hp: 20, lost: [shackle, carabiner], win: false, + dmg: { hail: 60, rain: 20 }, pondPeak: 0, pondDumped: 0, + }); + assertEq(cascade.mode, 'cascade'); + assert(/carabiner at P1/.test(cascade.verdict), + `a cascade must name the WEAKEST link as going first, not whichever was listed first: "${cascade.verdict}"`); + + const single = verdictFor({ + hp: 30, lost: [shackle], win: false, + dmg: { hail: 60, rain: 10 }, pondPeak: 0, pondDumped: 0, + }); + assertEq(single.mode, 'corner'); + assert(/shackle at H3/.test(single.verdict), `should name it: "${single.verdict}"`); + }); + + t.test('verdict distinguishes the two ways a garden dies', () => { + // Opposite mistakes, opposite fixes: under-bought hardware vs a rig that + // held perfectly over the wrong patch of grass. One sentence each. + const hailed = verdictFor({ hp: 20, lost: [], win: false, dmg: { hail: 70, rain: 10 }, pondPeak: 0, pondDumped: 0 }); + const rained = verdictFor({ hp: 20, lost: [], win: false, dmg: { hail: 0, rain: 80 }, pondPeak: 0, pondDumped: 0 }); + assertEq(hailed.mode, 'uncovered'); + assertEq(rained.mode, 'rain'); + assert(hailed.verdict !== rained.verdict, 'hail and rain deaths must not read identically'); + }); + + t.test('a clean hold reads as a clean hold, and the broom gets its credit', () => { + const clean = verdictFor({ hp: 96, lost: [], win: true, dmg: { hail: 2, rain: 2 }, pondPeak: 0, pondDumped: 0 }); + assertEq(clean.mode, 'clean'); + + const broomed = verdictFor({ hp: 70, lost: [], win: true, dmg: { hail: 20, rain: 10 }, pondPeak: 400, pondDumped: 380 }); + assertEq(broomed.mode, 'broomed', 'wearing 380 kg of water to save the rig should be the story'); + assert(/380 kg/.test(broomed.verdict), `say what it cost: "${broomed.verdict}"`); + }); + // --- camera -------------------------------------------------------------- t.test('camera keeps a clear line to the player from every angle', () => {