<!-- SOLID CITY · part 119 -->
h.sin(it) * 0.8 : 0;
  c.g.position.set(c.x, c.y + bob, c.z); c.g.rotation.set(0, c.face + look * 0.3, sway * 0.4);
}

/* ---------------- LOD: only the nearest few are drawn and fully simulated ---------------- */
var lodT = 0, follow = null;
function focusPt() { if (follow) return { x: follow.x, z: follow.z }; if (S.mode === 'walk') return { x: S.p.x, z: S.p.z }; return { x: S.ship.x, z: S.ship.z }; }
function relod() {
  var f = focusPt(), list = [];
  all.forEach(function (c) { c.dist = Math.hypot(c.x - f.x, c.z - f.z); var lim = c.st === 'fly' ? FLY_D : NEAR; c.cand = c.dist < lim || c === follow; if (c.cand) list.push(c); });
  list.sort(function (a, b) { return (a === follow ? -1 : a.dist) - (b === follow ? -1 : b.dist); });
  active = list.slice(0, CAP);
  all.forEach(function (c) { c.on = active.indexOf(c) >= 0; });
}
function place(c) {
  var flying = c.st === 'fly';
  c.g.visible = c.on && !flying && !c.hideSelf;   // hideSelf: an AI pilot's own body in his own view (pilots.js)
  if (c.ship) { c.ship.visible = c.on && flying && !c.hideSelf; if (flying && c.on) { c.ship.position.set(c.x, c.y + 1, c.z); c.ship.rotation.set(c.fph === 'cruise' ? 0 : -0.15, c.hdg, 0); c.ship.userData.flame.scale.set(1, 0.7 + Math.random() * 0.5, 1); } }
  if (c.g.visible) { animate(c, 1 / 60); c.tag.visible = c.dist < TAG_D || c === follow; c.emb3.visible = c.dist < 35; if (c.bub) c.bub.visible = c.bubT > S.t; }
}

/* ---------------- speech bubbles (scripted greetings) ---------------- */
function bubble(c, text) {
  if (!c.g) return;
  if (!c.bub) { c.bubCv = canvas(512, 112); c.bubTex = new T.CanvasTexture(c.bubCv); c.bub = new T.Sprite(new T.SpriteMaterial({ map: c.bubTex, depthWrite: false, transparent: true })); c.bub.scale.set(3.4, 0.74, 1); c.bub.position.y = 3.45; c.bub.renderOrder = 4; c.g.add(c.bub); }
  var g = c.bubCv.getContext('2d'); g.clearRect(0, 0, 512, 112); g.fillStyle = 'rgba(246,233,196,.95)'; g.beginPath(); g.moveTo(16, 4); g.lineTo(496, 4); g.quadraticCurveTo(508, 4, 508, 16); g.lineTo(508, 84); g.quadraticCurveTo(508, 96, 496, 96); g.lineTo(272, 96); g.lineTo(256, 110); g.lineTo(240, 96); g.lineTo(16, 96); g.quadraticCurveTo(4, 96, 4, 84); g.lineTo(4, 16); g.quadraticCurveTo(4, 4, 16, 4); g.fill();
  g.fillStyle = '#1a0a10'; g.textAlign = 'center'; g.font = '600 26px system-ui, sans-serif';
  var words = String(text).split(' '), lines = [''], li = 0; words.forEach(function (w) { var t = (lines[li] ? lines[li] + ' ' : '') + w; if (g.measureText(t).width > 470 && lines[li]) { if (li === 1) { lines[1] += '…'; return; } lines[++li] = w; } else lines[li] = t; });
  lines = lines.slice(0, 2); lines.forEach(function (l, i) { g.fillText(l, 256, lines.length === 1 ? 60 : 40 + i * 34); });
  c.bubTex.needsUpdate = true; c.bubT = S.t + 5;
}

/* ---------------- talk panel ---------------- */
var panel = document.createElement('div'); panel.id = 'cz-talk'; panel.className = 'sc-talk cz-talk'; panel.hidden = true;
panel.innerHTML = '<div class="sc-talk-h"><img id="cz-pic" alt=""><b id="cz-name"></b><span id="cz-sub"></span><button type="button" id="cz-close" aria-label="Close">\u2715</button></div>' +
  '<div class="sc-talk-log" id="cz-log"></div><div class="cz-chips" id="cz-chips"></div>' +
  '<div class="sc-talk-login" id="cz-login" hidden>Scripted replies only. <a href="/login">Log in</a> for live AI conversation with the citizens.</div>' +
  '<form class="sc-talk-in" id="cz-form"><input id="cz-msg" maxlength="300" autocomplete="off"><button type="submit" id="cz-send">SEND</button></form>';
$('sc').appendChild(panel);
var me = null, cur = null, busy = false;
function getMe() { return me ? Promise.resolve(me) : fetch('/api/city/me', { credentials: 'same-origin', cache: 'no-store' }).then(function (r) { return r.json(); }).then(function (j) { me = j; return j; }).catch(function () { return { loggedIn: false }; }); }
function logLine(cls, text, tag) { var d = document.createElement('div'); d.className = cls; d.textContent = text; if (tag) { var s = document.createElement('small'); s.textContent = ' ' + tag; d.appendChild(s); } $('cz-log').appendChild(d); $('cz-log').scrollTop = 1e9; }
function open(id) {
  var c = BY[id]; if (!c) return false;
  if (c.ext === 'sable') { A.openTalk(); return true; }
  if (c.ext === 'soul') { if (c.g) bubble(c, pick(c.hi)); A.openSoul(); return true; }
  ['sc-talk', 'sp-talk'].forEach(function (p) { if ($(p)) $(p).hidden = true; });
  if (cur !== c) { cur = c; c.hist = c.hist || []; $('cz-log').textContent = ''; c.hist.slice(-6).forEach(function (h) { logLine(h.role === 'user' ? 'me' : 'her', h.text); }); }
  cur.talking = true; panel.style.setProperty('--cz', hex(c.accent));
  $('cz-pic').src = portraitURL(c); $('cz-name').textContent = c.name.toUpperCase(); $('cz-sub').textContent = c.role + ' \u00b7 ' + where(c);
  $('cz-msg').placeholder = 'Say something to ' + c.name + '\u2026';
  var chips = $('cz-chips'); chips.textContent = '';
  ['Wh