<!-- SOLID CITY · part 94 -->
ns/' + 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 (closing) s += '; they rose again, shaken but whole';
  } else if (ev.type === 'funnyMoment') s = 'saw something funny' + where + ' and laughed';
  else if (ev.type === 'nearMiss') s = 'saw a near miss' + where + ' and called a warning';
  else s = 'witnessed ' + ev.type + where;
  return s + ' — meaning, in ' + c.id + '’s reading: ' + (meaning || '—');
}
function startReaction(c, ev) {
  if (c.possessed || c.stopped) return false;
  var script = (REACT.scripts || {})[c.id]; if (!script) return false;
  var p = perceive(c, ev); if (!p.seen && !p.heard) return false;
  var nowMs = performance.now();
  if (nowMs - (c.lastReactAt || -1e9) < 4000) return false;
  var kind = ev.type === 'fall' ? 'vehicleCrash' : ev.type; // a fall is answered with a crash's concern, under its own name
  var meaning = ((REACT.interpretations || {})[c.id] || {})[kind] || null;
  var beats = null, resolve = null;
  if (!p.seen) beats = script.heardOnly;
  else if (kind === 'vehicleCrash') { var sc = script.vehicleCrash && script.vehicleCrash[bandOf(ev.speed || 0)]; if (!sc) return false; beats = sc; resolve = script.vehicleCrash.resolve; }
  else beats = script[ev.type];
  if (!beats || !beats.length) return false;
  c.lastReactAt = nowMs;
  c.session = { ev: ev, beats: beats, resolve: resolve, idx: 0, t0: nowMs, meaning: meaning, outcome: null, done: false };
  // THE BODY SPEAKS FIRST: before any beat, before any word — the
  // head snaps toward it and the shoulders tense. Then the others
  // nearby feel it too (the social layer below).
  if (p.seen && (ev.type === 'vehicleCrash' || ev.type === 'fall')) {
    c.faceTarget = { x: ev.x, z: ev.z };
    if (c.motion) c.motion.play('startle');
    broadcastAttention(c, ev);
  }
  return true;
}
function tickSession(c, nowMs) {
  var ss = c.session; if (!ss) return;
  var t = nowMs - ss.t0;
  while (ss.idx < ss.beats.length && ss.beats[ss.idx].at <= t) doBeat(c, ss.beats[ss.idx++], ss);
  if (ss.idx >= ss.beats.length) {
    if (ss.resolve && !ss.outcome) return; // the world has not spoken yet
    var closing = null;
    if (ss.resolve && ss.outcome) { closing = ss.resolve[ss.outcome]; if (closing) doBeat(c, closing, ss); }
    finishSession(c, closing);
  }
}
function doBeat(c, b, ss) {
  if (b.look) c.faceTarget = { x: ss.ev.x, z: ss.ev.z };
  // Speech comes SECOND (the King's law of Living Moments): a line
  // waits half a second behind the body that speaks first.
  if (b.line) (c.pendingLines ??= []).push({ line: b.line, due: performance.now() + 550 });
  if (b.gesture && c.motion) c.motion.play(b.gesture);
  if (b.action && b.action.type === 'approach') c.moveTarget = { x: ss.ev.x, z: ss.ev.z, stop: 2.2 };
  if (b.action && b.action.type === 'requestAssistance') {
    A.help('🆘 ' + c.name + ' requests rescue assistance — ' + (ss.ev.label || 'nearby') + '!', 7);
    c.moveTarget = { x: ss.ev.x, z: ss.ev.z, stop: 2.2 };
  }
}
function finishSession(c, closing) {
  var ss = c.session; c.session = null;
  c.moveTarget = { x: c.rec.post.x, z: c.rec.post.z, stop: 0 };
  if (REACT.online) api('/api/citizens/' + c.id + '/witness', { method: 'POST', body: JSON.stringify({ text: describe(c, ss.ev, closing, ss.meaning) }) });
}

/* ---------- THE SOCIAL LAYER: citizens answer each other's bodies ----------
 * No speech, no API — movement answering movement. A startle turns
 * every nearby head; a rescue reach draws a second pair of hands;
 * a wel