<!-- SOLID CITY · part 1399 -->
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, the Double Strike hangar stands south-west past the spaceport \u2014 walk through its gate to fly with the AI \u2014 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 log = $('t-log'); var le = log.lastElementChild; if (cls === 'sys' && le && le.className === 'sys' && le.textContent === text) return; var d = document.createElement('div'); d.className = cls; d.textContent = text; log.appendChild(d); 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;
  micLockStart();
  getMe().then(function (j) {
    var ok = j && j.loggedIn; $('t-login').hidden = ok; if (!micLock.on) $('t-form').style.display = ok ? '' : 'none'; if (!ok) micLockStop(true);
    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 && !micLock.on) $('t-msg').focus();
  });
}
$('t-close').addEventListener('click', function () { $('sc-talk').hidden = true; });
$('t-gear').addEventListener('click', function () { var s = $('t-settings'); s.hidden = !s.hidden; });
$('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.'); })
    .th