Verdicts read the actual failure mode (SPRINT6 gate 1)

The aftermath was lying. Any run under 50 hp read "the rain found what you
skimped on" — including Lane B's twisted quad that holds 4/4 corners and skimps
on nothing. The verdict is the game's entire feedback channel: DESIGN.md wants
every disaster to replay as "…the shackle, I knew about the shackle", and
blaming the wrong thing teaches the opposite of the lesson the storm just spent
90 seconds giving.

The garden now keeps its damage split by cause, because the caller was pre-summing
hail and rain into one number and that sum is exactly the information the verdict
needs and can never recover afterwards. A garden that only knows it went 100 → 39
cannot tell a player whether their hardware let go or their perfectly-held sail
simply wasn't over the bed — opposite mistakes, opposite fixes.

verdictFor() is pure and lifted to module scope so it can be asserted directly.
It picks from modes the run can PROVE: cascade (naming the weakest link as going
first, not whichever was listed first), single corner, uncovered, rain, broomed,
ponded, clean. The integrator's exact case now reads "THE GARDEN IS GONE — and
every corner held. The hail fell where your sail wasn't."

Aftermath also gains decision 13's headline: "51 HP to hail, 13 to rain".

Verified live on the reported scenario, not just in asserts. Selftest 244/0/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-17 09:15:02 +10:00
parent 125411e0df
commit c3967d98e8
2 changed files with 206 additions and 16 deletions

View File

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

View File

@ -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', () => {