The router is a hand-maintained delegation list and it has already cost us once: when Lane C added rainMmPerHour/rainDepthMm, it didn't forward them, and nothing went red — every suite holds a real wind, only the GAME holds the router. Lane B's ponding would have passed every assert it had and done nothing in the yard; the integrator caught it by hand at merge. The class matters more than the instance. Sprint 5's headline system is Lane C's hailAt(), and decision 13 hangs the whole garden score off it — the same silent swallow would make rigging look irrelevant to the garden all over again, which is the exact thing the sprint exists to fix. The assert diffs the router against a real wind and names what's missing. Verified it fires rather than just passing: rebuilt Sprint 4's pre-fix router and it reports exactly rainMmPerHour, rainDepthMm. Garden drain now goes through sky.gardenExposure() — same arithmetic, but it's Lane C's term to own, and decision 13 extends precisely this shape, so hail lands as one added term rather than a rewrite. Numbers unmoved: good rig 53%. Also re-measured Sprint 4's headline finding against a TRUE control (fresh boot, nothing ever rigged, shadow over bed 0.000) because my original control left the previous round's sail in the shadow grid: no-sail is 48.5% vs a good rig's 53%. The finding stands, and decision 13's premise with it. Selftest 209/0/0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
413 lines
18 KiB
JavaScript
413 lines
18 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 { FIXED_DT, STORM_LEN, YARD, checkContract, createStubWind } from '../contracts.js';
|
|
import { createWorld, heightAt } from '../world.js';
|
|
import { createCameraRig } from '../camera.js';
|
|
import { createGame, createWindRouter } 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)');
|
|
});
|
|
|
|
// --- 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 11 anchors: 3 house, 5 tree, 3 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'), 3, 'post anchors — p3 added, SPRINT3 decision 2');
|
|
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');
|
|
});
|
|
}
|