<!-- SOLID CITY · part 995 -->
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 marqueeLine(text) { var log = $('t-log'); var old = log.querySelector('.sc-marquee:not(.done)'); if (old) old.classList.add('done'); var d = document.createElement('div'); d.className = 'her sc-marquee'; var s = document.createElement('span'); s.textContent = text; d.appendChild(s); d.style.setProperty('--mqd', Math.max(10, Math.min(45, text.length / 4)) + 's'); d.addEventListener('click', function () { d.classList.toggle('paused'); }); log.appendChild(d); 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();
    if (ok) micLockStart();
  });
}
$('t-close').addEventListener('click', function () { $('sc-talk').hidden = true; });
$('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 = (window.CityVoiceSpeed ? CityVoiceSpeed.rate() : 1.0); u.pitch = pitch || 0.95;
    if (window.CityPlayer) CityPlayer.duck(true);
    window.__sableSpeaking = true;
    u.onend = u.onerror = function () { window.__sableSpeaking = false; if (window.CityPlayer) 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 });
      if (window.AudioDrive) AudioDrive.receive({ from: 'sable', text: reply, render: function () { logLine('her', reply); } }); else { 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). Sends the CSRF token,
   renders every reply with textContent (no HTML), keeps only this visit's short history. */
var soulHist = [], soulBusy = false;
function spLin