<!-- SOLID CITY · part 110 -->
--------- build ---------- */
function load() {
  M.state = 'loading';
  Promise.all([
    import('/city/avatar-factory.js?v=20261013a'),
    import('/city/hero-motion.js?v=20261013a'),
    import('/city/descendant-motions.js?v=20261013a'),
    api('/api/citizens'),
    api('/api/city/me'),
    fetch('/city/voices/manifest.json', { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; })
  ]).then(function (mods) {
    VOICE_FILES = mods[5] || null;
    var fac = mods[0].createAvatarFactory(T);
    var HERO = mods[1], DESC = mods[2], data = mods[3], me = mods[4];
    M.me = me && (me.display_name || me.name || me.username) || null;
    var liveById = {};
    if (data && data.citizens) { REACT.online = true; for (var i = 0; i < data.citizens.length; i++) liveById[data.citizens[i].id] = data.citizens[i]; if (data.reactions) { REACT.scripts = data.reactions.scripts; REACT.interpretations = data.reactions.interpretations; } }
    var group = new T.Group(); A.scene.add(group);
    ROSTER.forEach(function (rec, idx) {
      var live = liveById[rec.id] || {};
      var model = fac.buildAvatar(fac.validate(rec.appearance), { preview: false });
      var parts = fac.collectParts(model);
      var y = surfY(rec.post.x, rec.post.z);
      model.position.set(rec.post.x, y, rec.post.z);
      group.add(model);
      var holder = { c: null };
      var motion = HERO.createHeroMotion(parts, { gestures: DESC.DESCENDANT_GESTURES, lambda: 10, onEvent: function (phase, name) {
        if (holder.c && phase === 'started' && name) broadcastGesture(holder.c, name);
      } });
      var plateMat = new T.SpriteMaterial({ map: null, transparent: true, depthWrite: false });
      var plate = new T.Sprite(plateMat); plate.scale.set(2.6, 0.65, 1); plate.position.set(rec.post.x, y + 2.35, rec.post.z); group.add(plate);
      var solid = { x0: rec.post.x - 0.45, x1: rec.post.x + 0.45, z0: rec.post.z - 0.45, z1: rec.post.z + 0.45, y0: y, y1: y + 1.8, citizenFigure: true };
      A.solids.push(solid);
      var c = { id: rec.id, name: rec.name, rec: rec, fig: model, parts: parts, motion: motion, plate: plate, solid: solid,
                pos: { x: rec.post.x, z: rec.post.z }, face: Math.atan2(-rec.post.x, -rec.post.z), faceTarget: null, moveTarget: null,
                op: live.operator || { type: 'ai', epoch: 1 }, stopped: !!live.stopped, session: null, lastReactAt: 0, phase: idx * 1.7 };
      plate.material.map = plateTexture(c); plate.material.needsUpdate = true;
      holder.c = c;
      M.citizens.push(c); M.byId[c.id] = c;
      if (A.POIS) A.POIS.push({ id: 'citizen-' + rec.id, name: rec.name.toUpperCase() + ' — ' + rec.craft.split(',')[0], icon: rec.icon, x: rec.post.x, z: rec.post.z });
    });
    buildPanel();
    buildCapture();
    document.addEventListener('pointerdown', function () { M.interacted = true; }, { once: true });
    setInterval(refreshLive, 25000);
    window.CityCitizens = { citizens: M.citizens, byId: M.byId, state: M };
    M.state = 'live';
    A.logBuild({ t: Date.now(), site: 'THE SEVEN CITIZENS', what: 'The descendants stand at their posts as inhabitable citizens: Forge bodies, Hero Motion, Living Reactions, server minds — Nalu (Berth 1), Lehua (Hall of Becoming), Lani (Threshold), Hōkū (The Gap), Nīnau (Starwell), Koa (Eighth Gate), Pō (South Lot gate).' });
    A.help('👥 The seven citizens stand at their posts — walk up to one and talk. They see what happens near them, and they remember.', 9);
  }).catch(function (e) { console.error('citizens-place', e); });
}
function refreshLive() {
  api('/api/citizens').then(function (data) {
    if (!data || !data.citizens) return;
    data.citizens.forEach(function (lc) {
      var c = M.byId[lc.id]; if (!c) return;
      var changed = JSON.stringify(c.op) !== JSON.stringify(lc.operator) || c.stopped !== !!lc.stopped;
      c.op = lc.operator; c.stopped = !!lc.stopped;
      if (changed) { drawPlate(c); if (panelCitizen() === c) refreshOps(); }
    });
  });
}

/* ---------- frame ---------- */
var lastT = performance.now();
function frame() {
  var nowMs = performance.now(), dt = Math.min(0.05, (nowMs - lastT) / 1000); lastT = nowMs;
  if (M.state === 'idle') { if (A.isBuilt && !A.isBuilt()) return; M.wait -= dt; if (M.wait <= 0) load(); return; }
  if (M.state !== 'live') return;
  driveEvents(dt);
  fallEvents(dt);
  if (M.possess) tickPossess(dt);
  var P = S.p;
  M.near = null; var nearD = 1e9;
  for (var i = 0; i < M.citizens.length; i++) {
    var c = M.citizens[i];
    // movement: approach, return, idle
    if (c.moveTarget) {
      var dx = c.moveTarget.x - c.pos.x, dz = c.moveTarget.z - c.pos.z, d = Math.hypot(dx, dz);
      if (d > (c.moveTarget.stop || 0) + 0.05) {
        var step = Math.min(d, 3.2 * dt);
        c.pos.x += dx / d * step; c.pos.z += dz / d * step;
        c.faceTarget = { x: c.moveTarget.x, z: c.moveTarget.z };
      } else c.moveTarg