<!-- SOLID CITY · part 104 -->
e":"sentinelSignal","action":{"type":"requestAssistance","need":"rescue"}}}},"heardOnly":[{"at":0,"look":true,"line":"A crash. Note the time. Where, exactly?"}],"funnyMoment":[{"at":0,"look":true,"line":"Noted. No flaw found in the machine. That was entirely the operator."}],"nearMiss":[{"at":0,"look":true,"line":"Noted, with the hour. A pattern is forming on this road."}]}},"online":false};
if (!ROSTER) return;

var M = { state: 'idle', wait: 5.0, citizens: [], byId: {}, me: null, voice: true, voiceMine: false, interacted: false,
          panelFor: null, greeted: {}, spinAcc: 0, spinT: 0, prevDrive: null };
var $ = function (sel) { return document.querySelector(sel); };

function surfY(x, z) {
  var s = -1000;
  try { s = A.standingAt(x, z, null); } catch (e) {}
  if (s == null || s <= -999) s = A.groundAt(x, z);
  return s;
}
function api(path, opts) {
  return fetch(path, Object.assign({ credentials: 'same-origin', headers: { 'Content-Type': 'application/json' } }, opts || {}))
    .then(function (r) { return r.json().catch(function () { return { ok: false, status: r.status }; }); })
    .catch(function () { return { ok: false, error: 'network' }; });
}

/* ---------- nameplates ---------- */
function plateTexture(c) {
  var cv = document.createElement('canvas'); cv.width = 512; cv.height = 128;
  var g = cv.getContext('2d');
  g.fillStyle = 'rgba(10,14,22,0.72)';
  g.beginPath(); if (g.roundRect) g.roundRect(56, 18, 400, 92, 22); else g.rect(56, 18, 400, 92); g.fill();
  g.strokeStyle = '#d8a833'; g.lineWidth = 3; g.stroke();
  g.fillStyle = '#e8edf5'; g.font = '600 44px system-ui, sans-serif'; g.textAlign = 'center';
  g.fillText(c.name, 256, 66);
  g.fillStyle = '#9aa4b5'; g.font = '28px system-ui, sans-serif';
  var mark = !c.op ? '' : c.op.type === 'human' ? '◆ ' + (c.op.name || 'inhabited') : c.op.type === 'unoccupied' ? '○ resting' : '● AI';
  g.fillText(mark, 256, 100);
  var tex = new T.CanvasTexture(cv); tex.anisotropy = 4; return tex;
}
function drawPlate(c) {
  var old = c.plate.material.map;
  c.plate.material.map = plateTexture(c); c.plate.material.needsUpdate = true;
  if (old) old.dispose();
}

/* ---------- speech: bubbles + voice ----------
 * The voice ladder, in order: the citizen's own pre-rendered
 * Kokoro recording (instant, works with the whole API dark) →
 * live Kokoro synthesis of a generated line → the device voice →
 * the bubble alone. The body never waits for any of them. */
var VOICE_FILES = null, liveAudio = null;
function playWav(url) {
  try {
    if (window.speechSynthesis) speechSynthesis.cancel();
    if (liveAudio) { try { liveAudio.pause(); } catch (e) {} liveAudio = null; }
    liveAudio = new Audio(url);
    var p = liveAudio.play(); if (p && p.catch) p.catch(function () {});
  } catch (e) {}
}
function deviceSpeak(line) {
  if (!window.speechSynthesis) return;
  try {
    speechSynthesis.cancel();
    var u = new SpeechSynthesisUtterance(line.replace(/[—–]/g, ', '));
    u.rate = 1.02; speechSynthesis.speak(u);
  } catch (e) {}
}
function speak(c, line, secs) {
  c.bubbleText = line; c.bubbleUntil = performance.now() + (secs || 4.2) * 1000;
  if (panelCitizen() === c) addLine(c.name, line, 'them');
  if (!M.voice || !M.interacted) return;
  var f = VOICE_FILES && VOICE_FILES[c.id] && VOICE_FILES[c.id][line];
  if (f) { playWav('/city/voices/' + c.id + '/' + f); return; }
  if (REACT.online) {
    fetch('/api/citizens/' + c.id + '/tts', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: line }) })
      .then(function (r) { if (!r.ok) throw new Error('tts'); return r.blob(); })
      .then(function (b) { playWav(URL.createObjectURL(b)); })
      .catch(function () { deviceSpeak(line); });
    return;
  }
  deviceSpeak(line);
}

/* ---------- Living Reactions: the staged runner (client port) ---------- */
function bandOf(speed) { return speed >= 16 ? 'hard' : speed >= 9 ? 'mid' : 'soft'; }
function perceive(c, ev) {
  var dx = ev.x - c.pos.x, dz = ev.z - c.pos.z, d = Math.hypot(dx, dz);
  var seen = d <= 60, heard = !seen && ev.type === 'vehicleCrash' && d <= 140;
  return { seen: seen, heard: heard, distance: d };
}
function describe(c, ev, closing, meaning) {
  var where = ev.label ? ' at ' + ev.label : '';
  var s;
  if (ev.type === 'vehicleCrash') {
    s = 'saw a vehicle crash' + where + ' (' + Math.round(ev.speed || 0) + ' m/s)';
    if (closing && closing.action && closing.action.type === 'requestAssistance') s += '; the driver was trapped, and rescue assistance was requested';
    else if (closing) s += '; the driver got up, and it ended in care, not rescue';
  } else if (ev.type === 'fall') {
    s = 'saw someone fall hard' + where + ' (impact ' + Math.round(ev.speed || 0) + ' m/s)';
    if (closing && closing.action && closing.action.type === 'requestAssistance') s += '; they could not rise, and rescue assistance was requested';
    else if (closin