/**
 * A `City` turned into something the renderer can ask questions of: projection,
 * polygon predicates, and the cached heightfield everything else samples.
 *
 * One `World` per city, built once. The engine's other modules take a `World`
 * rather than importing constants, which is the whole reason a second city is
 * a data file and not a fork.
 *
 * ## Constructed immediately, ready later
 *
 * Everything here that does not touch the heightfield — `project`, `metres`,
 * `pointInPolygon`, `elevationAt` — works the instant the constructor returns.
 * The heightfield does not: it is half a million samples of four-octave noise
 * and a distance-to-coastline, and on the Bay Area that is about 730 ms of
 * unbroken main thread. It is built in a Worker, and `await world.ready()` is
 * how a caller waits for it.
 *
 * The synchronous samplers stay synchronous, because `terrain.ts`, `blocks.ts`,
 * `structures.ts`, `nightlights.ts` and `minimap.ts` call `groundAt` in tight
 * loops and an `await` inside those loops would cost far more than the block it
 * saved. So the split is: *becoming* ready is asynchronous, *being* ready is
 * not.
 *
 * ## What the field carries, and why it grew
 *
 * Height was the first thing worth computing once and reading back, and for a
 * while it was the only one. It was not the only one that was being recomputed:
 * `isLand` and "is this in a park" are polygon walks over coastlines with
 * hundreds of vertices, and the layer builders were asking them hundreds of
 * thousands of times *on the main thread*, for points the Worker had already
 * classified on its way past. So the field carries `land` and `park` too, and
 * `isLandSampled`/`inParkSampled` read them. The exact predicates are still
 * here, still exact, and are what the Worker itself uses.
 */

import { reconciledCity } from "../cities/reconcile.ts";
import type { City, LatLng } from "./types.ts";

/** The lattice, and the three arrays sampled off it. */
export interface Field {
  latSteps: number;
  lngSteps: number;
  /** Latitude of every row; spacing is not uniform. See `buildAxis`. */
  lats: Float64Array;
  /** Longitude of every column. */
  lngs: Float64Array;
  /** Metres above sea level, row-major, `(lngSteps + 1)` wide. */
  height: Float32Array;
  /** 1 where the point is on land, 0 in water. Same layout as `height`. */
  land: Uint8Array;
  /**
   * 1 where the point is inside `city.parks`, 0 elsewhere and everywhere wet.
   * Same layout as `height`.
   *
   * Here rather than left to the consumers because the loop that fills it is
   * already standing on the point with the coordinate in hand, and because the
   * consumers are on the main thread while this is not. See `computeField`.
   */
  park: Uint8Array;
}

/**
 * How the heightfield is getting on, for whoever is showing a boot card.
 *
 * `phase` is a stable key and not a sentence: the engine has no opinion about
 * what language the page is in, and the one place that already writes this copy
 * — `#boot-step` — is the app's, not the engine's.
 */
export interface FieldProgress {
  phase: "heightfield";
  /** 0..1. Rows completed, which is honest: every row costs about the same. */
  fraction: number;
  /**
   * True when the build fell back to the main thread, so a caller can tell the
   * difference between "this is slow" and "this is slow *and* the page is
   * frozen, do not bother animating anything".
   */
  onMainThread: boolean;
}

export interface ReadyOptions {
  /**
   * Abandons the build. The promise then resolves `false` rather than
   * rejecting: switching city mid-build is a normal thing for a person to do,
   * not an error, and a rejection would have to be caught at every call site
   * or become an unhandled rejection in the console.
   */
  signal?: AbortSignal;
  onProgress?: (progress: FieldProgress) => void;
}

export class World {
  readonly city: City;
  readonly lngScale: number;
  /** Metres of latitude per scene unit. */
  readonly metresPerUnit: number;
  /** Longitude's foreshortening at this latitude, for distance maths. */
  readonly lngSquash: number;

  private readonly bboxes = new WeakMap<LatLng[], Float64Array>();
  private state: Field | null = null;
  private pending: Promise<boolean> | null = null;

  /**
   * The pack is asked to reconcile itself before anything is derived from it.
   *
   * With `TERA_RECONCILE` off — the default, and every deploy — `reconciledCity`
   * returns the argument by identity and the three lines below compute exactly
   * what they always computed. With it on the pack comes back with one relief
   * rule, one road convention, one coastal ramp and one reference latitude, and
   * this constructor does not need to know which: it is data, and the whole of
   * the engine's involvement is reading `lngScale` off the pack when the pack
   * has an opinion. See `cities/reconcile.ts`.
   */
  constructor(city: City) {
    this.city = reconciledCity(city);
    this.lngScale =
      this.city.lngScale ?? this.city.latScale * Math.cos((this.city.center.lat * Math.PI) / 180);
    this.metresPerUnit = 111_320 / this.city.latScale;
    // Not `cos(centre.lat)` any more but the same number whenever `lngScale` is
    // derived from it, and the *right* number when it is not: a hill is a circle
    // in scene space only if the squash used to measure its radius is the squash
    // the projection uses. Reconciling one without the other draws ellipses.
    this.lngSquash = this.lngScale / this.city.latScale;
  }

  // ---- Projection ---------------------------------------------------------

  projectX(lng: number): number {
    return (lng - this.city.center.lng) * this.lngScale;
  }

  projectZ(lat: number): number {
    return -(lat - this.city.center.lat) * this.city.latScale;
  }

  /** `[x, z]`. `x` runs east, `z` runs south, so north is `-z`. */
  project(lat: number, lng: number): [number, number] {
    return [this.projectX(lng), this.projectZ(lat)];
  }

  unproject(x: number, z: number): [number, number] {
    return [this.city.center.lat - z / this.city.latScale, this.city.center.lng + x / this.lngScale];
  }

  projectPolygon(coords: LatLng[]): [number, number][] {
    return coords.map(([lat, lng]) => this.project(lat, lng));
  }

  /** Metres above sea level to scene units, exaggeration applied. */
  metres(m: number): number {
    return (m / this.metresPerUnit) * this.city.verticalExaggeration;
  }

  unitsToMetres(u: number): number {
    return (u * this.metresPerUnit) / this.city.verticalExaggeration;
  }

  // ---- Polygon predicates -------------------------------------------------

  /**
   * Bounding box, cached per polygon array.
   *
   * The heightfield asks `isLand` at hundreds of thousands of lattice points,
   * and without this each one walked every edge of every landmass — the SF
   * outline alone is fifty. Four comparisons first took the SF terrain build
   * from 2.3 s to 1.0 s.
   */
  private bbox(poly: LatLng[]): Float64Array {
    const hit = this.bboxes.get(poly);
    if (hit) return hit;
    let minLat = Infinity;
    let maxLat = -Infinity;
    let minLng = Infinity;
    let maxLng = -Infinity;
    for (const [lat, lng] of poly) {
      if (lat < minLat) minLat = lat;
      if (lat > maxLat) maxLat = lat;
      if (lng < minLng) minLng = lng;
      if (lng > maxLng) maxLng = lng;
    }
    const box = Float64Array.of(minLat, maxLat, minLng, maxLng);
    this.bboxes.set(poly, box);
    return box;
  }

  pointInPolygon(lat: number, lng: number, poly: LatLng[]): boolean {
    const box = this.bbox(poly);
    if (lat < box[0]! || lat > box[1]! || lng < box[2]! || lng > box[3]!) return false;
    let inside = false;
    for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
      const a = poly[i];
      const b = poly[j];
      if (!a || !b) continue;
      const [latI, lngI] = a;
      const [latJ, lngJ] = b;
      if (latI > lat !== latJ > lat) {
        const x = ((lngJ - lngI) * (lat - latI)) / (latJ - latI) + lngI;
        if (lng < x) inside = !inside;
      }
    }
    return inside;
  }

  pointInAny(lat: number, lng: number, polys: LatLng[][]): boolean {
    for (const p of polys) if (this.pointInPolygon(lat, lng, p)) return true;
    return false;
  }

  /** Shortest distance to a polygon's boundary, in degrees. */
  private distanceToEdge(lat: number, lng: number, poly: LatLng[], cap = Infinity): number {
    let best = cap;
    for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
      const from = poly[j];
      const to = poly[i];
      if (!from || !to) continue;
      const [aLat, aLng] = from;
      const [bLat, bLng] = to;
      // Cheap rejection against the segment's own extent. The Bay Area's
      // coastlines run to hundreds of vertices and this is called for every
      // land point in the lattice, so skipping a segment that cannot beat the
      // current best is worth the four comparisons.
      if (
        (lat - aLat > best && lat - bLat > best) ||
        (aLat - lat > best && bLat - lat > best) ||
        (lng - aLng > best && lng - bLng > best) ||
        (aLng - lng > best && bLng - lng > best)
      ) {
        continue;
      }
      const dLat = bLat - aLat;
      const dLng = bLng - aLng;
      const lenSq = dLat * dLat + dLng * dLng;
      let t = lenSq === 0 ? 0 : ((lat - aLat) * dLat + (lng - aLng) * dLng) / lenSq;
      t = Math.max(0, Math.min(1, t));
      const d = Math.hypot(lat - (aLat + t * dLat), lng - (aLng + t * dLng));
      if (d < best) best = d;
    }
    return best;
  }

  isLand(lat: number, lng: number): boolean {
    if (this.pointInAny(lat, lng, this.city.inlandWater)) return false;
    return this.pointInAny(lat, lng, this.city.landmasses);
  }

  /** Inside one of the city's parks. The exact test; see `inParkSampled`. */
  inPark(lat: number, lng: number): boolean {
    return this.pointInAny(lat, lng, this.city.parks);
  }

  // ---- Relief -------------------------------------------------------------

  /**
   * Ground elevation in metres, from the city's hills.
   *
   * Overlapping hills combine as `tallest + 35% of the rest`. A straight sum
   * puts Twin Peaks and Mount Sutro — 1.2 km apart, 281 m and 275 m — at a
   * fictional 500 m; a plain max leaves a suspicious notch between them. The
   * blend keeps summits near their real heights and still builds the ridge
   * that actually connects them.
   */
  elevationAt(lat: number, lng: number): number {
    let peak = 0;
    let total = 0;
    for (const hill of this.city.hills) {
      const dLat = lat - hill.lat;
      const dLng = (lng - hill.lng) * this.lngSquash;
      const d = Math.hypot(dLat, dLng) / hill.radius;
      if (d >= 1) continue;
      const f = (1 - d * d) ** 2; // 1 at the summit, 0 with zero gradient at the edge
      const h = hill.elevation * f;
      total += h;
      if (h > peak) peak = h;
    }
    if (peak === 0) return 0;
    const h = peak + (total - peak) * 0.35;
    // Roughen. Bare radial bumps read as golf balls; real hills have spurs and
    // gullies. Multiplied rather than added so the flats stay flat instead of
    // growing dunes.
    const rough = 0.82 + 0.36 * fbm(lat / 0.0042, lng / 0.0053);
    return h * rough * this.coastalFalloff(lat, lng);
  }

  /** 0 at the waterline, 1 once `coastFalloff` degrees inland. */
  private coastalFalloff(lat: number, lng: number): number {
    const limit = this.city.coastFalloff;
    let d = Infinity;
    for (const poly of this.city.landmasses) {
      if (!this.pointInPolygon(lat, lng, poly)) continue;
      // Seed the search with the cap: the result saturates at `coastFalloff`,
      // so any edge farther than that cannot change the answer, and seeding
      // `best` lets the per-segment rejection above discard almost everything
      // for a point well inland.
      const dist = this.distanceToEdge(lat, lng, poly, limit);
      if (dist < d) d = dist;
    }
    if (!Number.isFinite(d)) return 0;
    const t = Math.min(1, d / limit);
    return t * t * (3 - 2 * t);
  }

  // ---- Cached heightfield -------------------------------------------------

  /** True once the heightfield exists and the samplers are cheap. */
  get built(): boolean {
    return this.state !== null;
  }

  /**
   * Build the heightfield, off the main thread if the browser will let us.
   *
   * Resolves `true` when the field is up and the synchronous samplers are safe,
   * `false` when the build was abandoned through `options.signal`. It never
   * rejects and it never leaves a half-built field behind.
   *
   * Idempotent and single-flight: the second caller gets the first caller's
   * promise, and the first caller's `signal` and `onProgress` are the ones that
   * count. One `World` builds one field, once.
   *
   * The fallback to building here on the main thread is not a stub and is not
   * optional. Workers are unavailable under `file://`, under a strict enough
   * `Content-Security-Policy`, and in a handful of embedded webviews, and this
   * repo's one enforced promise is that it boots with no server, no key and no
   * account. A map that renders in two seconds is a slow map; a map that throws
   * because `new Worker` was blocked is a broken one.
   */
  ready(options: ReadyOptions = {}): Promise<boolean> {
    if (this.state) return Promise.resolve(true);
    if (this.pending) return this.pending;
    const run = this.build(options).then((ok) => {
      // Cleared when the build was abandoned, so a caller that still wants this
      // `World` can start another; on success it stays set and never matters,
      // because `this.state` short-circuits above.
      if (!ok) this.pending = null;
      return ok;
    });
    this.pending = run;
    return run;
  }

  private async build(options: ReadyOptions): Promise<boolean> {
    const { signal, onProgress } = options;
    if (signal?.aborted) return false;

    const worker = spawnFieldWorker();
    if (worker) {
      const result = await runInWorker(worker, this.city, signal, onProgress);
      if (result === "abandoned") return false;
      if (result !== "failed") {
        this.adopt(result);
        return true;
      }
    }

    if (signal?.aborted) return false;
    // Let the caller's progress line reach the glass before we take the thread
    // away for the better part of a second. This is the same double-rAF trick
    // `main.ts` uses around its boot card, and for the same reason: a style
    // change and the work that follows it in the same task paint together, so
    // the label the user was supposed to read arrives after the freeze it was
    // meant to explain.
    onProgress?.({ phase: "heightfield", fraction: 0, onMainThread: true });
    await nextPaint();
    if (signal?.aborted) return false;

    this.adopt(computeField(this));
    onProgress?.({ phase: "heightfield", fraction: 1, onMainThread: true });
    return true;
  }

  /**
   * Install a field, first one wins.
   *
   * `lattice()` hands its typed arrays straight out and `terrain.ts` keeps the
   * reference, so replacing a field that is already in use would leave the mesh
   * reading one lattice and the minimap another. The two would in fact agree —
   * the build is deterministic — which is exactly what makes the bug the kind
   * you find six months later.
   */
  private adopt(field: Field): void {
    if (!this.state) this.state = field;
  }

  /**
   * The field, building it here and now if nobody awaited `ready()`.
   *
   * Sampling before ready is a bug in the caller, and this deliberately does
   * not throw for it. The whole point of the Worker is to stop the main thread
   * freezing; a thrown error would stop the map existing, which is a strictly
   * worse failure and one that a self-hoster would hit on the very path — no
   * Worker available — that the fallback exists to cover. So it warns once,
   * loudly enough to find in a console, and builds.
   */
  private ensureField(): Field {
    if (this.state) return this.state;
    warnSampledEarly(this.city.id, this.pending !== null);
    const field = computeField(this);
    this.adopt(field);
    return field;
  }

  /**
   * The lattice, for the terrain mesh builder.
   *
   * `lats`/`lngs` are the coordinate of every row and column, because the
   * spacing is no longer uniform and a consumer cannot recover it from
   * `minLat + i * cellLat` any more.
   */
  lattice(): Field {
    return this.ensureField();
  }

  /** Elevation in metres, bilinearly sampled from the cached lattice. */
  elevationSampled(lat: number, lng: number): number {
    const { lats, lngs, lngSteps, height } = this.ensureField();
    const w = lngSteps + 1;

    const i = cellIndex(lats, lat);
    const j = cellIndex(lngs, lng);
    if (i < 0 || j < 0) return 0;

    const lat0 = lats[i] as number;
    const lat1 = lats[i + 1] as number;
    const lng0 = lngs[j] as number;
    const lng1 = lngs[j + 1] as number;
    const ti = lat1 > lat0 ? (lat - lat0) / (lat1 - lat0) : 0;
    const tj = lng1 > lng0 ? (lng - lng0) / (lng1 - lng0) : 0;

    const a = height[i * w + j] ?? 0;
    const b = height[i * w + j + 1] ?? 0;
    const c = height[(i + 1) * w + j] ?? 0;
    const d = height[(i + 1) * w + j + 1] ?? 0;
    return (a * (1 - tj) + b * tj) * (1 - ti) + (c * (1 - tj) + d * tj) * ti;
  }

  /** Scene-space ground height. What everything stands on. */
  groundAt(lat: number, lng: number): number {
    return this.metres(this.elevationSampled(lat, lng));
  }

  /**
   * A yes/no mask read back off the lattice, with the exact polygon test run
   * only where the lattice cannot answer.
   *
   * This is the boolean half of what `elevationSampled` already does for
   * height, and it exists for the same measured reason. `blocks.ts` asks about
   * ~186k candidate lots on the Bay Area board, each of which walked every edge
   * of every landmass and every park: 240 ms of the boot's main thread, on a
   * desktop, for two facts the Worker had already established across the whole
   * lattice. Sampling instead costs two binary searches and four byte loads —
   * 19 ms for the same 186k lots, measured.
   *
   * The rule is **unanimity, or ask properly**. Four corners that agree decide
   * the cell; a cell that straddles an edge falls through to `exact`, so the
   * coastline and the park boundaries are answered by the polygons that define
   * them and nothing is quantised where quantising would show. On the Bay Area
   * that fallback fires for 532 of 186k lots, and the placement it produces
   * differs from the exhaustive answer by eight buildings in 185,036.
   *
   * Unanimity is also the *more* correct answer inside a cell, not a
   * concession. `terrain.ts` already emits a quad only where all four corners
   * are land, so a lot that the exhaustive test called land inside a cell the
   * terrain skipped was a building standing on no ground at all. This makes the
   * two agree by construction.
   */
  private sampled(
    pick: (field: Field) => Uint8Array,
    lat: number,
    lng: number,
    exact: (lat: number, lng: number) => boolean,
  ): boolean {
    const field = this.ensureField();
    const { lats, lngs, lngSteps } = field;
    const i = cellIndex(lats, lat);
    const j = cellIndex(lngs, lng);
    // Off the board entirely. The lattice has no opinion and the polygons do.
    if (i < 0 || j < 0) return exact.call(this, lat, lng);
    const mask = pick(field);
    const w = lngSteps + 1;
    const k = i * w + j;
    const votes = (mask[k] ?? 0) + (mask[k + 1] ?? 0) + (mask[k + w] ?? 0) + (mask[k + w + 1] ?? 0);
    if (votes === 4) return true;
    if (votes === 0) return false;
    return exact.call(this, lat, lng);
  }

  /** `isLand`, read off the lattice. See `sampled` for what that costs and buys. */
  isLandSampled(lat: number, lng: number): boolean {
    return this.sampled(landOf, lat, lng, this.isLand);
  }

  /** `inPark`, read off the lattice. See `sampled`. */
  inParkSampled(lat: number, lng: number): boolean {
    return this.sampled(parkOf, lat, lng, this.inPark);
  }
}

// Module-level so `sampled`'s two callers pass one stable function each rather
// than allocating a closure per lookup, which at 186k lookups per board is the
// difference between this optimisation and a different kind of garbage.
const landOf = (field: Field): Uint8Array => field.land;
const parkOf = (field: Field): Uint8Array => field.park;

// ---- Producing a field -----------------------------------------------------

/**
 * The heightfield, from scratch. The expensive thing this whole module is
 * arranged around.
 *
 * `elevationAt` is not cheap — every hill, four octaves of noise, and a
 * distance-to-polygon per landmass. The terrain mesh wants it at hundreds of
 * thousands of lattice points, and then every building, road sample and camera
 * target wants it again. Computed once, read back bilinearly.
 *
 * A free function taking a `World` rather than a method, because the Worker
 * runs exactly this code against a `World` it built from the cloned `City`.
 * Sharing the function is what stops the off-thread and on-thread paths drifting
 * into two subtly different maps — and they would drift, because nobody looks at
 * the fallback.
 *
 * `onRow` fires once per lattice row and must be cheap; the Worker uses it to
 * throttle its progress messages, and the main-thread fallback ignores it,
 * since nothing can observe progress on a thread it is blocking.
 */
export function computeField(world: World, onRow?: (done: number, rows: number) => void): Field {
  const { bounds, cellLat, cellLng } = world.city;
  const coarse = Math.max(1, world.city.coarseFactor ?? 1);
  const regions = world.city.focusRegions ?? [];

  // Rectilinear but NOT uniform: fine spacing across any band that a focus
  // region occupies, coarse everywhere else. See `buildAxis`.
  const lats = buildAxis(
    bounds.minLat,
    bounds.maxLat,
    cellLat,
    cellLat * coarse,
    regions.map((r) => [r.minLat, r.maxLat] as [number, number]),
  );
  const lngs = buildAxis(
    bounds.minLng,
    bounds.maxLng,
    cellLng,
    cellLng * coarse,
    regions.map((r) => [r.minLng, r.maxLng] as [number, number]),
  );

  const latSteps = lats.length - 1;
  const lngSteps = lngs.length - 1;
  const w = lngSteps + 1;
  const rows = latSteps + 1;
  const height = new Float32Array(rows * w);
  const land = new Uint8Array(rows * w);
  const park = new Uint8Array(rows * w);
  for (let i = 0; i < rows; i++) {
    const lat = lats[i] as number;
    for (let j = 0; j <= lngSteps; j++) {
      const lng = lngs[j] as number;
      const k = i * w + j;
      const onLand = world.isLand(lat, lng);
      land[k] = onLand ? 1 : 0;
      height[k] = onLand ? world.elevationAt(lat, lng) : 0;
      // Only on land, and not merely as an optimisation: a park mask with 1s
      // out in the bay would let `sampled` carry a coastal cell unanimously
      // into a park that stops at the shore.
      park[k] = onLand && world.inPark(lat, lng) ? 1 : 0;
    }
    onRow?.(i + 1, rows);
  }
  return { latSteps, lngSteps, lats, lngs, height, land, park };
}

// ---- The Worker ------------------------------------------------------------

/** What `terrain.worker.ts` sends back. Kept here so both ends see one type. */
export type FieldMessage =
  | { type: "progress"; done: number; rows: number }
  | ({ type: "field" } & Field)
  | { type: "failed"; message: string };

/** What it is sent. */
export interface FieldRequest {
  city: City;
}

/**
 * `new Worker(new URL(...), { type: "module" })` is spelled out inline because
 * that literal form is what Vite pattern-matches to emit the worker chunk. A
 * variable holding the URL builds clean and 404s in production.
 */
function spawnFieldWorker(): Worker | null {
  if (typeof Worker === "undefined") return null;
  try {
    return new Worker(new URL("./terrain.worker.ts", import.meta.url), { type: "module" });
  } catch {
    // `file://` and some CSPs throw here rather than firing `onerror`.
    return null;
  }
}

/**
 * Longest silence tolerated from a worker before it is written off.
 *
 * Not a build budget — the worker reports progress about eight times a second,
 * so on a slow phone taking twelve seconds over SoCal this never comes close to
 * firing. It is a liveness check, and it exists for the one failure the Worker
 * API gives you no event for: a browser reclaiming a worker under memory
 * pressure. No `error`, no `messageerror`, nothing. Without this, the boot card
 * stays up forever and the map never arrives, which is precisely the outcome
 * the fallback path is supposed to make impossible.
 */
const WORKER_SILENCE_MS = 10_000;

/**
 * Drive one worker to completion, or give up on it.
 *
 * Resolves rather than rejects in every case, including the ones that are
 * genuinely wrong, because the caller's answer to all of them is the same: fall
 * back and carry on. What differs is how loud we are about it on the way past.
 */
function runInWorker(
  worker: Worker,
  city: City,
  signal: AbortSignal | undefined,
  onProgress: ((progress: FieldProgress) => void) | undefined,
): Promise<Field | "failed" | "abandoned"> {
  return new Promise((resolve) => {
    let settled = false;
    // `ReturnType` rather than `number`: this repo has `@types/node` in the
    // tree for the server workspace, which makes the global `setTimeout` the
    // Node one at type-check time even in browser code.
    let watchdog: ReturnType<typeof setTimeout> | undefined;
    const finish = (result: Field | "failed" | "abandoned") => {
      if (settled) return;
      settled = true;
      clearTimeout(watchdog);
      signal?.removeEventListener("abort", abandon);
      // Terminate rather than let it finish and ignore the answer. An abandoned
      // Bay Area build is most of a second of a core that the city being
      // switched *to* wants for itself.
      worker.terminate();
      resolve(result);
    };
    const abandon = () => finish("abandoned");
    signal?.addEventListener("abort", abandon, { once: true });

    const heard = () => {
      clearTimeout(watchdog);
      watchdog = setTimeout(() => {
        console.warn(
          `Tera: heightfield worker went silent for ${WORKER_SILENCE_MS} ms; ` +
            `building on the main thread`,
        );
        finish("failed");
      }, WORKER_SILENCE_MS);
    };
    heard();

    worker.onmessage = (event: MessageEvent<FieldMessage>) => {
      heard();
      const message = event.data;
      if (message.type === "progress") {
        onProgress?.({
          phase: "heightfield",
          fraction: message.rows > 0 ? message.done / message.rows : 0,
          onMainThread: false,
        });
        return;
      }
      if (message.type === "field") {
        onProgress?.({ phase: "heightfield", fraction: 1, onMainThread: false });
        const { latSteps, lngSteps, lats, lngs, height, land, park } = message;
        finish({ latSteps, lngSteps, lats, lngs, height, land, park });
        return;
      }
      console.warn(
        `Tera: heightfield worker failed (${message.message}); building on the main thread`,
      );
      finish("failed");
    };
    // Fires for a module that will not load at all — a CSP that permits
    // `worker-src` but not the script, an offline reload against a stale cache
    // — as well as for anything thrown inside it.
    worker.onerror = () => {
      console.warn("Tera: heightfield worker did not start; building on the main thread");
      finish("failed");
    };
    worker.onmessageerror = () => finish("failed");

    try {
      worker.postMessage({ city } satisfies FieldRequest);
    } catch (err) {
      // A `City` is pure data by contract — see `types.ts` — and structured
      // clone is how that contract is enforced at runtime. If this throws,
      // something has put a function, a class instance or a DOM node in a city
      // pack, and the fix is to take it back out, not to JSON round-trip it
      // here and lose whatever it was.
      console.error(
        `Tera: city "${city.id}" is not structured-cloneable, so its heightfield ` +
          `cannot be built off the main thread. A city pack must be pure data.`,
        err,
      );
      finish("failed");
    }
  });
}

/** Two frames, which is the shortest wait that straddles a paint. */
function nextPaint(): Promise<void> {
  if (typeof requestAnimationFrame !== "function") return Promise.resolve();
  return new Promise((resolve) => {
    requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
  });
}

let warnedEarly = false;

function warnSampledEarly(cityId: string, building: boolean): void {
  if (warnedEarly) return;
  warnedEarly = true;
  console.warn(
    `Tera: the heightfield for "${cityId}" was sampled before \`await world.ready()\`` +
      (building ? " and while a worker was already building it" : "") +
      `, so it was built on the main thread instead. This is a bug in the caller.`,
  );
}

// ---- Variable-resolution lattice ------------------------------------------

/**
 * The coordinates of every row (or column) of the heightfield: fine spacing
 * across the bands the focus regions occupy, coarse in between.
 *
 * This is what makes a region the size of Southern California renderable at
 * all. San Francisco is 0.20 x 0.36 degrees and a uniform 45 m lattice over it
 * is 337k points — fine. LA plus Orange County plus Riverside is about
 * fourteen times that area, and the same uniform lattice is 2.9M points; the
 * Bay Area extended to San Jose is 3.7M and roughly eleven seconds of build.
 * Neither is a thing anyone waits for.
 *
 * The refinement is per-axis rather than per-rectangle, so a focus region
 * refines its whole row *and* its whole column — a plus, not a box. That over-
 * samples the corners where two regions' bands cross, and it is deliberate: it
 * keeps the lattice rectilinear, which keeps the mesh builder a double loop and
 * bilinear sampling a pair of binary searches. A true quadtree would sample
 * less and cost far more everywhere else.
 */
function buildAxis(
  min: number,
  max: number,
  fine: number,
  coarse: number,
  bands: [number, number][],
): Float64Array {
  const out: number[] = [min];
  let x = min;
  // A guard band of one coarse cell either side, so the transition from coarse
  // to fine happens outside the region rather than exactly on its edge, where
  // it would show as a crease in the terrain.
  const inFine = (v: number) => bands.some(([a, b]) => v >= a - coarse && v <= b + coarse);
  while (x < max) {
    x += inFine(x) ? fine : coarse;
    out.push(Math.min(x, max));
  }
  // A degenerate final cell (the clamp above landing on `max` twice) would make
  // a zero-width row that the interpolator would divide by.
  if (out.length > 1 && out[out.length - 1] === out[out.length - 2]) out.pop();
  return Float64Array.from(out);
}

/** Index of the cell containing `v`, or -1 if outside. Binary search. */
function cellIndex(axis: Float64Array, v: number): number {
  const last = axis.length - 1;
  if (v < (axis[0] as number) || v >= (axis[last] as number)) return -1;
  let lo = 0;
  let hi = last;
  while (hi - lo > 1) {
    const mid = (lo + hi) >> 1;
    if ((axis[mid] as number) <= v) lo = mid;
    else hi = mid;
  }
  return lo;
}

// ---- Deterministic noise and randomness -----------------------------------

/**
 * Integer hash, not the `Math.sin(...) * 43758` trick this used to be.
 *
 * `valueNoise` calls this four times and `fbm` runs four octaves, so every
 * elevation sample was sixteen `Math.sin` calls. Over the Bay Area's lattice
 * that is twenty-six million of them, and it was most of an eleven-second
 * terrain build. The inputs are already integers here — `valueNoise` floors
 * them — so an imul-based mix is both faster and better distributed.
 */
function hash2(x: number, y: number): number {
  let h = Math.imul(x | 0, 0x27d4eb2d) ^ Math.imul(y | 0, 0x165667b1);
  h = Math.imul(h ^ (h >>> 15), 0x85ebca6b);
  h ^= h >>> 13;
  return (h >>> 0) / 4294967296;
}

function valueNoise(x: number, y: number): number {
  const xi = Math.floor(x);
  const yi = Math.floor(y);
  const xf = x - xi;
  const yf = y - yi;
  const u = xf * xf * (3 - 2 * xf);
  const v = yf * yf * (3 - 2 * yf);
  const a = hash2(xi, yi);
  const b = hash2(xi + 1, yi);
  const c = hash2(xi, yi + 1);
  const d = hash2(xi + 1, yi + 1);
  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}

/** Four octaves is enough texture at 45 m cells; more is invisible. */
export function fbm(x: number, y: number): number {
  let f = 0;
  let amp = 0.5;
  let freq = 1;
  for (let i = 0; i < 4; i++) {
    f += amp * valueNoise(x * freq, y * freq);
    freq *= 2.1;
    amp *= 0.5;
  }
  return f;
}

/**
 * Deterministic PRNG (mulberry32). Every scatter in the scene draws from one of
 * these so a reload produces the same city. A map that reshuffles its own
 * buildings between visits is a lava lamp, not a map.
 */
export function seededRandom(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (s + 0x6d2b79f5) >>> 0;
    let t = s;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
