<!-- SOLID CITY · part 1696 -->
th.min(bd, Math.hypot(x - RADIO_MASTS[mi].x, z - RADIO_MASTS[mi].z, Math.max(0, y - CITY_Y - 90)));
    CityPlayer.setBoost(1 + 0.3 * smooth((260 - bd) / 200));
  }
  if (helpTimer > 0) { helpTimer -= 0.12; if (helpTimer <= 0) $('sc-help').style.opacity = 0; }
  if (PERF) { var ts = terrainStats(); $('sc-perf').hidden = false; $('sc-perf').textContent = fpsVal.toFixed(0) + ' fps  ' + renderer.info.render.calls + ' calls\n' + Math.round(renderer.info.render.triangles / 1000) + 'k tris  ' + ts.chunks + ' chunks  q' + ts.queue; }
}

/* ---------------- SABLE talk (login-gated, rate-limited server-side) ---------------- */
var me = null, talkHist = [], talkBusy = false;
var CITY_SEED = { role: 'sable', text: 'Welcome to Solid City. I stand in the Forum Plaza, the heart of the capital, on the west coast of the main volcanic island. The Music Studio Tower rises to the east, and its gate opens Solid Sound at /ss. Ships launch from the Orbital Spaceport to the south-west: board one, press the big red LAUNCH button, and fly as far as the ocean goes, past the volcanoes, even above the atmosphere. Return to City autopilot brings you home. The Warp Speed arcade run is through the spaceport gate, and the Forum gate here leads to the boards.' };
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) { var d = document.createElement('div'); d.className = cls; d.textContent = text; $('t-log').appendChild(d); $('t-log').scrollTop = 1e9; }
function openTalk() {
  $('sp-talk').hidden = true; $('sc-talk').hidden = false;
  getMe().then(function (j) {
    var ok = j && j.loggedIn; $('t-login').hidden = ok; $('t-form').style.display = ok ? '' : 'none';
    if (!$('t-log').children.length) logLine('sys', ok ? 'SABLE turns to you. \u201cHi, ' + (j.name || 'there') + ' \u2014 what can I help with?\u201d' : 'SABLE smiles. Log in to epistles and she\u2019ll talk with you.');
    if (ok && !IS_TOUCH) $('t-msg').focus();
  });
}
$('t-close').addEventListener('click', function () { $('sc-talk').hidden = true; try { speechSynthesis.cancel(); } catch (e) {} });
$('t-form').addEventListener('submit', function (e) { e.preventDefault(); sendTalk($('t-msg').value); });
function speak(text, pitch) {
  try {
    if (!window.speechSynthesis) return; speechSynthesis.cancel();
    var u = new SpeechSynthesisUtterance(text.replace(/[*_#`>\[\]]/g, '').slice(0, 600)), vs = speechSynthesis.getVoices();
    var v = vs.filter(function (v) { return /en/i.test(v.lang) && /samantha|female|zira|aria|jenny|serena|karen|moira|victoria|google uk english female/i.test(v.name); })[0] || vs.filter(function (v) { return /^en/i.test(v.lang); })[0];
    if (v) u.voice = v; u.rate = 1.0; u.pitch = pitch || 0.95;
    if (window.CityPlayer) { CityPlayer.duck(true); u.onend = u.onerror = function () { CityPlayer.duck(false); }; }
    speechSynthesis.speak(u);
  } catch (e) {}
}
function sendTalk(text) {
  text = String(text || '').trim(); if (!text || talkBusy) return;
  $('t-msg').value = ''; logLine('me', text); talkBusy = true; $('t-send').disabled = true;
  var wait = document.createElement('div'); wait.className = 'sys'; wait.textContent = 'SABLE is thinking…'; $('t-log').appendChild(wait);
  function callSable() { return fetch('/api/city/sable', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': (me && me.csrf) || '' }, body: JSON.stringify({ message: text, history: [CITY_SEED].concat(talkHist.slice(-8)) }) }); }
  callSable().then(function (r) { return r.status === 502 || r.status === 503 ? new Promise(function (ok) { setTimeout(ok, 1500); }).then(callSable) : r; })
    .then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) { return { s: r.status, j: j }; }); })
    .then(function (o) {
      wait.remove();
      if (o.s === 401) { me = null; logLine('sys', 'Log in to talk to SABLE.'); $('t-login').hidden = false; return; }
      if (o.s === 429) { logLine('sys', 'SABLE: \u201cI\u2019m getting a lot of messages at once. Give me a minute and ask again.\u201d'); return; }
      var reply = o.j && (o.j.sable || o.j.reply);
      if (!reply) { logLine('sys', 'SABLE didn\u2019t answer (' + ((o.j && o.j.error) || o.s) + ').'); return; }
      talkHist.push({ role: 'user', text: text }, { role: 'sable', text: reply });
      logLine('her', reply); speak(reply);
    })
    .catch(function () { wait.remove(); logLine('sys', 'Connection lost.'); })
    .then(function () { talkBusy = false; $('t-send').disabled = false; });
}

/* ---------------- SOUL PRINT talk (Soul Print Tower door; login-gated + rate-limited server-side) ----------------
   Uses /api/city/soulprint (proxies to the existing /api/soulprint/chat relay). 