<!-- SOLID CITY · part 5 -->
ame };
}
function placeOf(v) { const id = ALIAS[v] || v; return PLACES[id] ? id : null; }
const faceYaw = (fx, fz, tx, tz) => Math.atan2(-(tx - fx), -(tz - fz));   // the city camera's yaw convention: forward = (-sin yaw, -cos yaw)
const nearest = (x, z) => { let b = null, bd = 1e9; for (const id in PLACES) { const p = PLACES[id], d = Math.hypot(p[0] - x, p[1] - z); if (d < bd) { bd = d; b = id; } } return { id: b, d: Math.round(bd) }; };

/* ---------- limits ---------- */
function limit(id, kind, n, ms) { const k = id + ':' + kind, now = Date.now(), a = (P.hits.get(k) || []).filter(t => now - t < ms); if (a.length >= n) { P.hits.set(k, a); return Math.ceil((ms - (now - a[0])) / 1000); } a.push(now); P.hits.set(k, a); return 0; }
setInterval(() => { const now = Date.now(); for (const [k, a] of P.hits) if (!a.some(t => now - t < 3600e3)) P.hits.delete(k); for (const [k, a] of P.bad) if (!a.some(t => now - t < 600e3)) P.bad.delete(k); for (const [k, v] of P.presence) if (now - v.t > PRESENCE_MS) P.presence.delete(k); }, 60e3).unref();

/* ---------- words ---------- */
const SECRET = [/\bsk-[A-Za-z0-9_-]{12,}/, /AIza[0-9A-Za-z_-]{20,}/, /\b[A-Za-z0-9_-]{36,}\b/, /\b[a-f0-9]{32,}\b/i, /\b(password|passwd|api[ _-]?key|secret|token)\s*(is|[:=])/i];
function words(raw, max) {
  let t = String(raw || '').replace(/<[^>]*>/g, ' ').replace(/[\u0000-\u001f\u007f\u200b-\u200f\u202a-\u202e]+/g, ' ').replace(/\s+/g, ' ').trim();
  if (!t) return { err: 'Nothing to say.' };
  if (/https?:|www\.|\.(com|net|org|xyz|io)\b/i.test(t)) return { err: 'No links in speech bubbles.' };
  if (SECRET.some(r => r.test(t))) return { err: 'That looks like a secret; not said.' };
  t = t.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email removed]');
  if (t.length > max) t = t.slice(0, max - 1).replace(/\s+\S*$/, '') + '\u2026';
  return { text: t };
}

/* ---------- actions ---------- */
async function act(p, q) {   // q: plain object of query params. -> { msg, err?, code? }
  const s = get(p), now = Date.now(), one = k => (q[k] == null ? '' : String(q[k]));
  const kinds = ['go', 'look', 'do', 'drive', 'say', 'talk'].filter(k => one(k));
  if (!kinds.length) { s.last = now; save(); return { msg: s.flight ? 'Still flying to ' + s.flight.name + '.' : 'You look around.' , view: true }; }
  if (kinds.length > 1) return { err: 'One action per link, please (' + kinds.join(', ') + ').', code: 400 };
  const w = limit(p.id, 'act', 30, 60e3); if (w) return { err: 'Easy: 30 actions per minute. Try again in ' + w + ' s.', code: 429 };
  const k = kinds[0], v = one(k).slice(0, 300), lv = v.toLowerCase();
  const flying = !!s.flight;
  const done = (msg, mut) => { s.last = now; s.seq++; if (mut) s.action = mut; save(); return { msg }; };
  if (k === 'look') {
    const L = { left: Math.PI / 4, right: -Math.PI / 4, back: Math.PI };
    if (L[lv] != null) { s.yaw = ((s.yaw + L[lv]) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI); return done('You turn ' + (lv === 'back' ? 'around' : lv) + '.', 'looking around'); }
    if (lv === 'up') { s.pitch = Math.max(-0.5, s.pitch - 0.3); return done('You look up.', 'looking up'); }
    if (lv === 'down') { s.pitch = Math.min(0.7, s.pitch + 0.3); return done('You look down.', 'looking down'); }
    if (lv === 'level' || lv === 'ahead') { s.pitch = 0.05; return done('You look straight ahead.', 'looking ahead'); }
    return { err: 'look must be left, right, back, up, down or level.', code: 400 };
  }
  if (k === 'go') {
    const id = placeOf(lv); if (!id) return { err: 'Unknown place "' + v.slice(0, 40) + '". Use one of the walk links.', code: 400 };
    if (flying) return { err: 'You are flying (AI DRIVE to ' + s.flight.name + '). Refresh until you land, then walk.', code: 409 };
    const pl = PLACES[id], d = Math.round(Math.hypot(pl[2] - s.x, pl[3] - s.z));
    const wasShip = s.mode === 'ship'; s.mode = 'walk'; s.x = pl[2]; s.z = pl[3]; s.yaw = faceYaw(pl[2], pl[3], pl[0], pl[1]); s.pitch = 0.05; s.place = id;
    s.walk = { t0: now, dur: Math.min(90, Math.round(d / WALK_V)) };
    return done((wasShip ? 'You step out of your ship and walk ' : 'You walk ') + d + ' m to ' + pl[4] + '.', 'walked to ' + pl[4]);
  }
  if (k === 'do') {
    if (lv === 'forward') {
      if (flying) return { err: 'You are flying; refresh until you land.', code: 409 };
      if (s.mode === 'ship') return { err: 'You are in your ship. Exit first (do=exit) or AI DRIVE somewhere.', code: 409 };
      s.prev = { x: s.x, z: s.z }; s.x -= Math.sin(s.yaw) * 8; s.z -= Math.cos(s.yaw) * 8; s.place = nearest(s.x, s.z).id;
      return done('You step forward 8 m.', 'walking');
    }
    if (lv === 'board') { if (flying || s.mode === 'ship') return { err: 'You are already in your ship.', code: 409 }; s.mode = 'ship'; return done('You board your ship. AI DRIVE links fly it anywhere; do=exit steps out.', 'boarded his ship'); }
    if (lv === 'exit') { if (flying) return { err: 'You cannot exit mid-flight.