/**
 * Ollie Cosmos — player viewport. A CLIENT of the Inhabitant API.
 *
 * Renders the world from snapshot + perceive, draws other inhabitants
 * (multiplayer), supports walk/fly, shows the inhabitant's life, and can
 * regenerate the world from a seed. Never touches the sim directly — the exact
 * seam the agent integration plugs into.
 */
import * as THREE from 'three';

// Which server are we an inhabitant of? Empty = our own local world (relative
// URLs). Set to "http://<ip>:19013" to JOIN a friend's world by IP — only the
// API calls go remote; the page/assets still load from our own app. The remote
// server runs permissive CORS, so cross-origin perceive/act/author just work.
const SERVER_KEY = 'cosmos-server';
let API_BASE = '';
try { API_BASE = localStorage.getItem(SERVER_KEY) || ''; } catch (_) {}
const api = (url) => (API_BASE ? API_BASE.replace(/\/+$/, '') + url : url);

const j = (r) => r.json();
const post = (url, body) => fetch(api(url), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) }).then(j);
const get = (url) => fetch(api(url)).then(j);

// Normalize "192.168.1.50", "192.168.1.50:19013", or a full URL → an origin.
function normalizeServer(input) {
  let s = String(input || '').trim();
  if (!s) return '';
  if (!/^https?:\/\//i.test(s)) s = 'http://' + s;
  try {
    const u = new URL(s);
    if (!u.port) u.port = '19013';
    return u.origin;
  } catch (_) { return ''; }
}

// Brief on-screen toast (saves, join errors) — no dependency, auto-dismisses.
function toast(text, isErr) {
  const el = document.createElement('div');
  el.textContent = text;
  el.style.cssText = 'position:fixed;left:50%;top:24px;transform:translateX(-50%);z-index:50;'
    + 'background:' + (isErr ? '#5a1d1d' : '#13311c') + ';color:#e6edf3;border:1px solid '
    + (isErr ? '#f85149' : '#3fb950') + ';border-radius:8px;padding:9px 16px;font:13px ui-sans-serif,system-ui,sans-serif;box-shadow:0 8px 24px rgba(0,0,0,.5);';
  document.body.appendChild(el);
  setTimeout(() => el.remove(), 2200);
}

function fail(msg) {
  const el = document.getElementById('err');
  el.style.display = 'flex';
  el.innerHTML = 'Ollie Cosmos error: <code>' + String(msg) + '</code>';
}

const EYE = 1.7;

// ── chunk streaming: the ground arrives one tile at a time ──────────────────
// The world is infinite (format v2): there is no whole-map heightfield to
// download. A ring of chunks around the camera is fetched, meshed (vertex
// colors, same look as before), and disposed as you leave it behind. Each
// chunk's wire format is a (CHUNK+1)² grid — one row of overlap into its
// neighbours — so adjacent meshes share edge vertices and the ground is
// seamless without any client-side stitching.
const CHUNK_RING = 4; // chunks of ground in every direction (instancing bought this a wider ring)
// makeChunkManager keeps ground geometry in CHUNK-LOCAL coordinates (0..CHUNK)
// and positions each chunk's mesh at its world corner minus the current
// render origin — so a floating-origin rebase only has to move `mesh.position`
// (see reposition()), never touch a vertex buffer.
function makeChunkManager(scene, snap, getOrigin) {
  const CH = snap.chunkSize;
  const chunks = new Map(); // "cx,cz" -> { mesh, height: Int32Array (centi), grid, cx, cz }
  const pending = new Set();
  let inFlight = 0;

  function buildMesh(d) {
    const W = d.grid;
    const geo = new THREE.BufferGeometry();
    const positions = new Float32Array(W * W * 3);
    const colors = new Float32Array(W * W * 3);
    for (let j = 0; j < W; j++) {
      for (let i = 0; i < W; i++) {
        const k = j * W + i;
        positions[k * 3] = i;
        positions[k * 3 + 1] = d.height[k] / 100;
        positions[k * 3 + 2] = j;
        const c = snap.materials[d.material[k]].color;
        colors[k * 3] = c[0]; colors[k * 3 + 1] = c[1]; colors[k * 3 + 2] = c[2];
      }
    }
    const indices = [];
    for (let j = 0; j < W - 1; j++) {
      for (let i = 0; i < W - 1; i++) {
        const a = j * W + i, b = a + 1, c2 = a + W, dd = c2 + 1;
        indices.push(a, c2, b, b, c2, dd);
      }
    }
    geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
    geo.setIndex(indices);
    geo.computeVertexNormals();
    const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.95 }));
    mesh.userData.terrain = true; // right-clickable ground
    return mesh;
  }

  function fetchChunk(cx, cz) {
    const key = cx + ',' + cz;
    if (chunks.has(key) || pending.has(key) || inFlight >= 4) return;
    pending.add(key);
    inFlight++;
    get('/api/world/chunk?cx=' + cx + '&cz=' + cz).then((r) => {
      if (r && r.ok && r.chunk) {
        const mesh = buildMesh(r.chunk);
        const o = getOrigin();
        mesh.position.set(cx * CH - o.x, 0, cz * CH - o.z);
        const rec = { mesh, height: r.chunk.height, material: r.chunk.material, grid: r.chunk.grid, cx, cz };
        scene.add(rec.mesh);
        chunks.set(key, rec);
      }
    }).catch(() => {}).finally(() => { pending.delete(key); inFlight--; });
  }

  return {
    /** Bilinear ground height, or null while that chunk is still in flight. */
    heightAt(x, z) {
      const cx = Math.floor(x / CH), cz = Math.floor(z / CH);
      const rec = chunks.get(cx + ',' + cz);
      if (!rec) return null;
      const W = rec.grid;
      const lx = Math.min(W - 1.001, Math.max(0, x - cx * CH));
      const lz = Math.min(W - 1.001, Math.max(0, z - cz * CH));
      const x0 = Math.floor(lx), z0 = Math.floor(lz);
      const tx = lx - x0, tz = lz - z0;
      const h = rec.height;
      const h00 = h[z0 * W + x0], h10 = h[z0 * W + x0 + 1], h01 = h[(z0 + 1) * W + x0], h11 = h[(z0 + 1) * W + x0 + 1];
      return ((h00 * (1 - tx) + h10 * tx) * (1 - tz) + (h01 * (1 - tx) + h11 * tx) * tz) / 100;
    },
    /** Material id under (x,z), or null while that chunk is in flight. */
    materialAt(x, z) {
      const cx = Math.floor(x / CH), cz = Math.floor(z / CH);
      const rec = chunks.get(cx + ',' + cz);
      if (!rec) return null;
      const W = rec.grid;
      const i = Math.min(W - 1, Math.max(0, Math.round(x - cx * CH)));
      const j = Math.min(W - 1, Math.max(0, Math.round(z - cz * CH)));
      return rec.material[j * W + i];
    },
    /** Keep the ring around (x,z) loaded; drop what's far behind. */
    update(x, z) {
      const ccx = Math.floor(x / CH), ccz = Math.floor(z / CH);
      for (let r = 0; r <= CHUNK_RING; r++) { // nearest rings first
        for (let dz = -r; dz <= r; dz++) {
          for (let dx = -r; dx <= r; dx++) {
            if (Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue;
            fetchChunk(ccx + dx, ccz + dz);
          }
        }
      }
      for (const [key, rec] of chunks) {
        if (Math.max(Math.abs(rec.cx - ccx), Math.abs(rec.cz - ccz)) > CHUNK_RING + 1) {
          scene.remove(rec.mesh);
          rec.mesh.geometry.dispose();
          chunks.delete(key);
        }
      }
    },
    /** The ground changed (sculpt/paint) — refetch everything loaded. */
    resync() {
      for (const [key, rec] of chunks) {
        scene.remove(rec.mesh);
        rec.mesh.geometry.dispose();
        chunks.delete(key);
      }
    },
    ready(x, z) { return this.heightAt(x, z) != null; },
    /** Floating-origin rebase: re-anchor every loaded chunk to the new origin. */
    reposition() {
      const o = getOrigin();
      for (const rec of chunks.values()) rec.mesh.position.set(rec.cx * CH - o.x, 0, rec.cz * CH - o.z);
    },
  };
}

// ── instanced flora/rock props ──────────────────────────────────────────────
// Trees/pines/rocks/flora never carry visible per-instance state (unlike
// devices/creatures), and perceive() already caps how many are live near an
// inhabitant — a perfect fit for one InstancedMesh draw call per body part
// per kind instead of one THREE.Group per plant. Structures and anything
// with a named sub-mesh (door panel, lever stick, lamp globe...) keep
// individual meshes; see isStatic/STATIC_KINDS below for the boundary.
const TREE_LEAF = [0x2f7d33, 0x3c8f3f, 0x5aa84a, 0x27692b, 0x6fae3b, 0xc77d2a];
const PINE_LEAF = [0x1f5d2e, 0x256b35, 0x1a5229];
const FLOWER_PETAL = [0xe8c93e, 0xe06fa4, 0xf2f2f2, 0x8f6fe0, 0xe0704a];
const REED_COLOR = [0x7a9a4a, 0x8aa855, 0x6b8c3f];
// Each kind is a fixed list of "parts" (geometry + local offset, matching the
// makeObject groups they replace). `colors`+`group` = pick one color per
// instance (shared across parts in the same group, e.g. both tree cones);
// `heightJitter` = this part's local Y-scale varies per instance (reeds).
const INSTANCED_KINDS = {
  tree: [
    { geo: () => new THREE.CylinderGeometry(0.18, 0.32, 2.2, 6), color: 0x6b4a2b, rough: 1, flat: false, pos: [0, 1.1, 0] },
    { geo: () => new THREE.ConeGeometry(1.5, 2.6, 8), colors: TREE_LEAF, group: 'leaf', rough: 0.9, flat: true, pos: [0, 2.7, 0] },
    { geo: () => new THREE.ConeGeometry(1.05, 1.9, 8), colors: TREE_LEAF, group: 'leaf', rough: 0.9, flat: true, pos: [0, 3.8, 0] },
  ],
  pine: [
    { geo: () => new THREE.CylinderGeometry(0.14, 0.24, 1.8, 6), color: 0x5d4023, rough: 1, flat: false, pos: [0, 0.9, 0] },
    { geo: () => new THREE.ConeGeometry(1.25, 2.0, 8), colors: PINE_LEAF, group: 'leaf', rough: 0.9, flat: true, pos: [0, 2.6, 0] },
    { geo: () => new THREE.ConeGeometry(0.95, 1.7, 8), colors: PINE_LEAF, group: 'leaf', rough: 0.9, flat: true, pos: [0, 3.8, 0] },
    { geo: () => new THREE.ConeGeometry(0.6, 1.4, 8), colors: PINE_LEAF, group: 'leaf', rough: 0.9, flat: true, pos: [0, 4.9, 0] },
  ],
  rock: [
    { geo: () => new THREE.IcosahedronGeometry(0.9, 0), color: 0x787c82, rough: 1, flat: true, pos: [0, 0, 0] },
  ],
  bush: [
    { geo: () => new THREE.IcosahedronGeometry(0.55, 0), color: 0x3c8f3f, rough: 0.9, flat: true, pos: [0, 0.45, 0] },
    { geo: () => new THREE.IcosahedronGeometry(0.4, 0), color: 0x2f7d33, rough: 0.9, flat: true, pos: [0.42, 0.5, 0.1] },
    { geo: () => new THREE.IcosahedronGeometry(0.36, 0), color: 0x4a9c44, rough: 0.9, flat: true, pos: [-0.35, 0.42, 0.2] },
  ],
  flower: [
    { geo: () => new THREE.CylinderGeometry(0.03, 0.03, 0.5, 5), color: 0x3c8f3f, rough: 0.9, flat: false, pos: [0, 0.25, 0] },
    { geo: () => new THREE.IcosahedronGeometry(0.13, 0), colors: FLOWER_PETAL, group: 'petal', rough: 0.6, flat: true, pos: [0, 0.55, 0] },
  ],
  reed: [0, 1, 2].map((i) => ({
    geo: () => new THREE.CylinderGeometry(0.035, 0.05, 1, 4), color: REED_COLOR[i], rough: 0.9, flat: false,
    pos: [(i - 1) * 0.15, 0, (i % 2) * 0.12], heightJitter: true,
  })),
  mushroom: [
    { geo: () => new THREE.CylinderGeometry(0.09, 0.12, 0.3, 6), color: 0xe3d9c2, rough: 0.9, flat: false, pos: [0, 0.15, 0] },
    { geo: () => new THREE.ConeGeometry(0.28, 0.24, 8), color: 0xc0392b, rough: 0.6, flat: true, pos: [0, 0.4, 0] },
  ],
  apple: [
    { geo: () => new THREE.SphereGeometry(0.32, 10, 8), color: 0xd23b2e, rough: 0.45, flat: false, pos: [0, 0.32, 0] },
  ],
};
// Approximate bounding box (radius, height) for the right-click highlight box
// — instances share one mesh so BoxHelper(obj) doesn't apply to them.
const INSTANCED_BOUNDS = {
  tree: { r: 1.6, h: 4.6 }, pine: { r: 1.3, h: 5.2 }, rock: { r: 0.9, h: 1.8 }, bush: { r: 0.7, h: 1.0 },
  flower: { r: 0.25, h: 0.65 }, reed: { r: 0.25, h: 1.6 }, mushroom: { r: 0.3, h: 0.55 }, apple: { r: 0.32, h: 0.64 },
};
const isInstancedKind = (k) => !!INSTANCED_KINDS[k];

function hashStr(s) {
  let h = 2166136261 >>> 0;
  for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
  return h >>> 0;
}
function seededRng(seedVal) {
  let s = seedVal >>> 0;
  return function () {
    s = (s + 0x6D2B79F5) | 0;
    let t = Math.imul(s ^ (s >>> 15), 1 | s);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const IY_AXIS = new THREE.Vector3(0, 1, 0);
// getOrigin: () => {x,z} — the floating-origin offset current render positions
// are relative to (see maybeRebase in main()).
function makeInstancedProps(scene, getOrigin) {
  const kinds = {}; // kind -> { parts, slots: Map(id->idx), free: [idx...], count, jitters: Map, picks: Map }
  const dummy = new THREE.Object3D();
  const tmpColor = new THREE.Color();

  function ensureKind(kind) {
    let k = kinds[kind];
    if (k) return k;
    const capacity = 256;
    const parts = INSTANCED_KINDS[kind].map((p) => {
      const colored = !!p.colors;
      const mat = new THREE.MeshStandardMaterial({ roughness: p.rough, flatShading: !!p.flat, vertexColors: colored, color: colored ? 0xffffff : p.color });
      const im = new THREE.InstancedMesh(p.geo(), mat, capacity);
      im.count = 0;
      im.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
      if (colored) {
        im.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3), 3);
        im.instanceColor.setUsage(THREE.DynamicDrawUsage);
      }
      im.userData.kind = kind;
      scene.add(im);
      return { mesh: im, def: p, capacity, ids: new Array(capacity).fill(null) };
    });
    k = { parts, slots: new Map(), free: [], count: 0, jitters: new Map(), picks: new Map() };
    kinds[kind] = k;
    return k;
  }

  // Grow every part's InstancedMesh (double capacity) when the free list runs
  // dry — copies the live instance range into a fresh, larger buffer.
  function growParts(k, need) {
    for (const part of k.parts) {
      if (need <= part.capacity) continue;
      const newCap = Math.max(need, part.capacity * 2);
      const old = part.mesh;
      const nim = new THREE.InstancedMesh(old.geometry, old.material, newCap);
      nim.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
      nim.userData.kind = old.userData.kind;
      nim.instanceMatrix.array.set(old.instanceMatrix.array.subarray(0, old.count * 16));
      nim.count = old.count;
      if (old.instanceColor) {
        nim.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(newCap * 3), 3);
        nim.instanceColor.setUsage(THREE.DynamicDrawUsage);
        nim.instanceColor.array.set(old.instanceColor.array.subarray(0, old.count * 3));
      }
      scene.remove(old);
      scene.add(nim);
      part.mesh = nim;
      const oldLen = part.ids.length;
      part.ids.length = newCap;
      for (let i = oldLen; i < newCap; i++) part.ids[i] = null;
      part.capacity = newCap;
    }
  }

  function setTransform(id, e) {
    const k = kinds[e.kind];
    if (!k) return;
    const idx = k.slots.get(id);
    if (idx == null) return;
    const o = getOrigin();
    const rx = e.pose.x - o.x, rz = e.pose.z - o.z;
    const baseY = (e.pose.y || 0) + (yOffset[e.kind] || 0);
    const yaw = e.pose.rot || 0;
    const scale = e.scale || 1;
    const template = INSTANCED_KINDS[e.kind];
    const jitters = k.jitters.get(id);
    const picks = k.picks.get(id);
    template.forEach((p, pi) => {
      const part = k.parts[pi];
      const localY = p.heightJitter ? jitters[pi] / 2 : p.pos[1];
      dummy.position.set(p.pos[0] * scale, localY * scale, p.pos[2] * scale)
        .applyAxisAngle(IY_AXIS, yaw)
        .add(new THREE.Vector3(rx, baseY, rz));
      dummy.quaternion.setFromAxisAngle(IY_AXIS, yaw);
      dummy.scale.set(scale, scale * (p.heightJitter ? jitters[pi] : 1), scale);
      dummy.updateMatrix();
      part.mesh.setMatrixAt(idx, dummy.matrix);
      part.mesh.instanceMatrix.needsUpdate = true;
      if (p.colors) {
        tmpColor.setHex(picks[pi]);
        part.mesh.setColorAt(idx, tmpColor);
        if (part.mesh.instanceColor) part.mesh.instanceColor.needsUpdate = true;
      }
    });
  }

  function add(id, e) {
    const k = ensureKind(e.kind);
    const idx = k.free.length ? k.free.pop() : k.count++;
    growParts(k, k.count);
    k.slots.set(id, idx);
    for (const part of k.parts) part.ids[idx] = id;
    const rng = seededRng(hashStr(id));
    const groupPick = {};
    const template = INSTANCED_KINDS[e.kind];
    k.picks.set(id, template.map((p) => {
      if (!p.colors) return null;
      const g = p.group || '_';
      if (groupPick[g] == null) groupPick[g] = Math.floor(rng() * p.colors.length);
      return p.colors[groupPick[g]];
    }));
    k.jitters.set(id, template.map((p) => (p.heightJitter ? 0.75 + rng() * 0.5 : 1)));
    for (const part of k.parts) part.mesh.count = k.count;
    setTransform(id, e);
  }

  function remove(id, kind) {
    const k = kinds[kind];
    if (!k) return;
    const idx = k.slots.get(id);
    if (idx == null) return;
    k.slots.delete(id);
    k.jitters.delete(id);
    k.picks.delete(id);
    k.free.push(idx);
    dummy.position.set(0, -100000, 0);
    dummy.quaternion.identity();
    dummy.scale.set(0, 0, 0);
    dummy.updateMatrix();
    for (const part of k.parts) {
      part.mesh.setMatrixAt(idx, dummy.matrix);
      part.mesh.instanceMatrix.needsUpdate = true;
      part.ids[idx] = null;
    }
  }

  // Floating-origin rebase: every instance matrix's translation shifts by the
  // same (dx,dz) as the camera — no need to re-derive from pose/scale/rot.
  function shiftAll(dx, dz) {
    for (const k of Object.values(kinds)) {
      for (const part of k.parts) {
        const arr = part.mesh.instanceMatrix.array;
        for (let idx = 0; idx < k.count; idx++) {
          if (part.ids[idx] == null) continue;
          arr[idx * 16 + 12] -= dx;
          arr[idx * 16 + 14] -= dz;
        }
        part.mesh.instanceMatrix.needsUpdate = true;
      }
    }
  }

  function idAt(mesh, instanceId) {
    for (const k of Object.values(kinds)) {
      for (const part of k.parts) if (part.mesh === mesh) return part.ids[instanceId] || null;
    }
    return null;
  }

  return { add, update: setTransform, remove, shiftAll, idAt };
}

// ── authored custom types (built from mesh specs sent by the sim) ────────────
const customTypes = new Map(); // typeName -> meshSpec
let loadedTypesVersion = 0;
let _fetchingTypes = false;
function registerTypes(types) { for (const t of types || []) if (t && t.name) customTypes.set(t.name, t.mesh); }
function fetchTypes() {
  if (_fetchingTypes) return;
  _fetchingTypes = true;
  get('/api/world/types')
    .then((r) => { if (r && r.ok) { registerTypes(r.types); loadedTypesVersion = r.typesVersion; } })
    .catch(() => {})
    .finally(() => { _fetchingTypes = false; });
}
const BUILTIN_KINDS = new Set([
  'tree', 'pine', 'rock', 'apple', 'bush', 'flower', 'reed', 'mushroom',
  'hut', 'well', 'tower', 'stone', 'campfire', 'tent',
  'wall', 'floor', 'fence', 'door', 'lever', 'plate', 'lamp', 'ball', 'crate',
  'wanderer', 'hopper', 'prowler', 'villager',
  'building', 'car', 'shopfront', 'sign', 'streetlight', // WP6 contemporary content
]);
const knownKind = (k) => BUILTIN_KINDS.has(k) || customTypes.has(k);
// Collision radii for props you can walk into — must mirror SOLID_RADII in
// src/sim/world.js so the player's client-side collision agrees with the sim.
// (Doors are handled separately: 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 };

// What the number keys build (act 'place' — spends inventory, see the build bar).
const BUILD_SLOTS = [
  { key: '1', kind: 'wall', cost: '2 wood' },
  { key: '2', kind: 'floor', cost: '2 wood' },
  { key: '3', kind: 'fence', cost: '1 wood' },
  { key: '4', kind: 'door', cost: '2 wood' },
  { key: '5', kind: 'campfire', cost: '2 wood' },
  { key: '6', kind: 'lever', cost: '1 wood' },
  { key: '7', kind: 'plate', cost: '1 stone' },
  { key: '8', kind: 'lamp', cost: '1 stone + 1 wood' },
  { key: '9', kind: 'ball', cost: '1 wood' },
  { key: '0', kind: 'crate', cost: '2 wood' },
];

// A mesh spec is: a primitive, a { group:[primitives] }, or { obj:'<text>' }.
function meshFromSpec(spec) {
  if (!spec) return primitive({ shape: 'box', color: '#888888' });
  if (typeof spec === 'string') return objToMesh(spec, '#cccccc');
  if (spec.obj) return objToMesh(spec.obj, spec.color || '#cccccc');
  if (Array.isArray(spec.group)) { const g = new THREE.Group(); for (const p of spec.group) g.add(primitive(p)); return g; }
  return primitive(spec);
}
function primitive(s) {
  s = s || {};
  const mat = new THREE.MeshStandardMaterial({ color: new THREE.Color(s.color || '#9aa0a6'), roughness: s.rough != null ? s.rough : 0.85, flatShading: !!s.flat });
  const shape = s.shape || 'box';
  let geo;
  if (shape === 'sphere') geo = new THREE.SphereGeometry(s.r || 0.5, 16, 12);
  else if (shape === 'cone') geo = new THREE.ConeGeometry(s.r || 0.5, s.h || 1, s.seg || 8);
  else if (shape === 'cylinder') geo = new THREE.CylinderGeometry(s.rt != null ? s.rt : (s.r || 0.3), s.rb != null ? s.rb : (s.r || 0.3), s.h || 1, s.seg || 10);
  else if (shape === 'icosahedron') geo = new THREE.IcosahedronGeometry(s.r || 0.7, s.detail || 0);
  else geo = new THREE.BoxGeometry(...(s.size || [1, 1, 1]));
  const m = new THREE.Mesh(geo, mat);
  m.position.set(s.x || 0, s.y || 0, s.z || 0);
  return m;
}
function objToMesh(text, color) {
  const geo = parseOBJ(text);
  geo.computeVertexNormals();
  return new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color: new THREE.Color(color || '#cccccc'), roughness: 0.85, flatShading: true }));
}
function parseOBJ(text) {
  const verts = []; const faces = []; const pos = [];
  for (const line of String(text || '').split('\n')) {
    const p = line.trim().split(/\s+/);
    if (p[0] === 'v') verts.push([+p[1] || 0, +p[2] || 0, +p[3] || 0]);
    else if (p[0] === 'f') {
      const idx = p.slice(1).map((t) => parseInt(t.split('/')[0], 10) - 1).filter((n) => !isNaN(n));
      for (let i = 1; i < idx.length - 1; i++) faces.push([idx[0], idx[i], idx[i + 1]]);
    }
  }
  for (const f of faces) for (const vi of f) { const v = verts[vi] || [0, 0, 0]; pos.push(v[0], v[1], v[2]); }
  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
  return geo;
}

// ── editor helpers ────────────────────────────────────────────────────────────
function typing() {
  const a = document.activeElement;
  return a && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA');
}
function toggleEditor() {
  const ed = document.getElementById('editor');
  const open = ed.style.display !== 'block';
  ed.style.display = open ? 'block' : 'none';
  if (open) { if (document.exitPointerLock) document.exitPointerLock(); document.getElementById('ed-name').focus(); }
}
function edMsg(text, isErr) {
  const el = document.getElementById('ed-msg');
  el.textContent = text;
  el.style.color = isErr ? '#ff7b72' : '#3fb950';
}

// In-app prompt/confirm — Electron does NOT support window.prompt()/confirm().
// Resolves to the input string, `true` (confirm), or null (cancel).
function uiPrompt(opts) {
  const o = opts || {};
  return new Promise((resolve) => {
    const wrap = document.createElement('div');
    wrap.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(8,10,18,.7);z-index:40;';
    const box = document.createElement('div');
    box.style.cssText = 'background:#161b22;border:1px solid #30363d;border-radius:12px;padding:20px;min-width:300px;color:#e6edf3;font:13px ui-sans-serif,system-ui,sans-serif;box-shadow:0 12px 40px rgba(0,0,0,.6);';
    const title = document.createElement('div');
    title.textContent = o.title || '';
    title.style.cssText = 'font-size:15px;margin-bottom:14px;';
    box.appendChild(title);
    let input = null;
    if (!o.confirmOnly) {
      input = document.createElement('input');
      input.value = o.value != null ? String(o.value) : '';
      input.placeholder = o.placeholder || '';
      input.spellcheck = false;
      input.style.cssText = 'width:100%;box-sizing:border-box;background:#0d1117;color:#e6edf3;border:1px solid #30363d;border-radius:8px;padding:9px;font-family:ui-monospace,monospace;margin-bottom:14px;';
      box.appendChild(input);
    }
    const row = document.createElement('div');
    row.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;';
    const cancel = document.createElement('button');
    cancel.textContent = 'Cancel';
    cancel.style.cssText = 'background:#30363d;color:#fff;border:none;border-radius:7px;padding:8px 14px;cursor:pointer;';
    const ok = document.createElement('button');
    ok.textContent = o.okText || 'OK';
    ok.style.cssText = 'background:#238636;color:#fff;border:none;border-radius:7px;padding:8px 14px;cursor:pointer;';
    row.appendChild(cancel); row.appendChild(ok);
    box.appendChild(row);
    wrap.appendChild(box);
    document.body.appendChild(wrap);
    if (document.exitPointerLock) document.exitPointerLock();
    if (input) input.focus();
    const done = (val) => { wrap.remove(); resolve(val); };
    ok.addEventListener('click', () => done(o.confirmOnly ? true : ((input.value || '').trim() || null)));
    cancel.addEventListener('click', () => done(null));
    wrap.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') { e.preventDefault(); ok.click(); }
      else if (e.key === 'Escape') { e.preventDefault(); cancel.click(); }
    });
  });
}

// ── meshes by kind ─────────────────────────────────────────────────────────────
const yOffset = { tree: 0, apple: 0, rock: 0.55, wanderer: 0.8 };
const STATIC_KINDS = new Set(['tree', 'pine', 'rock', 'apple', 'bush', 'flower', 'reed', 'mushroom', 'hut', 'well', 'tower', 'stone', 'campfire', 'tent', 'wall', 'floor', 'fence', 'door', 'lever', 'plate', 'lamp', 'building', 'car', 'shopfront', 'sign', 'streetlight']);
const isStatic = (k) => STATIC_KINDS.has(k);

const std = (color, opts = {}) => new THREE.MeshStandardMaterial({ color, roughness: opts.rough != null ? opts.rough : 0.9, flatShading: opts.flat !== false, emissive: opts.emissive || 0x000000, emissiveIntensity: opts.emissiveIntensity != null ? opts.emissiveIntensity : 1 });
const mesh = (geo, mat, x = 0, y = 0, z = 0) => { const m = new THREE.Mesh(geo, mat); m.position.set(x, y, z); return m; };

function makeObject(kind, e) {
  if (kind === 'tree') {
    const g = new THREE.Group();
    g.add(mesh(new THREE.CylinderGeometry(0.18, 0.32, 2.2, 6), std(0x6b4a2b, { flat: false, rough: 1 }), 0, 1.1, 0));
    // Varied greens (plus the occasional autumn tree) and a fuller two-tier canopy
    // so a forest reads as a living forest, not a field of identical cones.
    const palette = [0x2f7d33, 0x3c8f3f, 0x5aa84a, 0x27692b, 0x6fae3b, 0xc77d2a];
    const leaf = std(palette[Math.floor(Math.random() * palette.length)]);
    g.add(mesh(new THREE.ConeGeometry(1.5, 2.6, 8), leaf, 0, 2.7, 0));
    g.add(mesh(new THREE.ConeGeometry(1.05, 1.9, 8), leaf, 0, 3.8, 0));
    return g;
  }
  if (kind === 'pine') {
    // Conifer — taller, darker, three stacked tiers. Forests get real texture
    // from mixing these with the broadleaf trees.
    const g = new THREE.Group();
    const greens = [0x1f5d2e, 0x256b35, 0x1a5229];
    const leaf = std(greens[Math.floor(Math.random() * greens.length)]);
    g.add(mesh(new THREE.CylinderGeometry(0.14, 0.24, 1.8, 6), std(0x5d4023, { flat: false, rough: 1 }), 0, 0.9, 0));
    g.add(mesh(new THREE.ConeGeometry(1.25, 2.0, 8), leaf, 0, 2.6, 0));
    g.add(mesh(new THREE.ConeGeometry(0.95, 1.7, 8), leaf, 0, 3.8, 0));
    g.add(mesh(new THREE.ConeGeometry(0.6, 1.4, 8), leaf, 0, 4.9, 0));
    return g;
  }
  if (kind === 'rock') {
    return new THREE.Mesh(new THREE.IcosahedronGeometry(0.9, 0), std(0x787c82, { rough: 1 }));
  }
  if (kind === 'apple') {
    const g = new THREE.Group();
    g.add(mesh(new THREE.SphereGeometry(0.32, 10, 8), std(0xd23b2e, { flat: false, rough: 0.45 }), 0, 0.32, 0));
    return g;
  }
  if (kind === 'bush') {
    const g = new THREE.Group();
    g.add(mesh(new THREE.IcosahedronGeometry(0.55, 0), std(0x3c8f3f), 0, 0.45, 0));
    g.add(mesh(new THREE.IcosahedronGeometry(0.4, 0), std(0x2f7d33), 0.42, 0.5, 0.1));
    g.add(mesh(new THREE.IcosahedronGeometry(0.36, 0), std(0x4a9c44), -0.35, 0.42, 0.2));
    return g;
  }
  if (kind === 'flower') {
    const g = new THREE.Group();
    const petals = [0xe8c93e, 0xe06fa4, 0xf2f2f2, 0x8f6fe0, 0xe0704a];
    g.add(mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.5, 5), std(0x3c8f3f, { flat: false }), 0, 0.25, 0));
    g.add(mesh(new THREE.IcosahedronGeometry(0.13, 0), std(petals[Math.floor(Math.random() * petals.length)], { rough: 0.6 }), 0, 0.55, 0));
    return g;
  }
  if (kind === 'reed') {
    const g = new THREE.Group();
    for (let i = 0; i < 3; i++) {
      const h = 1.0 + Math.random() * 0.5;
      g.add(mesh(new THREE.CylinderGeometry(0.035, 0.05, h, 4), std([0x7a9a4a, 0x8aa855, 0x6b8c3f][i], { flat: false }), (i - 1) * 0.15, h / 2, (i % 2) * 0.12));
    }
    return g;
  }
  if (kind === 'mushroom') {
    const g = new THREE.Group();
    g.add(mesh(new THREE.CylinderGeometry(0.09, 0.12, 0.3, 6), std(0xe3d9c2, { flat: false }), 0, 0.15, 0));
    g.add(mesh(new THREE.ConeGeometry(0.28, 0.24, 8), std(0xc0392b, { rough: 0.6 }), 0, 0.4, 0));
    return g;
  }
  if (kind === 'hut') {
    const g = new THREE.Group();
    g.add(mesh(new THREE.CylinderGeometry(1.8, 1.85, 1.9, 8), std(0x8a6a45, { rough: 1 }), 0, 0.95, 0));
    g.add(mesh(new THREE.ConeGeometry(2.35, 1.6, 8), std(0xa8842f, { rough: 1 }), 0, 2.7, 0));
    g.add(mesh(new THREE.BoxGeometry(0.85, 1.35, 0.12), std(0x4a3319, { flat: false }), 0, 0.68, 1.76));
    return g;
  }
  if (kind === 'well') {
    const g = new THREE.Group();
    g.add(mesh(new THREE.CylinderGeometry(0.85, 0.92, 0.8, 10), std(0x8d9299, { rough: 1 }), 0, 0.4, 0));
    g.add(mesh(new THREE.CylinderGeometry(0.62, 0.62, 0.1, 10), std(0x27436b, { flat: false, rough: 0.3 }), 0, 0.78, 0));
    g.add(mesh(new THREE.BoxGeometry(0.12, 1.5, 0.12), std(0x5d4023, { flat: false }), 0.75, 1.1, 0));
    g.add(mesh(new THREE.BoxGeometry(0.12, 1.5, 0.12), std(0x5d4023, { flat: false }), -0.75, 1.1, 0));
    g.add(mesh(new THREE.ConeGeometry(1.15, 0.6, 6), std(0x7a5a30), 0, 2.05, 0));
    return g;
  }
  if (kind === 'tower') {
    const g = new THREE.Group();
    g.add(mesh(new THREE.CylinderGeometry(1.15, 1.55, 7, 9), std(0x9aa0a6, { rough: 1 }), 0, 3.5, 0));
    g.add(mesh(new THREE.CylinderGeometry(1.45, 1.45, 0.5, 9), std(0x84898f, { rough: 1 }), 0, 7.0, 0));
    g.add(mesh(new THREE.ConeGeometry(1.6, 1.6, 9), std(0x7a3b2e), 0, 8.05, 0));
    g.add(mesh(new THREE.BoxGeometry(0.5, 0.75, 0.12), std(0x2b3138, { flat: false }), 0, 5.4, 1.32));
    g.add(mesh(new THREE.BoxGeometry(0.85, 1.5, 0.12), std(0x3a2a17, { flat: false }), 0, 0.75, 1.5));
    return g;
  }
  if (kind === 'stone') {
    const g = new THREE.Group();
    const s = mesh(new THREE.BoxGeometry(0.75, 2.3, 0.5), std(0x6f7681, { rough: 1 }), 0, 1.05, 0);
    s.rotation.z = (Math.random() - 0.5) * 0.12; // ancient stones lean a little
    g.add(s);
    return g;
  }
  if (kind === 'campfire') {
    const g = new THREE.Group();
    const log = std(0x5d4023, { flat: false, rough: 1 });
    const l1 = mesh(new THREE.CylinderGeometry(0.11, 0.11, 1.1, 5), log, 0, 0.16, 0); l1.rotation.z = Math.PI / 2;
    const l2 = mesh(new THREE.CylinderGeometry(0.11, 0.11, 1.1, 5), log, 0, 0.16, 0); l2.rotation.z = Math.PI / 2; l2.rotation.y = Math.PI / 3;
    const l3 = mesh(new THREE.CylinderGeometry(0.11, 0.11, 1.1, 5), log, 0, 0.16, 0); l3.rotation.z = Math.PI / 2; l3.rotation.y = -Math.PI / 3;
    for (let i = 0; i < 5; i++) {
      const a = (i / 5) * Math.PI * 2;
      g.add(mesh(new THREE.IcosahedronGeometry(0.16, 0), std(0x6f7681, { rough: 1 }), Math.cos(a) * 0.62, 0.1, Math.sin(a) * 0.62));
    }
    const flame = mesh(new THREE.ConeGeometry(0.3, 0.75, 6), std(0xff9d3c, { emissive: 0xff7a1a, emissiveIntensity: 1.6, rough: 0.4 }), 0, 0.62, 0);
    flame.name = 'flame';
    const light = new THREE.PointLight(0xff9048, 0, 16, 1.8); // intensity driven per-frame (night glow + flicker)
    light.position.set(0, 1.1, 0);
    light.name = 'firelight';
    g.add(l1, l2, l3, flame, light);
    return g;
  }
  if (kind === 'tent') {
    const g = new THREE.Group();
    const t = mesh(new THREE.ConeGeometry(1.25, 1.7, 4), std(0xb08d57, { rough: 1 }), 0, 0.85, 0);
    t.rotation.y = Math.PI / 4; // face a flat side forward
    g.add(t);
    return g;
  }
  if (kind === 'wall') {
    const g = new THREE.Group();
    g.add(mesh(new THREE.BoxGeometry(2.2, 1.8, 0.3), std(0x8a6a45, { rough: 1 }), 0, 0.9, 0));
    g.add(mesh(new THREE.BoxGeometry(2.3, 0.14, 0.36), std(0x6b4a2b, { flat: false }), 0, 1.83, 0));
    return g;
  }
  if (kind === 'floor') {
    return mesh(new THREE.BoxGeometry(2, 0.12, 2), std(0x9a7a50, { rough: 1 }), 0, 0.06, 0);
  }
  if (kind === 'fence') {
    const g = new THREE.Group();
    const wood = std(0x7a5a30, { flat: false, rough: 1 });
    g.add(mesh(new THREE.BoxGeometry(1.1, 0.1, 0.08), wood, 0, 0.85, 0));
    g.add(mesh(new THREE.BoxGeometry(1.1, 0.1, 0.08), wood, 0, 0.45, 0));
    g.add(mesh(new THREE.BoxGeometry(0.09, 1.0, 0.09), std(0x6b4a2b, { flat: false }), -0.45, 0.5, 0));
    g.add(mesh(new THREE.BoxGeometry(0.09, 1.0, 0.09), std(0x6b4a2b, { flat: false }), 0.45, 0.5, 0));
    return g;
  }
  if (kind === 'door') {
    // Portcullis-style: the panel rides up when the door opens (state applied
    // in syncEntities — the sim owns whether it's open).
    const g = new THREE.Group();
    const frame = std(0x6b4a2b, { flat: false, rough: 1 });
    g.add(mesh(new THREE.BoxGeometry(0.18, 2.2, 0.3), frame, -0.95, 1.1, 0));
    g.add(mesh(new THREE.BoxGeometry(0.18, 2.2, 0.3), frame, 0.95, 1.1, 0));
    g.add(mesh(new THREE.BoxGeometry(2.1, 0.18, 0.3), frame, 0, 2.25, 0));
    const panel = mesh(new THREE.BoxGeometry(1.72, 2.0, 0.12), std(0x8a6a45, { rough: 1 }), 0, 1.0, 0);
    panel.name = 'panel';
    g.add(panel);
    return g;
  }
  if (kind === 'lever') {
    const g = new THREE.Group();
    g.add(mesh(new THREE.BoxGeometry(0.32, 0.26, 0.32), std(0x6f7681, { rough: 1 }), 0, 0.13, 0));
    const stick = mesh(new THREE.CylinderGeometry(0.045, 0.055, 0.62, 5), std(0xb08d57, { flat: false }), 0, 0.45, 0);
    stick.name = 'stick';
    stick.rotation.z = 0.6;
    g.add(stick);
    return g;
  }
  if (kind === 'plate') {
    const pad = mesh(new THREE.BoxGeometry(1.1, 0.09, 1.1), std(0x8d9299, { rough: 0.8 }), 0, 0.05, 0);
    pad.name = 'pad';
    const g = new THREE.Group();
    g.add(pad);
    return g;
  }
  if (kind === 'lamp') {
    const g = new THREE.Group();
    g.add(mesh(new THREE.CylinderGeometry(0.06, 0.09, 1.6, 6), std(0x5d4023, { flat: false, rough: 1 }), 0, 0.8, 0));
    const globe = mesh(new THREE.SphereGeometry(0.22, 10, 8), std(0xfff3c4, { flat: false, rough: 0.4, emissive: 0xffdf8a, emissiveIntensity: 0 }), 0, 1.75, 0);
    globe.name = 'globe';
    const light = new THREE.PointLight(0xffe2a1, 0, 13, 1.8);
    light.position.set(0, 1.8, 0);
    light.name = 'lamplight';
    g.add(globe, light);
    return g;
  }
  if (kind === 'ball') {
    const g = new THREE.Group();
    g.add(mesh(new THREE.IcosahedronGeometry(0.35, 1), std(0xf2f2f2, { rough: 0.5 }), 0, 0.35, 0));
    g.add(mesh(new THREE.IcosahedronGeometry(0.145, 0), std(0x22262c, { rough: 0.5 }), 0.22, 0.55, 0.12));
    return g;
  }
  if (kind === 'crate') {
    const g = new THREE.Group();
    g.add(mesh(new THREE.BoxGeometry(1, 1, 1), std(0x9a7a50, { rough: 1 }), 0, 0.5, 0));
    g.add(mesh(new THREE.BoxGeometry(1.04, 0.12, 1.04), std(0x6b4a2b, { flat: false }), 0, 0.85, 0));
    g.add(mesh(new THREE.BoxGeometry(1.04, 0.12, 1.04), std(0x6b4a2b, { flat: false }), 0, 0.15, 0));
    return g;
  }
  if (kind === 'prowler') {
    const g = new THREE.Group();
    g.add(mesh(new THREE.ConeGeometry(0.7, 1.5, 6), std(0x2b2233, { rough: 0.95 }), 0, 0.75, 0));
    g.add(mesh(new THREE.ConeGeometry(0.32, 0.6, 5), std(0x1e1826, { rough: 1 }), 0.35, 1.2, 0.15));
    g.add(mesh(new THREE.ConeGeometry(0.32, 0.6, 5), std(0x1e1826, { rough: 1 }), -0.3, 1.15, -0.1));
    const eye = std(0xff4433, { emissive: 0xff2211, emissiveIntensity: 1.8, rough: 0.3 });
    g.add(mesh(new THREE.SphereGeometry(0.06, 6, 5), eye, 0.16, 1.05, 0.32));
    g.add(mesh(new THREE.SphereGeometry(0.06, 6, 5), eye, -0.16, 1.05, 0.32));
    return g;
  }
  if (kind === 'villager') {
    const g = new THREE.Group();
    const robes = [0x7a5a3c, 0x5c6b3f, 0x6b4a5c, 0x3f5c6b, 0x8a6a2f];
    const robe = std(robes[Math.floor(Math.random() * robes.length)], { flat: false, rough: 1 });
    g.add(mesh(new THREE.CylinderGeometry(0.24, 0.3, 1.15, 8), robe, 0, 0.58, 0));
    g.add(mesh(new THREE.SphereGeometry(0.19, 10, 8), std(0xd9b48f, { flat: false, rough: 0.6 }), 0, 1.28, 0));
    g.add(mesh(new THREE.ConeGeometry(0.21, 0.22, 8), robe, 0, 1.47, 0)); // a simple hood/cap
    return g;
  }
  if (kind === 'wanderer') {
    return new THREE.Mesh(new THREE.ConeGeometry(0.6, 1.6, 6), std(0xff8c42, { flat: false, rough: 0.8 }));
  }
  if (kind === 'hopper') {
    const g = new THREE.Group();
    const fur = std(0xb59a7a, { rough: 0.95 });
    g.add(mesh(new THREE.SphereGeometry(0.28, 8, 6), fur, 0, 0.28, 0));
    g.add(mesh(new THREE.SphereGeometry(0.18, 8, 6), std(0xc4ac8e, { rough: 0.95 }), 0.2, 0.5, 0));
    g.add(mesh(new THREE.CylinderGeometry(0.035, 0.05, 0.32, 5), fur, 0.24, 0.72, 0.07));
    g.add(mesh(new THREE.CylinderGeometry(0.035, 0.05, 0.32, 5), fur, 0.24, 0.72, -0.07));
    g.add(mesh(new THREE.SphereGeometry(0.09, 6, 5), std(0xf2ead9, { rough: 1 }), -0.26, 0.3, 0));
    return g;
  }
  if (kind === 'building') {
    // Story count is structural (set by settlementSpecs), not a toggle — the
    // mesh is built with exactly that many floor slabs. Windows sit in a
    // named group so the frame loop can light them at dusk, same trick as
    // the lamp/streetlight globe.
    const floors = Math.max(1, Math.min(8, (e && e.floors) || 2));
    const palette = [0x8d8478, 0x9a8f7a, 0x7d8a8f, 0x8a7d7a, 0x7a8478];
    const wall = std(palette[Math.floor(Math.random() * palette.length)], { rough: 0.95 });
    const trim = std(0x4a463f, { flat: false, rough: 0.9 });
    const g = new THREE.Group();
    const FLOOR_H = 2.6, W = 3.4;
    const windows = new THREE.Group();
    windows.name = 'windows';
    for (let f = 0; f < floors; f++) {
      const y = FLOOR_H * f + FLOOR_H / 2;
      g.add(mesh(new THREE.BoxGeometry(W, FLOOR_H - 0.15, W), wall, 0, y, 0));
      for (const wx of [-0.8, 0.8]) {
        windows.add(mesh(new THREE.BoxGeometry(0.5, 0.7, 0.05), std(0xbfe0f2, { flat: false, rough: 0.25, emissive: 0xfff0b0, emissiveIntensity: 0 }), wx, y, W / 2 + 0.03));
      }
    }
    g.add(windows);
    g.add(mesh(new THREE.BoxGeometry(W + 0.3, 0.25, W + 0.3), trim, 0, FLOOR_H * floors + 0.1, 0));
    return g;
  }
  if (kind === 'car') {
    const palette = [0xb0432f, 0x2f4a7a, 0x3f6b45, 0x8d8478, 0x2b2b2e, 0xc9a227];
    const idx = e && e.variant != null ? e.variant % palette.length : Math.floor(Math.random() * palette.length);
    const body = std(palette[idx], { rough: 0.5 });
    const g = new THREE.Group();
    g.add(mesh(new THREE.BoxGeometry(1.9, 0.55, 0.95), body, 0, 0.4, 0));
    g.add(mesh(new THREE.BoxGeometry(1.05, 0.42, 0.88), body, -0.05, 0.76, 0));
    g.add(mesh(new THREE.BoxGeometry(0.9, 0.28, 0.8), std(0x2b3138, { flat: false, rough: 0.3 }), -0.05, 0.78, 0));
    const wheel = std(0x1a1a1a, { rough: 0.9 });
    for (const [wx, wz] of [[0.6, 0.45], [0.6, -0.45], [-0.6, 0.45], [-0.6, -0.45]]) {
      const wl = mesh(new THREE.CylinderGeometry(0.22, 0.22, 0.18, 10), wheel, wx, 0.22, wz);
      wl.rotation.z = Math.PI / 2;
      g.add(wl);
    }
    return g;
  }
  if (kind === 'shopfront') {
    const palette = [0xb0432f, 0x2f6b7a, 0x7a5a2f, 0x5a2f7a];
    const idx = e && e.variant != null ? e.variant % palette.length : Math.floor(Math.random() * palette.length);
    const g = new THREE.Group();
    g.add(mesh(new THREE.BoxGeometry(2.6, 2.2, 2.2), std(0xa8926f, { rough: 0.9 }), 0, 1.1, 0));
    g.add(mesh(new THREE.BoxGeometry(2.8, 0.3, 0.6), std(palette[idx], { flat: false, rough: 0.7 }), 0, 2.05, 1.1));
    g.add(mesh(new THREE.BoxGeometry(1.6, 1.1, 0.06), std(0xbfe0f2, { flat: false, rough: 0.25 }), 0, 1.1, 1.13));
    return g;
  }
  if (kind === 'sign') {
    const palette = [0xc0392b, 0x2f6b45, 0x2f4a7a, 0x8a6a2f];
    const idx = e && e.variant != null ? e.variant % palette.length : Math.floor(Math.random() * palette.length);
    const g = new THREE.Group();
    g.add(mesh(new THREE.CylinderGeometry(0.06, 0.06, 1.8, 6), std(0x5d4023, { flat: false, rough: 1 }), 0, 0.9, 0));
    g.add(mesh(new THREE.BoxGeometry(0.9, 0.5, 0.06), std(palette[idx], { rough: 0.6 }), 0, 1.7, 0));
    return g;
  }
  if (kind === 'streetlight') {
    // Same globe/lamplight naming as `lamp` (no lever wiring — see devices.js)
    // so it rides the identical day/night lighting code in the frame loop.
    const g = new THREE.Group();
    g.add(mesh(new THREE.CylinderGeometry(0.08, 0.1, 3.2, 8), std(0x3a3f45, { flat: false, rough: 0.8 }), 0, 1.6, 0));
    g.add(mesh(new THREE.BoxGeometry(0.9, 0.08, 0.08), std(0x3a3f45, { flat: false }), 0.45, 3.15, 0));
    const globe = mesh(new THREE.SphereGeometry(0.22, 10, 8), std(0xfff3c4, { flat: false, rough: 0.4, emissive: 0xffdf8a, emissiveIntensity: 0 }), 0.85, 3.05, 0);
    globe.name = 'globe';
    const light = new THREE.PointLight(0xffe2a1, 0, 14, 1.8);
    light.position.set(0.85, 3.1, 0);
    light.name = 'lamplight';
    g.add(globe, light);
    return g;
  }
  if (customTypes.has(kind)) return meshFromSpec(customTypes.get(kind)); // authored in-world
  return primitive({ shape: 'box', color: '#888888' });
}

function makeAvatar() {
  const g = new THREE.Group();
  const body = new THREE.Mesh(new THREE.CapsuleGeometry(0.35, 0.9, 4, 8), new THREE.MeshStandardMaterial({ color: 0x4f86c6, roughness: 0.7 }));
  body.position.y = 0.95;
  const head = new THREE.Mesh(new THREE.SphereGeometry(0.28, 12, 10), new THREE.MeshStandardMaterial({ color: 0xe6b98f, roughness: 0.6 }));
  head.position.y = 1.7;
  g.add(body, head);
  return g;
}

async function main() {
  const attached = await post('/api/world/attach', { kind: 'player', name: 'Player' });
  if (!attached.ok) return fail('could not attach');
  const me = attached.id;

  // Detach immediately on reload/close so we don't leave a phantom inhabitant
  // (the server also prunes stale ones after 12s as a fallback).
  addEventListener('pagehide', () => {
    try { navigator.sendBeacon(api('/api/world/detach'), new Blob([JSON.stringify({ id: me })], { type: 'application/json' })); } catch (_) {}
  });

  const snapRes = await get('/api/world/snapshot');
  if (!snapRes.ok) return fail('could not load snapshot');
  const snap = snapRes.snapshot;
  let worldVersion = snap.worldVersion;
  registerTypes(snap.types);
  loadedTypesVersion = snap.typesVersion || 0;

  // scene
  const renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
  renderer.setSize(innerWidth, innerHeight);
  document.getElementById('app').appendChild(renderer.domElement);

  const scene = new THREE.Scene();
  const sky = new THREE.Color(0x82c6f2);
  scene.background = sky;
  // Lighter, farther haze so you can see across the world (helps it *feel* big)
  // rather than walling you in with close grey fog.
  const VIEW = (snap.chunkSize || 64) * CHUNK_RING; // how far the streamed ground reaches
  let camRef = null; // set after the camera is created (applyTimeOfDay runs before that)
  scene.fog = new THREE.Fog(0xc3e4ff, VIEW * 0.55, VIEW * 1.45); // the fog hides the streaming horizon
  const hemi = new THREE.HemisphereLight(0xd6ecff, 0x6e7d4a, 0.85); // blue sky fill + warm green ground bounce
  scene.add(hemi);
  const sun = new THREE.DirectionalLight(0xfff3da, 1.45); // warmer, stronger key light → colours pop
  sun.position.set(180, 260, 90); // repositioned every perceive by applyTimeOfDay
  scene.add(sun);

  // ── day/night ─────────────────────────────────────────────────────────────
  // The sim owns the clock (perceive.time.t: 0 = midnight, 0.5 = noon); every
  // client renders the same sun. The palette lerps through night → a warm
  // dawn/dusk band → full day, and campfires take over after dark.
  const SKY_DAY = new THREE.Color(0x82c6f2), SKY_NIGHT = new THREE.Color(0x0b1026), SKY_WARM = new THREE.Color(0xff9d5c);
  const FOG_DAY = new THREE.Color(0xc3e4ff), FOG_NIGHT = new THREE.Color(0x141b33), FOG_WARM = new THREE.Color(0xe8a06a);
  let worldTime = (snap.time && snap.time.t) || 0.4;
  let nightFactor = 0; // 0 in daylight → 1 at deep night (drives campfire glow)
  function applyTimeOfDay(t) {
    worldTime = t;
    const sunEl = Math.sin((t - 0.25) * Math.PI * 2); // -1 midnight … +1 noon
    const day = Math.max(0, Math.min(1, (sunEl + 0.12) / 0.5)); // rises fast around dawn
    const warm = Math.max(0, 1 - Math.abs(sunEl) * 3.2) * 0.85; // horizon band at dawn/dusk
    nightFactor = 1 - day;
    const skyC = SKY_NIGHT.clone().lerp(SKY_DAY, day).lerp(SKY_WARM, warm * 0.55);
    const fogC = FOG_NIGHT.clone().lerp(FOG_DAY, day).lerp(FOG_WARM, warm * 0.5);
    sky.copy(skyC);
    scene.fog.color.copy(fogC);
    sun.intensity = 0.08 + Math.max(0, sunEl) * 1.4;
    sun.color.setHex(warm > 0.3 ? 0xffb35c : 0xfff3da);
    hemi.intensity = 0.22 + day * 0.65; // the moon keeps the world navigable, never pitch black
    const a = (t - 0.25) * Math.PI * 2;
    const cp = camRef ? camRef.position : { x: 0, z: 0 }; // camRef set once the camera exists
    sun.position.set(cp.x + Math.cos(a) * 300, Math.sin(a) * 280 + 40, cp.z + 120);
  }
  applyTimeOfDay(worldTime);

  // Floating origin: everything in `scene` is positioned relative to `origin`
  // (a world-space point), not absolute world coordinates — keeps geometry
  // near (0,0,0) so float32 doesn't jitter far from spawn. maybeRebase() (in
  // the frame loop) shifts `origin` and every render position together once
  // the camera drifts ~4,096 units from it. World-space (server) coordinates
  // are always renderPos + origin.
  let origin = { x: 0, z: 0 };
  const getOrigin = () => origin;
  const instancedProps = makeInstancedProps(scene, getOrigin);
  const chunkMgr = makeChunkManager(scene, snap, getOrigin);
  chunkMgr.update(snap.spawn.x, snap.spawn.z); // start pulling the spawn ring immediately
  // Water: one big plane that follows the camera — the sea is everywhere the
  // ground dips under it, and the world has no edge to size a plane to.
  const water = new THREE.Mesh(
    new THREE.PlaneGeometry(VIEW * 6, VIEW * 6).rotateX(-Math.PI / 2),
    new THREE.MeshStandardMaterial({ color: 0x2a6fb0, transparent: true, opacity: 0.62, roughness: 0.2, metalness: 0.1 }),
  );
  water.position.y = snap.seaLevel;
  scene.add(water);
  let loadedTerrainVersion = snap.terrainVersion || 1;
  // The ground was sculpted/painted (by us or someone in our world) — drop the
  // loaded chunks; the ring refetches them fresh on the next update pass.
  function resyncTerrain() {
    loadedTerrainVersion = null; // set from the next perceive
    chunkMgr.resync();
  }

  // entities (creatures + scenery) and other players
  const objs = new Map();   // entity id -> { obj, kind, target } or { instanced:true, kind }
  const entityData = new Map(); // entity id -> latest serialized entity (for the inspector)
  const avatars = new Map(); // inhabitant id -> { obj, target }
  const othersData = new Map(); // inhabitant id -> latest `o` from perceive (for rebase)
  const solids = [];         // { x, z, r } RENDER-space, for trees/rocks — player collision
  let myLife = null;
  let lastEat = 0;

  function syncEntities(entities) {
    const seen = new Set();
    solids.length = 0;
    for (const e of entities) {
      // An authored type we haven't loaded the mesh for yet — fetch it, render
      // it next frame once we know how. (Multiplayer: a friend defined it.)
      if (!knownKind(e.kind)) { fetchTypes(); continue; }
      if (SOLID_RADII[e.kind]) solids.push({ x: e.pose.x - origin.x, z: e.pose.z - origin.z, r: SOLID_RADII[e.kind] * (e.scale || 1) });
      else if (e.kind === 'door' && !e.open) solids.push({ x: e.pose.x - origin.x, z: e.pose.z - origin.z, r: 1.0 * (e.scale || 1) });
      seen.add(e.id);
      entityData.set(e.id, e);
      let rec = objs.get(e.id);
      if (rec && rec.kind !== e.kind) { // e.g. forked into a fresh authored type — rebuild
        if (rec.instanced) instancedProps.remove(e.id, rec.kind); else scene.remove(rec.obj);
        objs.delete(e.id);
        rec = null;
      }
      if (isInstancedKind(e.kind)) {
        if (!rec) { instancedProps.add(e.id, e); rec = { instanced: true, kind: e.kind }; objs.set(e.id, rec); }
        else instancedProps.update(e.id, e); // keep transform live (scale/rot edits)
        continue; // flora/rock: no per-frame lerp or device state below
      }
      if (!rec) {
        const obj = makeObject(e.kind, e);
        obj.traverse((o) => { o.userData.entityId = e.id; }); // every sub-mesh resolves to the entity
        scene.add(obj);
        rec = { obj, kind: e.kind, target: new THREE.Vector3() };
        objs.set(e.id, rec);
        rec.obj.position.set(e.pose.x - origin.x, e.pose.y + (yOffset[e.kind] || 0), e.pose.z - origin.z);
      }
      // Apply scale + rotation every sync so edits (and behavior-driven spin) show live.
      const sc = e.scale || 1;
      if (rec.obj.scale.x !== sc) rec.obj.scale.setScalar(sc);
      if (e.pose.rot != null) rec.obj.rotation.y = e.pose.rot;
      rec.target.set(e.pose.x - origin.x, e.pose.y + (yOffset[e.kind] || 0), e.pose.z - origin.z);
      if (isStatic(e.kind)) rec.obj.position.copy(rec.target); // props don't move
      // Device state → visible state (the sim owns truth; we just show it).
      if (e.kind === 'door') rec.open = !!e.open;
      else if (e.kind === 'lever') {
        const stick = rec.obj.getObjectByName('stick');
        if (stick) stick.rotation.z = e.on ? -0.6 : 0.6;
      } else if (e.kind === 'plate') {
        const pad = rec.obj.getObjectByName('pad');
        if (pad) pad.position.y = e.pressed ? 0.015 : 0.05;
      } else if (e.kind === 'lamp' || e.kind === 'streetlight') {
        rec.lit = !!e.lit;
        const globe = rec.obj.getObjectByName('globe');
        if (globe) globe.material.emissiveIntensity = e.lit ? 1.5 : 0;
      }
    }
    for (const [id, rec] of objs) {
      if (seen.has(id)) continue;
      if (rec.instanced) instancedProps.remove(id, rec.kind); else scene.remove(rec.obj);
      objs.delete(id);
      entityData.delete(id);
    }
    document.getElementById('ecount').textContent = String(objs.size);
  }
  syncEntities(snap.entities);

  function syncOthers(others) {
    const seen = new Set();
    for (const o of others || []) {
      seen.add(o.id);
      othersData.set(o.id, o);
      let rec = avatars.get(o.id);
      if (!rec) {
        const obj = makeAvatar();
        scene.add(obj);
        rec = { obj, target: new THREE.Vector3() };
        avatars.set(o.id, rec);
        rec.obj.position.set(o.pose.x - origin.x, o.pose.y - EYE, o.pose.z - origin.z);
      }
      rec.target.set(o.pose.x - origin.x, o.pose.y - EYE, o.pose.z - origin.z);
      rec.obj.rotation.y = o.pose.yaw || 0;
    }
    for (const [id, rec] of avatars) if (!seen.has(id)) { scene.remove(rec.obj); avatars.delete(id); othersData.delete(id); }
    document.getElementById('pcount').textContent = String(avatars.size + 1);
  }

  // camera + controls — hold on the overlay until the spawn chunk has arrived
  // so the first frame stands on real ground, not a guess.
  const camera = new THREE.PerspectiveCamera(72, innerWidth / innerHeight, 0.1, VIEW * 2.6);
  for (let waited = 0; !chunkMgr.ready(attached.pose.x, attached.pose.z) && waited < 8000; waited += 100) {
    chunkMgr.update(attached.pose.x, attached.pose.z);
    await new Promise((r) => setTimeout(r, 100));
  }
  const spawnGround = chunkMgr.heightAt(attached.pose.x, attached.pose.z);
  camera.position.set(attached.pose.x, (spawnGround != null ? spawnGround : attached.pose.y) + EYE + 1.8, attached.pose.z);
  camRef = camera;
  const control = makeControls(camera, renderer.domElement, snap, {
    spawnCreature: () => post('/api/world/author', { id: me, spec: { kind: 'wanderer', x: camera.position.x + origin.x, z: camera.position.z + origin.z } }).catch(() => {}),
    spawnTree: () => post('/api/world/author', { id: me, spec: { kind: 'tree', x: camera.position.x + origin.x, z: camera.position.z + origin.z } }).catch(() => {}),
    collides: (x, z, r) => { for (const s of solids) { const dx = s.x - x, dz = s.z - z, rr = r + s.r; if (dx * dx + dz * dz < rr * rr) return true; } return false; },
    // (x,z) here are camera candidates — RENDER-space — chunkMgr is keyed by world coords.
    groundAt: (x, z) => chunkMgr.heightAt(x + origin.x, z + origin.z),
  });

  // seed + server HUD — and the world introduces itself by name
  document.getElementById('seed').textContent = String(snap.seed);
  const nameEl = document.getElementById('wname');
  if (nameEl && snap.name) nameEl.textContent = snap.name;
  if (snap.name) document.title = `${snap.name} — Ollie Cosmos`;
  if (snap.name && snap.landmarks && snap.landmarks.length) {
    const village = snap.landmarks.find((l) => l.kind === 'village');
    toast(`Welcome to ${snap.name}${village ? ` — the village of ${village.name} lies at (${village.x}, ${village.z})` : ''}`);
  }
  const serverEl = document.getElementById('server');
  if (serverEl) serverEl.textContent = API_BASE ? API_BASE.replace(/^https?:\/\//, '') : 'local';

  // ── direct manipulation: right-click anything → inspect / edit / recode ──────
  const raycaster = new THREE.Raycaster();
  let editorMode = { mode: 'create', typeName: null };
  let selBox = null, groundMarker = null, groundPick = null;

  const INP = 'width:100%;box-sizing:border-box;background:#0d1117;color:#e6edf3;border:1px solid #30363d;border-radius:6px;padding:6px;font-family:ui-monospace,monospace;margin:3px 0 7px;';
  const BTN = 'border:none;border-radius:6px;padding:7px 10px;cursor:pointer;color:#fff;font-size:12px;';
  const inspector = document.createElement('div');
  inspector.id = 'inspector';
  inspector.style.cssText = 'position:fixed;left:14px;top:14px;width:260px;max-height:92vh;overflow:auto;background:rgba(13,17,23,.97);border:1px solid #30363d;border-radius:12px;color:#e6edf3;font:12px ui-sans-serif,system-ui,sans-serif;padding:14px;z-index:25;display:none;box-shadow:0 12px 40px rgba(0,0,0,.55);';
  document.body.appendChild(inspector);

  function clearHighlight() { if (selBox) { scene.remove(selBox); selBox = null; } }
  function hideGroundMarker() { if (groundMarker) { scene.remove(groundMarker); groundMarker.geometry.dispose(); groundMarker = null; } }
  function closeInspector() { inspector.style.display = 'none'; clearHighlight(); hideGroundMarker(); groundPick = null; }
  // Instanced entities (trees/rocks/flora) share one mesh across every
  // instance of their kind, so BoxHelper(obj) doesn't apply — draw an
  // approximate Box3 at the entity's pose instead.
  function highlightEntity(id) {
    clearHighlight();
    const rec = objs.get(id);
    if (rec && rec.obj) { selBox = new THREE.BoxHelper(rec.obj, 0xffe066); scene.add(selBox); return; }
    const e = entityData.get(id);
    if (rec && rec.instanced && e) {
      const b = INSTANCED_BOUNDS[e.kind] || { r: 0.6, h: 1.2 };
      const rx = e.pose.x - origin.x, rz = e.pose.z - origin.z, ry = e.pose.y || 0;
      const box = new THREE.Box3(new THREE.Vector3(rx - b.r, ry, rz - b.r), new THREE.Vector3(rx + b.r, ry + b.h, rz + b.r));
      selBox = new THREE.Box3Helper(box, 0xffe066);
      scene.add(selBox);
    }
  }
  // `worldP` is world-space (x,z, optional y fallback) — the marker itself is
  // drawn in render-space (origin-relative), matching the rest of the scene.
  function showGroundMarker(worldP, radius) {
    hideGroundMarker();
    const geo = new THREE.RingGeometry(Math.max(0.2, radius - 0.4), radius, 40);
    geo.rotateX(-Math.PI / 2);
    groundMarker = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ color: 0xffe066, transparent: true, opacity: 0.85, side: THREE.DoubleSide }));
    const h = chunkMgr.heightAt(worldP.x, worldP.z);
    groundMarker.position.set(worldP.x - origin.x, (h != null ? h : (worldP.y || 0)) + 0.15, worldP.z - origin.z);
    scene.add(groundMarker);
  }

  function resolveEntityId(obj) { for (let o = obj; o; o = o.parent) if (o.userData && o.userData.entityId) return o.userData.entityId; return null; }
  function isTerrainObj(obj) { for (let o = obj; o; o = o.parent) if (o.userData && o.userData.terrain) return true; return false; }
  function pickAt(ndcX, ndcY) {
    raycaster.setFromCamera({ x: ndcX, y: ndcY }, camera);
    for (const h of raycaster.intersectObjects(scene.children, true)) {
      if (h.object.isInstancedMesh) {
        const id = instancedProps.idAt(h.object, h.instanceId);
        if (id) return { type: 'entity', id };
        continue;
      }
      const id = resolveEntityId(h.object);
      if (id) return { type: 'entity', id };
      if (isTerrainObj(h.object)) return { type: 'ground', point: h.point };
    }
    return null;
  }

  // Pull a type's full source (mesh + behavior) into the E editor and switch it
  // to "apply changes" mode — a redefine that updates every instance live.
  function openEditorForType(name) {
    get('/api/world/types').then((r) => {
      const t = (r && r.types || []).find((x) => x.name === name);
      if (!t) return toast('could not load "' + name + '"', true);
      document.getElementById('ed-name').value = t.name;
      document.getElementById('ed-mesh').value = JSON.stringify(t.mesh, null, 2);
      document.getElementById('ed-behavior').value = t.behavior || '';
      setEditorMode('edit', name);
      document.getElementById('editor').style.display = 'block';
      if (document.exitPointerLock) document.exitPointerLock();
      edMsg('editing "' + name + '" — Apply updates every one in the world', false);
    }).catch(() => toast('could not load "' + name + '"', true));
  }

  function openEntityInspector(id) {
    const e = entityData.get(id);
    if (!e) return;
    if (document.exitPointerLock) document.exitPointerLock();
    hideGroundMarker();
    highlightEntity(id);
    const builtin = BUILTIN_KINDS.has(e.kind);
    const rot = e.pose ? (e.pose.rot != null ? e.pose.rot : e.pose.heading || 0) : 0;
    inspector.innerHTML =
      '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">'
      + '<b style="font-size:13px;color:#58a6ff">' + e.kind + '</b><span data-x style="cursor:pointer;color:#9da7b3">✕</span></div>'
      + '<div style="color:#6e7681;margin-bottom:9px;word-break:break-all;font-size:11px">' + id + '</div>'
      + '<label style="color:#9da7b3">scale</label><input data-scale type="number" step="0.1" min="0.05" value="' + (e.scale != null ? e.scale : 1) + '" style="' + INP + '">'
      + '<label style="color:#9da7b3">rotation</label><input data-rot type="number" step="0.1" value="' + (Math.round(rot * 100) / 100) + '" style="' + INP + '">'
      + (e.life != null ? '<label style="color:#9da7b3">life</label><input data-life type="number" step="1" min="0" value="' + Math.round(e.life) + '" style="' + INP + '">' : '')
      + '<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px">'
      + '<button data-apply style="' + BTN + 'background:#238636">Apply</button>'
      + '<button data-code style="' + BTN + 'background:#1f6feb">' + (builtin ? 'Make editable' : 'Edit code ✎') + '</button>'
      + '<button data-del style="' + BTN + 'background:#da3633">Delete</button></div>'
      + '<div data-msg style="margin-top:8px;min-height:14px;color:#9da7b3"></div>';
    inspector.style.display = 'block';
    const q = (s) => inspector.querySelector(s);
    q('[data-x]').onclick = closeInspector;
    q('[data-apply]').onclick = () => {
      const props = { scale: parseFloat(q('[data-scale]').value), rot: parseFloat(q('[data-rot]').value) };
      const lifeEl = q('[data-life]');
      if (lifeEl) props.life = parseFloat(lifeEl.value);
      post('/api/world/edit', { id, props, actor: me }).then((r) => q('[data-msg]').textContent = r && r.ok ? '✓ applied' : (r && r.error) || 'failed').catch(() => {});
    };
    q('[data-del]').onclick = () => post('/api/world/edit', { id, op: 'delete', actor: me }).then(closeInspector).catch(() => {});
    q('[data-code]').onclick = () => {
      if (builtin) {
        post('/api/world/edit', { id, op: 'fork', actor: me }).then((r) => {
          if (!r || !r.ok) return (q('[data-msg]').textContent = (r && r.error) || 'failed');
          if (r.type) registerTypes([r.type]);
          closeInspector();
          openEditorForType(r.type.name);
        }).catch(() => {});
      } else {
        closeInspector();
        openEditorForType(e.kind);
      }
    };
  }

  function openGroundInspector(point) {
    if (document.exitPointerLock) document.exitPointerLock();
    clearHighlight();
    // `point` is the raycast hit — render-space. groundPick is WORLD-space
    // (what the terrain API and chunkMgr both expect); the marker function
    // converts back to render-space for display.
    groundPick = { x: point.x + origin.x, y: point.y, z: point.z + origin.z };
    let radius = 5;
    showGroundMarker(groundPick, radius);
    const matId = chunkMgr.materialAt(groundPick.x, groundPick.z);
    const matName = (matId != null && snap.materials[matId] ? snap.materials[matId].name : '?');
    const h = chunkMgr.heightAt(groundPick.x, groundPick.z) ?? groundPick.y;
    const opts = snap.materials.map((m) => '<option value="' + m.id + '">' + m.name + '</option>').join('');
    inspector.innerHTML =
      '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">'
      + '<b style="font-size:13px;color:#58a6ff">ground</b><span data-x style="cursor:pointer;color:#9da7b3">✕</span></div>'
      + '<div style="color:#9da7b3;margin-bottom:9px">material <b style="color:#e6edf3">' + matName + '</b> · height <b style="color:#e6edf3">' + h.toFixed(1) + '</b></div>'
      + '<label style="color:#9da7b3">brush radius</label><input data-r type="number" step="1" min="1" max="40" value="' + radius + '" style="' + INP + '">'
      + '<div style="display:flex;gap:6px;margin-bottom:8px"><button data-raise style="' + BTN + 'background:#238636;flex:1">Raise</button><button data-lower style="' + BTN + 'background:#9a6700;flex:1">Lower</button></div>'
      + '<label style="color:#9da7b3">paint material</label><select data-mat style="' + INP + '">' + opts + '</select>'
      + '<button data-paint style="' + BTN + 'background:#1f6feb;width:100%">Paint</button>'
      + '<div data-msg style="margin-top:8px;min-height:14px;color:#9da7b3">right-click another spot to move the brush</div>';
    inspector.style.display = 'block';
    const q = (s) => inspector.querySelector(s);
    q('[data-mat]').value = String(matId);
    q('[data-x]').onclick = closeInspector;
    q('[data-r]').oninput = () => { radius = Math.max(1, Math.min(40, parseFloat(q('[data-r]').value) || 5)); showGroundMarker({ x: groundPick.x, z: groundPick.z }, radius); };
    const sculpt = (mode, extra) => {
      post('/api/world/terrain', Object.assign({ x: groundPick.x, z: groundPick.z, radius, mode, actor: me }, extra || {}))
        .then((r) => { if (r && r.ok) resyncTerrain(); }).catch(() => {});
    };
    q('[data-raise]').onclick = () => sculpt('raise', { strength: 2 });
    q('[data-lower]').onclick = () => sculpt('lower', { strength: 2 });
    q('[data-paint]').onclick = () => sculpt('paint', { material: parseInt(q('[data-mat]').value, 10) });
  }

  // Right-click selects: from the crosshair when pointer-locked, else from the cursor.
  addEventListener('contextmenu', (e) => {
    e.preventDefault();
    let nx = 0, ny = 0;
    if (document.pointerLockElement !== renderer.domElement) { nx = (e.clientX / innerWidth) * 2 - 1; ny = -(e.clientY / innerHeight) * 2 + 1; }
    const hit = pickAt(nx, ny);
    if (!hit) return closeInspector();
    if (hit.type === 'entity') openEntityInspector(hit.id);
    else openGroundInspector(hit.point);
  });
  addEventListener('keydown', (e) => { if (e.code === 'Escape') closeInspector(); });

  function setEditorMode(mode, typeName) {
    editorMode = { mode, typeName: typeName || null };
    const go = document.getElementById('ed-go');
    const h2 = document.querySelector('#editor h2');
    if (mode === 'edit') { go.textContent = 'Apply changes'; if (h2) h2.textContent = 'Edit "' + typeName + '"'; }
    else { go.textContent = 'Define & Spawn'; if (h2) h2.textContent = 'Create anything'; }
  }

  // Reload type definitions and drop spawned custom-type meshes so a *mesh*
  // redefine (editing code in-world) re-renders on things already placed.
  function refreshTypesAndRebuild() {
    get('/api/world/types').then((r) => {
      if (!r || !r.ok) return;
      registerTypes(r.types);
      loadedTypesVersion = r.typesVersion;
      for (const [id, rec] of objs) {
        if (!BUILTIN_KINDS.has(rec.kind)) { scene.remove(rec.obj); objs.delete(id); } // rebuilt next sync
      }
    }).catch(() => {});
  }

  // Floating origin: keep the camera (and thus the geometry/matrices near it)
  // within REBASE_DIST of (0,0,0) render-space, so float32 doesn't jitter far
  // from spawn. This is a pure coordinate-system shift — camera, chunks,
  // entities and avatars all move by the exact same (dx,dz), so nothing
  // visibly jumps.
  const REBASE_DIST = 4096;
  function maybeRebase() {
    const dx = camera.position.x, dz = camera.position.z;
    if (dx * dx + dz * dz < REBASE_DIST * REBASE_DIST) return;
    origin = { x: origin.x + dx, z: origin.z + dz };
    camera.position.x -= dx; camera.position.z -= dz;
    chunkMgr.reposition();
    instancedProps.shiftAll(dx, dz);
    for (const rec of objs.values()) {
      if (rec.instanced) continue;
      rec.obj.position.x -= dx; rec.obj.position.z -= dz;
      rec.target.x -= dx; rec.target.z -= dz;
    }
    for (const rec of avatars.values()) {
      rec.obj.position.x -= dx; rec.obj.position.z -= dz;
      rec.target.x -= dx; rec.target.z -= dz;
    }
    closeInspector(); // any open ground/entity pick was captured render-space
  }

  // loop
  let lastPerceive = 0, lastReport = 0;
  const clock = new THREE.Clock();
  let lastChunkPass = 0;
  function frame() {
    const dt = Math.min(0.05, clock.getDelta());
    const elapsed = clock.elapsedTime;
    control.update(dt);
    maybeRebase();
    // Stream the ground: keep the chunk ring centred on the camera, and the
    // sea following it (the water plane is camera-local — the world has no
    // edges to pin it to).
    if (performance.now() - lastChunkPass > 400) {
      lastChunkPass = performance.now();
      chunkMgr.update(camera.position.x + origin.x, camera.position.z + origin.z);
    }
    water.position.x = camera.position.x;
    water.position.z = camera.position.z;
    for (const [id, rec] of objs) {
      if (rec.instanced) continue;
      if (!isStatic(rec.kind)) rec.obj.position.lerp(rec.target, Math.min(1, dt * 6));
      if (rec.kind === 'hopper') {
        // Client-side hop — a nervous bounce while nibbling, a long fast bound
        // when bolting. Pure decoration; the sim owns the actual position.
        const e = entityData.get(id);
        const fleeing = e && e.fleeing;
        const speed = fleeing ? 13 : 4.5;
        const amp = fleeing ? 0.45 : 0.08;
        const seedPhase = (id.charCodeAt(id.length - 1) || 0) * 1.7;
        rec.obj.position.y = rec.target.y + Math.abs(Math.sin(elapsed * speed + seedPhase)) * amp;
      } else if (rec.kind === 'campfire') {
        const light = rec.obj.getObjectByName('firelight');
        const flame = rec.obj.getObjectByName('flame');
        const flicker = 0.82 + Math.sin(elapsed * 11 + rec.obj.position.x) * 0.1 + Math.sin(elapsed * 23) * 0.08;
        if (light) light.intensity = (0.35 + nightFactor * 2.4) * flicker; // embers by day, hearth by night
        if (flame) flame.scale.set(1, 0.85 + flicker * 0.3, 1);
      } else if (rec.kind === 'door') {
        // Portcullis glide toward the sim's open/closed truth.
        const panel = rec.obj.getObjectByName('panel');
        if (panel) {
          const target = rec.open ? 2.72 : 1.0;
          panel.position.y += (target - panel.position.y) * Math.min(1, dt * 7);
        }
      } else if (rec.kind === 'lamp' || rec.kind === 'streetlight') {
        const light = rec.obj.getObjectByName('lamplight');
        if (light) light.intensity = rec.lit ? 0.5 + nightFactor * 2.2 : 0;
      } else if (rec.kind === 'building') {
        // Windows light up as dusk falls — the WP6 "city reads as a gradient
        // of light at night" cue, no per-instance sim state needed for it.
        const windows = rec.obj.getObjectByName('windows');
        if (windows) { const on = nightFactor > 0.35 ? 1 : 0; for (const w of windows.children) if (w.material.emissiveIntensity !== on) w.material.emissiveIntensity = on; }
      }
    }
    for (const rec of avatars.values()) rec.obj.position.lerp(rec.target, Math.min(1, dt * 8));

    const now = performance.now();
    if (now - lastPerceive > 1000 / snap.tickRate) {
      lastPerceive = now;
      get('/api/world/perceive?id=' + me).then((p) => {
        if (!p || !p.ok) return;
        if (p.worldVersion !== worldVersion) { location.reload(); return; }
        if (p.typesVersion !== loadedTypesVersion) refreshTypesAndRebuild(); // a type was authored or redefined
        if (p.terrainVersion != null && loadedTerrainVersion != null && p.terrainVersion !== loadedTerrainVersion) resyncTerrain(); // ground was sculpted
        loadedTerrainVersion = p.terrainVersion;
        if (p.time && p.time.t != null) applyTimeOfDay(p.time.t);
        syncEntities(p.entities);
        syncOthers(p.others);
        document.getElementById('tick').textContent = String(p.tick);
        const timeEl = document.getElementById('wtime');
        if (timeEl && p.time) timeEl.textContent = p.time.phase;
        if (p.self && p.self.life) { myLife = p.self.life; updateLife(p.self.life); }
        if (p.self && p.self.inventory) updateInventoryHud(p.self.inventory);
        if (p.heard && p.heard.length) p.heard.forEach((m) => addChatLine(m.from, m.text, false));
      }).catch(() => {});
    }
    // Auto-eat: walk near an apple while hungry → eat it (server restores life).
    // Apples are instanced (no rec.obj) — read position from entityData instead.
    if (myLife && myLife.value < myLife.max - 0.5 && now - lastEat > 350) {
      for (const [id, rec] of objs) {
        if (rec.kind !== 'apple') continue;
        const e = entityData.get(id);
        if (!e) continue;
        const apx = e.pose.x - origin.x, apz = e.pose.z - origin.z;
        if (Math.hypot(apx - camera.position.x, apz - camera.position.z) < 2.4) {
          lastEat = now;
          post('/api/world/act', { id: me, action: { type: 'eat' } }).then((r) => { if (r && r.ate && r.life) { myLife = r.life; updateLife(r.life); } }).catch(() => {});
          break;
        }
      }
    }
    if (now - lastReport > 120) {
      lastReport = now;
      post('/api/world/act', { id: me, action: { type: 'pose', pose: {
        x: camera.position.x + origin.x, y: camera.position.y, z: camera.position.z + origin.z, yaw: control.yaw, pitch: control.pitch,
      } } }).catch(() => {});
    }
    renderer.render(scene, camera);
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);

  // ── in-world chat ──────────────────────────────────────────────────────────
  // Talk to whoever's inhabiting the world with you (agents hear it on their next
  // look; you hear them via perceive's `heard`). Enter focuses the box; typing()
  // already gates the movement/action keys while it's focused, so WASD is safe.
  const chatInput = document.getElementById('chat-input');
  const chatLog = document.getElementById('chat-log');
  function addChatLine(from, text, mine) {
    if (!chatLog) return;
    const el = document.createElement('div');
    el.className = 'm' + (mine ? ' me' : '');
    const b = document.createElement('b');
    b.textContent = (mine ? 'you' : from) + ': ';
    el.appendChild(b);
    el.appendChild(document.createTextNode(text));
    chatLog.appendChild(el);
    while (chatLog.children.length > 40) chatLog.removeChild(chatLog.firstChild);
    chatLog.scrollTop = chatLog.scrollHeight;
  }
  if (chatInput) {
    chatInput.addEventListener('keydown', (e) => {
      if (e.code === 'Enter') {
        e.preventDefault();
        e.stopPropagation();
        const text = chatInput.value.trim();
        chatInput.value = '';
        if (text) {
          addChatLine('you', text, true);
          post('/api/world/say', { id: me, text }).catch(() => {});
        }
        chatInput.blur();
      } else if (e.code === 'Escape') {
        e.preventDefault();
        e.stopPropagation();
        chatInput.value = '';
        chatInput.blur();
      }
    });
  }
  addEventListener('keydown', (e) => {
    if (e.code === 'Enter' && !typing() && chatInput) { e.preventDefault(); chatInput.focus(); }
  });

  // ── body verbs: harvest / build / use / strike ─────────────────────────────
  function updateInventoryHud(inv) {
    const w = document.getElementById('inv-wood'), s = document.getElementById('inv-stone');
    if (w && inv) w.textContent = String(inv.wood || 0);
    if (s && inv) s.textContent = String(inv.stone || 0);
  }
  function act(action, onOk) {
    post('/api/world/act', { id: me, action }).then((r) => {
      if (r && r.inventory) updateInventoryHud(r.inventory);
      if (r && r.ok === false && r.error) toast(r.error.split('.')[0], true);
      else if (onOk) onOk(r);
    }).catch(() => {});
  }
  const buildBar = document.getElementById('build-bar');
  function toggleBuildBar() {
    if (!buildBar) return;
    if (buildBar.style.display !== 'block') {
      buildBar.innerHTML = '<b>Build</b> — aim, press the number (spends materials; C to gather)<br>'
        + BUILD_SLOTS.map((s) => `<span class="key">${s.key}</span> ${s.kind} <span style="opacity:.55">(${s.cost})</span>`).join(' · ');
      buildBar.style.display = 'block';
    } else buildBar.style.display = 'none';
  }

  // actions: spawn / new world / load seed / open editor
  addEventListener('keydown', (e) => {
    if (typing()) return; // don't fire game keys while writing code in the editor
    const slot = BUILD_SLOTS.find((s) => e.key === s.key);
    if (slot) {
      // Place just ahead of where you're looking (the sim aims from your yaw).
      act({ type: 'place', kind: slot.kind }, (r) => { if (r && r.placed) toast(`placed ${slot.kind}`); });
      return;
    }
    if (e.code === 'KeyC') { act({ type: 'harvest' }, (r) => { if (r && r.got) toast(`+${Object.entries(r.got).map(([m, n]) => `${n} ${m}`).join(', ')} (${r.harvested})`); else toast('nothing to harvest in reach', true); }); return; }
    if (e.code === 'KeyV') { act({ type: 'use' }, (r) => { if (r && r.used) toast(`${r.used}: ${r.state}`); else toast('nothing to use in reach', true); }); return; }
    if (e.code === 'KeyQ') { act({ type: 'strike' }, (r) => { if (r && r.hit) toast(r.dissolved ? 'the prowler dissolves' : 'you strike the prowler'); else toast('nothing to strike', true); }); return; }
    if (e.code === 'KeyB') { toggleBuildBar(); return; }
    if (e.code === 'KeyF') post('/api/world/author', { id: me, spec: { kind: 'wanderer', x: camera.position.x + origin.x, z: camera.position.z + origin.z } }).catch(() => {});
    else if (e.code === 'KeyT') post('/api/world/author', { id: me, spec: { kind: 'tree', x: camera.position.x + origin.x, z: camera.position.z + origin.z } }).catch(() => {});
    else if (e.code === 'KeyN') { uiPrompt({ title: 'Generate a new random world?', confirmOnly: true, okText: 'New World' }).then((ok) => { if (ok) post('/api/world/regenerate', {}).then(() => location.reload()); }); }
    else if (e.code === 'KeyL') { uiPrompt({ title: 'Load world seed', placeholder: 'e.g. 1337', value: (document.getElementById('seed') || {}).textContent }).then((s) => { if (s) post('/api/world/regenerate', { seed: s }).then(() => location.reload()); }); }
    else if (e.code === 'KeyE') { if (document.getElementById('editor').style.display !== 'block') setEditorMode('create', null); toggleEditor(); }
    else if (e.code === 'KeyK') { post('/api/world/save', {}).then((r) => toast(r && r.ok ? '✓ world saved' : 'save failed', !(r && r.ok))).catch(() => toast('save failed', true)); }
    else if (e.code === 'KeyJ') joinPrompt();
    else if (e.code === 'KeyH') { if (API_BASE) { try { localStorage.removeItem(SERVER_KEY); } catch (_) {} location.reload(); } else toast('already in your local world'); }
  });

  // Join another inhabitant's world by IP (or go back home with H).
  function joinPrompt() {
    uiPrompt({ title: 'Join a world by IP', placeholder: 'e.g. 192.168.1.50 or 192.168.1.50:19013', value: API_BASE, okText: 'Join' }).then((v) => {
      if (!v) return;
      const base = normalizeServer(v);
      if (!base) return toast('that doesn\'t look like an address', true);
      toast('connecting to ' + base.replace(/^https?:\/\//, '') + '…');
      fetch(base + '/api/health').then((r) => r.json()).then((h) => {
        if (!h || !h.ok || h.service !== 'ollie-cosmos') throw new Error('not a Cosmos server');
        try { localStorage.setItem(SERVER_KEY, base); } catch (_) {}
        location.reload();
      }).catch(() => toast('could not reach a Cosmos world at ' + base.replace(/^https?:\/\//, ''), true));
    });
  }

  // in-world code editor → define a brand-new entity type (mesh + behavior) and
  // spawn it live. The same author() call an agent will make.
  document.getElementById('ed-close').addEventListener('click', toggleEditor);
  document.getElementById('ed-go').addEventListener('click', async () => {
    const name = document.getElementById('ed-name').value.trim();
    const meshRaw = document.getElementById('ed-mesh').value.trim();
    const behavior = document.getElementById('ed-behavior').value;
    let mesh;
    if (meshRaw.startsWith('{') || meshRaw.startsWith('[')) {
      try { mesh = JSON.parse(meshRaw); } catch (err) { return edMsg('mesh JSON error: ' + err.message, true); }
    } else {
      mesh = { obj: meshRaw }; // anything else is treated as raw OBJ text
    }
    try {
      const editing = editorMode.mode === 'edit';
      const r = await post('/api/world/author', { id: me, spec: { define: { name, mesh, behavior }, spawn: !editing, x: camera.position.x + origin.x, z: camera.position.z + origin.z } });
      if (!r.ok) return edMsg(r.error || 'failed', true);
      if (r.type) registerTypes([r.type]);
      if (editing) { refreshTypesAndRebuild(); edMsg(`✓ applied to every "${name}" in the world`, false); }
      else { setEditorMode('edit', name); edMsg(`✓ defined & spawned "${name}" — edits now apply live`, false); }
    } catch (err) {
      edMsg(String(err.message || err), true);
    }
  });

  addEventListener('resize', () => {
    camera.aspect = innerWidth / innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(innerWidth, innerHeight);
  });
}

function updateLife(life) {
  const pct = Math.max(0, Math.min(100, (life.value / life.max) * 100));
  const fill = document.getElementById('life-fill');
  fill.style.width = pct + '%';
  fill.style.background = pct < 30 ? 'linear-gradient(90deg,#da3633,#f85149)' : 'linear-gradient(90deg,#3fb950,#56d364)';
  document.getElementById('life-val').textContent = Math.round(life.value);
}

// ── terrain / water ─────────────────────────────────────────────────────────────
// ── walk / fly controls ──────────────────────────────────────────────────────────
function makeControls(camera, dom, snap, hooks = {}) {
  const overlay = document.getElementById('overlay');
  const keys = new Set();
  const ctrl = { yaw: 0, pitch: -0.2, speed: 22, mode: 'fly', vy: 0, onGround: false };
  camera.rotation.order = 'YXZ';
  const toggleMode = () => { ctrl.mode = ctrl.mode === 'fly' ? 'walk' : 'fly'; ctrl.vy = 0; document.getElementById('mode').textContent = ctrl.mode; };

  // Gamepad (Xbox 360 / One, PS5 DualSense, …) — works WITHOUT pointer lock.
  const GP_DEAD = 0.18;
  const gpPrev = {};
  const gpBtn = (gp, i) => { const b = gp.buttons[i]; return b ? (typeof b === 'object' ? b.pressed : b > 0.5) : false; };
  const gpEdge = (gp, i, name) => { const now = gpBtn(gp, i); const was = gpPrev[name]; gpPrev[name] = now; return now && !was; };
  const gpAx = (v) => (Math.abs(v) > GP_DEAD ? v : 0);
  const firstPad = () => { const pads = navigator.getGamepads ? navigator.getGamepads() : []; for (const p of pads) if (p && p.connected) return p; return null; };

  const enter = () => {
    if (document.pointerLockElement === dom || !dom.requestPointerLock) return;
    try {
      const p = dom.requestPointerLock(); // returns a Promise in newer Chromium
      if (p && p.catch) p.catch(() => {}); // benign: lock exited before it resolved
    } catch (_) { /* ignore */ }
  };
  dom.addEventListener('click', enter);
  overlay.addEventListener('click', enter);
  document.addEventListener('pointerlockchange', () => { overlay.style.display = document.pointerLockElement === dom ? 'none' : 'flex'; });
  document.addEventListener('mousemove', (e) => {
    if (document.pointerLockElement !== dom) return;
    ctrl.yaw -= e.movementX * 0.0022;
    ctrl.pitch -= e.movementY * 0.0022;
    ctrl.pitch = Math.max(-Math.PI / 2 + 0.05, Math.min(Math.PI / 2 - 0.05, ctrl.pitch));
  });
  addEventListener('keydown', (e) => {
    if (typing()) return; // editor has focus — don't move while typing
    keys.add(e.code);
    if (e.code === 'KeyG') toggleMode();
  });
  addEventListener('keyup', (e) => keys.delete(e.code));

  ctrl.update = (dt) => {
    // ── gamepad: look (right stick) + actions (face buttons) ──
    let padUp = false, padDown = false, padJump = false;
    const gp = firstPad();
    if (gp) {
      const rx = gpAx(gp.axes[2] || 0), ry = gpAx(gp.axes[3] || 0);
      ctrl.yaw -= rx * 2.6 * dt;
      ctrl.pitch -= ry * 2.2 * dt;
      ctrl.pitch = Math.max(-Math.PI / 2 + 0.05, Math.min(Math.PI / 2 - 0.05, ctrl.pitch));
      padUp = gpBtn(gp, 7) || gpBtn(gp, 0);   // RT or A → up
      padDown = gpBtn(gp, 6);                  // LT → down
      padJump = gpBtn(gp, 0) || gpBtn(gp, 7);  // A or RT → jump (walk)
      if (gpEdge(gp, 1, 'b')) toggleMode();                              // B → walk/fly
      if (gpEdge(gp, 2, 'x') && hooks.spawnCreature) hooks.spawnCreature(); // X → creature
      if (gpEdge(gp, 3, 'y') && hooks.spawnTree) hooks.spawnTree();         // Y → tree
      // any controller activity dismisses the click-to-enter overlay
      if (rx || ry || gpAx(gp.axes[0] || 0) || gpAx(gp.axes[1] || 0) || padUp || padDown) overlay.style.display = 'none';
    }

    camera.rotation.y = ctrl.yaw;
    camera.rotation.x = ctrl.pitch;
    const fwd = new THREE.Vector3(-Math.sin(ctrl.yaw), 0, -Math.cos(ctrl.yaw));
    const right = new THREE.Vector3(Math.cos(ctrl.yaw), 0, -Math.sin(ctrl.yaw));
    const step = ctrl.speed * dt;
    // Accumulate the desired horizontal move (keyboard + left stick)…
    let mx = 0, mz = 0;
    if (keys.has('KeyW')) { mx += fwd.x * step; mz += fwd.z * step; }
    if (keys.has('KeyS')) { mx -= fwd.x * step; mz -= fwd.z * step; }
    if (keys.has('KeyD')) { mx += right.x * step; mz += right.z * step; }
    if (keys.has('KeyA')) { mx -= right.x * step; mz -= right.z * step; }
    if (gp) {
      const ly = gpAx(gp.axes[1] || 0), lx = gpAx(gp.axes[0] || 0);
      mx += fwd.x * (-ly * step) + right.x * (lx * step);
      mz += fwd.z * (-ly * step) + right.z * (lx * step);
    }
    // …then apply with collision (axis-separated → slide along walls). The
    // world is infinite — there are no edges to clamp to.
    const R = 0.45;
    let nx = camera.position.x + mx;
    let nz = camera.position.z + mz;
    if (hooks.collides && hooks.collides(nx, nz, R)) {
      if (!hooks.collides(nx, camera.position.z, R)) nz = camera.position.z;
      else if (!hooks.collides(camera.position.x, nz, R)) nx = camera.position.x;
      else { nx = camera.position.x; nz = camera.position.z; }
    }
    camera.position.x = nx;
    camera.position.z = nz;

    const g = hooks.groundAt ? hooks.groundAt(camera.position.x, camera.position.z) : null;
    const groundY = (g != null ? g : camera.position.y - EYE) + EYE; // chunk still in flight → treat as level
    if (ctrl.mode === 'fly') {
      if (keys.has('Space') || padUp) camera.position.y += ctrl.speed * dt;
      if (keys.has('ShiftLeft') || padDown) camera.position.y -= ctrl.speed * dt;
      if (camera.position.y < groundY) camera.position.y = groundY;
    } else {
      // walk: gravity + jump, stick to terrain
      ctrl.vy -= 30 * dt;
      camera.position.y += ctrl.vy * dt;
      if (camera.position.y <= groundY) {
        camera.position.y = groundY; ctrl.vy = 0; ctrl.onGround = true;
      } else {
        ctrl.onGround = false;
      }
      if ((keys.has('Space') || padJump) && ctrl.onGround) ctrl.vy = 9.5;
    }
  };
  return ctrl;
}

// Don't auto-run — the license gate (verification.js) boots the world once the
// app is verified, by calling this.
window.__bootCosmos = () => main().catch((e) => fail((e && e.message) || e));
