/** * 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 } from '../main.js'; import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js'; /** @param {import('../testkit.js').Suite} t */ export default function run(t) { const scene = new THREE.Scene(); const world = createWorld(scene, { wind: createStubWind({ calm: true }) }); // --- 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('; '), ''); }); // --- 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 7 anchors: 3 house, 2 tree, 2 post', () => { const by = (type) => world.anchors.filter((a) => a.type === type).length; assertEq(by('house'), 3, 'house anchors'); assertEq(by('tree'), 2, 'tree anchors'); assertEq(by('post'), 2, 'post anchors'); const ids = world.anchors.map((a) => a.id); assertEq(new Set(ids).size, ids.length, `anchor ids not unique: ${ids}`); }); 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'); }); }