/**
 * 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, loadSite, validateSite } 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, nightAt, 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();
  // 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('; '), '');
  });

  // --- 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');
    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');
    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');
  });

  // 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).
  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');
  });

  // --- 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('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');
  });
}
