HardYards/web/world/js/tests/a.test.js
m3ultra 52bdcd0a5e The week: five nights, one wallet, and an ending that tells the truth
SPRINT8 gate 1, carried since Sprint 6. week.js is a thin wrapper over the phase
machine, not a new system: it answers three questions between rounds — which
storm is tonight, how much money is there, and is it over. Zero THREE, pure data,
so the ladder and the money are testable at no cost.

The forecast card stops being a difficulty picker and becomes NIGHT n OF 5 with
the ladder as pips, tonight's storm, and the bank. Aftermath itemises the
settlement (fee / bonus / gear recovered / collateral / spent → bank), and E's
dawn tint comes up on their 2.2s ease BEFORE the scoreboard — their note is right
that you sell the survival first. E's diptych and copy are wired verbatim.

Two economy decisions, both made because measuring the week exposed them:

A lost night pays a FRACTION of the fee. Zero is unrecoverable and reads as
punishment; full pay means failure only costs the broken shackle and the bank
only ever climbs. Gear comes back at HALF — a full refund makes hardware free, so
the only sink is breakage and "broke = game over" becomes unreachable. Half is
also honest: a shackle that rode out a gale has been fatigued, which DESIGN.md
already says.

And the one that matters: REACHING night five is not SURVIVING it. My first draft
said it was, and playing the week found it — a $20-carabiner player loses four
gardens, never dips under the broke line because a cheap rig barely costs
anything, and got handed "THE WEEK HELD — everything's still where you put it"
over four dead gardens. The end card is the last thing this game says; it doesn't
get to lie the way the aftermath verdict used to. Words now scale with gardens
held: clean (5), scraped (3-4, E's own alternate copy), solvent (0-2, mine —
"STILL TRADING · The books balanced. The garden didn't." E never wrote words for
that ending because nobody knew the game could produce it).

Two economy questions I could NOT settle from a harness and have documented
rather than quietly tuned: a competent week runs the bank 80 → 350, so night five
stops being a decision; and a floor-scraping week may never actually go broke.
My bad-player model handed back a fixed $30 of gear whatever it spent, which
flattered the bottom of the curve — the same class of mistake this repo keeps
catching me in. Both want a human playing five nights.

Selftest 270 pass; the single red is gate 0's balance line, which is B+D's this
sprint and was already red on main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:13:44 +10:00

551 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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, verdictFor } from '../main.js';
import { orderRing } from '../sail.js';
import { loadStorm, createWind } from '../weather.js';
import { createWeek, NIGHTS, gradeFor, BROKE_BELOW } from '../week.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 week (SPRINT8 gate 1) -------------------------------------------
t.test('the week is five escalating nights and the ladder never reorders', () => {
assertEq(NIGHTS.length, 5);
assertEq(NIGHTS[0], 'storm_01_gentle', 'night one must be the one that cannot hurt you');
assertEq(NIGHTS[4], 'storm_02b_icenight', 'night five is the ice night');
const w = createWeek();
assertEq(w.night, 1); assertEq(w.bank, 80);
assert(!w.isFinalNight);
for (let i = 0; i < 4; 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');
assertEq(s.pay, s.fee + s.bonus + s.refund - 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('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');
});
// --- 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('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)}`);
}
}
}
}
}
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');
});
}