/**
 * LANE-SIM — the deterministic heart of FKTRY.
 *
 * Headless: no DOM, no clock, no unseeded randomness. Same seed + same command
 * sequence = identical snapshots forever. Everything mutates through Commands;
 * everything observable leaves through snapshot() and drainEvents().
 */
import {
  TICKS_PER_SECOND,
  type BeltItem,
  type Command,
  type CommissionDef,
  type Dir,
  type EntityState,
  type GameData,
  type MachineDef,
  type RecipeDef,
  type RelicState,
  type ResearchState,
  type Sim,
  type SimEvent,
  type SimSnapshot,
  type TechDef,
  type Vec2,
  type WildlifeState,
} from '../contracts';
import { DIR_VEC, footprintOf, inBounds, tileKey } from './geom';
import { makeRng, type Rng } from './rng';

// The tuning constants live in ./constants so other lanes' tests can import truth
// instead of hand-copying numbers that go stale in silence.
import {
  BELT_CAPACITY,
  BELT_SPACING,
  DUST_GROWTH_PER_HELD,
  DUST_ITEM,
  DUST_PILE_MAX,
  DUST_PILE_SPREAD,
  DUST_SPILL_GRACE,
  HEAT_COOL_DEFAULT,
  HEAT_COOL_MAX,
  HEAT_RESTART,
  HEAT_SCRAM,
  HEAT_THROTTLE_FLOOR,
  HEAT_THROTTLE_START,
  INPUT_BUFFER_FACTOR,
  MAX_TRACE,
  OUTPUT_STALL_FACTOR,
  RELIC_KEEPOUT,
  RELIC_DIG_RANGE,
  RELIC_MAX_DIST,
  RELIC_MIN_DIST,
  RESEARCH_TICKS_PER_PACK,
  SPLITTER_CAPACITY,
  SWARM_ATTACH_RANGE,
  SWARM_DRIFT_TILES_PER_SEC,
  SWARM_ITEM,
  SWARM_SEVERITY_GROWTH,
  SWARM_MAX,
  SWARM_SLOW_FLOOR,
  TRAP_RADIUS,
} from './constants';

/**
 * Seam regions — where extractors may mine. This mirrors DATA's `data/seams.json` exactly.
 * SIM's rule is PRESENCE, not resource-gating: an extractor runs while its footprint overlaps
 * any seam rect, whatever that seam's `resource` says. `richness`/`era`/`hidden` are for DATA,
 * RENDER and a future data-driven extraction rate; this lane only reads the rects.
 *
 * Still consumed defensively — `GameData` has no `seams` field and main.ts doesn't load the
 * file yet, so absent it the whole map is minable. See NOTES round 5.
 */
export interface SeamDef {
  id: string;
  rect: { x: number; y: number; w: number; h: number };
  era?: string;
  resource?: string;
  richness?: number;
  hidden?: boolean;
}

export interface SeamMap {
  /** DATA keeps this corner seam-free so the seed-derived relic always has somewhere to land. */
  relicReserve?: { x: number; y: number; w: number; h: number };
  seams: SeamDef[];
}

/** Internal entity record. `state` is the contract-shaped object handed to consumers. */
interface Ent {
  state: EntityState;
  def: MachineDef;
  /** mid-craft: inputs already consumed, progress running */
  crafting: boolean;
  /** heat shutdown: no work, no generation, until it cools to HEAT_RESTART */
  scrammed: boolean;
  /** tile keys this entity occupies, for teardown */
  tiles: number[];
  /** kind==='splitter': items awaiting an open output, and the round-robin cursor */
  queue: Array<{ item: string; id: number }>;
  cursor: number;
  /** kind==='lab': ticks accrued toward converting the current pack (0..RESEARCH_TICKS_PER_PACK) */
  labTimer: number;
}

export function createSim(): Sim {
  let data: GameData = { items: [], machines: [], recipes: [], tech: [], commissions: [] };
  let rng: Rng = makeRng(0);

  const defs = new Map<string, MachineDef>();
  const recipeDefs = new Map<string, RecipeDef>();
  const commissionDefs = new Map<string, CommissionDef>();
  const techDefs = new Map<string, TechDef>();

  // ents and entityStates are parallel and always in ascending-id order — the tick
  // loop walks them in that order, which is what makes the whole sim deterministic.
  let ents: Ent[] = [];
  let entityStates: EntityState[] = [];
  const byId = new Map<number, Ent>();
  const occ = new Map<number, number>(); // tileKey -> entity id
  const beltContents = new Map<number, BeltItem[]>(); // belt id -> items, t descending
  let allBeltItems: BeltItem[] = [];
  /** belt id -> every machine its chain can reach (through splitters). [] = dead end/loop. */
  const traceCache = new Map<number, number[]>();
  /** entity id -> total cooling per tick (own rate + every aura reaching it), capped. */
  const coolCache = new Map<number, number>();

  let nextId = 1;
  let nextItemId = 1;
  let curTick = 0;
  let paused = false;
  let brownout = false;
  let stored = 0;
  let commissionQueue: string[] = [];

  /** every id any tech gates — locked until the tech that names it completes */
  const gatedIds = new Set<string>();
  /** ids the completed techs have opened up */
  const openedIds = new Set<string>();
  let research: ResearchState = { active: null, progress: {}, unlocked: [] };

  /** recipe ids that CAN a swarm (empty inputs, output includes SWARM_ITEM) — the traps */
  const trapRecipeIds = new Set<string>();
  /** authored seam rects; empty means "no seam map" → extractors mine anywhere */
  let seams: SeamDef[] = [];
  /** DATA's kept-clear corner for the relic, when the seam map supplies one */
  let relicReserve: SeamMap['relicReserve'];

  let wildlife: WildlifeState[] = [];
  let relics: RelicState[] = [];
  let nextWildlifeId = 1;
  let nextRelicId = 1;
  /** entity id -> attached swarm's severity this tick, for the speed penalty */
  const swarmBite = new Map<number, number>();

  const cmdQueue: Command[] = [];
  let events: SimEvent[] = [];
  /** items already advanced this tick, so a transfer downstream can't double-move */
  const moved = new Set<BeltItem>();
  const candidateBelts: number[] = [];

  const snap: SimSnapshot = {
    tick: 0,
    paused: false,
    entities: entityStates,
    beltItems: allBeltItems,
    bandwidth: { gen: 0, draw: 0, stored: 0, capacity: 0, brownout: false },
    shippedTotal: {},
    activeCommission: null,
    commissionQueue: [], // hardens to required in v5; always present
    commissionProgress: {},
    research,
    wildlife,
    relics,
  };

  // ------------------------------------------------------------------ lookups

  const entAt = (x: number, y: number): Ent | undefined => {
    const id = occ.get(tileKey(x, y));
    return id === undefined ? undefined : byId.get(id);
  };

  const recipeOf = (e: Ent): RecipeDef | null =>
    e.state.recipe === null ? null : recipeDefs.get(e.state.recipe) ?? null;

  /** The tile a belt feeds into. */
  const beltTarget = (e: Ent): Ent | undefined => {
    const v = DIR_VEC[e.state.dir];
    return entAt(e.state.pos.x + v.x, e.state.pos.y + v.y);
  };

  /** Static appetite: does this machine's recipe ever call for this item? */
  const machineWants = (e: Ent, item: string): boolean => {
    if (e.def.kind === 'shipper') return true;
    if (e.def.kind === 'lab') {
      // A lab's appetite is whatever the active tech costs — so it moves when the player
      // picks a different target, and setResearch has to drop the route cache.
      const t = research.active === null ? undefined : techDefs.get(research.active);
      return !!t && (t.cost[item] ?? 0) > 0;
    }
    const r = recipeOf(e);
    return !!r && (r.inputs[item] ?? 0) > 0;
  };

  /** Belts adjacent to this entity that don't point back into it. */
  function collectOutputBelts(e: Ent, out: number[]): void {
    out.length = 0;
    const fp = footprintOf(e.def.footprint, e.state.dir);
    for (let dx = 0; dx < fp.x; dx++) {
      for (let dy = 0; dy < fp.y; dy++) {
        const x = e.state.pos.x + dx;
        const y = e.state.pos.y + dy;
        for (let d = 0; d < 4; d++) {
          const v = DIR_VEC[d];
          const b = entAt(x + v.x, y + v.y);
          if (!b || b.def.kind !== 'belt') continue;
          const bv = DIR_VEC[b.state.dir];
          if (occ.get(tileKey(b.state.pos.x + bv.x, b.state.pos.y + bv.y)) === e.state.id) continue;
          if (!out.includes(b.state.id)) out.push(b.state.id);
        }
      }
    }
    out.sort((a, b) => a - b);
  }

  /**
   * Every machine a belt's chain can reach. Splitters fan out, so this is a set, not a
   * single terminal: a lane through a splitter can reach both a consumer and an uplink.
   * Cached; invalidated on any topology or recipe change.
   */
  function traceTerminals(beltId: number): number[] {
    const cached = traceCache.get(beltId);
    if (cached !== undefined) return cached;

    const result: number[] = [];
    const seen = new Set<number>();
    const stack: number[] = [beltId];
    const branch: number[] = [];
    while (stack.length && seen.size < MAX_TRACE) {
      const id = stack.pop()!;
      if (seen.has(id)) continue;
      seen.add(id);
      const e = byId.get(id);
      if (!e) continue;
      if (e.def.kind === 'splitter') {
        collectOutputBelts(e, branch);
        for (const b of branch) stack.push(b);
        continue;
      }
      const next = beltTarget(e);
      if (!next) continue; // dead end: items ride to the end and pile up
      if (next.def.kind === 'belt' || next.def.kind === 'splitter') {
        stack.push(next.state.id);
      } else if (!result.includes(next.state.id)) {
        result.push(next.state.id);
      }
    }
    result.sort((a, b) => a - b);
    traceCache.set(beltId, result);
    return result;
  }

  /**
   * How much a machine wants to hand this item to this belt — the best outcome reachable
   * down that lane:
   *   2 = it reaches a machine that consumes this item
   *   1 = it only reaches an uplink (takes anything — a sink, not a destination)
   *   0 = dead end or loop (items just pile; keeps a half-built belt from feeling broken)
   *  -1 = every machine it reaches will refuse this item — pushing here deadlocks the lane
   */
  function outputBeltRank(beltId: number, item: string): number {
    const terms = traceTerminals(beltId);
    if (!terms.length) return 0;
    let best = -1;
    for (const t of terms) {
      const e = byId.get(t);
      if (!e) continue;
      const r = e.def.kind === 'shipper' ? 1 : machineWants(e, item) ? 2 : -1;
      if (r > best) best = r;
    }
    return best;
  }

  function beltHasRoom(beltId: number, atT: number): boolean {
    const items = beltContents.get(beltId);
    if (!items || items.length >= BELT_CAPACITY) return false;
    const rear = items[items.length - 1];
    return !rear || rear.t - atT >= BELT_SPACING;
  }

  function spawnOnBelt(beltId: number, item: string, id: number): void {
    const it: BeltItem = { item, entity: beltId, t: 0, id };
    beltContents.get(beltId)!.push(it);
    allBeltItems.push(it);
  }

  function removeFlat(it: BeltItem): void {
    const i = allBeltItems.indexOf(it);
    if (i >= 0) allBeltItems.splice(i, 1);
  }

  function setJam(e: Ent, reason: string | null): void {
    if (e.state.jammed === reason) return; // edge-triggered: one event per stall
    e.state.jammed = reason;
    if (reason !== null) events.push({ kind: 'jammed', entity: e.state.id, reason, tick: curTick });
  }

  /** Splitter contents are mirrored into inputBuf so the inspector can see them. */
  function syncSplitter(e: Ent): void {
    const buf: Record<string, number> = {};
    for (const q of e.queue) buf[q.item] = (buf[q.item] ?? 0) + 1;
    e.state.inputBuf = buf;
  }

  /**
   * v4 gating: an id named by ANY tech's `unlocks` is locked until that tech completes.
   * Ids no tech mentions are free forever — absence from the tech tree means available,
   * not forbidden, so a data file with no tech at all gates nothing.
   */
  const isAvailable = (id: string): boolean => !gatedIds.has(id) || openedIds.has(id);

  /** The first recipe a machine can legally run — its listed order is its preference. */
  const defaultRecipe = (def: MachineDef): string | null =>
    def.recipes.find((r) => isAvailable(r)) ?? null;

  /** Chebyshev gap between two footprints, in tiles. 0 = touching or overlapping. */
  function rectGap(a: Ent, b: Ent): number {
    const af = footprintOf(a.def.footprint, a.state.dir);
    const bf = footprintOf(b.def.footprint, b.state.dir);
    const ax1 = a.state.pos.x + af.x - 1, ay1 = a.state.pos.y + af.y - 1;
    const bx1 = b.state.pos.x + bf.x - 1, by1 = b.state.pos.y + bf.y - 1;
    const dx = Math.max(0, a.state.pos.x - bx1, b.state.pos.x - ax1);
    const dy = Math.max(0, a.state.pos.y - by1, b.state.pos.y - ay1);
    return Math.max(dx, dy);
  }

  /**
   * v4 spatial cooling: a machine sheds its own `coolPerTick`, plus the `coolPerTick` of
   * every machine whose `coolRadius` aura reaches it. Auras stack by sum and the total is
   * capped (HEAT_COOL_MAX) so a wall of coolers can't make heat meaningless. A cooler does
   * not cool itself twice — its own rate is already counted. Cached; topology changes clear it.
   */
  function coolingOf(e: Ent): number {
    const cached = coolCache.get(e.state.id);
    if (cached !== undefined) return cached;
    let total = e.def.coolPerTick ?? HEAT_COOL_DEFAULT;
    for (const c of ents) {
      if (c === e) continue;
      const radius = c.def.coolRadius ?? 0;
      if (radius <= 0 || !c.def.coolPerTick) continue;
      if (rectGap(c, e) <= radius) total += c.def.coolPerTick;
    }
    if (total > HEAT_COOL_MAX) total = HEAT_COOL_MAX;
    coolCache.set(e.state.id, total);
    return total;
  }

  /** Scrammed and idle machines contribute nothing; hot ones work at reduced speed. */
  function heatThrottle(e: Ent): number {
    if (e.scrammed) return 0;
    const h = e.state.heat;
    if (h <= HEAT_THROTTLE_START) return 1;
    const over = (h - HEAT_THROTTLE_START) / (1 - HEAT_THROTTLE_START);
    return 1 - over * (1 - HEAT_THROTTLE_FLOOR);
  }

  // ----------------------------------------------------------------- commands

  function doPlace(def: MachineDef, pos: { x: number; y: number }, dir: Dir): void {
    if (!isAvailable(def.id)) return; // locked behind research: silently dropped
    const fp = footprintOf(def.footprint, dir);
    const tiles: number[] = [];
    for (let dx = 0; dx < fp.x; dx++) {
      for (let dy = 0; dy < fp.y; dy++) {
        const x = pos.x + dx;
        const y = pos.y + dy;
        if (!inBounds(x, y)) return;
        const k = tileKey(x, y);
        if (occ.has(k)) return;
        tiles.push(k);
      }
    }
    const state: EntityState = {
      id: nextId++,
      def: def.id,
      pos: { x: pos.x, y: pos.y },
      dir,
      recipe: defaultRecipe(def), // first recipe research has actually opened
      progress: 0,
      inputBuf: {},
      outputBuf: {},
      jammed: null,
      heat: 0,
      scrammed: false,
    };
    const e: Ent = {
      state, def, crafting: false, scrammed: false, tiles, queue: [], cursor: 0, labTimer: 0,
    };
    ents.push(e);
    entityStates.push(state);
    byId.set(state.id, e);
    for (const k of tiles) occ.set(k, state.id);
    if (def.kind === 'belt') beltContents.set(state.id, []);
    traceCache.clear();
    coolCache.clear(); // auras are geometric: any move re-evaluates them
    events.push({ kind: 'placed', entity: state.id, def: def.id, tick: curTick });
  }

  function doRemove(pos: { x: number; y: number }): void {
    const e = entAt(pos.x, pos.y);
    if (!e) return;
    const id = e.state.id;
    for (const k of e.tiles) occ.delete(k);
    byId.delete(id);
    const i = ents.indexOf(e);
    ents.splice(i, 1);
    entityStates.splice(i, 1);
    const items = beltContents.get(id);
    if (items) {
      for (const it of items) removeFlat(it); // cargo dies with the belt
      beltContents.delete(id);
    }
    traceCache.clear();
    coolCache.clear(); // auras are geometric: any move re-evaluates them
    events.push({ kind: 'removed', entity: id, def: e.def.id, tick: curTick });
  }

  function doRotate(pos: { x: number; y: number }): void {
    const e = entAt(pos.x, pos.y);
    if (!e) return;
    const dir = ((e.state.dir + 1) % 4) as Dir;
    const fp = footprintOf(e.def.footprint, dir);
    const tiles: number[] = [];
    for (let dx = 0; dx < fp.x; dx++) {
      for (let dy = 0; dy < fp.y; dy++) {
        const x = e.state.pos.x + dx;
        const y = e.state.pos.y + dy;
        if (!inBounds(x, y)) return;
        const k = tileKey(x, y);
        const owner = occ.get(k);
        if (owner !== undefined && owner !== e.state.id) return; // rotation would collide
        tiles.push(k);
      }
    }
    for (const k of e.tiles) occ.delete(k);
    for (const k of tiles) occ.set(k, e.state.id);
    e.tiles = tiles;
    e.state.dir = dir;
    traceCache.clear();
    coolCache.clear(); // auras are geometric: any move re-evaluates them
  }

  function doSetRecipe(entity: number, recipe: string | null): void {
    const e = byId.get(entity);
    if (!e) return;
    setRecipeOn(e, recipe);
  }

  function setRecipeOn(e: Ent, recipe: string | null): void {
    if (recipe !== null && !e.def.recipes.includes(recipe)) return;
    if (recipe !== null && !isAvailable(recipe)) return; // locked behind research
    e.state.recipe = recipe;
    e.state.progress = 0;
    e.crafting = false;
    e.state.jammed = null;
    traceCache.clear(); // routing keys off what machines want, which just changed
  }

  function applyCommand(cmd: Command): void {
    switch (cmd.kind) {
      case 'place': {
        const def = defs.get(cmd.def);
        if (def) doPlace(def, cmd.pos, cmd.dir);
        break;
      }
      case 'remove': doRemove(cmd.pos); break;
      case 'rotate': doRotate(cmd.pos); break;
      case 'setRecipe': doSetRecipe(cmd.entity, cmd.recipe); break;
      case 'setResearch': doSetResearch(cmd.tech); break;
      case 'grantResearch': doGrantResearch(cmd.tech); break;
      case 'excavate': doExcavate(cmd.pos); break;
      case 'setRecipeAt': {
        const e = entAt(cmd.pos.x, cmd.pos.y); // id-free: any tile of the footprint works
        if (e) setRecipeOn(e, cmd.recipe);
        break;
      }
      case 'setPaused': paused = cmd.paused; snap.paused = paused; break;
    }
  }

  // -------------------------------------------------------------- tick phases

  /** Consume inputs and light the fuse. Runs before power so a starting craft pays this tick. */
  function startCrafts(): void {
    for (const e of ents) {
      if (e.def.kind !== 'crafter' && e.def.kind !== 'extractor') continue;
      if (e.scrammed || e.crafting) continue;
      const r = recipeOf(e);
      if (!r) continue;

      // An extractor is only a miner where there's something to mine.
      if (e.def.kind === 'extractor' && !onSeam(e)) { setJam(e, 'no seam'); continue; }

      let stalled = false;
      for (const k in r.outputs) {
        if ((e.state.outputBuf[k] ?? 0) >= r.outputs[k] * OUTPUT_STALL_FACTOR) { stalled = true; break; }
      }
      if (stalled) { setJam(e, 'output full'); continue; }

      // A trap runs on presence, not on a belt: no inputs, but it only closes on a swarm
      // that's actually in range. Idle traps look starved, which is honest — they're waiting.
      if (trapRecipeIds.has(r.id) && !swarmInTrapRange(e)) { setJam(e, 'starved'); continue; }

      let ready = true;
      let needsInput = false;
      for (const k in r.inputs) {
        needsInput = true;
        if ((e.state.inputBuf[k] ?? 0) < r.inputs[k]) { ready = false; break; }
      }
      if (!ready) { setJam(e, needsInput ? 'starved' : null); continue; }
      setJam(e, null);

      for (const k in r.inputs) {
        const left = (e.state.inputBuf[k] ?? 0) - r.inputs[k];
        if (left > 0) e.state.inputBuf[k] = left;
        else delete e.state.inputBuf[k];
      }
      e.crafting = true;
      e.state.progress = 0;
    }
  }

  /**
   * Bandwidth v3: gen vs draw with tank storage. Compression pays for itself — a recipe
   * with negative bandwidth (the quantizer) generates instead of drawing. Surplus charges
   * the tanks; a deficit drains them. Only once the tanks run dry does the factory brown
   * out and run at supplied/draw speed.
   *
   * UNITS (v3 ruling): gen and draw are bandwidth per SECOND; stored is bandwidth-SECONDS.
   * So a tick only ever moves one tick's worth of charge — surplus/TICKS_PER_SECOND — into
   * or out of the tanks. A 30/s deficit drains 30 stored per second, not per tick, which is
   * what makes DATA's bufferCap 240 the 8 seconds of cover they intended.
   */
  function computePower(): number {
    let gen = 0;
    let draw = 0;
    let capacity = 0;
    for (const e of ents) {
      draw += e.def.powerDraw; // a scrammed machine is stopped, not unplugged
      if (e.def.kind === 'buffer') capacity += e.def.bufferCap ?? 0;
      if (e.def.powerGen) gen += e.def.powerGen * heatThrottle(e);
      if (e.crafting && !e.scrammed) {
        const r = recipeOf(e);
        if (r) {
          if (r.bandwidth > 0) draw += r.bandwidth;
          else gen -= r.bandwidth;
        }
      }
    }
    if (stored > capacity) stored = capacity;

    let supplied = gen;
    if (gen >= draw) {
      const charge = Math.min((gen - draw) / TICKS_PER_SECOND, capacity - stored);
      if (charge > 0) stored += charge;
    } else {
      const wanted = (draw - gen) / TICKS_PER_SECOND; // bandwidth-seconds owed this tick
      const drawn = Math.min(wanted, stored);
      stored -= drawn;
      supplied = gen + drawn * TICKS_PER_SECOND;
    }

    // Epsilon: a tank covering a deficit exactly lands supplied on draw, and float drift
    // there would otherwise flicker the brownout flag on and off every tick.
    const on = supplied < draw - 1e-9;
    if (on !== brownout) {
      brownout = on;
      events.push({ kind: 'brownout', on, tick: curTick });
    }
    snap.bandwidth.gen = gen;
    snap.bandwidth.draw = draw;
    snap.bandwidth.stored = stored;
    snap.bandwidth.capacity = capacity;
    snap.bandwidth.brownout = on;
    return on ? (draw > 0 ? supplied / draw : 1) : 1;
  }

  function advanceCrafts(mult: number): void {
    for (const e of ents) {
      if (!e.crafting) continue;
      const r = recipeOf(e);
      if (!r) { e.crafting = false; continue; }
      const speed = mult * heatThrottle(e) * swarmThrottle(e);
      if (speed <= 0) continue; // scrammed mid-craft: progress holds, resumes on restart
      e.state.progress += speed / r.ticks;
      if (e.state.progress < 1) continue;

      // A trap only closes on a swarm still in range — one that drifted off or was caught by
      // a neighbour leaves this trap holding an empty jar, so it waits rather than canning air.
      if (trapRecipeIds.has(r.id)) {
        const prey = swarmInTrapRange(e);
        if (!prey) { e.state.progress = 1; continue; }
        catchSwarm(prey.id);
      }

      e.state.progress = 0;
      e.crafting = false;
      for (const k in r.outputs) e.state.outputBuf[k] = (e.state.outputBuf[k] ?? 0) + r.outputs[k];
      if (r.heat) e.state.heat = Math.min(1, e.state.heat + r.heat);
      events.push({ kind: 'crafted', entity: e.state.id, recipe: r.id, tick: curTick });
    }
  }

  /**
   * Heat v1: work heats, everything cools, hot machines throttle, boiling machines scram.
   *
   * Cooling is applied every tick whether or not the machine is working, so `heatPerTick`
   * is GROSS heat and the net climb is heatPerTick - coolPerTick. A machine whose
   * heatPerTick doesn't clear its cooling simply never overheats. That's what makes the
   * codex's ASIC coolers mean something — and it's the trap DATA is currently in; see NOTES.
   */
  function updateHeat(): void {
    for (const e of ents) {
      const hpt = e.def.heatPerTick ?? 0;
      // Most of a big factory is belts, and a belt can never be anything but stone cold.
      // Skipping them here is the difference between 59x and 70x realtime at 3,500 entities.
      if (hpt === 0 && e.state.heat === 0 && !e.scrammed) continue;
      const cool = coolingOf(e);
      const active = !e.scrammed && (e.def.kind === 'power' ? true : e.crafting);
      let h = e.state.heat + (active ? hpt : 0) - cool;
      if (h < 0) h = 0;
      else if (h > 1) h = 1;
      e.state.heat = h;

      if (!e.scrammed && h >= HEAT_SCRAM) {
        e.scrammed = true;
        e.state.scrammed = true;
        setJam(e, null); // scram is reported by its own event, not as a flow jam
        events.push({ kind: 'scram', entity: e.state.id, on: true, tick: curTick });
      } else if (e.scrammed && h < HEAT_RESTART) {
        e.scrammed = false;
        e.state.scrammed = false;
        events.push({ kind: 'scram', entity: e.state.id, on: false, tick: curTick });
      }
    }
  }

  function pushOutputs(): void {
    for (const e of ents) {
      if (e.def.kind !== 'crafter' && e.def.kind !== 'extractor') continue;
      let any = false;
      for (const k in e.state.outputBuf) if (e.state.outputBuf[k] > 0) { any = true; break; }
      if (!any) continue;
      collectOutputBelts(e, candidateBelts);
      if (!candidateBelts.length) continue;

      for (const item of Object.keys(e.state.outputBuf).sort()) {
        if ((e.state.outputBuf[item] ?? 0) <= 0) continue;
        // Pick the best-ranked lane for this item, then a belt of that rank with room.
        // Never fall back to a worse rank just because the good belt is momentarily
        // full — that's how coefficient packs end up shipped instead of moshed. A
        // splitter is the sanctioned way to route surplus somewhere else.
        let bestRank = -1;
        for (const bid of candidateBelts) {
          const r = outputBeltRank(bid, item);
          if (r > bestRank) bestRank = r;
        }
        if (bestRank < 0) continue;
        let target = -1;
        for (const bid of candidateBelts) {
          if (outputBeltRank(bid, item) !== bestRank || !beltHasRoom(bid, 0)) continue;
          target = bid;
          break;
        }
        if (target < 0) continue;

        spawnOnBelt(target, item, nextItemId++);
        const left = e.state.outputBuf[item] - 1;
        if (left > 0) e.state.outputBuf[item] = left;
        else delete e.state.outputBuf[item];
      }
    }
  }

  /**
   * Splitters are deliberately dumb: round-robin across every outward-facing belt, and a
   * blocked output is skipped rather than waited on. That skip is what makes them the
   * factory's pressure valve — when the good lane backs up, the surplus keeps moving.
   */
  function stepSplitters(): void {
    for (const e of ents) {
      if (e.def.kind !== 'splitter' || !e.queue.length) continue;
      collectOutputBelts(e, candidateBelts);
      if (!candidateBelts.length) continue;
      let dirty = false;
      while (e.queue.length) {
        let sent = false;
        for (let n = 0; n < candidateBelts.length; n++) {
          const bid = candidateBelts[e.cursor % candidateBelts.length];
          e.cursor = (e.cursor + 1) % candidateBelts.length;
          if (!beltHasRoom(bid, 0)) continue;
          const q = e.queue.shift()!;
          spawnOnBelt(bid, q.item, q.id); // keeps its id across the splitter
          sent = true;
          dirty = true;
          break;
        }
        if (!sent) break;
      }
      if (dirty) syncSplitter(e);
    }
  }

  /** Belt -> machine. Rejection is not failure: the item waits and the line backs up. */
  function tryPushToMachine(e: Ent, item: string, id: number): boolean {
    if (e.def.kind === 'shipper') {
      e.state.inputBuf[item] = (e.state.inputBuf[item] ?? 0) + 1;
      return true;
    }
    if (e.def.kind === 'splitter') {
      if (e.queue.length >= SPLITTER_CAPACITY) return false;
      e.queue.push({ item, id });
      syncSplitter(e);
      return true;
    }
    if (e.def.kind === 'lab') {
      // Only take packs the active tech still needs, and only as many as it still needs —
      // counting what's already delivered AND what every lab is holding, so a bank of labs
      // can't collectively hoard more than the tech costs and strand the surplus. (Processing
      // now takes time, which makes over-hoarding much more visible than it was in v4.)
      const t = research.active === null ? undefined : techDefs.get(research.active);
      if (!t) return false;
      const need = t.cost[item] ?? 0;
      if (need <= 0) return false;
      let held = 0;
      for (const l of ents) if (l.def.kind === 'lab') held += l.state.inputBuf[item] ?? 0;
      if ((research.progress[item] ?? 0) + held >= need) return false;
      e.state.inputBuf[item] = (e.state.inputBuf[item] ?? 0) + 1;
      return true;
    }
    const r = recipeOf(e);
    if (!r) return false;
    const need = r.inputs[item] ?? 0;
    if (need <= 0) return false;
    const have = e.state.inputBuf[item] ?? 0;
    if (have >= need * INPUT_BUFFER_FACTOR) return false;
    e.state.inputBuf[item] = have + 1;
    return true;
  }

  /**
   * Advance one item. Returns true if it left this belt (and was spliced out of `items`).
   * `aheadT` is the item in front on this same belt, already moved this tick, or null
   * if this is the front item.
   */
  function advanceItem(
    belt: Ent, it: BeltItem, aheadT: number | null, step: number,
    items: BeltItem[], idx: number,
  ): boolean {
    let target = it.t + step;

    if (aheadT !== null) {
      const cap = aheadT - BELT_SPACING;
      if (target > cap) target = cap;
      if (target > it.t) it.t = target;
      return false;
    }

    if (target >= 1) {
      const next = beltTarget(belt);
      if (next && next.def.kind === 'belt') {
        const nItems = beltContents.get(next.state.id)!;
        const rear = nItems[nItems.length - 1];
        if (nItems.length < BELT_CAPACITY) {
          let entryT = target - 1;
          if (rear && rear.t - entryT < BELT_SPACING) entryT = rear.t - BELT_SPACING;
          if (entryT >= 0) {
            items.splice(idx, 1);
            it.entity = next.state.id;
            it.t = entryT; // strictly behind `rear`, so the t-descending order holds
            nItems.push(it);
            moved.add(it);
            return true;
          }
        }
        // Blocked. Hold station one spacing behind the item across the seam, so the
        // gap stays honest instead of everything bunching at the tile boundary.
        let cap = 1;
        if (rear) {
          const c = 1 + rear.t - BELT_SPACING;
          if (c < cap) cap = c;
        }
        if (target > cap) target = cap;
      } else if (next && tryPushToMachine(next, it.item, it.id)) {
        items.splice(idx, 1);
        removeFlat(it);
        return true;
      } else if (target > 1) {
        target = 1; // machine full, or nothing there: park at the lip
      }
    }

    if (target > it.t) it.t = target; // items never travel backwards
    return false;
  }

  function moveBelts(mult: number): void {
    moved.clear();
    for (const e of ents) {
      if (e.def.kind !== 'belt') continue;
      const step = ((e.def.beltSpeed ?? 0) / TICKS_PER_SECOND) * mult;
      const items = beltContents.get(e.state.id)!;
      let i = 0;
      while (i < items.length) {
        const it = items[i];
        if (moved.has(it)) { i++; continue; }
        // On a transfer the item is spliced out at i, so whatever slid into this slot
        // is now the front item and gets its own shot this tick — don't advance i.
        if (advanceItem(e, it, i > 0 ? items[i - 1].t : null, step, items, i)) continue;
        i++;
      }
    }
  }

  /** Rebuilds the opened-id set from the completed tech list. */
  function applyUnlocks(): void {
    openedIds.clear();
    for (const id of research.unlocked) {
      const t = techDefs.get(id);
      if (t) for (const u of t.unlocks) openedIds.add(u);
    }
    traceCache.clear(); // what a machine will accept can change when a recipe opens up
  }

  function doSetResearch(tech: string | null): void {
    if (tech !== null) {
      if (!techDefs.has(tech)) return;                  // unknown
      if (research.unlocked.includes(tech)) return;     // already done
    }
    if (research.active === tech) return;
    research.active = tech;
    // Progress is per-active-tech: switching targets abandons the packs already delivered
    // rather than banking them. Packs are the cost of indecision. Labs mid-pack reset too —
    // that half-processed pack is forfeit.
    research.progress = {};
    for (const e of ents) if (e.def.kind === 'lab') { e.labTimer = 0; e.state.progress = 0; }
    snap.research = research;
    traceCache.clear(); // a lab's appetite just changed, so lanes into it re-rank
  }

  /** Idempotent instant unlock — the sandbox/demo verb. Marks each real, un-researched tech done. */
  function doGrantResearch(techs: string[]): void {
    let changed = false;
    for (const id of techs) {
      if (!techDefs.has(id) || research.unlocked.includes(id)) continue;
      research.unlocked.push(id);
      changed = true;
      events.push({ kind: 'researched', tech: id, tick: curTick });
    }
    if (!changed) return;
    if (research.active !== null && research.unlocked.includes(research.active)) {
      research.active = null; // granting the active target completes it out from under the labs
      research.progress = {};
    }
    applyUnlocks();
    snap.research = research;
  }

  /**
   * Labs turn delivered science packs into research progress — but over time, not instantly.
   * Each lab spends RESEARCH_TICKS_PER_PACK ticks per pack, so a tech is an investment; more
   * labs process more packs in parallel. Stepped: a whole pack is consumed and progress ticks
   * up by exactly 1 only when the timer completes, keeping `research.progress` integer.
   */
  function drainLabs(): void {
    const t = research.active === null ? undefined : techDefs.get(research.active);
    for (const e of ents) {
      if (e.def.kind !== 'lab') continue;
      if (!t) { e.labTimer = 0; e.state.progress = 0; continue; }

      // Find a pack this lab holds that the tech still needs (accounting for progress and
      // whatever other labs are also mid-processing this tick — but the simple, deterministic
      // rule is: pick the lowest-sorted needed pack this lab physically has).
      const buf = e.state.inputBuf;
      let picked: string | null = null;
      for (const item of Object.keys(buf).sort()) {
        if ((buf[item] ?? 0) <= 0) continue;
        if ((t.cost[item] ?? 0) - (research.progress[item] ?? 0) <= 0) continue;
        picked = item;
        break;
      }
      if (picked === null) { e.labTimer = 0; e.state.progress = 0; continue; }

      e.labTimer += 1;
      e.state.progress = e.labTimer / RESEARCH_TICKS_PER_PACK; // UI/render see a lab working
      if (e.labTimer < RESEARCH_TICKS_PER_PACK) continue;

      e.labTimer = 0;
      e.state.progress = 0;
      const left = buf[picked] - 1;
      if (left > 0) buf[picked] = left;
      else delete buf[picked];
      research.progress[picked] = (research.progress[picked] ?? 0) + 1;
    }
    if (!t) return;
    for (const k in t.cost) if ((research.progress[k] ?? 0) < t.cost[k]) return;

    research.unlocked.push(t.id);
    research.active = null;
    research.progress = {};
    applyUnlocks();
    events.push({ kind: 'researched', tech: t.id, tick: curTick });
  }

  /** Activates the commission at the head of the queue and zeroes its progress. */
  function activateCommission(): void {
    const id = commissionQueue[0] ?? null;
    snap.activeCommission = id;
    snap.commissionQueue = commissionQueue; // active first, then upcoming: UI's "next in tray"
    snap.commissionProgress = {};
    const c = id === null ? undefined : commissionDefs.get(id);
    if (c) for (const k in c.wants) snap.commissionProgress[k] = 0;
  }

  function creditCommission(item: string, count: number): void {
    const id = commissionQueue[0];
    if (id === undefined) return;
    const c = commissionDefs.get(id);
    if (!c) return;
    const want = c.wants[item] ?? 0;
    if (want <= 0) return; // progress only counts toward the active order
    snap.commissionProgress[item] = Math.min(want, (snap.commissionProgress[item] ?? 0) + count);
    for (const k in c.wants) {
      if ((snap.commissionProgress[k] ?? 0) < c.wants[k]) return;
    }
    events.push({ kind: 'commissionDone', commission: c.id, tick: curTick });
    commissionQueue.shift();
    if (c.repeat) commissionQueue.push(c.id); // standing order: back of the queue
    activateCommission();
  }

  function drainShippers(): void {
    for (const e of ents) {
      if (e.def.kind !== 'shipper') continue;
      const buf = e.state.inputBuf;
      for (const item of Object.keys(buf).sort()) {
        const n = buf[item];
        if (!n) continue;
        delete buf[item];
        snap.shippedTotal[item] = (snap.shippedTotal[item] ?? 0) + n;
        events.push({ kind: 'shipped', item, count: n, tick: curTick });
        creditCommission(item, n);
      }
    }
  }

  // ------------------------------------------------------------------ seams

  /** True when any tile of this entity's footprint sits inside an authored seam region. */
  function onSeam(e: Ent): boolean {
    if (!seams.length) return true; // no seam map authored: the whole world is minable
    const fp = footprintOf(e.def.footprint, e.state.dir);
    for (const { rect: s } of seams) {
      if (e.state.pos.x + fp.x - 1 < s.x || e.state.pos.x > s.x + s.w - 1) continue;
      if (e.state.pos.y + fp.y - 1 < s.y || e.state.pos.y > s.y + s.h - 1) continue;
      return true;
    }
    return false;
  }

  const posOnSeam = (x: number, y: number): boolean =>
    seams.some(({ rect: s }) => x >= s.x && x < s.x + s.w && y >= s.y && y < s.y + s.h);

  // ------------------------------------------------------------------ relic

  /**
   * The buried optical fossil: one per world, its position a pure function of the seed. It
   * sits 18–28 tiles out (past the starter sprawl), never on a seam (you'd hit it mining,
   * which isn't finding it — it's an accident), and never inside the origin core where the
   * reference factory lives. Nothing signposts it; the only lock is looking.
   */
  function placeRelic(): void {
    relics = [];
    nextRelicId = 1;
    let pos: Vec2 | null = null;
    for (let tries = 0; tries < 512 && pos === null; tries++) {
      const ang = rng.next() * Math.PI * 2;
      const dist = RELIC_MIN_DIST + rng.next() * (RELIC_MAX_DIST - RELIC_MIN_DIST);
      let x = Math.round(Math.cos(ang) * dist);
      let y = Math.round(Math.sin(ang) * dist);
      // DATA keeps a corner deliberately clear for this. Honour it when it's supplied:
      // fold the roll into the reserve so the secret lands on ground nobody builds on.
      if (relicReserve) {
        x = relicReserve.x + Math.abs(x) % relicReserve.w;
        y = relicReserve.y + Math.abs(y) % relicReserve.h;
      }
      if (!inBounds(x, y)) continue;
      const d = Math.hypot(x, y);
      if (d < RELIC_MIN_DIST || d > RELIC_MAX_DIST) continue; // rounding can drift it out
      if (RELIC_KEEPOUT.some((k) => x >= k.x0 && x <= k.x1 && y >= k.y0 && y <= k.y1)) continue;
      if (posOnSeam(x, y)) continue;
      pos = { x, y };
    }
    // A world so seamed-over that 512 rolls all failed still gets its relic; the corner is
    // deterministic, so this stays reproducible rather than silently dropping the secret.
    if (pos === null) pos = { x: RELIC_MAX_DIST, y: 0 };
    relics = [{ id: nextRelicId++, kind: 'optical-fossil', pos, found: false }];
    snap.relics = relics;
  }

  function doExcavate(pos: Vec2): void {
    for (const r of relics) {
      if (r.found) continue;
      if (Math.abs(r.pos.x - pos.x) > RELIC_DIG_RANGE) continue;
      if (Math.abs(r.pos.y - pos.y) > RELIC_DIG_RANGE) continue;
      r.found = true;
      events.push({ kind: 'relicFound', relic: r.kind, id: r.id, tick: curTick });
      return; // one dig, one relic
    }
    // Digging anywhere else is not an error — it's just dirt.
  }

  // --------------------------------------------------------------- wildlife

  const wildlifeEvent = (on: 'spawned' | 'cleared', w: WildlifeState): void => {
    events.push({
      kind: 'wildlife', on, wildlife: w.kind, id: w.id,
      pos: { x: w.pos.x, y: w.pos.y }, tick: curTick,
    });
  };

  /** Chebyshev distance from a point to an entity's footprint (0 = inside/touching). */
  function gapToEntity(e: Ent, x: number, y: number): number {
    const fp = footprintOf(e.def.footprint, e.state.dir);
    const dx = Math.max(0, e.state.pos.x - x, x - (e.state.pos.x + fp.x - 1));
    const dy = Math.max(0, e.state.pos.y - y, y - (e.state.pos.y + fp.y - 1));
    return Math.max(dx, dy);
  }

  /**
   * The dust→swarm loop. Piles grow from the HF dust a machine is holding rather than from
   * the machine merely running: belt your dust to an uplink and the buffer stays near empty
   * and nothing ever hatches. Let it back up to the stall cap and you are farming mosquitoes.
   * That asymmetry is the whole design — the hazard is a consequence of bad plumbing.
   */
  function updateWildlife(mult: number): void {
    swarmBite.clear();

    // 1. dust accumulates near machines whose dust is BACKED UP — not merely present.
    // A buffer that fills and drains between belt pushes is a working factory; only what
    // piles beyond one craft's worth is neglect, and only neglect breeds.
    for (const e of ents) {
      const held = e.state.outputBuf[DUST_ITEM] ?? 0;
      if (held <= 0) continue;
      const perCraft = recipeOf(e)?.outputs[DUST_ITEM] ?? 0;
      const spill = held - Math.max(1, perCraft * DUST_SPILL_GRACE);
      if (spill <= 0) continue;
      let pile = wildlife.find(
        (w) => w.kind === 'dust-pile' && gapToEntity(e, w.pos.x, w.pos.y) <= DUST_PILE_SPREAD,
      );
      if (!pile) {
        const fp = footprintOf(e.def.footprint, e.state.dir);
        // The seeded RNG finally rolls: where the pile settles is arbitrary but reproducible.
        const px = e.state.pos.x + rng.int(fp.x + DUST_PILE_SPREAD * 2) - DUST_PILE_SPREAD;
        const py = e.state.pos.y + rng.int(fp.y + DUST_PILE_SPREAD * 2) - DUST_PILE_SPREAD;
        if (!inBounds(px, py)) continue;
        pile = { id: nextWildlifeId++, kind: 'dust-pile', pos: { x: px, y: py }, size: 0 };
        wildlife.push(pile);
        wildlifeEvent('spawned', pile);
      }
      pile.size += DUST_GROWTH_PER_HELD * spill * mult;
    }

    // 2. a full pile hatches — up to the population cap, past which full piles just sit
    // there. A machine wedged on dust forever would otherwise breed a plague.
    let liveSwarms = 0;
    for (const w of wildlife) if (w.kind === 'mosquito-swarm') liveSwarms++;
    for (let i = wildlife.length - 1; i >= 0; i--) {
      const w = wildlife[i];
      if (w.kind !== 'dust-pile' || w.size < DUST_PILE_MAX) continue;
      if (liveSwarms >= SWARM_MAX) { w.size = DUST_PILE_MAX; continue; }
      liveSwarms++;
      wildlife.splice(i, 1);
      wildlifeEvent('cleared', w);
      const swarm: WildlifeState = {
        id: nextWildlifeId++, kind: 'mosquito-swarm', pos: { x: w.pos.x, y: w.pos.y }, size: 0,
      };
      wildlife.push(swarm);
      wildlifeEvent('spawned', swarm);
    }

    // 3. swarms hunt: drift to the nearest working machine and bite it
    const step = (SWARM_DRIFT_TILES_PER_SEC / TICKS_PER_SECOND) * mult;
    for (const w of wildlife) {
      if (w.kind !== 'mosquito-swarm') continue;
      // Production machinery only — never power, belts, splitters or uplinks. A power
      // plant reads as permanently "running" and would hoover up every swarm in the world
      // to no effect, which is exactly as silly on screen as it sounds.
      //
      // Working machines are preferred, but a jammed one still gets settled on: the dust
      // that bred these came from a machine that seized, and a swarm with no victim would
      // simply evaporate the moment it mattered. It waits, and bites when work resumes.
      let best: Ent | undefined;
      let bestGap = Infinity;
      let bestWorking = false;
      for (const e of ents) {
        if (e.def.kind !== 'crafter' && e.def.kind !== 'extractor') continue;
        if (e.state.recipe === null) continue;
        const working = e.crafting;
        const g = gapToEntity(e, w.pos.x, w.pos.y);
        const better = !best
          || (working && !bestWorking)                                  // a live target wins
          || (working === bestWorking && (g < bestGap                   // then the nearest
            || (g === bestGap && e.state.id < best.state.id)));         // ties on id, never luck
        if (better) {
          best = e;
          bestGap = g;
          bestWorking = working;
        }
      }
      if (!best) { w.target = undefined; continue; }
      w.target = best.state.id;

      if (bestGap > SWARM_ATTACH_RANGE) {
        const fp = footprintOf(best.def.footprint, best.state.dir);
        const cx = best.state.pos.x + (fp.x - 1) / 2;
        const cy = best.state.pos.y + (fp.y - 1) / 2;
        const dx = cx - w.pos.x;
        const dy = cy - w.pos.y;
        const len = Math.hypot(dx, dy) || 1;
        w.pos.x += (dx / len) * step;
        w.pos.y += (dy / len) * step;
        continue;
      }
      // Attached: it gets worse the longer you leave it.
      w.size = Math.min(1, w.size + SWARM_SEVERITY_GROWTH * mult);
      swarmBite.set(best.state.id, Math.max(swarmBite.get(best.state.id) ?? 0, w.size));
    }
  }

  /** Speed multiplier from an attached swarm: 1 when clean, SWARM_SLOW_FLOOR at full severity. */
  const swarmThrottle = (e: Ent): number => {
    const sev = swarmBite.get(e.state.id);
    return sev === undefined ? 1 : 1 - sev * (1 - SWARM_SLOW_FLOOR);
  };

  /** Is a swarm close enough for this trap to catch? */
  const swarmInTrapRange = (e: Ent): WildlifeState | undefined => {
    let best: WildlifeState | undefined;
    let bestGap = Infinity;
    for (const w of wildlife) {
      if (w.kind !== 'mosquito-swarm') continue;
      const g = gapToEntity(e, Math.round(w.pos.x), Math.round(w.pos.y));
      if (g <= TRAP_RADIUS && (g < bestGap || (best && g === bestGap && w.id < best.id))) {
        best = w;
        bestGap = g;
      }
    }
    return best;
  };

  function catchSwarm(id: number): void {
    const i = wildlife.findIndex((w) => w.id === id);
    if (i < 0) return;
    const [w] = wildlife.splice(i, 1);
    wildlifeEvent('cleared', w);
  }

  // --------------------------------------------------------------------- api

  return {
    init(gameData: GameData, seed: number): void {
      data = gameData;
      rng = makeRng(seed);
      void rng; // seeded and ready; nothing rolls dice yet

      defs.clear();
      recipeDefs.clear();
      commissionDefs.clear();
      techDefs.clear();
      gatedIds.clear();
      for (const m of gameData.machines) defs.set(m.id, m);
      for (const r of gameData.recipes) recipeDefs.set(r.id, r);
      for (const c of gameData.commissions) commissionDefs.set(c.id, c);
      for (const t of gameData.tech) {
        techDefs.set(t.id, t);
        for (const u of t.unlocks) gatedIds.add(u); // named by a tech => locked until it lands
      }
      // A trap is any recipe that cans a live swarm out of thin air — no inputs, swarm out.
      // Data-driven so DATA can name the machine whatever it likes.
      trapRecipeIds.clear();
      for (const r of gameData.recipes) {
        if ((r.outputs[SWARM_ITEM] ?? 0) > 0 && Object.keys(r.inputs).length === 0) {
          trapRecipeIds.add(r.id);
        }
      }
      // Seams are optional until DATA authors them + the orchestrator wires GameData.seams;
      // absent, the whole map is minable and nothing changes. See NOTES round 5.
      // Accepts DATA's file shape ({relicReserve, seams:[...]}) or a bare rect list.
      const rawSeams = (gameData as GameData & { seams?: SeamMap | SeamDef[] }).seams;
      seams = Array.isArray(rawSeams) ? rawSeams : rawSeams?.seams ?? [];
      relicReserve = Array.isArray(rawSeams) ? undefined : rawSeams?.relicReserve;

      ents = [];
      entityStates = [];
      allBeltItems = [];
      byId.clear();
      occ.clear();
      beltContents.clear();
      traceCache.clear();
      coolCache.clear();
      cmdQueue.length = 0;
      events = [];
      moved.clear();

      nextId = 1;
      nextItemId = 1;
      curTick = 0;
      paused = false;
      brownout = false;
      stored = 0;
      commissionQueue = data.commissions.map((c) => c.id);
      research = { active: null, progress: {}, unlocked: [] };
      applyUnlocks();

      wildlife = [];
      nextWildlifeId = 1;
      swarmBite.clear();

      snap.tick = 0;
      snap.paused = false;
      snap.entities = entityStates;
      snap.beltItems = allBeltItems;
      snap.bandwidth = { gen: 0, draw: 0, stored: 0, capacity: 0, brownout: false };
      snap.shippedTotal = {};
      snap.research = research;
      snap.wildlife = wildlife;
      activateCommission();
      // Rolled here because it needs `seams` loaded, and it is the RNG stream's first
      // consumer either way — nothing else rolls during init, so the relic site is a pure
      // function of the seed, not of what else happened to happen first.
      placeRelic();
    },

    enqueue(cmd: Command): void {
      cmdQueue.push(cmd);
    },

    tick(): void {
      for (const cmd of cmdQueue) applyCommand(cmd); // build while paused: allowed
      cmdQueue.length = 0;
      if (paused) return; // tick is sim-time; pausing freezes it

      // Wildlife first: this tick's bites are decided before anything spends them, so the
      // slowdown a swarm inflicts lands on the same tick the player can see it attached.
      updateWildlife(1);
      startCrafts();
      const mult = computePower();
      advanceCrafts(mult);
      updateHeat();
      pushOutputs();
      stepSplitters();
      moveBelts(mult);
      drainShippers();
      drainLabs();

      curTick++;
      snap.tick = curTick;
    },

    snapshot(): SimSnapshot {
      return snap;
    },

    drainEvents(): SimEvent[] {
      const out = events;
      events = [];
      return out;
    },

    /**
     * Everything the tick loop reads, including the state the snapshot deliberately hides:
     * the crafting/scram latches, splitter queues and cursors, and the RNG position. Miss
     * any of those and a loaded save diverges a few hundred ticks later, which is exactly
     * the bug that never reproduces. `data` is not saved — load() into a sim init()'d with
     * the same GameData.
     */
    save(): string {
      return JSON.stringify({
        v: 4,
        tick: curTick,
        paused,
        brownout,
        stored,
        nextId,
        nextItemId,
        rng: rng.state(),
        commissionQueue,
        research,
        wildlife,
        relics,
        nextWildlifeId,
        nextRelicId,
        shippedTotal: snap.shippedTotal,
        commissionProgress: snap.commissionProgress,
        pending: cmdQueue, // commands enqueued but not yet applied
        entities: ents.map((e) => ({
          state: e.state,
          crafting: e.crafting,
          scrammed: e.scrammed,
          queue: e.queue,
          cursor: e.cursor,
          labTimer: e.labTimer,
        })),
        beltItems: allBeltItems,
      });
    },

    load(json: string): void {
      const s = JSON.parse(json);
      if (s.v !== 4) throw new Error(`sim.load: unsupported save version ${s.v}`);

      ents = [];
      entityStates = [];
      allBeltItems = [];
      byId.clear();
      occ.clear();
      beltContents.clear();
      traceCache.clear();
      coolCache.clear();
      cmdQueue.length = 0;
      events = [];
      moved.clear();

      // Research first: gating decides what a placement is even allowed to be, and the
      // entities below were placed under the saved rules, not a fresh sim's.
      research = s.research ?? { active: null, progress: {}, unlocked: [] };
      applyUnlocks();

      for (const rec of s.entities) {
        const def = defs.get(rec.state.def);
        if (!def) throw new Error(`sim.load: this GameData has no machine "${rec.state.def}"`);
        const state = rec.state as EntityState;
        const fp = footprintOf(def.footprint, state.dir);
        const tiles: number[] = [];
        for (let dx = 0; dx < fp.x; dx++) {
          for (let dy = 0; dy < fp.y; dy++) tiles.push(tileKey(state.pos.x + dx, state.pos.y + dy));
        }
        const e: Ent = {
          state, def, crafting: rec.crafting, scrammed: rec.scrammed,
          tiles, queue: rec.queue, cursor: rec.cursor, labTimer: rec.labTimer ?? 0,
        };
        ents.push(e);
        entityStates.push(state);
        byId.set(state.id, e);
        for (const k of tiles) occ.set(k, state.id);
        if (def.kind === 'belt') beltContents.set(state.id, []);
      }

      for (const it of s.beltItems as BeltItem[]) {
        allBeltItems.push(it);
        beltContents.get(it.entity)?.push(it);
      }
      // Belt contents are ordered front-first everywhere else; restore that invariant
      // rather than trusting the blob's ordering.
      for (const list of beltContents.values()) list.sort((a, b) => b.t - a.t);

      curTick = s.tick;
      paused = s.paused;
      brownout = s.brownout;
      stored = s.stored;
      nextId = s.nextId;
      nextItemId = s.nextItemId;
      rng.restore(s.rng);
      commissionQueue = s.commissionQueue;
      wildlife = s.wildlife ?? [];
      // A found relic stays found forever — that's the whole point of finding it.
      relics = s.relics ?? [];
      nextWildlifeId = s.nextWildlifeId ?? 1;
      nextRelicId = s.nextRelicId ?? 1;
      swarmBite.clear();
      for (const cmd of s.pending ?? []) cmdQueue.push(cmd);

      snap.tick = curTick;
      snap.paused = paused;
      snap.entities = entityStates;
      snap.beltItems = allBeltItems;
      snap.bandwidth = { gen: 0, draw: 0, stored, capacity: 0, brownout };
      snap.shippedTotal = s.shippedTotal;
      snap.commissionProgress = s.commissionProgress;
      snap.activeCommission = commissionQueue[0] ?? null;
      snap.commissionQueue = commissionQueue;
      snap.research = research;
      snap.wildlife = wildlife;
      snap.relics = relics;
    },
  };
}
