<!-- SOLID CITY · part 13 -->
quireLogin, rateLimit(60 * 60 * 1000, 60), async (req, res) => {
    const b = req.body || {}, id = String(b.id || ''), c = Object.prototype.hasOwnProperty.call(CZ, id) ? CZ[id] : null;
    if (!c) return res.status(404).json({ error: 'no such citizen' });
    const msg = oneLine(b.message, 300);
    if (!msg) return res.status(400).json({ error: 'empty message' });
    const hist = (Array.isArray(b.history) ? b.history.slice(-6) : [])
      .map(h => (h && h.role === 'user' ? 'Visitor' : c[0]) + ': ' + oneLine(h && h.text, 140)).join('\n').slice(0, 900);
    const where = oneLine(b.where, 60), doing = oneLine(b.doing, 60);
    // Solid City University office hours: the lesson the student is reading (same route, same limits)
    const L = b.lesson && typeof b.lesson === 'object' ? { campus: oneLine(b.lesson.campus, 60), course: oneLine(b.lesson.course, 90), lesson: oneLine(b.lesson.lesson, 100), notes: oneLine(b.lesson.intro, 700) } : null;
    const lessonCtx = L && L.course ? ' You are holding office hours at Solid City University (' + L.campus + '). The student is studying the course "' + L.course + '", lesson "' + L.lesson + '".' +
      (L.notes ? ' Lesson notes: ' + L.notes : '') + ' Answer as their professor: accurate, clear and encouraging, 2-4 sentences, in character; if you are unsure, say so.' +
      ' For medical, legal or financial questions give general education only and suggest a qualified professional.' : '';
    const key = id + '|' + (L && L.course ? L.course + '/' + L.lesson + '|' : '') + msg.toLowerCase().replace(/[^a-z0-9 ]+/g, '').trim();
    const hit = !hist && czCache.get(key);
    if (hit && Date.now() - hit.t < 3600000) return res.json({ reply: hit.reply, cached: true });
    if (typeof deps.brain !== 'function') return res.json({ fallback: true, reason: 'offline' });
    const uk = String(req.user.id), t = Date.now(), ub = (czBurst.get(uk) || []).filter(x => t - x < 60000);
    if (ub.length >= 6) return res.status(429).json({ fallback: true, reason: 'slow down' });
    if (!czBudget()) return res.json({ fallback: true, reason: 'busy' });
    ub.push(t); czBurst.set(uk, ub);
    const who = oneLine(req.user.display_name || req.user.username || 'a visitor', 40).replace(/[\[\]]/g, '');
    const text = czPrompt(c, (where ? ' Right now you are at ' + where + (doing ? ', ' + doing : '') + '.' : '') + lessonCtx) +
      '\nThe visitor is ' + who + '.' + (hist ? '\nConversation so far:\n' + hist : '') + '\nVisitor: ' + msg + '\n' + c[0] + ':';
    czFlight++;
    try {
      const brain = await deps.brain({ contents: [{ role: 'user', parts: [{ text }] }], generationConfig: { temperature: 0.9, maxOutputTokens: 1500 } }, { timeoutMs: 12000 });
      if (brain.throttled) return res.json({ fallback: true, reason: 'busy' });
      const reply = czText(brain);
      if (!reply) return res.json({ fallback: true, reason: 'quiet' });
      if (!hist) { czCache.set(key, { t: Date.now(), reply }); if (czCache.size > 500) czCache.delete(czCache.keys().next().value); }
      res.json({ reply });
    } catch (e) { console.error('[city] citizen:', e.message); res.json({ fallback: true, reason: 'error' }); }
    finally { czFlight--; }
  });

  const czChatter = []; let czChatT = 0;
  const CZ_TOPICS = ['the weather over the island', 'traffic at the spaceport', 'a new Forge build', 'the song on Studio Radio', 'the sunset over the volcano', 'a parcel delivery', 'the plaza crowd', 'the night sky'];
  app.get('/api/city/citizens/chatter', (req, res) => {
    res.set('Cache-Control', 'no-store');
    res.json({ lines: czChatter.slice(-6) });
    if (!req.user || typeof deps.brain !== 'function' || Date.now() - czChatT < 600000) return;
    czChatT = Date.now();
    if (!czBudget()) return;
    const ids = Object.keys(CZ).filter(k => k !== 'sable'), id = ids[Math.floor(Math.random() * ids.length)], c = CZ[id];
    const topic = CZ_TOPICS[Math.floor(Math.random() * CZ_TOPICS.length)];
    czFlight++;
    deps.brain({ contents: [{ role: 'user', parts: [{ text: czPrompt(c) + '\nSay ONE short line (max 18 words) over the city radio about ' + topic + '. Just the line.' }] }], generationConfig: { temperature: 1.0, maxOutputTokens: 1200 } }, { timeoutMs: 9000 })
      .then(brain => { const t = czText(brain).replace(/^"|"$/g, '').slice(0, 160); if (t) { czChatter.push({ id, text: t, at: Date.now() }); if (czChatter.length > 12) czChatter.shift(); } })
      .catch(() => {}).then(() => { czFlight--; });
  });

  /* ---------------- ✧ PROVOLTA's prophecies (the King: 'Provolta alive') ----------------
     GET /api/city/provolta/prophecies -> { pool:[{text, ai}], period, now }. Public, read-only, the same for everyone
     (clients pick the line by server time, so every visitor sees the same prophecy at the same moment).
     At most ONE new AI line per PV_EVERY site-wide, through czBudget (CITIZEN_AI_HOURLY/DAILY, 4 in flight) and the
     SAME brain as the citizens; fallback set