/** * 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 { FIXED_DT, STORM_LEN, YARD, checkContract, createStubWind } from '../contracts.js'; import { createWorld, heightAt } from '../world.js'; import { createCameraRig } from '../camera.js'; import { createGame, createWindRouter, verdictFor } from '../main.js'; import { orderRing } from '../sail.js'; import { loadStorm, createWind } from '../weather.js'; import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js'; /** @param {import('../testkit.js').Suite} t */ export default async function run(t) { const scene = new THREE.Scene(); const world = createWorld(scene, { wind: createStubWind({ calm: true }) }); // Dress the yard before asserting anything about it: anchors are only FINAL // after dress(), which moves them onto the positions Lane E baked and adds the // extra tree branches. Testing the graybox would be testing a yard that never // reaches a player. Guarded, so a missing server degrades to graybox asserts // rather than reddening the whole lane. let dressed = false; try { await world.dress(); dressed = true; } catch (err) { console.warn('[a.test] dress() unavailable, asserting against graybox:', err.message); } // --- contract conformance ------------------------------------------------ // These are the merge tripwires: if a lane's module drifts from contracts.js, // this is where we find out, not three lanes later. t.test('contract: stub wind conforms', () => { assertEq(checkContract('wind', createStubWind()).join('; '), ''); }); t.test('contract: world conforms', () => { assertEq(checkContract('world', world).join('; '), ''); }); t.test('contract: camera rig conforms', () => { const rig = createCameraRig(document.createElement('div')); assertEq(checkContract('camera', rig).join('; '), ''); }); t.test('contract: game conforms', () => { assertEq(checkContract('game', createGame()).join('; '), ''); }); // --- the wind router ----------------------------------------------------- t.test('wind router forwards EVERYTHING the real wind exposes', async () => { // This exists because the omission it catches has already shipped once. // // Lane C added rainMmPerHour/rainDepthMm for ponding; main.js's router — a // hand-maintained delegation list — didn't forward them. Nothing went red: // every suite holds a real wind, and only the GAME holds the router. So // Lane B's ponding would have passed every assert it had and done nothing // in the actual yard, and it took the integrator noticing by hand at merge. // // The class of bug is worse than the instance. Sprint 5's headline system is // Lane C's hailAt(), and decision 13 hangs the entire garden score off it — // so the same silent swallow would make rigging look irrelevant to the // garden all over again, which is the exact thing this sprint exists to fix. // Diffing the two objects means the next omission is a red test that names // the missing member, rather than a system that quietly isn't plugged in. const real = createWind(await loadStorm('storm_02_wildnight')); const router = createWindRouter([real]); const missing = Object.keys(real).filter((k) => !(k in router)); assert(missing.length === 0, `the wind router swallows ${missing.join(', ')} — add ${missing.length > 1 ? 'them' : 'it'} ` + `to createWindRouter in main.js, or the game silently runs without ${missing.length > 1 ? 'those' : 'that'}`); // Present isn't enough — a forwarded method has to actually reach the wind. const wrongType = Object.keys(real).filter((k) => typeof real[k] === 'function' && typeof router[k] !== 'function'); assert(wrongType.length === 0, `router has ${wrongType.join(', ')} but not as callable(s)`); }); t.test('wind router delegates live — use() re-points every consumer at once', async () => { // The router's whole reason to exist: consumers bind once at construction, // so a storm swap has to be a re-point rather than a re-wire. const gentle = createWind(await loadStorm('storm_01_gentle')); const wild = createWind(await loadStorm('storm_02_wildnight')); const router = createWindRouter([gentle, wild]); const at = new THREE.Vector3(0, 0, 0); router.use(gentle); const calm = router.speedAt(at, 60); router.use(wild); const gale = router.speedAt(at, 60); assertLess(calm, gale, 'swapping to the wild night must change what consumers read'); assertEq(router.def, wild.def, 'def follows the active storm (skyfx reads it at construction)'); }); // --- verdicts tell the truth (SPRINT6 gate 1) ---------------------------- t.test('a run that held every corner is never told it skimped', () => { // The bug this pins shipped and was caught by playing, not by asserting: // any run under 50 hp read "the rain found what you skimped on", including // Lane B's twisted quad that held 4/4 and skimped on nothing. The verdict is // the game's whole feedback channel — DESIGN.md wants every disaster to // replay as "…the shackle, I knew about the shackle", and blaming the wrong // thing teaches the exact opposite of the lesson the storm just gave. const heldAll = verdictFor({ hp: 39, lost: [], win: false, dmg: { hail: 48, rain: 13 }, pondPeak: 0, pondDumped: 0, }); assertEq(heldAll.mode, 'uncovered', 'a 4/4 hold that lost the garden to hail is a COVERAGE failure, not a hardware one'); assert(!/skimp/i.test(heldAll.verdict), `verdict accused a player who broke nothing of skimping: "${heldAll.verdict}"`); assert(/held/i.test(heldAll.verdict), `verdict should credit the corners that held: "${heldAll.verdict}"`); }); t.test('verdict names the weakest link that actually let go', () => { // "…the shackle. I knew about the shackle." The whole point is that the // player recognises the corner they gambled on. const carabiner = { anchorId: 'p1', hw: { name: 'carabiner', rating: 1200, cost: 5 } }; const shackle = { anchorId: 'h3', hw: { name: 'shackle', rating: 3200, cost: 15 } }; const cascade = verdictFor({ hp: 20, lost: [shackle, carabiner], win: false, dmg: { hail: 60, rain: 20 }, pondPeak: 0, pondDumped: 0, }); assertEq(cascade.mode, 'cascade'); assert(/carabiner at P1/.test(cascade.verdict), `a cascade must name the WEAKEST link as going first, not whichever was listed first: "${cascade.verdict}"`); const single = verdictFor({ hp: 30, lost: [shackle], win: false, dmg: { hail: 60, rain: 10 }, pondPeak: 0, pondDumped: 0, }); assertEq(single.mode, 'corner'); assert(/shackle at H3/.test(single.verdict), `should name it: "${single.verdict}"`); }); t.test('verdict distinguishes the two ways a garden dies', () => { // Opposite mistakes, opposite fixes: under-bought hardware vs a rig that // held perfectly over the wrong patch of grass. One sentence each. const hailed = verdictFor({ hp: 20, lost: [], win: false, dmg: { hail: 70, rain: 10 }, pondPeak: 0, pondDumped: 0 }); const rained = verdictFor({ hp: 20, lost: [], win: false, dmg: { hail: 0, rain: 80 }, pondPeak: 0, pondDumped: 0 }); assertEq(hailed.mode, 'uncovered'); assertEq(rained.mode, 'rain'); assert(hailed.verdict !== rained.verdict, 'hail and rain deaths must not read identically'); }); t.test('a clean hold reads as a clean hold, and the broom gets its credit', () => { const clean = verdictFor({ hp: 96, lost: [], win: true, dmg: { hail: 2, rain: 2 }, pondPeak: 0, pondDumped: 0 }); assertEq(clean.mode, 'clean'); const broomed = verdictFor({ hp: 70, lost: [], win: true, dmg: { hail: 20, rain: 10 }, pondPeak: 400, pondDumped: 380 }); assertEq(broomed.mode, 'broomed', 'wearing 380 kg of water to save the rig should be the story'); assert(/380 kg/.test(broomed.verdict), `say what it cost: "${broomed.verdict}"`); }); // --- camera -------------------------------------------------------------- t.test('camera keeps a clear line to the player from every angle', () => { // PLAN3D §5-A acceptance: "camera never clips through house". Stated here // as the underlying invariant — no solid between the player's head and the // camera — so it still means something after Lane E swaps the graybox house // for house_yardside.glb. const rig = createCameraRig(document.createElement('div')); rig.setSolids(world.solids); rig.setGround(heightAt); const ray = new THREE.Raycaster(); const head = new THREE.Vector3(); const spots = [ [0, -9.4], // backed against the house — the case that caught it [-5, -9.2], // under a fascia anchor [-9, 2.6], // hard against a tree trunk [-6, 7], // hard against a post [0, 9.2], // against the south fence ]; for (const [x, z] of spots) { const spot = new THREE.Vector3(x, heightAt(x, z), z); for (let k = 0; k < 6; k++) { rig.yaw = (k / 6) * Math.PI * 2; // 60 steps = 1 s of smoothing at lambda 9, i.e. 99.99% settled. Every // step is a raycast against the whole yard, so this bound is the // difference between a 1 s selftest and a 4 s one — and every lane runs // it after every merge. for (let i = 0; i < 60; i++) rig.update(1 / 60, spot); head.set(spot.x, spot.y + 1.55, spot.z); const cam = rig.object.position; const d = head.distanceTo(cam); assert(d > 0.1, `camera collapsed onto the player (${d.toFixed(3)}m) at (${x},${z})`); ray.set(head, cam.clone().sub(head).divideScalar(d)); ray.far = d - 0.02; const blocked = ray.intersectObjects(world.solids, true)[0]; assert( !blocked, `'${blocked?.object.name || '?'}' sits between the player at (${x},${z}) ` + `and the camera at yaw ${rig.yaw.toFixed(2)}`, ); // The ground isn't a solid (heightAt handles it), so the raycast above // can't speak for it — assert it separately. assert( cam.y > heightAt(cam.x, cam.z), `camera is underground at (${cam.x.toFixed(1)},${cam.z.toFixed(1)}), ` + `y=${cam.y.toFixed(2)} vs terrain ${heightAt(cam.x, cam.z).toFixed(2)}`, ); } } }); // --- terrain ------------------------------------------------------------- t.test('heightAt is pure and gentle across the whole yard', () => { for (let x = -15; x <= 15; x += 1.5) { for (let z = -10; z <= 10; z += 1.5) { const h = heightAt(x, z); assertEq(h, heightAt(x, z), `heightAt(${x},${z}) not pure`); assert(Math.abs(h) < 0.5, `heightAt(${x},${z}) = ${h}: terrain should stay gentle`); } } }); t.test('garden bed sits inside the yard', () => { const b = world.gardenBed; assert(Math.abs(b.x) + b.w / 2 < YARD.width / 2, 'bed overhangs east/west'); assert(Math.abs(b.z) + b.d / 2 < YARD.depth / 2, 'bed overhangs north/south'); }); // --- anchors ------------------------------------------------------------- t.test('yard offers 12 anchors: 3 house, 5 tree, 4 post', () => { const by = (type) => world.anchors.filter((a) => a.type === type).length; assertEq(by('house'), 3, 'house anchors'); assertEq(by('tree'), dressed ? 5 : 2, 'tree anchors (branch_anchor_* arrive with dress())'); assertEq(by('post'), 4, 'post anchors — p3 (SPRINT3 dec 2), p4 (SPRINT6 gate 1)'); const ids = world.anchors.map((a) => a.id); assertEq(new Set(ids).size, ids.length, `anchor ids not unique: ${ids}`); }); t.test('anchors carry Lane E\'s rating_hint, and the fascia is the weak one', () => { if (!dressed) return t.skip('needs dress()'); const hint = (id) => world.anchors.find((a) => a.id === id)?.ratingHint; // DESIGN.md: "The fascia board is a lie: holds until the first real gust." // Lane E encoded that as rating_hint 0.35 in house_yardside_v1.glb, so the // asset says it and nothing here has to restate it. If this ever flips to // 1.0, the yard has quietly stopped teaching its best lesson. assertLess(hint('h1'), 0.5, 'fascia anchor should be the weak option'); assertEq(world.anchors.find((a) => a.id === 'h1').collateral, 'gutter', 'a fascia failure takes the gutter with it — that is the collateral cost'); assert(hint('t1') > hint('t1c'), 'a branch anchor at the fork must out-rate one out where the limb is thin'); }); // --- decision 2: the yard has to offer a real choice ---------------------- t.test('yard offers ≥3 riggable quads in the 18-45 m² band that shade the bed', () => { if (!dressed) return t.skip('needs dress() — anchors are only final after it'); // SPRINT3 decision 2. Before the rework every quad covering the bed was // 110 m²+, which pre-tensions itself into a cascade at t=0.4 s before the // wind does anything — the yard taught the wrong lesson. const bed = world.gardenBed; const areaOf = (q) => { const r = orderRing(q); let a = 0; for (let i = 0, j = r.length - 1; i < r.length; j = i++) { a += (r[j].pos.x + r[i].pos.x) * (r[j].pos.z - r[i].pos.z); } return Math.abs(a / 2); }; const inside = (x, z, r) => { let c = false; for (let i = 0, j = r.length - 1; i < r.length; j = i++) { const a = r[i].pos, b = r[j].pos; if ((a.z > z) !== (b.z > z) && x < ((b.x - a.x) * (z - a.z)) / (b.z - a.z) + a.x) c = !c; } return c; }; const coverOf = (q) => { const r = orderRing(q); let hit = 0, tot = 0; for (let i = 0; i < 6; i++) { for (let j = 0; j < 4; j++) { const x = bed.x - bed.w / 2 + ((i + 0.5) / 6) * bed.w; const z = bed.z - bed.d / 2 + ((j + 0.5) / 4) * bed.d; tot++; if (inside(x, z, r)) hit++; } } return hit / tot; }; const A = world.anchors; const band = []; for (let i = 0; i < A.length; i++) { for (let j = i + 1; j < A.length; j++) { for (let k = j + 1; k < A.length; k++) { for (let l = k + 1; l < A.length; l++) { const q = [A[i], A[j], A[k], A[l]]; const m2 = areaOf(q); if (m2 >= 18 && m2 <= 45 && coverOf(q) >= 0.25) { band.push(`${q.map((a) => a.id).join('+')} ${m2.toFixed(0)}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`); }); 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'); }); }