HardYards/web/world/js/tests/a.test.js
type-two 807de80969 Wire the kid's bike: site key + loader, gnome pattern; decline the $60
Gate 3.2: bike_kid_01_v1 on backyard_01's south fence, rotYDeg 180 so the
baked -Z lean lands ON the fence — bars measured to the rail (0.286 m
reach, z 9.69 -> grip at 9.98), tyres 0.31 m clear, between posts. Unpriced
by ruling: the sim can't knock a bike over, and billing an unseen event is
the lie the invoice kills; the windward-fence pose is the honest one.
a.test pins lean direction, standoff band, and the ruling. 318/0/0.
2026-07-17 20:34:47 +10:00

946 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 { ANCHOR_TYPE, FIXED_DT, STORM_LEN, YARD, checkContract, createStubWind } from '../contracts.js';
import { createWindField } from '../weather.core.js';
import { createWorld, heightAt, loadSite, validateSite } from '../world.js';
import { createCameraRig } from '../camera.js';
import { CALM_STORM, createGame, createWindRouter, stormsToPreload, verdictFor } from '../main.js';
import { orderRing } from '../sail.js';
import { loadStorm, createWind } from '../weather.js';
import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY } from '../week.js';
import { createHud } from '../hud.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();
// SPRINT10: the yard is data now. Loading the REAL site rather than a fixture
// is the point — these asserts are the proof that the extraction moved nothing.
const site = await loadSite('backyard_01');
const world = createWorld(scene, { wind: createStubWind({ calm: true }), site });
// 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 week (SPRINT8 gate 1) -------------------------------------------
t.test('the week is five escalating nights, each a storm and a site', () => {
assertEq(NIGHTS.length, 5);
// SPRINT10: a night is {storm, site} now. nightAt() normalises either shape.
assertEq(nightAt(0).storm, 'storm_01_gentle', 'night one must be the one that cannot hurt you');
assertEq(nightAt(4).storm, 'storm_02b_icenight', 'night five is the ice night');
assertEq(nightAt(2).site, 'site_02_corner_block', 'night three moves to the corner block');
assertEq(nightAt(0).site, 'backyard_01', 'the rest are the backyard');
const w = createWeek();
assertEq(w.night, 1); assertEq(w.bank, 80);
assertEq(w.site, 'backyard_01', 'week.site tracks the ladder');
assert(!w.isFinalNight);
for (let i = 0; i < 2; i++) w.advance();
assertEq(w.site, 'site_02_corner_block', 'and reaches the corner block on night three');
for (let i = 0; i < 2; i++) w.advance();
assertEq(w.night, 5);
assert(w.isFinalNight, 'night five is the last');
w.advance();
assertEq(w.night, 5, 'the ladder does not walk off its own end');
});
t.test('money persists across nights, and the bank is next night\'s shop', () => {
const w = createWeek();
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
const s = w.settle(
{ hp: 80, win: true, collateral: [], intactHardwareValue: 60 }, def, 70,
);
assertEq(s.bankBefore, 80);
assertEq(s.bankAfter, 80 - 70 + s.pay, 'bank = bank spent + pay, nothing else');
// SPRINT11 — `clean` is the new term, and this assert is the reason it's
// spelled out here rather than folded into the fee: the invoice shows four
// lines, so pay must BE those four lines. It caught the clean bonus the
// moment it landed (got 143, want 123), which is exactly its job — an
// invoice whose rows don't sum to its total is the one bug a player will
// definitely find.
assertEq(s.pay, s.fee + s.bonus + s.refund + s.clean - s.collateral, 'the ledger adds up as shown');
assertEq(s.refund, 30, 'gear comes home at half — a shackle that rode a gale is not new');
w.advance();
assertEq(w.bank, s.bankAfter, 'the bank IS the next shop');
});
t.test("the calm day loads even if no night is the gentle storm (D's landmine)", () => {
// main.js preloads STORMS and reads winds[CALM_STORM] for every forecast and
// prep frame. That only worked because night one HAPPENED to be the gentle
// storm — an invariant nothing stated. Drop gentle from the ladder and the
// game died at boot on "Cannot read properties of undefined (reading
// 'duration')", naming neither storms nor calm nor the week. Gate 2 rewrote
// every night entry, which is exactly when that bites.
//
// Asserts the RULE against main.js's own function, and asserts it on a
// ladder WITHOUT the gentle storm — the case that actually broke. Checked
// against the shipped NIGHTS it would pass either way (night one is gentle
// today), which is how the bug hid in the first place.
assert(stormsToPreload().includes(CALM_STORM),
'the calm day is preloaded on the shipped ladder');
assert(stormsToPreload(['storm_02_wildnight']).includes(CALM_STORM),
"...and on a ladder that doesn't contain it anywhere — the case that broke");
assertEq(stormsToPreload(['storm_01_gentle']).length, 1,
'and it is still deduped, not loaded twice');
});
// --- SPRINT11 gate 2: the job sheet -------------------------------------
t.test('every night is a JOB — a client, a brief, and a yard', () => {
for (let i = 0; i < NIGHTS.length; i++) {
const j = nightAt(i);
assert(j.client, `night ${i + 1} has a client`);
assert(j.addr, `night ${i + 1} has an address — a letterhead bills somebody somewhere`);
assert(j.brief && j.brief.length > 20, `night ${i + 1} has a brief worth reading`);
assert(j.site, `night ${i + 1} has a yard`);
}
// The week's shape, which is DATA and not decoration: four nights on
// retainer at one yard while they're away, and ONE short-notice callout to
// a stranger's corner block. Night 3 landing differently is the whole point
// of the ladder — a different site MEANS a different client.
const clients = NIGHTS.map((_, i) => nightAt(i).client);
assertEq(new Set(clients).size, 2, 'two clients: the retainer and the one-off');
assert(clients[2] !== clients[0], 'night 3 is somebody else');
assertEq(nightAt(2).site, 'site_02_corner_block', "and it's their corner block");
});
t.test('an unbriefed night is a job with no letterhead, not a crash', () => {
// SPRINT10's promise was that a bare storm key still resolves. SPRINT11 must
// keep it: the job sheet degrades to the old storm card rather than throwing
// or printing "undefined" at a client.
const j = nightAt(99); // off the end entirely
assertEq(j.client, null, 'no client is null, not undefined');
assertEq(j.addr, null, 'no address is null');
assertEq(j.brief, null, 'no brief is null');
assertEq(j.site, 'backyard_01', 'and it still names a yard');
});
t.test('the job sheet quotes what the night pays BEFORE you rig it', () => {
// The feature, not the flavour. The fee has existed since Sprint 8 and was
// only ever shown in the aftermath — you chose what to spend without knowing
// what the job was worth. That's a reveal, not a decision.
const w = createWeek();
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
const q = w.quote(def);
assertEq(q.base, PAY.feeFor(20), 'base is the fee, quoted at full');
assertEq(q.garden, PAY.gardenBonusMax, 'the garden bonus at a perfect bed');
assertEq(q.clean, PAY.noCollateralBonus, 'and the clean bonus');
assertEq(q.total, q.base + q.garden + q.clean, 'the quote sums to what it promises');
// The quote must not lie: settle a perfect night and the invoice has to pay
// what the sheet advertised. A job sheet that over-promises is worse than no
// job sheet — it's the game lying on paper.
const s = createWeek().settle({ hp: 100, win: true, collateral: [], intactHardwareValue: 0 }, def, 0);
assertEq(s.fee + s.bonus + s.clean, q.total, 'a perfect night pays exactly the quote');
});
t.test('the clean bonus is about THEIR property, not your success', () => {
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
const gnome = [{ what: 'garden gnome', cost: 25 }];
const cleanWin = createWeek().settle({ hp: 90, win: true, collateral: [], intactHardwareValue: 0 }, def, 0);
const dirtyWin = createWeek().settle({ hp: 90, win: true, collateral: gnome, intactHardwareValue: 0 }, def, 0);
const cleanLoss = createWeek().settle({ hp: 10, win: false, collateral: [], intactHardwareValue: 0 }, def, 0);
assertEq(cleanWin.clean, PAY.noCollateralBonus, 'broke nothing: paid');
assertEq(dirtyWin.clean, 0, 'broke the gnome: forfeited');
// The deliberate one. Lose the garden but break nothing of theirs and you
// are still a tradesperson who didn't wreck the place. Different fact,
// different row — that's what makes it an invoice and not a score.
assertEq(cleanLoss.clean, PAY.noCollateralBonus, 'a lost night can still be a clean one');
// And it makes the trap bite twice: 180 + the forfeited bonus.
const carport = createWeek().settle(
{ hp: 90, win: true, collateral: [{ what: 'the carport', cost: 180 }], intactHardwareValue: 0 }, def, 0);
assertEq(cleanWin.pay - carport.pay, 180 + PAY.noCollateralBonus,
'the carport costs 200: the roof AND the bonus');
});
t.test('a lost night pays a fraction of the fee, not zero and not all of it', () => {
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
const won = createWeek().settle({ hp: 80, win: true, collateral: [], intactHardwareValue: 0 }, def, 0);
const lost = createWeek().settle({ hp: 80, win: false, collateral: [], intactHardwareValue: 0 }, def, 0);
assertLess(lost.fee, won.fee, 'losing must cost you the fee');
assert(lost.fee > 0, 'but not all of it — you turned up. Zero is unrecoverable, not hard');
});
t.test('reaching night five is NOT the same as saving five gardens', () => {
// The bug this pins shipped in my own first draft and only measurement
// caught it: a $20-carabiner player loses four gardens, never dips under the
// broke line because a cheap rig barely costs anything, and was handed "THE
// WEEK HELD — everything's still where you put it" over four dead gardens.
// Outlasting the week is not surviving it, and the end card is the last
// thing this game says to anyone.
assertEq(gradeFor(5), 'clean', 'five held is the only clean week');
assertEq(gradeFor(4), 'scraped');
assertEq(gradeFor(3), 'scraped');
assertEq(gradeFor(1), 'solvent', 'one garden out of five is not a triumph');
assertEq(gradeFor(0), 'solvent');
});
t.test('going broke ends the week, but never on the final night', () => {
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
const ruin = { hp: 0, win: false, collateral: [{ what: 'gnome', cost: 25 }], intactHardwareValue: 0 };
const w = createWeek();
const s = w.settle(ruin, def, 80); // spend the lot, get almost nothing back
assert(s.bankAfter < BROKE_BELOW, `bank ${s.bankAfter} should be under the $${BROKE_BELOW} floor`);
assertEq(s.outcome, 'gameover', 'you cannot rig four corners, so the week is over');
// On the last night there is no next shop to be unable to afford, so being
// broke is just being broke — the week still finished.
const w2 = createWeek();
for (let i = 0; i < 4; i++) w2.advance();
assertEq(w2.settle(ruin, def, 80).outcome, 'win', 'night five always ends the week, not the run');
});
// Gate 2 acceptance: both sites load from data, and the corner block is not
// the backyard with the furniture moved. Built up front (Suite.test() can't
// await — the guard I added last sprint enforces it, and just caught me).
// storm_03b is the corner block's night: the early buster IS the southerly the
// funnel exists to teach, so the venturi assert measures the real pairing.
const earlyBusterDef = await loadStorm('storm_03b_earlybuster');
let site2World = null;
try {
const s2 = validateSite(await loadSite('site_02_corner_block'));
site2World = createWorld(new THREE.Scene(), { wind: createStubWind({ calm: true }), site: s2 });
try { await site2World.dress(); } catch { /* graybox anchors are enough */ }
} catch (err) {
console.warn('[a.test] site_02 unavailable (no server?):', err.message);
}
t.test('site_02 loads from JSON and is a genuinely different yard', () => {
if (!site2World) return 'SKIPPED — no server for site_02';
const ids = new Set(site2World.anchors.map((a) => a.id));
assert(ids.has('q1') && ids.has('tr1'), 'has its own honest anchors');
assert(!ids.has('h1') && !ids.has('p4'), 'and NOT the backyard\'s');
assertLess(site2World.gardenBed.w, world.gardenBed.w, 'the corner block is a smaller yard');
});
t.test('the carport is a trap: worst steel in the game, and a bracket job', () => {
if (!site2World) return 'SKIPPED — no server for site_02';
const cb = site2World.anchors.find((a) => a.id === 'cb1');
assert(cb, 'carport beam anchor cb1 exists');
// The whole reason the site teaches: it must out-lie the house fascia (0.35),
// and E's e.test pins these below it. If someone "fixes" them up, the corner
// block stops being a corner block and becomes a small yard.
if (cb.ratingHint != null) assertLess(cb.ratingHint, 0.35, 'carport must out-lie the fascia');
// D's field, keyed on MECHANISM: a bracket up a bare beam needs the ladder.
// Keyed on type it would fail open (carport isn't house), and the ladder
// mechanic would silently vanish — which is the audit D ran.
assertEq(cb.work, 'bracket', 'the carport beam is a bracket job — needs the ladder');
});
// --- SPRINT12 gate 3: E's letterhead/invoice kit, rendered ----------------
// Both of Sprint 11's card bugs were invisible to the suite because nothing
// ever RENDERED a card and looked. These do the minimum of that in DOM: the
// kit's contract classes must exist on a real render AND their load-bearing
// CSS must actually draw. Eyes on the browser are still required — a DOM
// assert can't see a letterhead with buttons through it.
t.test('SPRINT12: the invoice wears the letterhead, and the dead bonus is struck through', () => {
if (typeof document === 'undefined') return 'SKIPPED — no DOM';
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
// Night 3, the carport night: collateral forfeits the clean bonus, so this
// one render exercises the void, the neg AMOUNT DUE, and the docket number.
const wk = createWeek();
wk.advance(); wk.advance();
const w = wk.settle({ hp: 86, win: true,
collateral: [{ what: 'the carport', cost: 180 }], intactHardwareValue: 12 }, def, 71);
const hud = createHud({ scene: new THREE.Scene() });
try {
hud.showAftermath({ hp: 86, cornersLost: 1, cornersTotal: 4, hailBlocked: 'some hail',
bill: 34, collateral: [{ what: 'the carport', cost: 180 }],
subtitle: 'test', verdict: 'test', win: true, week: w }, () => {});
const q = (sel) => document.querySelector(`#hud-card ${sel}`);
assertEq(q('.letterhead .mark')?.textContent, 'HARD YARDS', "the outfit's mark (ruling: adopted)");
const no = q('.letterhead .docket .no')?.textContent ?? '';
assert(no.includes('INV 1043'), `docket says INV 1043, got "${no}"`);
// The ruling is that the ABN STAYS sequential — it reads as "example" the
// way 555 numbers do, and a realistic one is a stranger's tax identity.
assert(no.includes('ABN 12 345 678 901'), `the sequential ABN survives, got "${no}"`);
assertEq(q('.billto .who')?.textContent, 'the Vasilaros place', 'the bill-to block names the client');
// E's .void — quoted, not paid, and still on the paper.
const voidB = q('.ledger .row.void b');
assert(voidB, 'the forfeited clean bonus renders as .void, not as $0 or missing');
assertEq(voidB.textContent, `$${w.cleanMax}`, 'struck at the QUOTED amount');
assert(getComputedStyle(voidB).textDecorationLine.includes('line-through'),
'and the strike actually draws — the class without its CSS is decoration');
// AMOUNT DUE is the conclusion: pay spent, negative the night you take
// the carport with you, and it must SAY so.
const due = w.pay - 71;
const dueB = q('.ledger .row.due b');
assert(dueB, 'the invoice concludes with AMOUNT DUE');
assertEq(dueB.textContent, `${due < 0 ? '' : '+'}$${Math.abs(due)}`, 'due = pay spent');
assertEq(q('.ledger .row.due')?.classList.contains('neg'), due < 0, 'a negative due reads as one');
} finally { hud.dispose(); }
});
t.test('SPRINT12: the job sheet carries the same letterhead, a quote, and the terms line', () => {
if (typeof document === 'undefined') return 'SKIPPED — no DOM';
// The same outfit, the same night, the other document: JOB 1043 in the
// morning becomes INV 1043 — the pair answering each other is the design.
const wk = createWeek();
wk.advance(); wk.advance();
const hud = createHud({ scene: new THREE.Scene() });
try {
hud.showForecast(
{ key: 'storm_03b_earlybuster', def: earlyBusterDef, site: null, tomorrowDef: null },
{ night: wk.night, nights: wk.nights, log: [], job: wk.job,
quote: wk.quote(earlyBusterDef), bank: wk.bank }, () => {});
const q = (sel) => document.querySelector(`#hud-card ${sel}`);
assertEq(q('.letterhead .mark')?.textContent, 'HARD YARDS', 'same mark as the invoice');
assertEq(q('.letterhead .docket .kind')?.textContent, 'JOB SHEET', 'but this paper is the quote');
assert((q('.letterhead .docket .no')?.textContent ?? '').includes('JOB 1043'),
'and it shares the docket number with its invoice');
assert(q('.billto .addr'), 'the bill-to block says where the job is');
assertEq(document.querySelectorAll('#hud-card .sched .cond').length, 3,
'three pay lines, each carrying its condition');
assert((q('.terms')?.textContent ?? '').includes('not a variation'),
"the terms line: quoted on the forecast, weather is not a variation");
} finally { hud.dispose(); }
});
t.test("SPRINT12: the kid's bike leans INTO the south fence, not into open air", () => {
// E's axis trap, pinned from the PLACEMENT side. e.test proves the GLB
// leans toward local Z; nothing until now proved the site points that lean
// at anything. Un-rotate the bike or drag it mid-yard and this reddens —
// the failure mode is a bike leaning on air, which reads as a physics bug
// and would get chased in the wrong lane (E's words, their lean note).
const b = site.bike;
assert(b, 'backyard_01 places the bike');
assertEq(b.model, 'bike_kid_01_v1', "E's model, the v1 with the baked lean");
// Local Z rotated by rotY must land on +Z — the south fence side.
const ry = (b.rotYDeg * Math.PI) / 180;
const leanTowardFence = -Math.cos(ry); // z of (0,0,1) rotated about Y
assert(leanTowardFence > 0.95,
`rotYDeg ${b.rotYDeg} points the baked lean at z=${leanTowardFence.toFixed(2)} — not the fence`);
// The standoff, measured: bars reach 0.286 m past the tyre line toward the
// lean; the fence rails sit at z = depth/2 ± 0.02. The band accepts
// bars-on-rail ± a hand's width, and rejects mid-yard or tyres-in-fence.
const gap = site.yard.depth / 2 - b.z;
assert(gap > 0.15 && gap < 0.45,
`bike stands ${gap.toFixed(2)} m off the fence line — bars touch at ~0.31`);
// RULING (SPRINT12 gate 3.2): E's $60 is DECLINED until the sim can knock a
// bike over. Billing collateral for an event the player never sees is the
// lie the invoice exists to kill; leaning on the windward fence is the one
// pose a southerly can't falsify. This assert is the ruling's tripwire: if
// someone prices the bike, they must also build the fall, and this message
// is where they find that out.
assert(b.collateralValue == null, 'the bike is unpriced BY RULING — build the fall first');
});
// --- SPRINT11 gate 1: the three rulings, each pinned ----------------------
t.test('ruling: the carport is typed as a carport, not smuggled in as a post', () => {
if (!site2World) return 'SKIPPED — no server for site_02';
const by = (id) => site2World.anchors.find((a) => a.id === id);
// D's flag. Typed 'post' these four quietly joined the sail-post family that
// C's venturi and B's audit both read off `type`. The lie is what's pinned
// here: if someone re-types them 'post' to dodge the enum, this reddens.
assertEq(by('cb1').type, 'carport', 'beam anchors are carports');
assertEq(by('cb2').type, 'carport', 'beam anchors are carports');
assertEq(by('cp1').type, 'carport_post', 'the posts are carport posts');
assertEq(by('cp2').type, 'carport_post', 'the posts are carport posts');
// The one behaviour keyed on a type string, and the reason the lie mattered:
// a carport must never be mistaken for a tree and cast a wind shadow.
const trees = site2World.anchors.filter((a) => a.type === 'tree');
assert(trees.length > 0, 'the gum tree is still a tree');
assert(!trees.some((a) => a.id.startsWith('c')), 'no carport anchor answers the tree filter');
});
t.test('ruling: the widened enum is CHECKED, so a bad type cannot ship', () => {
// The enum was JSDoc for ten sprints — documentation cannot fail, which is
// exactly how a carport got typed 'post'. This is the assert that makes the
// contract real, so it must actually reject something.
const bogus = {
yard: { width: 10, depth: 10 }, gardenBed: { x: 0, z: 0, w: 2, d: 2 },
sun: { elevationDeg: 45, azimuthDeg: 0 },
posts: [{ id: 'x1', x: 0, z: 0, h: 3, type: 'trampoline', work: 'cloth' }],
};
let threw = null;
try { validateSite(bogus, 'bogus'); } catch (err) { threw = err.message; }
assert(threw, 'a site with an unknown anchor type must fail loud');
assert(/trampoline/.test(threw), 'and must NAME the type it rejected');
// ...and must not reject the types that are real, including the new ones.
for (const type of ANCHOR_TYPE) {
const ok = { ...bogus, posts: [{ id: 'x1', x: 0, z: 0, h: 3, type, work: 'cloth' }] };
validateSite(ok, `ok-${type}`); // throws = red
}
});
t.test('ruling: the venturi axis is a LINE — C and A never disagreed', () => {
// The reconciliation SPRINT11 asked for, pinned so it is never "fixed" back.
// weather.core aligns on |dot(wind, axis)| because a gap funnels either way
// through it, so axis and axis+PI are the same gap. A shipped 2.1, C read the
// southerly's heading at -1.08, and those are 2.2 deg apart: one line, two
// ends. Pinned against the SHIPPED axis — the site's geometry is the fact
// under test, not the storm heading it happens to run along.
const at = (axis) => {
const f = createWindField(earlyBusterDef);
f.setVenturi([{ x: -6, z: 0, axis, gain: 1.5, radius: 5, sharp: 3 }]);
return f;
};
const c = at(2.1), flipped = at(2.1 + Math.PI);
let worst = 0;
for (let tt = 0; tt <= 90; tt += 0.5) {
worst = Math.max(worst, Math.abs(c.speedAt(-6, 0, tt) - flipped.speedAt(-6, 0, tt)));
}
// NOT assertEq(worst, 0) — and the reason is worth the comment. I first
// "measured" this at exactly 0.000000 in a node probe and wrote that into
// the site JSON. It was a toFixed(6) printing 1e-14 as zero: the probe
// rounded a number I then reported as measured. cos(x+PI) is not bit-exactly
// -cos(x), so flipping the axis carries ~1e-16 of float noise into |dot|.
// 1e-14 m/s is not physics — a gust is 21 m/s — but "exactly" was my word,
// not the harness's. The claim is: identical to float precision.
assertLess(worst, 1e-9, 'axis and axis+PI are the same funnel, to float precision');
// And the funnel must actually DO something, or the corner block's whole
// weather personality is a no-op that no test would have noticed.
const off = createWindField(earlyBusterDef);
const peakT = 48.75; // measured: storm_03b's worst
assert(c.speedAt(-6, 0, peakT) > off.speedAt(-6, 0, peakT) * 1.2,
'the throat is meaningfully faster than the open yard');
});
t.test("every anchor carries a NUMBER for ratingHint (D's landmine)", () => {
if (!site2World) return 'SKIPPED — no server for site_02';
// Nothing reads ratingHint yet (that's the open ruling — see THREADS), and
// this assert exists BECAUSE of that: it makes the wire safe before it's
// made. The honest posts are built from site JSON and never pass through
// adoptAnchor, so they had no ratingHint at all — and `load > hw.rating *
// undefined` is `load > NaN`, which is always false. Wire it naively and the
// strongest anchors become immortal while the trap looks like it works.
for (const a of site2World.anchors) {
assert(Number.isFinite(a.ratingHint), `${a.id} has a finite ratingHint, not undefined`);
assert(a.ratingHint > 0, `${a.id}'s ratingHint is positive — a 0 would be unbreakable-by-zero`);
}
for (const a of world.anchors) {
assert(Number.isFinite(a.ratingHint), `backyard ${a.id} has a finite ratingHint too`);
}
// And the honest steel must still out-rate the trap, or the site has no thesis.
const q1 = site2World.anchors.find((a) => a.id === 'q1');
const cb1 = site2World.anchors.find((a) => a.id === 'cb1');
if (q1 && cb1 && cb1.ratingHint < 1) assertLess(cb1.ratingHint, q1.ratingHint,
'the carport beam is worse steel than an honest post');
});
t.test('ruling: the carport BILLS — 180, once, and takes the roof with it', () => {
if (!site2World) return 'SKIPPED — no server for site_02';
// E shipped the trap and said it plainly: the anchors said collateral
// "carport" and nothing said what a carport COST, so nothing scored it.
// You could lose the worst steel in the game and pay for a $15 shackle.
const priced = site2World.collateralFor('carport');
assert(priced, 'the carport has a price at all');
assertEq(priced.cost, 180, "A's ruling: 180 — 2.25 nights' budget");
assertEq(priced.label, 'the carport', 'and reads as English on the invoice');
// The price is the SITE's, not the mesh's. Sites are data.
assertEq(site2World.anchors.find((a) => a.id === 'cb1').collateral, 'carport',
'the beam anchor carries the key that reaches the price');
// An unpriced label must read as "not scored", never as free: the house's
// fascia anchors carry collateral "gutter" and nobody has priced a gutter.
assertEq(site2World.collateralFor('gutter'), null, 'unpriced is null, not 0');
assertEq(site2World.collateralFor(undefined), null, 'and no key is not a bill');
});
// --- the wind router -----------------------------------------------------
// Loaded HERE, not inside t.test(). These two were written `async` and
// Suite.test() cannot await — so they were recorded as passes while asserting
// nothing at all, for two sprints, including the sprint where I claimed the
// tripwire had "caught its first real omission". It had: in a hand-run probe,
// not in the suite. runAll DOES await run(), so awaiting belongs out here.
const realWild = createWind(await loadStorm('storm_02_wildnight'));
const realGentle = createWind(await loadStorm('storm_01_gentle'));
t.test('wind router forwards EVERYTHING the real wind exposes', () => {
// 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 = realWild;
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', () => {
// 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 = realGentle;
const wild = realWild;
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('the pyrrhic win: a sail that dies saving the garden is a WIN', () => {
// SPRINT9 decision 1, Lane A's ruling. The garden is the client's; the sail
// is yours. This night used to score as a flat LOSS, which was the repo's
// longest-running dead end — C measured the only $80 line on the wild night
// and it lands here.
const carabiner = { anchorId: 'p1', hw: { name: 'carabiner', rating: 1200, cost: 5 } };
const shackle = { anchorId: 'p3', hw: { name: 'shackle', rating: 3200, cost: 15 } };
const v = verdictFor({
hp: 52, lost: [shackle, carabiner], win: true,
dmg: { hail: 40, rain: 8 }, pondPeak: 0, pondDumped: 0,
});
assertEq(v.mode, 'pyrrhic', 'garden saved + sail destroyed is its own ending, not a cascade');
assert(/GARDEN MADE IT/.test(v.verdict), `it is a win and must read as one: "${v.verdict}"`);
assert(/carabiner at P1/.test(v.verdict), 'and still names the weakest link that went first');
});
t.test('the same wreck WITHOUT the garden is still a plain cascade', () => {
// The other half of the ruling: corners are priced, not gated — but the
// garden is still the whole job. Lose it and two corners and you lost.
const carabiner = { anchorId: 'p1', hw: { name: 'carabiner', rating: 1200, cost: 5 } };
const shackle = { anchorId: 'p3', hw: { name: 'shackle', rating: 3200, cost: 15 } };
const v = verdictFor({
hp: 20, lost: [shackle, carabiner], win: false,
dmg: { hail: 70, rain: 10 }, pondPeak: 0, pondDumped: 0,
});
assertEq(v.mode, 'cascade');
assert(/SAIL LOST/.test(v.verdict), `no garden, no win: "${v.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 12 anchors: 3 house, 5 tree, 4 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'), 4, 'post anchors — p3 (SPRINT3 dec 2), p4 (SPRINT6 gate 1)');
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');
});
}