/**
 * sail.selftest.js — assert suite for the sail sim.  [Lane B]
 *
 * Exports SAIL_TESTS as plain [name, fn] pairs so ONE set of asserts runs in
 * two harnesses: Lane A's selftest.html (via js/tests/b.test.js) and node
 * (`node web/world/js/sail.selftest.js`) for fast iteration without a browser.
 * Drives time with fixed-dt loops only — never rAF, never a clock.
 *
 * The headline assert is `hypar sheds load vs flat`: it is the game's thesis
 * stated as a test. If it ever goes red, the sail has stopped being a sail.
 */

import { SailRig } from './sail.js';
import { HARDWARE, FIXED_DT, createStubWind, rng } from './contracts.js';
import { createWindField, RAIN_TIME_COMPRESSION } from './weather.core.js';

const SIM_DT = FIXED_DT;

// ---------- real storm wind (SPRINT2 B-4) ----------
// The §7 gate used to run on the local stub, which is uniform, horizontal and
// tuned by nobody. These load the storms design actually ships and drive the
// cloth with them. weather.core.js is pure and import-free, so the same code
// path works in node and in Lane A's selftest.html; only reading the JSON off
// disk differs, and weather.js's own loadStorm can't help there (its STORM_DIR
// is a file:// URL under node, which fetch won't open).

async function loadStormDef(name) {
  const url = new URL(`../data/storms/${name}.json`, import.meta.url);
  if (typeof process !== 'undefined' && process.versions?.node) {
    const { readFile } = await import('node:fs/promises');
    return JSON.parse(await readFile(url, 'utf8'));
  }
  return (await fetch(url)).json();
}

const STORM_02 = await loadStormDef('storm_02_wildnight');

/** A Wind over a real storm def. Same field the game flies. */
function realWind(def = STORM_02, opts = {}) {
  const field = createWindField(def, opts);
  const out = { x: 0, y: 0, z: 0 };
  return {
    sample(pos, t) { return field.vecAt(pos.x, pos.z, t, out); },
    speedAt(t) { field.vecAt(0, 0, t, out); return Math.hypot(out.x, out.z); },
    gustTelegraph: (t) => field.gustTelegraph?.(t) ?? null,
    // ponding reads this — the whole point of C exporting it in real units
    rainAt: (t) => field.rainAt(t),
    rainMmPerHour: (t) => field.rainMmPerHour(t),
  };
}

/** Lane A's yard, verbatim (THREADS: "yard layout is now FACT"). */
const YARD = [
  ['h1', 'house', -5, 2.60, -9.9], ['h2', 'house', 0, 2.60, -9.9], ['h3', 'house', 5, 2.60, -9.9],
  ['t1', 'tree', -9, 3.22, 2], ['t2', 'tree', 8, 3.08, -2],
  ['p1', 'post', -4.9, 3.95, 5.9], ['p2', 'post', 4.3, 3.96, 6.5], ['p3', 'post', 0, 3.95, 7.6],
].map(([id, type, x, y, z]) => {
  const pos = { x, y, z };
  // Static on purpose: tree sway is world.js's, and mixing it in here would make
  // a cloth assert fail for a reason that isn't the cloth. Sway is exercised in
  // the game and in a.test.
  return { id, type, pos, sway: () => pos };
});

/**
 * SPRINT4 decision 11: §7's twisted rig re-pointed off the old 145 m² quad onto
 * a real one from A's decision-2 yard — 23 m², inside the 18-45 m² band, and the
 * most twisted quad the band offers. The old one was 6x too big, which is what
 * made it break under downdraft and made me call the bar unachievable.
 */
const TWISTED_QUAD = ['t1', 'p1', 'p2', 'p3'];

const yardRig = (ids, hw, tension) => {
  const r = new SailRig({ anchors: YARD, gridN: 10 });
  r.watchDivergence = true;
  return r.attach(ids, Array.isArray(hw) ? hw : Array(4).fill(hw), tension);
};

// ---------- deterministic stub wind ----------
// contracts.js ships createStubWind(), and the integration test below uses it.
// This local one exists only because the thesis needs the wind DIRECTION swept,
// which the shared stub does not expose. Lane C's weather.js replaces both.

function makeStubWind({ seed = 7, stormLen = 90, dir = { x: 0, y: 0, z: 1 }, calm = false } = {}) {
  const rand = rng(seed);
  const gusts = [];
  for (let t = 3; t < stormLen; t += 5 + rand() * 7) {
    gusts.push({ start: t, pow: 12 + rand() * 16 + 10 * (t / stormLen) });
  }
  const len = Math.hypot(dir.x, dir.y, dir.z) || 1;
  const dx = dir.x / len, dy = dir.y / len, dz = dir.z / len;
  const out = { x: 0, y: 0, z: 0 };

  return {
    speedAt(t) {
      if (calm) return 0;
      let speed = 8 + 26 * Math.min(1, (t / stormLen) * 1.6);
      for (const g of gusts) {
        const gt = t - g.start;
        if (gt < 0 || gt >= 5) continue;
        if (gt < 1.5) continue;                               // telegraph: seen, not felt
        else if (gt < 2.3) speed += g.pow * (gt - 1.5) / 0.8; // ramp
        else if (gt < 4.0) speed += g.pow;                    // hold
        else speed += g.pow * (5.0 - gt);                     // fade
      }
      return speed;
    },
    sample(pos, t) {
      const s = this.speedAt(t);
      out.x = dx * s; out.y = dy * s; out.z = dz * s;
      return out;
    },
    gustTelegraph: () => null,
  };
}

const constantWind = (v) => ({ sample: () => v, speedAt: () => Math.hypot(v.x, v.y, v.z), gustTelegraph: () => null });

// ---------- test rigs ----------
// Same 5x5 m footprint, same multiset of corner heights {4.0, 4.0, 2.5, 2.5}.
// Only the ARRANGEMENT differs: coplanar (flat, pitched) vs permuted (twisted
// hypar). Any load difference is therefore purely geometry, nothing else.

const FOOT = [
  { x: -2.5, z: -2.5 }, { x: 2.5, z: -2.5 }, { x: 2.5, z: 2.5 }, { x: -2.5, z: 2.5 },
];
export const HEIGHTS_FLAT = [4.0, 4.0, 2.5, 2.5];  // y linear in z -> one plane
export const HEIGHTS_HYPAR = [4.0, 2.5, 4.0, 2.5]; // opposite corners up/down -> saddle

/**
 * Anchors shaped like contracts.js Anchor: sway(t) is the ABSOLUTE position.
 * `theta` spins the footprint about the yard's Y axis — which is how you sweep
 * wind direction against a real storm, whose direction curve you don't get to
 * choose. Rotating the rig under the wind and rotating the wind over the rig are
 * the same experiment; only one of them is available with authored storm JSON.
 */
export const makeAnchors = (heights, theta = 0) =>
  FOOT.map((f, i) => {
    const c = Math.cos(theta), s = Math.sin(theta);
    const pos = { x: f.x * c - f.z * s, y: heights[i], z: f.x * s + f.z * c };
    return { id: `a${i}`, type: 'post', pos, sway: () => pos };
  });

const ALL_IDS = ['a0', 'a1', 'a2', 'a3'];

// A right-sized LEVEL sail — a 5x5 m carport roof, all corners at one height.
// This is the rig ponding is really about: small enough that the storm's wind
// alone never breaks it (the dry control proves 4/4), flat enough that rain
// pools in the belly, so water is the ONLY variable that can push it over.
// Measured: dry 4/4, wet loses a corner at t~85 s holding ~440 kg. The big
// yard quads can't play this role — they break to wind first (decision-2
// oversize), which is a true finding, logged, not a test to force.
const LEVEL_CARPORT = [3.2, 3.2, 3.2, 3.2];
const carportAnchors = (S = 2.5) =>
  [[-S, -S], [S, -S], [S, S], [-S, S]].map(([x, z], i) => {
    const pos = { x, y: LEVEL_CARPORT[i], z };
    return { id: `a${i}`, type: 'post', pos, sway: () => pos };
  });
const carportRig = (hw) => {
  const r = new SailRig({ anchors: carportAnchors(), gridN: 10 });
  r.watchDivergence = true;
  return r.attach(ALL_IDS, Array(4).fill(hw), 1.0);
};
const UNBREAKABLE = { name: 'test rig', cost: 0, rating: Infinity };

function rig(heights, { hw = UNBREAKABLE, tension = 1.0, porosity = 0 } = {}) {
  const r = new SailRig({ anchors: makeAnchors(heights), gridN: 10, porosity });
  r.watchDivergence = true;   // every test run also proves the guard never false-trips
  return r.attach(ALL_IDS, [hw, hw, hw, hw], tension);
}

/** Fixed-dt fast-forward. Returns the peak corner load over the whole run, N. */
function runStorm(r, wind, secs, onStep) {
  const steps = Math.round(secs / SIM_DT);
  let peak = 0;
  for (let i = 0; i < steps; i++) {
    r.step(SIM_DT, wind, i * SIM_DT);
    const m = r.maxLoad();
    if (m > peak) peak = m;
    if (onStep) onStep(r, i);
  }
  return peak;
}

const TESTS = [];
const test = (name, fn) => TESTS.push([name, fn]);
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
const kN = (n) => `${(n / 1000).toFixed(2)} kN`;

// ---------- the suite ----------

test('sim stays finite through a full storm', () => {
  const r = rig(HEIGHTS_HYPAR);
  runStorm(r, makeStubWind({ stormLen: 90 }), 90);
  for (const v of r.pos) assert(Number.isFinite(v), 'node position went NaN/Infinity');
  for (const c of r.corners) assert(Number.isFinite(c.load), 'corner load went NaN');
  return `peak ${kN(r.corners.reduce((m, c) => Math.max(m, c.peakLoad), 0))}`;
});

test('divergence BREAKS on the production path — a NaN corner cannot hold forever', () => {
  // watchDivergence stays OFF here on purpose: that tripwire is test-only, and
  // this test is about the SHIPPED path. Before the guard in _checkFailure,
  // `load > rating` was false for NaN — a diverged corner never broke, never
  // recovered, and the sail froze silently while NaN ate the spring network.
  const r = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
  r.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0);
  const broke = [];
  r.events.on('break', (e) => broke.push(e));
  const w = makeStubWind({ stormLen: 90 });
  runStorm(r, w, 2);
  assert(broke.length === 0, 'rig should be healthy before the poison');
  // Poison one interior node — the shape a solver blow-up leaves behind.
  r.pos[(r.N + 1) * 3 + 1] = NaN;
  runStorm(r, w, 2);
  assert(broke.some((e) => e.reason === 'divergence'),
    `NaN spread through the cloth and no corner broke by divergence — the silent freeze is back (${broke.length} breaks)`);
  const b = r.corners.find((c) => c.broken);
  assert(b && b.load === 0, 'a corner broken by divergence must read load 0, not NaN, for the HUD');
  return `${broke.filter((e) => e.reason === 'divergence').length}/4 corners let go by divergence, HUD reads 0`;
});

test('ponding: consolidation cannot stack one node past the cap (post-flow clamp)', () => {
  // The rain-add clamp only ran WHILE raining; once rain stopped, downhill
  // flow could pile a basin node arbitrarily high and nothing trimmed it.
  // Inject an absurd pond onto the settled belly and take one step: the
  // clamp must shed it — and it must be the CLAMP doing it, not a belly
  // tear, or the assert is measuring the wrong mechanism.
  const r = carportRig(UNBREAKABLE);
  const still = { sample: (_p, _t, out) => (out ? out.set(0, 0, 0) : { x: 0, y: 0, z: 0 }), rainMmPerHour: () => 0 };
  runStorm(r, still, 5);                          // settle: the belly is the basin
  const dumps = [];
  r.events.on('pondDump', (e) => dumps.push(e));
  const belly = Math.floor(r.N / 2) * r.N + Math.floor(r.N / 2);
  r.water[belly] = 2000;                          // ~7x any sane per-node cap
  r.step(SIM_DT, still, 5 + SIM_DT);
  assert(dumps.length === 0, `the belly tore (${dumps[0]?.reason}) — reduce the injection, this test is about the clamp`);
  assert(r.water[belly] < 400,
    `node ${belly} still holds ${r.water[belly].toFixed(0)} kg after a step — the per-node cap is not enforced once rain stops`);
  return `injected 2000 kg, one step later the node holds ${r.water[belly].toFixed(0)} kg, no tear`;
});

test('sail sags under gravity when calm', () => {
  const r = rig(HEIGHTS_FLAT);
  runStorm(r, makeStubWind({ calm: true }), 6);
  const N = r.N, mid = (Math.floor(N / 2) * N + Math.floor(N / 2)) * 3;
  const midY = r.pos[mid + 1];
  const cornerMeanY = HEIGHTS_FLAT.reduce((a, b) => a + b) / 4;
  assert(midY < cornerMeanY, `belly (${midY.toFixed(2)}m) should hang below corner mean (${cornerMeanY}m)`);
  return `belly sags ${(cornerMeanY - midY).toFixed(2)} m below corner plane`;
});

// Newton's third law. This is what pins FABRIC_K to real newtons: if the corner
// reactions don't sum to the actual aerodynamic + weight force on the fabric,
// the load meter is lying and every kN rating on it is meaningless.
test('statics: corner reactions balance the applied force', () => {
  const w = constantWind({ x: 0, y: 0, z: 18 });
  const r = rig(HEIGHTS_FLAT);
  runStorm(r, w, 12); // settle
  // A membrane in steady wind never fully stops moving, so compare the
  // TIME-AVERAGED reaction against the time-averaged applied force. That is the
  // momentum balance that must hold; instant by instant it need not.
  let n = 0, ax = 0, ay = 0, az = 0, rx = 0, ry = 0, rz = 0;
  for (let i = 0; i < Math.round(4 / SIM_DT); i++) {
    const t = 12 + i * SIM_DT;
    r.step(SIM_DT, w, t);
    const f = r.netAppliedForce(w, t);
    ax += f.x; ay += f.y; az += f.z;
    for (const c of r.corners) { rx += c.loadVec.x; ry += c.loadVec.y; rz += c.loadVec.z; }
    n++;
  }
  ax /= n; ay /= n; az /= n; rx /= n; ry /= n; rz /= n;
  const appliedMag = Math.hypot(ax, ay, az);
  const err = Math.hypot(rx - ax, ry - ay, rz - az) / appliedMag;
  assert(err < 0.2, `reactions ${kN(Math.hypot(rx, ry, rz))} vs applied ${kN(appliedMag)} — ${(err * 100).toFixed(0)}% out of balance`);
  return `applied ${kN(appliedMag)}, reactions ${kN(Math.hypot(rx, ry, rz))}, residual ${(err * 100).toFixed(1)}%`;
});

// THE THESIS. A twisted sail resists bellying into one coherent pocket, so its
// worst moment is gentler than a flat sail's worst moment.
//
// Scored on WORST CASE over wind direction, not per-direction. Lane C's storms
// veer, so the player never gets to choose the wind, and worst-case is what the
// hardware actually has to survive. Per-direction would be a false assert: a
// flat sail sitting edge-on to the wind genuinely does have low drag, and from
// that one angle it beats the hypar. Demanding otherwise would mean tuning the
// sim into a lie.
test('hypar sheds load vs flat, worst case over wind direction (the thesis)', () => {
  const DIRS = [
    { name: 'N', x: 0, z: 1 }, { name: 'NE', x: 0.707, z: 0.707 },
    { name: 'E', x: 1, z: 0 }, { name: 'SE', x: 0.707, z: -0.707 },
    { name: 'S', x: 0, z: -1 }, { name: 'SW', x: -0.707, z: -0.707 },
    { name: 'W', x: -1, z: 0 }, { name: 'NW', x: -0.707, z: 0.707 },
  ];
  const sweep = (heights) => {
    let worst = 0, at = '';
    for (const d of DIRS) {
      const storm = makeStubWind({ seed: 7, stormLen: 45, dir: { x: d.x, y: 0, z: d.z } });
      const p = runStorm(rig(heights), storm, 45);
      if (p > worst) { worst = p; at = d.name; }
    }
    return { worst, at };
  };
  const flat = sweep(HEIGHTS_FLAT);
  const hypar = sweep(HEIGHTS_HYPAR);
  assert(
    hypar.worst < flat.worst * 0.8,
    `hypar worst ${kN(hypar.worst)} (${hypar.at}) should be well under flat worst ${kN(flat.worst)} (${flat.at})`
  );
  return `flat worst ${kN(flat.worst)} from ${flat.at} -> hypar worst ${kN(hypar.worst)} from ${hypar.at} (sheds ${((1 - hypar.worst / flat.worst) * 100).toFixed(0)}%)`;
});

test('cascade: losing a corner spikes its neighbours', () => {
  const w = constantWind({ x: 0, y: 0, z: 22 });
  const r = rig(HEIGHTS_HYPAR);
  runStorm(r, w, 6); // settle
  const before = Math.max(r.corners[1].load, r.corners[3].load);
  r.corners[0].broken = true;
  r._repin(r.t);
  runStorm(r, w, 2.5); // let the load redistribute
  const after = Math.max(r.corners[1].load, r.corners[3].load);
  assert(after >= before * 2, `neighbour went ${kN(before)} -> ${kN(after)}, wanted >= 2x`);
  return `neighbour ${kN(before)} -> ${kN(after)} (${(after / before).toFixed(1)}x)`;
});

test('determinism: identical inputs give byte-equal load traces', () => {
  const trace = () => {
    const r = rig(HEIGHTS_HYPAR);
    const w = makeStubWind({ seed: 3, stormLen: 30 });
    const out = [];
    runStorm(r, w, 30, (rr) => { for (const c of rr.corners) out.push(c.load); });
    return out;
  };
  const a = trace(), b = trace();
  assert(a.length === b.length, 'traces differ in length');
  for (let i = 0; i < a.length; i++) assert(a[i] === b[i], `sample ${i} diverged: ${a[i]} vs ${b[i]}`);
  return `${a.length} load samples identical`;
});

test('determinism: variable frame dt matches fixed dt', () => {
  // Lane A's render loop delivers ragged dt. The internal accumulator has to
  // absorb that, or nothing the selftest proves applies to the real game.
  const w1 = makeStubWind({ seed: 5, stormLen: 20 });
  const fixed = rig(HEIGHTS_HYPAR);
  for (let i = 0; i < Math.round(20 / SIM_DT); i++) fixed.step(SIM_DT, w1, i * SIM_DT);

  const w2 = makeStubWind({ seed: 5, stormLen: 20 });
  const ragged = rig(HEIGHTS_HYPAR);
  const rand = rng(99);
  let acc = 0;
  while (acc < 20) {
    const dt = 0.004 + rand() * 0.02; // 4-24 ms frames
    ragged.step(dt, w2, acc);
    acc += dt;
  }
  for (let k = 0; k < 4; k++) {
    const d = Math.abs(fixed.corners[k].load - ragged.corners[k].load);
    assert(d < 1e-6, `corner ${k} drifted ${d.toFixed(6)} N between fixed and ragged dt`);
  }
  return 'ragged frame times converge on the fixed-dt trace';
});

test('re-attach resets the clock: night 2 flies the same storm as night 1', () => {
  // SPRINT13. main.js builds ONE SailRig at boot and re-attach()es it every
  // night, and step() runs in EVERY phase once rigged — so the clock ran on
  // through the aftermath and the forecast card, and night 2 opened its storm
  // wherever night 1 left off, sampling the curve past its own end. The offset
  // was however long the player read the invoice for, so it wasn't even the
  // same wrong storm twice.
  //
  // Asserted as the PROPERTY that broke rather than `t === 0`: two identical
  // nights on one rig must produce identical load traces. That stays true if
  // the clock is ever reset somewhere else, and it goes red the moment the
  // reset leaves attach() — which is the mutation-check (delete `this.t = 0`
  // from attach and the second trace diverges on sample 0).
  const trace = (r) => {
    const w = makeStubWind({ seed: 7, stormLen: 30 });
    const out = [];
    runStorm(r, w, 30, (rr) => { for (const c of rr.corners) out.push(c.load); });
    return out;
  };
  const r = rig(HEIGHTS_HYPAR);
  const night1 = trace(r);

  // the aftermath: the sail is still up and still being stepped while the
  // player reads their invoice. This is the frame budget that used to poison
  // the next night.
  const calm = makeStubWind({ seed: 7, stormLen: 30 });
  for (let i = 0; i < Math.round(20 / SIM_DT); i++) r.step(SIM_DT, calm, i * SIM_DT);

  r.attach(ALL_IDS, Array(4).fill(UNBREAKABLE), 1.0);   // night 2, same rig object
  const night2 = trace(r);

  assert(night1.length === night2.length, `night 1 traced ${night1.length} samples, night 2 ${night2.length}`);
  for (let i = 0; i < night1.length; i++) {
    assert(night1[i] === night2[i],
      `night 2 diverged from night 1 at sample ${i}: ${night1[i]} vs ${night2[i]} — ` +
      'the rig carried its clock across attach(), so night 2 is flying a different storm ' +
      '(and one that depends on how long the aftermath was on screen)');
  }
  return `${night1.length} load samples identical across a re-attach — same storm both nights`;
});

test('tension dial changes load (drum tight shock-loads)', () => {
  const w = constantWind({ x: 0, y: 0, z: 20 });
  const loosePeak = runStorm(rig(HEIGHTS_HYPAR, { tension: 0.7 }), w, 8);
  const tightPeak = runStorm(rig(HEIGHTS_HYPAR, { tension: 1.35 }), w, 8);
  assert(tightPeak > loosePeak, `tight ${kN(tightPeak)} should exceed loose ${kN(loosePeak)}`);
  return `loose ${kN(loosePeak)} vs tight ${kN(tightPeak)}`;
});

test('porous shade cloth carries less load than solid membrane', () => {
  const w = constantWind({ x: 0, y: 0, z: 20 });
  const solid = runStorm(rig(HEIGHTS_HYPAR, { porosity: 0 }), w, 8);
  const porous = runStorm(rig(HEIGHTS_HYPAR, { porosity: 0.35 }), w, 8);
  assert(porous < solid, `porous ${kN(porous)} should be under solid ${kN(solid)}`);
  return `solid ${kN(solid)} vs porous ${kN(porous)}`;
});

test('coverage: sail shades the ground under it, not beside it', () => {
  const r = rig(HEIGHTS_FLAT);
  runStorm(r, makeStubWind({ calm: true }), 4);
  // world.gardenBed rects are CENTRE + size, so this bed straddles the origin.
  const under = r.coverageOver({ x: 0, z: 0, w: 4, d: 4 });
  const beside = r.coverageOver({ x: 14, z: 14, w: 4, d: 4 });
  assert(under > 0.9, `ground under the sail only ${(under * 100).toFixed(0)}% shaded`);
  assert(beside === 0, `ground 14 m away reported ${(beside * 100).toFixed(0)}% shaded`);
  return `under sail ${(under * 100).toFixed(0)}%, off to the side ${(beside * 100).toFixed(0)}%`;
});

test('coverage tracks a low sun off to the side', () => {
  const r = rig(HEIGHTS_FLAT);
  runStorm(r, makeStubWind({ calm: true }), 4);
  const noon = r.coverageOver({ x: 0, z: 0, w: 4, d: 4 }, { x: 0, y: 1, z: 0 });
  const lowSun = r.coverageOver({ x: 0, z: 0, w: 4, d: 4 }, { x: 0.9, y: 0.25, z: 0 });
  assert(noon > lowSun, `shadow should slide off the bed as the sun drops (noon ${noon}, low ${lowSun})`);
  return `noon ${(noon * 100).toFixed(0)}% -> low sun ${(lowSun * 100).toFixed(0)}%`;
});

// PLAN3D §7 definition of done, in miniature.
test('cheap flat rig cascades; twisted mixed rig survives', () => {
  const storm = () => makeStubWind({ seed: 11, stormLen: 90 });
  const cheap = rig(HEIGHTS_FLAT, { hw: HARDWARE[0], tension: 1.35 });
  runStorm(cheap, storm(), 90);
  const cheapBroken = cheap.corners.filter((c) => c.broken).length;

  const good = rig(HEIGHTS_HYPAR, { hw: HARDWARE[2], tension: 0.95 });
  runStorm(good, storm(), 90);
  const goodBroken = good.corners.filter((c) => c.broken).length;

  assert(cheapBroken >= 2, `flat drum-tight carabiner rig only lost ${cheapBroken} corners — should cascade`);
  assert(goodBroken === 0, `twisted rated-shackle rig lost ${goodBroken} corners — should survive`);
  return `cheap flat lost ${cheapBroken}/4, good hypar lost ${goodBroken}/4`;
});

// PLAN3D §5-B: "broken corner frees the node -> flogging is emergent". This
// drives a REAL overload failure rather than setting broken by hand, because
// hand-setting it was exactly what hid the bug where _checkFailure marked a
// corner broken but never gave its node its mass back — so a blown corner
// stayed welded in mid-air and the sail never flogged.
test('a blown corner is freed and flies (flogging is emergent)', () => {
  const w = makeStubWind({ seed: 11, stormLen: 90 });
  const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[0], tension: 1.3 }); // cheap and tight: this one lets go
  const broke = [];
  r.events.on('break', (e) => broke.push(e));

  // step until the first corner lets go
  let i = 0;
  for (const end = Math.round(90 / SIM_DT); i < end && !broke.length; i++) r.step(SIM_DT, w, i * SIM_DT);
  assert(broke.length > 0, 'a carabiner rig should have blown a corner somewhere in a 90 s storm');

  const k = r.corners.indexOf(broke[0].corner);
  const node = r.cornerIdx[k], ci = node * 3;
  const anchor = r.corners[k].anchor.pos;
  const before = [r.pos[ci], r.pos[ci + 1], r.pos[ci + 2]];
  for (let j = 0; j < Math.round(3 / SIM_DT); j++) r.step(SIM_DT, w, (i + j) * SIM_DT);

  const moved = Math.hypot(r.pos[ci] - before[0], r.pos[ci + 1] - before[1], r.pos[ci + 2] - before[2]);
  const fromAnchor = Math.hypot(r.pos[ci] - anchor.x, r.pos[ci + 1] - anchor.y, r.pos[ci + 2] - anchor.z);

  assert(r.invMass[node] > 0, 'blown corner still has infinite mass — it is welded in mid-air, not flogging');
  assert(moved > 0.05, `blown corner only drifted ${moved.toFixed(3)} m in 3 s — it is not flogging`);
  assert(fromAnchor > 0.2, `blown corner is still ${fromAnchor.toFixed(2)} m from its anchor — it never let go`);
  return `corner ${broke[0].anchorId} blew at t=${broke[0].t.toFixed(1)}s, tore ${fromAnchor.toFixed(2)} m off its anchor and is flying`;
});

test('break and repair emit on the events Emitter', () => {
  const w = constantWind({ x: 0, y: 0, z: 20 });
  const r = rig(HEIGHTS_HYPAR, { hw: UNBREAKABLE });
  const seen = [];
  r.events.on('break', (e) => seen.push(e));
  r.events.on('repair', (e) => seen.push(e));

  runStorm(r, w, 4);
  r.corners[0].broken = true;
  r._repin(r.t);
  runStorm(r, w, 1);
  assert(r.corners[0].load === 0, 'broken corner should carry no load');

  assert(r.repairCorner(0, UNBREAKABLE), 'repairCorner should report success');
  runStorm(r, w, 3);
  assert(r.corners[0].load > 100, `repaired corner only pulling ${kN(r.corners[0].load)}`);
  assert(seen.some((e) => e.type === 'repair' && e.corner === r.corners[0]), 'no repair event with {type, corner}');
  return `repaired corner back to ${kN(r.corners[0].load)}, ${seen.length} event(s) emitted`;
});

// --- SPRINT2 decision 4: the seam Lane D already calls ---------------------

test('decision 4: repair(i) re-rigs a blown corner with the spare', () => {
  const w = constantWind({ x: 0, y: 0, z: 20 });
  const r = rig(HEIGHTS_HYPAR, { hw: HARDWARE[0] });
  runStorm(r, w, 4);
  r.corners[0].broken = true;
  r._repin(r.t);

  // exactly Lane D's interact.js call: no hardware argument, return ignored
  r.repair(0);
  assert(!r.corners[0].broken, 'repair(0) should have re-rigged the corner');
  assert(r.corners[0].hw === HARDWARE[1], `spare should re-rig at shackle grade, got ${r.corners[0].hw.name}`);
  assert(r.invMass[r.cornerIdx[0]] === 0, 'repaired corner should be pinned again');
  runStorm(r, w, 3);
  assert(r.corners[0].load > 100, `repaired corner only pulling ${kN(r.corners[0].load)}`);
  return `repair(0) -> ${r.corners[0].hw.name}, back to ${kN(r.corners[0].load)}`;
});

test('decision 4: repair(i) on an intact corner is a no-op', () => {
  const r = rig(HEIGHTS_HYPAR, { hw: HARDWARE[2] });
  runStorm(r, constantWind({ x: 0, y: 0, z: 12 }), 2);
  const hw = r.corners[1].hw;
  r.repair(1); // D gates on corner.broken, but the rig must not trust that
  assert(r.corners[1].hw === hw, 'repairing an intact corner downgraded its hardware');
  return 'intact corner untouched';
});

test('decision 4: trim(i, delta) tightens one corner only', () => {
  const r = rig(HEIGHTS_HYPAR);
  r.trim(0, +0.1);
  assert(Math.abs(r.corners[0].trim - 1.1) < 1e-9, `corner 0 trim ${r.corners[0].trim}`);
  assert(r.corners[1].trim === 1.0, 'trim leaked onto a neighbour');
  for (let i = 0; i < 40; i++) r.trim(0, +0.1); // Lane D can hold the key down
  assert(r.corners[0].trim <= 1.15 + 1e-9, `trim ran past its clamp: ${r.corners[0].trim}`);
  return `trim clamps at ${r.corners[0].trim.toFixed(2)}, neighbours unmoved`;
});

test('decision 4: cornerPos(i) is live, fresh, and chases a flogging corner', () => {
  const w = makeStubWind({ seed: 11, stormLen: 90 });
  const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[0], tension: 1.3 });
  const anchor = r.corners[0].anchor.pos;
  const p0 = r.cornerPos(0);
  assert(Math.hypot(p0.x - anchor.x, p0.y - anchor.y, p0.z - anchor.z) < 1e-6,
    'an intact corner should report its anchor position');
  assert(r.cornerPos(0) !== r.cornerPos(0), 'cornerPos must return a FRESH vector, not shared scratch');

  // blow it, then confirm the prompt would follow the flying corner
  r.corners[0].broken = true;
  r._repin(r.t);
  runStorm(r, w, 6);
  const p1 = r.cornerPos(0);
  const drift = Math.hypot(p1.x - anchor.x, p1.y - anchor.y, p1.z - anchor.z);
  assert(drift > 0.3, `blown corner's prompt only moved ${drift.toFixed(2)} m off the anchor`);
  assert(new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT) }).cornerPos(0) === null,
    'cornerPos on an unrigged rig should be null, not a throw');
  return `prompt tracks the blown corner ${drift.toFixed(2)} m off its anchor`;
});

// --- SPRINT2 decision 5: debris -------------------------------------------

const crate = (over) => ({ x: 0, y: 3.25, z: 0, vx: 0, vy: 0, vz: 14, r: 0.3, mass: 9, alive: true, ...over });

test('decision 5: a crate hitting the sail conserves momentum', () => {
  const r = rig(HEIGHTS_FLAT);
  runStorm(r, makeStubWind({ calm: true }), 4); // settle, so the cloth isn't ringing
  // aimed at the belly, not a corner: a pinned corner would (correctly) dump
  // momentum into the house and there'd be nothing to conserve
  const mid = r.N * Math.floor(r.N / 2) + Math.floor(r.N / 2);
  const p = crate({ x: r.pos[mid * 3], y: r.pos[mid * 3 + 1] - 0.25, z: r.pos[mid * 3 + 2], vy: 6, vz: 0 });

  const clothP = () => {
    let x = 0, y = 0, z = 0;
    for (let n = 0; n < r.invMass.length; n++) {
      if (r.invMass[n] === 0) continue; // pinned: its momentum belongs to the house
      const i = n * 3;
      x += (r.pos[i] - r.prev[i]) / SIM_DT * r.nodeMass;
      y += (r.pos[i + 1] - r.prev[i + 1]) / SIM_DT * r.nodeMass;
      z += (r.pos[i + 2] - r.prev[i + 2]) / SIM_DT * r.nodeMass;
    }
    return { x, y, z };
  };
  const total = () => {
    const c = clothP();
    return { x: c.x + p.vx * p.mass, y: c.y + p.vy * p.mass, z: c.z + p.vz * p.mass };
  };

  const before = total();
  r._applyDebris([p], SIM_DT);
  const after = total();

  const drift = Math.hypot(after.x - before.x, after.y - before.y, after.z - before.z);
  const scale = Math.hypot(before.x, before.y, before.z);
  assert(scale > 1, 'test crate carries no momentum to conserve');
  assert(drift / scale < 0.01, `momentum drifted ${drift.toFixed(3)} of ${scale.toFixed(1)} kg·m/s (${(drift / scale * 100).toFixed(1)}%)`);
  assert(p.vy < 6, `the crate should have LOST speed to the cloth, still at ${p.vy.toFixed(2)} m/s`);
  return `crate ${scale.toFixed(0)} kg·m/s, exchange conserves to ${(drift / scale * 100).toFixed(3)}%`;
});

test('decision 5: a crate through the sail shoves the cloth and emits', () => {
  const r = rig(HEIGHTS_FLAT);
  runStorm(r, makeStubWind({ calm: true }), 4);
  const hits = [];
  r.events.on('debrisHit', (e) => hits.push(e));

  const mid = r.N * Math.floor(r.N / 2) + Math.floor(r.N / 2);
  const before = r.pos[mid * 3 + 1];
  const p = crate({ x: r.pos[mid * 3], y: r.pos[mid * 3 + 1] - 0.6, z: r.pos[mid * 3 + 2], vy: 12, vz: 0 });
  const v0 = p.vy;

  // Peak, not final: the crate crosses the cloth in about three frames and the
  // membrane springs back well inside the run, so sampling the end measures the
  // recovery rather than the punch.
  const wind = makeStubWind({ calm: true });
  let peak = before;
  for (let i = 0; i < 30; i++) {
    r.step(SIM_DT, wind, i * SIM_DT, { pieces: [p] });
    p.y += p.vy * SIM_DT; p.z += p.vz * SIM_DT;
    peak = Math.max(peak, r.pos[mid * 3 + 1]);
  }
  assert(hits.length > 0, 'crate passed through the cloth without a single contact');
  assert(peak > before + 0.05, `belly only lifted ${(peak - before).toFixed(3)} m — the crate went straight through`);
  assert(p.vy < v0, `crate left at ${p.vy.toFixed(2)} m/s, never paid for the punch (entered at ${v0})`);
  return `${hits.length} contacts, belly punched ${(peak - before).toFixed(2)} m, crate ${v0} -> ${p.vy.toFixed(1)} m/s`;
});

test('decision 5: no debris and empty debris are both fine', () => {
  const w = makeStubWind({ seed: 2, stormLen: 20 });
  const a = rig(HEIGHTS_HYPAR), b = rig(HEIGHTS_HYPAR);
  for (let i = 0; i < 600; i++) {
    a.step(SIM_DT, w, i * SIM_DT);                    // Lane A's 3-arg call still works
    b.step(SIM_DT, makeStubWind({ seed: 2, stormLen: 20 }), i * SIM_DT, { pieces: [] });
  }
  for (let k = 0; k < 4; k++) {
    assert(Math.abs(a.corners[k].load - b.corners[k].load) < 1e-9,
      'an empty debris list changed the sim');
  }
  return 'empty and absent debris both no-op';
});

// --- SPRINT2 B-4: the §7 gate, against the wind the game actually flies ------

// PLAN3D §7: "A flat drum-tight cheap rig MUST cascade-fail in storm_02; a
// well-twisted mixed rig with one mid-storm repair MUST be survivable." The old
// version of this proved it against my own stub wind, which is uniform,
// horizontal and tuned by nobody — so it proved the cloth was self-consistent,
// not that the game works. This is the real storm JSON, the real yard, and the
// same two rig shapes Lane C measured decision 3 against.
test('§7 gate on REAL storm_02: cheap flat rig cascades', () => {
  const rig = yardRig(['h1', 'h3', 'p2', 'p1'], HARDWARE[0], 1.3); // drum-tight carabiners
  const broke = [];
  rig.events.on('break', (e) => broke.push(e));
  const w = realWind();
  for (let i = 0; i < Math.round(STORM_02.duration / SIM_DT); i++) rig.step(SIM_DT, w, i * SIM_DT);
  const lost = rig.corners.filter((c) => c.broken).length;
  assert(lost >= 2, `flat drum-tight carabiner rig only lost ${lost}/4 in the real storm_02 — no cascade`);
  return `lost ${lost}/4, first at t=${broke[0].t.toFixed(1)}s (${broke[0].anchorId}, ${broke[0].hw})`;
});

test('§7 gate on REAL storm_02: twisted mixed rig survives', () => {
  // Lane C's shape: h1 (house, 2.6) / t2 (tree, 3.1) / p1 (post, 3.9) / t1 (tree, 3.2)
  // — corners at four different heights, i.e. an actual hypar, eased off tight.
  const rig = yardRig(TWISTED_QUAD, [HARDWARE[2], HARDWARE[1], HARDWARE[2], HARDWARE[1]], 0.85);
  const w = realWind();
  let peak = 0;
  for (let i = 0; i < Math.round(STORM_02.duration / SIM_DT); i++) {
    rig.step(SIM_DT, w, i * SIM_DT);
    peak = Math.max(peak, rig.maxLoad());
  }
  const lost = rig.corners.filter((c) => c.broken).length;
  assert(lost === 0, `well-twisted mixed rig lost ${lost}/4 in storm_02 — §7 says it must be survivable`);
  return `all 4 corners held, peak ${kN(peak)} (area ${rig.area.toFixed(0)} m2)`;
});

test('§7 gate on REAL storm_02: twisted rig + one repair on the dodgy corner', () => {
  // The other half of §7: "a well-twisted mixed rig with ONE mid-storm repair
  // MUST be survivable". The twisted rig above already survives outright, so
  // the interesting scenario is DESIGN.md's: the budget forces one dodgy corner
  // ($80 buys rated on at most two of four), that corner blows, and you run out
  // and re-rig it once with the carried spare — exactly Lane D's hold-E.
  // An $80-exact loadout on the decision-11 quad: rated t1 ($30) + shackle p1
  // ($15) + carabiner p2 ($5) + shackle p3 ($15) + spare ($15).
  //
  // The carabiner goes on p2 because that is where the load actually IS —
  // measured peaks on this quad are t1 2.43 / p2 2.35 / p3 0.82 / p1 0.60 kN.
  // Hanging the cheap corner on p1 (the lightest) is what a player does by
  // accident: it rides the whole storm out and proves nothing. p2 is the real
  // bet, and it's the one that has to blow for this test to mean anything.
  const rig = yardRig(
    TWISTED_QUAD,                                             // ['t1','p1','p2','p3']
    [HARDWARE[2], HARDWARE[1], HARDWARE[0], HARDWARE[1]],
    0.85,
  );
  const w = realWind();
  let repairs = 0;
  rig.events.on('break', () => { /* seen below; repairing inside the emit would reenter step */ });
  for (let i = 0; i < Math.round(STORM_02.duration / SIM_DT); i++) {
    rig.step(SIM_DT, w, i * SIM_DT);
    if (repairs === 0) {
      const k = rig.corners.findIndex((c) => c.broken);
      if (k >= 0) { rig.repair(k); repairs++; }
    }
  }
  const lost = rig.corners.filter((c) => c.broken).length;
  if (repairs === 0) {
    // A vacuous pass is worse than a skip: "nothing broke" would let this go
    // green forever while proving nothing. Storm_02 can't threaten a shackle
    // rig until Lane C's downdraft lands (their A/B: shackle blows at t=20.8 s
    // with downdraft 0.3, never without). Lights up by itself on merge.
    // Decision 11 landed the downdraft for real, so there is no longer an excuse
    // for nothing breaking: a vacuous pass here would mean the §7 repair leg —
    // the sprint's whole definition of done — is checking nothing.
    assert(false, 'nothing blew, so the repair scenario proved nothing — the dodgy corner is not on a loaded corner');
  }
  assert(lost <= 1, `after one repair the rig still lost ${lost}/4 — not survivable`);
  return `${repairs} repair, finished ${4 - lost}/4 corners intact`;
});

// --- SPRINT2 decision 3 / B-6: the flat-horizontal loophole ------------------

// Sprint 1 finding: a flat HORIZONTAL sail was the lowest-load rig of all,
// because a horizontal plate in horizontal wind has almost no drag — which
// inverted DESIGN.md's "big, flat, low = death in a storm". Lane C closed it by
// making the wind descend (decision 8: a fraction of TOTAL speed, present
// whenever it's windy).
//
// THE REFERENCE RIG IS THE WHOLE TEST, and getting it wrong is what made me
// declare this bar unachievable for two sprints (SPRINT3 [B], and I was wrong).
// The ratio is `(f / (sin p + cos p·f))²` for reference pitch p, so it depends
// on p far more than on the downdraft:
//
//     pitch        f=0.12   f=0.45      the bar is 60%
//     16.7°          8.9%    39.2%      <- my old synthetic rig: unreachable,
//                                          asymptote 109%, would need f=0.86
//      4.8°         34.9%    71.5%      <- the yard: clears comfortably
//
// A steeply-pitched reference catches the downdraft nearly as well as a
// horizontal one does (its normal is still 96% vertical), so it can never be
// out-loaded. The yard cannot BUILD a 16.7° sail: house fascia is 2.60 m and
// the posts are 3.95 m, ~16 m apart — 4.8°. Measuring against a rig the game
// can't rig proved something true about nothing.
const YARD_PITCH_DEG = 4.8;  // house 2.60 -> post 3.95 over ~16 m, from world.js
test('decision 3: flat-horizontal is no longer a free lunch', () => {
  const f = STORM_02.gusts?.downdraftOfTotal ?? STORM_02.gusts?.downdraft ?? 0;
  assert(f > 0, 'storm_02 has no downdraft at all — decision 3/8 has regressed out of the data');

  // Footprint sized and pitched like a real quad from the dressed yard, spun
  // through 8 headings under the real storm. (Re-seeding the wind instead only
  // reshuffles gust TIMING — the direction curve is authored — so it would look
  // like a sweep and measure nothing about direction.)
  const S = Math.sqrt(30);                                     // ~30 m², mid of A's 18-45 band
  const rise = Math.tan((YARD_PITCH_DEG * Math.PI) / 180) * S;
  const PITCHED = [3.2 + rise / 2, 3.2 + rise / 2, 3.2 - rise / 2, 3.2 - rise / 2];
  const HORIZ = [3.2, 3.2, 3.2, 3.2];

  const foot = [[-S / 2, -S / 2], [S / 2, -S / 2], [S / 2, S / 2], [-S / 2, S / 2]];
  const at = (hs, th) => foot.map(([x, z], i) => {
    const c = Math.cos(th), s = Math.sin(th);
    const pos = { x: x * c - z * s, y: hs[i], z: x * s + z * c };
    return { id: `a${i}`, type: 'post', pos, sway: () => pos };
  });
  const sweep = (hs) => {
    let worst = 0;
    for (let k = 0; k < 8; k++) {
      const r = new SailRig({ anchors: at(hs, (k / 8) * Math.PI * 2), gridN: 10 })
        .attach(ALL_IDS, Array(4).fill(UNBREAKABLE), 1.0);
      // full duration: storm_02's own note says the peak lands just AFTER the
      // southerly change, so a short sweep measures the wrong half of the storm
      worst = Math.max(worst, runStorm(r, realWind(), STORM_02.duration));
    }
    return worst;
  };

  const pitched = sweep(PITCHED);
  const horizontal = sweep(HORIZ);
  const ratio = horizontal / pitched;
  assert(ratio >= 0.6, `flat-horizontal peaks at only ${(ratio * 100).toFixed(0)}% of flat-pitched (${kN(horizontal)} vs ${kN(pitched)}) — still a free lunch`);
  return `flat-horizontal ${kN(horizontal)} vs flat-pitched ${kN(pitched)} = ${(ratio * 100).toFixed(0)}% at ${YARD_PITCH_DEG}° yard pitch, downdraftOfTotal ${f}`;
});

test('runs against the shared contracts.js stub wind', () => {
  // Proves the rig eats the sanctioned Wind implementation, not just my local
  // stub — so nothing surprises us when Lane C's weather.js drops in.
  const r = rig(HEIGHTS_HYPAR, { hw: HARDWARE[1] });
  const wind = createStubWind({ seed: 1, stormLen: 90 });
  const peak = runStorm(r, wind, 90);
  for (const v of r.pos) assert(Number.isFinite(v), 'went NaN on the shared stub wind');
  assert(peak > 0, 'shared stub wind produced no load at all');
  return `90 s on contracts.js stub wind, peak ${kN(peak)}, ${r.corners.filter((c) => c.broken).length}/4 corners lost`;
});


// --- SPRINT4 decision 10 / SPRINT5: ponding -------------------------------

const STORM_01 = await loadStormDef('storm_01_gentle');

test('ponding: a flat rig pools water and a twisted one sheds it', () => {
  // Real yard quads, not the synthetic level saddle: HEIGHTS_HYPAR is a
  // symmetric two-up-two-down at one footprint, which sags into a central belly
  // under a night of rain and ponds like a flat sail — an artifact of the test
  // rig, not the sim. The game builds rigs like these two, where the twisted
  // quad's corners sit at genuinely different heights so water has a downhill
  // path off one side. Measured 50x apart (1.7 vs 13 kg/m²).
  const flat = yardRig(['h1', 'h3', 'p2', 'p1'], UNBREAKABLE, 1.0);   // 2.6/2.6/4.0/4.0 — a roof
  const twisted = yardRig(TWISTED_QUAD, UNBREAKABLE, 0.85);           // §7's own survivor
  runStorm(flat, realWind(), STORM_02.duration);
  runStorm(twisted, realWind(), STORM_02.duration);
  const fm = flat.pondMass(), tm = twisted.pondMass();
  const fpm = fm / flat.area, tpm = tm / twisted.area;              // per m², since areas differ
  assert(fpm > 8, `flat rig only held ${fpm.toFixed(1)} kg/m² after a night of rain — it isn't ponding`);
  assert(tpm < fpm * 0.25, `twisted rig held ${tpm.toFixed(1)} kg/m² vs the flat rig's ${fpm.toFixed(1)} — it should shed`);
  return `flat ${fpm.toFixed(1)} kg/m² vs twisted ${tpm.toFixed(1)} kg/m² (${(tpm / fpm * 100).toFixed(0)}%)`;
});

// THE POINT OF THE WHOLE WATER ARC. Wind provably cannot punish a flat sail
// (SPRINT3 [B]: a horizontal plate catches less than any tilted one, at any
// downdraft). Water can, it's DESIGN.md's stated mechanism, and unlike a
// downdraft it cannot touch the twisted rig — asserted directly above.
// A CONTROLLED experiment isolating water as the killer. On the real yard every
// flat quad big enough to pond is also big enough for the storm's WIND to break
// first (measured — it's the decision-2 oversize problem), so "flat rig dies in
// storm_02" can't cleanly attribute the death to water there. Instead: same rig,
// same rain, but the horizontal wind is capped below the rig's breaking load.
// Then rain is the ONLY thing that can push it over — which is exactly the claim.
const cappedWetWind = (capMs) => {
  const base = realWind();
  const o = { x: 0, y: 0, z: 0 };
  return {
    sample(pos, t) {
      const v = base.sample(pos, t);
      const h = Math.hypot(v.x, v.z);
      if (h > capMs) { const s = capMs / h; o.x = v.x * s; o.y = v.y; o.z = v.z * s; return o; }
      o.x = v.x; o.y = v.y; o.z = v.z; return o;
    },
    rainAt: (t) => base.rainAt(t),
    rainMmPerHour: (t) => base.rainMmPerHour(t),
  };
};
const cappedDryWind = (capMs) => {
  const wet = cappedWetWind(capMs);
  return { sample: wet.sample, rainAt: () => 0, rainMmPerHour: () => 0 };
};

test('ponding: rain alone kills a flat rig the capped wind cannot', () => {
  const CAP = 16; // m/s — the dry control proves this rig holds 4/4 against it
  const r = carportRig(HARDWARE[1]); // shackle: holds the capped wind, not a night of water
  const broke = [];
  r.events.on('break', (e) => broke.push(e));
  runStorm(r, cappedWetWind(CAP), STORM_02.duration);
  assert(broke.length > 0, `flat rated rig survived — peak pond was only ${r.pondMass().toFixed(0)} kg, rain isn't loading it`);
  return `${broke.length} corner(s) blew to water under a ${CAP} m/s wind cap, first at t=${broke[0].t.toFixed(1)}s`;
});

test('ponding: the same rig under the same capped wind survives with rain OFF', () => {
  // The control that makes the test above mean "water", not "wind": identical
  // rig, identical capped wind, rain turned off -> it must hold 4/4.
  const CAP = 16;
  const r = carportRig(HARDWARE[1]);
  const broke = [];
  r.events.on('break', (e) => broke.push(e));
  runStorm(r, cappedDryWind(CAP), STORM_02.duration);
  assert(broke.length === 0, `the rig lost ${broke.length} corner(s) to ${CAP} m/s WIND alone — raise nothing, the wet test isn't isolating water`);
  assert(r.pondMass() === 0, 'no rain should mean no pond');
  return `dry, ${CAP} m/s cap: 4/4 held — so the kill above is the water`;
});

test('ponding: storm_01 gentle cannot hurt anyone', () => {
  const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[1] });
  const broke = [];
  r.events.on('break', (e) => broke.push(e));
  runStorm(r, realWind(STORM_01), STORM_01.duration);
  assert(broke.length === 0, `a gentle day blew ${broke.length} corner(s) — storm_01 is the tutorial`);
  return `4/4 held, ${r.pondMass().toFixed(0)} kg of water on the cloth`;
});

test('ponding: mass conserves until something dumps it', () => {
  const r = rig(HEIGHTS_FLAT);
  const w = realWind();
  runStorm(r, w, 40);
  const held = r.pondMass();
  assert(held > 50, `only ${held.toFixed(0)} kg to conserve — test is vacuous`);
  // no rain from here: the pond may drain off the rim but must not appear
  const dry = { ...w, rainAt: () => 0, rainMmPerHour: () => 0 };
  runStorm(r, dry, 5);
  assert(r.pondMass() <= held + 1e-6, `pond GREW from ${held.toFixed(0)} to ${r.pondMass().toFixed(0)} kg with no rain`);
  // The old assert here compared `dumped` to itself through a helper that
  // returned its argument — a tautology that could not fail no matter what
  // dumpPond returned. Say the real thing: what it reports dropping is what
  // was on the cloth.
  const before = r.pondMass();
  assert(before > 1, `only ${before.toFixed(1)} kg left to dump — test is vacuous`);
  const dumped = r.dumpPond('test');
  assert(Math.abs(dumped - before) < 1e-9, `dumpPond reported ${dumped.toFixed(1)} kg but ${before.toFixed(1)} kg was on the cloth`);
  assert(r.pondMass() === 0, 'dumpPond left water behind');
  return `held ${held.toFixed(0)} kg, dumped ${dumped.toFixed(0)} kg, sail now dry`;
});

test('ponding: a blown corner tips the pond off (DESIGN.md sudden dump)', () => {
  const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[1] });
  const dumps = [];
  r.events.on('pondDump', (e) => dumps.push(e));
  runStorm(r, realWind(), STORM_02.duration);
  assert(dumps.some((d) => d.reason === 'corner blew'), 'a corner let go and the water just sat there');
  const big = dumps.find((d) => d.reason === 'corner blew');
  return `corner blew and dropped ${big.kg.toFixed(0)} kg at t=${big.t.toFixed(1)}s`;
});

test('ponding: winching the sail up tips the water off', () => {
  const r = rig(HEIGHTS_FLAT, { tension: 0.9 });
  runStorm(r, realWind(), 40);
  const held = r.pondMass();
  assert(held > 50, 'nothing to tip off — test is vacuous');
  const dumps = [];
  r.events.on('pondDump', (e) => dumps.push(e));
  r.setTension(1.1);
  assert(dumps.some((d) => d.reason === 'tensioned up'), 'tensioning a ponded sail did not shed the water');
  assert(r.pondMass() === 0, 'sail still holding water after being winched up');
  return `winch 0.9 -> 1.1 shed ${held.toFixed(0)} kg — the turnbuckle is a tool, not a slider`;
});

// Lane D's broom (SPRINT5 gate 1). DESIGN.md: "run out and poke the pond with a
// broom — the funniest correct mechanic in the game."
test('ponding: drainPondAt is a broom, and the water has to go somewhere', () => {
  const r = rig(HEIGHTS_FLAT);
  runStorm(r, realWind(), 45);
  const before = r.pondMass();
  assert(before > 100, `only ${before.toFixed(0)} kg to sweep — test is vacuous`);
  const target = r.pondCentroid();
  assert(target && target.node >= 0, 'pondCentroid found no pond to aim at');

  const dumps = [];
  r.events.on('pondDump', (e) => dumps.push(e));
  let onYourHead = 0;
  for (let i = 0; i < Math.round(1.5 / SIM_DT); i++) onYourHead += r.drainPondAt(target.node, SIM_DT);

  assert(onYourHead > before * 0.4, `a 1.5 s poke only shifted ${onYourHead.toFixed(0)} of ${before.toFixed(0)} kg`);
  assert(r.pondMass() < before * 0.6, 'the belly is still full after a full poke');
  assert(dumps.length > 0, 'drainPondAt emitted nothing for Lane D to react to');
  return `1.5 s poke dropped ${onYourHead.toFixed(0)} kg of ${before.toFixed(0)} on your head`;
});

test('ponding: the broom SAVES a flat rig that water would have killed', () => {
  // Gate 1, both halves: the rig above dies to water under a 14 m/s cap; a
  // diligent landscaper who sweeps the belly keeps it. Same rig, same capped
  // wind, so the only thing that changed is the broom.
  const CAP = 16;
  const swept = carportRig(HARDWARE[1]);
  const broke = [];
  swept.events.on('break', (e) => broke.push(e));
  const w = cappedWetWind(CAP);
  const steps = Math.round(STORM_02.duration / SIM_DT);
  for (let i = 0; i < steps; i++) {
    swept.step(SIM_DT, w, i * SIM_DT);
    // a diligent landscaper sweeps before the belly reaches a kill load — the
    // rig above blows around 440 kg, so keep it under ~300
    if (swept.pondMass() > 250) {
      const c = swept.pondCentroid();
      if (c) swept.drainPondAt(c.node, SIM_DT, 3);
    }
  }
  assert(broke.length === 0, `swept rig still lost ${broke.length} corner(s) — the broom does not save it`);
  return `kept 4/4 by sweeping the belly; unswept the same rig loses corners to water`;
});

test('ponding: pond accessors are safe before the sail is rigged', () => {
  // Caught live: Lane A's HUD reads pondMass() every frame, including before the
  // player has rigged anything — and this.water doesn't exist until attach().
  const bare = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT) });
  assert(bare.pondMass() === 0, 'pondMass threw / was non-zero on an unrigged sail');
  assert(bare.pondCentroid() === null, 'pondCentroid should be null on an unrigged sail');
  assert(bare.drainPondAt(0, SIM_DT) === 0, 'drainPondAt should no-op on an unrigged sail');
  assert(bare.dumpPond() === 0, 'dumpPond should no-op on an unrigged sail');
  return 'pondMass/centroid/drain/dump all safe pre-attach';
});

test('ponding: rain that the router swallows cannot silently pass', () => {
  // The integrator caught the wind router dropping the rain API this sprint,
  // which would have made every test above pass while ponding did nothing in the
  // real game. A wind with no rain methods must therefore be LOUD, not benign.
  const r = rig(HEIGHTS_FLAT);
  const noRainApi = { sample: realWind().sample, speedAt: () => 0, gustTelegraph: () => null };
  runStorm(r, noRainApi, 30);
  assert(r.pondMass() === 0, 'water appeared from a wind with no rain API');
  return 'no rain API -> no pond (and Lane A asserts the router keeps it)';
});

// --- SPRINT12: THE RATINGS ARE REAL (A's ruling, THREADS sprint 11) ---------
// sail.js fails a corner on load > hw.rating * anchor.ratingHint. This test
// asserts the CONSEQUENCE, not the formula: on the corner block, at max
// tension, through the funnel, the carport blows before an honest post — the
// yard's whole thesis, which D measured to be UNENFORCED before the wiring
// (at any tension the sim had no reason to prefer cb over q; the lever that
// decided the trap was tension, not steel).

const STORM_03B = await loadStormDef('storm_03b_earlybuster');

/**
 * site_02_corner_block, DRESSED — dumped live from world.anchors after dress()
 * (browser, SPRINT12), same provenance as tools/site_audit's backyard dump.
 * Node cannot dress (GLTFLoader + fetch), and the graybox positions are a
 * different yard — see audit.mjs's header for the whole sermon. ratingHint is
 * E's baked number, adopted by adoptAnchor; q1..q4 are site-JSON posts at
 * world.js's DEFAULT_RATING_HINT = 1.
 */
const SITE2_DRESSED = [
  ['tr1', 'tree', 6.833, 2.992, 0.154, 1.0], ['tr1b', 'tree', 7.142, 3.764, 0.043, 0.88],
  ['q1', 'post', -3.953, 3.937, -2.824, 1.0], ['q2', 'post', 3.983, 4.001, -2.276, 1.0],
  ['q3', 'post', 2.249, 3.991, 4.498, 1.0], ['q4', 'post', -3.334, 4.022, 4.445, 1.0],
  ['cb1', 'carport', -8.41, 2.289, -3, 0.22], ['cb2', 'carport', -5.59, 2.289, -3, 0.22],
  ['cp1', 'carport_post', -8.41, 1.679, -0.39, 0.3], ['cp2', 'carport_post', -8.41, 1.679, -5.61, 0.3],
].map(([id, type, x, y, z, ratingHint]) => {
  const pos = { x, y, z };
  return { id, type, ratingHint, pos, sway: () => pos };
});

/** site_02's funnel, verbatim from the site JSON — the yard's personality. */
const SITE2_VENTURI = [{ x: -6, z: 0, axis: 2.1, gain: 1.5, radius: 5, sharp: 3 }];

/** realWind() with the SITE's venturi on it, the way main.js sets it at load. */
function site2Wind(def) {
  const field = createWindField(def);
  field.setVenturi(SITE2_VENTURI);
  const out = { x: 0, y: 0, z: 0 };
  return {
    sample(pos, t) { return field.vecAt(pos.x, pos.z, t, out); },
    speedAt(t) { field.vecAt(-6, 0, t, out); return Math.hypot(out.x, out.z); },
    gustTelegraph: () => null,
    rainAt: (t) => field.rainAt(t),
    rainMmPerHour: (t) => field.rainMmPerHour(t),
  };
}

/**
 * D's exact carport line (cb1 cb2 q2 q3) on UNIFORM rated shackles, settled 12 s
 * on the calm day (funnel on — the gap doesn't switch off for prep, same as the
 * audit), then flown through the whole early buster. Uniform hardware ON
 * PURPOSE: the only asymmetry left is the anchors themselves, so if a cb corner
 * goes first it can only be ratingHint. Returns breaks with the rig's OWN clock
 * on them — settle is t 0..12, the storm is t 12..102.
 */
const flyCarportLine = (anchors, tension) => {
  const r = new SailRig({ anchors, gridN: 10, porosity: 0.30 });
  r.watchDivergence = true;
  r.attach(['cb1', 'cb2', 'q2', 'q3'], Array(4).fill(HARDWARE[2]), tension);
  const breaks = [];
  r.events.on('break', (e) => breaks.push({ id: e.anchorId, t: e.t }));
  const calm = site2Wind(STORM_01), storm = site2Wind(STORM_03B);
  for (let i = 0, n = Math.round(12 / SIM_DT); i < n; i++) r.step(SIM_DT, calm, (i * SIM_DT) % 3);
  const SETTLE_END = r.t;
  for (let i = 0; i < Math.round(STORM_03B.duration / SIM_DT); i++) r.step(SIM_DT, storm, i * SIM_DT);
  return { breaks, SETTLE_END };
};

test('ruling: on site_02 the CARPORT blows before an honest post — at DEFAULT tension', () => {
  // The half D's cold pass proved missing: pre-wiring, at tension 1.0 the
  // honest post blew first and the carport cost nothing, so the lever that
  // decided the trap was tension. Now the same rig, default tension, survives
  // prep and loses the CARPORT mid-storm while both honest posts hold — the
  // anchor decides, not the dial. (Measured: cb1 lets go around t≈41 with
  // q2/q3 peaking ~1.8/2.5 kN, well under a bare 6.5 kN rated shackle.)
  const { breaks, SETTLE_END } = flyCarportLine(SITE2_DRESSED, 1.0);
  assert(breaks.length > 0,
    'nothing blew at default tension: ratingHint is not reaching _checkFailure — the trap is still tension-gated');
  const firstCb = breaks.findIndex((b) => b.id.startsWith('cb'));
  const firstQ = breaks.findIndex((b) => b.id.startsWith('q'));
  assert(firstCb >= 0, `the carport never blew (breaks: ${breaks.map((b) => b.id).join(',')})`);
  assert(firstQ === -1 || firstCb < firstQ,
    `an honest post (${breaks[firstQ]?.id}) blew before the carport — the trap is pointing the wrong way`);
  assert(breaks[firstCb].t > SETTLE_END,
    `the beam let go at t=${breaks[firstCb].t.toFixed(1)}s, INSIDE the ${SETTLE_END.toFixed(0)}s prep settle — ` +
    'at default tension the trap should fire in the storm, not on the shop floor');
  return `default tension: ${breaks[firstCb].id} blew at t=${(breaks[firstCb].t - SETTLE_END).toFixed(1)}s into the storm, honest posts held`;
});

test('ruling: at MAX tension the carport still goes first — and the hint is doing it', () => {
  // The sprint's literal shape (max tension through the funnel), plus the
  // control that makes both tests mean "the HINT", not "the load".
  const { breaks } = flyCarportLine(SITE2_DRESSED, 1.4); // TENSION_MAX
  const firstCb = breaks.findIndex((b) => b.id.startsWith('cb'));
  const firstQ = breaks.findIndex((b) => b.id.startsWith('q'));
  assert(firstCb >= 0, `max tension and the carport still never blew (breaks: ${breaks.map((b) => b.id).join(',')})`);
  assert(firstQ === -1 || firstCb < firstQ,
    `an honest post (${breaks[firstQ]?.id}) blew before the carport at max tension`);

  // Identical yard, every hint forced to 1 — bare hardware ratings, the
  // pre-wiring game — must survive the same night intact: rated shackles at
  // 6.5 kN hold these loads (worst peak ~3.4 kN); only 6.5 × 0.22 = 1.43 kN
  // lets go. This is the mutation control IN the suite: unwire sail.js and
  // both tests above go red because the wired and unhinted runs collapse into
  // this one. If THIS half ever breaks, the loads moved and the cb-first
  // asserts stop isolating the wiring — re-measure before touching thresholds.
  const unhinted = flyCarportLine(
    SITE2_DRESSED.map((a) => ({ ...a, ratingHint: 1, sway: a.sway })), 1.4).breaks;
  assert(unhinted.length === 0,
    `hint-free control lost ${unhinted.map((b) => b.id).join(',')} — bare rated shackles no longer hold this line; ` +
    'the cb-first asserts are no longer attributing the break to ratingHint');
  return `max tension: ${breaks[firstCb].id} first at t=${breaks[firstCb].t.toFixed(1)}s; hint-free control held 4/4`;
});

export const SAIL_TESTS = TESTS;

export function runSailSelftest() {
  const results = TESTS.map(([name, fn]) => {
    try { return { name, pass: true, detail: fn() || '' }; }
    catch (e) { return { name, pass: false, detail: e.message }; }
  });
  return { pass: results.every((r) => r.pass), results };
}

export function report(out) {
  const lines = out.results.map(
    (r) => `${r.pass ? 'PASS' : 'FAIL'}  ${r.name}${r.detail ? `\n        ${r.detail}` : ''}`
  );
  return `${lines.join('\n')}\n\n${out.pass ? 'ALL GREEN' : 'FAILURES'} — ${out.results.filter((r) => r.pass).length}/${out.results.length}`;
}

// Run only when invoked directly; importing this module must not run the suite.
if (typeof process !== 'undefined' && process.versions?.node && import.meta.filename === process.argv[1]) {
  const out = runSailSelftest();
  console.log(report(out));
  process.exit(out.pass ? 0 : 1);
}

export { makeStubWind };
