<!-- SOLUV · part 496 -->
{ type: 'status', state, text }); root.dispatchEvent({ type: 'change' }); }
  function normalize(m) {
    if (m.body !== undefined) {   // site lobby shape: { id, body, created_at (s), user_id, display_name, bot }
      const name = m.display_name || (m.bot ? 'PROVOLTA' : '?');
      return { id: Number(m.id), from: name, user: m.user_id != null ? 'uid:' + m.user_id : 'bot:' + name, king: /^the ?epistolary$/i.test(name), text: String(m.body || ''), t: (Number(m.created_at) || 0) * 1000, pending: !!m.pending, live: false, camId: null, lastFrame: null, lastClip: null };
    }
    return { id: Number(m.id), from: m.from || 'Guest', user: m.user || null, king: !!m.king, text: String(m.text || ''), t: Number(m.t) || 0, live: !!m.live, camId: m.camId || null, lastFrame: m.lastFrame || null, lastClip: m.lastClip || null };
  }
  function updateMsgs(list, del) { // lobby edits (e.g. a placeholder filled) and deletions, by id
    let ch = 0;
    for (const raw of list || []) { const m = normalize(raw); const old = S.msgs.find(x => x.id === m.id); if (old) { Object.assign(old, m, { _wrap: null }); ch++; } }
    for (const id of del || []) { const i = S.msgs.findIndex(x => x.id === Number(id)); if (i >= 0) { S.msgs.splice(i, 1); S.ids.delete(Number(id)); ch++; } }
    if (ch) relayout(); return ch;
  }
  // cursor = newest id seen from the room's own feed (not from my own post reply), so a post of mine that lands
  // after someone else's never makes the follow skip theirs: ten people posting at once all appear, in server order.
  function addMsgs(list, own = false) {
    let added = 0;
    if (!own) for (const raw of list) { const id = +raw.id; if (Number.isFinite(id)) S.cursor = Math.max(S.cursor, id); }
    for (const raw of list) { const m = normalize(raw); if (!Number.isFinite(m.id) || S.ids.has(m.id) || m.pending) continue; S.ids.add(m.id); S.msgs.push(m); S.lastId = Math.max(S.lastId, m.id); S.lastMsgT = Math.max(S.lastMsgT, m.t); added++; }
    if (added) { S.msgs.sort((a, b) => a.id - b.id); S.sessions.clear(); S.msgs.forEach(trackSession); relayout(); root.dispatchEvent({ type: 'messages', added, own, newest: S.msgs.slice(-added) }); }   // hosts (read-aloud) hear new lines
    return added;
  }
  const liveText = () => S.msgs.length + ' message' + (S.msgs.length === 1 ? '' : 's') + (source && source.type === 'lobby' ? ' \u00b7 follows the lobby every 3 s' : '');
  async function start() {
    S.started = true;
    await loadFont(fontUrl);
    if (source.type === 'static') {
      setStatus('loading', 'Loading test text');
      const r = await fetch(source.url);
      if (!r.ok) { const e = 'Test text failed: HTTP ' + r.status; S.errors.push(e); console.error('[soluv-wall] ' + e); setStatus('error', e); return; }
      const txt = await r.text(); S.bytes += txt.length;
      const msgs = JSON.parse(txt).msgs || [], rep = Math.max(1, source.repeat | 0 || 1), out = [];
      for (let k = 0; k < rep; k++) msgs.forEach((m, i) => out.push(Object.assign({}, m, { id: k * 100000 + (m.id || i + 1), t: Date.now() - (rep - k) * 60000 })));
      addMsgs(out);
      if (source.live) source.live.forEach(sp => api.setLive(sp, true));
      setStatus(S.msgs.length ? 'live' : 'empty', S.msgs.length ? liveText() : 'No messages yet');
      return;
    }
    if (source.type === 'lobby') return startLobby(source.base);
    const base = source.base;
    const fail = e => { S.errors.push(e); console.error('[soluv-wall] ' + e); setStatus('error', e + ' (retrying in 5 s)'); S.retry = setTimeout(get, 5000); };
    const get = async () => {
      setStatus('loading', 'Opening ' + route);
      let r;
      try { r = await fetch(base + '?since=' + S.lastId, { cache: 'no-store', credentials: 'same-origin' }); }
      catch (e) { return fail('Room unreachable: ' + (e.message || e)); }
      if (!r.ok) return fail('Room offline: HTTP ' + r.status + ' from ' + base);
      const txt = await r.text(); S.bytes += txt.length;
      let j; try { j = JSON.parse(txt); } catch (e) { return fail('Room sent bad JSON: ' + e.message); }
      addMsgs(j.msgs || []);
      setStatus(S.msgs.length ? 'live' : 'empty', S.msgs.length ? liveText() : 'No messages yet');
      openStream();
    };
    const openStream = () => {
      if (S.es || S.disposed) return;
      if (typeof EventSource === 'undefined') { setStatus(S.status.state, S.status.text + ' (no live stream in this browser: checking every 3 s)'); S.poll = setInterval(async () => { const r = await fetch(base + '?since=' + S.lastId, { cache: 'no-store' }); if (r.ok) addMsgs((await r.json()).msgs || []); else setStatus('error', 'Room offline: HTTP ' + r.status); }, 3000); return; }
      const es = new EventSource(base + '/events?since=' + S.lastId); S.es = es;
      es.onmessage = ev => { S.sseBytes += ev.data.length; let m; try { m = JSON.parse(ev.data); } catch (e) { setStatus('error', 'Bad live message: ' + e.message); return; } if (addMsgs([m])) setStatus('live',