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