master
AdityaAngajala Publish 0.3.0 6d39e2e 2mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775
const DEFAULTS = Object.freeze({
  seed: "nitrogen",
  width: 900,
  pitch: 7.895,
  featureScale: 1,
  chunkRows: 96,
  density: 1,
  fill: 0.668,
  routeLength: 0.78,
  horizontalFlow: 0.82,
  flowAngle: 0,
  swirl: 0.28,
  swirlScale: 0.8,
  diagonalFlow: 0.58,
  jogLength: 0.72,
  channelAttraction: 0.78,
  longFlow: 0.18,
  edgeDensity: 0.22,
  maxRouteBudget: 86,
  routeGuardRows: 0,
});

const DIRECTIONS = Object.freeze([[1, 0], [0, 1], [-1, 0], [0, -1]]);

function clamp(value, minimum, maximum) {
  return Math.max(minimum, Math.min(maximum, value));
}

function hashSeed(value) {
  let hash = 2166136261;
  for (const character of String(value)) {
    hash ^= character.charCodeAt(0);
    hash = Math.imul(hash, 16777619);
  }
  return hash >>> 0;
}

function mix32(value) {
  value = Math.imul(value ^ (value >>> 16), 0x7feb352d);
  value = Math.imul(value ^ (value >>> 15), 0x846ca68b);
  return (value ^ (value >>> 16)) >>> 0;
}

function coordinateHash(seed, x, y, salt = 0) {
  let value = seed ^ Math.imul(x | 0, 0x9e3779b1);
  value ^= Math.imul(y | 0, 0x85ebca77);
  value ^= Math.imul(salt | 0, 0xc2b2ae3d);
  return mix32(value);
}

function unitHash(seed, x, y, salt = 0) {
  return coordinateHash(seed, x, y, salt) / 4294967296;
}

function mulberry32(seed) {
  return function random() {
    let value = (seed += 0x6d2b79f5);
    value = Math.imul(value ^ (value >>> 15), value | 1);
    value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
    return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
  };
}

function integer(random, minimum, maximum) {
  if (maximum <= minimum) return minimum;
  return Math.floor(random() * (maximum - minimum + 1)) + minimum;
}

function weighted(random, entries) {
  let total = 0;
  for (const [, weight] of entries) total += weight;
  let roll = random() * total;
  for (const [value, weight] of entries) {
    roll -= weight;
    if (roll <= 0) return value;
  }
  return entries.at(-1)?.[0];
}

function shuffle(random, values) {
  for (let index = values.length - 1; index > 0; index -= 1) {
    const swapIndex = integer(random, 0, index);
    [values[index], values[swapIndex]] = [values[swapIndex], values[index]];
  }
  return values;
}

function diagonalness(vector) {
  return 1 - Math.abs(Math.abs(vector[0]) - Math.abs(vector[1]));
}

function closestDirection(vector) {
  let result = 0;
  let bestScore = -Infinity;
  for (let index = 0; index < DIRECTIONS.length; index += 1) {
    const score = DIRECTIONS[index][0] * vector[0] + DIRECTIONS[index][1] * vector[1];
    if (score > bestScore) {
      bestScore = score;
      result = index;
    }
  }
  return result;
}

function simplify(path) {
  if (path.length < 3) return path.slice();
  const result = [path[0]];
  for (let index = 1; index < path.length - 1; index += 1) {
    const previous = result.at(-1);
    const current = path[index];
    const next = path[index + 1];
    const ax = Math.sign(current[0] - previous[0]);
    const ay = Math.sign(current[1] - previous[1]);
    const bx = Math.sign(next[0] - current[0]);
    const by = Math.sign(next[1] - current[1]);
    if (ax !== bx || ay !== by) result.push(current);
  }
  result.push(path.at(-1));
  return result;
}

function chamfer(path) {
  if (path.length < 3) return path.slice();
  const result = [path[0]];
  for (let index = 1; index < path.length - 1; index += 1) {
    const previous = path[index - 1];
    const current = path[index];
    const next = path[index + 1];
    const ax = current[0] - previous[0];
    const ay = current[1] - previous[1];
    const bx = next[0] - current[0];
    const by = next[1] - current[1];
    const incomingLength = Math.hypot(ax, ay);
    const outgoingLength = Math.hypot(bx, by);
    if (ax * bx + ay * by !== 0 || (ax === bx && ay === by)) {
      result.push(current);
      continue;
    }
    const inset = Math.min(0.38, incomingLength * 0.38, outgoingLength * 0.38);
    result.push(
      [current[0] - ax / incomingLength * inset, current[1] - ay / incomingLength * inset],
      [current[0] + bx / outgoingLength * inset, current[1] + by / outgoingLength * inset],
    );
  }
  result.push(path.at(-1));
  return result;
}

function pathLength(points) {
  let length = 0;
  for (let index = 1; index < points.length; index += 1) {
    length += Math.hypot(
      points[index][0] - points[index - 1][0],
      points[index][1] - points[index - 1][1],
    );
  }
  return length;
}

function normalizeOptions(options) {
  const config = { ...DEFAULTS, ...options };
  const featureScale = Math.max(0.25, Number(config.featureScale) || 1);
  const pitch = Math.max(1, Number(config.pitch) * featureScale || DEFAULTS.pitch);
  const requestedWidth = Math.max(240, Number(config.width) || DEFAULTS.width);
  const columns = Math.max(30, Math.floor(requestedWidth / pitch));
  const chunkRows = Math.max(24, Math.round(Number(config.chunkRows) || DEFAULTS.chunkRows));
  const maxRouteBudget = Math.max(
    12,
    Math.round((Number(config.maxRouteBudget) || DEFAULTS.maxRouteBudget)
      * clamp(Number(config.routeLength) || DEFAULTS.routeLength, 0.2, 2)),
  );
  const routeGuardRows = Math.max(
    maxRouteBudget + 8,
    Math.round(Number(config.routeGuardRows) || 0),
  );
  return {
    ...config,
    width: (columns - 1) * pitch,
    pitch,
    columns,
    chunkRows,
    maxRouteBudget,
    routeGuardRows,
    fill: clamp(Number(config.fill) || DEFAULTS.fill, 0.05, 0.9),
    density: clamp(Number(config.density) || DEFAULTS.density, 0.05, 1.5),
    horizontalFlow: clamp(Number(config.horizontalFlow) || 0, 0, 1),
    swirl: clamp(Number(config.swirl) || 0, 0, 1.5),
    swirlScale: clamp(Number(config.swirlScale) || DEFAULTS.swirlScale, 0.2, 3),
    diagonalFlow: clamp(Number(config.diagonalFlow) || 0, 0, 1),
    jogLength: clamp(Number(config.jogLength) || 0, 0.15, 1.5),
    channelAttraction: clamp(Number(config.channelAttraction) || 0, 0, 2),
    longFlow: clamp(Number(config.longFlow) || 0, 0, 1),
    edgeDensity: clamp(Number(config.edgeDensity) || 0, 0, 1),
    flowAngle: Number(config.flowAngle) || 0,
  };
}

/**
 * Creates a deterministic, append-only field of non-crossing circuit traces.
 * Geometry uses absolute world coordinates. ensureThrough() is synchronous and
 * always processes the same fixed-height chunks, regardless of call cadence.
 */
export function createStreamingCircuitField(options = {}) {
  let config;
  let seedHash;
  let occupied;
  let rowOccupancy;
  let flowVortexCache;
  let retained;
  let callbacks;
  let nextChunkId;
  let nextTraceSequence;
  let generatedThroughRow;
  let totalPublished;
  let totalPruned;
  let totalOccupiedCells;
  let prunedOccupiedCells;
  let prunedOccupancyRows;
  let prunedFlowVortices;
  let generationCacheMinimumRow;
  let rejectedRoutes;
  let destroyed = false;

  function initialize(nextOptions) {
    config = normalizeOptions(nextOptions);
    seedHash = hashSeed(config.seed);
    occupied = new Set();
    rowOccupancy = new Map();
    flowVortexCache = new Map();
    retained = [];
    callbacks = callbacks || new Set();
    nextChunkId = 0;
    nextTraceSequence = 0;
    generatedThroughRow = 0;
    totalPublished = 0;
    totalPruned = 0;
    totalOccupiedCells = 0;
    prunedOccupiedCells = 0;
    prunedOccupancyRows = 0;
    prunedFlowVortices = 0;
    generationCacheMinimumRow = 1;
    rejectedRoutes = 0;
  }

  function assertLive() {
    if (destroyed) throw new Error("Streaming circuit field has been destroyed");
  }

  function cellKey(x, y) {
    return y * config.columns + x;
  }

  function isBlocked(x, y) {
    return occupied.has(cellKey(x, y));
  }

  function flowVortex(latticeX, latticeY, spacing) {
    const key = latticeX + "," + latticeY;
    let vortex = flowVortexCache.get(key);
    if (vortex) return vortex;
    vortex = {
      x: (latticeX + 0.15 + unitHash(seedHash, latticeX, latticeY, 1) * 0.7) * spacing,
      y: (latticeY + 0.15 + unitHash(seedHash, latticeX, latticeY, 2) * 0.7) * spacing,
      radius: spacing * (0.7 + unitHash(seedHash, latticeX, latticeY, 3) * 0.55),
      spin: unitHash(seedHash, latticeX, latticeY, 4) < 0.5 ? -1 : 1,
      strength: 0.72 + unitHash(seedHash, latticeX, latticeY, 5) * 0.56,
    };
    flowVortexCache.set(key, vortex);
    return vortex;
  }

  function flowVector(x, y, angleDegrees) {
    const radians = angleDegrees * Math.PI / 180;
    let vx = Math.cos(radians);
    let vy = Math.sin(radians);
    const spacing = Math.max(24, Math.round(72 * config.swirlScale));
    const gridX = Math.floor(x / spacing);
    const gridY = Math.floor(y / spacing);

    // A hashed lattice gives the infinite field stable vortices without using
    // viewport dimensions or the current generated height.
    for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
      for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
        const latticeX = gridX + offsetX;
        const latticeY = gridY + offsetY;
        const vortex = flowVortex(latticeX, latticeY, spacing);
        const dx = x - vortex.x;
        const dy = y - vortex.y;
        const distance = Math.hypot(dx, dy);
        if (distance < 0.0001) continue;
        const falloff = Math.exp(
          -(distance * distance) / (2 * vortex.radius * vortex.radius),
        );
        const influence = config.swirl * vortex.strength * falloff * 0.34;
        vx += (-dy / distance) * vortex.spin * influence;
        vy += (dx / distance) * vortex.spin * influence;
      }
    }

    const magnitude = Math.hypot(vx, vy) || 1;
    return [vx / magnitude, vy / magnitude];
  }

  function runLength(random, perpendicular, vector) {
    let result;
    if (perpendicular) {
      result = random() < 0.22 ? integer(random, 4, 7) : integer(random, 1, 4);
      const scale = config.jogLength + (1 - config.jogLength) * diagonalness(vector);
      return Math.max(1, Math.round(result * scale));
    }
    const roll = random();
    if (roll < 0.14) result = integer(random, 16, 28);
    else if (roll < 0.52) result = integer(random, 9, 16);
    else result = integer(random, 4, 10);
    const scale = 1 - diagonalness(vector) * config.diagonalFlow * 0.9;
    return Math.max(1, Math.round(result * scale));
  }

  function traceBudget(random) {
    const ceiling = config.maxRouteBudget;
    const roll = random();
    let result;
    if (roll < 0.2) result = integer(random, Math.min(42, ceiling), ceiling);
    else if (roll < 0.65) result = integer(random, Math.min(24, ceiling), Math.min(50, ceiling));
    else result = integer(random, Math.min(12, ceiling), Math.min(28, ceiling));
    return Math.max(6, result);
  }

  function lookAhead(x, y, direction, local, minimumRow, maximumRow) {
    const [dx, dy] = DIRECTIONS[direction];
    let open = 0;
    for (let step = 1; step <= 5; step += 1) {
      const nextX = x + dx * step;
      const nextY = y + dy * step;
      if (
        nextX < 1
        || nextX >= config.columns - 1
        || nextY < minimumRow
        || nextY > maximumRow
      ) break;
      const key = cellKey(nextX, nextY);
      if (occupied.has(key) || local.has(key)) break;
      open += 1;
    }
    return open;
  }

  function growOne(
    start,
    initialDirection,
    limit,
    local,
    random,
    angleDegrees,
    travelSign,
    minimumRow,
    maximumRow,
  ) {
    const points = [start];
    let [x, y] = start;
    let direction = initialDirection;
    const firstVector = flowVector(x, y, angleDegrees);
    let targetVector = [firstVector[0] * travelSign, firstVector[1] * travelSign];
    let remaining = runLength(random, false, targetVector);

    while (points.length <= limit) {
      const candidates = [];
      const primaryDirection = closestDirection(targetVector);
      for (const candidate of [direction, (direction + 1) % 4, (direction + 3) % 4]) {
        const [dx, dy] = DIRECTIONS[candidate];
        const nextX = x + dx;
        const nextY = y + dy;
        if (
          nextX < 1
          || nextX >= config.columns - 1
          || nextY < minimumRow
          || nextY > maximumRow
        ) continue;
        const key = cellKey(nextX, nextY);
        if (occupied.has(key) || local.has(key)) continue;
        const open = lookAhead(x, y, candidate, local, minimumRow, maximumRow);
        if (!open) continue;
        const straight = candidate === direction;
        let weight = straight ? (remaining > 0 ? 15 : 1.6) : (remaining > 0 ? 0.12 : 4.2);
        const alignment = dx * targetVector[0] + dy * targetVector[1];
        if (alignment < -0.16) continue;
        weight *= 0.45 + open * 0.34;
        let neighbors = 0;
        if (candidate % 2 === 0) {
          weight *= 0.45 + Math.max(0, alignment) * 2.2;
          if (nextY > minimumRow && isBlocked(nextX, nextY - 1)) neighbors += 1;
          if (nextY < maximumRow && isBlocked(nextX, nextY + 1)) neighbors += 1;
        } else {
          if (nextX > 1 && isBlocked(nextX - 1, nextY)) neighbors += 1;
          if (nextX < config.columns - 2 && isBlocked(nextX + 1, nextY)) neighbors += 1;
        }
        weight *= 1 + neighbors * config.channelAttraction;
        candidates.push([candidate, weight]);
      }
      if (!candidates.length) break;
      const nextDirection = weighted(random, candidates);
      if (nextDirection !== direction) {
        direction = nextDirection;
        remaining = runLength(
          random,
          direction % 2 !== primaryDirection % 2,
          targetVector,
        );
      } else {
        remaining -= 1;
      }
      x += DIRECTIONS[direction][0];
      y += DIRECTIONS[direction][1];
      const key = cellKey(x, y);
      local.add(key);
      points.push([x, y]);
      const sampled = flowVector(x, y, angleDegrees);
      targetVector = [sampled[0] * travelSign, sampled[1] * travelSign];
    }
    return points;
  }

  function commitTrace(cells, ownerChunk, destination) {
    if (cells.length < 6) {
      rejectedRoutes += 1;
      return false;
    }
    for (const [x, y] of cells) {
      const key = cellKey(x, y);
      if (occupied.has(key)) {
        rejectedRoutes += 1;
        return false;
      }
    }
    for (const [x, y] of cells) {
      occupied.add(cellKey(x, y));
      rowOccupancy.set(y, (rowOccupancy.get(y) || 0) + 1);
    }
    totalOccupiedCells += cells.length;
    // Simplify first, then cut only a short diagonal across each reserved
    // corner. This keeps the visible path inside its occupied grid corridor.
    const shaped = chamfer(simplify(cells));
    const points = shaped.map(([x, y]) => [x * config.pitch, y * config.pitch]);
    const yValues = points.map((point) => point[1]);
    const trace = Object.freeze({
      id: `${seedHash.toString(36)}-${nextTraceSequence.toString(36)}`,
      chunkId: ownerChunk,
      ownerChunk,
      points,
      length: pathLength(points),
      minY: Math.min(...yValues),
      maxY: Math.max(...yValues),
      viaStart: true,
      viaEnd: true,
    });
    nextTraceSequence += 1;
    destination.push(trace);
    retained.push(trace);
    totalPublished += 1;
    return true;
  }

  function makeInteriorTrace(start, ownerChunk, destination, random, minimumRow, maximumRow) {
    const startKey = cellKey(start[0], start[1]);
    if (occupied.has(startKey)) return false;
    const followsPrimary = random() < config.horizontalFlow;
    const angleDegrees = config.flowAngle + (followsPrimary ? 0 : 90);
    const startVector = flowVector(start[0], start[1], angleDegrees);
    const direction = closestDirection(startVector);
    const budget = traceBudget(random);
    const forwardBudget = Math.max(3, Math.floor(budget * (0.42 + random() * 0.16)));
    const local = new Set([startKey]);
    const forward = growOne(
      start,
      direction,
      forwardBudget,
      local,
      random,
      angleDegrees,
      1,
      minimumRow,
      maximumRow,
    );
    const backward = growOne(
      start,
      (direction + 2) % 4,
      budget - forwardBudget,
      local,
      random,
      angleDegrees,
      -1,
      minimumRow,
      maximumRow,
    );
    return commitTrace(backward.slice(1).reverse().concat(forward), ownerChunk, destination);
  }

  function makeEdgeTrace(
    start,
    direction,
    ownerChunk,
    destination,
    random,
    minimumRow,
    maximumRow,
    routeOptions = {},
  ) {
    const startKey = cellKey(start[0], start[1]);
    if (occupied.has(startKey)) return false;
    const local = new Set([startKey]);
    const budget = routeOptions.budget ?? traceBudget(random);
    const angleDegrees = routeOptions.angleDegrees
      ?? config.flowAngle + (random() < config.horizontalFlow ? 0 : 90);
    const sampled = flowVector(start[0], start[1], angleDegrees);
    const inward = DIRECTIONS[direction][0] * sampled[0] + DIRECTIONS[direction][1] * sampled[1];
    const cells = growOne(
      start,
      direction,
      budget,
      local,
      random,
      angleDegrees,
      inward >= 0 ? 1 : -1,
      minimumRow,
      maximumRow,
    );
    if (cells.length < (routeOptions.minimumLength ?? 6)) {
      rejectedRoutes += 1;
      return false;
    }
    return commitTrace(cells, ownerChunk, destination);
  }

  function occupiedInRange(startRow, endRow) {
    let result = 0;
    for (let row = startRow; row <= endRow; row += 1) {
      result += rowOccupancy.get(row) || 0;
    }
    return result;
  }

  // Future route probes reject every row below this monotonically increasing
  // guard, so neither occupancy structure can observe discarded rows again.
  function pruneGenerationCaches(minimumRouteRow) {
    generationCacheMinimumRow = minimumRouteRow;

    for (const key of occupied) {
      const row = Math.floor(key / config.columns);
      if (row < minimumRouteRow) {
        occupied.delete(key);
        prunedOccupiedCells += 1;
      }
    }

    for (const row of rowOccupancy.keys()) {
      if (row < minimumRouteRow) {
        rowOccupancy.delete(row);
        prunedOccupancyRows += 1;
      }
    }

    // flowVector samples gridY - 1 through gridY + 1, making lower lattice
    // rows unreachable once the route guard advances past them.
    const spacing = Math.max(24, Math.round(72 * config.swirlScale));
    const minimumLatticeRow = Math.floor(minimumRouteRow / spacing) - 1;
    for (const key of flowVortexCache.keys()) {
      const latticeRow = Number(key.slice(key.indexOf(",") + 1));
      if (latticeRow < minimumLatticeRow) {
        flowVortexCache.delete(key);
        prunedFlowVortices += 1;
      }
    }
  }

  function generateChunk(chunkId) {
    const chunkStart = chunkId * config.chunkRows + 1;
    const chunkEnd = chunkStart + config.chunkRows - 1;
    const minimumRouteRow = Math.max(1, chunkStart - config.routeGuardRows);
    const maximumRouteRow = chunkEnd + config.routeGuardRows;
    const random = mulberry32(hashSeed(
      `${config.seed}:${config.columns}:chunk:${chunkId}`,
    ));
    const appended = [];
    const angleRadians = config.flowAngle * Math.PI / 180;
    const absoluteX = Math.abs(Math.cos(angleRadians));
    const absoluteY = Math.abs(Math.sin(angleRadians));
    const primaryHorizontal = absoluteX / (absoluteX + absoluteY || 1);
    const horizontalWeight = config.horizontalFlow * primaryHorizontal
      + (1 - config.horizontalFlow) * (1 - primaryHorizontal);

    // Carriers are admitted only from the true left and right boundaries.
    // Chunk top and bottom are never treated as edges, which avoids seams.
    const carriers = [];
    for (let row = chunkStart; row <= chunkEnd; row += 1) {
      carriers.push([[1, row], 0], [[config.columns - 2, row], 2]);
    }
    shuffle(random, carriers);
    const carrierTarget = Math.round(config.chunkRows * config.longFlow * horizontalWeight);
    let carrierCount = 0;
    for (const [start, direction] of carriers) {
      if (carrierCount >= carrierTarget) break;
      const minimumBudget = Math.max(12, Math.round(config.columns * 0.58));
      const maximumBudget = Math.min(
        config.maxRouteBudget,
        Math.max(minimumBudget, Math.round(config.columns * 1.02)),
      );
      const budget = integer(random, Math.min(minimumBudget, maximumBudget), maximumBudget);
      if (makeEdgeTrace(
        start,
        direction,
        chunkId,
        appended,
        random,
        minimumRouteRow,
        maximumRouteRow,
        {
          budget,
          angleDegrees: config.flowAngle,
          minimumLength: Math.max(6, Math.round(config.columns * 0.3)),
        },
      )) carrierCount += 1;
    }

    const edgeSeeds = shuffle(random, carriers.slice());
    const edgeTarget = Math.round(config.chunkRows * 2 * config.edgeDensity);
    let edgeCount = 0;
    for (const [start, direction] of edgeSeeds) {
      if (edgeCount >= edgeTarget) break;
      if (makeEdgeTrace(
        start,
        direction,
        chunkId,
        appended,
        random,
        minimumRouteRow,
        maximumRouteRow,
      )) edgeCount += 1;
    }

    const seeds = [];
    for (let row = chunkStart; row <= chunkEnd; row += 1) {
      for (let column = 1; column < config.columns - 1; column += 1) {
        seeds.push([column, row]);
      }
    }
    shuffle(random, seeds);
    const targetFill = Math.min(0.82, Math.max(0.12, config.fill * config.density));
    const targetOccupied = Math.round(
      targetFill * config.chunkRows * (config.columns - 2),
    );
    let chunkOccupied = occupiedInRange(chunkStart, chunkEnd);
    for (const seed of seeds) {
      if (chunkOccupied >= targetOccupied) break;
      if (isBlocked(seed[0], seed[1])) continue;
      if (makeInteriorTrace(
        seed,
        chunkId,
        appended,
        random,
        minimumRouteRow,
        maximumRouteRow,
      )) {
        chunkOccupied = occupiedInRange(chunkStart, chunkEnd);
      }
    }

    generatedThroughRow = chunkEnd;
    nextChunkId = chunkId + 1;
    const nextMinimumRouteRow = Math.max(
      1,
      nextChunkId * config.chunkRows + 1 - config.routeGuardRows,
    );
    pruneGenerationCaches(nextMinimumRouteRow);
    if (appended.length) {
      const immutableBatch = Object.freeze(appended.slice());
      for (const callback of callbacks) callback(immutableBatch, api);
    }
    return appended;
  }

  const api = {
    ensureThrough(worldY) {
      assertLive();
      const target = Math.max(0, Number(worldY) || 0);
      const appended = [];
      while (generatedThroughRow * config.pitch < target) {
        appended.push(...generateChunk(nextChunkId));
      }
      return appended;
    },

    onAppend(callback) {
      assertLive();
      if (typeof callback !== "function") throw new TypeError("callback must be a function");
      callbacks.add(callback);
      return () => callbacks.delete(callback);
    },

    pruneBefore(worldY) {
      assertLive();
      const boundary = Number(worldY) || 0;
      const removed = [];
      retained = retained.filter((trace) => {
        if (trace.maxY >= boundary) return true;
        removed.push(trace.id);
        return false;
      });
      totalPruned += removed.length;
      return removed;
    },

    reset(nextOptions = {}) {
      assertLive();
      initialize({ ...config, ...nextOptions });
      return api;
    },

    destroy() {
      if (destroyed) return;
      destroyed = true;
      callbacks.clear();
      retained = [];
    },

    get width() {
      return config.width;
    },

    get height() {
      return generatedThroughRow * config.pitch;
    },

    get generatedThrough() {
      return generatedThroughRow * config.pitch;
    },

    get traces() {
      return retained;
    },

    get retainedTraces() {
      return retained;
    },

    get stats() {
      const generatedCells = Math.max(1, generatedThroughRow * config.columns);
      return Object.freeze({
        chunks: nextChunkId,
        columns: config.columns,
        chunkRows: config.chunkRows,
        pitch: config.pitch,
        width: config.width,
        generatedThrough: generatedThroughRow * config.pitch,
        occupiedCells: totalOccupiedCells,
        occupancyFill: totalOccupiedCells / generatedCells,
        occupiedCellsRetained: occupied.size,
        occupiedCellsPruned: prunedOccupiedCells,
        occupancyRowsRetained: rowOccupancy.size,
        occupancyRowsPruned: prunedOccupancyRows,
        flowVorticesRetained: flowVortexCache.size,
        flowVorticesPruned: prunedFlowVortices,
        generationCacheMinimumRow,
        totalPublished,
        retainedTraces: retained.length,
        totalPruned,
        rejectedRoutes,
        routeGuardRows: config.routeGuardRows,
        occupancyRetainedForSafety: true,
      });
    },
  };

  initialize(options);
  return api;
}