/**
 * worldgen.js — the infinite world. WP1's generation core (see ROADMAP.md).
 *
 * THE RULE THIS FILE EXISTS TO KEEP: every cell of the world is a pure
 * function of (seed, x, z). No global scans, no whole-map walkers, no state
 * that one chunk writes and another reads. That single property is what makes
 * the world infinite: any chunk can be generated at any time, in any order,
 * on any machine, and come out byte-identical — which is also what keeps
 * saves tiny (store only what *changed*) and multiplayer honest (everyone
 * regenerates the same ground).
 *
 * Everything the bounded v2 generator did with post-hoc array edits is
 * re-expressed here as point functions:
 *   - island falloff        → continent-scale land/ocean noise
 *   - river walkers         → ridged river-noise (channel where |noise| ≈ 0)
 *   - village site scan     → per-province site selection (bounded, cached)
 *   - flattening the ground → point-blend toward the site's height
 *   - painted paths         → roads as distance-to-samples along a polyline
 *                             whose points are global (chunk-independent)
 *   - fords                 → road ∩ river, decidable per point
 *
 * Provinces are the structure grid: PROVINCE×PROVINCE cell regions whose
 * salted seed decides what they hold (wilderness → hamlet → village → town →
 * city, weighted by the development field — the D2 gradient). Province (0,0)
 * always holds at least a village, so every world starts near civilization.
 *
 * KNOWN HORIZON: makeNoise2D wraps its lattice at 256, so a field with scale
 * s repeats every 256/s cells (~21k cells at the finest scales used here).
 * That's a ~21 km walk before déjà vu — acceptable for now, noted for later.
 */
'use strict';

const { makeNoise2D, makeFbm, mulberry32 } = require('./noise');
const { MAT } = require('./materials');

const CHUNK = 64; // cells per chunk side
const PROVINCE = 256; // cells per province side
const MAX_ELEVATION = 52;
const SEA_LEVEL = MAX_ELEVATION * 0.18; // same waterline the bounded world used
const ROAD_RADIUS = 1.2; // half-width of a painted road, in cells
// Town/city street grid (WP6): shared by materialAt (paints the streets) and
// settlementSpecs (places buildings in the gaps) so the two always agree —
// a pure function of (x,z) relative to the settlement site, no state.
const BLOCK = 7; // cells between street centerlines
const STREET_HALF = 1.2; // half-width of a painted street

// Name fragments — same voice as the bounded generator's.
const WORLD_ADJ = ['Whispering', 'Amber', 'Silent', 'Golden', 'Misty', 'Elder', 'Hollow', 'Sunken', 'Emerald', 'Wandering', 'Mossy', 'Cinder', 'Sapphire', 'Thistle', 'Ashen', 'Verdant'];
const WORLD_NOUN = ['Vale', 'Reach', 'Hollow', 'Shore', 'Highlands', 'Downs', 'Fen', 'Crossing', 'Rest', 'Expanse', 'Wilds', 'Cradle', 'Steppe', 'Haven', 'Marches', 'Cape'];
const TOWN_A = ['Ash', 'Fern', 'Oak', 'Mill', 'Stone', 'Brook', 'Wolf', 'Hearth', 'Bram', 'Elder', 'Fox', 'Gorse'];
const TOWN_B = ['ford', 'vale', 'wick', 'stead', 'holm', 'bury', 'dale', 'moor', 'field', 'haven', 'marsh', 'combe'];

/** Deterministic 32-bit mix of (seed, a, b) — the salt for anything keyed by
 *  integer coordinates (provinces, chunks, road pairs). */
function hash2(seed, a, b) {
  let h = (seed ^ 0x9e3779b9) >>> 0;
  h = Math.imul(h ^ (a | 0), 0x85ebca6b);
  h = ((h << 13) | (h >>> 19)) >>> 0;
  h = Math.imul(h ^ (b | 0), 0xc2b2ae35);
  h = ((h << 11) | (h >>> 21)) >>> 0;
  h ^= h >>> 16;
  h = Math.imul(h, 0x27d4eb2f);
  return (h ^ (h >>> 15)) >>> 0;
}

/**
 * Build the generator for one seed. Everything returned is pure; the caches
 * (provinces, road polylines) are bounded by where you actually go.
 */
function makeWorldGen(seed) {
  seed = seed >>> 0;

  // ── fields: the world as continuous functions ────────────────────────────
  const norm01 = (n) => (n + 1) * 0.5;
  const continentN = makeFbm(makeNoise2D(seed ^ 0x1f83d9ab), { octaves: 3, persistence: 0.55, lacunarity: 2.0, scale: 0.0016 });
  const elevN = makeFbm(makeNoise2D(seed), { octaves: 5, persistence: 0.5, lacunarity: 2.0, scale: 0.012 });
  const ridgeN = makeFbm(makeNoise2D(seed ^ 0x1b873593), { octaves: 4, persistence: 0.5, lacunarity: 2.1, scale: 0.013 });
  const moistN = makeFbm(makeNoise2D(seed ^ 0x9e3779b9), { octaves: 3, persistence: 0.5, lacunarity: 2.2, scale: 0.02 });
  const tempN = makeFbm(makeNoise2D(seed ^ 0x27d4eb2f), { octaves: 3, persistence: 0.5, lacunarity: 2.0, scale: 0.011 });
  const forestN = makeFbm(makeNoise2D(seed ^ 0x165667b1), { octaves: 3, persistence: 0.55, lacunarity: 2.0, scale: 0.03 });
  const devN = makeFbm(makeNoise2D(seed ^ 0x5f356495), { octaves: 3, persistence: 0.5, lacunarity: 2.0, scale: 0.0009 });
  const riverN = makeFbm(makeNoise2D(seed ^ 0x38b34ae5), { octaves: 2, persistence: 0.5, lacunarity: 2.0, scale: 0.006 });
  const warp = makeNoise2D(seed ^ 0xc2b2ae35);

  /** Land/ocean shape: >0 land rising inland, <0 ocean deepening out. */
  function continent(x, z) {
    // Domain-warped so coastlines meander instead of tracing noise blobs.
    const wx = x + warp(x * 0.004, z * 0.004) * 90;
    const wz = z + warp((x + 517) * 0.004, (z + 293) * 0.004) * 90;
    return continentN(wx, wz) + 0.18; // biased landward — continents, not archipelago dust
  }

  /** Base terrain height before rivers/settlements, in world units. */
  function baseHeight(x, z) {
    const c = continent(x, z);
    if (c <= 0) {
      // Ocean floor: deepens past the coast, floored so the water plane
      // always has ground under it.
      return Math.max(0, SEA_LEVEL + c * 26);
    }
    const wx = x + warp(x * 0.018, z * 0.018) * 16;
    const wz = z + warp((x + 131) * 0.018, (z + 71) * 0.018) * 16;
    const base = norm01(elevN(wx, wz));
    const ridge = 1 - Math.abs(ridgeN(wx, wz));
    const e = base * 0.58 + ridge * ridge * 0.5;
    // Coastal fade: land climbs away from the waterline over the first
    // ~0.25 of continent value, so coasts arrive as beaches, not cliffs.
    const inland = Math.min(1, c / 0.25);
    return SEA_LEVEL + 1.2 + Math.max(0, e * 0.92) * (MAX_ELEVATION - SEA_LEVEL) * 0.9 * inland;
  }

  /** River channel test → 0 (none) or 0..1 (bank → channel centre). Rivers
   *  live where the ridged river-noise pinches toward zero, in low country —
   *  mountains and open ocean stay river-free. `hBase` saves a recompute
   *  when the caller already has baseHeight. */
  function riverDepth(x, z, hBase) {
    const c = continent(x, z);
    if (c <= 0.02) return 0;
    const h = hBase != null ? hBase : baseHeight(x, z);
    if (h > SEA_LEVEL + (MAX_ELEVATION - SEA_LEVEL) * 0.55) return 0;
    const r = Math.abs(riverN(x, z));
    const width = 0.016 + 0.01 * Math.max(0, 1 - (h - SEA_LEVEL) / 18); // wider in the lowlands
    if (r >= width) return 0;
    return 1 - r / width;
  }

  const fields = {
    continent,
    moisture: (x, z) => norm01(moistN(x, z)),
    temperature: (x, z) => {
      const h = baseHeight(x, z);
      return Math.max(0, Math.min(1, norm01(tempN(x, z)) - (h / MAX_ELEVATION) * 0.45));
    },
    forest: (x, z) => norm01(forestN(x, z)),
    development: (x, z) => norm01(devN(x, z)),
  };

  // ── provinces: who lives where ───────────────────────────────────────────
  const provinces = new Map(); // "px,pz" -> province record

  /** Pick the settlement site inside a province: the flattest, driest spot on
   *  a coarse candidate lattice. Bounded (64 candidates × 5 samples), pure. */
  function chooseSite(px, pz, rng) {
    const x0 = px * PROVINCE, z0 = pz * PROVINCE;
    let best = null;
    for (let i = 0; i < 8; i++) {
      for (let j = 0; j < 8; j++) {
        const x = x0 + PROVINCE * (0.15 + 0.7 * (i / 7)) + (rng() - 0.5) * 8;
        const z = z0 + PROVINCE * (0.15 + 0.7 * (j / 7)) + (rng() - 0.5) * 8;
        if (continent(x, z) <= 0.04) continue; // firmly on land
        const h = baseHeight(x, z);
        if (h <= SEA_LEVEL + 1.5) continue;
        if (riverDepth(x, z, h) > 0) continue; // not in a river
        let lo = h, hi = h, wet = false;
        for (const [dx, dz] of [[9, 0], [-9, 0], [0, 9], [0, -9]]) {
          const s = baseHeight(x + dx, z + dz);
          if (s < lo) lo = s;
          if (s > hi) hi = s;
          // A channel inside the future settlement radius would get paved
          // over by the flattening rule — settlements go NEAR rivers, never
          // on top of them.
          if (riverDepth(x + dx, z + dz, s) > 0) wet = true;
        }
        const rough = hi - lo;
        if (rough > 6 || wet) continue;
        const score = rough + rng() * 0.4;
        if (!best || score < best.score) best = { x: Math.round(x), z: Math.round(z), score };
      }
    }
    return best;
  }

  function province(px, pz) {
    const key = px + ',' + pz;
    let p = provinces.get(key);
    if (p) return p;
    const rng = mulberry32(hash2(seed, px, pz));
    const cx = (px + 0.5) * PROVINCE, cz = (pz + 0.5) * PROVINCE;
    const dev = fields.development(cx, cz);
    // The gradient (D2): wilderness in low-dev country, cities at the top.
    // Thresholds are QUANTILES of the field's real distribution (fBm output
    // spans ~0.25–0.81, nothing like 0..1 — measured across 5 seeds × 441
    // provinces), targeting ≈45% wild / 25% hamlet / 18% village / 8% town /
    // 4% city. Naive 0..1 thresholds here mean cities simply never exist.
    let tier =
      dev < 0.490 ? 'wilderness' :
      dev < 0.548 ? 'hamlet' :
      dev < 0.606 ? 'village' :
      dev < 0.648 ? 'town' : 'city';
    // The origin province is always at least a village — every world begins
    // within walking distance of a fire and a roof.
    if (px === 0 && pz === 0 && (tier === 'wilderness' || tier === 'hamlet')) tier = 'village';
    let site = null;
    if (tier !== 'wilderness') {
      site = chooseSite(px, pz, rng);
      if (!site) tier = 'wilderness'; // no buildable ground — the wild keeps it
    }
    p = {
      px, pz, tier, site,
      name: site ? `${TOWN_A[Math.floor(rng() * TOWN_A.length)]}${TOWN_B[Math.floor(rng() * TOWN_B.length)]}` : null,
      siteH: site ? baseHeight(site.x, site.z) : 0, // cached — settleAdjust runs per cell
      // Town/city grew for WP6's street grid — a real grid needs enough
      // blocks to read as dense, not a village-sized ring with taller huts.
      settleR: tier === 'city' ? 32 : tier === 'town' ? 22 : tier === 'village' ? 13 : tier === 'hamlet' ? 9 : 0,
    };
    provinces.set(key, p);
    return p;
  }

  // ── roads: settled neighbours get a road between their sites ────────────
  // A road is a wobbled polyline sampled every 2 cells. The samples are
  // GLOBAL (a pure function of the two endpoint provinces), so every chunk —
  // and every direct point query — sees the same road. Cached per pair.
  const roads = new Map(); // "px,pz|qx,qz" -> [{x,z}, ...]

  function roadBetween(pa, pb) {
    const key = pa.px + ',' + pa.pz + '|' + pb.px + ',' + pb.pz;
    let pts = roads.get(key);
    if (pts) return pts;
    const a = pa.site, b = pb.site;
    const dist = Math.hypot(b.x - a.x, b.z - a.z);
    const steps = Math.max(2, Math.ceil(dist / 2));
    const amp = Math.min(24, dist * 0.1);
    const rng = mulberry32(hash2(seed, hash2(seed, pa.px, pa.pz), hash2(seed, pb.px, pb.pz)));
    const phase = rng() * Math.PI * 2;
    const nx = -(b.z - a.z) / dist, nz = (b.x - a.x) / dist;
    pts = [];
    for (let s = 0; s <= steps; s++) {
      const t = s / steps;
      const off = Math.sin(t * Math.PI * 2 + phase) * amp * Math.sin(t * Math.PI);
      pts.push({ x: a.x + (b.x - a.x) * t + nx * off, z: a.z + (b.z - a.z) * t + nz * off });
    }
    roads.set(key, pts);
    return pts;
  }

  /** Every road polyline that could pass near province (px,pz): E/S links of
   *  every province in the surrounding 3×3. The 3×3 (not just this
   *  province's own links) matters because a road's wobble (≤24 cells) can
   *  carry it across a province corner it doesn't terminate in — a chunk
   *  there must still paint it, or roads would have corner-shaped gaps. */
  const roadsNearCache = new Map(); // "px,pz" -> [pts, ...]
  function roadsTouching(px, pz) {
    const key = px + ',' + pz;
    let out = roadsNearCache.get(key);
    if (out) return out;
    out = [];
    for (let dz = -1; dz <= 1; dz++) {
      for (let dx = -1; dx <= 1; dx++) {
        const ax = px + dx, az = pz + dz;
        const pa = province(ax, az);
        if (!pa.site) continue;
        for (const [bx, bz] of [[ax + 1, az], [ax, az + 1]]) {
          const pb = province(bx, bz);
          if (pb.site) out.push(roadBetween(pa, pb));
        }
      }
    }
    roadsNearCache.set(key, out);
    return out;
  }

  /** Is (x,z) within paint distance of any road? Point-query form; chunk
   *  generation uses the mask below (same samples → same answer). */
  function roadNear(x, z) {
    const px = Math.floor(x / PROVINCE), pz = Math.floor(z / PROVINCE);
    const rr = ROAD_RADIUS * ROAD_RADIUS;
    for (const pts of roadsTouching(px, pz)) {
      for (const pt of pts) {
        const dx = pt.x - x, dz = pt.z - z;
        if (dx * dx + dz * dz <= rr) return true;
      }
    }
    return false;
  }

  // ── the composite: height and material for any single point ─────────────
  /** Settlement flattening as a point function: blend toward the site's own
   *  base height inside the settlement radius. Returns the adjusted height
   *  and the settlement (if any) whose ground this is. */
  function settleAdjust(x, z, h) {
    const px = Math.floor(x / PROVINCE), pz = Math.floor(z / PROVINCE);
    for (let dz = -1; dz <= 1; dz++) {
      for (let dx = -1; dx <= 1; dx++) {
        const p = province(px + dx, pz + dz);
        if (!p.site) continue;
        const R = p.settleR;
        const d = Math.hypot(x - p.site.x, z - p.site.z);
        if (d > R) continue;
        const w = d < R * 0.6 ? 1 : 1 - (d - R * 0.6) / (R * 0.4);
        return { h: h * (1 - w) + p.siteH * w, p, d };
      }
    }
    return { h, p: null, d: Infinity };
  }

  /** Height at a point, all layers applied. THE canonical elevation. */
  function heightAt(x, z) {
    const hb = baseHeight(x, z);
    const s = settleAdjust(x, z, hb);
    let h = s.h;
    const rd = riverDepth(x, z, hb);
    if (rd > 0 && !s.p) { // rivers never carve a settlement's levelled ground
      h = Math.min(h, Math.max(SEA_LEVEL - 1.2, h - rd * 3.2));
      if (rd > 0.35 && roadNear(x, z)) h = Math.max(h, SEA_LEVEL + 0.35); // a ford: wading depth
    }
    return h;
  }

  /**
   * Material at a point. `opts` lets chunk generation hand over what it
   * already knows (final height, slope from its border grid, road mask hit)
   * so nothing is computed twice; a bare materialAt(x, z) still works alone.
   */
  function materialAt(x, z, opts = {}) {
    const h = opts.h != null ? opts.h : heightAt(x, z);
    if (h <= SEA_LEVEL) return MAT.WATER;
    const hb = opts.hb != null ? opts.hb : baseHeight(x, z);
    const rd = riverDepth(x, z, hb);
    const road = opts.road != null ? opts.road : roadNear(x, z);
    const s = settleAdjust(x, z, hb);
    if (rd > 0.35 && !s.p) return road ? MAT.SAND : MAT.WATER; // channel — except the ford's sand shallows
    if (rd > 0 && !s.p) return MAT.SAND; // banks
    if (road) return MAT.PATH;
    if (s.p && s.d < 4.5) return MAT.PATH; // the settlement plaza — trodden ground
    // Towns and cities pave a real street grid over their leveled ground —
    // buildings (settlementSpecs) sit in the gaps between these same lines.
    if (s.p && (s.p.tier === 'town' || s.p.tier === 'city') && s.d < s.p.settleR) {
      const lx = x - s.p.site.x, lz = z - s.p.site.z;
      const wrap = (v) => ((v % BLOCK) + BLOCK) % BLOCK;
      if (wrap(lx) < STREET_HALF || wrap(lz) < STREET_HALF) return MAT.PATH;
    }
    if (h <= SEA_LEVEL + 1.0) return MAT.SAND;
    let slope = opts.slope;
    if (slope == null) {
      const hx = heightAt(x + 1, z) - heightAt(x - 1, z);
      const hz = heightAt(x, z + 1) - heightAt(x, z - 1);
      slope = Math.sqrt(hx * hx + hz * hz);
    }
    const elevFrac = h / MAX_ELEVATION;
    if (elevFrac > 0.88) return MAT.SNOW;
    if (slope > 5.2 || elevFrac > 0.82) return MAT.ROCK;
    if (fields.moisture(x, z) < 0.28) return MAT.DIRT;
    return MAT.GRASS;
  }

  // ── chunks ───────────────────────────────────────────────────────────────
  /**
   * Generate one CHUNK×CHUNK tile: heights, then materials with slope taken
   * from a shared (CHUNK+2)² border grid and roads from a mask built off the
   * same global samples the point query uses. Deterministic and
   * order-independent — strictly per-cell evaluation, plus caches that are
   * themselves pure functions of the seed.
   */
  function generateChunk(cx, cz) {
    const x0 = cx * CHUNK, z0 = cz * CHUNK;
    const W = CHUNK + 2;
    const hg = new Float32Array(W * W); // heights incl. a 1-cell border for slope
    for (let j = 0; j < W; j++) {
      for (let i = 0; i < W; i++) {
        hg[j * W + i] = heightAt(x0 + i - 1, z0 + j - 1);
      }
    }

    // Road mask from the global polyline samples (identical predicate to
    // roadNear, evaluated sample-first instead of cell-first for speed).
    const mask = new Uint8Array(CHUNK * CHUNK);
    const seen = new Set();
    for (let pj = 0; pj <= 1; pj++) {
      for (let pi = 0; pi <= 1; pi++) {
        const px = Math.floor((x0 + pi * (CHUNK - 1)) / PROVINCE);
        const pz = Math.floor((z0 + pj * (CHUNK - 1)) / PROVINCE);
        const k = px + ',' + pz;
        if (seen.has(k)) continue;
        seen.add(k);
        for (const pts of roadsTouching(px, pz)) {
          for (const pt of pts) {
            if (pt.x < x0 - 2 || pt.x > x0 + CHUNK + 1 || pt.z < z0 - 2 || pt.z > z0 + CHUNK + 1) continue;
            const iMin = Math.max(0, Math.ceil(pt.x - ROAD_RADIUS) - x0);
            const iMax = Math.min(CHUNK - 1, Math.floor(pt.x + ROAD_RADIUS) - x0);
            const jMin = Math.max(0, Math.ceil(pt.z - ROAD_RADIUS) - z0);
            const jMax = Math.min(CHUNK - 1, Math.floor(pt.z + ROAD_RADIUS) - z0);
            for (let j = jMin; j <= jMax; j++) {
              for (let i = iMin; i <= iMax; i++) {
                const dx = pt.x - (x0 + i), dz = pt.z - (z0 + j);
                if (dx * dx + dz * dz <= ROAD_RADIUS * ROAD_RADIUS) mask[j * CHUNK + i] = 1;
              }
            }
          }
        }
      }
    }

    const height = new Float32Array(CHUNK * CHUNK);
    const material = new Uint8Array(CHUNK * CHUNK);
    for (let j = 0; j < CHUNK; j++) {
      for (let i = 0; i < CHUNK; i++) {
        const x = x0 + i, z = z0 + j;
        const h = hg[(j + 1) * W + (i + 1)];
        const hx = hg[(j + 1) * W + (i + 2)] - hg[(j + 1) * W + i];
        const hz = hg[(j + 2) * W + (i + 1)] - hg[j * W + (i + 1)];
        height[j * CHUNK + i] = h;
        material[j * CHUNK + i] = materialAt(x, z, {
          h,
          slope: Math.sqrt(hx * hx + hz * hz),
          road: mask[j * CHUNK + i] === 1,
        });
      }
    }
    return { cx, cz, height, material };
  }

  // ── settlements: what stands at a site ───────────────────────────────────
  /**
   * The signal-fabric teaching pen + ball pitch shared by every tier ≥
   * hamlet: lever→lamp, plate→door, a ball to kick, a crate to nudge. Placed
   * on a ray at `bandR` from the site so it never overlaps whatever the tier
   * put closer in (hut ring or street grid) — found the hard way by the
   * "ball rolls when kicked" test wedging the ball into a hut wall.
   */
  function civicPen(sx, sz, bandR, rng, put) {
    const TAU = Math.PI * 2;
    const penA = rng() * TAU;
    const penR = bandR + 3.5;
    const pxp = sx + Math.cos(penA) * penR, pzp = sz + Math.sin(penA) * penR;
    const toPen = Math.atan2(sx - pxp, sz - pzp);
    const fwd = [-Math.sin(toPen), -Math.cos(toPen)];
    const right = [-fwd[1], fwd[0]];
    const at = (f, r) => ({ x: pxp + fwd[0] * f + right[0] * r, z: pzp + fwd[1] * f + right[1] * r });
    for (const r of [-2.2, 2.2]) for (const f of [0, -2.2, -4.4]) { const q = at(f, r); put('fence', q.x, q.z, { rot: toPen }); }
    const back = at(-4.4, 0); put('fence', back.x, back.z, { rot: toPen + Math.PI / 2 });
    const door = at(0, 0); put('door', door.x, door.z, { rot: toPen });
    const plate = at(2.2, 0); put('plate', plate.x, plate.z, { rot: toPen });
    const lamp = at(-2.2, 0); put('lamp', lamp.x, lamp.z, { rot: toPen });
    const lever = at(-3.6, 1.2); put('lever', lever.x, lever.z, { rot: toPen });
    const pitchA = penA + Math.PI * (0.75 + rng() * 0.5);
    const pitchR = bandR + 6; // goals reach ±5 along the axis — keep the near goal clear
    const bx = sx + Math.cos(pitchA) * pitchR, bz = sz + Math.sin(pitchA) * pitchR;
    const across = pitchA + Math.PI / 2;
    for (const side of [-5, 5]) {
      for (const g of [-1.4, 1.4]) {
        put('fence', bx + Math.cos(pitchA) * side + Math.cos(across) * g, bz + Math.sin(pitchA) * side + Math.sin(across) * g, { rot: across });
      }
    }
    put('ball', bx, bz);
    const crA = rng() * TAU;
    put('crate', sx + Math.cos(crA) * 2.2, sz + Math.sin(crA) * 2.2, { rot: crA });
  }

  /**
   * Contemporary content (WP6) for town/city: a real street grid (painted by
   * materialAt using the same BLOCK/STREET_HALF), multi-story buildings and
   * shopfronts filling the blocks between streets, parked cars, signage, and
   * streetlights that hold the whole tier's night back by construction — the
   * D2 promise that civilization density buys safety, not just a bigger
   * version of the village bonfire ring.
   */
  function citySpecs(p, sx, sz, rng, put) {
    const TAU = Math.PI * 2;
    put('well', sx, sz); // the plaza still centers on water, same as every smaller tier
    const plazaA = rng() * TAU;
    put('sign', sx + Math.cos(plazaA) * 3.2, sz + Math.sin(plazaA) * 3.2, { rot: plazaA, extra: { variant: Math.floor(rng() * 4) } });

    const maxIdx = Math.ceil((p.settleR + BLOCK) / BLOCK);
    const maxFloors = p.tier === 'city' ? 6 : 4;
    const buildingSpots = [];
    for (let bi = -maxIdx; bi <= maxIdx; bi++) {
      for (let bj = -maxIdx; bj <= maxIdx; bj++) {
        const lx = bi * BLOCK + BLOCK / 2, lz = bj * BLOCK + BLOCK / 2;
        const d = Math.hypot(lx, lz);
        if (d < 3.5 || d > p.settleR - 4) continue; // clear plaza center + stay inside leveled ground
        const bx = sx + lx, bz = sz + lz;
        const roll = rng();
        // A citizen's doorstep: the block's OUTER street-grid corner (away
        // from the plaza — the inner corner of the four innermost blocks is
        // the site center, i.e. the well's solid). Every corner is
        // √2·(BLOCK/2) ≈ 4.95 from EVERY adjacent block's center — outside
        // even a max-scaled building's solid radius (3.2 × 1.15 ≈ 3.68).
        // Anchoring people at the block CENTER entombs them inside their own
        // building's collision and the walker can never step out — the same
        // trap the village recipe dodges with "home just outside the door",
        // found live: 69 of 74 citizens frozen at seed 10.
        const hx = sx + (bi + (bi >= 0 ? 1 : 0)) * BLOCK;
        const hz = sz + (bj + (bj >= 0 ? 1 : 0)) * BLOCK;
        if (roll < 0.55) { // a building — one to `maxFloors` stories, the WP6 wall/floor/roof language stacked
          const floors = 1 + Math.floor(rng() * maxFloors);
          put('building', bx, bz, { rot: rng() < 0.5 ? 0 : Math.PI / 2, scale: 0.85 + rng() * 0.3, extra: { floors } });
          buildingSpots.push({ hx, hz });
        } else if (roll < 0.72) { // a parked car — density without a building's footprint
          put('car', bx + (rng() - 0.5) * 2, bz + (rng() - 0.5) * 2, { rot: rng() * TAU, extra: { variant: Math.floor(rng() * 5) } });
        } else if (roll < 0.88) { // a shopfront + its sign — plaza flavor
          put('shopfront', bx, bz, { rot: rng() < 0.5 ? 0 : Math.PI / 2, extra: { variant: Math.floor(rng() * 4) } });
          const signA = rng() * TAU;
          put('sign', bx + Math.cos(signA) * 2.6, bz + Math.sin(signA) * 2.6, { rot: signA, extra: { variant: Math.floor(rng() * 4) } });
          buildingSpots.push({ hx, hz });
        }
        // else: an empty block — the plaza/park breathing room a solid grid needs
      }
    }

    // Streetlights ring the settlement — cities are safe at night by
    // construction (D2), no lever/plate wiring, they just light themselves.
    const lights = p.tier === 'city' ? 10 : 6;
    const la0 = rng() * TAU;
    for (let i = 0; i < lights; i++) {
      const a = la0 + (i / lights) * TAU;
      const r = p.settleR * 0.7;
      put('streetlight', sx + Math.cos(a) * r, sz + Math.sin(a) * r, { rot: a });
    }

    civicPen(sx, sz, p.settleR + 3, rng, put);

    // The people: about two in five buildings/shopfronts house 1–2 citizens,
    // home on the street corner by their building, working the plaza well.
    for (const b of buildingSpots) {
      if (rng() >= 0.4) continue;
      const wa = rng() * TAU;
      const n = 1 + Math.floor(rng() * 2);
      for (let i = 0; i < n; i++) {
        put('villager', b.hx, b.hz, { extra: { home: { x: b.hx, z: b.hz }, work: { x: sx + Math.cos(wa) * 1.6, z: sz + Math.sin(wa) * 1.6 } } });
      }
    }
  }

  /**
   * Entity SPECS for a province's settlement — [{kind, x, z, rot, scale,
   * home?, work?}]. The World instantiates these when the province first
   * activates (slice 2). Hamlets and villages keep the original hut-ring
   * recipe; towns and cities get citySpecs()'s street grid (WP6).
   */
  function settlementSpecs(px, pz) {
    const p = province(px, pz);
    if (!p.site) return [];
    const rng = mulberry32(hash2(seed ^ 0x9747b28c, px, pz));
    const { x: sx, z: sz } = p.site;
    const TAU = Math.PI * 2;
    const specs = [];
    const put = (kind, x, z, opts = {}) => specs.push({ kind, x, z, rot: opts.rot || 0, scale: opts.scale || 1, ...(opts.extra || {}) });

    if (p.tier === 'town' || p.tier === 'city') {
      citySpecs(p, sx, sz, rng, put);
      return specs;
    }

    const hutCounts = { hamlet: 2, village: 4 + Math.floor(rng() * 3) };
    const ringR = { hamlet: 5.5, village: 7 + rng() * 1.6 };
    const huts = hutCounts[p.tier];
    put('well', sx, sz);
    const fa = rng() * TAU;
    put('campfire', sx + Math.cos(fa) * 3.4, sz + Math.sin(fa) * 3.4);
    const a0 = rng() * TAU;
    const hutSpots = [];
    for (let i = 0; i < huts; i++) {
      const a = a0 + (i / huts) * TAU + (rng() - 0.5) * 0.25;
      const r = ringR[p.tier];
      const hx = sx + Math.cos(a) * r, hz = sz + Math.sin(a) * r;
      const hs = 0.9 + rng() * 0.25; // held (not inlined) — the doorstep below needs it
      put('hut', hx, hz, { rot: Math.atan2(sx - hx, sz - hz) + Math.PI, scale: hs });
      hutSpots.push({ x: hx, z: hz, scale: hs });
    }
    // Villages keep the teaching toys (hamlets are too small for them). Sits
    // OUTSIDE the hut band, or the ball spawns wedged against a hut wall and
    // can never roll — found the hard way by the "ball rolls when kicked" test.
    if (p.tier !== 'hamlet') civicPen(sx, sz, ringR[p.tier] + 3, rng, put);
    // The people. 2–3 per hut, home outside their own door, workplace the well.
    // "Outside" must respect the hut's SCALE: solid radius is 2.4 × scale
    // (up to 2.76), and the villager walker refuses steps within +0.35 of a
    // solid — a fixed 3.0m offset left big-hut villagers pacing at a door
    // they could never quite reach.
    for (const hut of hutSpots) {
      const toWell = Math.hypot(sx - hut.x, sz - hut.z) || 1;
      const doorstep = 2.4 * hut.scale + 0.85;
      const home = { x: hut.x + ((sx - hut.x) / toWell) * doorstep, z: hut.z + ((sz - hut.z) / toWell) * doorstep };
      const n = 2 + Math.floor(rng() * 2);
      for (let i = 0; i < n; i++) {
        const wa = rng() * TAU;
        put('villager', home.x, home.z, { extra: { home, work: { x: sx + Math.cos(wa) * 1.6, z: sz + Math.sin(wa) * 1.6 } } });
      }
    }
    return specs;
  }

  /**
   * Flora + creature SPECS for one chunk — the biome rules that used to live
   * in the bounded world's _spawnScenery, re-expressed per chunk. Pure
   * function of the seed and chunk coords: same chunk, same forest.
   * Density comes from sampling the fields at each candidate point, so
   * forests cluster, reeds hug shorelines, and hoppers stay in the meadows —
   * exactly as before, just without needing the whole map to exist.
   */
  function scenerySpecs(cx, cz) {
    const rng = mulberry32(hash2(seed ^ 0x5bd1e995, cx, cz));
    const x0 = cx * CHUNK, z0 = cz * CHUNK;
    const specs = [];
    const put = (kind, x, z, scale, rot) => specs.push({ kind, x, z, rot: rot != null ? rot : rng() * Math.PI * 2, scale });

    // Keep settlement grounds clear — no oak through a hut roof. (Same rule
    // the bounded generator enforced with its clearings list.)
    const nearSettlement = (x, z) => {
      const px = Math.floor(x / PROVINCE), pz = Math.floor(z / PROVINCE);
      for (let dz = -1; dz <= 1; dz++) {
        for (let dx = -1; dx <= 1; dx++) {
          const p = province(px + dx, pz + dz);
          if (p.site && Math.hypot(x - p.site.x, z - p.site.z) < p.settleR + 7) return true;
        }
      }
      return false;
    };

    // ~N placement attempts per kind per chunk; predicates decide what takes.
    const tryPlace = (count, fn) => {
      for (let t = 0; t < count; t++) {
        const x = x0 + rng() * CHUNK, z = z0 + rng() * CHUNK;
        const hb = baseHeight(x, z);
        if (continent(x, z) <= 0.02 || hb <= SEA_LEVEL + 0.8) continue;
        if (riverDepth(x, z, hb) > 0) continue;
        if (roadNear(x, z)) continue; // nothing grows on a trodden road
        if (nearSettlement(x, z)) continue;
        fn(x, z, hb);
      }
    };

    const forestAt = (x, z) => fields.forest(x, z);
    const elevFrac = (h) => h / MAX_ELEVATION;

    tryPlace(30, (x, z, h) => { // broadleaf woods, low country
      if (forestAt(x, z) > 0.52 && elevFrac(h) < 0.62) put('tree', x, z, 0.8 + rng() * 0.9);
    });
    tryPlace(18, (x, z, h) => { // pines climb the slopes
      if (forestAt(x, z) > 0.56 && elevFrac(h) >= 0.3) put('pine', x, z, 0.9 + rng() * 0.7);
    });
    tryPlace(6, (x, z) => { // mushrooms under the deep canopy
      if (forestAt(x, z) > 0.62) put('mushroom', x, z, 0.8 + rng() * 0.7);
    });
    tryPlace(10, (x, z) => { // undergrowth at the forest edge
      const f = forestAt(x, z);
      if (f > 0.45 && f < 0.6) put('bush', x, z, 0.7 + rng() * 0.7);
    });
    tryPlace(8, (x, z, h) => { // flower meadows in damp open grass
      if (forestAt(x, z) < 0.5 && fields.moisture(x, z) > 0.4 && elevFrac(h) < 0.5) {
        const n = 2 + Math.floor(rng() * 3);
        for (let f = 0; f < n; f++) put('flower', x + (rng() - 0.5) * 4, z + (rng() - 0.5) * 4, 0.8 + rng() * 0.6);
      }
    });
    tryPlace(10, (x, z, h) => { // reeds along shorelines (sea and rivers)
      let wet = false;
      for (const [dx, dz] of [[2, 0], [-2, 0], [0, 2], [0, -2]]) {
        const nh = baseHeight(x + dx, z + dz);
        if (nh <= SEA_LEVEL || riverDepth(x + dx, z + dz, nh) > 0.35) { wet = true; break; }
      }
      if (wet) put('reed', x, z, 0.8 + rng() * 0.6);
    });
    tryPlace(5, (x, z, h) => { // rocks favour high, dry country
      if (elevFrac(h) > 0.35 || fields.moisture(x, z) < 0.3) put('rock', x, z, 0.6 + rng() * 0.8);
    });
    tryPlace(6, (x, z) => { // food — the foraging loop shouldn't require a hike
      put('apple', x, z, 0.8 + rng() * 0.4);
    });
    // Creatures: sparse, biome-true. Wanderers roam anywhere walkable;
    // hoppers keep to open meadow.
    tryPlace(2, (x, z) => {
      if (rng() < 0.35) put('wanderer', x, z, 1);
    });
    tryPlace(3, (x, z) => {
      if (forestAt(x, z) < 0.55 && rng() < 0.5) put('hopper', x, z, 1);
    });
    return specs;
  }

  /** Named places within `r` of a point — the discoverable landmark list. */
  function landmarksNear(x, z, r) {
    const out = [];
    const pr = Math.ceil(r / PROVINCE) + 1;
    const px = Math.floor(x / PROVINCE), pz = Math.floor(z / PROVINCE);
    for (let dz = -pr; dz <= pr; dz++) {
      for (let dx = -pr; dx <= pr; dx++) {
        const p = province(px + dx, pz + dz);
        if (!p.site) continue;
        if (Math.hypot(p.site.x - x, p.site.z - z) > r) continue;
        out.push({ kind: p.tier, name: p.name, x: p.site.x, z: p.site.z });
      }
    }
    out.sort((a, b) => Math.hypot(a.x - x, a.z - z) - Math.hypot(b.x - x, b.z - z));
    return out;
  }

  const nameRng = mulberry32(seed ^ 0xdeadbeef);
  const worldName = `${WORLD_ADJ[Math.floor(nameRng() * WORLD_ADJ.length)]} ${WORLD_NOUN[Math.floor(nameRng() * WORLD_NOUN.length)]}`;

  return {
    seed,
    CHUNK,
    PROVINCE,
    SEA_LEVEL,
    MAX_ELEVATION,
    worldName,
    fields,
    heightAt,
    materialAt,
    riverDepth,
    roadNear,
    province,
    generateChunk,
    settlementSpecs,
    scenerySpecs,
    landmarksNear,
  };
}

module.exports = { makeWorldGen, CHUNK, PROVINCE, hash2 };
