week.js: REP (stars, half-steps, x1+0.04(rep-3) fee multiplier), warrantyItems() derived from the previous settlement's rig record so quote==settle holds by construction; rep moves at dawn of the night that earned it and never reads the garden. main.js: rigRecordFor (exported value — the tally remembers what repairCorner's cleared flag forgets), night break/repair tally, rep on the forecast call. hud.js: rep under the ABN on job sheet AND invoice, warranty lines on both papers worded per spec, fee-row multiplier the night it applies, rep line under the verdict, end card speaks rep + shows warranty receipts, C's gate-3.3 forecast print seam (inert until they land). a.test: 8 new tests incl. the clean-week negative control. Selftest 426/0/0 in the browser. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1740 lines
95 KiB
JavaScript
1740 lines
95 KiB
JavaScript
/**
|
||
* Lane A selftests — contracts, yard layout, anchors, phase machine.
|
||
* Lane A owns this file. Other lanes: yours is js/tests/<letter>.test.js.
|
||
*/
|
||
|
||
import * as THREE from '../../vendor/three.module.js';
|
||
import {
|
||
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 { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY, REP } from '../week.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 five escalating nights, each a storm and a site', () => {
|
||
assertEq(NIGHTS.length, 5);
|
||
// SPRINT10: a night is {storm, site} now. nightAt() normalises either shape.
|
||
assertEq(nightAt(0).storm, 'storm_01_gentle', 'night one must be the one that cannot hurt you');
|
||
assertEq(nightAt(4).storm, 'storm_02b_icenight', 'night five is the ice night');
|
||
assertEq(nightAt(2).site, 'site_02_corner_block', 'night three moves to the corner block');
|
||
assertEq(nightAt(0).site, 'backyard_01', 'the rest are the backyard');
|
||
// SPRINT13 gate 1.4: the unsavable-garden flag is night 5's alone, and a
|
||
// night without it is presumed savable — new nights can't inherit the excuse.
|
||
assertEq(nightAt(4).gardenBeyondSaving, true, 'night 5 is beyond saving BY DESIGN — canon, ruled');
|
||
for (let i = 0; i < 4; i++) 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 < 2; i++) w.advance();
|
||
assertEq(w.night, 5);
|
||
assert(w.isFinalNight, 'night five is the last');
|
||
w.advance();
|
||
assertEq(w.night, 5, 'the ladder does not walk off its own end');
|
||
});
|
||
|
||
t.test('money persists across nights, and the bank is next night\'s shop', () => {
|
||
const w = createWeek();
|
||
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
|
||
const s = w.settle(
|
||
{ hp: 80, win: true, collateral: [], intactHardwareValue: 60 }, def, 70,
|
||
);
|
||
assertEq(s.bankBefore, 80);
|
||
assertEq(s.bankAfter, 80 - 70 + s.pay, 'bank = bank − spent + pay, nothing else');
|
||
// SPRINT11 — `clean` is the new term, and this assert is the reason it's
|
||
// spelled out here rather than folded into the fee: the invoice shows four
|
||
// lines, so pay must BE those four lines. It caught the clean bonus the
|
||
// moment it landed (got 143, want 123), which is exactly its job — an
|
||
// invoice whose rows don't sum to its total is the one bug a player will
|
||
// definitely find.
|
||
// 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.
|
||
assert(stormsToPreload().includes(CALM_STORM),
|
||
'the calm day is preloaded on the shipped ladder');
|
||
assert(stormsToPreload(['storm_02_wildnight']).includes(CALM_STORM),
|
||
"...and on a ladder that doesn't contain it anywhere — the case that broke");
|
||
assertEq(stormsToPreload(['storm_01_gentle']).length, 1,
|
||
'and it is still deduped, not loaded twice');
|
||
});
|
||
|
||
// --- SPRINT11 gate 2: the job sheet -------------------------------------
|
||
|
||
t.test('every night is a JOB — a client, a brief, and a yard', () => {
|
||
for (let i = 0; i < NIGHTS.length; i++) {
|
||
const j = nightAt(i);
|
||
assert(j.client, `night ${i + 1} has a client`);
|
||
assert(j.addr, `night ${i + 1} has an address — a letterhead bills somebody somewhere`);
|
||
assert(j.brief && j.brief.length > 20, `night ${i + 1} has a brief worth reading`);
|
||
assert(j.site, `night ${i + 1} has a yard`);
|
||
}
|
||
// The week's shape, which is DATA and not decoration: four nights on
|
||
// retainer at one yard while they're away, and ONE short-notice callout to
|
||
// a stranger's corner block. Night 3 landing differently is the whole point
|
||
// of the ladder — a different site MEANS a different client.
|
||
const clients = NIGHTS.map((_, i) => nightAt(i).client);
|
||
assertEq(new Set(clients).size, 2, 'two clients: the retainer and the one-off');
|
||
assert(clients[2] !== clients[0], 'night 3 is somebody else');
|
||
assertEq(nightAt(2).site, 'site_02_corner_block', "and it's their corner block");
|
||
});
|
||
|
||
t.test('an unbriefed night is a job with no letterhead, not a crash', () => {
|
||
// SPRINT10's promise was that a bare storm key still resolves. SPRINT11 must
|
||
// keep it: the job sheet degrades to the old storm card rather than throwing
|
||
// or printing "undefined" at a client.
|
||
const j = nightAt(99); // off the end entirely
|
||
assertEq(j.client, null, 'no client is null, not undefined');
|
||
assertEq(j.addr, null, 'no address is null');
|
||
assertEq(j.brief, null, 'no brief is null');
|
||
assertEq(j.site, 'backyard_01', 'and it still names a yard');
|
||
});
|
||
|
||
t.test('the job sheet quotes what the night pays BEFORE you rig it', () => {
|
||
// The feature, not the flavour. The fee has existed since Sprint 8 and was
|
||
// only ever shown in the aftermath — you chose what to spend without knowing
|
||
// what the job was worth. That's a reveal, not a decision.
|
||
const w = createWeek();
|
||
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
|
||
const q = w.quote(def);
|
||
assertEq(q.base, PAY.feeFor(20), 'base is the fee, quoted at full');
|
||
assertEq(q.garden, PAY.gardenBonusMax, 'the garden bonus at a perfect bed');
|
||
assertEq(q.clean, PAY.noCollateralBonus, 'and the clean bonus');
|
||
assertEq(q.total, q.base + q.garden + q.clean, 'the quote sums to what it promises');
|
||
|
||
// The quote must not lie: settle a perfect night and the invoice has to pay
|
||
// what the sheet advertised. A job sheet that over-promises is worse than no
|
||
// job sheet — it's the game lying on paper.
|
||
const s = createWeek().settle({ hp: 100, win: true, collateral: [], intactHardwareValue: 0 }, def, 0);
|
||
assertEq(s.fee + s.bonus + s.clean, q.total, 'a perfect night pays exactly the quote');
|
||
});
|
||
|
||
t.test('the clean bonus is about THEIR property, not your success', () => {
|
||
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
|
||
const gnome = [{ what: 'garden gnome', cost: 25 }];
|
||
|
||
const cleanWin = createWeek().settle({ hp: 90, win: true, collateral: [], intactHardwareValue: 0 }, def, 0);
|
||
const dirtyWin = createWeek().settle({ hp: 90, win: true, collateral: gnome, intactHardwareValue: 0 }, def, 0);
|
||
const cleanLoss = createWeek().settle({ hp: 10, win: false, collateral: [], intactHardwareValue: 0 }, def, 0);
|
||
|
||
assertEq(cleanWin.clean, PAY.noCollateralBonus, 'broke nothing: paid');
|
||
assertEq(dirtyWin.clean, 0, 'broke the gnome: forfeited');
|
||
// The deliberate one. Lose the garden but break nothing of theirs and you
|
||
// are still a tradesperson who didn't wreck the place. Different fact,
|
||
// different row — that's what makes it an invoice and not a score.
|
||
assertEq(cleanLoss.clean, PAY.noCollateralBonus, 'a lost night can still be a clean one');
|
||
|
||
// And it makes the trap bite twice: 180 + the forfeited bonus.
|
||
const carport = createWeek().settle(
|
||
{ hp: 90, win: true, collateral: [{ what: 'the carport', cost: 180 }], intactHardwareValue: 0 }, def, 0);
|
||
assertEq(cleanWin.pay - carport.pay, 180 + PAY.noCollateralBonus,
|
||
'the carport costs 200: the roof AND the bonus');
|
||
});
|
||
|
||
t.test('a lost night pays a fraction of the fee, not zero and not all of it', () => {
|
||
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
|
||
const won = createWeek().settle({ hp: 80, win: true, collateral: [], intactHardwareValue: 0 }, def, 0);
|
||
const lost = createWeek().settle({ hp: 80, win: false, collateral: [], intactHardwareValue: 0 }, def, 0);
|
||
assertLess(lost.fee, won.fee, 'losing must cost you the fee');
|
||
assert(lost.fee > 0, 'but not all of it — you turned up. Zero is unrecoverable, not hard');
|
||
});
|
||
|
||
t.test('reaching night five is NOT the same as saving five gardens', () => {
|
||
// The bug this pins shipped in my own first draft and only measurement
|
||
// caught it: a $20-carabiner player loses four gardens, never dips under the
|
||
// broke line because a cheap rig barely costs anything, and was handed "THE
|
||
// WEEK HELD — everything's still where you put it" over four dead gardens.
|
||
// Outlasting the week is not surviving it, and the end card is the last
|
||
// thing this game says to anyone.
|
||
assertEq(gradeFor(5), 'clean', 'five held is the only clean week');
|
||
assertEq(gradeFor(4), 'scraped');
|
||
assertEq(gradeFor(3), 'scraped');
|
||
assertEq(gradeFor(1), 'solvent', 'one garden out of five is not a triumph');
|
||
assertEq(gradeFor(0), 'solvent');
|
||
});
|
||
|
||
t.test('going broke ends the week, but never on the final night', () => {
|
||
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
|
||
const ruin = { hp: 0, win: false, collateral: [{ what: 'gnome', cost: 25 }], intactHardwareValue: 0 };
|
||
|
||
const w = createWeek();
|
||
const s = w.settle(ruin, def, 80); // spend the lot, get almost nothing back
|
||
assert(s.bankAfter < BROKE_BELOW, `bank ${s.bankAfter} should be under the $${BROKE_BELOW} floor`);
|
||
assertEq(s.outcome, 'gameover', 'you cannot rig four corners, so the week is over');
|
||
|
||
// On the last night there is no next shop to be unable to afford, so being
|
||
// broke is just being broke — the week still finished.
|
||
const w2 = createWeek();
|
||
for (let i = 0; i < 4; i++) w2.advance();
|
||
assertEq(w2.settle(ruin, def, 80).outcome, 'win', 'night five always ends the week, not the run');
|
||
});
|
||
|
||
// Gate 2 acceptance: both sites load from data, and the corner block is not
|
||
// the backyard with the furniture moved. Built up front (Suite.test() can't
|
||
// await — the guard I added last sprint enforces it, and just caught me).
|
||
// storm_03b is the corner block's night: the early buster IS the southerly the
|
||
// funnel exists to teach, so the venturi assert measures the real pairing.
|
||
const earlyBusterDef = await loadStorm('storm_03b_earlybuster');
|
||
|
||
let site2World = null;
|
||
try {
|
||
const s2 = validateSite(await loadSite('site_02_corner_block'));
|
||
site2World = createWorld(new THREE.Scene(), { wind: createStubWind({ calm: true }), site: s2 });
|
||
try { await site2World.dress(); } catch { /* graybox anchors are enough */ }
|
||
} catch (err) {
|
||
console.warn('[a.test] site_02 unavailable (no server?):', err.message);
|
||
}
|
||
|
||
t.test('site_02 loads from JSON and is a genuinely different yard', () => {
|
||
if (!site2World) return 'SKIPPED — no server for site_02';
|
||
const ids = new Set(site2World.anchors.map((a) => a.id));
|
||
assert(ids.has('q1') && ids.has('tr1'), 'has its own honest anchors');
|
||
assert(!ids.has('h1') && !ids.has('p4'), 'and NOT the backyard\'s');
|
||
assertLess(site2World.gardenBed.w, world.gardenBed.w, 'the corner block is a smaller yard');
|
||
});
|
||
|
||
t.test('the carport is a trap: worst steel in the game, and a bracket job', () => {
|
||
if (!site2World) return 'SKIPPED — no server for site_02';
|
||
const cb = site2World.anchors.find((a) => a.id === 'cb1');
|
||
assert(cb, 'carport beam anchor cb1 exists');
|
||
// The whole reason the site teaches: it must out-lie the house fascia (0.35),
|
||
// and E's e.test pins these below it. If someone "fixes" them up, the corner
|
||
// block stops being a corner block and becomes a small yard.
|
||
if (cb.ratingHint != null) assertLess(cb.ratingHint, 0.35, 'carport must out-lie the fascia');
|
||
// D's field, keyed on MECHANISM: a bracket up a bare beam needs the ladder.
|
||
// Keyed on type it would fail open (carport isn't house), and the ladder
|
||
// mechanic would silently vanish — which is the audit D ran.
|
||
assertEq(cb.work, 'bracket', 'the carport beam is a bracket job — needs the ladder');
|
||
});
|
||
|
||
// --- SPRINT12 gate 3: E's letterhead/invoice kit, rendered ----------------
|
||
// Both of Sprint 11's card bugs were invisible to the suite because nothing
|
||
// ever RENDERED a card and looked. These do the minimum of that in DOM: the
|
||
// kit's contract classes must exist on a real render AND their load-bearing
|
||
// CSS must actually draw. Eyes on the browser are still required — a DOM
|
||
// assert can't see a letterhead with buttons through it.
|
||
|
||
t.test('SPRINT12: the invoice wears the letterhead, and the dead bonus is struck through', () => {
|
||
if (typeof document === 'undefined') return 'SKIPPED — no DOM';
|
||
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
|
||
// Night 3, the carport night: collateral forfeits the clean bonus, so this
|
||
// one render exercises the void, the neg AMOUNT DUE, and the docket number.
|
||
const wk = createWeek();
|
||
wk.advance(); wk.advance();
|
||
const w = wk.settle({ hp: 86, win: true,
|
||
collateral: [{ what: 'the carport', cost: 180 }], intactHardwareValue: 12 }, def, 71);
|
||
|
||
const hud = createHud({ scene: new THREE.Scene() });
|
||
try {
|
||
hud.showAftermath({ hp: 86, cornersLost: 1, cornersTotal: 4, hailBlocked: 'some hail',
|
||
bill: 34, collateral: [{ what: 'the carport', cost: 180 }],
|
||
subtitle: 'test', verdict: 'test', win: true, week: w }, () => {});
|
||
const q = (sel) => document.querySelector(`#hud-card ${sel}`);
|
||
|
||
assertEq(q('.letterhead .mark')?.textContent, 'HARD YARDS', "the outfit's mark (ruling: adopted)");
|
||
const no = q('.letterhead .docket .no')?.textContent ?? '';
|
||
assert(no.includes('INV 1043'), `docket says INV 1043, got "${no}"`);
|
||
// The ruling is that the ABN STAYS sequential — it reads as "example" the
|
||
// way 555 numbers do, and a realistic one is a stranger's tax identity.
|
||
assert(no.includes('ABN 12 345 678 901'), `the sequential ABN survives, got "${no}"`);
|
||
assertEq(q('.billto .who')?.textContent, 'the Vasilaros place', 'the bill-to block names the client');
|
||
|
||
// E's .void — quoted, not paid, and still on the paper.
|
||
const voidB = q('.ledger .row.void b');
|
||
assert(voidB, 'the forfeited clean bonus renders as .void, not as $0 or missing');
|
||
assertEq(voidB.textContent, `$${w.cleanMax}`, 'struck at the QUOTED amount');
|
||
assert(getComputedStyle(voidB).textDecorationLine.includes('line-through'),
|
||
'and the strike actually draws — the class without its CSS is decoration');
|
||
|
||
// AMOUNT DUE is the conclusion: pay − spent, negative the night you take
|
||
// the carport with you, and it must SAY so.
|
||
const due = w.pay - 71;
|
||
const dueB = q('.ledger .row.due b');
|
||
assert(dueB, 'the invoice concludes with AMOUNT DUE');
|
||
assertEq(dueB.textContent, `${due < 0 ? '−' : '+'}$${Math.abs(due)}`, 'due = pay − spent');
|
||
assertEq(q('.ledger .row.due')?.classList.contains('neg'), due < 0, 'a negative due reads as one');
|
||
} finally { hud.dispose(); }
|
||
});
|
||
|
||
t.test('SPRINT12: the job sheet carries the same letterhead, a quote, and the terms line', () => {
|
||
if (typeof document === 'undefined') return 'SKIPPED — no DOM';
|
||
// The same outfit, the same night, the other document: JOB 1043 in the
|
||
// morning becomes INV 1043 — the pair answering each other is the design.
|
||
const wk = createWeek();
|
||
wk.advance(); wk.advance();
|
||
const hud = createHud({ scene: new THREE.Scene() });
|
||
try {
|
||
hud.showForecast(
|
||
{ key: 'storm_03b_earlybuster', def: earlyBusterDef, site: null, tomorrowDef: null },
|
||
{ night: wk.night, nights: wk.nights, log: [], job: wk.job,
|
||
quote: wk.quote(earlyBusterDef), bank: wk.bank }, () => {});
|
||
const q = (sel) => document.querySelector(`#hud-card ${sel}`);
|
||
|
||
assertEq(q('.letterhead .mark')?.textContent, 'HARD YARDS', 'same mark as the invoice');
|
||
assertEq(q('.letterhead .docket .kind')?.textContent, 'JOB SHEET', 'but this paper is the quote');
|
||
assert((q('.letterhead .docket .no')?.textContent ?? '').includes('JOB 1043'),
|
||
'and it shares the docket number with its invoice');
|
||
assert(q('.billto .addr'), 'the bill-to block says where the job is');
|
||
assertEq(document.querySelectorAll('#hud-card .sched .cond').length, 3,
|
||
'three pay lines, each carrying its condition');
|
||
assert((q('.terms')?.textContent ?? '').includes('not a variation'),
|
||
"the terms line: quoted on the forecast, weather is not a variation");
|
||
} finally { hud.dispose(); }
|
||
});
|
||
|
||
t.test("SPRINT12: the kid's bike leans INTO the south fence, not into open air", () => {
|
||
// E's axis trap, pinned from the PLACEMENT side. e.test proves the GLB
|
||
// leans toward local −Z; nothing until now proved the site points that lean
|
||
// at anything. Un-rotate the bike or drag it mid-yard and this reddens —
|
||
// the failure mode is a bike leaning on air, which reads as a physics bug
|
||
// and would get chased in the wrong lane (E's words, their lean note).
|
||
const b = site.bike;
|
||
assert(b, 'backyard_01 places the bike');
|
||
assertEq(b.model, 'bike_kid_01_v1', "E's model, the v1 with the baked lean");
|
||
|
||
// Local −Z rotated by rotY must land on +Z — the south fence side.
|
||
const ry = (b.rotYDeg * Math.PI) / 180;
|
||
const leanTowardFence = -Math.cos(ry); // z of (0,0,−1) rotated about Y
|
||
assert(leanTowardFence > 0.95,
|
||
`rotYDeg ${b.rotYDeg} points the baked lean at z=${leanTowardFence.toFixed(2)} — not the fence`);
|
||
|
||
// The standoff, measured: bars reach 0.286 m past the tyre line toward the
|
||
// lean; the fence rails sit at z = depth/2 ± 0.02. The band accepts
|
||
// bars-on-rail ± a hand's width, and rejects mid-yard or tyres-in-fence.
|
||
const gap = site.yard.depth / 2 - b.z;
|
||
assert(gap > 0.15 && gap < 0.45,
|
||
`bike stands ${gap.toFixed(2)} m off the fence line — bars touch at ~0.31`);
|
||
|
||
// RULING (SPRINT12 gate 3.2): E's $60 is DECLINED until the sim can knock a
|
||
// bike over. Billing collateral for an event the player never sees is the
|
||
// lie the invoice exists to kill; leaning on the windward fence is the one
|
||
// pose a southerly can't falsify. This assert is the ruling's tripwire: if
|
||
// someone prices the bike, they must also build the fall, and this message
|
||
// is where they find that out.
|
||
assert(b.collateralValue == null, 'the bike is unpriced BY RULING — build the fall first');
|
||
});
|
||
|
||
// =========================================================================
|
||
// 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 night 5'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: night 5's designed loss costs NO rep — a clean rig still earns it", () => {
|
||
const w = createWeek();
|
||
for (let i = 0; i < 4; i++) w.advance();
|
||
assertEq(nightAt(4).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 < 5; 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, 'five clean nights ride the clamp to the top of the scale');
|
||
assertEq(w.log[4].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.
|
||
const bogus = {
|
||
yard: { width: 10, depth: 10 }, gardenBed: { x: 0, z: 0, w: 2, d: 2 },
|
||
sun: { elevationDeg: 45, azimuthDeg: 0 },
|
||
posts: [{ id: 'x1', x: 0, z: 0, h: 3, type: 'trampoline', work: 'cloth' }],
|
||
};
|
||
let threw = null;
|
||
try { validateSite(bogus, 'bogus'); } catch (err) { threw = err.message; }
|
||
assert(threw, 'a site with an unknown anchor type must fail loud');
|
||
assert(/trampoline/.test(threw), 'and must NAME the type it rejected');
|
||
// ...and must not reject the types that are real, including the new ones.
|
||
for (const type of ANCHOR_TYPE) {
|
||
const ok = { ...bogus, posts: [{ id: 'x1', x: 0, z: 0, h: 3, type, work: 'cloth' }] };
|
||
validateSite(ok, `ok-${type}`); // throws = red
|
||
}
|
||
});
|
||
|
||
t.test('ruling: the venturi axis is a LINE — C and A never disagreed', () => {
|
||
// The reconciliation SPRINT11 asked for, pinned so it is never "fixed" back.
|
||
// weather.core aligns on |dot(wind, axis)| because a gap funnels either way
|
||
// through it, so axis and axis+PI are the same gap. A shipped 2.1, C read the
|
||
// southerly's heading at -1.08, and those are 2.2 deg apart: one line, two
|
||
// ends. Pinned against the SHIPPED axis — the site's geometry is the fact
|
||
// under test, not the storm heading it happens to run along.
|
||
const at = (axis) => {
|
||
const f = createWindField(earlyBusterDef);
|
||
f.setVenturi([{ x: -6, z: 0, axis, gain: 1.5, radius: 5, sharp: 3 }]);
|
||
return f;
|
||
};
|
||
const c = at(2.1), flipped = at(2.1 + Math.PI);
|
||
let worst = 0;
|
||
for (let tt = 0; tt <= 90; tt += 0.5) {
|
||
worst = Math.max(worst, Math.abs(c.speedAt(-6, 0, tt) - flipped.speedAt(-6, 0, tt)));
|
||
}
|
||
// NOT assertEq(worst, 0) — and the reason is worth the comment. I first
|
||
// "measured" this at exactly 0.000000 in a node probe and wrote that into
|
||
// the site JSON. It was a toFixed(6) printing 1e-14 as zero: the probe
|
||
// rounded a number I then reported as measured. cos(x+PI) is not bit-exactly
|
||
// -cos(x), so flipping the axis carries ~1e-16 of float noise into |dot|.
|
||
// 1e-14 m/s is not physics — a gust is 21 m/s — but "exactly" was my word,
|
||
// not the harness's. The claim is: identical to float precision.
|
||
assertLess(worst, 1e-9, 'axis and axis+PI are the same funnel, to float precision');
|
||
// And the funnel must actually DO something, or the corner block's whole
|
||
// weather personality is a no-op that no test would have noticed.
|
||
const off = createWindField(earlyBusterDef);
|
||
const peakT = 48.75; // measured: storm_03b's worst
|
||
assert(c.speedAt(-6, 0, peakT) > off.speedAt(-6, 0, peakT) * 1.2,
|
||
'the throat is meaningfully faster than the open yard');
|
||
});
|
||
|
||
t.test("every anchor carries a NUMBER for ratingHint (D's landmine)", () => {
|
||
if (!site2World) return 'SKIPPED — no server for site_02';
|
||
// Nothing reads ratingHint yet (that's the open ruling — see THREADS), and
|
||
// this assert exists BECAUSE of that: it makes the wire safe before it's
|
||
// made. The honest posts are built from site JSON and never pass through
|
||
// adoptAnchor, so they had no ratingHint at all — and `load > hw.rating *
|
||
// undefined` is `load > NaN`, which is always false. Wire it naively and the
|
||
// strongest anchors become immortal while the trap looks like it works.
|
||
for (const a of site2World.anchors) {
|
||
assert(Number.isFinite(a.ratingHint), `${a.id} has a finite ratingHint, not undefined`);
|
||
assert(a.ratingHint > 0, `${a.id}'s ratingHint is positive — a 0 would be unbreakable-by-zero`);
|
||
}
|
||
for (const a of world.anchors) {
|
||
assert(Number.isFinite(a.ratingHint), `backyard ${a.id} has a finite ratingHint too`);
|
||
}
|
||
// And the honest steel must still out-rate the trap, or the site has no thesis.
|
||
const q1 = site2World.anchors.find((a) => a.id === 'q1');
|
||
const cb1 = site2World.anchors.find((a) => a.id === 'cb1');
|
||
if (q1 && cb1 && cb1.ratingHint < 1) assertLess(cb1.ratingHint, q1.ratingHint,
|
||
'the carport beam is worse steel than an honest post');
|
||
});
|
||
|
||
t.test('ruling: the carport BILLS — 180, once, and takes the roof with it', () => {
|
||
if (!site2World) return 'SKIPPED — no server for site_02';
|
||
// E shipped the trap and said it plainly: the anchors said collateral
|
||
// "carport" and nothing said what a carport COST, so nothing scored it.
|
||
// You could lose the worst steel in the game and pay for a $15 shackle.
|
||
const priced = site2World.collateralFor('carport');
|
||
assert(priced, 'the carport has a price at all');
|
||
assertEq(priced.cost, 180, "A's ruling: 180 — 2.25 nights' budget");
|
||
assertEq(priced.label, 'the carport', 'and reads as English on the invoice');
|
||
// The price is the SITE's, not the mesh's. Sites are data.
|
||
assertEq(site2World.anchors.find((a) => a.id === 'cb1').collateral, 'carport',
|
||
'the beam anchor carries the key that reaches the price');
|
||
// An unpriced label must read as "not scored", never as free: the house's
|
||
// fascia anchors carry collateral "gutter" and nobody has priced a gutter.
|
||
assertEq(site2World.collateralFor('gutter'), null, 'unpriced is null, not 0');
|
||
assertEq(site2World.collateralFor(undefined), null, 'and no key is not a bill');
|
||
});
|
||
|
||
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("night 5's loss is TAUGHT, not blamed: beyondSaving rewrites the garden verdicts only", () => {
|
||
// SPRINT13 gate 1.4, the night-5 ruling. 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)}m²`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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 4, 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');
|
||
});
|
||
}
|