<!-- SOLID CITY · part 105 -->
g) 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 welcome is waved back; a laugh is contagious. Responses are
 * throttled per citizen and never interrupt a citizen's own moment. */
function broadcastAttention(from, ev) {
  for (var i = 0; i < M.citizens.length; i++) {
    var c = M.citizens[i];
    if (c === from || c.possessed || c.stopped || c.session) continue;
    if (Math.hypot(ev.x - c.pos.x, ev.z - c.pos.z) > 25) continue;
    c.faceTarget = { x: ev.x, z: ev.z };
    socialPlay(c, 'startle');
  }
}
function broadcastGesture(from, name) {
  for (var i = 0; i < M.citizens.length; i++) {
    var c = M.citizens[i];
    if (c === from || c.possessed || c.stopped) continue;
    var d = Math.hypot(from.pos.x - c.pos.x, from.pos.z - c.pos.z);
    if (name === 'rescueReach' && d <= 25 && !c.session) {
      // a hand is offered: go and take it
      c.faceTarget = { x: from.pos.x, z: from.pos.z };
      c.moveTarget = { x: from.pos.x, z: from.pos.z, stop: 1.3 };
      c.socialGoal = { citizen: from, kind: 'handshake' };
    } else if ((name === 'openWelcome' || name === 'welcomeBecoming' || name === 'bindingWord') && d <= 12 && !c.session) {
      c.faceTarget = { x: from.pos.x, z: from.pos.z };
      socialPlay(c, d < 3.5 ? 'fistBump' : 'wave');
    } else if ((name === 'laugh' || name === 'headShake') && d <= 20 && !c.session) {
      c.faceTarget = { x: from.pos.x, z: from.pos.z };
      socialPlay(c, name === 'laugh' ? 'laugh' : 'headShake');
    } else if (name === 'sentinelSignal' && d <= 25) {
      // Pô has seen something: heads turn her way, quietly
      c.fac