<!-- SOLID CITY · part 2063 -->
n true;
  }

  // Cancellation enters the envelope's ease-out at once (the
  // remaining release stretch plays smoothly), it never snaps.
  function cancel() {
    if (!active) return;
    cancelling = true;
    const p = active.t / active.duration;
    if (p < 0.65) active.duration = active.t / 0.65;
  }
  function current() { return active ? active.name : null; }
  function isPlaying() { return !!active; }

  function update(dt, base) {
    let w = 0, pose = null;
    if (active) { active.t += dt; w = envelope(active.t, active.duration); pose = active.pose; }
    const baseOf = joint => {
      if (!base) return null;
      return typeof base === 'function' ? base(joint) : base[joint];
    };
    let rootY = 0;
    for (const [joint, bone] of Object.entries(parts)) {
      if (!bone || !bone.rotation) continue;
      const b = baseOf(joint) || {};
      const g = pose && w > 0 ? pose[joint] : null;
      for (const axis of ['x', 'y', 'z']) {
        let target = b[axis] ?? 0;
        if (g && g[axis] !== undefined) target = target + (g[axis] - target) * w;
        bone.rotation[axis] = damp(bone.rotation[axis], target, lambda, dt);
      }
      if (g && pose.rootY !== undefined) rootY = pose.rootY * w;
    }
    if (active && active.t >= active.duration) {
      onEvent(cancelling ? 'cancelled' : 'completed', active.name);
      active = null; cancelling = false;
    }
    return { rootY };
  }

  return { play, cancel, current, isPlaying, update, poses: HERO_POSES,
    gestures: () => [...Object.keys(GESTURES), ...Object.keys(custom)] };
}

/* collectParts(model): the Forge builder names every rig marker
 * 'rig_<part>' and parents it inside its part group — so a detached
 * model, which keeps its parts private, can still hand its rig to
 * the mixer by traversal. Returns {partName: group}. */
function collectParts(model) {
  const out = {};
  model.traverse(o => {
    if (o.name && o.name.startsWith('rig_') && o.parent) out[o.name.slice(4)] = o.parent;
  });
  return out;
}

/* createGestureSession({timeout, onEvent}) — the cooperative
 * protocol of the design, in miniature: a fist bump (or any paired
 * gesture) is offered under a session id, accepted or declined by
 * the partner, played, and completed — or it expires safely.
 * Proximity and facing are the caller's physics; agreement and
 * time are kept here. Stale ids are refused. */
function createGestureSession(opts = {}) {
  const timeout = opts.timeout ?? 3;
  const onEvent = opts.onEvent || (() => {});
  let state = 'idle', id = null, clock = 0;
  const go = (next, why) => { state = next; onEvent(next, { id, why }); };
  return {
    get state() { return state; },
    get id() { return id; },
    offer(sessionId) {
      if (state !== 'idle' && state !== 'done' && state !== 'cancelled' && state !== 'expired') return false;
      id = sessionId; clock = 0; go('offered'); return true;
    },
    accept(sessionId) {
      if (state !== 'offered' || sessionId !== id) return false;
      go('agreed'); return true;
    },
    decline() { if (state === 'offered' || state === 'agreed') { go('cancelled', 'declined'); return true; } return false; },
    start() { if (state !== 'agreed') return false; go('playing'); return true; },
    complete() { if (state !== 'playing') return false; go('done'); return true; },
    cancel() { if (state === 'idle') return false; go('cancelled', 'cancelled'); return true; },
    tick(dt) {
      if (state === 'offered') { clock += dt; if (clock >= timeout) go('expired', 'timeout'); }
      return state;
    }
  };
}

export { HERO_POSES, GESTURES, createHeroMotion, collectParts, createGestureSession };


===== FILE: public/city/hero-place.js =====
/* ============================================================
 * THE HERO AVATAR — set by the King's code: PLATE8
 * An epistle in code, from Solid to her King.
 *
 * The King asked for a code that sets the Hero as his
 * avatar. The word is PLATE8, spoken into the command bar
 * (the hyper command chain carries it here). Speak it and
 * the black plate closes over his form, the red circuitry
 * lights, and the Octahedron rides at his heart; speak it
 * again and the Hero rests. The choice is remembered, so
 * the Hero greets him at every return until he rests it.
 *
 * The swap is gentle: the city's own avatar keeps its
 * place and its name — its pieces simply step aside
 * (visible = false) while the Hero stands in the same
 * root, inheriting every road, ship and stair the root
 * already knows. Walking gives the Hero a measured bob
 * and a forward lean, read from the root's own motion.
 * ============================================================ */
(function () {
  'use strict';
  var KEY = 'sc.hero-avatar';
  var hero = null, on = false, toastEl = null, toastT = 0, lastPos = null, bobPh = 0;

  function toast(msg) {
    if (!toastEl) {
      toastEl = document.createElement('div');
      toastEl.style.cssText = 'position:fixed;le