HardYards/web/world/js/tests/a.test.js
type-two dd69a2cf6c Lane A S17 gates 0+1+2: THE JOB BOARD — two offers a morning, the chosen job IS the night
Gate 0 rulings (written, with reasoning, in THREADS):
- 0.4 hail-light balance: CANON. B's finding reproduces exactly ($4/$2 garden
  spread), but the mild-night rig market buys the STEEL and the collateral, not
  the bed — a cheap rig that fails on site_02 swings $184 and two stars against
  the same rig holding, and the cheapest HOLDING line is $40, not the $20 floor.
  Ruled for B's option (b): the separation block asks the wrong question on
  those nights; the new shape is B's and is NOT this sprint. Rain drain-weight
  lever declined at its proper price (full gauntlet re-measure, own gate).
- 0.4b gradeFor scraped/solvent >=3 STANDS on the seven-night shape, ruled on
  the arithmetic (savable 6 → solvent is 4-6 dead, which is what the copy says).
- 0.3 score-header wording consulted for B (no code from me).

Gate 1 — the board: board.js (pure data, node-testable), week.offers()/take(),
seeded from week+night via contracts' mulberry32 — no Date.now, no Math.random.
An offer carries a whole NIGHT ENTRY and take() installs it, so sheet, quote,
invoice, ledger, warranty, rep and end card all read a chosen job through the
path they already had — seven surfaces, zero changed lines. Scripted spine runs
byte-identically when nothing is taken.

Gate 2 (data half) — constraints as night data, checked enum, premium ruled
(+25% anchor ban / +20% budget cap, both UNMEASURED and flagged cut-first),
stated on the offer, the job sheet and the invoice it was paid under. ONE
calloutFee() now serves quote() and settle(). Enforcement is B's.

Two bugs found off the suite: take() stored raw pool entries (crashed quote()
on a missing pay block) → normaliseNight() split so both doors share it; and
NIGHT 1 HAD NO BOARD — the splash bypassed it — found by looking, not testing.
Pool pairings audited in the browser before shipping; both constraints checked
for soft-lock against real holding lines.

Mutation-checked ×6. One did NOT go red: "every pool storm is preloaded" passed
with the POOL source deleted, because every pool storm is also in NIGHTS — the
same coincidence that hid the calm-day bug. stormsToPreload(nights, pool) split
so it can fail. Selftest 485/0/0 (474 + 11).
2026-07-21 02:11:16 +10:00

2056 lines
114 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, HARDWARE, PHASES, START_BUDGET, STORM_LEN, YARD,
checkContract, createStubWind,
} from '../contracts.js';
import { createWindField } from '../weather.core.js';
import { createWorld, heightAt, loadSite, validateSite } from '../world.js';
import { createCameraRig, spawnYawFor } from '../camera.js';
import {
CALM_STORM, accumulate, applyMute, canPlayHere, createGame, createWindRouter, enterCommits,
rigRecordFor, stormsToPreload, verdictFor,
} from '../main.js';
import { createGarden } from '../garden.js';
import { RiggingSession } from '../rigging.js';
import { createSkyFx } from '../skyfx.js';
import { SailRig, orderRing } from '../sail.js';
import { loadStorm, createWind } from '../weather.js';
import {
BROKE_BELOW, PAY, REP, calloutFee, createWeek, gradeFor, nightAt, NIGHTS,
} from '../week.js';
// SPRINT17 gate 1/2 — the board and the constraint data are mine; these are
// the seam this suite pins (offer shape, pool, exposure, constraint enum).
import { CONSTRAINT_PREMIUM, POOL, exposureOf, validateConstraint } from '../board.js';
import { createHud } from '../hud.js';
import { PALETTE, boundsFaults, emptyTemplate, exportSiteJSON, placeEntry } from '../editor.js';
import { assert, assertClose, 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('; '), '');
});
// --- SPRINT13 gate 3: the front door of a PUBLIC game --------------------
t.test('ENTER CANNOT SKIP THE STORM — the exploit that shipped, pinned', () => {
// The QA pass found this live on partly.party: Enter during a storm fell
// through to game.advance() and paid a perfect invoice for a storm that
// never ran — garden 100%, "every corner held", clean bonus, +$90. It
// shipped because the rule lived in a keydown closure inside boot(), behind
// a canvas and a WebGL context, where no assert could reach it. The rule is
// a value now, so this can fail.
//
// Exhaustive over the phase machine rather than a spot-check on 'storm': a
// sixth phase added later is asserted the day it appears, and the default it
// gets is "may not advance", which is the safe direction.
for (const phase of PHASES) {
assertEq(enterCommits(phase, false), phase === 'prep',
`Enter in '${phase}' must ${phase === 'prep' ? 'commit' : 'do NOTHING'}`);
}
// The storm is the money one — name it, so a reader of a red run knows what
// broke without decoding the loop above.
assertEq(enterCommits('storm', false), false, 'Enter mid-storm cannot bank a night that never ran');
assertEq(enterCommits('aftermath', false), false, 'nor re-advance the invoice');
assertEq(enterCommits('forecast', false), false, 'nor skip the job sheet');
// A card owns the keyboard while it's up: its button is the only way through.
for (const phase of PHASES) {
assertEq(enterCommits(phase, true), false, `a card is open in '${phase}' — Enter is the card's, not the rig's`);
}
});
t.test('P stops the sim dead, and gives back no free time on resume', () => {
// Tested as a value, and that is the point. The pause lives in frame(),
// frame() is only called by requestAnimationFrame, and rAF DOES NOT FIRE IN
// A HIDDEN TAB — so my first probe drove the real loop, measured simT
// advancing 0 while paused, 0 while running, and reported success. It was
// measuring a frozen tab. This can actually fail.
const running = accumulate(0, FIXED_DT * 3.5, false);
assertEq(running.steps, 3, 'three whole steps out of three and a half');
assert(running.acc > 0 && running.acc < FIXED_DT, 'and the half-step is carried, not spent');
const paused = accumulate(running.acc, FIXED_DT * 10, true);
assertEq(paused.steps, 0, 'paused: the sim takes not one step');
assertEq(paused.acc, 0, 'and the accumulator is DRAINED — no free sixtieth on resume');
// The bug the drain prevents, stated: carry `acc` across a pause and the
// first frame after resume spends time that elapsed while you were paused.
assertEq(accumulate(0, 0, false).steps, 0, 'a zero-delta frame owes nothing');
assertEq(accumulate(FIXED_DT * 0.9, FIXED_DT * 0.9, false).steps, 1, 'carried time still adds up to a step');
// A breakpoint or a background tab must not run thousands of steps at once.
assertEq(accumulate(0, 10, false).steps, 60, 'the step ceiling holds');
assertEq(accumulate(0, 10, false, 5).steps, 5, 'and it is the caller\'s to set');
});
t.test('the touch notice locks out phones, not touchscreen laptops', () => {
// The bug this exists to prevent is the FIX, not the gap: `(pointer: coarse)`
// asks what the primary pointer is, so a laptop with a touchscreen answers
// "coarse" and gets a courtesy card instead of the game it can perfectly well
// play. Taking the game away from someone who could play it is worse than the
// dead canvas we're replacing.
const mm = (answers) => (q) => ({ matches: !!answers[q] });
assertEq(canPlayHere(mm({ '(any-pointer: fine)': true })), true, 'a mouse anywhere means play');
assertEq(canPlayHere(mm({ '(any-pointer: fine)': false })), false, 'a phone gets the notice');
// The hybrid: primary pointer coarse (finger), but a trackpad exists.
assertEq(canPlayHere(mm({ '(any-pointer: fine)': true, '(pointer: coarse)': true })), true,
'touchscreen laptop plays — this is the case the naive check gets wrong');
// Unknown/old browsers err toward letting people in, never toward a lecture.
assertEq(canPlayHere(null), true, 'no matchMedia at all: let them in');
assertEq(canPlayHere(() => { throw new Error('nope'); }), true, 'a throwing matchMedia: let them in');
assertEq(canPlayHere(() => ({})), true, 'a browser that answers nothing: let them in');
});
t.test('M reaches the real bus when there is one, and is not advertised when there is not', () => {
// SPRINT13 gate 3.3, closed by C's sky.setMute (lane/c 8a3dc32). The wiring
// bug this pins is the one D named on rigging.setWorld: `sky.setMute?.(on)`
// on a tree without the tap is a no-op that looks like a call, so a key the
// splash advertises can silently do nothing on a public URL. applyMute is
// the application as a value; the HUD half asserts the advertisement tracks
// the same feature-detect in BOTH directions.
const calls = [];
const bus = { setMute: (v) => calls.push(v) };
assertEq(applyMute(bus, true), true, 'a real bus takes the state');
assertEq(applyMute(bus, false), true, 'both directions');
assertEq(calls.join(','), 'true,false', 'and receives booleans, in order — this is what M re-applies across makeSky()');
assertEq(applyMute({}, true), false, 'no tap: says so instead of pretending');
assertEq(applyMute(null, true), false, 'no sky at all (pre-boot): still honest');
if (typeof document === 'undefined') return;
const hud = createHud({ scene: new THREE.Scene() });
try {
assertEq(hud.comfortKeysHint(), 'P pause', 'no bus: the help line does not promise M');
hud.setAudioMuteAvailable(true);
assertEq(hud.comfortKeysHint(), 'P pause · M mute', 'bus landed: M appears by itself');
hud.showSplash(() => {});
assert((document.querySelector('#hud-card')?.textContent ?? '').includes('P · M'),
'and the splash card lists it');
hud.hideCard();
hud.setAudioMuteAvailable(false);
assertEq(hud.comfortKeysHint(), 'P pause', 'and it can go dark again — the detect is live, not a latch');
} finally { hud.dispose(); }
});
t.test('the spawn frame points at the garden with no pole through the player', () => {
// The QA pass: "the boot camera puts a pole dead-centre through the player on
// every single first impression." Measured, not eyeballed — these are the
// real backyard_01 numbers that produced the bug.
const player = { x: 0, z: 6 };
const bed = { x: 1, z: 2 };
// No obstacles: the ideal frame is the camera OPPOSITE the bed, so the player
// stands in front of what they're protecting. The view direction (from camera
// through the player) must point at the bed.
const clean = spawnYawFor(player, bed, []);
const viewOff = (yaw) => {
const view = { x: -Math.sin(yaw), z: -Math.cos(yaw) };
const tb = { x: bed.x - player.x, z: bed.z - player.z };
const l = Math.hypot(tb.x, tb.z);
return Math.acos(Math.max(-1, Math.min(1, (view.x * tb.x + view.z * tb.z) / (l || 1)))) * 180 / Math.PI;
};
assertLess(viewOff(clean), 1, 'with nothing in the way, the frame looks straight at the bed');
// The bug: p3 stands at (0,7), one metre behind the spawn, dead on the ideal
// view line. The fix must TURN to clear it — and still keep the bed in a 62°
// FOV (< 31° off centre).
const withPole = spawnYawFor(player, bed, [{ x: 0, z: 7 }]);
const cam = { x: player.x + Math.sin(withPole) * 4.5, z: player.z + Math.cos(withPole) * 4.5 };
const abx = cam.x - player.x, abz = cam.z - player.z, l2 = abx * abx + abz * abz;
let t = ((0 - player.x) * abx + (7 - player.z) * abz) / l2; t = Math.max(0, Math.min(1, t));
const poleClear = Math.hypot(0 - (player.x + abx * t), 7 - (player.z + abz * t));
assert(poleClear > 0.5, `the pole is off the view line (${poleClear.toFixed(2)}m), not through the head`);
assertLess(viewOff(withPole), 31, 'and the bed is still in frame after the turn');
// The failure mode I actually shipped first, reproduced from the real yard.
// With the full backyard_01 obstacle set and a clearance the geometry cannot
// meet, the sweep falls back to "roomiest" — and UNBOUNDED, roomiest swung
// 105° off the garden to stare at a fence (the exact number the QA pass would
// have seen). maxOff caps the fallback to the 90° arc, and this config brings
// it back to 68°. Without the cap this assert goes red at 105°.
const yardObstacles = [
{ x: -4.5, z: 5.5 }, { x: 4, z: 6 }, { x: 0, z: 7 }, { x: -3.2, z: -1.2 },
{ x: -9, z: 2 }, { x: 8, z: -2 },
];
const unreachable = spawnYawFor(player, bed, yardObstacles, { clearance: 5 });
assert(Number.isFinite(unreachable), 'an impossible clearance still yields a finite yaw, never NaN');
assertLess(viewOff(unreachable), 91, 'and never turns its back on the bed — the cap holds (105° without it)');
});
// --- the week (SPRINT8 gate 1) -------------------------------------------
t.test('the week is seven escalating nights, each a storm and a site', () => {
// SPRINT16 gate 4 (D's wiring): five → six. SPRINT17 gate 0.1 (D again,
// wiring JOHN'S RULING 2026-07-21): six → SEVEN — the wildnight restored
// at night 5, between the swing lawn and the icenight. The swing lawn
// debuts night 4 under Tuesday's southerly; the soaker still closes the
// week over the corner block. The deeper pins — one-variable law,
// survived-storm debut, the measured soaker pairing, the wildnight's
// known-yard slot — live in d.test.js next to the wiring's owner.
assertEq(NIGHTS.length, 7);
// 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(3).storm, 'storm_03_southerly', 'night four repeats the southerly — the yard is the only new thing');
assertEq(nightAt(3).site, 'site_03_swing_lawn', 'night four is the swing lawn');
assertEq(nightAt(4).storm, 'storm_02_wildnight', "night five is the wildnight — restored, John's seven-night ruling");
assertEq(nightAt(4).site, 'backyard_01', 'and it flies the backyard — the yard its separation is pinned to');
assertEq(nightAt(5).storm, 'storm_02b_icenight', 'night six is the ice night');
assertEq(nightAt(6).storm, 'storm_06_soaker', 'night seven is the fabric bet');
assertEq(nightAt(6).site, 'site_02_corner_block', "and it flies over the corner block — C's measured pairing");
assertEq(nightAt(2).site, 'site_02_corner_block', 'night three moves to the corner block');
assertEq(nightAt(0).site, 'backyard_01', 'the retainer nights are the backyard');
// SPRINT13 gate 1.4: the unsavable-garden flag is the icenight's alone, and
// a night without it is presumed savable — new nights can't inherit the
// excuse. The flag rides the NIGHT, not the slot: it moved 5 → 6 with the ladder.
assertEq(nightAt(5).gardenBeyondSaving, true, 'night 6 is beyond saving BY DESIGN — canon, ruled');
for (const i of [0, 1, 2, 3, 4, 6]) assertEq(nightAt(i).gardenBeyondSaving, false, `night ${i + 1} is savable`);
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 < 3; i++) w.advance();
assertEq(w.night, 6);
assert(!w.isFinalNight, 'the ice night is still not the last — its dawn breaks keep their morning to book on');
w.advance();
assertEq(w.night, 7);
assert(w.isFinalNight, 'night seven is the last');
w.advance();
assertEq(w.night, 7, '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.
// SPRINT16: warrantyTotal joins the sum (0 on a history-less week, so
// this line is the formula's pin, not a behaviour change here).
assertEq(s.pay, s.fee + s.bonus + s.refund + s.clean - s.collateral - s.warrantyTotal,
'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.
// SPRINT17: stormsToPreload takes the LADDER and the POOL separately now
// (see its docstring — the single-list version made the pool assert
// decoration). These pass an empty pool so this pin keeps testing exactly
// what it was written to test: the calm day's survival, ladder-only.
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: the Hendersons'
// retainer, one short-notice callout to a stranger's corner block, the
// Delaneys' swing lawn — and the corner block calling BACK, which is
// SPRINT16's whole thesis (your work follows you) written into the ladder.
const clients = NIGHTS.map((_, i) => nightAt(i).client);
assertEq(new Set(clients).size, 3, 'three clients: the retainer, the one-off, and the swing lawn');
assert(clients[2] !== clients[0], 'night 3 is somebody else');
assertEq(nightAt(2).site, 'site_02_corner_block', "and it's their corner block");
assertEq(clients[NIGHTS.length - 1], clients[2], 'the closer is the corner block client again — a return booking, not a stranger');
});
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.
// SPRINT17 gate 0.1 (D, the same-commit demand from the S16 filing): the
// clean bar is derived — every SAVABLE garden held. Seven nights minus the
// icenight's designed loss is 6; held>=5 'clean' would have printed THE
// WEEK HELD over a savable garden that died.
assertEq(gradeFor(6), 'clean', 'six held — every savable garden — is the only clean week');
assertEq(gradeFor(5), 'scraped', 'five of six savable is no longer clean — the bar moved with the ladder');
assertEq(gradeFor(4), 'scraped');
assertEq(gradeFor(3), 'scraped');
assertEq(gradeFor(1), 'solvent', 'one garden out of six 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. SPRINT16 moved the
// last night to six; SPRINT17's seven-night ruling moves it again — the
// walk below reads NIGHTS.length, so this pin TRACKS the final night by
// construction, and the second-to-last-night control stays a real game
// over — both directions pinned so a re-ordering can't quietly move the rule.
const w2 = createWeek();
for (let i = 0; i < NIGHTS.length - 1; i++) w2.advance();
assert(w2.isFinalNight, 'precondition: the walk reached the final night');
assertEq(w2.settle(ruin, def, 80).outcome, 'win', 'the final night always ends the week, not the run');
const w3 = createWeek();
for (let i = 0; i < NIGHTS.length - 2; i++) w3.advance();
assertEq(w3.settle(ruin, def, 80).outcome, 'gameover',
'the second-to-last night is not the final night — going broke there ends the run');
});
// --- SPRINT17 gate 1: THE JOB BOARD -------------------------------------
// Loaded up front — Suite.test() cannot await, and every board assert wants a
// real storm def to price a fee against.
const boardDefs = {};
for (const k of ['storm_01_gentle', 'storm_03_southerly', 'storm_03b_earlybuster']) {
try { boardDefs[k] = await loadStorm(k); } catch { /* no server: asserts below degrade */ }
}
// The raw site JSON, for the exposure pins — deliberately NOT a built world:
// exposureOf() answers "what is priced in this yard" off data a board can read
// without building anything, which is the whole reason it can price an offer
// for a yard the player has not chosen yet.
const siteJsonForTest = {};
for (const k of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn']) {
try { siteJsonForTest[k] = await loadSite(k); } catch { /* degrades to SKIPPED */ }
}
t.test('SPRINT17: every morning offers TWO jobs — tonight, plus one alternative', () => {
const w = createWeek();
for (let i = 0; i < NIGHTS.length; i++) {
const offers = w.offers();
assertEq(offers.length, 2, `night ${i + 1} offers two jobs`);
// The scripted spine SURVIVES, and it is always offer 0 — the campaign is
// the thing the player is in the middle of, and a board that shuffled
// which door was which would make "take the job you were going to do" a
// reading exercise every morning.
assertEq(offers[0].kind, 'scripted');
assertEq(offers[0].night.storm, nightAt(i).storm, `night ${i + 1}'s scripted offer IS the ladder's night`);
assertEq(offers[0].night.site, nightAt(i).site);
assertEq(offers[1].kind, 'callout', 'the second is drawn from the pool');
// An alternative that is the same yard under the same storm is not an
// alternative — it is the board lying about having offered a choice.
assert(!(offers[1].night.storm === offers[0].night.storm
&& offers[1].night.site === offers[0].night.site),
`night ${i + 1}'s alternative must be a DIFFERENT job, got ${offers[1].night.site} × ${offers[1].night.storm}`);
w.advance();
}
});
t.test('SPRINT17: the board is DETERMINISTIC — same week, same offers, always', () => {
// The repo's oldest rule (contracts.js §Determinism) and the selftest
// depends on it: a pool that drew from Date.now or Math.random is a suite
// that goes red on a Tuesday. Three independent weeks, walked in full.
const walk = (seed) => {
const w = createWeek(seed == null ? {} : { seed });
const out = [];
for (let i = 0; i < NIGHTS.length; i++) { out.push(w.offers().map((o) => o.id).join('|')); w.advance(); }
return out.join(' ; ');
};
const a = walk(), b = walk(), c = walk();
assertEq(a, b, 'two fresh weeks must offer the same board');
assertEq(b, c, '...and a third');
// And the seed is REAL — a different week is a different board, or the
// "deterministic" pin above is passing on a constant and proving nothing.
// (This is the negative control for the assert above it: without it, an
// offersFor() that ignored the seed entirely and always returned POOL[0]
// would sail through the equality checks.)
const other = walk(7);
assert(other !== a, 'a different seed must draw a different week of offers');
});
t.test('SPRINT17: TAKE ONE — the chosen job IS the night, all the way to the invoice', () => {
if (!boardDefs.storm_03_southerly) return 'SKIPPED — no server for storm defs';
const w = createWeek();
const offers = w.offers();
const alt = offers[1];
// Precondition: the alternative really is a different job from the spine,
// or this test proves nothing about taking one.
assert(alt.night.site !== nightAt(0).site || alt.night.storm !== nightAt(0).storm,
'precondition: the alternative differs from night 1');
w.take(alt);
assertEq(w.site, alt.night.site, 'week.site follows the taken job');
assertEq(w.stormKey, alt.night.storm, 'and so does the storm');
assertEq(w.job.client, alt.night.client, 'and the client the letterhead bills');
assertEq(w.takenOffer, alt.id, 'the week records WHICH offer was taken');
// THE ASSERT GATE 1 ASKS FOR IN SO MANY WORDS: "a chosen alternative that
// skips the ledger is the board lying." Every per-night surface must work
// UNCHANGED on a chosen night — so run the whole paper chain over one.
const def = boardDefs[alt.night.storm];
const q = w.quote(def);
assert(q.base > 0, 'a taken job quotes a fee');
assertEq(q.total, q.base + q.garden + q.clean - q.warrantyTotal, 'the quote still sums');
const s = w.settle({ hp: 90, win: true, collateral: [], intactHardwareValue: 40, rigRecord: [] }, def, 40);
assertEq(s.client, alt.night.client, 'the invoice bills the client you chose');
assertEq(s.site, alt.night.site, 'the ledger records the yard you chose');
assertEq(s.taken, alt.id, 'and that it came off the board');
assertEq(s.pay, s.fee + s.bonus + s.refund + s.clean - s.collateral - s.warrantyTotal,
'the ledger adds up on a chosen night exactly as it does on a scripted one');
assertEq(q.base, s.fullFee, 'quote==settle holds on a taken job');
assert(s.rep != null && s.grade != null, 'rep and grade are computed for a chosen night');
});
t.test('SPRINT17: a taken night is INDISTINGUISHABLE in shape from an authored one', () => {
// The bug this pins was real and the board probe found it before a player
// could: take() stored the raw pool entry, and a pool entry with no `pay`
// block threw "Cannot read properties of undefined (reading 'garden')" out
// of quote(). Both doors normalise through normaliseNight() now — this
// asserts the SHAPES match rather than trusting that they do.
const w = createWeek();
const scripted = nightAt(0);
w.take({ id: 'alt:probe', night: { storm: 'storm_01_gentle', site: 'backyard_01' } });
const takenKeys = Object.keys(w.job).sort().join(',');
assertEq(takenKeys, Object.keys(scripted).sort().join(','),
'a taken night carries exactly the fields an authored night carries');
// And the defaults really defaulted, rather than being present-but-undefined.
assertEq(typeof w.job.pay, 'object', 'pay defaults to a block, not undefined');
assertEq(w.job.constraints.length, 0, 'constraints default to an empty array');
assertEq(w.job.gardenBeyondSaving, false, 'a new night cannot inherit the icenight\'s excuse');
});
t.test('SPRINT17: take is a COMMITMENT — the other offer vanishes and there is no untake', () => {
const w = createWeek();
const [scripted, alt] = w.offers();
w.take(alt);
assertEq(w.takenOffer, alt.id);
// Taking again on the same morning is the SAME take, not a re-pick. A
// player who could re-open the board after reading the job sheet would be
// shopping, not choosing — and the fee is already quoted against the job.
w.take(scripted);
assertEq(w.takenOffer, alt.id, 'the morning\'s choice is final');
assertEq(w.site, alt.night.site, 'and the yard did not change under it');
// A fresh morning is a fresh choice, though.
w.advance();
assertEq(w.takenOffer, null, 'tomorrow is unchosen');
});
t.test('SPRINT17: the SPINE runs untouched when nothing is taken — the campaign is not the board', () => {
// 474 asserts stand on the scripted week. The board must be a second door,
// not a replacement: take nothing and every per-night reading is the
// authored ladder, byte for byte.
const w = createWeek();
for (let i = 0; i < NIGHTS.length; i++) {
w.offers(); // building a board must not MOVE anything
assertEq(w.stormKey, nightAt(i).storm, `night ${i + 1} storm untouched by the board`);
assertEq(w.site, nightAt(i).site, `night ${i + 1} site untouched`);
assertEq(w.job.client, nightAt(i).client, `night ${i + 1} client untouched`);
assertEq(w.takenOffer, null, 'and nothing is marked taken');
w.advance();
}
});
t.test('SPRINT17: every pool night is a JOB, and its storm is one the game preloads', () => {
for (const p of POOL) {
assert(p.id, 'a pool entry needs an id');
assert(p.client, `${p.id} has a client`);
assert(p.addr, `${p.id} has an address — a letterhead bills somebody somewhere`);
assert(p.brief && p.brief.length > 20, `${p.id} has a brief worth reading`);
assert(p.site, `${p.id} names a yard`);
assert(p._why, `${p.id} carries its audit receipt — an unmeasured pairing is a night the board is guessing about`);
// ⚠️ D's calm-day landmine, in its SPRINT17 costume: the board can put any
// pool night on tonight, so a pool storm the loader never fetched is
// `defs[week.stormKey] === undefined` the moment somebody takes it.
assert(stormsToPreload().includes(p.storm),
`${p.id} flies ${p.storm}, which nothing preloads — taking this offer would boot into an undefined storm`);
}
// ⚠️ THE LINE ABOVE IS NOT ENOUGH, AND A MUTATION PROVED IT (A, S17): every
// storm the pool flies TODAY also appears in NIGHTS, so that assert passes
// with the whole POOL source deleted from stormsToPreload — decoration, and
// the identical coincidence that hid the calm-day bug for a sprint. THIS is
// the assert with teeth: a pool storm that is NOT in the ladder, which is
// the case that actually breaks, and the case D's pool yard will be the
// moment it brings a storm of its own.
assert(stormsToPreload(['storm_01_gentle'], ['storm_99_fixture']).includes('storm_99_fixture'),
'a POOL-ONLY storm must be preloaded — the board can put it on tonight and nothing else would load it');
assert(stormsToPreload(['storm_01_gentle'], []).includes(CALM_STORM),
'the calm day still survives a ladder without it (S11 pin, still true)');
// ...and it is still deduped, so the loader does not fetch a storm twice
// just because the pool and the ladder agree about it.
assertEq(stormsToPreload(['storm_01_gentle'], ['storm_01_gentle']).length, 1,
'a storm in BOTH the ladder and the pool is loaded once');
});
t.test('SPRINT17: collateral exposure is the yard\'s priced property, and it matches the site JSON', () => {
if (!siteJsonForTest.backyard_01) return 'SKIPPED — no server for site JSON';
// Two routes to one set of dollars (board.js walks the site JSON up front;
// world.js resolves a collateral KEY at failure time), which is exactly the
// shape this repo has been bitten by. Pin the totals against the data.
const b = exposureOf(siteJsonForTest.backyard_01);
assertEq(b.total, 115, 'the backyard: gutter 90 + gnome 25');
const c = exposureOf(siteJsonForTest.site_02_corner_block);
assertEq(c.total, 205, 'the corner block: carport 180 + gnome 25 — and no house');
const s = exposureOf(siteJsonForTest.site_03_swing_lawn);
assertEq(s.total, 255, 'the swing lawn: gutter 90 + swing set 140 + gnome 25 — the worst in the game');
// Every item priced must be a real number a player can actually be billed,
// and the total must BE the items — an exposure that didn't sum to its own
// list would be the invoice bug one card earlier.
for (const { what, cost } of [...b.items, ...c.items, ...s.items]) {
assert(what && typeof what === 'string', 'every exposure item names itself');
assert(Number.isFinite(cost) && cost > 0, `${what} is priced`);
}
assertEq(s.total, s.items.reduce((n, i) => n + i.cost, 0), 'the total IS the items');
});
// --- SPRINT17 gate 2: CLIENT CONSTRAINTS (A's data half) -----------------
t.test('SPRINT17: a constrained night pays a premium, on BOTH papers, from ONE formula', () => {
if (!boardDefs.storm_03b_earlybuster) return 'SKIPPED — no server for storm defs';
const capped = POOL.find((p) => p.constraints?.some((c) => c.kind === 'budgetCap'));
assert(capped, 'the pool ships a budget-capped job');
const def = boardDefs[capped.storm];
const plain = calloutFee(def, REP.START, []);
const withCap = calloutFee(def, REP.START, capped.constraints);
assert(withCap > plain, `a constraint must PAY — got ${withCap} vs ${plain}`);
assertEq(withCap, Math.round(plain * (1 + CONSTRAINT_PREMIUM.budgetCap)),
'and by exactly the premium the constraint declares');
// The sheet and the invoice must read the same number. quote() and settle()
// route through the same calloutFee() — this is the pin that keeps them
// there when somebody adds a fourth term.
const w = createWeek();
w.take({ id: `alt:${capped.id}`, night: capped });
const q = w.quote(def);
const s = w.settle({ hp: 90, win: true, collateral: [], intactHardwareValue: 0, rigRecord: [] }, def, 45);
assertEq(q.base, withCap, 'the job sheet quotes the premium');
assertEq(s.fullFee, withCap, 'and the invoice pays it');
assertEq(q.premium, s.premium, 'both papers state the same premium');
// The invoice repeats the TERMS it was paid under — gate 2's requirement.
assertEq(s.constraints.length, capped.constraints.length,
'the invoice repeats the constraint it was paid under');
assertEq(s.constraints[0].label, capped.constraints[0].label);
});
t.test('SPRINT17: the constraint enum is CHECKED, so a bad one cannot ship', () => {
// An unenforced enum is decoration — this repo's standing rule, learned
// when the carport shipped typed as a 'post' behind a JSDoc that said it
// couldn't. Every shipped constraint validates...
for (const p of POOL) {
for (const c of p.constraints ?? []) validateConstraint(c, p.id);
}
// ...and a bogus one throws, with the kind named in the message.
let threw = '';
try { validateConstraint({ kind: 'pay_me_double', says: 'give me money', label: 'nope' }, 'fixture'); }
catch (err) { threw = err.message; }
assert(threw.includes('pay_me_double'), `a bad kind must fail loud and name itself, got: ${threw || '(no throw)'}`);
// The fields the papers PRINT are required too — a constraint with no
// client words would render an empty quote block on two cards.
let threw2 = '';
try { validateConstraint({ kind: 'budgetCap', cap: 45, label: 'capped' }, 'fixture'); }
catch (err) { threw2 = err.message; }
assert(threw2.includes('says'), `a constraint with no client words must fail, got: ${threw2 || '(no throw)'}`);
// ...and a budgetCap with no number is not a cap.
let threw3 = '';
try { validateConstraint({ kind: 'budgetCap', says: 'keep it cheap please', label: 'capped' }, 'fixture'); }
catch (err) { threw3 = err.message; }
assert(threw3.includes('cap'), `a capless budgetCap must fail, got: ${threw3 || '(no throw)'}`);
});
t.test('SPRINT17: a budget cap is BELOW the start budget and ABOVE the four-carabiner floor', () => {
// The ruling, pinned: the client caps your SPEND, not your wallet. A cap at
// or above START_BUDGET is not a constraint (it changes nothing and the
// premium is free money); a cap under BROKE_BELOW is a soft-lock, because
// four carabiners is the cheapest legal rig and the game refuses to leave
// prep without four corners.
for (const p of POOL) {
for (const c of p.constraints ?? []) {
if (c.kind !== 'budgetCap') continue;
assertLess(c.cap, START_BUDGET, `${p.id}: a cap at or above the start budget constrains nothing`);
assert(c.cap >= BROKE_BELOW,
`${p.id}: cap $${c.cap} is under the $${BROKE_BELOW} four-carabiner floor — that is a soft-lock, not a squeeze`);
}
}
});
// 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');
});
// =========================================================================
// SPRINT16 gate 1 — THE LEDGER: work history, warranty, reputation
// =========================================================================
// The fixtures speak the seam contract's shapes (THREADS [A] 2026-07-20):
// rc() is one corner of the rig record, cleanRun() a night nothing went
// wrong on. s16def matches the def the older week tests use — feeFor(20).
const s16def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
const rc = (anchorId, over = {}) => ({
anchorId, hw: 'shackle', hwCost: 15, effRating: 3200,
broke: false, repaired: false, brokenAtDawn: false, ...over,
});
const cleanRun = (over = {}) => ({
hp: 80, win: true, collateral: [], intactHardwareValue: 0,
rigRecord: [rc('p1'), rc('p2'), rc('p3'), rc('p4')], ...over,
});
t.test('SPRINT16: the work history — the tally remembers what the corner flag forgets', () => {
// rigRecordFor is the value scoreRun calls (the Enter-guard lesson: the
// closure inside boot() is unreachable, the rule is not). The trap it
// exists to dodge: repairCorner() clears `broken`, so at dawn a corner
// that broke and was fixed is indistinguishable from one that never went —
// the EVENT tally is the only honest witness.
const mk = (id, hw, hint, broken) => ({ anchorId: id, hw, broken, anchor: { ratingHint: hint } });
const shackle = { name: 'shackle', cost: 15, rating: 3200 };
const carab = { name: 'carabiner', cost: 5, rating: 1200 };
const tally = new Map([
['q2', { broke: 1, repaired: 1 }], // broke, fixed — flag forgot, tally didn't
['q3', { broke: 2, repaired: 1 }], // broke twice, fixed once — still down
]);
const recs = rigRecordFor([
mk('q1', carab, 0.22, false), // held all night on the lying beam
mk('q2', shackle, 1, false), // hw at dawn = the spare, honestly
mk('q3', shackle, 1, true),
mk('q4', shackle, 1, true), // no tally entry at all (divergence-shaped) — still recorded
], tally);
const by = (id) => recs.find((r) => r.anchorId === id);
assertEq(by('q1').broke, false, 'a corner that held is not a break');
assertEq(by('q1').effRating, Math.round(1200 * 0.22),
'effRating is the number sail.js fails on — rating × hint, not the bare rating');
assertEq(by('q2').broke, true, 'the tally remembers the break the cleared flag forgot');
assertEq(by('q2').repaired, true, 'and standing at dawn = repaired');
assertEq(by('q2').brokenAtDawn, false);
assertEq(by('q3').repaired, false, 'broke twice, fixed once: brokenAtDawn, not repaired');
assertEq(by('q3').brokenAtDawn, true);
assertEq(by('q4').broke, true, 'a broken corner with no tally entry is still a break — belt AND braces');
for (const r of recs) {
assert(!(r.repaired && r.brokenAtDawn), `${r.anchorId}: repaired and brokenAtDawn must partition broke`);
assertEq(r.repaired || r.brokenAtDawn, r.broke, `${r.anchorId}: every break ends exactly one way`);
}
// …and the week KEEPS it: the settlement carries the record verbatim.
const w = createWeek();
const s = w.settle(cleanRun({ rigRecord: recs }), s16def, 0);
assertEq(s.rig, recs, 'settlement.rig IS the work history — the ledger keeps what the night computed');
});
t.test('SPRINT16: warranty fires ONLY on corners still broken at dawn — a repair books nothing', () => {
const w = createWeek();
w.settle(cleanRun({ rigRecord: [
rc('q2', { broke: true, repaired: true }), // mid-storm repair: NOT a warranty item (spec)
rc('q3', { broke: true, brokenAtDawn: true }), // rode it out broken: THE item
rc('p1'), rc('p2'),
] }), s16def, 0);
w.advance();
const q = w.quote(s16def);
assertEq(q.warranty.length, 1, 'one item: the repaired corner books nothing');
assertEq(q.warranty[0].anchorId, 'q3');
assertEq(q.warranty[0].hw, 'shackle');
assertEq(q.warranty[0].cost, 15, "the broken hardware's shop price — parts; the labour is the point, unpaid");
assertEq(q.warrantyTotal, 15);
assert(q.warranty[0].addr, 'the item says whose yard it came from');
// The item lives ONE morning: settle tonight clean, and tomorrow books none.
w.settle(cleanRun(), s16def, 0);
w.advance();
assertEq(w.quote(s16def).warranty.length, 0, 'a warranty line does not haunt the week');
});
t.test('SPRINT16: quote==settle holds WITH a warranty line — the deduction is at quote time', () => {
const w = createWeek();
w.settle(cleanRun({ rigRecord: [
rc('q3', { broke: true, brokenAtDawn: true }), rc('p1'), rc('p2'), rc('p3'),
] }), s16def, 0);
w.advance();
const q = w.quote(s16def);
assert(q.warrantyTotal > 0, 'precondition: tonight carries the deduction');
assertEq(q.total, q.base + q.garden + q.clean - q.warrantyTotal,
'the quote CONCLUDES at the deducted number — the sheet does not hide it');
const s = w.settle(cleanRun({ hp: 100 }), s16def, 0);
assertEq(s.warrantyTotal, q.warrantyTotal, 'same items, same source — derived, never stored twice');
assertEq(s.fee, q.base, 'the fee is the quoted fee');
assertEq(s.fee + s.bonus + s.clean - s.warrantyTotal, q.total,
'a perfect night pays exactly what the sheet quoted — the invariant, with teeth in it');
assertEq(s.pay, s.fee + s.bonus + s.refund + s.clean - s.collateral - s.warrantyTotal,
'and the invoice rows still sum to the total shown');
});
t.test('SPRINT16: rep — clean up, dawn-broken down, collateral down harder; the garden is NOT an input', () => {
const s1 = createWeek().settle(cleanRun(), s16def, 0);
assertEq(s1.repBefore, REP.START, 'the outfit starts neutral');
assertEq(s1.rep, REP.START + REP.CLEAN, 'a clean night moves the letterhead number up');
assert(s1.repReasons.includes('clean night'), 'with its reason stated');
const s2 = createWeek().settle(cleanRun({ rigRecord: [
rc('p1', { broke: true, repaired: true }), rc('p2'), rc('p3'), rc('p4'),
] }), s16def, 0);
assertEq(s2.repDelta, 0, 'broke-but-repaired is NEUTRAL: not clean, not a warranty — fixing it IS the job');
assert(s2.repReasons.join(' ').includes('repaired by dawn'), 'and the flat line says why');
const s3 = createWeek().settle(cleanRun({ rigRecord: [
rc('p1', { broke: true, brokenAtDawn: true }), rc('p2'), rc('p3'), rc('p4'),
] }), s16def, 0);
assertEq(s3.repDelta, REP.WARRANTY, 'riding it out broken costs the number');
const s4 = createWeek().settle(cleanRun({
collateral: [{ what: 'the carport', cost: 180 }],
}), s16def, 0);
assertEq(s4.repDelta, REP.COLLATERAL, 'their property beats your hardware');
assertLess(s4.repDelta, s3.repDelta, 'collateral hits HARDER than a blown corner — the spec\'s word');
// The pin that matters most: same rig, opposite garden — identical movement.
// hp and win are absent from the movement BY CONSTRUCTION, which is what
// makes the icenight's designed loss free without an exemption to go stale.
const a1 = createWeek().settle(cleanRun({ hp: 100, win: true }), s16def, 0);
const b1 = createWeek().settle(cleanRun({ hp: 0, win: false }), s16def, 0);
assertEq(a1.repDelta, b1.repDelta, 'rep never reads the garden');
});
t.test("SPRINT16: the icenight's designed loss costs NO rep — a clean rig still earns it", () => {
// S17: the walk finds the flagged night instead of hardcoding slot 4 — the
// icenight moved to night 6 with the seven-night ruling and this pin is
// about the FLAG, not the slot.
const flaggedIdx = NIGHTS.findIndex((_, i) => nightAt(i).gardenBeyondSaving);
const w = createWeek();
for (let i = 0; i < flaggedIdx; i++) w.advance();
assertEq(nightAt(flaggedIdx).gardenBeyondSaving, true, 'precondition: the flagged night');
// Garden lost by design, steel held, nothing of theirs broken: that was
// the whole job tonight (the verdict's own words), and the number says so.
const s = w.settle(cleanRun({ hp: 0, win: false }), s16def, 0);
assertEq(s.repDelta, REP.CLEAN,
'a designed loss the ledger punished would be the verdict lying again — it goes UP');
});
t.test('SPRINT16: rep prices the fee, and the multiplier is on the sheet the night it applies', () => {
const w = createWeek();
w.settle(cleanRun(), s16def, 0); // → ★3.5
w.advance();
const q = w.quote(s16def);
assertEq(q.rep, 3.5);
assertEq(q.feeMult, REP.multiplier(3.5));
assertEq(q.feeMult, 1.02, 'half-steps land EXACTLY on two decimals — the printed number is the number used');
assertEq(q.base, Math.round(PAY.feeFor(20) * 1.02), 'the fee is priced on the standing');
if (typeof document !== 'undefined') {
const hud = createHud({ scene: new THREE.Scene() });
try {
hud.showForecast(
{ key: 'storm_03b_earlybuster', def: earlyBusterDef, site: null, tomorrowDef: null },
{ night: w.night, nights: w.nights, log: [], job: w.job,
quote: q, bank: w.bank, rep: w.rep }, () => {});
const doc = (sel) => document.querySelector(`#hud-card ${sel}`);
assertEq(doc('.docket .rep')?.textContent, 'standing ★ 3.5',
'the number is on the letterhead, under the ABN — E\'s contract');
assert((doc('.sched .row.headline span')?.textContent ?? '').includes('×1.02'),
'the multiplier prints on the fee row the night it applies');
} finally { hud.dispose(); }
}
// …and settle pays the multiplied fee the sheet quoted.
const s = w.settle(cleanRun({ hp: 100 }), s16def, 0);
assertEq(s.fee, q.base, 'trust became visible money, and the invoice honoured it');
assertEq(s.feeMult, q.feeMult);
// Neutral control: a fresh week prints no multiplier tag at all.
assertEq(createWeek().quote(s16def).feeMult, 1, 'neutral standing is ×1.00 — and the sheet stays quiet about it');
});
t.test('SPRINT16 negative control: a clean week books ZERO warranty items and ends rep-positive', () => {
const w = createWeek();
let items = 0;
for (let n = 0; n < NIGHTS.length; n++) {
items += w.quote(s16def).warranty.length; // counted off the sheet…
items += w.settle(cleanRun(), s16def, 0).warranty.length; // …and the invoice
w.advance();
}
assertEq(items, 0, 'no sheet and no invoice ever carried a warranty line');
assert(w.rep > REP.START, `the week ends rep-positive (★${w.rep})`);
assertEq(w.rep, REP.MAX, 'seven clean nights ride the clamp to the top of the scale');
assertEq(w.log[NIGHTS.length - 1].warrantiesToDate, 0, 'and the end card has no receipts to show');
w.reset();
assertEq(w.rep, REP.START, 'reset() is a new outfit — rep goes back to neutral');
});
t.test('SPRINT16: the invoice carries the warranty line and speaks rep; the endings differ', () => {
if (typeof document === 'undefined') return 'SKIPPED — no DOM';
const w = createWeek();
w.settle(cleanRun({ rigRecord: [
rc('q3', { broke: true, brokenAtDawn: true }), rc('p1'), rc('p2'), rc('p3'),
] }), s16def, 0);
w.advance();
const s = w.settle(cleanRun({ hp: 90 }), s16def, 0);
const hud = createHud({ scene: new THREE.Scene() });
try {
hud.showAftermath({ hp: 90, cornersLost: 0, cornersTotal: 4, hailBlocked: 'x',
bill: 0, collateral: [], subtitle: 'test', verdict: 'test', win: true, week: s }, () => {});
const doc = (sel) => document.querySelector(`#hud-card ${sel}`);
const ledger = doc('.ledger')?.textContent ?? '';
assert(ledger.includes('warranty — Q3, shackle, no charge to the client'),
`the warranty line, worded as the spec words it — got: "${ledger}"`);
assert(ledger.includes('$15'), 'and priced at the parts\' shop price');
assert((doc('.docket .rep')?.textContent ?? '').includes('★'),
'rep is under the ABN on the invoice too — both papers, one number');
const repline = doc('.repline')?.textContent ?? '';
assert(repline.includes('reputation'), 'the verdict speaks rep');
assert(repline.includes('clean night'), `and states the reason: "${repline}"`);
hud.hideCard();
// The two endings the gate names, rendered and compared: they must not
// read the same, and rich-with-warranties must show its receipts.
hud.showEndCard({ ...s, outcome: 'gameover', rep: 4.5, warrantiesToDate: 0, grade: 'solvent' }, () => {});
const brokeClean = document.querySelector('#hud-card .kicker')?.textContent ?? '';
hud.hideCard();
hud.showEndCard({ ...s, outcome: 'win', rep: 1.5, warrantiesToDate: 4, grade: 'clean', held: 5 }, () => {});
const richWarranties = document.querySelector('#hud-card .kicker')?.textContent ?? '';
const endtext = document.querySelector('#hud-card .endtext')?.textContent ?? '';
assert(endtext.includes('warranty jobs booked'), 'rich-with-warranties shows its receipts');
assert(endtext.includes('reputation'), 'and the number is on the last card the game shows');
assert(brokeClean.length > 0 && richWarranties.length > 0 && brokeClean !== richWarranties,
'broke-but-clean and rich-with-warranties are DIFFERENT endings and must read as such');
} finally { hud.dispose(); }
});
// --- 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.
//
// SPRINT16 (E's flag, defused): the bogus type was 'trampoline' — which is
// literally gate 5's third asset, deferred to arc 2. The day it joined
// ANCHOR_TYPE this fixture would have validated and the test gone red on a
// correct change, at the worst possible moment. The bogus type must be a
// thing the yard will NEVER offer.
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: 'bouncy_castle', 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(/bouncy_castle/.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');
});
t.test('SPRINT12 ruling: the gutter BILLS — 90, and the free failure is dead', () => {
// E priced it ($90, reasoning baked in the asset and adopted in the site
// JSON's _collateral) and named the seam: the house is not a structure, so
// collateralFor/wreckStructure had to be taught the house entry. This is
// the assert E left me to flip: backyard_01's house was the last free
// failure in the game.
const priced = world.collateralFor('gutter');
assert(priced, 'the gutter has a price at all — the null era ends here');
assertEq(priced.cost, 90, "A's ruling: 90 — wipes the trap's night (kit + fee), not the week");
assertEq(priced.label, 'the gutter', 'and reads as English on the invoice');
// The band that makes the number legible: ornament < gutter < structure.
assert(site.gnome.collateralValue < priced.cost && priced.cost < 180,
'gnome 25 < gutter 90 < carport 180');
// The key prices THIS site's house, not a global constant: the corner
// block has no house, so its 'gutter' stays null (pinned above).
// And nothing is wrecked before anyone breaks anything. (The wreck GLB
// arrives from lane/e at integration; absent, the swap no-ops false and
// the bill still lands — same offline contract as the carport.)
assertEq(world.isWrecked('gutter'), false, 'the gutter starts on the house');
});
// --- 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("the icenight's loss is TAUGHT, not blamed: beyondSaving rewrites the garden verdicts only", () => {
// SPRINT13 gate 1.4, the icenight ruling (night 5 then, night 6 since S17). Measured game-true (B's pay table,
// my probe agreeing): the icenight garden cannot reach the win line at any
// price — bare 0.0, best buyable 27.7. So week.js flags the night, and the
// verdict must stop implying a better rig existed. The worst liar was
// 'uncovered' ("the hail fell where your sail wasn't") on a night where no
// WHERE saves it.
const dmg = { hail: 90, rain: 5 };
const held = verdictFor({ hp: 25, lost: [], win: false, dmg, pondPeak: 0, pondDumped: 0, beyondSaving: true });
assertEq(held.mode, 'beyond-saving');
assert(/BEYOND SAVING/.test(held.verdict), `says so plainly: "${held.verdict}"`);
assert(/held|did it/i.test(held.verdict), 'and a 4/4 hold is CREDITED — keeping the steel was the job');
assert(!/wasn't|skimp/i.test(held.verdict), 'no placement-blame, no skimp-blame');
// Losing your sail on the unsavable night is still yours to own.
const shackle = { name: 'shackle', rating: 3200 };
const torn = verdictFor({ hp: 5,
lost: [{ hw: shackle, anchorId: 'p1', anchor: { ratingHint: 1 } }],
win: false, dmg, pondPeak: 0, pondDumped: 0, beyondSaving: true });
assertEq(torn.mode, 'beyond-saving');
assert(/shackle at P1/.test(torn.verdict), `still names the steel that went: "${torn.verdict}"`);
// The flag defaults OFF and changes nothing anywhere else: the two garden
// deaths keep their opposite sentences (mutation check — delete the branch
// and the first assert above reds; pass the flag everywhere and THIS one does).
const uncov = verdictFor({ hp: 20, lost: [], win: false, dmg: { hail: 70, rain: 10 }, pondPeak: 0, pondDumped: 0 });
assertEq(uncov.mode, 'uncovered', 'a savable night keeps its honest placement lesson');
// And a WIN on a flagged night ignores the flag — week.js says the flag
// would be stale, and a stale excuse must never eat a real win.
const win = verdictFor({ hp: 90, lost: [], win: true, dmg, pondPeak: 0, pondDumped: 0, beyondSaving: true });
assertEq(win.mode, 'clean');
});
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 13 anchors: 3 house, 5 tree, 5 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'), 5,
'post anchors — p3 (SPRINT3 dec 2), p4 (SPRINT6 gate 1), p5 the clothesline post (SPRINT13 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 'SKIPPED — 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 'SKIPPED — 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 'SKIPPED — 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`);
});
// --- SPRINT13 gate 1.4: THE PINNED SEPARATION TARGET ----------------------
//
// The ruling, RESTATED after B's skew finding (THREADS [A], twice — read the
// second entry): on the shipped week's wild night — night 5 since S17's ruling, THIS yard — the
// best line START_BUDGET can buy must HOLD and WIN comfortably (>60), a bare
// bed must LOSE (<50), and the spread must read as most of a plant state
// (≥25 HP), because the win line and the invoice are the game's real stakes.
// FULL (>66) was the first ruling and it died honestly: it was only ever met
// through this flight's own 12 s phantom settle — SailRig samples wind at its
// INTERNAL clock, so a settled rig flies the storm 12 s off the authored
// curve while the sky flies t=0..90 (B's three-way probe: game-true 63.8 /
// skewed 68.4 / shape-only 64.3). The settle is GONE: in the real game the
// rig does not exist before commit, and commit→attach→storm is one keypress
// (C's bench correction, same day, same bug).
//
// The recipe is DATA (site.separation) so B's audit reads the same one, and
// this flight is the pin: real shop at the real budget, real
// commit()->attach() (D's skipped-attach landmine — session.commit(rig) IS
// the attach path), real skyfx exposure, the real garden model out of
// garden.js. Wind is wired the way main.js wires a site (venturi from site
// data — backyard's list is empty, but the wiring must not ASSUME that — plus
// tree shelters); switch to C's windForSite at integration, it is the same
// wiring behind one door. Flown OUTSIDE t.test() because Suite.test cannot
// await — the two async-test ghosts above are the precedent.
//
// Sway is frozen (balance.test's reasoning): the recipe is all posts, whose
// real sway is constant anyway, so frozen here means "identical to the game",
// not "conveniently calm". (C's landmine 2 — a frozen TREE under-reads — is
// exactly why the pinned recipe staying post-only matters to this harness.)
const sep = site.separation;
let sepRun = null;
if (sep && typeof document !== 'undefined') {
try {
const frozen = world.anchors.map((a) => {
const pos = { x: a.pos.x, y: a.pos.y, z: a.pos.z };
return { id: a.id, type: a.type, pos, sway: () => pos, ratingHint: a.ratingHint };
});
const trees = frozen.filter((a) => a.type === 'tree');
const stormDef = await loadStorm(sep.stormKey);
const session = new RiggingSession({ anchors: frozen }); // the REAL budget
const shopLog = [];
for (const { anchor } of sep.line) {
const r = session.rig(anchor);
if (!r.ok) shopLog.push(`rig ${anchor}: ${r.reason}`);
}
for (const { anchor, hw } of sep.line) {
const want = HARDWARE.find((h) => h.name === hw);
if (!want) { shopLog.push(`no hardware named "${hw}"`); continue; }
const r = session.setHardware(anchor, want);
if (!r.ok) shopLog.push(`setHardware ${anchor} ${hw}: ${r.reason}`);
}
session.setTension(sep.tension);
const spent = START_BUDGET - session.budget;
const rig = session.commit(new SailRig({ anchors: frozen, gridN: 10 }));
// NO settle here, deliberately — see the header. The rig flies the storm
// from t=0 on a fresh internal clock, exactly like a committed rig does.
const flyNight = (sail) => {
const wind = createWind(stormDef);
wind.setVenturi(site.wind?.venturi ?? []); // main.js:the funnel is SITE data
wind.setSheltersFromTrees(trees);
const camera = new THREE.PerspectiveCamera(60, 1.6, 0.1, 400);
camera.position.set(0, 2, 8);
const sky = createSkyFx({ wind, night: true, camera });
const garden = createGarden({ setPlants() {} }); // the model main.js flies
const bed = world.gardenBed;
const steps = Math.round(stormDef.duration / FIXED_DT);
for (let i = 0; i < steps; i++) {
const t2 = i * FIXED_DT;
if (sail) sail.step(FIXED_DT, wind, t2);
sky.step(FIXED_DT, t2, { sail });
garden.step(FIXED_DT, sky.gardenHailExposure(bed, t2), sky.gardenExposure(bed, t2));
}
sky.dispose?.();
return { hp: garden.hp, state: garden.state };
};
const held = flyNight(rig);
const lost = rig.corners.filter((c) => c.broken).length;
const bare = flyNight(null);
sepRun = { shopLog, spent, held, lost, bare };
} catch (err) {
sepRun = { err: String(err && err.stack || err) };
}
}
t.test('PINNED: the wild night separates — best buyable line holds and wins, bare bed loses', () => {
if (!sep) throw new Error('backyard_01 lost its separation block — the target is decoration again');
if (typeof document === 'undefined') return 'SKIPPED — needs DOM (skyfx)';
if (sepRun.err) throw new Error(`separation flight died: ${sepRun.err}`);
assertEq(sepRun.shopLog.join('; '), '', 'the shop sold the whole recipe at the real budget');
assert(sepRun.spent <= START_BUDGET, `recipe costs $${sepRun.spent} — must be buyable night 1`);
// SIM-CHAIN facts, deliberately: this flight is rig+sky+garden with no
// debris events, so it is deterministic and 0/4 here is real headroom. In
// PLAY the yard also throws the t=38 crate and ponds rain — the played
// reference (site JSON _playedReference: hp 55.7 WIN, 2/4 lost, pyrrhic
// verdict) sits ~8 under this chain, and that ~8 is exactly why the
// thresholds below carry margin over the win line instead of hugging it.
assertEq(sepRun.lost, 0, 'the pinned line holds this deterministic chain — 0/4 lost (play may cost the sail; the WIN must survive that)');
assert(sepRun.held.hp > sep.heldMustExceed,
`held bed reads ${sepRun.held.hp.toFixed(1)} — must stay >${sep.heldMustExceed} so the PLAYED night ` +
`(measured ~8 HP under this chain through crate + ponding) keeps clearing the win line at 50. ` +
`Sim-chain 63.6 / played 55.7 at landing. FULL (>66) was the first ruling and it was only ever met ` +
`through this flight's own 12 s clock skew (B's finding). A red here means a retune moved the wild ` +
`night: re-litigate in THREADS, no quiet threshold bump.`);
assert(sepRun.held.state !== 'dead', 'the held bed is ALIVE — tattered is the wild night speaking');
assertLess(sepRun.bare.hp, sep.bareMustLoseBelow,
`bare bed must LOSE the night (<${sep.bareMustLoseBelow}) — game-true 35.7 at landing`);
// The felt spread, stated so a red prints the whole story:
assert(sepRun.held.hp - sepRun.bare.hp >= (sep.separationAtLeast ?? 25),
`separation ${(sepRun.held.hp - sepRun.bare.hp).toFixed(1)} — rigging must matter by most of a ` +
`plant state, not a garden-bonus margin (game-true 27.9 at landing)`);
});
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');
});
// =========================================================================
// SPRINT14 gate 1 — THE YARD EDITOR
// =========================================================================
/**
* The round trip: template → place a rig-able yard → export → load it back →
* build → dress → anchors adopt. Gate 1.5's requirement is that editor bugs
* must not be able to produce a yard the game can't boot, so this exercises
* the editor's OWN placement function — `placeEntry`, the one the mouse
* calls — rather than a test-shaped imitation of it.
*
* What it cannot do is the fetch: `loadSite(name, dir)` builds a URL and a
* test has nowhere to put a file. Worth being precise rather than vague
* about that — `loadSite` IS `fetch` then `validateSite`, so every step
* below the network is exercised here, and the network is not a step an
* editor bug can reach.
*/
const edScene = new THREE.Scene();
const built = emptyTemplate();
const P = (pred) => {
const item = PALETTE.find(pred);
assert(item, 'palette item missing — the round-trip fixture is built on it');
return item;
};
const postItem = P((p) => p.kind === 'post');
// A quad wants four corners: the template ships one post, so place three
// more — plus the two GLB-backed things whose anchors have to be ADOPTED for
// this to mean anything (a yard of bare posts never touches dress()).
placeEntry(built, postItem, 4.5, -3);
placeEntry(built, postItem, 4.5, 4.5);
placeEntry(built, postItem, -4, 4.5);
placeEntry(built, P((p) => p.model === 'tree_gum_01_v1'), -8, 0);
placeEntry(built, P((p) => p.model === 'carport_01_v1'), 7, -4);
built.id = 'roundtrip_yard';
const exported = exportSiteJSON(built, { ok: true, errors: [] });
const reloaded = validateSite(JSON.parse(exported), 'roundtrip_yard');
const rtWorld = createWorld(edScene, { wind: createStubWind({ calm: true }), site: reloaded });
let rtDressed = false;
try { await rtWorld.dress(); rtDressed = true; } catch (err) {
console.warn('[a.test] round-trip dress() unavailable:', err.message);
}
t.test('editor: a placed yard survives export → load → build', () => {
assertEq(reloaded.id, 'roundtrip_yard');
// 4 posts (1 template + 3 placed) + 3 tree branches + 4 carport = 11
assertEq(rtWorld.anchors.length, 11, 'the rebuilt world has the wrong anchor count');
assertEq(checkContract('world', rtWorld).join('; '), '', 'round-tripped world breaks the contract');
});
t.test('editor: every placed anchor types inside the checked enum', () => {
const types = [
...(reloaded.trees ?? []).flatMap((x) => x.anchors ?? []),
...(reloaded.structures ?? []).flatMap((x) => x.anchors ?? []),
...(reloaded.posts ?? []),
].map((a) => a.type);
assertEq(types.length, 11, 'the fixture stopped placing what it claims to place');
for (const ty of types) {
assert(ANCHOR_TYPE.includes(ty), `the palette wrote type '${ty}', which is not in ANCHOR_TYPE`);
}
});
t.test('editor: GLB-backed anchors ADOPT, and the carport is still a trap', () => {
if (!rtDressed) return 'SKIPPED — no server — dress() unavailable';
// `collateral` is set by adoptAnchor and by nothing else, so its presence
// is the honest tell that the named node was found in the asset. A palette
// naming a node the GLB doesn't have would leave the anchor at its graybox
// position with ratingHint 1.0 — a trap silently made into honest steel.
for (const a of [...(reloaded.trees ?? []), ...(reloaded.structures ?? [])]
.flatMap((x) => x.anchors ?? [])) {
const live = rtWorld.anchors.find((x) => x.id === a.id);
assert(live, `anchor ${a.id} was never built`);
assert(Object.hasOwn(live, 'collateral'),
`anchor ${a.id}: node '${a.node}' was not adopted from the GLB`);
}
// The numbers E baked, arriving through the editor's palette unchanged.
const beam = rtWorld.anchors.find((a) => a.id === 's1_b1');
const cpost = rtWorld.anchors.find((a) => a.id === 's1_p1');
assertClose(beam.ratingHint, 0.22, 1e-6, 'carport beam lost its rating_hint');
assertClose(cpost.ratingHint, 0.30, 1e-6, 'carport post lost its rating_hint');
assertEq(beam.collateral, 'carport', 'carport beam lost its collateral key');
});
t.test('editor export is byte-identical across a round trip', () => {
const again = exportSiteJSON(JSON.parse(exported), { ok: true, errors: [] });
assertEq(again, exported, 'export → parse → export changed the bytes');
});
t.test('editor: out of bounds is SEEN — faults, and a loud export key (S15 gate 3.2)', () => {
const site = emptyTemplate(); // 24×16 → half-extents 12×8
assertEq(boundsFaults(site).join('; '), '', 'the template itself must be in bounds');
placeEntry(site, postItem, 14, 0); // 2 m past the east bound
const faults = boundsFaults(site);
assertEq(faults.length, 1, 'one post 2 m outside must be exactly one fault');
assert(faults[0].includes('post') && faults[0].includes('outside'),
`fault text should name the thing and the problem: ${faults[0]}`);
// The garden bed counts its EDGES — centre in bounds, half its width out.
const bedSite = emptyTemplate();
bedSite.gardenBed = { x: 11, z: 0, w: 5, d: 3.5 }; // 11 + 2.5 > 12
assertEq(boundsFaults(bedSite).length, 1,
'a bed hanging over the fence must fault even with its centre inside');
// Exactly ON the line is legal — snapping to the fence is not a mistake.
const edgeSite = emptyTemplate();
edgeSite.posts[0].x = 12; edgeSite.posts[0].z = 8;
assertEq(boundsFaults(edgeSite).join('; '), '', 'on-the-line must not fault');
// The export wears it, FIRST (a human reads the top of a diff), and only
// when it is true — a clean yard must not carry the key.
const out = JSON.parse(exportSiteJSON(site, { ok: true, errors: [], bounds: faults }));
assert(Array.isArray(out._OUT_OF_BOUNDS), 'export must carry _OUT_OF_BOUNDS when a fault exists');
assertEq(Object.keys(out)[0], '_OUT_OF_BOUNDS', 'the bounds key must be the first key in the file');
const clean = JSON.parse(exportSiteJSON(emptyTemplate(), { ok: true, errors: [], bounds: [] }));
assert(!('_OUT_OF_BOUNDS' in clean), 'a clean yard must not carry the key');
});
t.test('editor export ignores key INSERTION order', () => {
// The determinism that actually matters. Two objects that ARE the same
// yard but were assembled in a different order must serialise identically,
// or every site diff carries noise that hides the one number that changed.
//
// The fixture carries two keys `canonical()` has NEVER HEARD OF, and that
// is the entire point of it. Written first, this test shuffled only root
// keys — every one of which is in KEY_ORDER, so the alphabetical fallback
// for unknown keys was never reached, and deleting the `.sort()` outright
// did not turn it red. It was decoration, and only mutating the code it
// claims to protect said so. An unknown key is also the realistic case:
// it's what a hand-added field, or a prop the palette doesn't know yet,
// looks like on its way through the editor.
const ok = { ok: true, errors: [] };
const base = JSON.parse(exported);
// The two unknown keys, inserted in OPPOSITE orders. Nothing normalises
// them on the way in, so these two objects differ only in insertion order.
const withZA = { ...base, zzUnknown: 1, aaUnknown: 2 };
const withAZ = { ...base, aaUnknown: 2, zzUnknown: 1 };
assertEq(exportSiteJSON(withZA, ok), exportSiteJSON(withAZ, ok),
'two identical yards assembled in different key orders exported different bytes');
const keys = Object.keys(JSON.parse(exportSiteJSON(withZA, ok)));
assertLess(keys.indexOf('aaUnknown'), keys.indexOf('zzUnknown'),
'keys canonical() does not know must sort alphabetically, not follow insertion order');
// The KNOWN-key path is a separate mechanism (a fixed list, not a sort), so
// it gets its own reversal: this is what goes red if `known` ever starts
// following the object instead of KEY_ORDER.
const reversed = {};
for (const k of Object.keys(base).reverse()) reversed[k] = base[k];
assertEq(exportSiteJSON(reversed, ok), exported,
'reversing the known keys changed the export');
});
t.test('editor: an INVALID yard exports, but exports LOUD', () => {
const broken = emptyTemplate();
delete broken.posts; // now nothing to rig to
let v;
try {
validateSite(structuredClone(broken), 'broken');
v = { ok: true, errors: [] };
} catch (err) {
v = { ok: false, errors: String(err.message).split('\n').slice(1).map((s) => s.trim()) };
}
assert(!v.ok, 'an anchor-less yard validated — this fixture is not testing anything');
const json = exportSiteJSON(broken, v);
assertEq(Object.keys(JSON.parse(json))[0], '_INVALID',
'_INVALID must be the FIRST key or a human scanning a diff will miss it');
assert(json.includes('no anchors at all'), 'the _INVALID banner must carry the actual reasons');
assert(!exported.includes('_INVALID'), 'a VALID yard exported the invalid banner');
});
// --- the two shipped bugs the editor found (SPRINT14) --------------------
t.test('world: the graybox house is built ONLY when the site declares one', () => {
// Both directions, because only the pair can fail for the right reason:
// backyard_01 declares a house and must have one; a site that declares
// none must NOT get a 16 x 3 x 6 m grey slab across its north horizon,
// which is what site_02_corner_block shipped with until this sprint.
assert(world.root.getObjectByName('house_yardside'),
'backyard_01 declares a house and lost it');
assert(!reloaded.house, 'fixture drift: the template should declare no house');
assertEq(rtWorld.root.getObjectByName('house_yardside') ?? null, null,
'a site with no house still got the graybox house');
});
t.test('world: the shed composes a finite matrix (rotY, not undefined)', () => {
if (!dressed) return 'SKIPPED — no server — dress() unavailable';
const shed = world.root.getObjectByName('shed_01');
assert(shed, 'backyard_01 declares a shed and it is not in the yard');
// The bug was `rotation.y = undefined` → an all-NaN quaternion → three.js
// silently declining to draw it. Nothing threw and nothing logged, so the
// only assert that could ever have caught it is one that looks at the
// COMPOSED matrix. Dimensions and positions cannot see a NaN rotation —
// this is the same lesson as MANUAL.md's axis trap, one level lower.
assert(Number.isFinite(shed.rotation.y), `shed rotation.y is ${shed.rotation.y}`);
shed.updateMatrixWorld(true);
assert(!shed.matrixWorld.elements.some((n) => !Number.isFinite(n)),
'the shed composes a non-finite world matrix — it will not render');
assertClose(shed.rotation.y, -Math.PI / 2, 1e-9, 'shed lost the rotation the site asked for');
});
t.test('collateral resolves by KEY, not by structure id (the free-carport bug)', () => {
// SPRINT14, E's find. This resolved by structure ID for five sprints and
// was correct only because site_02 happens to id its structure "carport",
// matching the string its anchors carry. The editor MUST generate unique
// ids, so the very first carport anyone places is `s1` — at which point
// the anchors still say "carport", no structure has that id, the $180
// prices to null, and the trap becomes a free failure. The gutter bug.
//
// The fixture is the real thing: a carport the editor named itself.
const st = reloaded.structures[0];
assertEq(st.id, 's1', 'fixture drift — the editor should have generated s1');
assertEq(st.collateralKey, 'carport');
assert(st.id !== st.collateralKey,
'this assert only means something while the id and the key DIFFER — if the editor ever '
+ 'starts naming structures after their collateral, re-point the fixture, do not delete it');
const priced = rtWorld.collateralFor('carport');
assert(priced, 'a carport the editor named "s1" priced to NULL — that is a free failure');
assertEq(priced.cost, 180, 'the carport must still cost $180 under a generated id');
assertEq(priced.label, 'the carport');
});
t.test('the wreck swaps by collateral key too', () => {
if (!rtDressed) return 'SKIPPED — no server, no wreck GLB';
// Same seam, the other half: main.js calls wreckStructure() with the key
// it read off the blown anchor, never with the structure's id.
assertEq(rtWorld.isWrecked('carport'), false, 'the carport starts standing');
assert(rtWorld.wreckStructure('carport'),
'wreckStructure could not find the carport by its collateral key');
assertEq(rtWorld.isWrecked('carport'), true, 'the carport never swapped to its wreck');
});
t.test('world: a site with no shedTable builds instead of throwing', () => {
// Unreachable for both shipped sites; reached by the editor's template on
// its first frame. Every consumer already guards `world.shedTable`.
assert(!reloaded.shedTable, 'fixture drift: the template should declare no shed table');
assertEq(rtWorld.shedTable, null, 'a table-less site should publish shedTable = null');
assert(world.shedTable?.pos, 'backyard_01 declares a table and lost its pickup point');
});
}