'use strict';
// SHADES — Lane C — weather selftest.
//
// Imports the pure core only (no THREE, no DOM), so this runs in node OR from
// Lane A's selftest.html. Fixed-dt loops, never rAF (rAF pauses in hidden tabs
// — PLAN3D §0).
//
//   node web/world/js/tests/run-node.mjs
//
// Lane A: `import { runWeatherSuite } from './js/tests/weather.selftest.js'` and
// call it with the fetched storm defs.

import {
  createWindField, validateStorm, validateSiteWind, gustEnvelope, GUST, RAIN_TIME_COMPRESSION,
  stormStats, forecastFor, hailBlockFor,
} from '../weather.core.js';

const DT = 1 / 60;

// yard is ~30×20 m, origin at centre — probe the corners and the middle
const PROBES = [
  { x: 0, z: 0 }, { x: -14, z: -9 }, { x: 14, z: -9 },
  { x: -14, z: 9 }, { x: 14, z: 9 }, { x: 5, z: -3 },
];

// t1 and t2 from Lane A's landed yard (THREADS: "yard layout is now FACT")
const TREE_SHELTERS = [
  { x: -9, z: 2, radius: 3, strength: 0.45, length: 14 },
  { x: 8, z: -2, radius: 2.5, strength: 0.4, length: 12 },
];

/**
 * The case list, defined once and run by two harnesses: run-node.mjs for a
 * one-second tuning loop, and js/tests/c.test.js for Lane A's selftest.html at
 * merge time. Cases are sync, matching testkit's Suite.test(label, fn).
 *
 * @param {Object<string, object>} storms  parsed storm defs, keyed by name
 * @returns {{cases: {name:string, fn:() => void}[], metrics: object}}
 *   `metrics` fills in as the cases run — read it after, not before.
 */
export function weatherCases(storms) {
  const cases = [];
  const metrics = {};
  const test = (name, fn) => cases.push({ name, fn });
  const assert = (cond, msg) => { if (!cond) throw new Error(msg); };

  const defs = Object.entries(storms);

  // ---- 1. gust telegraph lead (PLAN3D §5-C.5: always >= 1.2 s before ramp) ----
  // The whole gust read is "you SEE it coming, then it hits". If the lead ever
  // collapses, the storm stops being fair and starts being a dice roll.
  for (const [name, def] of defs) {
    test(`${name}: gust telegraph lead >= 1.2s`, () => {
      const field = createWindField(def);
      assert(field.gusts.length > 0, 'storm scheduled no gusts at all');
      const step = 1 / 240;
      let worst = Infinity;
      for (const g of field.gusts) {
        // when does THIS gust first actually push?
        let rise = null;
        for (let t = g.t0; t < g.endAt; t += step) {
          if (gustEnvelope(t - g.t0, g.pow) > 1e-6) { rise = t; break; }
        }
        assert(rise !== null, `gust at t=${g.t0.toFixed(2)} never rises`);

        // when did the HUD first get told about it?
        let announced = null;
        for (let t = Math.max(0, g.t0 - 2); t < rise; t += step) {
          const tg = field.telegraph(t);
          if (tg && Math.abs(tg.eta - (g.rampAt - t)) < 1e-6) { announced = t; break; }
        }
        assert(announced !== null, `gust at t=${g.t0.toFixed(2)} was never telegraphed`);

        const lead = rise - announced;
        worst = Math.min(worst, lead);
        assert(lead >= 1.2,
          `gust at t=${g.t0.toFixed(2)}: telegraph lead ${lead.toFixed(3)}s < 1.2s`);

        // and the telegraph window must be silent — no force before the ramp
        for (let t = g.t0; t < g.rampAt; t += step) {
          assert(gustEnvelope(t - g.t0, g.pow) === 0,
            `gust at t=${g.t0.toFixed(2)} pushes during its telegraph window`);
        }
      }
      metrics[`${name}.worstTelegraphLead`] = +worst.toFixed(3);
    });
  }

  // ---- 2. wind.sample continuity ----
  // Sail load goes with wind², so a discontinuity here is an impulse that can
  // snap a corner out of nowhere. Both axes matter: time (a standing player)
  // and space (a running one).
  const MAX_JUMP = 1.5;        // m/s per 1/60 frame
  for (const [name, def] of defs) {
    test(`${name}: wind continuity in time (< ${MAX_JUMP} m/s per frame)`, () => {
      const field = createWindField(def).setShelters(TREE_SHELTERS);
      const a = { x: 0, y: 0, z: 0 }, b = { x: 0, y: 0, z: 0 };
      let worst = 0, worstT = 0, worstP = null;
      for (const p of PROBES) {
        field.vecAt(p.x, p.z, 0, a);
        for (let t = DT; t <= field.duration; t += DT) {
          field.vecAt(p.x, p.z, t, b);
          const d = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
          if (d > worst) { worst = d; worstT = t; worstP = p; }
          a.x = b.x; a.y = b.y; a.z = b.z;
        }
      }
      metrics[`${name}.maxTemporalJump`] = +worst.toFixed(4);
      assert(worst < MAX_JUMP,
        `jump ${worst.toFixed(3)} m/s at t=${worstT.toFixed(2)} probe=(${worstP.x},${worstP.z})`);
    });

    test(`${name}: wind continuity in space (< ${MAX_JUMP} m/s per 0.1 m)`, () => {
      const field = createWindField(def).setShelters(TREE_SHELTERS);
      const a = { x: 0, y: 0, z: 0 }, b = { x: 0, y: 0, z: 0 };
      let worst = 0, worstAt = null;
      // sweep the yard at the storm's angriest moments, straight through both trees
      for (const t of [10, 30, 57, 64, 80]) {
        for (let z = -10; z <= 10; z += 1) {
          field.vecAt(-15, z, t, a);
          for (let x = -15 + 0.1; x <= 15; x += 0.1) {
            field.vecAt(x, z, t, b);
            const d = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
            if (d > worst) { worst = d; worstAt = { x: +x.toFixed(1), z, t }; }
            a.x = b.x; a.y = b.y; a.z = b.z;
          }
        }
      }
      metrics[`${name}.maxSpatialJump`] = +worst.toFixed(4);
      assert(worst < MAX_JUMP,
        `jump ${worst.toFixed(3)} m/s at ${JSON.stringify(worstAt)}`);
    });
  }

  // ---- 3. storm JSON validator ----
  for (const [name, def] of defs) {
    test(`${name}: validates`, () => {
      const { ok, errors } = validateStorm(def, name);
      assert(ok, errors.join('; '));
    });
  }

  // A validator that only ever says yes isn't a validator.
  test('validator rejects broken storms', () => {
    const base = () => JSON.parse(JSON.stringify(storms.storm_02_wildnight));
    const cases = [
      ['duration missing', (d) => { delete d.duration; }],
      ['duration negative', (d) => { d.duration = -5; }],
      ['baseCurve absent', (d) => { delete d.baseCurve; }],
      ['baseCurve non-monotonic t', (d) => { d.baseCurve = [[0, 5], [50, 9], [20, 7], [90, 6]]; }],
      ['baseCurve negative speed', (d) => { d.baseCurve = [[0, 5], [90, -2]]; }],
      ['baseCurve ends before duration', (d) => { d.baseCurve = [[0, 5], [40, 9]]; }],
      ['dirCurve absent', (d) => { delete d.dirCurve; }],
      ['gusts absent', (d) => { delete d.gusts; }],
      ['gusts.minGap zero', (d) => { d.gusts.minGap = 0; }],
      ['gusts.maxGap < minGap', (d) => { d.gusts.minGap = 9; d.gusts.maxGap = 4; }],
      ['gusts overlap (minGap < gust length)', (d) => { d.gusts.minGap = 2; }],
      ['debris event with no model', (d) => { d.events = [{ t: 10, type: 'debris' }]; }],
      ['event with no type', (d) => { d.events = [{ t: 10 }]; }],
      ['windchange that dirCurve never delivers', (d) => {
        d.dirCurve = [[0, 0.9], [90, 1.0]];
        d.events = [{ t: 55, type: 'windchange', telegraph: 6 }];
      }],
    ];
    for (const [label, mutate] of cases) {
      const d = base();
      mutate(d);
      const { ok } = validateStorm(d, 'broken');
      assert(!ok, `validator ACCEPTED a storm with: ${label}`);
    }
  });

  // ---- 4. determinism ----
  // Everything downstream (selftest fast-forward, Lane B's byte-equal load
  // traces) rests on this. Two builds of the same storm must be indiscernible.
  test('same def + same seed => identical trace', () => {
    const def = storms.storm_02_wildnight;
    const a = createWindField(def).setShelters(TREE_SHELTERS);
    const b = createWindField(def).setShelters(TREE_SHELTERS);
    const va = { x: 0, y: 0, z: 0 }, vb = { x: 0, y: 0, z: 0 };
    for (let t = 0; t <= def.duration; t += DT) {
      for (const p of PROBES) {
        a.vecAt(p.x, p.z, t, va);
        b.vecAt(p.x, p.z, t, vb);
        assert(va.x === vb.x && va.z === vb.z,
          `diverged at t=${t.toFixed(3)} probe=(${p.x},${p.z})`);
      }
    }
    assert(a.gusts.length === b.gusts.length, 'gust timelines differ in length');
    a.gusts.forEach((g, i) => {
      assert(g.t0 === b.gusts[i].t0 && g.pow === b.gusts[i].pow, `gust ${i} differs`);
    });
  });

  test('different seed => different storm', () => {
    const def = storms.storm_02_wildnight;
    const a = createWindField(def, { seed: 1 });
    const b = createWindField(def, { seed: 2 });
    const same = a.gusts.length === b.gusts.length
      && a.gusts.every((g, i) => g.t0 === b.gusts[i].t0 && g.pow === b.gusts[i].pow);
    assert(!same, 'seed is being ignored — every storm would be identical');
  });

  // ---- 5. sampling order must not matter ----
  // sample() is called by sail/player/debris/rain in whatever order the frame
  // happens to run. If it ever depends on call order, storms stop replaying.
  test('sample order independent', () => {
    const def = storms.storm_02_wildnight;
    // same fixed t values, walked in two different orders, on two fields
    const times = [0, 3.5, 17.3, 4.1, 55.0, 88.9, 63.2, 21.7, 39.9, 70.4];
    const sorted = [...times].sort((a, b) => a - b);
    const inOrder = createWindField(def).setShelters(TREE_SHELTERS);
    const shuffled = createWindField(def).setShelters(TREE_SHELTERS);
    const va = { x: 0, y: 0, z: 0 }, vb = { x: 0, y: 0, z: 0 };

    const seen = new Map();
    for (const t of sorted) {
      inOrder.vecAt(3, -2, t, va);
      seen.set(t, { x: va.x, z: va.z });
    }
    for (const t of times) {                 // deliberately out of order, and jumping backwards
      shuffled.vecAt(3, -2, t, vb);
      const want = seen.get(t);
      assert(vb.x === want.x && vb.z === want.z,
        `sampling order changed the wind at t=${t}: ${vb.x},${vb.z} vs ${want.x},${want.z}`);
    }
  });

  // ---- 6. the storms are what the design says they are (PLAN3D §7) ----
  // storm_02 has to be able to destroy a flat cheap rig; storm_01 must not.
  test('storm_02 is genuinely violent, storm_01 is not', () => {
    const wild = createWindField(storms.storm_02_wildnight);
    const gentle = createWindField(storms.storm_01_gentle);
    const peak = (f) => {
      let mx = 0, mxBase = 0;
      for (let t = 0; t <= f.duration; t += DT) {
        mx = Math.max(mx, f.uniformSpeed(t));
        mxBase = Math.max(mxBase, f.uniformSpeed(t) - f.gustOnly(t));
      }
      return { mx, mxBase };
    };
    const w = peak(wild), g = peak(gentle);
    metrics['storm_02.peakGustSpeed'] = +w.mx.toFixed(2);
    metrics['storm_02.peakSustained'] = +w.mxBase.toFixed(2);
    metrics['storm_01.peakGustSpeed'] = +g.mx.toFixed(2);
    metrics['storm_01.peakSustained'] = +g.mxBase.toFixed(2);
    assert(w.mx >= 30, `storm_02 peaks at only ${w.mx.toFixed(1)} m/s — won't break a cheap rig`);
    assert(w.mxBase >= 18, `storm_02 sustained peaks at only ${w.mxBase.toFixed(1)} m/s`);
    assert(g.mx <= 15, `storm_01 peaks at ${g.mx.toFixed(1)} m/s — too wild for the gentle storm`);
    assert(w.mx > g.mx * 2, 'storm_02 should be far worse than storm_01');
  });

  // ---- 7. wind change actually swings the wind ----
  test('storm_02 southerly change swings the wind', () => {
    const f = createWindField(storms.storm_02_wildnight);
    const ev = (storms.storm_02_wildnight.events || []).find((e) => e.type === 'windchange');
    assert(ev, 'storm_02 has no windchange event');
    const before = f.dirAt(ev.t - 5);
    const after = f.dirAt(ev.t + 8);
    const swing = Math.abs(after - before);
    metrics['storm_02.changeSwingRad'] = +swing.toFixed(3);
    assert(swing > 0.9, `change only swings ${swing.toFixed(2)} rad — should be a real slew`);
    // and the player must be warned before it lands
    assert((ev.telegraph ?? 0) >= 4, 'windchange telegraph is too short to react to');
  });

  // ---- 8. shelters ----
  test('tree wind shadow bites downwind and nowhere else', () => {
    const def = storms.storm_02_wildnight;
    const bare = createWindField(def);
    const shad = createWindField(def).setShelters([{ x: 0, z: 0, radius: 3, strength: 0.5, length: 14 }]);
    const t = 30;
    const d = bare.dirAt(t);
    const dx = Math.cos(d), dz = Math.sin(d);
    const lee = { x: dx * 5, z: dz * 5 };          // 5 m downwind of the tree
    const luv = { x: -dx * 5, z: -dz * 5 };        // 5 m upwind
    const leeS = shad.speedAt(lee.x, lee.z, t), leeB = bare.speedAt(lee.x, lee.z, t);
    const luvS = shad.speedAt(luv.x, luv.z, t), luvB = bare.speedAt(luv.x, luv.z, t);
    metrics['shelter.leeDrop'] = +(1 - leeS / leeB).toFixed(3);
    assert(leeS < leeB * 0.85, `lee side only dropped to ${(leeS / leeB).toFixed(2)}× — shadow too weak`);
    assert(Math.abs(luvS - luvB) < 1e-9, 'upwind side is being sheltered — shadow is pointing the wrong way');
  });

  // ---- 8b. venturi (SPRINT9 site_02 — the corner block funnels the southerly) ----
  test('venturi speeds the wind up when it blows along the gap, not across it', () => {
    const def = storms.storm_02_wildnight;
    // a funnel whose axis runs NE–SW (0.9 rad ≈ 51°), at the yard centre
    const AXIS = 0.9;
    const bare = createWindField(def);
    const funnel = createWindField(def).setVenturi([{ x: 0, z: 0, axis: AXIS, gain: 1.5, radius: 5, sharp: 3 }]);

    // when the wind blows ALONG the axis, the throat should scream
    const dt = 1 / 60;
    let alongBoost = 1, crossBoost = 1, alongT = 0, crossT = 0;
    for (let t = 0; t <= def.duration; t += dt) {
      const d = bare.dirAt(t);
      // alignment of the wind with the funnel axis, |dot|
      const align = Math.abs(Math.cos(d) * Math.cos(AXIS) + Math.sin(d) * Math.sin(AXIS));
      const boost = funnel.speedAt(0, 0, t) / bare.speedAt(0, 0, t);
      if (align > 0.98 && boost > alongBoost) { alongBoost = boost; alongT = t; }
      if (align < 0.15 && boost < crossBoost) { crossBoost = boost; crossT = t; }
    }
    metrics['venturi.alignedBoost'] = +alongBoost.toFixed(3);
    metrics['venturi.crosswindBoost'] = +crossBoost.toFixed(3);
    assert(alongBoost > 1.35, `a dead-on wind only boosted ${alongBoost.toFixed(2)}× at t=${alongT.toFixed(1)} — funnel too weak`);
    assert(crossBoost < 1.03, `a crosswind still boosted ${crossBoost.toFixed(2)}× at t=${crossT.toFixed(1)} — a funnel shouldn't fire across its axis`);
    // never slows the wind (a venturi accelerates, it doesn't shelter)
    assert(alongBoost >= 1 && crossBoost >= 1, 'venturi reduced the wind somewhere — it can only speed it up');
  });

  test('venturi only reaches inside its radius, and downdraft rides it', () => {
    const def = storms.storm_02_wildnight;
    const f = createWindField(def).setVenturi([{ x: 0, z: 0, axis: 0.9, gain: 1.5, radius: 5, sharp: 3 }]);
    const bare = createWindField(def);
    // far outside the throat: untouched
    assert(Math.abs(f.speedAt(20, 0, 40) - bare.speedAt(20, 0, 40)) < 1e-9, 'venturi reached 20 m away');
    // the vertical downdraft is a fraction of local speed, so it funnels too
    const out = { x: 0, y: 0, z: 0 }, outBare = { x: 0, y: 0, z: 0 };
    let maxRatio = 0;
    for (let t = 0; t <= def.duration; t += 1 / 60) {
      f.vecAt(0, 0, t, out); bare.vecAt(0, 0, t, outBare);
      if (outBare.y < -0.1) maxRatio = Math.max(maxRatio, out.y / outBare.y);   // both negative
    }
    assert(maxRatio > 1.3, `funnel didn't strengthen the downdraft (max ${maxRatio.toFixed(2)}×) — it should ride local speed`);
  });

  test('validateSiteWind: a good funnel passes, a bad one fails loud', () => {
    const ok = validateSiteWind({ venturi: [{ x: -6, z: 4, axis: -1.08, gain: 1.4, radius: 5, sharp: 3 }] }, 's');
    assert(ok.ok, `a valid venturi was rejected: ${ok.errors.join('; ')}`);
    assert(validateSiteWind(null, 's').ok, 'a site with no wind block should validate');
    assert(validateSiteWind({}, 's').ok, 'an empty wind block should validate');
    assert(validateSiteWind({ venturi: [] }, 's').ok, 'an empty venturi list should validate');
    for (const [label, bad] of [
      ['gain < 1 (a venturi speeds up, never slows)', { venturi: [{ x: 0, z: 0, gain: 0.7 }] }],
      ['gain absurd', { venturi: [{ x: 0, z: 0, gain: 9 }] }],
      ['no x,z', { venturi: [{ axis: 1, gain: 1.4 }] }],
      ['axis not finite', { venturi: [{ x: 0, z: 0, axis: 'south' }] }],
      ['radius <= 0', { venturi: [{ x: 0, z: 0, radius: 0 }] }],
      ['venturi not an array', { venturi: { x: 0 } }],
    ]) {
      assert(!validateSiteWind(bad, 's').ok, `validateSiteWind accepted a bad funnel: ${label}`);
    }
  });

  test('an empty venturi list is a perfect no-op (backyard_01 is untouched)', () => {
    const def = storms.storm_02_wildnight;
    const bare = createWindField(def);
    const set = createWindField(def).setVenturi([]);
    for (const p of PROBES) {
      for (const t of [10, 40, 60, 75]) {
        assert(bare.speedAt(p.x, p.z, t) === set.speedAt(p.x, p.z, t),
          `setVenturi([]) changed the wind at (${p.x},${p.z}) t=${t}`);
      }
    }
  });

  test('venturi keeps the wind continuous — no frame-to-frame snap', () => {
    // a discontinuity in the funnel would be an impulse straight into a corner;
    // both axes matter — a standing sail (time) and a swinging wind (alignment).
    const def = storms.storm_02_wildnight;
    const f = createWindField(def).setVenturi([{ x: 0, z: 0, axis: 0.9, gain: 1.6, radius: 5, sharp: 3 }]);
    const a = { x: 0, y: 0, z: 0 }, b = { x: 0, y: 0, z: 0 };
    let worst = 0, worstT = 0;
    // sit right in the throat through the whole storm, including the change
    f.vecAt(0.5, 0.5, 0, a);
    for (let t = 1 / 60; t <= def.duration; t += 1 / 60) {
      f.vecAt(0.5, 0.5, t, b);
      const jump = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
      if (jump > worst) { worst = jump; worstT = t; }
      a.x = b.x; a.y = b.y; a.z = b.z;
    }
    metrics['venturi.maxTemporalJump'] = +worst.toFixed(3);
    assert(worst < 1.5, `venturi jumped ${worst.toFixed(2)} m/s in one frame at t=${worstT.toFixed(1)}`);
  });

  // ---- 9. vertical structure (SPRINT3 decision 8: fraction of TOTAL) ----
  // Cloth pressure goes with dot(wind, normal). A flat panel's normal points at
  // the sky, so in a purely horizontal wind that dot is ~0 and "lie it flat and
  // ignore the storm" wins — the opposite of the game. The downdraft is now a
  // fraction of the LOCAL total wind speed (was: gust power), so a flat roof is
  // pressed whenever it's windy, not only at gust peaks. Lane B owns the
  // cloth-side no-free-lunch assert; these are the wind side.
  test('downdraft is a fixed fraction of the local horizontal speed', () => {
    const def = storms.storm_02_wildnight;
    const f = createWindField(def);
    const frac = def.gusts.downdraftOfTotal;
    assert(Math.abs(f.downFrac - frac) < 1e-12, `field downFrac ${f.downFrac} != json ${frac}`);
    const out = { x: 0, y: 0, z: 0 };
    let peakDown = 0;
    for (const p of PROBES) {
      for (let t = 0; t <= f.duration; t += DT) {
        f.vecAt(p.x, p.z, t, out);
        const horiz = Math.hypot(out.x, out.z);
        assert(out.y <= 1e-9, `vertical went UP (${out.y.toFixed(3)}) at t=${t.toFixed(2)} — downdraft only`);
        // out.y must be exactly -frac * horizontal, everywhere, always
        assert(Math.abs(out.y + frac * horiz) < 1e-9,
          `downdraft ${out.y.toFixed(3)} != -${frac}×${horiz.toFixed(3)} at t=${t.toFixed(2)}`);
        peakDown = Math.min(peakDown, out.y);
      }
    }
    metrics['storm_02.peakDowndraft'] = +peakDown.toFixed(2);
    // Held at downdraftOfTotal 0.15 → ~-4.9 m/s; target 0.45 → ~-14.7. Floor at
    // -3 so this proves "a real downdraft exists" across the whole transition
    // range without false-failing when the joint step bumps the value.
    assert(peakDown < -3, `peak downdraft only ${peakDown.toFixed(2)} m/s — a flat sail would still shrug it off`);
  });

  test('downdraft rides the wind: present when windy, gone when calm', () => {
    // The point of fraction-of-total: it's not a gust-only feature any more. Some
    // sustained-wind moment between gusts must still carry a real downdraft, and a
    // hypothetically dead-calm field must carry none.
    const f = createWindField(storms.storm_02_wildnight);
    let sustainedDown = 0;
    for (let t = 0; t <= f.duration; t += DT) {
      const inGust = f.gusts.some((g) => t > g.t0 && t < g.endAt);
      if (!inGust) sustainedDown = Math.min(sustainedDown, f.verticalAt(0, 0, t));
    }
    assert(sustainedDown < -2,
      `between gusts the downdraft peaks at only ${sustainedDown.toFixed(2)} — total-speed semantics should keep it pressing`);

    // dead calm → no downdraft (guards against a constant offset sneaking in)
    const calm = createWindField({
      duration: 10, baseCurve: [[0, 0], [10, 0]], dirCurve: [[0, 0], [10, 0]],
      gusts: { minGap: 6, maxGap: 6, powBase: 0, powRand: 0, powRamp: 0, downdraftOfTotal: 0.5 },
    });
    for (let t = 0; t <= 10; t += 0.1) {
      assert(Math.abs(calm.verticalAt(0, 0, t)) < 1e-9, `air is falling in a dead calm at t=${t.toFixed(1)}`);
    }
  });

  test('downdraft follows the tree shadow (shelters from falling air too)', () => {
    const def = storms.storm_02_wildnight;
    const f = createWindField(def).setShelters([{ x: 0, z: 0, radius: 3, strength: 0.5, length: 14 }]);
    const t = 30;
    const d = f.dirAt(t);
    const dx = Math.cos(d), dz = Math.sin(d);
    const leeDown = Math.abs(f.verticalAt(dx * 5, dz * 5, t));    // downwind of the tree
    const openDown = Math.abs(f.verticalAt(-dx * 5, -dz * 5, t)); // upwind, unsheltered
    assert(leeDown < openDown * 0.85, `lee downdraft ${leeDown.toFixed(2)} not sheltered vs open ${openDown.toFixed(2)}`);
  });

  test('downdraftOfTotal 0 gives a perfectly horizontal wind', () => {
    const def = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
    def.gusts.downdraftOfTotal = 0;
    const f = createWindField(def);
    const out = { x: 0, y: 0, z: 0 };
    for (let t = 0; t <= f.duration; t += 0.05) {
      f.vecAt(2, -1, t, out);
      assert(out.y === 0, `y=${out.y} at t=${t.toFixed(2)} with downdraft 0 — the opt-out leaks`);
    }
  });

  test('downdraft does not re-time the storm', () => {
    // The vertical carries NO rng draws of its own now (it's a pure function of
    // local speed), so tuning it cannot possibly shift gust times or powers. Lane
    // A hand-drove storm_02 and watched the carabiner blow at t=45.4 and p2
    // cascade at t=56; a downdraft tweak silently moving those would be a nasty
    // way to lose an afternoon. Determinism is now structural, but still asserted.
    const base = storms.storm_02_wildnight;
    const a = createWindField(base);
    for (const dd of [0, 0.1, 0.22, 0.5, 1]) {
      const d = JSON.parse(JSON.stringify(base));
      d.gusts.downdraftOfTotal = dd;
      const b = createWindField(d);
      assert(a.gusts.length === b.gusts.length, `downdraft ${dd} changed the gust count`);
      a.gusts.forEach((g, i) => {
        assert(g.t0 === b.gusts[i].t0,
          `downdraft ${dd} moved gust ${i} from t=${g.t0.toFixed(3)} to ${b.gusts[i].t0.toFixed(3)}`);
        assert(g.pow === b.gusts[i].pow, `downdraft ${dd} changed gust ${i}'s power`);
      });
      // and the HORIZONTAL wind must be byte-identical regardless of downdraft
      assert(a.speedAt(3, -2, 47.3) === b.speedAt(3, -2, 47.3), `downdraft ${dd} changed the horizontal wind`);
    }
  });

  test('speedAt stays horizontal — a wind meter does not read falling air', () => {
    const f = createWindField(storms.storm_02_wildnight);
    const out = { x: 0, y: 0, z: 0 };
    for (const p of PROBES) {
      for (const t of [12, 40, 60, 75.3]) {
        f.vecAt(p.x, p.z, t, out);
        assert(Math.abs(f.speedAt(p.x, p.z, t) - Math.hypot(out.x, out.z)) < 1e-9,
          `speedAt != horizontal magnitude of sample at t=${t}`);
      }
    }
  });

  test('validator rejects a bad downdraft and the renamed field', () => {
    for (const dd of [-0.1, 1.5, NaN, 'lots']) {
      const d = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
      d.gusts.downdraftOfTotal = dd;
      assert(!validateStorm(d, 'broken').ok, `validator ACCEPTED downdraftOfTotal = ${dd}`);
    }
    // the old gust-only field must be rejected, not silently re-meant
    const legacy = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
    delete legacy.gusts.downdraftOfTotal;
    legacy.gusts.downdraft = 0.3;
    assert(!validateStorm(legacy, 'legacy').ok, 'validator silently accepted the pre-SPRINT3 downdraft field');
  });

  // ---- 10. the ponding story (SPRINT4 decision 10) ----
  // Lane B owns water-on-cloth; these assert the RAIN DATA can tell the story
  // their mass model needs, using their own arithmetic, before their cloth lands.
  // B measured: 5 cm over a 25 m² flat sail = 1250 kg = 3.1 kN/corner, against a
  // storm_02 wind load of only 0.2–1.1 kN. So depth is the whole mechanic.
  const FLAT_AREA = 25;               // m², B's reference flat sail
  const KILL_DEPTH_MM = 50;           // 5 cm — B's 3.1 kN/corner
  /** Water on a flat sail over a whole storm, at decision 10's compression. */
  const stormDepthMm = (def) => {
    const f = createWindField(def);
    return f.rainDepthMm(0, f.duration) * RAIN_TIME_COMPRESSION;
  };
  /** B's arithmetic: mm over an area → kN per corner (4 corners, 1000 kg/m³). */
  const kNPerCorner = (mm) => (mm / 1000) * FLAT_AREA * 1000 * 9.81 / 4 / 1000;

  test('storm_02 rain can drown a flat rig; storm_01 rain cannot', () => {
    const wild = stormDepthMm(storms.storm_02_wildnight);
    const gentle = stormDepthMm(storms.storm_01_gentle);
    metrics['storm_02.pondDepth_mm'] = +wild.toFixed(1);
    metrics['storm_02.pond_kN_per_corner'] = +kNPerCorner(wild).toFixed(2);
    metrics['storm_01.pondDepth_mm'] = +gentle.toFixed(2);
    metrics['storm_01.pond_kN_per_corner'] = +kNPerCorner(gentle).toFixed(3);

    assert(wild >= KILL_DEPTH_MM * 0.9,
      `storm_02 only delivers ${wild.toFixed(1)} mm — Lane B needs ~${KILL_DEPTH_MM} mm to drown a flat rig`);
    // and it must dwarf the wind it's competing with (B: 0.2–1.1 kN/corner)
    assert(kNPerCorner(wild) > 1.5,
      `storm_02 ponding is only ${kNPerCorner(wild).toFixed(2)} kN/corner — no stronger than the wind`);
    // the gentle storm must be unable to hurt anything, or the ramp is a lie
    assert(kNPerCorner(gentle) < 0.3,
      `storm_01 ponds ${kNPerCorner(gentle).toFixed(2)} kN/corner — a light shower must not threaten a rig`);
    assert(wild > gentle * 20, 'the wild night should deliver vastly more water than a shower');
  });

  test('storm_03 ponding sits between the other two', () => {
    const mid = stormDepthMm(storms.storm_03_southerly);
    const wild = stormDepthMm(storms.storm_02_wildnight);
    const gentle = stormDepthMm(storms.storm_01_gentle);
    metrics['storm_03.pondDepth_mm'] = +mid.toFixed(1);
    metrics['storm_03.pond_kN_per_corner'] = +kNPerCorner(mid).toFixed(2);
    assert(mid > gentle * 3 && mid < wild * 0.5,
      `storm_03 delivers ${mid.toFixed(1)} mm — wanted a real middle rung between ${gentle.toFixed(1)} and ${wild.toFixed(1)}`);
    // it should threaten a carabiner (1.2 kN) once wind is added, not a shackle (3.2)
    assert(kNPerCorner(mid) > 0.25 && kNPerCorner(mid) < 1.2,
      `storm_03 ponds ${kNPerCorner(mid).toFixed(2)} kN/corner — should tease a cheap rig, not drown a decent one`);
  });

  test('rain depth integrates monotonically and matches its curve', () => {
    const f = createWindField(storms.storm_02_wildnight);
    // depth only ever accumulates
    let prev = 0;
    for (let t = 1; t <= f.duration; t += 1) {
      const d = f.rainDepthMm(0, t);
      assert(d >= prev - 1e-9, `rain depth went BACKWARDS at t=${t}: ${d} < ${prev}`);
      prev = d;
    }
    // splitting the interval must give the same water (no double-count, no gap)
    const whole = f.rainDepthMm(0, 90);
    const split = f.rainDepthMm(0, 30) + f.rainDepthMm(30, 60) + f.rainDepthMm(60, 90);
    assert(Math.abs(whole - split) < 0.05,
      `depth(0,90)=${whole.toFixed(3)} but the three thirds sum to ${split.toFixed(3)}`);
    // dead calm before the rain starts
    assert(f.rainDepthMm(0, 0) === 0, 'zero-length interval delivered water');
    assert(f.rainDepthMm(10, 5) === 0, 'a backwards interval delivered water');
  });

  test('rainMmPerHour tracks rainAt against the storm scale', () => {
    for (const [name, def] of defs) {
      if (!def.rain || !def.rain.curve) continue;
      const f = createWindField(def);
      const peak = def.rain.peakMmPerHour;
      assert(Number.isFinite(peak), `${name} has a rain curve but no peakMmPerHour`);
      for (let t = 0; t <= f.duration; t += 2.5) {
        const want = f.rainAt(t) * peak;
        assert(Math.abs(f.rainMmPerHour(t) - want) < 1e-9,
          `${name}: rainMmPerHour ${f.rainMmPerHour(t)} != rainAt×peak ${want} at t=${t}`);
      }
      // rainAt stays a 0..1 intensity — skyfx uses it for drop count and opacity
      for (let t = 0; t <= f.duration; t += 2.5) {
        const r = f.rainAt(t);
        assert(r >= 0 && r <= 1, `${name}: rainAt ${r} outside 0..1 at t=${t}`);
      }
    }
  });

  test('validator rejects a rain curve with no scale, and a silly scale', () => {
    const noScale = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
    delete noScale.rain.peakMmPerHour;
    assert(!validateStorm(noScale, 'x').ok, 'validator accepted a rain curve with no mm/hr scale — ponding would silently use the default');
    for (const mm of [-1, 5000, NaN]) {
      const d = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
      d.rain.peakMmPerHour = mm;
      assert(!validateStorm(d, 'x').ok, `validator accepted peakMmPerHour=${mm}`);
    }
    const hot = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
    hot.rain.curve = [[0, 0], [45, 3], [90, 0]];
    assert(!validateStorm(hot, 'x').ok, 'validator accepted rain intensity above 1 — the scale is peakMmPerHour, not the curve');
  });

  // ---- 11. hail (SPRINT5 decision 13) ----
  // The wind-side of hail: intensity timeline and its ramp across the campaign.
  // The garden-damage half (steep shadow, ≥2× rig response) needs skyfx +
  // SailRig, so it lives in c.test.js.
  test('hail ramps across the campaign: none / mild / a wild-night burst', () => {
    const peakAndArea = (def) => {
      const f = createWindField(def);
      let peak = 0, peakT = 0, area = 0;
      for (let t = 0; t <= f.duration; t += DT) {
        const h = f.hailAt(t);
        if (h > peak) { peak = h; peakT = t; }
        area += h * DT;
      }
      return { peak, peakT, area };
    };
    const g = peakAndArea(storms.storm_01_gentle);
    const m = peakAndArea(storms.storm_03_southerly);
    const w = peakAndArea(storms.storm_02_wildnight);
    metrics['storm_01.hailPeak'] = +g.peak.toFixed(2);
    metrics['storm_03.hailPeak'] = +m.peak.toFixed(2);
    metrics['storm_02.hailPeak'] = +w.peak.toFixed(2);
    metrics['storm_02.hailSeconds'] = +w.area.toFixed(1);

    assert(g.peak === 0, `the gentle storm hailed (${g.peak}) — storm_01 must have no hail`);
    assert(m.peak > 0.2 && m.peak < 0.8, `storm_03 hail peak ${m.peak.toFixed(2)} — wanted a mild middle rung`);
    assert(w.peak >= 0.95, `storm_02 hail peak only ${w.peak.toFixed(2)} — the wild night needs a proper burst`);
    assert(w.area > m.area * 3, 'the wild night should carry vastly more hail than the mild storm');
  });

  test('storm_02 hail lands ON the southerly change', () => {
    const change = (storms.storm_02_wildnight.events.find((e) => e.type === 'windchange')).t;
    const f = createWindField(storms.storm_02_wildnight);
    // find the authored-burst peak (near the change), not just any gust-synced tick
    let peak = 0, peakT = 0;
    for (let t = change - 3; t <= change + 8; t += DT) {
      const h = f.hailAt(t);
      if (h > peak) { peak = h; peakT = t; }
    }
    assert(peak >= 0.95, `hail near the change only reached ${peak.toFixed(2)}`);
    assert(Math.abs(peakT - change) < 6, `hail peaks at t=${peakT.toFixed(1)}, the change is at t=${change} — they should coincide`);
  });

  test('hail is silent through a gust telegraph and rides its own gust', () => {
    // the gust-synced bursts must not fire during the telegraph window, or hail
    // would arrive before the gust it belongs to — the opposite of the drama
    const def = storms.storm_02_wildnight;
    const f = createWindField(def);
    const thresh = def.hail.withGustsAbove;
    for (const g of f.gusts) {
      if (g.pow < thresh) continue;
      // during the telegraph (first GUST.TELEGRAPH s) the gust-synced part is 0.
      // an authored burst may still overlap, so only check gusts clear of t=55±8.
      if (Math.abs(g.t0 - 55) < 10) continue;
      for (let t = g.t0 + 0.05; t < g.t0 + GUST.TELEGRAPH; t += DT) {
        assert(f.hailAt(t) < 1e-9, `hail fell during a gust's telegraph at t=${t.toFixed(2)} — it should wait for the gust`);
      }
    }
  });

  test('hail does not re-time the storm (zero draws of its own)', () => {
    const base = storms.storm_02_wildnight;
    const a = createWindField(base);
    for (const gi of [0, 0.3, 0.85, 1]) {
      const d = JSON.parse(JSON.stringify(base));
      d.hail.gustBurstIntensity = gi;
      d.hail.bursts = [];       // even removing the authored burst mustn't move gusts
      const b = createWindField(d);
      assert(a.gusts.length === b.gusts.length, `hail change moved the gust count`);
      a.gusts.forEach((g, i) => {
        assert(g.t0 === b.gusts[i].t0 && g.pow === b.gusts[i].pow, `hail change re-timed gust ${i}`);
      });
      assert(a.speedAt(3, -2, 47.3) === b.speedAt(3, -2, 47.3), 'hail change altered the wind');
    }
  });

  test('validator rejects broken hail blocks', () => {
    const base = () => JSON.parse(JSON.stringify(storms.storm_02_wildnight));
    const cases = [
      ['size 0', (d) => { d.hail.size = 0; }],
      ['size huge', (d) => { d.hail.size = 20; }],
      ['intensity > 1', (d) => { d.hail.bursts[0].intensity = 1.5; }],
      ['negative fade', (d) => { d.hail.bursts[0].fade = -1; }],
      ['burst after the storm', (d) => { d.hail.bursts[0].t = 200; }],
      ['gustBurstIntensity > 1', (d) => { d.hail.gustBurstIntensity = 2; }],
      ['neither bursts nor gusts', (d) => { delete d.hail.bursts; delete d.hail.withGustsAbove; }],
    ];
    for (const [label, mutate] of cases) {
      const d = base(); mutate(d);
      assert(!validateStorm(d, 'broken').ok, `validator accepted broken hail: ${label}`);
    }
    // a hail-free storm (no block at all) must still validate
    const none = base(); delete none.hail;
    assert(validateStorm(none, 'nohail').ok, 'validator rejected a storm with no hail — hail is optional');
  });

  // ---- 12. forecast uncertainty (SPRINT6 §Lane C) ----
  // DESIGN.md's partial-information canon. The one rule that matters: a vague
  // forecast is fine, a WRONG one isn't. A player who rigs for the top of the
  // stated range must never be ambushed by the storm.
  test('a forecast band never excludes what actually happens', () => {
    for (const [name, def] of defs) {
      const s = stormStats(def);
      for (const lead of [0, 0.25, 0.5, 0.75, 1]) {
        const f = forecastFor(def, lead);
        const inside = (b, v, what) => assert(v >= b.lo - 1e-9 && v <= b.hi + 1e-9,
          `${name} @lead ${lead}: ${what} truth ${v.toFixed(2)} outside forecast ${b.lo.toFixed(2)}–${b.hi.toFixed(2)}`);
        inside(f.sustained, s.sustained, 'sustained');
        inside(f.gustPeak, s.gustPeak, 'gustPeak');
        inside(f.rain, s.rainPeak, 'rain');
        inside(f.hail.seconds, s.hailSeconds, 'hailSeconds');
        if (s.changeAt != null) inside(f.changeAt, s.changeAt, 'changeAt');
      }
    }
  });

  test('a forecast tightens to the truth as the night approaches', () => {
    const def = storms.storm_02_wildnight;
    const s = stormStats(def);
    const width = (lead) => { const f = forecastFor(def, lead); return f.gustPeak.hi - f.gustPeak.lo; };
    const far = width(1), mid = width(0.5), near = width(0);
    metrics['forecast.gustBandWidth@lead1'] = +far.toFixed(1);
    metrics['forecast.gustBandWidth@lead0.5'] = +mid.toFixed(1);
    assert(far > mid && mid > near, `band must narrow with lead: ${far.toFixed(1)} → ${mid.toFixed(1)} → ${near.toFixed(1)}`);
    assert(near === 0, `tonight's forecast should be exact, band is ${near.toFixed(2)} wide`);
    const f0 = forecastFor(def, 0);
    assert(f0.gustPeak.lo === s.gustPeak && f0.confidence === 1, 'lead 0 must report the truth with full confidence');
  });

  test('a forecast is deterministic — re-reading the card cannot reroll it', () => {
    const def = storms.storm_02_wildnight;
    for (const lead of [0.3, 0.8]) {
      const a = forecastFor(def, lead), b = forecastFor(def, lead);
      assert(a.gustPeak.lo === b.gustPeak.lo && a.gustPeak.hi === b.gustPeak.hi,
        'two reads of the same forecast disagreed');
    }
  });

  test('stormStats measures the storm, and beats estimating it from the def', () => {
    const def = storms.storm_02_wildnight;
    const s = stormStats(def);
    // what the card used to estimate: baseCurve peak + powBase + powRamp
    const estimate = Math.max(...def.baseCurve.map((p) => p[1]))
      + (def.gusts.powBase ?? 0) + (def.gusts.powRamp ?? 0);
    metrics['storm_02.gustPeak.measured'] = +s.gustPeak.toFixed(1);
    metrics['storm_02.gustPeak.oldEstimate'] = +estimate.toFixed(1);
    assert(Math.abs(s.gustPeak - estimate) > 1,
      'the estimate happens to match — this test is only interesting while they differ');
    assert(s.gustPeak > 30 && s.gustPeak < 35, `measured gust peak ${s.gustPeak.toFixed(1)} is not credible`);
    assert(s.changeAt === 55, `storm_02's change is at ${s.changeAt}, expected 55`);
  });

  test('the forecast hail hint tracks the storm, and hedges when far out', () => {
    assert(forecastFor(storms.storm_01_gentle, 0).hail.chance === 'none', 'gentle storm forecast hail');
    assert(forecastFor(storms.storm_02_wildnight, 0).hail.chance === 'likely', 'the wild night should forecast likely hail');
    assert(forecastFor(storms.storm_02_wildnight, 1).hail.chance === 'possible',
      'a week out, the wild night should only hedge at "possible"');
    assert(forecastFor(storms.storm_03_southerly, 0).hail.chance === 'possible', 'storm_03 hails mildly');
  });

  // ---- 13. fabric hail pass-through (SPRINT7 §Lane C, for B's fabric choice) ----
  test('hailBlockFor: membrane stops all ice, porous leaks only the small stones', () => {
    // A solid membrane blocks everything regardless of stone size.
    for (const size of [0.4, 0.7, 1.0, 1.3, 1.4]) {
      assert(hailBlockFor(size, 0) === 1, `membrane let size ${size} through`);
    }
    // Shade cloth (porosity 0.3) fully blocks the wild-night stones (1.3+) but
    // leaks the finest pea hail — the honest, size-gated difference.
    assert(hailBlockFor(1.3, 0.3) > 0.99, 'shade cloth failed to stop a 1.3 storm stone');
    assert(hailBlockFor(1.4, 0.3) > 0.99, 'shade cloth failed to stop a 1.4 ice-night stone');
    const pea = hailBlockFor(0.7, 0.3);
    assert(pea > 0.4 && pea < 0.9, `shade cloth blocks ${pea.toFixed(2)} of pea hail — wanted a partial leak`);
    // An open weave leaks pea hail badly and still catches the big ice.
    assert(hailBlockFor(0.7, 0.5) < 0.3, 'an open weave should let most pea hail through');
    assert(hailBlockFor(1.3, 0.5) > 0.8, 'even an open weave should mostly stop a 1.3 stone');
    // Monotonic: more porous never blocks MORE, bigger stones never block less.
    for (const size of [0.6, 1.0, 1.4]) {
      assert(hailBlockFor(size, 0.5) <= hailBlockFor(size, 0.3) + 1e-9, 'more porous blocked more hail');
    }
    for (const por of [0.3, 0.5]) {
      assert(hailBlockFor(0.5, por) <= hailBlockFor(1.2, por) + 1e-9, 'a bigger stone passed more easily');
    }
    metrics['fabric.shadecloth.blocksPeaHail'] = +pea.toFixed(2);
  });

  test('the fabric choice is real: porous costs garden on pea-hail nights, not ice nights', () => {
    // The integration formula B/A will wire (documented for them, proven here):
    //   coveredHail = hailShadowOver(bed) * hailBlockFor(hailSize, sail.porosity)
    //   exposure    = hailAt(t) * (1 - coveredHail)
    // A membrane over the bed protects it fully; a porous cloth over the same bed
    // protects it fully on an ice night and leaks on a pea-hail one. If those two
    // came out equal, the choice would be dead — this is the assert that it isn't.
    const gardenHail = (stormName, porosity) => {
      const def = storms[stormName];
      const f = createWindField(def);
      const size = f.hailSize;
      let dmg = 0;
      const COVER = 1;                       // bed fully under the cloth
      for (let t = 0; t <= f.duration; t += DT) {
        const covered = COVER * hailBlockFor(size, porosity);
        dmg += f.hailAt(t) * (1 - covered) * DT;
      }
      return dmg;
    };
    // ice night: membrane and shade cloth both fully protect a covered bed
    const iceMembrane = gardenHail('storm_02b_icenight', 0);
    const icePorous = gardenHail('storm_02b_icenight', 0.3);
    assert(Math.abs(iceMembrane - icePorous) < 0.2 && iceMembrane < 0.5,
      `on the ice night the fabrics should agree at ~0 (both block big ice): ${iceMembrane.toFixed(2)} vs ${icePorous.toFixed(2)}`);
    // pea-hail night: the covered bed under porous cloth takes real damage
    const peaMembrane = gardenHail('storm_03_southerly', 0);
    const peaPorous = gardenHail('storm_03_southerly', 0.3);
    metrics['fabric.storm03.gardenHail.membrane'] = +peaMembrane.toFixed(2);
    metrics['fabric.storm03.gardenHail.porous'] = +peaPorous.toFixed(2);
    assert(peaMembrane < 0.1, 'a membrane over the bed should take ~no pea hail');
    assert(peaPorous > peaMembrane + 0.3, `porous should leak real pea hail: ${peaPorous.toFixed(2)} vs ${peaMembrane.toFixed(2)}`);
  });

  return { cases, metrics };
}

/** Run every case and collect results. Used by run-node.mjs. */
export function runWeatherSuite(storms) {
  const { cases, metrics } = weatherCases(storms);
  const results = cases.map((c) => {
    try {
      c.fn();
      return { name: c.name, ok: true };
    } catch (e) {
      return { name: c.name, ok: false, err: e.message };
    }
  });
  const pass = results.filter((r) => r.ok).length;
  return { suite: 'weather', pass, fail: results.length - pass, results, metrics };
}
