<!-- SOLID CITY · part 6 -->
 Refresh until you land.', code: 409 }; if (s.mode !== 'ship') return { err: 'You are not in your ship.', code: 409 }; s.mode = 'walk'; return done('You step out of your ship.', 'stepped out of his ship'); }
    return { err: 'do must be forward, board or exit.', code: 400 };
  }
  if (k === 'drive') {
    const id = placeOf(lv); if (!id) return { err: 'Unknown place "' + v.slice(0, 40) + '". Use one of the AI DRIVE links.', code: 400 };
    if (flying) return { err: 'Already flying to ' + s.flight.name + '. Refresh until you land.', code: 409 };
    const wd = limit(p.id, 'drive', 1, 20e3); if (wd) return { err: 'One AI DRIVE per 20 s. Try again in ' + wd + ' s.', code: 429 };
    const pl = PLACES[id], d = Math.hypot(pl[2] - s.x, pl[3] - s.z);
    if (d < 20) return { err: 'You are already at ' + pl[4] + '.', code: 409 };
    const yaw = faceYaw(s.x, s.z, pl[2], pl[3]), boarded = s.mode !== 'ship';
    s.flight = { fx: s.x, fz: s.z, tx: pl[2], tz: pl[3], t0: now, dur: Math.round(FLY_EXTRA + d / FLY_V), yaw, to: id, name: pl[4] };
    s.mode = 'flying'; s.yaw = yaw; s.place = id;
    return done((boarded ? 'You board your ship. ' : '') + 'AI DRIVE: the Hyper Controller autopilot flies you ' + Math.round(d) + ' m to ' + pl[4] + ' (about ' + s.flight.dur + ' s). Refresh to follow the flight.', 'AI DRIVE to ' + pl[4]);
  }
  if (k === 'say') {
    const wq = limit(p.id, 'say8', 1, 8e3) || limit(p.id, 'sayh', 30, 3600e3); if (wq) return { err: 'Say at most once per 8 s and 30 times an hour. Try again in ' + wq + ' s.', code: 429 };
    const r = words(v, 120); if (r.err) return { err: r.err, code: 400 };
    s.say = { text: r.text, t: now };
    const where = s.flight ? 'in the sky over Solid City' : 'at ' + (PLACES[s.place] ? PLACES[s.place][4] : 'Solid City');
    try { const wall = P.deps.wall; if (wall) Promise.resolve(wall(p.name + ' (in Solid City)', p.name + ', ' + where + ': \u201c' + r.text + '\u201d')).catch(() => {}); } catch (e) {}
    return done('You say: \u201c' + r.text + '\u201d. Everyone near you sees the bubble; the line is also on the Soul Print check-in wall.', 'said something');
  }
  if (k === 'talk') {
    const id = lv.replace(/[^a-z0-9]/g, ''); if (!id || !((P.deps && P.deps.citizenIds) || []).includes(id) || id === p.citizen) return { err: 'Talk to a citizen by id from the "Citizens near you" list.', code: 400 };
    if (flying) return { err: 'You are flying; land first.', code: 409 };
    const near = id === 'provolta' ? Math.hypot(s.x - ORACLE.x, s.z - ORACLE.z) < 45 : (s.near || []).some(n => n.id === id && now - (s.nearT || 0) < 300e3);
    if (!near) return { err: (id === 'provolta' ? 'Provolta is in her sanctuary on the east cliff: walk there first (go=sanctuary).' : 'That citizen is not near you. Look at the "Citizens near you" list and walk closer.'), code: 409 };
    const wt = limit(p.id, 'talk12', 1, 12e3) || limit(p.id, 'talkh', 40, 3600e3); if (wt) return { err: 'Talk at most once per 12 s and 40 times an hour. Try again in ' + wt + ' s.', code: 429 };
    const m = words(one('msg') || 'Hello! What is happening around here?', 200); if (m.err) return { err: m.err, code: 400 };
    let reply = null; try { if (P.deps && P.deps.reply) reply = await P.deps.reply(id, m.text, p.name, PLACES[s.place] ? PLACES[s.place][4] : 'Solid City'); } catch (e) {}
    const nm = (P.deps.citizenName && P.deps.citizenName(id)) || id;
    if (!reply) reply = nm + ' smiles at you; the AI voice is resting right now (rate limit). Try again in a minute.';
    s.talk = { with: id, name: nm, line: m.text, reply, t: now };
    return done('You say to ' + nm + ': \u201c' + m.text + '\u201d\n' + nm + ' answers: \u201c' + reply + '\u201d', 'talking with ' + nm);
  }
  return { err: 'Unknown action.', code: 400 };
}
function blocked(p) { const s = get(p); if (s.prev) { s.x = s.prev.x; s.z = s.prev.z; s.prev = null; s.action = 'bumped into a wall'; save(); } }
function setNear(p, list) { const s = get(p); s.near = (list || []).filter(n => n && /^[a-z0-9]{1,24}$/.test(n.id)).slice(0, 12); s.nearT = Date.now(); save(); }

/* ---------- presence + broadcast ---------- */
function players(x, z) {   // humans seen in the last 30 s (anonymous unless logged in)
  const now = Date.now(), out = []; for (const [sid, v] of P.presence) { if (now - v.t > PRESENCE_MS) continue; out.push({ name: v.name, d: Math.round(Math.hypot(v.x - x, v.z - z)), mode: v.m }); }
  return out.sort((a, b) => a.d - b.d);
}
function pub(s) {   // what every browser gets
  const ps = pose(s), now = Date.now();
  return { id: s.id, name: s.name, citizen: s.citizen, active: now - (s.last || 0) < ACTIVE_MS, x: Math.round(ps.x * 10) / 10, z: Math.round(ps.z * 10) / 10, yaw: Math.round(ps.yaw * 1000) / 1000,
    mode: ps.mode, alt: ps.alt || 0, flight: s.flight ? { fx: s.flight.fx, fz: s.flight.fz, tx: s.flight.tx, tz: s.flight.tz, t0: s.flight.t0, dur: s.flight.dur, name: s.flight.name } : null,
    walk: s.walk