<!-- SOLID CITY · part 111 -->
et = null;
    }
    // the offered hand is taken: arriving at a reaching citizen, answer with a fist bump
    if (c.socialGoal) {
      var gd = Math.hypot(c.socialGoal.citizen.pos.x - c.pos.x, c.socialGoal.citizen.pos.z - c.pos.z);
      if (gd < 2.4 && !c.moveTarget) { socialPlay(c, 'fistBump'); c.socialGoal = null; }
      else if (!c.moveTarget) c.socialGoal = null;
    }
    // lines that waited behind the body are spoken now
    if (c.pendingLines) while (c.pendingLines.length && c.pendingLines[0].due <= nowMs) speak(c, c.pendingLines.shift().line, 4.5);
    var y = surfY(c.pos.x, c.pos.z);
    c.fig.position.set(c.pos.x, y, c.pos.z);
    c.solid.x0 = c.pos.x - 0.45; c.solid.x1 = c.pos.x + 0.45; c.solid.z0 = c.pos.z - 0.45; c.solid.z1 = c.pos.z + 0.45; c.solid.y0 = y; c.solid.y1 = y + 1.8;
    c.plate.position.set(c.pos.x, y + 2.35, c.pos.z);
    // facing: event first, then the player when near, then drift home
    var pd = Math.hypot(P.x - c.pos.x, P.z - c.pos.z);
    var want = c.possessed ? c.face
      : c.faceTarget ? Math.atan2(c.faceTarget.x - c.pos.x, c.faceTarget.z - c.pos.z)
      : pd < 12 ? Math.atan2(P.x - c.pos.x, P.z - c.pos.z) : c.face;
    var diff = want - c.face; while (diff > Math.PI) diff -= 2 * Math.PI; while (diff < -Math.PI) diff += 2 * Math.PI;
    c.face += diff * Math.min(1, dt * 4); c.fig.rotation.y = c.face;
    if (c.faceTarget && Math.abs(diff) < 0.05 && !c.session) c.faceTarget = null;
    // the mixer is the sole writer of the joints
    var sway = Math.sin(nowMs / 900 + c.phase) * 0.012;
    var res = c.motion.update(dt, { spine: { z: sway }, head: { z: sway * 0.7 } });
    if (res && res.rootY) c.fig.position.y += res.rootY;
    tickSession(c, nowMs);
    if (pd < nearD) { nearD = pd; M.near = pd < 7 ? c : null; }
    // greeting: the celebration of an arrival, once per citizen
    if (pd < 4.5 && !M.greeted[c.id] && !c.session) {
      M.greeted[c.id] = true;
      speak(c, c.rec.greeting || ('I am ' + c.name + '.'), 6);
      if (c.motion) c.motion.play(c.rec.signature);
    }
  }
  if (pillEl) {
    if (M.near && M.panelFor !== M.near.id) { pillEl.style.display = ''; pillEl.textContent = '💬 Talk to ' + M.near.name; }
    else pillEl.style.display = 'none';
  }
  tickBubbles();
}
setInterval(frame, 33);
})();


===== FILE: public/city/citizens.js =====
/* Solid City — 👥 AI CITIZENS (window.CityCitizens)
   22 city characters modelled on the King's chat bots. Everything runs client-side:
   schedules, walking (baked A* routes + live Solidivism collision, Forge builds included),
   small-ship flights between districts, idle animations, name tags, radar blips,
   radio chatter, and a talk panel. Replies: POST /api/city/citizen (login + CSRF,
   rate-limited, server-side Gemini — no key here); scripted lines when logged out,
   rate-limited or the AI is busy. Nothing is ever sent to the real bots.
   SABLE keeps her own NPC + talk panel; Soul Print's citizen opens the tower panel. */
(function () {
'use strict';
if (!window.SC || !SC.api) return;
var A = SC.api, T = A.T, S = A.S, Y0 = A.CITY_Y, MOBILE = A.MOBILE;
var CAP = MOBILE ? 6 : 12, NEAR = MOBILE ? 240 : 420, TAG_D = MOBILE ? 40 : 70, FLY_D = MOBILE ? 900 : 1600;
function $(id) { return document.getElementById(id); }
function esc(s) { return String(s).replace(/[&<>"']/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]; }); }
function pick(a) { return a[Math.floor(Math.random() * a.length)]; }
function rnd(a, b) { return a + Math.random() * (b - a); }

/* ---------------- places (absolute xz, verified clear of buildings) ---------------- */
var STOPS = {
  plaza_n: [0, -55, 'the Forum gate'], plaza_e: [38, 6, 'Forum Plaza'], plaza_w: [-42, 14, 'Forum Plaza'], plaza_s: [14, 46, 'Forum Plaza'],
  hall: [-70, -48, 'City Hall corner'], forge: [80, -110, 'the Forge yard'], studio_gate: [372, -140, 'the Music Studio gate'],
  studio_yard: [455, -112, 'the Studio lounge'], masts: [470, -215, 'the radio masts'], soul_yard: [172, 152, 'Soul Print Tower'],
  garden: [120, 60, 'the tower garden'], port_apron: [-345, 308, 'the Spaceport apron'], port_tower: [-418, 322, 'the control tower'],
  hangar: [-290, 384, 'the hangar'], warp: [-392, 398, 'the Warp Speed gate'], north: [10, -262, 'the north road'],
  west: [-250, -30, 'the west road'], east: [250, -60, 'the east quarter'], south: [60, 280, 'the south quarter'],
  overlook: [-120, -330, 'the north overlook'], archive: [-180, 110, 'the old archive'],
  // Provolta's Sanctuary (sanctuary-place.js, centre 735,165): her home at the centre + two short wander spots on the walkways
  sanctum: [735, 165, 'Provolta\u2019s Sanctuary'], sanctum_n: [735, 153, 'the sanctuary walkway'], sanctum_w: [723, 165, 'the sanctuary gate walk']
};
// walk routes between stops, baked offline from the Hyper Controller's own A* (string-pulled corners)
// re-baked 2026-10-09 with the Fallen Worlds Monument (176,1