/**
 * The authoritative World.
 *
 * Owns truth: terrain, entities (creatures + scenery props), inhabitants, the
 * tick loop, and the world seed. Imports ZERO graphics. Humans (the viewport)
 * and agents (the future Assistant integration) are both just CLIENTS of the
 * Inhabitant API — attach / snapshot / perceive / act / author.
 *
 * Multiplayer falls straight out of this: the sim is authoritative and many
 * inhabitants can attach at once, so two browsers (or two people on a LAN, when
 * the server binds beyond loopback) share one world and see each other.
 */
'use strict';

const crypto = require('crypto');
const { makeWorldGen, CHUNK } = require('./worldgen');
const { ChunkStore } = require('./chunkstore');
const { MATERIALS, material } = require('./materials');
const { mulberry32 } = require('./noise');
const { Wanderer } = require('./entities/wanderer');
const { Hopper } = require('./entities/hopper');
const { Prowler } = require('./entities/prowler');
const { Villager } = require('./entities/villager');
const { Prop } = require('./entities/prop');
const { CustomEntity } = require('./entities/custom');
const { Lever, Plate, Door, Lamp, Streetlight } = require('./entities/devices');
const { Ball, Crate } = require('./entities/dynamics');
const { findPath } = require('./pathfind');
const { initLife, tickLife } = require('./life');

// Static prop kinds the worldgen scatters / builds. All render by kind in the
// viewport, all restore as plain Props, all forkable into editable types.
const PROP_KINDS = new Set([
  'tree', 'pine', 'rock', 'apple', 'bush', 'flower', 'reed', 'mushroom',
  'hut', 'well', 'tower', 'stone', 'campfire', 'tent',
  'wall', 'floor', 'fence', // buildable pieces (see PLACE_COSTS)
  'building', 'car', 'shopfront', 'sign', // WP6 contemporary content (town/city)
]);
// Kinds with their own class + tick behavior (not plain Props).
const DEVICE_KINDS = new Set(['lever', 'plate', 'door', 'lamp', 'streetlight']);
const DYNAMIC_KINDS = new Set(['ball', 'crate']);
const RESERVED_KINDS = new Set([...PROP_KINDS, ...DEVICE_KINDS, ...DYNAMIC_KINDS, 'wanderer', 'hopper', 'prowler', 'villager', 'player']);

// Collision radii for props big enough to walk into (per unit scale).
// Everything else (flowers, reeds, mushrooms, apples, signs) you walk through.
// Doors are special-cased in blockedByProp: solid only while closed.
const SOLID_RADII = { tree: 0.7, pine: 0.6, rock: 0.8, hut: 2.4, tower: 1.7, well: 0.95, stone: 0.8, tent: 1.3, wall: 1.05, fence: 0.5, crate: 0.6, building: 3.2, car: 1.1, shopfront: 1.8 };

// ── the matter loop ──────────────────────────────────────────────────────────
// Body verbs, not god verbs: harvesting pays out material, placing spends it.
// (author/spawn conjures from nothing and is gated by grants; placing is
// playing by the world's rules and is open to everyone.)
const HARVEST_YIELD = { tree: { wood: 4 }, pine: { wood: 3 }, bush: { wood: 1 }, rock: { stone: 3 } };
const HARVEST_RANGE = 3.2;
const PLACE_COSTS = {
  wall: { wood: 2 }, floor: { wood: 2 }, fence: { wood: 1 }, door: { wood: 2 },
  campfire: { wood: 2 }, lever: { wood: 1 }, plate: { stone: 1 }, lamp: { stone: 1, wood: 1 },
  ball: { wood: 1 }, crate: { wood: 2 },
};

// ── grants: who may use the GOD verbs ────────────────────────────────────────
// Two classes of verb exist. BODY verbs (move, eat, harvest, place, use,
// strike, kick) play *within* the world's rules and are open to every
// inhabitant. GOD verbs (author/spawn from nothing, edit, delete, sculpt,
// redefine types) rewrite reality and are gated per-inhabitant:
//   players — full CRUD by default (it's their world);
//   agents  — create + read by default: they can make new things and perceive
//             everything, but not rewrite or remove what exists. Flip it per
//             world via the `grants` option when you're ready to trust them.
// Everyone may always delete what they themselves created (tracked via
// entity.owner) — creativity without littering.
// NOTE: the Inhabitant API is unauthenticated, so this is policy for
// well-behaved clients (the viewport, the Assistant integration), not a
// security boundary.
const DEFAULT_GRANTS = {
  player: { create: true, update: true, delete: true },
  agent: { create: true, update: false, delete: false },
};

// A day in the world: 10 minutes of wall-clock at the default 10Hz tick.
// timeOfDay t ∈ [0,1): 0 = midnight, 0.5 = noon. Worlds wake up mid-morning.
const DAY_TICKS = 6000;
const TIME_OFFSET = Math.round(DAY_TICKS * 0.35);

// Seed-derived names — a world introduces itself, and the village on it has a
// name worth putting on a signpost. Pure functions of the world seed.
const WORLD_ADJ = ['Whispering', 'Amber', 'Silent', 'Golden', 'Misty', 'Elder', 'Hollow', 'Sunken', 'Emerald', 'Wandering', 'Mossy', 'Cinder', 'Sapphire', 'Thistle', 'Ashen', 'Verdant'];
const WORLD_NOUN = ['Vale', 'Reach', 'Hollow', 'Shore', 'Highlands', 'Downs', 'Fen', 'Crossing', 'Rest', 'Expanse', 'Wilds', 'Cradle', 'Steppe', 'Haven', 'Marches', 'Cape'];
const TOWN_A = ['Ash', 'Fern', 'Oak', 'Mill', 'Stone', 'Brook', 'Wolf', 'Hearth', 'Bram', 'Elder', 'Fox', 'Gorse'];
const TOWN_B = ['ford', 'vale', 'wick', 'stead', 'holm', 'bury', 'dale', 'moor', 'field', 'haven', 'marsh', 'combe'];
const INHABITANT_TIMEOUT = 12000; // drop a *player* that hasn't perceived in 12s (closed tab)
// An agent has a *body that acts on its own* between the mind's turns, so it must
// outlive the gaps when the LLM isn't perceiving. A "continuous-play" agent thinks
// on a slow cadence (e.g. every 10 min via a trigger), and its body must survive
// every gap or it would re-attach at a fresh spawn and lose its place. So this is
// generous (1h default) and must comfortably exceed the agent's think interval —
// override with OLLIE_COSMOS_AGENT_IDLE_MS if you run a slower loop.
const AGENT_TIMEOUT = Number(process.env.OLLIE_COSMOS_AGENT_IDLE_MS) || 60 * 60 * 1000;
const AGENT_SPEED = 4.2; // metres/second at a brisk walk (per-tick = /tickRate)
const AGENT_TURN = Math.PI; // radians/second the body turns toward its heading
const WANDER_DRIFT = 0.6; // how sharply a wandering body re-aims
const ARRIVE_EPS = 0.35; // close enough to a target point to count as "arrived"
const WAYPOINT_EPS = 1.4; // close enough to a routed waypoint to advance to the next one (see pathfind.js)
const EYE_HEIGHT = 1.7;

const TAU = Math.PI * 2;
const r1 = (n) => Math.round(n * 10) / 10;
// Smallest signed angle from a to b, in (-π, π].
function angleDiff(a, b) {
  let d = (b - a) % TAU;
  if (d > Math.PI) d -= TAU;
  if (d < -Math.PI) d += TAU;
  return d;
}
// Rotate `a` toward `target` by at most `maxStep` (smooth turning, not snapping).
function approachAngle(a, target, maxStep) {
  const d = angleDiff(a, target);
  return Math.abs(d) <= maxStep ? target : a + Math.sign(d) * maxStep;
}
// Yaw whose forward vector (-sinθ, -cosθ) points from (x,z) toward (tx,tz).
// Matches the player-camera convention: yaw 0 = North/-Z, +X = East.
const headingToward = (x, z, tx, tz) => Math.atan2(x - tx, z - tz);

// FNV-1a — stable seed material for deterministically-id'd generated entities.
function mkIdHash(s) {
  let h = 2166136261;
  for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
  return h >>> 0;
}

// Starting points when you "make editable" a built-in: a mesh spec + behavior
// source approximating the original, so forking gives you something to recode
// (y offsets baked in so they sit on the ground like the built-in did).
const FORK_TEMPLATES = {
  tree: {
    mesh: { group: [
      { shape: 'cylinder', rt: 0.22, rb: 0.34, h: 2.2, color: '#6b4a2b', y: 1.1 },
      { shape: 'cone', r: 1.5, h: 2.8, color: '#2f7d33', y: 3.1 },
    ] },
    behavior: '',
  },
  rock: { mesh: { shape: 'icosahedron', r: 0.9, color: '#787c82', flat: true, y: 0.55 }, behavior: '' },
  apple: { mesh: { shape: 'sphere', r: 0.32, color: '#d23b2e', y: 0.32 }, behavior: '' },
  pine: {
    mesh: { group: [
      { shape: 'cylinder', rt: 0.16, rb: 0.26, h: 1.8, color: '#5d4023', y: 0.9 },
      { shape: 'cone', r: 1.2, h: 1.9, color: '#1f5d2e', y: 2.6 },
      { shape: 'cone', r: 0.9, h: 1.6, color: '#256b35', y: 3.7 },
      { shape: 'cone', r: 0.6, h: 1.3, color: '#2c7a3d', y: 4.7 },
    ] },
    behavior: '',
  },
  bush: {
    mesh: { group: [
      { shape: 'sphere', r: 0.55, color: '#3c8f3f', flat: true, y: 0.45 },
      { shape: 'sphere', r: 0.42, color: '#2f7d33', flat: true, y: 0.6, x: 0.4 },
      { shape: 'sphere', r: 0.38, color: '#4a9c44', flat: true, y: 0.55, x: -0.35, z: 0.2 },
    ] },
    behavior: '',
  },
  flower: {
    mesh: { group: [
      { shape: 'cylinder', r: 0.03, h: 0.5, color: '#3c8f3f', y: 0.25 },
      { shape: 'sphere', r: 0.12, color: '#e8c93e', y: 0.55 },
    ] },
    behavior: '',
  },
  reed: {
    mesh: { group: [
      { shape: 'cylinder', r: 0.04, h: 1.3, color: '#7a9a4a', y: 0.65 },
      { shape: 'cylinder', r: 0.04, h: 1.0, color: '#8aa855', y: 0.5, x: 0.16 },
      { shape: 'cylinder', r: 0.04, h: 1.1, color: '#6b8c3f', y: 0.55, x: -0.13, z: 0.1 },
    ] },
    behavior: '',
  },
  mushroom: {
    mesh: { group: [
      { shape: 'cylinder', r: 0.09, h: 0.3, color: '#e3d9c2', y: 0.15 },
      { shape: 'cone', r: 0.28, h: 0.22, color: '#c0392b', y: 0.38 },
    ] },
    behavior: '',
  },
  hut: {
    mesh: { group: [
      { shape: 'cylinder', r: 1.8, h: 1.9, seg: 8, color: '#8a6a45', y: 0.95 },
      { shape: 'cone', r: 2.3, h: 1.6, seg: 8, color: '#a8842f', flat: true, y: 2.7 },
      { shape: 'box', size: [0.8, 1.3, 0.1], color: '#4a3319', y: 0.65, z: 1.78 },
    ] },
    behavior: '',
  },
  well: {
    mesh: { group: [
      { shape: 'cylinder', rt: 0.85, rb: 0.9, h: 0.8, seg: 10, color: '#8d9299', flat: true, y: 0.4 },
      { shape: 'box', size: [0.12, 1.5, 0.12], color: '#5d4023', y: 1.1, x: 0.75 },
      { shape: 'box', size: [0.12, 1.5, 0.12], color: '#5d4023', y: 1.1, x: -0.75 },
      { shape: 'cone', r: 1.1, h: 0.6, seg: 6, color: '#7a5a30', flat: true, y: 2.05 },
    ] },
    behavior: '',
  },
  tower: {
    mesh: { group: [
      { shape: 'cylinder', rt: 1.15, rb: 1.55, h: 7, seg: 9, color: '#9aa0a6', flat: true, y: 3.5 },
      { shape: 'cone', r: 1.6, h: 1.6, seg: 9, color: '#7a3b2e', flat: true, y: 7.8 },
      { shape: 'box', size: [0.5, 0.7, 0.1], color: '#2b3138', y: 5.6, z: 1.28 },
    ] },
    behavior: '',
  },
  stone: { mesh: { shape: 'box', size: [0.75, 2.3, 0.5], color: '#6f7681', flat: true, y: 1.05 }, behavior: '' },
  campfire: {
    mesh: { group: [
      { shape: 'cylinder', r: 0.12, h: 1.0, color: '#5d4023', y: 0.18 },
      { shape: 'cylinder', r: 0.12, h: 1.0, color: '#6b4a2b', y: 0.18, x: 0.28, z: 0.2 },
      { shape: 'cone', r: 0.34, h: 0.7, color: '#ff9d3c', y: 0.55 },
    ] },
    behavior: '',
  },
  tent: {
    mesh: { group: [
      { shape: 'cone', r: 1.25, h: 1.7, seg: 4, color: '#b08d57', flat: true, y: 0.85 },
    ] },
    behavior: '',
  },
  hopper: {
    mesh: { group: [
      { shape: 'sphere', r: 0.28, color: '#b59a7a', flat: true, y: 0.28 },
      { shape: 'sphere', r: 0.18, color: '#c4ac8e', flat: true, y: 0.52, z: 0.2 },
      { shape: 'cylinder', r: 0.04, h: 0.3, color: '#b59a7a', y: 0.72, x: 0.07, z: 0.2 },
      { shape: 'cylinder', r: 0.04, h: 0.3, color: '#b59a7a', y: 0.72, x: -0.07, z: 0.2 },
    ] },
    behavior: [
      'if (self.state.h == null) self.state.h = self.rot || 0;',
      'if (Math.random() < 0.25) {',
      '  if (Math.random() < 0.2) self.state.h += (Math.random() - 0.5) * 2;',
      '  var nx = self.x + Math.cos(self.state.h) * 0.2, nz = self.z + Math.sin(self.state.h) * 0.2;',
      '  var m = world.materialAt(nx, nz);',
      '  if (m && m.walkable && !m.liquid) { self.x = nx; self.z = nz; } else { self.state.h += 2; }',
      '}',
      'self.y = world.heightAt(self.x, self.z);',
    ].join('\n'),
  },
  wall: { mesh: { shape: 'box', size: [2.2, 1.8, 0.3], color: '#8a6a45', y: 0.9 }, behavior: '' },
  floor: { mesh: { shape: 'box', size: [2, 0.12, 2], color: '#9a7a50', y: 0.06 }, behavior: '' },
  fence: { mesh: { group: [
    { shape: 'box', size: [1.1, 0.1, 0.08], color: '#7a5a30', y: 0.85 },
    { shape: 'box', size: [0.09, 1.0, 0.09], color: '#6b4a2b', y: 0.5, x: -0.45 },
    { shape: 'box', size: [0.09, 1.0, 0.09], color: '#6b4a2b', y: 0.5, x: 0.45 },
  ] }, behavior: '' },
  // WP6 contemporary content (town/city). The built-in `building` varies its
  // story count per instance (self.floors, set by settlementSpecs) — this
  // template is a representative 3-story snapshot to recode from.
  building: { mesh: { group: [
    { shape: 'box', size: [3.6, 3, 3.6], color: '#8d8478', y: 1.5 },
    { shape: 'box', size: [3.7, 0.3, 3.7], color: '#4a463f', y: 3.05 },
  ] }, behavior: '' },
  car: { mesh: { group: [
    { shape: 'box', size: [1.9, 0.55, 0.9], color: '#7a3b3b', y: 0.42 },
    { shape: 'box', size: [1.1, 0.42, 0.85], color: '#9fb8c9', y: 0.78 },
  ] }, behavior: '' },
  shopfront: { mesh: { group: [
    { shape: 'box', size: [2.6, 2.2, 2.2], color: '#a8926f', y: 1.1 },
    { shape: 'box', size: [2.8, 0.3, 0.5], color: '#b0432f', y: 2.05, z: 1.1 },
  ] }, behavior: '' },
  sign: { mesh: { group: [
    { shape: 'cylinder', r: 0.06, h: 1.8, color: '#5d4023', y: 0.9 },
    { shape: 'box', size: [0.9, 0.5, 0.06], color: '#c0392b', y: 1.7 },
  ] }, behavior: '' },
  // The device templates carry their REAL logic so forking one teaches the
  // signal fabric: emit with `self.signal = true`, sense with world.signalAt.
  lever: {
    mesh: { group: [
      { shape: 'box', size: [0.3, 0.25, 0.3], color: '#6f7681', y: 0.12 },
      { shape: 'cylinder', r: 0.05, h: 0.6, color: '#b08d57', y: 0.5 },
    ] },
    behavior: [
      '// A lever: emits a signal while on. Toggled by the `use` action.',
      'if (self.state.on == null) self.state.on = false;',
      'self.signal = self.state.on;',
    ].join('\n'),
  },
  plate: {
    mesh: { shape: 'box', size: [1.1, 0.08, 1.1], color: '#8d9299', y: 0.04 },
    behavior: [
      '// A pressure plate: emits while something stands on it.',
      'self.signal = false;',
      'for (var inh of world.inhabitants.values()) {',
      '  if (Math.hypot(inh.pose.x - self.x, inh.pose.z - self.z) < 1.4) { self.signal = true; break; }',
      '}',
    ].join('\n'),
  },
  door: {
    mesh: { group: [
      { shape: 'box', size: [0.18, 2.1, 0.3], color: '#6b4a2b', y: 1.05, x: -0.95 },
      { shape: 'box', size: [0.18, 2.1, 0.3], color: '#6b4a2b', y: 1.05, x: 0.95 },
      { shape: 'box', size: [1.75, 2.0, 0.12], color: '#8a6a45', y: 1.0 },
    ] },
    behavior: [
      '// A door: opens when it senses a signal nearby (world.signalAt).',
      '// The built-in door also stops blocking while open — an authored fork',
      '// can fake it by moving aside: try `self.y = open ? -2 : world.heightAt(self.x, self.z)`.',
      'var open = world.signalAt(self.x, self.z, 6, self.id);',
      'self.y = open ? world.heightAt(self.x, self.z) - 1.8 : world.heightAt(self.x, self.z);',
    ].join('\n'),
  },
  lamp: {
    mesh: { group: [
      { shape: 'cylinder', r: 0.07, h: 1.6, color: '#5d4023', y: 0.8 },
      { shape: 'sphere', r: 0.22, color: '#ffe9a8', y: 1.7 },
    ] },
    behavior: [
      '// A lamp: senses the signal fabric. (The built-in also glows and',
      '// repels prowlers when lit; an authored fork can scale as a tell.)',
      'var lit = world.signalAt(self.x, self.z, 6, self.id);',
      'self.scale = lit ? 1.15 : 1.0;',
    ].join('\n'),
  },
  streetlight: {
    mesh: { group: [
      { shape: 'cylinder', r: 0.07, h: 3.2, color: '#3a3f45', y: 1.6 },
      { shape: 'sphere', r: 0.22, color: '#ffe9a8', y: 3.3 },
    ] },
    behavior: [
      '// A streetlight: no lever, no plate — it lights itself at dusk/night',
      '// straight from the shared clock (world.timeInfo()). Cities are safe',
      '// at night by construction, not by someone remembering to wire a switch.',
      'var phase = world.timeInfo().phase;',
      'var lit = phase === "night" || phase === "dusk";',
      'self.scale = lit ? 1.1 : 1.0;',
    ].join('\n'),
  },
  ball: {
    mesh: { shape: 'sphere', r: 0.35, color: '#f2f2f2', flat: true, y: 0.35 },
    behavior: [
      '// A ball: walk into it to kick it; it rolls, slows, drifts downhill.',
      'if (self.state.vx == null) { self.state.vx = 0; self.state.vz = 0; }',
      'for (var inh of world.inhabitants.values()) {',
      '  var dx = self.x - inh.pose.x, dz = self.z - inh.pose.z, d = Math.hypot(dx, dz);',
      '  if (d < 0.95) { self.state.vx = (dx / d) * 0.85; self.state.vz = (dz / d) * 0.85; }',
      '}',
      'var nx = self.x + self.state.vx, nz = self.z + self.state.vz;',
      'var m = world.materialAt(nx, nz);',
      'if (m && m.walkable && !m.liquid) { self.x = nx; self.z = nz; } else { self.state.vx *= -0.5; self.state.vz *= -0.5; }',
      'self.state.vx *= 0.96; self.state.vz *= 0.96;',
      'self.y = world.heightAt(self.x, self.z);',
    ].join('\n'),
  },
  crate: { mesh: { shape: 'box', size: [1, 1, 1], color: '#9a7a50', y: 0.5 }, behavior: '' },
  prowler: {
    mesh: { shape: 'cone', r: 0.7, h: 1.5, color: '#2b2233', y: 0.75 },
    behavior: [
      '// A prowler: hunts the nearest inhabitant at night, fears the light.',
      'if (world.timeInfo().phase !== "night") { world.removeEntity(self.id); }',
      'var prey = null, pd = 26 * 26;',
      'for (var inh of world.inhabitants.values()) {',
      '  if (world.lightAt(inh.pose.x, inh.pose.z)) continue;',
      '  var dx = inh.pose.x - self.x, dz = inh.pose.z - self.z, d = dx*dx + dz*dz;',
      '  if (d < pd) { pd = d; prey = inh.pose; }',
      '}',
      'if (prey) {',
      '  var h = Math.atan2(prey.z - self.z, prey.x - self.x);',
      '  var nx = self.x + Math.cos(h) * 0.45, nz = self.z + Math.sin(h) * 0.45;',
      '  var m = world.materialAt(nx, nz);',
      '  if (m && m.walkable && !m.liquid && !world.lightAt(nx, nz)) { self.x = nx; self.z = nz; }',
      '}',
      'self.y = world.heightAt(self.x, self.z);',
    ].join('\n'),
  },
  villager: {
    mesh: { group: [
      { shape: 'cylinder', r: 0.22, h: 1.15, color: '#7a5a3c', y: 0.58 },
      { shape: 'sphere', r: 0.19, color: '#d9b48f', y: 1.28 },
    ] },
    behavior: [
      '// A villager: home at night, the well by day, walking between.',
      '// The built-in villager also routes around obstacles and reacts to',
      '// nearby speech and prowlers — this is a starting point, not a clone;',
      '// set self.state.home / self.state.work to give it real anchors.',
      'if (self.state.stop == null) self.state.stop = "home";',
      'var wantHome = world.timeInfo().phase === "night";',
      'self.state.stop = wantHome ? "home" : "work";',
      'var t = self.state[self.state.stop] || { x: self.x, z: self.z };',
      'var dx = t.x - self.x, dz = t.z - self.z, d = Math.hypot(dx, dz);',
      'if (d > 0.3) {',
      '  var h = Math.atan2(self.x - t.x, self.z - t.z);',
      '  var nx = self.x - Math.sin(h) * 0.3, nz = self.z - Math.cos(h) * 0.3;',
      '  var m = world.materialAt(nx, nz);',
      '  if (m && m.walkable && !m.liquid) { self.x = nx; self.z = nz; self.rot = h; }',
      '}',
      'self.y = world.heightAt(self.x, self.z);',
    ].join('\n'),
  },
  wanderer: {
    mesh: { shape: 'cone', r: 0.6, h: 1.6, color: '#ff8c42', y: 0.8 },
    behavior: [
      'if (self.state.h == null) self.state.h = self.rot || 0;',
      'if (Math.random() < 0.1) self.state.h += (Math.random() - 0.5) * 1.5;',
      'var nx = self.x + Math.cos(self.state.h) * 0.3, nz = self.z + Math.sin(self.state.h) * 0.3;',
      'var m = world.materialAt(nx, nz);',
      'if (m && m.walkable && !m.liquid) { self.x = nx; self.z = nz; } else { self.state.h += 2; }',
      'self.y = world.heightAt(self.x, self.z);',
    ].join('\n'),
  },
};

class World {
  constructor(opts = {}) {
    // The world is INFINITE (format v2, D1): there is no `size`. `opts.size`
    // is accepted and ignored so older callers don't crash; distance-bounded
    // things (perceive radius, active chunks) replace edge-bounded ones.
    // Per-kind god-verb grants (see DEFAULT_GRANTS). Override per world, e.g.
    // { grants: { agent: { create: true, update: true, delete: true } } }.
    this.grants = {
      player: { ...DEFAULT_GRANTS.player, ...(opts.grants && opts.grants.player) },
      agent: { ...DEFAULT_GRANTS.agent, ...(opts.grants && opts.grants.agent) },
    };
    // D3: how many chunks of world stay simulated around each inhabitant.
    this.tickRadius = Number(process.env.OLLIE_COSMOS_TICK_RADIUS) || opts.tickRadius || 4;
    this.tickRate = opts.tickRate ?? 10;
    this.tick = 0;
    this.worldVersion = 1;
    this.typesVersion = 1;
    this.terrainVersion = 1; // bumps when the ground is sculpted/painted in-world
    this.types = new Map(); // in-world authored entity types: name -> { name, mesh, behavior, props, _fn }
    this.inhabitants = new Map();
    this.chat = []; // shared world chat — humans and agents talk here (ring buffer)
    this._chatSeq = 0; // monotonic id so "heard since last look" is timing-proof
    this.dormant = new Map(); // timed-out agent bodies (name -> {pose,life,intent}) waiting to be reclaimed
    this._seq = 0;
    // Testing affordance (mirrors the old `creatures` option): 0 suppresses
    // generated wanderers/hoppers so behavior tests get a quiet stage.
    this._creaturesEnabled = (opts.creatures ?? 6) !== 0;
    this._build((opts.seed ?? 1337) >>> 0);
  }

  /** (Re)build the world for a seed. Nothing is generated up front — chunks
   *  come into being when an inhabitant's presence activates them. */
  _build(seed) {
    this.seed = seed >>> 0;
    this.gen = makeWorldGen(this.seed);
    this.chunks = new ChunkStore(this.gen);
    this.entities = new Map();
    this.name = this.gen.worldName;
    this._activeChunks = new Set();
    this._spawnCache = null;
    this._buckets = new Map(); // chunkKey -> [entities], rebuilt each tick (see step)
  }

  // ── spatial queries: chunk-bucketed, so cost scales with what's NEARBY ────
  // At bounded-world scale a linear scan over every entity was fine; an
  // infinite world's active set is 10× that, and every hopper scanning every
  // fence post each tick is how sims melt. One cheap pass per tick buckets
  // live entities by chunk; every proximity question then reads only the
  // buckets its radius touches. (Stale by at most one tick for act handlers
  // that run between steps — nothing that matters at walking speed.)
  _rebuildBuckets() {
    this._buckets = new Map();
    for (const e of this.entities.values()) {
      const k = Math.floor(e.x / CHUNK) + ',' + Math.floor(e.z / CHUNK);
      let b = this._buckets.get(k);
      if (!b) { b = []; this._buckets.set(k, b); }
      b.push(e);
    }
  }

  _forEachNearby(x, z, r, fn) {
    const c0x = Math.floor((x - r) / CHUNK), c1x = Math.floor((x + r) / CHUNK);
    const c0z = Math.floor((z - r) / CHUNK), c1z = Math.floor((z + r) / CHUNK);
    for (let cz = c0z; cz <= c1z; cz++) {
      for (let cx = c0x; cx <= c1x; cx++) {
        const b = this._buckets.get(cx + ',' + cz);
        if (b) for (const e of b) fn(e);
      }
    }
  }

  /** Landmarks near a point — the world introduces its neighbourhood. */
  landmarksNear(x, z, r = 800) {
    return this.gen.landmarksNear(x, z, r);
  }

  /** timeOfDay ∈ [0,1): 0 = midnight, 0.5 = noon. Shared by every client so
   *  everyone in the world lives under the same sun. */
  timeInfo() {
    const t = ((this.tick + TIME_OFFSET) % DAY_TICKS) / DAY_TICKS;
    const phase = t < 0.22 ? 'night' : t < 0.3 ? 'dawn' : t < 0.72 ? 'day' : t < 0.8 ? 'dusk' : 'night';
    return { t: Math.round(t * 1000) / 1000, phase, dayTicks: DAY_TICKS };
  }

  /** New world from a given (or random) seed; existing inhabitants are kept
   *  and re-dropped onto fresh ground. Bumps worldVersion so clients reload. */
  regenerate(seed) {
    const s = seed != null && seed !== '' ? (Number(seed) >>> 0) : (Math.floor(Math.random() * 0xffffffff) >>> 0);
    this._build(s);
    this.worldVersion++;
    this.terrainVersion++;
    for (const inh of this.inhabitants.values()) {
      const { x, z } = this._findSpawn();
      inh.pose = { x, y: this.heightAt(x, z) + 1.7, z, yaw: 0, pitch: 0 };
    }
    return { seed: this.seed, worldVersion: this.worldVersion };
  }

  // ── terrain sampling ───────────────────────────────────────────────────────
  heightAt(x, z) {
    return this.chunks.heightAt(x, z);
  }
  materialAt(x, z) {
    // Infinite world: every coordinate has ground. (Old callers checked for
    // null out-of-bounds — there is no out-of-bounds anymore.)
    return material(this.chunks.materialIdAt(x, z));
  }
  _slopeAt(x, z) {
    const hx = this.chunks.heightAt(x + 1, z) - this.chunks.heightAt(x - 1, z);
    const hz = this.chunks.heightAt(x, z + 1) - this.chunks.heightAt(x, z - 1);
    return Math.sqrt(hx * hx + hz * hz);
  }

  // ── entity helpers (interaction / collision) ───────────────────────────────
  removeEntity(id) { return this.entities.delete(id); }

  /** Nearest entity of `kind` within `maxDist`, or null. */
  nearestEntity(kind, x, z, maxDist) {
    let best = null, bd = maxDist * maxDist;
    this._forEachNearby(x, z, maxDist, (e) => {
      if (e.kind !== kind) return;
      const dx = e.x - x, dz = e.z - z, d = dx * dx + dz * dz;
      if (d < bd) { bd = d; best = e; }
    });
    return best;
  }

  /** Nearest of SEVERAL kinds — one bucket walk instead of one per kind. */
  nearestOfKinds(kinds, x, z, maxDist) {
    let best = null, bd = maxDist * maxDist;
    this._forEachNearby(x, z, maxDist, (e) => {
      if (!kinds.includes(e.kind)) return;
      const dx = e.x - x, dz = e.z - z, d = dx * dx + dz * dz;
      if (d < bd) { bd = d; best = e; }
    });
    return best;
  }

  /** True if a circle (radius) at (x,z) overlaps a solid prop (trees, rocks,
   *  buildings — see SOLID_RADII; small flora you simply walk through).
   *  Doors are solid only while closed — that's what makes them doors. */
  blockedByProp(x, z, radius) {
    let hit = false;
    this._forEachNearby(x, z, radius + 6, (e) => { // +6 covers the largest solid (hut 2.4×scale)
      if (hit) return;
      let base = SOLID_RADII[e.kind];
      if (e.kind === 'door') base = e.open ? 0 : 1.0;
      if (!base) return;
      const pr = base * (e.scale || 1);
      const dx = e.x - x, dz = e.z - z, rr = radius + pr;
      if (dx * dx + dz * dz < rr * rr) hit = true;
    });
    return hit;
  }

  // ── the signal fabric (see entities/devices.js) ────────────────────────────
  /** Is any entity emitting a signal within `r` of (x,z)? Emitters: a truthy
   *  `signal` property — levers/plates via their class, authored entities by
   *  setting `self.signal = true` in a behavior. `excludeId` keeps a sensor
   *  from hearing itself. */
  signalAt(x, z, r = 6, excludeId = null) {
    const r2 = r * r;
    let hit = false;
    this._forEachNearby(x, z, r, (e) => {
      if (hit || !e.signal || e.id === excludeId) return;
      const dx = e.x - x, dz = e.z - z;
      if (dx * dx + dz * dz <= r2) hit = true;
    });
    return hit;
  }

  /** Is (x,z) inside light? Campfires always burn (radius 9); lamps count
   *  while lit (radius 7). Light is safety: prowlers refuse to enter it. */
  lightAt(x, z) {
    let lit = false;
    this._forEachNearby(x, z, 9, (e) => {
      if (lit) return;
      if (e.kind === 'campfire') {
        const dx = e.x - x, dz = e.z - z;
        if (dx * dx + dz * dz <= 81) lit = true;
      } else if ((e.kind === 'lamp' || e.kind === 'streetlight') && e.lit) {
        const dx = e.x - x, dz = e.z - z;
        if (dx * dx + dz * dz <= 49) lit = true;
      }
    });
    return lit;
  }

  /** Grant check for a god verb. Inhabitants unknown to the world (legacy
   *  clients that don't pass an actor) get full access — policy, not security. */
  _can(actorId, verb) {
    if (!actorId) return true;
    const inh = this.inhabitants.get(actorId);
    if (!inh) return true;
    const g = this.grants[inh.kind === 'player' ? 'player' : 'agent'] || {};
    return !!g[verb];
  }
  _denied(verb) {
    return { ok: false, error: `permission denied: your grants don't include '${verb}' (god verbs are gated per inhabitant; body verbs — move, eat, harvest, place, use — are always yours)` };
  }

  /** Drop one prop on dry, gentle ground (used for apple regrowth). */
  /** Drop one prop on dry, gentle ground near a living inhabitant — the
   *  infinite world regrows where someone is around to notice. */
  _spawnOneProp(kind) {
    const inhs = [...this.inhabitants.values()];
    if (!inhs.length) return null;
    const anchor = inhs[Math.floor(Math.random() * inhs.length)].pose;
    for (let t = 0; t < 30; t++) {
      const a = Math.random() * Math.PI * 2, r = 8 + Math.random() * 40;
      const x = anchor.x + Math.cos(a) * r, z = anchor.z + Math.sin(a) * r;
      const m = this.materialAt(x, z);
      if (!m || m.liquid || !m.walkable || this._slopeAt(x, z) > 2.6) continue;
      const id = `${kind}-r${this._seq++}`;
      this.entities.set(id, new Prop({ id, kind, x, y: this.heightAt(x, z), z, scale: 0.85 + Math.random() * 0.35, rot: Math.random() * 6.28 }));
      return id;
    }
    return null;
  }
  _countKind(kind) {
    let n = 0;
    for (const e of this.entities.values()) if (e.kind === kind) n++;
    return n;
  }
  /** Everyone spawns at the origin settlement (guaranteed to exist — see
   *  worldgen's spawn guarantee): a ring just outside the plaza, on the
   *  first walkable, unobstructed spot. Cached — it never changes. */
  _findSpawn() {
    if (this._spawnCache) return { ...this._spawnCache };
    const site = this.gen.province(0, 0).site || { x: 0, z: 0 };
    for (let r = 12; r < 80; r += 2) {
      for (let a = 0; a < 360; a += 20) {
        const x = site.x + Math.cos((a * Math.PI) / 180) * r;
        const z = site.z + Math.sin((a * Math.PI) / 180) * r;
        const m = this.materialAt(x, z);
        if (m && m.walkable && !m.liquid && !this.blockedByProp(x, z, 0.5)) {
          this._spawnCache = { x, z };
          return { x, z };
        }
      }
    }
    this._spawnCache = { x: site.x + 14, z: site.z };
    return { ...this._spawnCache };
  }


  // ── chunk activation: the world exists where inhabitants are ──────────────
  // Every tick, the union of discs (tickRadius chunks, D3) around all
  // inhabitants is the ACTIVE set. Chunks entering it come alive: first
  // activation instantiates their generated contents (scenery + settlement
  // specs from worldgen — deterministic ids, so pins and saves stay honest);
  // reactivation restores whatever was standing there when the chunk went
  // dormant. Chunks leaving it freeze their entities into the chunk record
  // (prowlers, being creatures of the moment, just dissolve).
  _chunkOfPos(x, z) {
    return { cx: Math.floor(x / CHUNK), cz: Math.floor(z / CHUNK) };
  }

  _updateActiveChunks() {
    const want = new Set();
    const R = this.tickRadius;
    for (const inh of this.inhabitants.values()) {
      const { cx, cz } = this._chunkOfPos(inh.pose.x, inh.pose.z);
      for (let dz = -R; dz <= R; dz++) {
        for (let dx = -R; dx <= R; dx++) {
          if (dx * dx + dz * dz > R * R + 1) continue; // a disc, not a square
          want.add((cx + dx) + ',' + (cz + dz));
        }
      }
    }
    for (const k of want) if (!this._activeChunks.has(k)) this._activateChunk(k);
    // One sweep freezes EVERY live entity standing outside the active set —
    // this covers deactivated chunks, creatures that wandered over an active
    // edge, and things authored from afar into ground nobody's near. The
    // invariant it keeps: live entities ⊆ active chunks, always.
    for (const [id, e] of this.entities) {
      const c = this._chunkOfPos(e.x, e.z);
      const k = c.cx + ',' + c.cz;
      if (want.has(k)) continue;
      this.entities.delete(id);
      if (e.kind === 'prowler') continue; // the night does not persist
      const rec = this.chunks.entityRecord(c.cx, c.cz);
      if (!rec.list) rec.list = [];
      const s = e.serialize();
      if (e.owner) s.owner = e.owner;
      rec.list.push(s);
    }
    this._activeChunks = want;
  }

  _activateChunk(k) {
    const [cx, cz] = k.split(',').map(Number);
    const rec = this.chunks.entityRecord(cx, cz);
    if (!rec.populated) {
      rec.populated = true;
      // First light: instantiate what the generator says stands here —
      // per-chunk scenery, plus any settlement pieces that fall inside this
      // chunk (a settlement spans several chunks; each takes its share).
      const specs = [...this.gen.scenerySpecs(cx, cz), ...this._settlementSpecsIn(cx, cz)];
      let n = 0;
      for (const s of specs) {
        n++; // ids stay aligned to spec order even when a spec is skipped
        if (!this._creaturesEnabled && (s.kind === 'wanderer' || s.kind === 'hopper')) continue;
        // A settlement's outer pieces (the pen, the pitch) can reach past the
        // levelled ground onto a pond or riverbank the site itself avoided —
        // a fence post in the water is a generation bug, so skip, don't sink.
        const m = this.materialAt(s.x, s.z);
        if (!m || m.liquid) continue;
        const ent = this._instantiate(s.kind, s.x, this.heightAt(s.x, s.z), s.z, s, `g${cx}_${cz}_${n}`);
        if (ent) this.entities.set(ent.id, ent);
      }
    }
    if (rec.list) {
      for (const e of rec.list) this._restoreEntity(e);
      rec.list = null;
    }
  }

  /** Settlement entity specs whose position falls inside chunk (cx,cz) —
   *  gathered from every province whose settlement could reach it. */
  _settlementSpecsIn(cx, cz) {
    const x0 = cx * CHUNK, z0 = cz * CHUNK;
    const out = [];
    const seen = new Set();
    for (const [px, pz] of [
      [Math.floor(x0 / this.gen.PROVINCE), Math.floor(z0 / this.gen.PROVINCE)],
      [Math.floor((x0 + CHUNK) / this.gen.PROVINCE), Math.floor((z0 + CHUNK) / this.gen.PROVINCE)],
      [Math.floor(x0 / this.gen.PROVINCE), Math.floor((z0 + CHUNK) / this.gen.PROVINCE)],
      [Math.floor((x0 + CHUNK) / this.gen.PROVINCE), Math.floor(z0 / this.gen.PROVINCE)],
    ]) {
      // Settlements sit well inside provinces but their pieces can spill a
      // chunk over — check this chunk's own province neighbourhood.
      for (let dz = -1; dz <= 1; dz++) {
        for (let dx = -1; dx <= 1; dx++) {
          const key = (px + dx) + ',' + (pz + dz);
          if (seen.has(key)) continue;
          seen.add(key);
          for (const s of this.gen.settlementSpecs(px + dx, pz + dz)) {
            if (s.x >= x0 && s.x < x0 + CHUNK && s.z >= z0 && s.z < z0 + CHUNK) out.push(s);
          }
        }
      }
    }
    return out;
  }


  // ── tick loop ──────────────────────────────────────────────────────────────
  start() {
    if (this._timer) return;
    this._timer = setInterval(() => this.step(), 1000 / this.tickRate);
    if (this._timer.unref) this._timer.unref();
  }
  stop() {
    if (this._timer) clearInterval(this._timer);
    this._timer = null;
  }
  step() {
    this.tick++;
    this._updateActiveChunks(); // the world lives where inhabitants are (D3)
    this._rebuildBuckets(); // one pass; every proximity query this tick reads buckets
    for (const e of this.entities.values()) {
      if (typeof e.update === 'function') {
        try {
          e.update(this);
          if (e._lastError) e._lastError = null; // recovered — clear stale error
        } catch (err) {
          // Don't swallow: stash it so the *author* (human or agent) can see why
          // their behavior is inert. Surfaced via perceive (per entity) and
          // getTypes (per authored type). See serializeEntities / getTypes.
          e._lastError = { msg: err.message, tick: this.tick };
          const t = e.type && this.types.get(e.type);
          if (t) t.lastError = { msg: err.message, tick: this.tick, entity: e.id };
          console.error(`[world] entity ${e.id} update failed:`, err.message);
        }
      }
    }
    for (const inh of this.inhabitants.values()) {
      // A human (kind 'player') drives their own body every frame from input —
      // we never move them. An agent's body executes its standing intent here,
      // so it keeps walking/following/etc. between the mind's turns.
      if (inh.kind !== 'player') this._stepIntent(inh);
      if (inh.life) tickLife(inh.life, { world: this, inhabitant: inh });
    }
    // Apple regrowth — keep the ecosystem alive so foragers don't strip it bare.
    if (this.tick % 100 === 0 && this._countKind('apple') < 26) this._spawnOneProp('apple');

    // The night: prowlers seep in after dark (away from people and light) and
    // dissolve at dawn (they handle that themselves). Fires and lit lamps are
    // the counterspell — see lightAt and entities/prowler.js.
    if (this.timeInfo().phase === 'night' && this.tick % 120 === 0 && this._countKind('prowler') < 4) {
      const inhs = [...this.inhabitants.values()];
      for (let t = 0; t < 40 && inhs.length; t++) {
        // The night stalks people: spawn in a ring just outside an
        // inhabitant's sight — but only in wild country. The development
        // gradient (D2) is a promise: hamlet nights are spooky, village
        // fires hold, and towns and cities never hear prowlers at all.
        const anchor = inhs[Math.floor(Math.random() * inhs.length)].pose;
        const a = Math.random() * Math.PI * 2, r = 26 + Math.random() * 24;
        const x = anchor.x + Math.cos(a) * r, z = anchor.z + Math.sin(a) * r;
        const m = this.materialAt(x, z);
        if (!m || m.liquid || !m.walkable || this.lightAt(x, z)) continue;
        const tier = this.gen.province(Math.floor(x / this.gen.PROVINCE), Math.floor(z / this.gen.PROVINCE)).tier;
        if (tier !== 'wilderness' && tier !== 'hamlet') continue;
        let tooClose = false;
        for (const other of this.inhabitants.values()) {
          if (Math.hypot(other.pose.x - x, other.pose.z - z) < 22) { tooClose = true; break; }
        }
        if (tooClose) continue;
        const id = `prowler-n${this._seq++}`;
        const p = new Prowler({ id, x, z, seed: (this.seed ^ (this._seq * 2654435761)) >>> 0 });
        p.y = this.heightAt(x, z);
        this.entities.set(id, p);
        break;
      }
    }

    // Prune inhabitants that stopped perceiving. A player's tab dies fast (12s);
    // an agent body lives much longer so it survives the gaps between turns.
    const now = Date.now();
    for (const [id, inh] of this.inhabitants) {
      const timeout = inh.kind === 'player' ? INHABITANT_TIMEOUT : AGENT_TIMEOUT;
      if ((inh.lastSeen || inh.attachedAt || 0) < now - timeout) {
        this._stashBody(inh); // agents keep their body (pose/life/intent) to reclaim on return
        this.inhabitants.delete(id);
      }
    }
  }

  /**
   * Advance an agent body one tick toward fulfilling its standing intent. This
   * is the continuous "controller" — the LLM sets an intent (walk, go, follow,
   * wander, …) and the body acts on it on its own, turning smoothly then
   * striding, grounding to terrain, refusing to wade into water. Arrivals and
   * interruptions are pushed to inh.events so the mind learns what happened.
   */
  _stepIntent(inh) {
    const it = inh.intent;
    if (!it || it.kind === 'idle') { inh._moving = false; return; }
    const dt = 1 / this.tickRate;
    const p = inh.pose;
    let desired = null; // heading to turn toward
    let move = false; // translate this tick?

    if (it.kind === 'move') {
      desired = it.heading;
      move = true;
    } else if (it.kind === 'face') {
      desired = it.yaw != null ? it.yaw : this._headingToTarget(inh, it.targetId);
    } else if (it.kind === 'goto') {
      const d = Math.hypot(it.x - p.x, it.z - p.z);
      if (d <= (it.stopAt || ARRIVE_EPS)) {
        inh.events.push(`arrived at ${it.label || `(${r1(it.x)}, ${r1(it.z)})`}`);
        inh.intent = { kind: 'idle' };
        inh._moving = false;
        inh._route = null;
        return;
      }
      // A route from a DIFFERENT goto target is stale — drop it (and the
      // memory of it having failed) so a fresh goal gets a fresh look.
      if (inh._routeGoal && (inh._routeGoal.x !== it.x || inh._routeGoal.z !== it.z)) {
        inh._route = null; inh._routeGoal = null; inh._routeIndex = 0; inh._routeFailed = null;
      }
      // Following a routed path (see pathfind.js / the blocked branch below):
      // aim at the current waypoint, advancing once close enough. The final
      // waypoint is always the exact goal, so ordinary arrival (above) still
      // fires the same way it always has — routing only changes what we aim
      // at along the way, never how "arrived" is decided.
      let aimX = it.x, aimZ = it.z;
      if (inh._route && inh._routeIndex < inh._route.length) {
        const wp = inh._route[inh._routeIndex];
        if (Math.hypot(wp.x - p.x, wp.z - p.z) <= WAYPOINT_EPS && inh._routeIndex < inh._route.length - 1) {
          inh._routeIndex++;
        }
        const cur = inh._route[inh._routeIndex];
        aimX = cur.x; aimZ = cur.z;
      }
      desired = headingToward(p.x, p.z, aimX, aimZ);
      move = true;
    } else if (it.kind === 'follow') {
      const t = this._targetPos(it.targetId);
      if (!t) {
        inh.events.push(`lost ${it.targetId} — stopped following`);
        inh.intent = { kind: 'idle' };
        inh._moving = false;
        return;
      }
      desired = headingToward(p.x, p.z, t.x, t.z);
      move = Math.hypot(t.x - p.x, t.z - p.z) > (it.distance || 1.6) + 0.2;
    } else if (it.kind === 'flee') {
      const t = it.targetId ? this._targetPos(it.targetId) : { x: it.x, z: it.z };
      if (!t || t.x == null) { inh.intent = { kind: 'idle' }; inh._moving = false; return; }
      desired = Math.atan2(t.x - p.x, t.z - p.z); // opposite of "toward"
      move = true;
    } else if (it.kind === 'wander') {
      if (it.h == null || Math.random() < 0.04) it.h = (it.h == null ? p.yaw : it.h) + (Math.random() - 0.5) * WANDER_DRIFT * 2;
      desired = it.h;
      move = true;
    }

    if (desired == null) { inh._moving = false; return; }
    p.yaw = approachAngle(p.yaw, desired, AGENT_TURN * dt);

    if (move) {
      const stepLen = AGENT_SPEED * dt;
      const tryAngle = (ang) => {
        const nx = p.x + -Math.sin(ang) * stepLen;
        const nz = p.z + -Math.cos(ang) * stepLen;
        return this._walkable(nx, nz) ? { nx, nz } : null;
      };
      // Step forward; if blocked by water/cliff, SLIDE — deflect left/right to
      // follow the shore instead of freezing. A being walks *along* a lake edge,
      // it doesn't stand paralyzed at it.
      let dest = tryAngle(p.yaw);
      const directBlocked = !dest; // the straight line toward `desired` specifically failed
      if (!dest) {
        for (const d of [Math.PI / 4, -Math.PI / 4, Math.PI / 2, -Math.PI / 2]) {
          dest = tryAngle(p.yaw + d);
          if (dest) break;
        }
      }

      // A goto whose straight line keeps failing is the "sliding along the
      // riverbank forever" case — the deflection above keeps it MOVING (so it
      // never trips the fully-boxed-in branch below), but never toward the
      // goal. A few ticks of that (not one — headings wobble near a corner)
      // is the signal to look ahead for a route around it. A route already
      // tried and failed for this exact goal isn't retried every tick.
      if (it.kind === 'goto') {
        inh._offCourse = directBlocked ? (inh._offCourse || 0) + 1 : 0;
        if (inh._offCourse >= 3) {
          if (inh._route) { inh._route = null; inh._routeGoal = null; inh._routeIndex = 0; } // stopped working — drop it, try fresh below
          if (!(inh._routeFailed && inh._routeFailed.x === it.x && inh._routeFailed.z === it.z) && this._walkable(it.x, it.z)) {
            const route = findPath(this, p.x, p.z, it.x, it.z);
            if (route && route.length) {
              inh._route = route;
              inh._routeGoal = { x: it.x, z: it.z };
              inh._routeIndex = 0;
              inh._offCourse = 0;
            } else {
              inh._routeFailed = { x: it.x, z: it.z };
            }
          }
        }
      }

      if (dest) {
        p.x = dest.nx;
        p.z = dest.nz;
        inh._blocked = 0;
      } else {
        // Truly boxed in — never permanently freeze. Reverse a wander to look for
        // a way out; let a stuck goto give up after a few seconds so the mind
        // can re-plan (rather than oscillating at an impassable edge forever).
        move = false;
        inh._blocked = (inh._blocked || 0) + 1;
        if (it.kind === 'wander') it.h = (it.h == null ? p.yaw : it.h) + Math.PI;
        else if (inh._blocked === 1) inh.events.push('blocked by water/terrain — looking for a way around');
        if ((it.kind === 'goto' || it.kind === 'move') && inh._blocked > 30) {
          inh.events.push(`couldn't reach ${it.label || 'there'} — water or steep terrain in the way`);
          inh.intent = { kind: 'idle' };
          inh._route = null;
        }
      }
    }
    p.y = this.heightAt(p.x, p.z) + EYE_HEIGHT;
    inh._moving = move;
  }

  /** Can an inhabitant stand/walk at this world position? (not water, not a cliff) */
  _walkable(x, z) {
    const m = typeof this.materialAt === 'function' ? this.materialAt(x, z) : null;
    if (!m || m.liquid || !m.walkable) return false;
    if (typeof this._slopeAt === 'function' && this._slopeAt(x, z) > 2.6) return false;
    return true;
  }

  /**
   * A sampled top-down "view" around an inhabitant, as text (cheap, exact — no
   * rendering). Two aligned grids: terrain/entities, and elevation relative to
   * the viewer. North up, west left, centered on the viewer (@). This is how an
   * agent *sees* — its surroundings and the lay of the land — the Cosmos-native
   * way (the world descends from a grid world, after all).
   */
  gridView(inhabitantId, opts = {}) {
    const self = this.inhabitants.get(inhabitantId);
    if (!self) return null;
    const cl = (v, lo, hi, d) => { const n = Number(v); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : d; };
    const radius = cl(opts.radius, 6, 120, 30);
    const step = cl(opts.step, 1, 12, 3);
    const half = Math.max(1, Math.round(radius / step));
    const cx = self.pose.x, cz = self.pose.z;
    const centerH = this.heightAt(cx, cz);
    const cellKey = (x, z) => `${Math.round((x - cx) / step)},${Math.round((z - cz) / step)}`;

    const ENT_SYM = {
      tree: 'T', pine: 'T', rock: 'O', apple: '*', wanderer: 'w', hopper: 'h', prowler: '!', villager: 'V',
      bush: 'b', flower: 'f', reed: 'i', mushroom: 'm',
      hut: 'H', well: 'U', tower: 'L', stone: 'S', campfire: 'c', tent: 'n',
      wall: 'x', fence: 'x', floor: '_', door: 'D', lever: '/', plate: '=', lamp: 'u', ball: 'o', crate: 'K',
      building: 'B', car: 'C', shopfront: '$', sign: 'g', streetlight: 'l',
    };
    const occ = new Map();
    for (const e of this.entities.values()) {
      if (Math.abs(e.x - cx) > radius + step || Math.abs(e.z - cz) > radius + step) continue;
      const k = cellKey(e.x, e.z);
      if (!occ.has(k)) occ.set(k, ENT_SYM[e.kind] || '?');
    }
    for (const [oid, o] of this.inhabitants) {
      if (oid === inhabitantId) continue;
      if (Math.abs(o.pose.x - cx) > radius + step || Math.abs(o.pose.z - cz) > radius + step) continue;
      occ.set(cellKey(o.pose.x, o.pose.z), o.kind === 'player' ? 'P' : 'a');
    }

    const TERR = { water: '~', sand: ':', grass: '.', dirt: ';', rock: '^', path: ',', snow: '"' };
    const terr = [], elev = [];
    for (let r = -half; r <= half; r++) { // r over z: -half = north (top)
      let trow = '', erow = '';
      for (let c = -half; c <= half; c++) { // c over x: -half = west (left)
        if (r === 0 && c === 0) { trow += '@'; erow += '@'; continue; }
        const wx = cx + c * step, wz = cz + r * step;
        let ts = occ.get(`${c},${r}`);
        if (!ts) { const m = this.materialAt(wx, wz); ts = m ? (TERR[m.name] || '?') : ' '; }
        trow += ts;
        let es;
        if (this._slopeAt(wx, wz) > 2.6) es = '#';
        else { const dh = this.heightAt(wx, wz) - centerH; es = dh > 1 ? '+' : dh < -1 ? '-' : '='; }
        erow += es;
      }
      terr.push(trow);
      elev.push(erow);
    }

    const yaw = self.pose.yaw || 0;
    const COMPASS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
    const facing = COMPASS[((-Math.round(yaw / (Math.PI / 4)) % 8) + 8) % 8];
    const time = this.timeInfo();
    const near = this.gen.landmarksNear(cx, cz, 600).slice(0, 5);
    const landmarkLine = near.length
      ? `Landmarks: ${near.map((l) => `${l.name} (${l.kind}) at (${l.x}, ${l.z})`).join(' · ')}.`
      : null;
    const text = [
      `Top-down view around you — north up, west left, each cell ≈ ${step}m. You are @ at (${r1(cx)}, ${r1(cz)}), facing ${facing}. It is ${time.phase}.`,
      ...(landmarkLine ? [landmarkLine] : []),
      `legend: @ you · T tree · O rock · * apple · w/h creatures · ! prowler(night) · V villager · b bush · f flower · m mushroom · H hut · U well · L tower · S stone · c campfire · n tent · x wall/fence · _ floor · D door · / lever · = plate · u lamp · o ball · K crate · B building · C car · $ shopfront · g sign · l streetlight · a agent · P player · ~ water · : sand · . grass · ; dirt · ^ rocky · , path`,
      ...terr,
      '',
      `elevation vs you (= level · + higher · - lower · # too steep to cross):`,
      ...elev,
    ].join('\n');
    return { text, facing, center: { x: r1(cx), z: r1(cz) }, step, radius };
  }

  /** Live position of an entity OR another inhabitant, by id. */
  _targetPos(id) {
    if (!id) return null;
    const e = this.entities.get(id);
    if (e) return { x: e.x, z: e.z };
    const inh = this.inhabitants.get(id);
    if (inh) return { x: inh.pose.x, z: inh.pose.z };
    return null;
  }

  _headingToTarget(inh, id) {
    const t = this._targetPos(id);
    return t ? headingToward(inh.pose.x, inh.pose.z, t.x, t.z) : inh.pose.yaw;
  }

  /** Coerce a client-supplied intent into a safe, well-formed standing intent. */
  _sanitizeIntent(raw) {
    const i = raw || {};
    const kind = String(i.kind || 'idle');
    const clampPos = (v) => Number(v) || 0; // infinite world — no edges to clamp to
    switch (kind) {
      case 'move':
        return { kind, heading: Number(i.heading) || 0 };
      case 'goto':
        return { kind, x: clampPos(i.x), z: clampPos(i.z), stopAt: i.stopAt != null ? Number(i.stopAt) : ARRIVE_EPS, label: String(i.label || '').slice(0, 40) };
      case 'follow':
        return { kind, targetId: String(i.targetId || ''), distance: i.distance != null ? Number(i.distance) : 1.6 };
      case 'flee':
        return i.targetId ? { kind, targetId: String(i.targetId) } : { kind, x: clampPos(i.x), z: clampPos(i.z) };
      case 'face':
        return i.targetId ? { kind, targetId: String(i.targetId) } : { kind, yaw: Number(i.yaw) || 0 };
      case 'wander':
        return { kind: 'wander' };
      default:
        return { kind: 'idle' };
    }
  }

  // ── Inhabitant API ───────────────────────────────────────────────────────────
  attach(o = {}) {
    const id = `inh-${crypto.randomBytes(4).toString('hex')}`;
    const kind = o.kind || 'player';
    const name = o.name || (kind === 'player' ? 'Wanderer' : 'Agent');
    // Persistent body: an *agent* that re-joins by name reclaims the body it left —
    // same place, same life/hunger, same standing intent — like logging back into
    // an MMO. (Re-grounded to current terrain in case the ground changed.) A
    // never-seen agent, and any human player, just spawns fresh.
    const saved = kind !== 'player' ? this.dormant.get(name) : null;
    let pose, life, intent, inventory;
    if (saved) {
      this.dormant.delete(name);
      pose = { ...saved.pose, y: this.heightAt(saved.pose.x, saved.pose.z) + EYE_HEIGHT };
      life = saved.life || initLife();
      intent = saved.intent || { kind: 'idle' };
      inventory = saved.inventory || { wood: 0, stone: 0 };
    } else {
      const { x, z } = this._findSpawn();
      pose = { x, y: this.heightAt(x, z) + EYE_HEIGHT, z, yaw: 0, pitch: 0 };
      life = initLife();
      intent = { kind: 'idle' }; // a body's persistent "what I'm doing" — executed each tick (agents only)
      inventory = { wood: 0, stone: 0 }; // the matter loop: harvest fills it, placing spends it
    }
    const grants = { ...(this.grants[kind === 'player' ? 'player' : 'agent']) };
    const inhabitant = {
      id,
      kind,
      name,
      pose,
      life,
      intent,
      inventory,
      events: [], // salient things that happened since the mind last perceived (drained by perceive)
      attachedAt: Date.now(),
      lastSeen: Date.now(),
    };
    this.inhabitants.set(id, inhabitant);
    return { id, kind, name, pose, life, inventory, grants, restored: Boolean(saved) };
  }

  /** Park an agent's body so a future attach by the same name resumes it. */
  _stashBody(inh) {
    if (!inh || inh.kind === 'player' || !inh.name) return;
    this.dormant.set(inh.name, { pose: { ...inh.pose }, life: inh.life, intent: inh.intent, inventory: inh.inventory });
    if (this.dormant.size > 64) this.dormant.delete(this.dormant.keys().next().value); // bound it
  }
  detach(id) {
    return this.inhabitants.delete(id);
  }

  /** World metadata — the ground itself streams per-chunk (see the
   *  /api/world/chunk route and chunkData below); a snapshot no longer
   *  carries whole-map arrays because there is no whole map. */
  snapshot() {
    const spawn = this._findSpawn();
    return {
      format: 2,
      infinite: true,
      seed: this.seed,
      name: this.name,
      chunkSize: CHUNK,
      spawn,
      landmarks: this.gen.landmarksNear(spawn.x, spawn.z, 900),
      time: this.timeInfo(),
      tickRate: this.tickRate,
      worldVersion: this.worldVersion,
      typesVersion: this.typesVersion,
      terrainVersion: this.terrainVersion,
      seaLevel: this.gen.SEA_LEVEL,
      maxElevation: this.gen.MAX_ELEVATION,
      materials: MATERIALS,
      types: this.getTypes(),
      entities: [], // live state streams through perceive (spatial)
    };
  }

  /** One chunk for the wire: centi-height ints (compact JSON) + materials.
   *  Sampled as a (CHUNK+1)² grid — one row/column of overlap into the east
   *  and south neighbours — so the client's chunk meshes share edge vertices
   *  and the ground has no seams, with zero client-side stitching. */
  chunkData(cx, cz) {
    const c = this.chunks.chunk(cx, cz); // ensures the chunk exists (and its version)
    const W = CHUNK + 1;
    const height = new Array(W * W);
    const material = new Array(W * W);
    const x0 = cx * CHUNK, z0 = cz * CHUNK;
    for (let j = 0; j < W; j++) {
      for (let i = 0; i < W; i++) {
        height[j * W + i] = Math.round(this.chunks.heightAt(x0 + i, z0 + j) * 100);
        material[j * W + i] = this.chunks.materialIdAt(x0 + i, z0 + j);
      }
    }
    return { cx, cz, version: c.version, grid: W, height, material };
  }

  perceive(inhabitantId) {
    const self = this.inhabitants.get(inhabitantId) || null;
    if (self) self.lastSeen = Date.now(); // heartbeat — clients perceive every tick
    const others = [];
    for (const [oid, o] of this.inhabitants) {
      if (oid === inhabitantId) continue;
      others.push({ id: o.id, kind: o.kind, name: o.name, pose: o.pose });
    }
    const events = self && self.events.length ? self.events.splice(0, self.events.length) : [];
    // Chat the inhabitant hasn't heard yet (since it last perceived) — this is how
    // an agent "hears" someone talk to it in the world. Its own lines are skipped.
    let heard = [];
    if (self) {
      const lastHeard = self.lastChatSeq || 0;
      heard = this.chat.filter((m) => m.seq > lastHeard && m.fromId !== self.id).map((m) => ({ from: m.from, fromId: m.fromId, kind: m.kind, text: m.text }));
      self.lastChatSeq = this._chatSeq;
    }
    // SPATIAL perceive: you see what's near your body, not the whole world —
    // there is no whole world anymore. (Structured-state analogue of a draw
    // distance; agents that want farther use gridView or landmarks.)
    const R2 = 96 * 96;
    const entities = [];
    if (self) {
      for (const e of this.entities.values()) {
        const dx = e.x - self.pose.x, dz = e.z - self.pose.z;
        if (dx * dx + dz * dz > R2) continue;
        const s = e.serialize();
        if (e._lastError) s.lastError = e._lastError;
        entities.push(s);
      }
    }
    return {
      tick: this.tick,
      time: this.timeInfo(), // shared sun — clients drive the sky from this
      worldVersion: this.worldVersion,
      typesVersion: this.typesVersion,
      terrainVersion: this.terrainVersion,
      self: self
        ? {
            id: self.id, kind: self.kind, name: self.name, pose: self.pose, life: self.life,
            intent: self.intent || { kind: 'idle' }, moving: !!self._moving,
            inventory: self.inventory || { wood: 0, stone: 0 },
            grants: { ...(this.grants[self.kind === 'player' ? 'player' : 'agent']) },
          }
        : null,
      events, // salient things since you last perceived (arrivals, interruptions)
      heard, // chat said in the world since you last looked
      others,
      landmarks: self ? this.gen.landmarksNear(self.pose.x, self.pose.z, 500).slice(0, 6) : [],
      entities,
    };
  }

  /** Speak into the shared world chat. Everyone in the world hears it on their next perceive. */
  say(inhabitantId, text) {
    const inh = this.inhabitants.get(inhabitantId);
    if (!inh) return { ok: false, error: 'unknown inhabitant' };
    const clean = String(text || '').slice(0, 500).trim();
    if (!clean) return { ok: false, error: 'empty message' };
    const msg = { from: inh.name, fromId: inh.id, kind: inh.kind, text: clean, ts: Date.now(), seq: ++this._chatSeq };
    this.chat.push(msg);
    if (this.chat.length > 100) this.chat.splice(0, this.chat.length - 100);
    return { ok: true, message: msg };
  }

  /** Recent chat for the viewport/log (full recent window, not just-since). */
  recentChat(n = 30) {
    return this.chat.slice(-n).map((m) => ({ from: m.from, kind: m.kind, text: m.text, ts: m.ts }));
  }

  act(inhabitantId, action) {
    const inh = this.inhabitants.get(inhabitantId);
    if (!inh) return { ok: false, error: 'unknown inhabitant' };
    inh.lastSeen = Date.now();
    const a = action || {};
    // Standing intent: the continuous-control path. The body then acts on this
    // every tick (see _stepIntent) until the mind changes it. 'stop' = idle.
    if (a.type === 'intent') {
      inh.intent = this._sanitizeIntent(a.intent);
      return { ok: true, intent: inh.intent, pose: inh.pose };
    }
    if (a.type === 'stop') {
      inh.intent = { kind: 'idle' };
      return { ok: true, intent: inh.intent, pose: inh.pose };
    }
    if (a.type === 'pose' && a.pose) {
      const x = Number(a.pose.x) || 0;
      const z = Number(a.pose.z) || 0;
      const floor = this.heightAt(x, z);
      const y = Number.isFinite(Number(a.pose.y)) ? Number(a.pose.y) : floor + 1.7;
      inh.pose = { x, y, z, yaw: Number(a.pose.yaw) || 0, pitch: Number(a.pose.pitch) || 0 };
      return { ok: true, pose: inh.pose };
    }
    if (a.type === 'eat') {
      // Eat the nearest apple within reach → restore life. The first real
      // interaction, and the player half of the homeostatic loop.
      const apple = this.nearestEntity('apple', inh.pose.x, inh.pose.z, 3.0);
      if (apple && inh.life) {
        this.removeEntity(apple.id);
        inh.life.value = Math.min(inh.life.max, inh.life.value + 30);
        return { ok: true, ate: true, life: inh.life };
      }
      return { ok: true, ate: false };
    }
    // ── body verbs (the ladder's middle rungs — never gated by grants) ──────
    if (a.type === 'harvest') {
      // Chop/break the nearest harvestable thing → material into inventory.
      let best = null, bd = HARVEST_RANGE * HARVEST_RANGE;
      for (const e of this.entities.values()) {
        if (!HARVEST_YIELD[e.kind]) continue;
        const dx = e.x - inh.pose.x, dz = e.z - inh.pose.z, d = dx * dx + dz * dz;
        if (d < bd) { bd = d; best = e; }
      }
      if (!best) return { ok: true, got: null, inventory: inh.inventory, note: 'nothing harvestable in reach (trees, pines, bushes, rocks)' };
      const yield_ = HARVEST_YIELD[best.kind];
      const got = {};
      for (const [mat, n] of Object.entries(yield_)) {
        const amount = Math.max(1, Math.round(n * (best.scale || 1)));
        inh.inventory[mat] = (inh.inventory[mat] || 0) + amount;
        got[mat] = amount;
      }
      const kind = best.kind;
      this.removeEntity(best.id);
      return { ok: true, harvested: kind, got, inventory: inh.inventory };
    }
    if (a.type === 'place') {
      // Build: spend material to put a piece into the world, just ahead of you
      // (or at the given spot, within arm's-plus reach).
      const kind = String(a.kind || '');
      const cost = PLACE_COSTS[kind];
      if (!cost) return { ok: false, error: `not placeable: '${kind}' (options: ${Object.keys(PLACE_COSTS).join(', ')})` };
      for (const [mat, n] of Object.entries(cost)) {
        if ((inh.inventory[mat] || 0) < n) {
          return { ok: false, error: `not enough ${mat} (need ${Object.entries(cost).map(([m, c]) => `${c} ${m}`).join(' + ')}; you have ${Object.entries(inh.inventory).map(([m, c]) => `${c} ${m}`).join(', ')}). Harvest trees for wood, rocks for stone.`, inventory: inh.inventory };
        }
      }
      let x, z;
      if (a.x != null && a.z != null) {
        x = Number(a.x); z = Number(a.z);
        if (Math.hypot(x - inh.pose.x, z - inh.pose.z) > 8) return { ok: false, error: 'too far away — build within 8m of where you stand' };
      } else {
        // 2m ahead of the body (forward = (-sin yaw, -cos yaw), the camera convention).
        x = inh.pose.x + -Math.sin(inh.pose.yaw) * 2.0;
        z = inh.pose.z + -Math.cos(inh.pose.yaw) * 2.0;
      }

      const m = this.materialAt(x, z);
      if (!m || m.liquid) return { ok: false, error: 'cannot build on water' };
      if (this._slopeAt(x, z) > 3.2) return { ok: false, error: 'too steep to build here' };
      for (const [mat, n] of Object.entries(cost)) inh.inventory[mat] -= n;
      const ent = this._instantiate(kind, x, this.heightAt(x, z), z, { rot: inh.pose.yaw });
      ent.owner = inh.name; // placer may always remove their own work
      this.entities.set(ent.id, ent);
      return { ok: true, placed: ent.serialize(), inventory: inh.inventory };
    }
    if (a.type === 'use') {
      // Flip the nearest lever or latch the nearest door (2.6m reach).
      let best = null, bd = 2.6 * 2.6;
      for (const e of this.entities.values()) {
        if (e.kind !== 'lever' && e.kind !== 'door') continue;
        const dx = e.x - inh.pose.x, dz = e.z - inh.pose.z, d = dx * dx + dz * dz;
        if (d < bd) { bd = d; best = e; }
      }
      if (!best) return { ok: true, used: null, note: 'nothing usable in reach (levers, doors)' };
      const state = best.use();
      return { ok: true, used: best.kind, id: best.id, state: best.kind === 'lever' ? (state ? 'on' : 'off') : (state ? 'open' : 'closed') };
    }
    if (a.type === 'strike') {
      // Hit the nearest prowler. Three solid blows dissolve it.
      const prowler = this.nearestEntity('prowler', inh.pose.x, inh.pose.z, 2.6);
      if (!prowler) return { ok: true, hit: false, note: 'no prowler in reach' };
      const dissolved = prowler.struck(this, 34);
      return { ok: true, hit: true, dissolved, remaining: dissolved ? 0 : Math.max(0, Math.round(prowler.life)) };
    }
    return { ok: false, error: `unknown action: ${a.type}` };
  }

  /**
   * Define a new entity type from an in-world authored spec:
   *   { name, mesh, behavior?, props? }
   * `behavior` is the BODY of `function(self, world){ ... }`, run each tick.
   */
  defineType(def = {}, actorId = null) {
    const name = String((def && def.name) || '').trim();
    if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)) {
      return { ok: false, error: 'type name must start with a letter (letters, numbers, _ -)' };
    }
    if (RESERVED_KINDS.has(name)) return { ok: false, error: `'${name}' is a reserved kind` };
    if (!def.mesh) return { ok: false, error: 'a mesh is required' };
    // Defining a NEW type is a create; redefining someone else's is an update.
    const existing = this.types.get(name);
    const actor = actorId ? this.inhabitants.get(actorId) : null;
    if (existing) {
      const owns = actor && existing.owner && existing.owner === actor.name;
      if (!owns && !this._can(actorId, 'update')) return this._denied('update');
    } else if (!this._can(actorId, 'create')) {
      return this._denied('create');
    }
    const behavior = typeof def.behavior === 'string' ? def.behavior : '';
    let fn = null;
    if (behavior.trim()) {
      try {
        fn = new Function('self', 'world', behavior); // god-mode; see CustomEntity header
      } catch (e) {
        return { ok: false, error: 'behavior failed to compile: ' + e.message };
      }
    }
    const owner = existing ? existing.owner : (actor ? actor.name : undefined);
    this.types.set(name, { name, mesh: def.mesh, behavior, props: def.props || {}, owner, _fn: fn });
    // Re-point every existing instance of this type so a redefine (editing code
    // in-world) takes effect live on things already in the world.
    for (const ent of this.entities.values()) if (ent.type === name) { ent._fn = fn; ent._lastError = null; }
    this.typesVersion += 1;
    return { ok: true, type: { name, mesh: def.mesh } };
  }

  // ── direct manipulation: edit / fork / sculpt anything in-world ─────────────
  /** May `actor` update/delete this specific entity? Owners always may. */
  _canTouch(actorId, entity, verb) {
    if (this._can(actorId, verb)) return true;
    const inh = actorId ? this.inhabitants.get(actorId) : null;
    return Boolean(inh && entity && entity.owner && entity.owner === inh.name);
  }

  /** Delete an entity as `actor` (grant- and ownership-checked). */
  deleteEntity(id, actorId = null) {
    const e = this.entities.get(id);
    if (!e) return { ok: false, error: 'no such entity' };
    if (!this._canTouch(actorId, e, 'delete')) return this._denied('delete');
    this.entities.delete(id);
    return { ok: true };
  }

  /** Patch an existing entity's properties (scale, rotation, position, life). */
  editEntity(id, props = {}, actorId = null) {
    const e = this.entities.get(id);
    if (!e) return { ok: false, error: 'no such entity' };
    if (!this._canTouch(actorId, e, 'update')) return this._denied('update');
    if (props.scale != null) e.scale = Math.max(0.05, Math.min(20, Number(props.scale) || 1));
    if (props.rot != null) {
      if ('heading' in e) e.heading = Number(props.rot) || 0; // wanderer steers by heading
      e.rot = Number(props.rot) || 0;
    }
    let moved = false;
    if (props.x != null) { e.x = Number(props.x); moved = true; }
    if (props.z != null) { e.z = Number(props.z); moved = true; }
    if (props.y != null) e.y = Number(props.y);
    else if (moved && typeof e.y === 'number') e.y = this.heightAt(e.x, e.z);
    if (props.life != null && 'life' in e) e.life = Math.max(0, Number(props.life));
    return { ok: true, entity: e.serialize() };
  }

  /** Turn a built-in entity (tree/rock/apple/wanderer) into a brand-new EDITABLE
   *  type, seeded from a template, and replace the clicked instance with it — so
   *  from now on you can recode its mesh + behavior like anything you authored. */
  forkEntity(id, actorId = null) {
    const e = this.entities.get(id);
    if (!e) return { ok: false, error: 'no such entity' };
    // Forking creates an equivalent editable copy in place — morally a create,
    // so the create grant suffices (the original isn't lost, it's transformed).
    if (!this._can(actorId, 'create')) return this._denied('create');
    const tmpl = FORK_TEMPLATES[e.kind];
    if (!tmpl) return { ok: false, error: `'${e.kind}' is already an editable type — edit its code directly` };
    const base = e.kind === 'wanderer' ? 'creature' : e.kind;
    let n = 1, name;
    do { name = base + n++; } while (this.types.has(name) || RESERVED_KINDS.has(name));
    const def = this.defineType({ name, mesh: tmpl.mesh, behavior: tmpl.behavior });
    if (!def.ok) return def;
    const { x, y, z } = e;
    const scale = e.scale || 1, rot = e.rot || e.heading || 0;
    this.removeEntity(id);
    const ent = this._instantiate(name, x, y, z, { scale, rot });
    this.entities.set(ent.id, ent);
    return { ok: true, type: def.type, spawned: ent.serialize() };
  }

  /** Apply one terrain edit op now AND remember it (so it survives save/reload —
   *  the heightfield itself isn't stored, the edit log is replayed on load). */
  editTerrain(op, actorId = null) {
    if (!op || op.x == null || op.z == null) return { ok: false, error: 'need x,z' };
    if (!this._can(actorId, 'update')) return this._denied('update'); // sculpting rewrites the ground itself
    const clean = {
      x: +op.x, z: +op.z,
      radius: +op.radius || 4,
      mode: op.mode || 'raise',
      strength: +op.strength || 2,
      material: op.material != null ? (+op.material & 0xff) : undefined,
    };
    // The chunk store applies it now AND logs it per chunk, so an evicted
    // chunk regenerates edited and a save replays it identically.
    this.chunks.applyEdit(clean);
    this.terrainVersion++;
    return { ok: true, terrainVersion: this.terrainVersion };
  }

  getTypes() {
    return Array.from(this.types.values()).map((t) => ({
      name: t.name,
      mesh: t.mesh,
      ...(t.lastError ? { lastError: t.lastError } : {}),
    }));
  }

  /** Authored types with enough to *recompile* them (mesh + behavior source). */
  serializeTypes() {
    return Array.from(this.types.values()).map((t) => ({ name: t.name, mesh: t.mesh, behavior: t.behavior || '', props: t.props || {} }));
  }

  // ── durable persistence ────────────────────────────────────────────────────
  /**
   * A self-contained snapshot that can rebuild this exact world later. Terrain
   * is NOT stored — it regenerates deterministically from (seed, size) — so the
   * save stays small. What IS stored is everything that *happened*: authored
   * types, every entity (props eaten, creatures born/dead, things you spawned),
   * and their poses. This is the difference between "regenerate the starting
   * world" and "load my world back the way I left it".
   */
  saveState() {
    // Freeze the LIVE entities into per-chunk records for the save (without
    // disturbing the running world): the save is "every chunk that ever
    // mattered, exactly as it stands". Untouched chunks store nothing — they
    // regenerate from the seed. This is what keeps an infinite world's save
    // kilobyte-scale.
    const chunkState = this.chunks.save();
    const byChunk = new Map(chunkState.entities.map(([k, rec]) => [k, { populated: rec.populated, list: rec.list ? [...rec.list] : [] }]));
    for (const e of this.entities.values()) {
      if (e.kind === 'prowler') continue; // the night does not persist
      const { cx, cz } = this._chunkOfPos(e.x, e.z);
      const k = cx + ',' + cz;
      if (!byChunk.has(k)) byChunk.set(k, { populated: true, list: [] });
      const s = e.serialize();
      if (e.owner) s.owner = e.owner;
      byChunk.get(k).list.push(s);
    }
    return {
      format: 2,
      seed: this.seed,
      name: this.name,
      tick: this.tick,
      worldVersion: this.worldVersion,
      typesVersion: this.typesVersion,
      terrainVersion: this.terrainVersion,
      chunkEdits: chunkState.edits,
      chunkEntities: [...byChunk.entries()],
      types: this.serializeTypes(),
      // Persist agent bodies (dormant + currently-live) so a server restart doesn't
      // wipe them — they reclaim by name on reconnect.
      dormant: (() => {
        const bodies = new Map(this.dormant);
        for (const inh of this.inhabitants.values()) {
          if (inh.kind !== 'player' && inh.name) bodies.set(inh.name, { pose: { ...inh.pose }, life: inh.life, intent: inh.intent, inventory: inh.inventory });
        }
        return [...bodies.entries()];
      })(),
    };
  }

  /** Restore a world from saveState() output (format 2 — the infinite world).
   *  Format-1 saves (the old bounded world) are from a different universe:
   *  refuse loudly so the caller can start fresh instead (D1 clean break). */
  loadState(save) {
    if (!save || typeof save !== 'object') throw new Error('invalid save');
    if (save.format !== 2) {
      throw new Error(`incompatible world save (format ${save.format || 1} — the pre-infinite bounded world). Starting fresh; the seed still means something, the old entity state doesn't.`);
    }
    this._build((Number(save.seed) || 1337) >>> 0);
    this.tick = Number(save.tick) || 0;
    this.worldVersion = Number(save.worldVersion) || 1;
    this.typesVersion = Number(save.typesVersion) || 1;
    this.terrainVersion = Number(save.terrainVersion) || 1;
    this.chunks.load({ edits: save.chunkEdits, entities: save.chunkEntities });
    this.types = new Map();
    this.dormant = new Map(Array.isArray(save.dormant) ? save.dormant : []); // agent bodies waiting to be reclaimed
    for (const t of save.types || []) {
      if (!t || !t.name) continue;
      let fn = null;
      const behavior = typeof t.behavior === 'string' ? t.behavior : '';
      if (behavior.trim()) {
        try { fn = new Function('self', 'world', behavior); } catch (e) { /* drop a bad type's code, keep the mesh */ }
      }
      this.types.set(t.name, { name: t.name, mesh: t.mesh, behavior, props: t.props || {}, _fn: fn });
    }
    // Entities stay dormant in their chunk records; activation wakes them
    // exactly where (and how) they were left.
    return this;
  }

  /** Rebuild a single entity from its serialize() shape. */
  _restoreEntity(e) {
    if (!e || !e.id) return;
    const p = e.pose || {};
    if (PROP_KINDS.has(e.kind)) {
      this.entities.set(e.id, new Prop({ id: e.id, kind: e.kind, x: p.x, y: p.y, z: p.z, scale: e.scale, rot: p.rot, floors: e.floors, variant: e.variant }));
    } else if (DEVICE_KINDS.has(e.kind)) {
      const Cls = { lever: Lever, plate: Plate, door: Door, lamp: Lamp, streetlight: Streetlight }[e.kind];
      this.entities.set(e.id, new Cls({ id: e.id, x: p.x, y: p.y, z: p.z, scale: e.scale, rot: p.rot, on: e.on, open: e.open, latched: e.latched, lit: e.lit }));
    } else if (DYNAMIC_KINDS.has(e.kind)) {
      const Cls = e.kind === 'ball' ? Ball : Crate;
      this.entities.set(e.id, new Cls({ id: e.id, x: p.x, y: p.y, z: p.z, scale: e.scale, rot: p.rot }));
    } else if (e.kind === 'prowler') {
      const pr = new Prowler({ id: e.id, x: p.x, z: p.z, seed: (this.seed ^ (this._seq++ * 2654435761)) >>> 0, scale: e.scale, life: e.life });
      pr.y = p.y || 0;
      this.entities.set(e.id, pr);
    } else if (e.kind === 'hopper') {
      const h = new Hopper({ id: e.id, x: p.x, z: p.z, seed: (this.seed ^ (this._seq++ * 2654435761)) >>> 0, scale: e.scale });
      h.y = p.y || 0;
      if (p.heading != null) h.heading = p.heading;
      this.entities.set(e.id, h);
    } else if (e.kind === 'villager') {
      // home/work/stop round-trip through serialize() so the routine resumes
      // exactly where it left off — no replanning, no forgetting the schedule.
      const v = new Villager({
        id: e.id, x: p.x, z: p.z, rot: p.rot, scale: e.scale,
        home: e.home || { x: p.x, z: p.z }, work: e.work || { x: p.x, z: p.z },
        seed: (this.seed ^ (this._seq++ * 2654435761)) >>> 0,
      });
      v.y = p.y || 0;
      if (e.stop) v.stop = e.stop;
      this.entities.set(e.id, v);
    } else if (e.kind === 'wanderer') {
      const w = new Wanderer({ id: e.id, x: p.x, z: p.z, seed: (this.seed ^ (this._seq++ * 2654435761)) >>> 0, scale: e.scale });
      w.y = p.y || 0;
      if (p.heading != null) w.heading = p.heading;
      if (e.life != null) w.life = e.life;
      if (e.maxLife != null) w.maxLife = e.maxLife;
      this.entities.set(e.id, w);
    } else {
      const t = this.types.get(e.kind);
      if (t) this.entities.set(e.id, new CustomEntity({ id: e.id, type: e.kind, x: p.x, y: p.y, z: p.z, scale: e.scale, rot: p.rot }, t._fn));
    }
    // Ownership survives the reload — "delete your own things" stays true.
    const restored = this.entities.get(e.id);
    if (restored && e.owner) restored.owner = e.owner;
  }

  /**
   * Build (but don't register) an entity of `kind`. `idSuffix` overrides the
   * runtime sequence id — chunk activation passes a deterministic
   * `g<cx>_<cz>_<n>` so generated entities keep the same identity across
   * regenerations of the same seed (pins and saves depend on that).
   */
  _instantiate(kind, x, y, z, spec = {}, idSuffix = null) {
    const mkId = () => `${kind}-${idSuffix != null ? idSuffix : 'x' + this._seq++}`;
    const entSeed = idSuffix != null
      ? (this.seed ^ (mkIdHash(idSuffix))) >>> 0 // stable per generated entity
      : (this.seed + this._seq * 7919) >>> 0;
    if (kind === 'wanderer') {
      const w = new Wanderer({ id: mkId(), x, z, seed: entSeed, scale: spec.scale });
      w.y = y;
      return w;
    }
    if (kind === 'hopper') {
      const h = new Hopper({ id: mkId(), x, z, seed: entSeed, scale: spec.scale });
      h.y = y;
      return h;
    }
    if (kind === 'prowler') {
      const p = new Prowler({ id: mkId(), x, z, seed: entSeed, scale: spec.scale });
      p.y = y;
      return p;
    }
    if (kind === 'villager') {
      // Authoring gives it real anchors when told (spec.home/spec.work) —
      // otherwise home is the spawn point and work defaults to the nearest
      // known settlement, so a god-spawned villager still has somewhere to
      // be during the day rather than idling in place forever.
      const home = spec.home || { x, z };
      const near = this.gen.landmarksNear(x, z, 800)[0];
      const work = spec.work || (near ? { x: near.x, z: near.z } : { x, z });
      const v = new Villager({ id: mkId(), x, z, home, work, seed: entSeed, scale: spec.scale });
      v.y = y;
      return v;
    }
    if (DEVICE_KINDS.has(kind)) {
      const Cls = { lever: Lever, plate: Plate, door: Door, lamp: Lamp, streetlight: Streetlight }[kind];
      return new Cls({ id: mkId(), x, y, z, scale: spec.scale || 1, rot: spec.rot || 0 });
    }
    if (DYNAMIC_KINDS.has(kind)) {
      const Cls = kind === 'ball' ? Ball : Crate;
      return new Cls({ id: mkId(), x, y, z, scale: spec.scale || 1, rot: spec.rot || 0 });
    }
    if (PROP_KINDS.has(kind)) {
      return new Prop({ id: mkId(), kind, x, y, z, scale: spec.scale || 1, rot: spec.rot || 0, floors: spec.floors, variant: spec.variant });
    }
    const t = this.types.get(kind);
    if (t) {
      return new CustomEntity({ id: mkId(), type: kind, x, y, z, scale: spec.scale || 1, rot: spec.rot || 0 }, t._fn);
    }
    return null;
  }

  /**
   * The authoring affordance. Spawn a library kind, define a new type, or both:
   *   { kind:'tree' }                                  → spawn a tree
   *   { define:{ name, mesh, behavior } }              → define + spawn the type
   *   { define:{...}, spawn:false }                    → define only
   *   { kind:'<typeName>' }                            → spawn an authored type
   */
  author(inhabitantId, spec = {}) {
    const inh = this.inhabitants.get(inhabitantId);
    if (!inh) return { ok: false, error: 'unknown inhabitant' };
    if (!this._can(inhabitantId, 'create')) return this._denied('create'); // spawning from nothing is the create verb

    let defined = null;
    if (spec.define) {
      defined = this.defineType(spec.define, inhabitantId);
      if (!defined.ok) return defined;
    }

    let kind = spec.kind || spec.type;
    if (!kind && defined && spec.spawn !== false) kind = defined.type.name;
    if (!kind && !defined) kind = 'wanderer';
    if (!kind) return defined; // defined a type but asked not to spawn

    const x = spec.x != null ? spec.x : inh.pose.x;
    const z = spec.z != null ? spec.z : inh.pose.z;
    const y = this.heightAt(x, z);
    const ent = this._instantiate(kind, x, y, z, spec);
    if (!ent) return { ok: false, error: `unknown spawn kind: ${kind}` };
    ent.owner = inh.name; // creators may always delete their own work
    this.entities.set(ent.id, ent);
    const out = { ok: true, spawned: ent.serialize() };
    if (defined) out.type = defined.type;
    return out;
  }

  serializeEntities() {
    const out = [];
    for (const e of this.entities.values()) {
      const s = e.serialize();
      if (e._lastError) s.lastError = e._lastError; // so authors see why a behavior is inert
      if (e.owner) s.owner = e.owner; // who made it (creators may delete their own)
      out.push(s);
    }
    return out;
  }
}

module.exports = { World };
