HardYards/web/world/js/tests/a.test.js
m3ultra c3967d98e8 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>
2026-07-17 09:15:02 +10:00

475 lines
21 KiB
JavaScript

/**
* Lane A selftests — contracts, yard layout, anchors, phase machine.
* Lane A owns this file. Other lanes: yours is js/tests/<letter>.test.js.
*/
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, verdictFor } 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 */
export default async function run(t) {
const scene = new THREE.Scene();
const world = createWorld(scene, { wind: createStubWind({ calm: true }) });
// Dress the yard before asserting anything about it: anchors are only FINAL
// after dress(), which moves them onto the positions Lane E baked and adds the
// extra tree branches. Testing the graybox would be testing a yard that never
// reaches a player. Guarded, so a missing server degrades to graybox asserts
// rather than reddening the whole lane.
let dressed = false;
try {
await world.dress();
dressed = true;
} catch (err) {
console.warn('[a.test] dress() unavailable, asserting against graybox:', err.message);
}
// --- contract conformance ------------------------------------------------
// These are the merge tripwires: if a lane's module drifts from contracts.js,
// this is where we find out, not three lanes later.
t.test('contract: stub wind conforms', () => {
assertEq(checkContract('wind', createStubWind()).join('; '), '');
});
t.test('contract: world conforms', () => {
assertEq(checkContract('world', world).join('; '), '');
});
t.test('contract: camera rig conforms', () => {
const rig = createCameraRig(document.createElement('div'));
assertEq(checkContract('camera', rig).join('; '), '');
});
t.test('contract: game conforms', () => {
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)');
});
// --- 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', () => {
// PLAN3D §5-A acceptance: "camera never clips through house". Stated here
// as the underlying invariant — no solid between the player's head and the
// camera — so it still means something after Lane E swaps the graybox house
// for house_yardside.glb.
const rig = createCameraRig(document.createElement('div'));
rig.setSolids(world.solids);
rig.setGround(heightAt);
const ray = new THREE.Raycaster();
const head = new THREE.Vector3();
const spots = [
[0, -9.4], // backed against the house — the case that caught it
[-5, -9.2], // under a fascia anchor
[-9, 2.6], // hard against a tree trunk
[-6, 7], // hard against a post
[0, 9.2], // against the south fence
];
for (const [x, z] of spots) {
const spot = new THREE.Vector3(x, heightAt(x, z), z);
for (let k = 0; k < 6; k++) {
rig.yaw = (k / 6) * Math.PI * 2;
// 60 steps = 1 s of smoothing at lambda 9, i.e. 99.99% settled. Every
// step is a raycast against the whole yard, so this bound is the
// difference between a 1 s selftest and a 4 s one — and every lane runs
// it after every merge.
for (let i = 0; i < 60; i++) rig.update(1 / 60, spot);
head.set(spot.x, spot.y + 1.55, spot.z);
const cam = rig.object.position;
const d = head.distanceTo(cam);
assert(d > 0.1, `camera collapsed onto the player (${d.toFixed(3)}m) at (${x},${z})`);
ray.set(head, cam.clone().sub(head).divideScalar(d));
ray.far = d - 0.02;
const blocked = ray.intersectObjects(world.solids, true)[0];
assert(
!blocked,
`'${blocked?.object.name || '?'}' sits between the player at (${x},${z}) ` +
`and the camera at yaw ${rig.yaw.toFixed(2)}`,
);
// The ground isn't a solid (heightAt handles it), so the raycast above
// can't speak for it — assert it separately.
assert(
cam.y > heightAt(cam.x, cam.z),
`camera is underground at (${cam.x.toFixed(1)},${cam.z.toFixed(1)}), ` +
`y=${cam.y.toFixed(2)} vs terrain ${heightAt(cam.x, cam.z).toFixed(2)}`,
);
}
}
});
// --- terrain -------------------------------------------------------------
t.test('heightAt is pure and gentle across the whole yard', () => {
for (let x = -15; x <= 15; x += 1.5) {
for (let z = -10; z <= 10; z += 1.5) {
const h = heightAt(x, z);
assertEq(h, heightAt(x, z), `heightAt(${x},${z}) not pure`);
assert(Math.abs(h) < 0.5, `heightAt(${x},${z}) = ${h}: terrain should stay gentle`);
}
}
});
t.test('garden bed sits inside the yard', () => {
const b = world.gardenBed;
assert(Math.abs(b.x) + b.w / 2 < YARD.width / 2, 'bed overhangs east/west');
assert(Math.abs(b.z) + b.d / 2 < YARD.depth / 2, 'bed overhangs north/south');
});
// --- anchors -------------------------------------------------------------
t.test('yard offers 11 anchors: 3 house, 5 tree, 3 post', () => {
const by = (type) => world.anchors.filter((a) => a.type === type).length;
assertEq(by('house'), 3, 'house anchors');
assertEq(by('tree'), dressed ? 5 : 2, 'tree anchors (branch_anchor_* arrive with dress())');
assertEq(by('post'), 3, 'post anchors — p3 added, SPRINT3 decision 2');
const ids = world.anchors.map((a) => a.id);
assertEq(new Set(ids).size, ids.length, `anchor ids not unique: ${ids}`);
});
t.test('anchors carry Lane E\'s rating_hint, and the fascia is the weak one', () => {
if (!dressed) return t.skip('needs dress()');
const hint = (id) => world.anchors.find((a) => a.id === id)?.ratingHint;
// DESIGN.md: "The fascia board is a lie: holds until the first real gust."
// Lane E encoded that as rating_hint 0.35 in house_yardside_v1.glb, so the
// asset says it and nothing here has to restate it. If this ever flips to
// 1.0, the yard has quietly stopped teaching its best lesson.
assertLess(hint('h1'), 0.5, 'fascia anchor should be the weak option');
assertEq(world.anchors.find((a) => a.id === 'h1').collateral, 'gutter',
'a fascia failure takes the gutter with it — that is the collateral cost');
assert(hint('t1') > hint('t1c'),
'a branch anchor at the fork must out-rate one out where the limb is thin');
});
// --- decision 2: the yard has to offer a real choice ----------------------
t.test('yard offers ≥3 riggable quads in the 18-45 m² band that shade the bed', () => {
if (!dressed) return t.skip('needs dress() — anchors are only final after it');
// SPRINT3 decision 2. Before the rework every quad covering the bed was
// 110 m²+, which pre-tensions itself into a cascade at t=0.4 s before the
// wind does anything — the yard taught the wrong lesson.
const bed = world.gardenBed;
const areaOf = (q) => {
const r = orderRing(q);
let a = 0;
for (let i = 0, j = r.length - 1; i < r.length; j = i++) {
a += (r[j].pos.x + r[i].pos.x) * (r[j].pos.z - r[i].pos.z);
}
return Math.abs(a / 2);
};
const inside = (x, z, r) => {
let c = false;
for (let i = 0, j = r.length - 1; i < r.length; j = i++) {
const a = r[i].pos, b = r[j].pos;
if ((a.z > z) !== (b.z > z) && x < ((b.x - a.x) * (z - a.z)) / (b.z - a.z) + a.x) c = !c;
}
return c;
};
const coverOf = (q) => {
const r = orderRing(q);
let hit = 0, tot = 0;
for (let i = 0; i < 6; i++) {
for (let j = 0; j < 4; j++) {
const x = bed.x - bed.w / 2 + ((i + 0.5) / 6) * bed.w;
const z = bed.z - bed.d / 2 + ((j + 0.5) / 4) * bed.d;
tot++;
if (inside(x, z, r)) hit++;
}
}
return hit / tot;
};
const A = world.anchors;
const band = [];
for (let i = 0; i < A.length; i++) {
for (let j = i + 1; j < A.length; j++) {
for (let k = j + 1; k < A.length; k++) {
for (let l = k + 1; l < A.length; l++) {
const q = [A[i], A[j], A[k], A[l]];
const m2 = areaOf(q);
if (m2 >= 18 && m2 <= 45 && coverOf(q) >= 0.25) {
band.push(`${q.map((a) => a.id).join('+')} ${m2.toFixed(0)}`);
}
}
}
}
}
assert(band.length >= 3,
`only ${band.length} quads in 18-45 m² shade the bed — the yard offers no ` +
`storm-survivable option. Found: ${band.join(', ') || 'none'}`);
});
t.test('full shade over the bed stays expensive — the tradeoff is the game', () => {
if (!dressed) return t.skip('needs dress()');
// The other half of decision 2, and the half that is easy to "fix" by
// accident. DESIGN.md's core tension is that big+flat+low buys great shade
// and dies in a storm, while small+twisted survives and shades patchily. If
// some future yard tweak ever lets a small quad cover the whole bed, that
// tension is gone and the rigging puzzle has no wrong answers left.
const bed = world.gardenBed;
const A = world.anchors;
let smallestFull = Infinity;
const areaOf = (q) => {
const r = orderRing(q);
let a = 0;
for (let i = 0, j = r.length - 1; i < r.length; j = i++) {
a += (r[j].pos.x + r[i].pos.x) * (r[j].pos.z - r[i].pos.z);
}
return Math.abs(a / 2);
};
const inside = (x, z, r) => {
let c = false;
for (let i = 0, j = r.length - 1; i < r.length; j = i++) {
const a = r[i].pos, b = r[j].pos;
if ((a.z > z) !== (b.z > z) && x < ((b.x - a.x) * (z - a.z)) / (b.z - a.z) + a.x) c = !c;
}
return c;
};
for (let i = 0; i < A.length; i++) {
for (let j = i + 1; j < A.length; j++) {
for (let k = j + 1; k < A.length; k++) {
for (let l = k + 1; l < A.length; l++) {
const q = [A[i], A[j], A[k], A[l]];
const r = orderRing(q);
let hit = 0;
for (let a = 0; a < 6; a++) {
for (let b = 0; b < 4; b++) {
const x = bed.x - bed.w / 2 + ((a + 0.5) / 6) * bed.w;
const z = bed.z - bed.d / 2 + ((b + 0.5) / 4) * bed.d;
if (inside(x, z, r)) hit++;
}
}
if (hit / 24 >= 0.9) smallestFull = Math.min(smallestFull, areaOf(q));
}
}
}
}
assert(smallestFull > 45,
`a ${smallestFull.toFixed(0)} m² quad covers the whole bed — full shade is ` +
`supposed to cost you a sail the storm can take`);
});
t.test('sway() returns an absolute position, not an offset', () => {
// If sway ever regresses to returning an offset, the returned point lands
// near the origin instead of near the anchor, and Lane B's cloth corners
// all snap to the middle of the yard. Catch it here.
for (const a of world.anchors) {
const p = a.sway(3.1);
assertLess(p.distanceTo(a.pos), 0.6, `${a.id}.sway() is ${p.length().toFixed(2)}m from origin but should be near pos`);
}
});
t.test('house and post anchors are rigid; tree anchors move', () => {
for (const a of world.anchors.filter((x) => x.type !== 'tree')) {
assertEq(a.sway(0).distanceTo(a.sway(12.5)), 0, `${a.id} should not move`);
}
for (const a of world.anchors.filter((x) => x.type === 'tree')) {
// Sampled at two times a half-period apart; a static tree would score 0.
const spread = a.sway(0).clone().distanceTo(a.sway(0.83));
assert(spread > 1e-4, `${a.id} should sway with the wind, moved ${spread}m`);
}
});
t.test('anchors do not alias each other scratch vectors', () => {
// Two tree anchors handing out the same scratch Vector3 would silently
// corrupt whichever corner was read first.
const t1 = world.anchor('t1'), t2 = world.anchor('t2');
const p1 = t1.sway(2);
const x1 = p1.x;
const p2 = t2.sway(2);
assert(p1 !== p2, 't1 and t2 handed out the same vector object');
assertEq(p1.x, x1, 'reading t2 mutated the vector t1 returned');
});
// --- wind stub -----------------------------------------------------------
t.test('gust telegraph always gives at least 1.2 s of warning', () => {
// The promise the whole storm rests on: the player can always react.
// Lane C must keep this true for the real weather.js.
const wind = createStubWind({ seed: 7 });
let had = false, edges = 0;
fixedLoop(STORM_LEN, FIXED_DT, (dt, time) => {
const tel = wind.gustTelegraph(time);
if (tel && !had) {
edges++;
assert(tel.eta >= 1.2, `telegraph appeared with only ${tel.eta.toFixed(2)}s of warning`);
}
had = !!tel;
});
assert(edges >= 5, `only ${edges} gusts telegraphed in a ${STORM_LEN}s storm — too quiet to test`);
});
t.test('wind stub is deterministic: same seed, same storm', () => {
const a = createStubWind({ seed: 42 });
const b = createStubWind({ seed: 42 });
const p = new THREE.Vector3(1, 1, 1);
for (let time = 0; time < STORM_LEN; time += 0.37) {
const va = a.sample(p, time).clone();
const vb = b.sample(p, time);
assertEq(va.x, vb.x, `x diverged at t=${time}`);
assertEq(va.z, vb.z, `z diverged at t=${time}`);
}
});
t.test('wind stub gets angrier as the storm runs', () => {
const wind = createStubWind({ seed: 3 });
const p = new THREE.Vector3();
assertLess(wind.sample(p, 1).length(), wind.sample(p, STORM_LEN - 1).length());
});
// --- phase machine -------------------------------------------------------
t.test('phases cycle forecast → prep → storm → aftermath → forecast', () => {
const game = createGame();
assertEq(game.phase, 'forecast');
game.advance(); assertEq(game.phase, 'prep');
game.advance(); assertEq(game.phase, 'storm');
game.advance(); assertEq(game.phase, 'aftermath');
game.advance(); assertEq(game.phase, 'forecast');
});
t.test('phaseChange fires once, with from and to', () => {
const game = createGame();
const seen = [];
game.on('phaseChange', (p) => seen.push(p));
game.setPhase('prep');
game.setPhase('prep'); // no-op: already there
assertEq(seen.length, 1, 'phaseChange fired for a no-op transition');
assertEq(seen[0].from, 'forecast');
assertEq(seen[0].to, 'prep');
});
t.test('a 90 s storm ends itself (fast-forwarded, no rAF)', () => {
const game = createGame();
game.setPhase('storm');
fixedLoop(STORM_LEN - 1, FIXED_DT, () => game.tick(FIXED_DT));
assertEq(game.phase, 'storm', 'storm ended early');
fixedLoop(2, FIXED_DT, () => game.tick(FIXED_DT));
assertEq(game.phase, 'aftermath', 'storm never ended');
});
t.test('unknown phase is rejected', () => {
const game = createGame();
let threw = false;
try { game.setPhase('apocalypse'); } catch { threw = true; }
assert(threw, 'setPhase accepted a phase that does not exist');
});
}