'use strict';
// SHADES — Lane C — wind field core.
//
// Pure math. Zero imports: no THREE, no DOM, no Date.now, no rAF. Everything is
// a closed-form function of (pos, t) given a storm def + seed, which buys us:
//   - selftest can fast-forward a 90 s storm and get identical numbers every run
//   - consumers can sample any t, in any order, as often as they like
//   - the determinism rule (PLAN3D §4) is structural, not a promise
//
// weather.js wraps this to expose the contracts.js surface (Vector3 in/out).
// The prototype scheduled gusts by INTEGRATING (wind.gustT += dt). We can't —
// sample(pos,t) is called by everyone at arbitrary t. So gusts are precomputed
// into a timeline from a seeded PRNG at storm load; the envelope shape below is
// a faithful port of prototype/game.js, just read from t instead of accumulated.

// ---------- gust envelope (ported from prototype/game.js windVec) ----------
// telegraph: wind hasn't risen yet, but you can SEE it coming (grass, band, audio)
export const GUST = Object.freeze({
  TELEGRAPH: 1.5,   // gt < 1.5        → 0        "it's coming"
  RAMP: 0.8,        // 1.5 .. 2.3      → 0 → pow
  HOLD: 1.7,        // 2.3 .. 4.0      → pow
  FADE: 1.0,        // 4.0 .. 5.0      → pow → 0
  TOTAL: 5.0,
});
const RAMP_AT = GUST.TELEGRAPH;                 // 1.5
const HOLD_AT = RAMP_AT + GUST.RAMP;            // 2.3
const FADE_AT = HOLD_AT + GUST.HOLD;            // 4.0
const END_AT = FADE_AT + GUST.FADE;             // 5.0

/** Gust strength at local gust time gt (seconds since telegraph began). */
export function gustEnvelope(gt, pow) {
  if (gt <= 0 || gt >= END_AT) return 0;
  if (gt < RAMP_AT) return 0;                              // telegraph window
  if (gt < HOLD_AT) return pow * (gt - RAMP_AT) / GUST.RAMP;
  if (gt < FADE_AT) return pow;
  return pow * (END_AT - gt) / GUST.FADE;
}

// ---------- deterministic noise ----------
// mulberry32 — small, fast, good enough, and identical in every JS engine.
export function mulberry32(seed) {
  let a = seed >>> 0;
  return function () {
    a = (a + 0x6D2B79F5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

// int32 hash — Math.imul keeps it exact (plain * would drift past 2^31 as a double)
function hash2(ix, iz, seed) {
  let h = (Math.imul(ix, 374761393) + Math.imul(iz, 668265263) + Math.imul(seed, 1274126177)) | 0;
  h = Math.imul(h ^ (h >>> 13), 1274126177);
  h ^= h >>> 16;
  return (h >>> 0) / 4294967296;
}

const smooth = (f) => f * f * (3 - 2 * f);

/**
 * Value noise, 0..1, C1-continuous (smoothstep interp) so wind never steps.
 *
 * @param {number} [period] Wrap the lattice at this many cells, making the noise
 *   tile seamlessly over [0, period). The wind doesn't want this (the yard would
 *   repeat); a scrolling cloud texture does, or every wrap boundary is a visible
 *   straight edge in the sky. Pass an integer that matches your frequency.
 */
export function valueNoise2(x, z, seed, period = 0) {
  const ix = Math.floor(x), iz = Math.floor(z);
  const ux = smooth(x - ix), uz = smooth(z - iz);
  // branch, not a closure: this is the wind's hot path (the cloth alone samples
  // it thousands of times a second) and a per-call allocation would show up.
  let x0 = ix, x1 = ix + 1, z0 = iz, z1 = iz + 1;
  if (period > 0) {
    x0 = ((x0 % period) + period) % period;
    x1 = ((x1 % period) + period) % period;
    z0 = ((z0 % period) + period) % period;
    z1 = ((z1 % period) + period) % period;
  }
  const a = hash2(x0, z0, seed), b = hash2(x1, z0, seed);
  const c = hash2(x0, z1, seed), d = hash2(x1, z1, seed);
  return (a + (b - a) * ux) * (1 - uz) + (c + (d - c) * ux) * uz;
}

export function smoothstep(e0, e1, x) {
  if (e0 === e1) return x < e0 ? 0 : 1;
  const f = Math.min(1, Math.max(0, (x - e0) / (e1 - e0)));
  return smooth(f);
}

// ---------- curves ----------
/** Piecewise-linear [[t,v],...] lookup, clamped at both ends. */
export function sampleCurve(curve, t) {
  if (!curve || curve.length === 0) return 0;
  if (t <= curve[0][0]) return curve[0][1];
  const last = curve[curve.length - 1];
  if (t >= last[0]) return last[1];
  for (let i = 1; i < curve.length; i++) {
    if (t <= curve[i][0]) {
      const [ta, va] = curve[i - 1], [tb, vb] = curve[i];
      const span = tb - ta;
      return span <= 0 ? vb : va + (vb - va) * ((t - ta) / span);
    }
  }
  return last[1];
}

/** Shortest-arc angle lerp — so a curve crossing ±π doesn't spin the long way. */
export function lerpAngle(a, b, k) {
  const TAU = Math.PI * 2;
  let d = ((b - a + Math.PI) % TAU + TAU) % TAU - Math.PI;
  return a + d * k;
}

function sampleAngleCurve(curve, t) {
  if (!curve || curve.length === 0) return 0;
  if (t <= curve[0][0]) return curve[0][1];
  const last = curve[curve.length - 1];
  if (t >= last[0]) return last[1];
  for (let i = 1; i < curve.length; i++) {
    if (t <= curve[i][0]) {
      const [ta, va] = curve[i - 1], [tb, vb] = curve[i];
      const span = tb - ta;
      return span <= 0 ? vb : lerpAngle(va, vb, (t - ta) / span);
    }
  }
  return last[1];
}

// ---------- gust timeline ----------
// Prototype: pow = 12 + rand*16 + 10*p, next = t + 5 + rand*7. Same shape, from JSON.
export const DEFAULT_DOWNDRAFT = 0.22;

/** Fallback rain scale, mm/hr at rainAt()==1. Storms should state their own. */
export const DEFAULT_PEAK_MM_PER_HOUR = 40;

/**
 * SPRINT4 decision 10 — the time-compression fiat, in ONE place.
 *
 * Game rain accumulates ~40× real time. A 90 s storm is canonically a whole
 * night (storm_02 telegraphs its change "around the hour mark"), so it should
 * deliver a night's water: 90 s × 40 = 3600 s = one hour of rain. At storm_02's
 * 80 mm/hr peak that lands ~5 cm on a flat sail — exactly Lane B's measured kill
 * threshold (5 cm over 25 m² = 1250 kg = 3.1 kN/corner) against a wind load of
 * only 0.2–1.1 kN. Ponding is 3–15× everything else, and it cannot pincer §7
 * because a hypar has no flat to pool in.
 *
 * Exported so Lane B applies it cloth-side rather than either of us hardcoding
 * 40 twice: how hard it rains is Lane C, how much water a sail holds is Lane B.
 */
export const RAIN_TIME_COMPRESSION = 40;

export function buildGustTimeline(def, seed) {
  const g = def.gusts || {};
  const rng = mulberry32(seed >>> 0);
  const minGap = g.minGap ?? 5, maxGap = g.maxGap ?? 12;
  const out = [];
  let t = g.firstAt ?? 3;
  // hard cap: a malformed gap can't spin us forever
  while (t < def.duration && out.length < 512) {
    const p = def.duration > 0 ? t / def.duration : 0;
    const pow = (g.powBase ?? 12) + rng() * (g.powRand ?? 16) + (g.powRamp ?? 10) * p;
    out.push({ t0: t, pow, rampAt: t + GUST.TELEGRAPH, endAt: t + GUST.TOTAL });
    t += minGap + rng() * Math.max(0, maxGap - minGap);
  }
  return out;
}

// ---------- hail (SPRINT5 decision 13) ----------
// Hail, not rain, carries the garden score. Rain honestly walks under a sail
// (droplets terminal ~9 m/s, so a 30 m/s crosswind blows them in at atan(30/9)
// ≈ 73° off vertical — nearly sideways), which is why a perfect rig scored 54%
// vs 48% for no rig at all. Hailstones are dense: terminal velocity ~20 m/s for
// a 1 cm stone, so the SAME 30 m/s gale only leans them atan(30/20)≈56° — and
// that overstates it, because a dense stone's drag couples weakly to the
// horizontal air, so observed hail lean tops out ~15-20°. Steep hail is blocked
// by overhead cloth even in a gale, so the garden score becomes rig-responsive
// without faking the rain physics. The steepness lives in skyfx.hailVelocity();
// the intensity timeline lives here.

/** Envelope of one authored hail burst at local time `dt` (s since it began). */
export function hailBurstEnvelope(dt, ramp, hold, fade, peak) {
  if (dt <= 0) return 0;
  if (dt < ramp) return peak * (dt / ramp);
  if (dt < ramp + hold) return peak;
  if (dt < ramp + hold + fade) return peak * (1 - (dt - ramp - hold) / fade);
  return 0;
}

/**
 * What fraction of hail a cloth of this porosity STOPS (0..1). Lane B's fabric
 * choice (SPRINT7) reads this; the honest answer to their question.
 *
 * The ruling first: porosity is about AIR and WATER, not ice. A knitted shade
 * cloth's gaps are ~1-3 mm; a damaging hailstone is 6-45 mm, so it can't pass a
 * mesh an order of magnitude finer than itself — porous and membrane block the
 * big stones identically. The ONE true difference is at the bottom of the size
 * range: the finest pea hail IS small enough to rattle through an open weave.
 * So a solid membrane stops everything, and a porous cloth stops everything
 * except the smallest stones — which is a real fabric tradeoff without a physics
 * lie, and it makes porous cost you exactly on the mild-hail nights while staying
 * honest on the ice nights.
 *
 * `size` is in hail.size units (1.0 ≈ a 1.5 cm stone; storms run 0.7 pea → 1.4).
 * `porosity` matches SailRig's (0 = membrane, ~0.3 = knitted shade cloth).
 */
export function hailBlockFor(size, porosity = 0) {
  if (!(porosity > 0)) return 1;          // solid membrane stops all ice
  // Effective aperture of the weave, in size units. A 70%-shade knit (porosity
  // ~0.3) reads ~0.6 — it leaks only the finest hail; a very open 0.5 weave
  // reads ~1.0 and lets small stones through too.
  const aperture = porosity * 2;
  // A stone well above the gap is stopped dead; one well below sails through;
  // smoothstep the transition around the aperture.
  return smoothstep(aperture * 0.5, aperture * 1.5, size);
}

// ---------- the field ----------
/**
 * @param {object} def   parsed storm JSON (see data/storms/*.json)
 * @param {object} [opts] {seed}
 */
export function createWindField(def, opts = {}) {
  const seed = (opts.seed ?? def.seed ?? 1) >>> 0;
  const duration = def.duration ?? 90;
  const gusts = buildGustTimeline(def, seed);
  const sp = def.spatial || {};
  const amp = sp.amp ?? 0.18;          // ±18% speed across the yard
  const scale = sp.scale ?? 12;        // metres per noise cell — yard is 30×20
  const advect = sp.advect ?? 0.5;     // noise drifts downwind (frozen turbulence)
  const wander = def.dirWander || {};
  const wAmp = wander.amp ?? 0.25, wRate = wander.rate ?? 0.13;
  const nSeed = (seed ^ 0x9e3779b9) | 0;
  // SPRINT3 decision 8: the downdraft is a fraction of TOTAL wind speed, not of
  // gust power. `downdraftOfTotal` is the field name; `downdraft` is read as a
  // legacy alias so an un-migrated storm doesn't silently lose its vertical.
  const gd = def.gusts || {};
  const downFrac = gd.downdraftOfTotal ?? gd.downdraft ?? DEFAULT_DOWNDRAFT;

  // Hail bursts: authored ones from the JSON, plus one synced to every gust at
  // or above `withGustsAbove` power — so the biggest gusts arrive WITH hail, the
  // storm's worst moment landing all at once. Gust-synced bursts key off the
  // deterministic gust timeline and draw NO randomness, so tuning hail can't
  // re-time the storm (same guarantee as the downdraft).
  const hailDef = def.hail || null;
  const hailBursts = (hailDef && Array.isArray(hailDef.bursts))
    ? hailDef.bursts.map((b) => ({
        t: b.t, ramp: b.ramp ?? 0.8, hold: b.hold ?? 3, fade: b.fade ?? 1.5,
        intensity: b.intensity ?? 1,
      }))
    : [];

  let shelters = [];
  let venturi = [];

  /** Spatially-uniform part: base curve + every gust envelope live at t. */
  function uniformSpeed(t) {
    let s = sampleCurve(def.baseCurve, t);
    for (let i = 0; i < gusts.length; i++) {
      const g = gusts[i];
      if (t <= g.t0) break;            // sorted — nothing later can be live
      if (t < g.endAt) s += gustEnvelope(t - g.t0, g.pow);
    }
    return s;
  }

  function gustOnly(t) {
    let s = 0;
    for (let i = 0; i < gusts.length; i++) {
      const g = gusts[i];
      if (t <= g.t0) break;
      if (t < g.endAt) s += gustEnvelope(t - g.t0, g.pow);
    }
    return s;
  }

  function dirAt(t) {
    return sampleAngleCurve(def.dirCurve, t) + wAmp * Math.sin(t * wRate);
  }

  /** Local horizontal wind speed (m/s) — base+gusts, spatial noise, tree shadow,
   *  site venturi. The one place the local-speed maths lives; speedAt/vecAt/
   *  verticalAt share it, so the downdraft rides the funnel too. */
  function localHoriz(x, z, t) {
    const uni = uniformSpeed(t);
    const d = dirAt(t);
    const s = uni * localFactor(x, z, t, Math.cos(d), Math.sin(d));
    return s > 0 ? s : 0;
  }

  /**
   * Vertical wind, m/s. NEGATIVE = downward. A fraction of the LOCAL horizontal
   * speed at this point and time.
   *
   * Why a horizontal sail must pay: cloth pressure goes with dot(wind, normal),
   * a flat panel's normal points at the sky, so in a purely horizontal wind the
   * dot is ~0 and "lie it flat and ignore the storm" wins — the opposite of the
   * game. A descending component hits a flat panel square on.
   *
   * SPRINT3 decision 8 — fraction of TOTAL, not of gust power. Under gust-only
   * semantics the downdraft peaked exactly at the gust peak, where the horizontal
   * ALSO peaked, so a flat sail could never reach 60% of a pitched one's load
   * (B measured 34%) without a downdraft so violent it also killed the twisted
   * rig the §7 gate needs to survive. The two gates pincered. Riding total speed
   * instead spreads the load across the whole storm: a flat roof is pressed
   * steadily (peak total 32.6 m/s dwarfs peak gust power 12.6), so the ratio
   * clears 60% at a gentle fraction, without a spike at the gust peak. It follows
   * the LOCAL speed, so a tree's wind shadow shelters from falling air too.
   */
  function verticalAt(x, z, t) {
    if (downFrac <= 0) return 0;
    return -downFrac * localHoriz(x, z, t);
  }

  // ---- noise drift ----
  // The noise pattern rides downwind with the mean flow (Taylor's frozen
  // turbulence), so a gust visibly travels ACROSS the yard instead of blinking on
  // everywhere at once. That displacement is an integral, D(t) = ∫ advect·U·dir dτ,
  // and it has to be integrated as one: the obvious closed form `U(t)·advect·t`
  // is not the integral, and it whips the whole accumulated field sideways the
  // instant U or dir moves — a 6.8 m/s single-frame jump at the southerly change,
  // which the continuity assert caught. So integrate once at build time into an
  // immutable table; sampling stays a pure function of t.
  // Mean flow only (base curve, no gusts): eddies are carried by the wind, they
  // don't surf their own gust, and it keeps the drift rate smooth.
  const DRIFT_DT = 0.25;
  const driftX = [], driftZ = [];
  {
    let dx = 0, dz = 0;
    const n = Math.ceil((duration + 2) / DRIFT_DT) + 2;
    for (let i = 0; i < n; i++) {
      driftX.push(dx); driftZ.push(dz);
      const tt = i * DRIFT_DT;
      const u = sampleCurve(def.baseCurve, tt) * advect;
      const d = dirAt(tt);
      dx += Math.cos(d) * u * DRIFT_DT;
      dz += Math.sin(d) * u * DRIFT_DT;
    }
  }
  const drift = { x: 0, z: 0 };
  function driftAt(t) {
    if (t <= 0) { drift.x = 0; drift.z = 0; return drift; }
    const f = t / DRIFT_DT;
    let i = Math.floor(f);
    if (i > driftX.length - 2) i = driftX.length - 2;   // past the end: extrapolate
    const k = f - i;
    drift.x = driftX[i] + (driftX[i + 1] - driftX[i]) * k;
    drift.z = driftZ[i] + (driftZ[i + 1] - driftZ[i]) * k;
    return drift;
  }

  /** Speed multiplier: smooth noise, carried downwind. */
  function spatialFactor(x, z, t) {
    if (amp <= 0) return 1;
    const d = driftAt(t);
    const nx = (x - d.x) / scale;
    const nz = (z - d.z) / scale;
    const n = 0.65 * valueNoise2(nx, nz, nSeed)
            + 0.35 * valueNoise2(nx * 2.2 + 31.7, nz * 2.2 + 11.3, nSeed ^ 0x51ed270b);
    return 1 + (n - 0.5) * 2 * amp;
  }

  /** Trees knock a hole downwind of themselves. Cheap, and very juicy. */
  function shelterFactor(x, z, dirX, dirZ) {
    let f = 1;
    for (let i = 0; i < shelters.length; i++) {
      const s = shelters[i];
      const rx = x - s.x, rz = z - s.z;
      const along = rx * dirX + rz * dirZ;          // >0 = downwind of the tree
      if (along <= 0 || along >= s.length) continue;
      const perp = Math.abs(rx * dirZ - rz * dirX);
      if (perp >= s.radius) continue;
      // ramp in over the first half-radius so the shadow can't snap on at along=0
      const fAlong = smoothstep(0, s.radius * 0.5, along) * (1 - smoothstep(0, s.length, along));
      const fPerp = 1 - smoothstep(0, s.radius, perp);
      f *= 1 - s.strength * fAlong * fPerp;
    }
    return f;
  }

  /**
   * A venturi is a shelter's opposite: a gap between buildings SPEEDS the wind up
   * when it blows along the gap's axis (SPRINT9 site_02, the corner block funnels
   * the southerly). Where a tree shadow depends on being downwind of the tree,
   * a venturi depends on the wind being ALIGNED with a fixed axis the SITE owns —
   * so it fires only when the storm's direction swings to match, which is the
   * whole drama: the corner block is calm until the southerly comes through, then
   * it screams. Multiplier ≥ 1, and inert (empty list) on any site that has no
   * funnel, so backyard_01 is untouched.
   */
  function venturiFactor(x, z, dirX, dirZ) {
    let f = 1;
    for (let i = 0; i < venturi.length; i++) {
      const v = venturi[i];
      const rx = x - v.x, rz = z - v.z;
      const dist = Math.hypot(rx, rz);
      if (dist >= v.radius) continue;
      // how well the wind lines up with the gap's axis. |dot| because a gap
      // funnels either way through it; ^sharp so only a well-aligned wind counts.
      let align = Math.abs(dirX * v.axisX + dirZ * v.axisZ);
      align = Math.pow(align, v.sharp);
      const radial = 1 - smoothstep(v.radius * 0.4, v.radius, dist);   // full in the throat, fades out
      f *= 1 + (v.gain - 1) * align * radial;
    }
    return f;
  }

  /** Every spatial speed multiplier at a point, given the wind direction. */
  function localFactor(x, z, t, dirX, dirZ) {
    return spatialFactor(x, z, t) * shelterFactor(x, z, dirX, dirZ) * venturiFactor(x, z, dirX, dirZ);
  }

  const field = {
    def,
    seed,
    gusts,
    duration,

    /**
     * Trees/house register wind shadows. Lane A calls this after building the
     * yard; unset = no shadows, so nothing breaks before world.js lands.
     * @param {Array<{x,z,radius,strength,length}>} list
     */
    setShelters(list) {
      shelters = (list || []).map((s) => ({
        x: s.x, z: s.z,
        radius: s.radius ?? 2.5,
        strength: Math.min(1, Math.max(0, s.strength ?? 0.45)),
        length: s.length ?? (s.radius ?? 2.5) * 4,
      }));
      return field;
    },
    get shelters() { return shelters; },

    /**
     * A site's wind funnels (SPRINT9 site_02). Lane A calls this from the site
     * JSON after building the yard, the same way it calls setSheltersFromTrees.
     * Unset = no funnels, so backyard_01 is untouched. Each zone:
     *   { x, z }        centre of the throat, metres
     *   axis            direction the gap runs, RADIANS in the XZ plane
     *   gain            peak speed multiplier when the wind is dead-on (>1)
     *   radius          how far the acceleration reaches, metres
     *   sharp           alignment falloff exponent — higher = only a wind almost
     *                   exactly along the axis funnels (default 3)
     */
    setVenturi(list) {
      venturi = (list || []).map((v) => {
        const axis = v.axis ?? 0;
        return {
          x: v.x, z: v.z,
          axisX: Math.cos(axis), axisZ: Math.sin(axis),
          gain: Math.max(1, v.gain ?? 1.4),
          radius: v.radius ?? 4,
          sharp: Math.max(1, v.sharp ?? 3),
        };
      });
      return field;
    },
    get venturi() { return venturi; },

    /**
     * Scalar wind speed (m/s) at a point — HORIZONTAL only, which is what an
     * anemometer reads and what the HUD, rain and grass want. The gust downdraft
     * is deliberately not in here: a wind meter jumping because air is falling
     * past it would read as a bug. Use vecAt/sample for the full 3D vector.
     * The cheap path — no allocation.
     */
    speedAt(x, z, t) {
      return localHoriz(x, z, t);
    },

    dirAt,
    uniformSpeed,
    gustOnly,
    verticalAt,
    get downFrac() { return downFrac; },

    /** Writes wind velocity (m/s) into out {x,y,z}. Ground plane is XZ, +Y up. */
    vecAt(x, z, t, out) {
      const d = dirAt(t);
      const dirX = Math.cos(d), dirZ = Math.sin(d);
      const s = localHoriz(x, z, t);
      out.x = dirX * s;
      out.y = -downFrac * s;           // the downdraft rides the local speed — see verticalAt()
      out.z = dirZ * s;
      return out;
    },

    /**
     * The next gust that has been telegraphed but hasn't started ramping.
     * eta = seconds until the wind actually rises. Null when nothing's inbound.
     */
    telegraph(t) {
      for (let i = 0; i < gusts.length; i++) {
        const g = gusts[i];
        if (t < g.t0) return null;     // sorted — next one hasn't telegraphed yet
        if (t < g.rampAt) {
          return { eta: g.rampAt - t, dir: dirAt(g.rampAt), power: g.pow };
        }
      }
      return null;
    },

    /** Storm events (windchange/debris) fired in (a, b]. Pure — replayable. */
    eventsBetween(a, b) {
      const evs = def.events || [];
      const out = [];
      for (let i = 0; i < evs.length; i++) {
        if (evs[i].t > a && evs[i].t <= b) out.push(evs[i]);
      }
      return out;
    },

    /** 0..1 rain intensity for skyfx. */
    rainAt(t) {
      const r = def.rain;
      if (!r) return 0;
      if (r.curve) return Math.min(1, Math.max(0, sampleCurve(r.curve, t)));
      return Math.min(1, Math.max(0, r.intensity ?? 0));
    },

    /**
     * 0..1 hail intensity at time t (SPRINT5 decision 13). Max over every live
     * burst — authored plus gust-synced — because "how hard is it hailing right
     * now" is a level, not a sum; two overlapping bursts don't hail at 1.6×.
     * Zero for a storm with no `hail` block, so storm_01 simply never hails.
     */
    hailAt(t) {
      if (!hailDef) return 0;
      let h = 0;
      for (let i = 0; i < hailBursts.length; i++) {
        const b = hailBursts[i];
        if (t <= b.t || t >= b.t + b.ramp + b.hold + b.fade) continue;
        const v = hailBurstEnvelope(t - b.t, b.ramp, b.hold, b.fade, b.intensity);
        if (v > h) h = v;
      }
      const thresh = hailDef.withGustsAbove;
      if (thresh != null) {
        const gi = hailDef.gustBurstIntensity ?? 0.8;
        for (let i = 0; i < gusts.length; i++) {
          const g = gusts[i];
          if (g.pow < thresh) continue;
          if (t <= g.t0 || t >= g.endAt) continue;
          // reuse the gust envelope: silent through the telegraph, then it hits
          // as the gust ramps and holds — the hail lands WITH the gust.
          // gustEnvelope(gt, pow) returns pow×fraction, so passing gi as "pow"
          // gives the intensity-scaled 0..gi envelope directly.
          const v = gustEnvelope(t - g.t0, gi);
          if (v > h) h = v;
        }
      }
      return h > 1 ? 1 : h;
    },

    /** Stone-size scalar (audio pitch, visual scale, damage weight). Default 1. */
    get hailSize() { return hailDef ? (hailDef.size ?? 1) : 0; },

    /**
     * Rain rate in REAL-WORLD mm/hr. Same curve as rainAt(), with physical units
     * on it — `rainAt` stays 0..1 because it drives drop count and opacity, and a
     * renderer doesn't want millimetres.
     *
     * This exists for ponding (decision 10). Without it Lane B has to invent the
     * mm/hr scale to turn intensity into water mass, which is exactly the
     * "default-off code tuned by a constant I invented" they rightly reverted.
     * The scale is storm data, so it lives here: `rain.peakMmPerHour`.
     * For reference: 8 = light shower, 30 = moderate, 50 = heavy, 80+ = severe.
     */
    rainMmPerHour(t) {
      const r = def.rain;
      if (!r) return 0;
      return field.rainAt(t) * (r.peakMmPerHour ?? DEFAULT_PEAK_MM_PER_HOUR);
    },

    /**
     * Real-world water depth in mm delivered over (t0, t1]. Deterministic
     * trapezoid over the curve — no compression applied.
     *
     * Lane B multiplies by RAIN_TIME_COMPRESSION cloth-side: how much water a
     * SAIL holds is theirs, how hard it rains is mine. Handy shape for a HUD
     * "water delivered" readout too.
     */
    rainDepthMm(t0, t1, stepS = 0.25) {
      if (!(t1 > t0)) return 0;
      let mm = 0;
      const n = Math.max(1, Math.ceil((t1 - t0) / stepS));
      const h = (t1 - t0) / n;
      let prev = field.rainMmPerHour(t0);
      for (let i = 1; i <= n; i++) {
        const cur = field.rainMmPerHour(t0 + i * h);
        mm += (prev + cur) * 0.5 * h / 3600;   // mm/hr × seconds → mm
        prev = cur;
      }
      return mm;
    },
  };

  return field;
}

// ---------- forecasting ----------
// DESIGN.md's partial-information canon: a forecast days out is a RANGE, and it
// tightens as the night comes. Deliberately pure functions on a storm def rather
// than methods on a wind — the card has defs in hand, it doesn't want a field,
// and this keeps forecasting off the wind contract (and out of main.js's router).

const _statsCache = new WeakMap();

/**
 * The truth about a storm, measured rather than estimated. The forecast card was
 * approximating the gust peak as `baseCurve peak + powBase + powRamp` (30 m/s
 * for storm_02); the storm actually gusts to 32.3, because gust power is drawn
 * per gust and rides a ramp. If the card is going to sell the dread it may as
 * well sell the real number. Cached per def — scanning is ~1800 samples.
 *
 * @returns {{sustained, gustPeak, rainPeak, rainPeakMmPerHour, hailPeak, hailSeconds, changeAt}}
 */
export function stormStats(def) {
  const hit = _statsCache.get(def);
  if (hit) return hit;
  const f = createWindField(def);
  const dur = def.duration ?? 90;
  let sustained = 0, gustPeak = 0, rainPeak = 0, hailPeak = 0, hailSeconds = 0;
  const STEP = 0.05;
  for (let t = 0; t <= dur; t += STEP) {
    const u = f.uniformSpeed(t);
    if (u > gustPeak) gustPeak = u;
    const base = u - f.gustOnly(t);
    if (base > sustained) sustained = base;
    const r = f.rainAt(t);
    if (r > rainPeak) rainPeak = r;
    const h = f.hailAt(t);
    if (h > hailPeak) hailPeak = h;
    hailSeconds += h * STEP;
  }
  const change = (def.events || []).find((e) => e.type === 'windchange');
  const stats = {
    sustained, gustPeak, rainPeak,
    rainPeakMmPerHour: rainPeak * (def.rain?.peakMmPerHour ?? DEFAULT_PEAK_MM_PER_HOUR),
    hailPeak, hailSeconds,
    changeAt: change ? change.t : null,
  };
  _statsCache.set(def, stats);
  return stats;
}

/** How wide each number's band runs at lead=1, as a fraction of the value. */
const FORECAST_SPREAD = {
  sustained: 0.30, gustPeak: 0.35, rain: 0.45, changeAt: 0.22, hailSeconds: 0.7,
};

/**
 * A forecast of `def` seen `lead` out — 0 = tonight (exact), 1 = the far end of
 * the week (vague). Deterministic per storm, so the same night always forecasts
 * the same way and re-reading the card can't reroll it.
 *
 * The band ALWAYS contains the truth. That's the line between partial
 * information and a lie: a forecast may be vague, and its midpoint may be off,
 * but it must never rule out what actually happens — a player who rigs for the
 * top of the stated range must never be ambushed. Width and centre-wander both
 * shrink to nothing as lead → 0.
 *
 * @param {object} def   parsed storm JSON
 * @param {number} lead  0..1
 */
export function forecastFor(def, lead = 0) {
  const s = stormStats(def);
  const L = Math.min(1, Math.max(0, lead));
  const r = mulberry32(((def.seed ?? 1) ^ 0xf0eca57) >>> 0);

  const band = (v, rel) => {
    if (L <= 0 || !(v > 0)) return { lo: v, hi: v };
    const w = v * rel * L;
    const c = v + (r() * 2 - 1) * w * 0.6;       // the centre wanders, seeded
    return { lo: Math.max(0, Math.min(v, c - w)), hi: Math.max(v, c + w) };
  };

  // Hail is the garden score (decision 13), so the card has to hint at it — but
  // a distant forecast shouldn't promise ice it can't see yet.
  let chance = 'none';
  if (s.hailSeconds > 0) {
    if (L > 0.55) chance = 'possible';
    else chance = s.hailSeconds > 6 ? 'likely' : 'possible';
  }

  return {
    lead: L,
    confidence: 1 - L,
    sustained: band(s.sustained, FORECAST_SPREAD.sustained),
    gustPeak: band(s.gustPeak, FORECAST_SPREAD.gustPeak),
    rain: band(s.rainPeak, FORECAST_SPREAD.rain),
    rainMmPerHour: band(s.rainPeakMmPerHour, FORECAST_SPREAD.rain),
    changeAt: s.changeAt == null ? null : band(s.changeAt, FORECAST_SPREAD.changeAt),
    hail: { chance, seconds: band(s.hailSeconds, FORECAST_SPREAD.hailSeconds) },
    truth: s,
  };
}

// ---------- storm JSON validator ----------
// Storms are data so design can tune without code (PLAN3D §4) — which means a
// typo is a data bug, and data bugs should fail loud, not silently blow calm.
export function validateStorm(def, name = 'storm') {
  const errors = [];
  const bad = (m) => errors.push(`${name}: ${m}`);
  const isCurve = (c) => Array.isArray(c) && c.length > 0
    && c.every((p) => Array.isArray(p) && p.length === 2 && p.every(Number.isFinite));
  const monotonic = (c) => c.every((p, i) => i === 0 || p[0] >= c[i - 1][0]);

  if (!def || typeof def !== 'object') { bad('not an object'); return { ok: false, errors }; }
  if (!Number.isFinite(def.duration) || def.duration <= 0) bad('duration must be a positive number');

  if (!isCurve(def.baseCurve)) bad('baseCurve must be [[t,speed],...] of finite numbers');
  else {
    if (!monotonic(def.baseCurve)) bad('baseCurve t must be non-decreasing');
    if (def.baseCurve.some((p) => p[1] < 0)) bad('baseCurve speed must be >= 0');
    const end = def.baseCurve[def.baseCurve.length - 1][0];
    if (Number.isFinite(def.duration) && end < def.duration) {
      bad(`baseCurve ends at t=${end} but storm runs to ${def.duration} — tail would flatline`);
    }
  }

  if (!isCurve(def.dirCurve)) bad('dirCurve must be [[t,radians],...] of finite numbers');
  else if (!monotonic(def.dirCurve)) bad('dirCurve t must be non-decreasing');

  const g = def.gusts;
  if (!g || typeof g !== 'object') bad('gusts block missing');
  else {
    const minGap = g.minGap ?? 5, maxGap = g.maxGap ?? 12;
    if (!(minGap > 0)) bad('gusts.minGap must be > 0 (else the timeline never advances)');
    if (maxGap < minGap) bad('gusts.maxGap must be >= minGap');
    // Overlapping gusts stack, and a stacked telegraph is unreadable to the player.
    if (minGap < GUST.TOTAL) bad(`gusts.minGap (${minGap}) < gust length ${GUST.TOTAL}s — gusts would overlap`);
    if ((g.powBase ?? 12) < 0) bad('gusts.powBase must be >= 0');
    // `downdraft` (gust-only, pre-SPRINT3) is still accepted but flagged, so an
    // un-migrated storm loads visibly wrong rather than silently at a third power.
    if (g.downdraft != null && g.downdraftOfTotal == null) {
      bad('gusts.downdraft is the old gust-only field — rename to downdraftOfTotal (SPRINT3 decision 8); it now means a fraction of TOTAL wind speed');
    }
    const dd = g.downdraftOfTotal ?? g.downdraft ?? DEFAULT_DOWNDRAFT;
    if (!Number.isFinite(dd) || dd < 0 || dd > 1) {
      bad(`gusts.downdraftOfTotal must be 0..1 — the fraction of TOTAL wind speed that blows DOWN — got ${dd}`);
    }
  }

  for (const e of def.events || []) {
    if (!Number.isFinite(e.t)) bad(`event ${JSON.stringify(e)} has no finite t`);
    if (!e.type) bad(`event at t=${e.t} has no type`);
    if (e.type === 'debris' && !e.model) bad(`debris event at t=${e.t} has no model`);
    // A windchange event is HUD metadata; dirCurve is the physics. If they drift
    // apart the player gets warned about a swing that never comes.
    if (e.type === 'windchange' && isCurve(def.dirCurve)) {
      const before = sampleAngleCurve(def.dirCurve, e.t - 0.5);
      const after = sampleAngleCurve(def.dirCurve, e.t + (e.over ?? 6));
      const swing = Math.abs(lerpAngle(before, after, 1) - before);
      if (swing < 0.5) {
        bad(`windchange at t=${e.t} promises a swing but dirCurve only turns ${swing.toFixed(2)} rad by t=${e.t + (e.over ?? 6)}`);
      }
    }
  }

  if (def.rain && def.rain.curve && !isCurve(def.rain.curve)) bad('rain.curve must be [[t,intensity],...]');
  if (def.rain) {
    if (def.rain.curve && isCurve(def.rain.curve)
        && def.rain.curve.some((p) => p[1] < 0 || p[1] > 1)) {
      bad('rain.curve intensity must be 0..1 — the physical scale is rain.peakMmPerHour');
    }
    const mm = def.rain.peakMmPerHour;
    if (mm != null && (!Number.isFinite(mm) || mm < 0 || mm > 300)) {
      bad(`rain.peakMmPerHour must be 0..300 mm/hr (8 light, 30 moderate, 50 heavy, 80+ severe) — got ${mm}`);
    }
    // Ponding reads this; a storm that rains with no scale silently ponds at the
    // default instead of what its author meant.
    if (def.rain.curve && mm == null) {
      bad('rain.curve without rain.peakMmPerHour — ponding needs the mm/hr scale (SPRINT4 decision 10)');
    }
  }

  // ---- hail (SPRINT5 decision 13) ----
  if (def.hail) {
    const hd = def.hail;
    if (hd.size != null && (!Number.isFinite(hd.size) || hd.size <= 0 || hd.size > 5)) {
      bad(`hail.size must be a positive scalar up to ~5 (stones bigger than golf balls break the metaphor) — got ${hd.size}`);
    }
    if (hd.withGustsAbove != null && !Number.isFinite(hd.withGustsAbove)) {
      bad('hail.withGustsAbove must be a finite gust-power threshold in m/s');
    }
    if (hd.gustBurstIntensity != null && (!(hd.gustBurstIntensity >= 0) || hd.gustBurstIntensity > 1)) {
      bad(`hail.gustBurstIntensity must be 0..1 — got ${hd.gustBurstIntensity}`);
    }
    // A storm with a hail block but nothing to fire it never hails — that's a
    // typo, not a design, so say so rather than shipping silent hail.
    if (!Array.isArray(hd.bursts) && hd.withGustsAbove == null) {
      bad('hail block has neither bursts[] nor withGustsAbove — it would never hail');
    }
    for (const b of hd.bursts || []) {
      if (!Number.isFinite(b.t)) bad(`hail burst ${JSON.stringify(b)} has no finite t`);
      if (b.t + 0 > (def.duration ?? 90)) bad(`hail burst at t=${b.t} starts after the storm ends`);
      for (const k of ['ramp', 'hold', 'fade']) {
        if (b[k] != null && !(b[k] >= 0)) bad(`hail burst at t=${b.t} has a negative ${k}`);
      }
      if (b.intensity != null && (!(b.intensity >= 0) || b.intensity > 1)) {
        bad(`hail burst at t=${b.t} intensity must be 0..1 — got ${b.intensity}`);
      }
    }
  }

  return { ok: errors.length === 0, errors };
}

// ---------- site wind validator (SPRINT10 site-as-data) ----------
// A site's `wind` block is hand-authored data like a storm, so it fails loud
// the same way. Lane A calls this at site load; setVenturi clamps defensively
// too, but a clamp hides a typo and this names it. Wind personality that lives
// in site JSON: venturi funnels (and later, per-site shelter overrides).
export function validateSiteWind(wind, name = 'site') {
  const errors = [];
  const bad = (m) => errors.push(`${name}.wind: ${m}`);
  if (wind == null) return { ok: true, errors };          // a site with no wind block is fine
  if (typeof wind !== 'object') { bad('must be an object'); return { ok: false, errors }; }

  if (wind.venturi != null) {
    if (!Array.isArray(wind.venturi)) bad('venturi must be an array of funnel zones');
    else wind.venturi.forEach((v, i) => {
      const at = `venturi[${i}]`;
      if (!Number.isFinite(v.x) || !Number.isFinite(v.z)) bad(`${at} needs finite x,z (the throat centre)`);
      if (v.axis != null && !Number.isFinite(v.axis)) bad(`${at}.axis must be radians (the direction the gap runs)`);
      if (v.gain != null && !(v.gain >= 1)) bad(`${at}.gain must be >= 1 — a venturi speeds wind UP; use shelters to slow it (got ${v.gain})`);
      if (v.gain != null && v.gain > 3) bad(`${at}.gain ${v.gain} is a wind tunnel, not a gap — cap ~2`);
      if (v.radius != null && !(v.radius > 0)) bad(`${at}.radius must be > 0 metres`);
      if (v.sharp != null && !(v.sharp >= 1)) bad(`${at}.sharp must be >= 1 (alignment falloff exponent)`);
    });
  }
  return { ok: errors.length === 0, errors };
}
