openvibers/openvibe.live Commits
Arena: yapper profiles for every chatter (accounts, anons, relayed), XP/levels/titles/streaks, OpenCoins on level-up, yap cards
Changes
@@@ -96,3 +96,22 @@ Tests: `node test/arena.test.js` (roster, ratings, filter, quotes) · `node test- **Hear it in their voice** — every 🔊 in the Arena calls `GET /api/arena/voice/<username>?t=<line>` (`server/arena/voice.js`): the line is synthesized in the streamer's **equipped cosmetic chat voice** (else the same per-identity auto voice chat gives `user:<username>`), written once to `data/tts-cache/<sha(voice+text)>.<mp3|wav>` and served with `Cache-Control: max-age=604800` — a repeat click never re-synthesizes (server cache + browser cache). `announcer` reads headlines/lore in `arena_announcer_voice` (default `tts_default_voice`). Cloud engines are metered into `ai_usage` (kind `tts`); `arena_voice_daily_max` (2000) caps fresh syntheses per day and per-IP limits are 8/min anonymous, 30/min signed in. Browser speech is only the fallback when the engine is down.- **Portraits from their own frames** — `generateImage` now feeds up to 3 real frames (live thumbnail, AI-moment frames the vision job persisted, VOD thumbnails — frames whose description mentions a person first) to `images/edits`, asking for a character-select caricature that keeps their setup, gear, lighting, silhouette and the vibe of their scene; text-only generation is only the fallback for streamers with no frames yet. Custom stats and the persona shape the costume/props.++++## Yappers — profiles for everyone who chats (`server/arena/chatters.js`)++++One polymorphic key per chatter — `user:<id>`, `anon:<N>` (the site's IP-derived `anon<N>` identity) or `relay:<platform>:<name>` (chat relayed from Twitch/Kick/YouTube/RobotStreamer) — so anonymous viewers and relayed chatters get profiles too. No extra AI calls for XP: it falls out of the board pipeline.++++| XP | for |++|---|---|++| +3 / +6 | a chat line that lands as a moment on a subject (double when the subject is HOT) |++| +4 | the first time you touch a subject |++| +15 | starting a subject (`!topic` / the page) |++| +10 | being quoted in a subject's lore (name appears in the rewritten lore) |++| +1 | hyping a fighter on a subject |++| +5 × streak (≤ 7) | the first moment of each day; streak = consecutive days |++++Level = `1 + floor(√(XP / 25))` (the game's curve). Titles: Lurker → Chatter (2) → Yapper (3) → Instigator (5) → Ragebaiter (7) → Menace (9) → Main Character (12) → Community Consciousness (15) → Final Boss (20). **OpenVibe accounts bank `level × 10` OpenCoins on every level-up** through the network wallet (idempotent key `live:arena_yap_level:<user>:<level>`; unlinked accounts are simply skipped); anons/relay chatters keep the level and the title. Level-ups are announced in the room the moment happened in.++++**Yap card** — at level ≥ 3, at most once a day and only when ≥ 25 XP changed, the AI writes a card (title, blurb, catchphrase in their typing style, "known for", archetype) from the chat AI's existing profile of them (`chat_ai_summaries` user/anon/relay) + their best lines and subjects; ≤ 4 cards per housekeeping minute.++++Where it shows: the Yappers block on `/arena` (podium with level rings, streak flames, cards; weekly movers), `/arena/chatter/<key>` (card, XP bar, titles ladder, moments, subjects, what the chat AI has on them), and the chat context menu (click a name → yap level + title + bar, linking to the page). API: `GET /api/arena/yappers`, `GET /api/arena/chatter/:key`, `GET /api/arena/chatter/by-user/:username`, admin `POST /api/arena/chatter/:key/card`.@@@ -14457,3 +14457,56 @@ a.arena-tag:hover { color: var(--accent-light); }.arena-focus small { color: var(--text-secondary); margin-left: 6px; }.arena-focus-ctx { margin-top: 6px; font-size: 0.8rem; color: var(--text-muted); }.arena-focus .arena-note { margin: 6px 0 0; }++++/* ── Arena: yapper profiles ── */++.arena-ladder-yap { grid-column: 1 / -1; }++.arena-yap-podium { display: grid; grid-template-columns: 1.3fr 1fr 1fr; gap: 10px; margin-bottom: 10px; }++.arena-yap-card { display: grid; grid-template-columns: auto auto 1fr; gap: 10px; align-items: start; padding: 12px; border-radius: 14px; background: var(--bg-tertiary); border: 1px solid var(--border); position: relative; overflow: hidden; }++.arena-yap-card.is-top { border-color: color-mix(in srgb, var(--yc) 60%, var(--border)); background: linear-gradient(135deg, color-mix(in srgb, var(--yc) 14%, var(--bg-tertiary)), var(--bg-tertiary) 60%); }++.arena-yap-card.is-top::after { content: ''; position: absolute; inset: -40%; background: conic-gradient(from 0deg, transparent 0 70%, color-mix(in srgb, var(--yc) 25%, transparent) 80%, transparent 90%); animation: arena-spin 6s linear infinite; pointer-events: none; }++@keyframes arena-spin { to { transform: rotate(360deg); } }++.arena-yap-rank { font-weight: 900; font-size: 1.2rem; color: var(--text-muted); }++.arena-yap-card.is-top .arena-yap-rank { color: #f5c542; }++.arena-yap-ring { position: relative; display: inline-grid; place-items: center; flex-shrink: 0; }++.arena-yap-ring svg { position: absolute; inset: 0; transform: rotate(-90deg); }++.arena-yap-ring-bg { fill: none; stroke: var(--border); stroke-width: 3; }++.arena-yap-ring-fg { fill: none; stroke: var(--yc, #f97316); stroke-width: 3; stroke-linecap: round; transition: stroke-dashoffset 0.8s cubic-bezier(0.2, 0.8, 0.2, 1); filter: drop-shadow(0 0 4px color-mix(in srgb, var(--yc, #f97316) 70%, transparent)); }++.arena-yap-ring b { font-size: 0.9em; font-weight: 900; color: var(--text-primary); }++.arena-yap-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; position: relative; }++.arena-yap-name { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }++.arena-yap-name a { color: var(--text-primary); text-decoration: none; }++.arena-yap-title { font-weight: 800; color: var(--yc, #f97316); font-size: 0.92rem; }++.arena-yap-title small { color: var(--text-muted); font-weight: 400; font-size: 0.74rem; }++.arena-yap-blurb { margin: 0; font-size: 0.84rem; color: var(--text-secondary); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }++.arena-yap-line { display: block; font-size: 0.86rem; color: var(--text-secondary); quotes: "“" "”"; }++.arena-yap-stats { display: flex; gap: 10px; flex-wrap: wrap; font-size: 0.74rem; color: var(--text-muted); }++.arena-yap-stats b { color: var(--text-primary); }++.arena-yap-row { grid-template-columns: 28px auto 1fr auto; }++.arena-yap-week { margin-top: 10px; display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }++.arena-streak i { animation: arena-flame 0.9s ease-in-out infinite alternate; }++@keyframes arena-flame { from { transform: scale(1); } to { transform: scale(1.25) rotate(-6deg); } }++.arena-flash { position: absolute; right: 14px; top: 10px; padding: 4px 10px; border-radius: 999px; background: #f97316; color: #1a1006; font-weight: 800; font-size: 0.72rem; animation: arena-pop 2.2s ease forwards; z-index: 2; }++@keyframes arena-pop { 0% { transform: scale(0.6); opacity: 0; } 15% { transform: scale(1.1); opacity: 1; } 80% { opacity: 1; } 100% { transform: translateY(-10px); opacity: 0; } }++.arena-ladder { position: relative; }++.arena-yap-page { display: flex; flex-direction: column; gap: 14px; }++.arena-yap-hero { display: grid; grid-template-columns: auto 1fr; gap: 18px; align-items: center; padding: 18px; border-radius: 18px; background: linear-gradient(135deg, color-mix(in srgb, var(--yc) 16%, var(--bg-card)), var(--bg-card) 60%); border: 1px solid color-mix(in srgb, var(--yc) 45%, var(--border)); }++.arena-yap-hero-main { display: flex; flex-direction: column; gap: 8px; min-width: 0; }++.arena-yap-hero h1 { margin: 0; font-size: 1.6rem; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }++.arena-yap-hero-title { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }++.arena-yap-card-title { font-weight: 800; color: var(--yc); }++.arena-yap-cardbox { padding: 14px; border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); }++.arena-yap-cardbox h3 { margin: 0 0 8px; font-size: 0.98rem; display: flex; gap: 8px; align-items: center; }++.arena-yap-cardbox h3 small { color: var(--text-muted); font-weight: 400; font-size: 0.76rem; }++.arena-yap-cardbox p { margin: 0 0 8px; line-height: 1.55; }++.arena-mini-row.is-earned .arena-rank { color: #f5c542; }++@media (max-width: 900px) { .arena-yap-podium { grid-template-columns: 1fr; } .arena-yap-hero { grid-template-columns: 1fr; } }++++/* ── chat context menu: Arena yap level ── */++.ctx-yap { display: flex; flex-direction: column; gap: 4px; padding: 8px 12px; border-top: 1px solid var(--border); font-size: 0.78rem; }++.ctx-yap-link { display: flex; align-items: center; gap: 6px; color: var(--text-primary); text-decoration: none; }++.ctx-yap-lvl { font-weight: 900; font-size: 0.68rem; letter-spacing: 0.06em; padding: 1px 6px; border-radius: 5px; background: linear-gradient(90deg, #f97316, #fbbf24); color: #1a1006; }++.ctx-yap-title { font-weight: 700; }++.ctx-yap-streak { color: #fb923c; font-size: 0.72rem; }++.ctx-yap-bar { display: block; height: 4px; border-radius: 2px; background: var(--bg-tertiary); overflow: hidden; }++.ctx-yap-bar span { display: block; height: 100%; background: linear-gradient(90deg, #f97316, #fbbf24); }++.ctx-yap small { color: var(--text-muted); }@@@ -62,7 +62,7 @@</script><!-- Stylesheets -->-- <link rel="stylesheet" href="/css/style.css?v=148">++ <link rel="stylesheet" href="/css/style.css?v=149"><link rel="stylesheet" href="/css/chat-shared.css?v=1"><link rel="stylesheet" href="/css/broadcast.css?v=11"><link rel="stylesheet" href="/css/cosmetics.css?v=2">@@@ -5041,7 +5041,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<script src="/js/chat-shared.js?v=3" defer></script><script src="/js/emotes.js?v=8" defer></script><script src="/js/gif-picker.js?v=1" defer></script>--<script src="/js/chat.js?v=77" defer></script>++<script src="/js/chat.js?v=78" defer></script><script src="/js/chat-autocomplete.js?v=1" defer></script><script src="/js/channel-uploads.js?v=12" defer></script><script src="/js/controls.js?v=8" defer></script>@@@ -5107,7 +5107,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<script src="/js/staff-console.js?v=1" defer></script><script src="/js/dashboard-moderation.js?v=5" defer></script><script src="/js/ai-viewers.js?v=2" defer></script>--<script src="/js/arena.js?v=9" defer></script>++<script src="/js/arena.js?v=10" defer></script><script src="/js/powerchat.js?v=10" defer></script><script src="/js/pastes.js?v=15" defer></script><script src="/js/draggable-fab.js?v=2" defer></script>@@@ -180,6 +180,7 @@ async function loadArenaPage(segments = []) {if (first === 'beef' && second) return await _aRenderBeef(root, Number(second));if (first === 'topic' && second) return await _aRenderTopic(root, Number(second));if (first === 'live' && second) return await _aRenderConsole(root, second);++ if (first === 'chatter' && second) return await _aRenderChatter(root, decodeURIComponent(second));if (first) return await _aRenderFighter(root, first);return await _aRenderHome(root);} catch (err) {@@@ -213,9 +214,8 @@ async function _aRenderHome(root) {<h2><i class="fa-solid fa-fire"></i> Trash Level ladder <small>XP from beef hits, judged moments on subjects, chat hype</small></h2>${_aLevelsList(boardData.levels || [])}</div>-- <div class="arena-ladder">-- <h2><i class="fa-solid fa-keyboard"></i> Yappers <small>chatters who keep the subjects alive (7 days)</small></h2>-- ${_aYappersList(boardData.yappers || [])}++ <div class="arena-ladder arena-ladder-yap" id="arena-yappers">++ ${_aYappersSection(boardData)}</div></section><section class="arena-leaderboard">@@@ -245,7 +245,8 @@ async function _aRenderHome(root) {_aEvery(20000, async () => {try {const [b, l, bd] = await Promise.all([api('/arena/beefs'), api('/arena/live'), api('/arena/board')]);-- const beefsEl = document.getElementById('arena-beefs'), liveEl = document.getElementById('arena-live'), boardEl = document.getElementById('arena-board'), pulseEl = document.getElementById('arena-pulse');++ const beefsEl = document.getElementById('arena-beefs'), liveEl = document.getElementById('arena-live'), boardEl = document.getElementById('arena-board'), pulseEl = document.getElementById('arena-pulse'), yapEl = document.getElementById('arena-yappers');++ if (yapEl) { const prevTop = yapEl.dataset.top; yapEl.innerHTML = _aYappersSection(bd); if (prevTop && bd.yappers?.[0] && prevTop !== bd.yappers[0].key) _aFlash(yapEl, 'new #1 yapper'); yapEl.dataset.top = bd.yappers?.[0]?.key || ''; }if (beefsEl) beefsEl.innerHTML = _aRenderBeefs(b);if (liveEl) liveEl.innerHTML = _aRenderLive(l.live || []);if (boardEl) boardEl.innerHTML = _aRenderBoard(bd);@@@ -511,13 +512,74 @@ function _aLevelsList(rows) {<span class="arena-lvl arena-lvl-big">LVL ${r.level}</span></div>`).join('')}</div>`;}--function _aYappersList(rows) {-- if (!rows.length) return '<p class="arena-note">Type about a subject on the board in any chat — your lines become moments and you show up here.</p>';-- return `<div class="arena-mini-list">${rows.map((r, i) => `<div class="arena-mini-row">-- <span class="arena-rank ${i < 3 ? `arena-rank-${i + 1}` : ''}">${i + 1}</span>-- <span class="arena-chip"><span><strong>${r.username ? _aA(_aChannelLink(r), _aEsc(r.name)) : _aEsc(r.name)}</strong><small>${r.moments} moment${r.moments === 1 ? '' : 's'} on ${r.topics} subject${r.topics === 1 ? '' : 's'}${r.last ? ` · “${_aEsc(String(r.last).slice(0, 60))}”` : ''}</small></span></span>-- <span class="arena-lvl">${r.moments}</span>-- </div>`).join('')}</div>`;++function _aChatterLink(key) { return `/arena/chatter/${encodeURIComponent(key)}`; }++function _aYapRing(y, size = 44) {++ const pct = y.xp_for_next ? Math.max(0.04, Math.min(1, y.xp_into_level / y.xp_for_next)) : 1;++ const r = (size / 2) - 3, c = 2 * Math.PI * r;++ const color = y.user?.profile_color || (y.kind === 'relay' ? '#60a5fa' : y.kind === 'anon' ? '#9ca3af' : '#f97316');++ return `<span class="arena-yap-ring" style="--yc:${_aEsc(color)};width:${size}px;height:${size}px"><svg viewBox="0 0 ${size} ${size}" width="${size}" height="${size}"><circle cx="${size / 2}" cy="${size / 2}" r="${r}" class="arena-yap-ring-bg"></circle><circle cx="${size / 2}" cy="${size / 2}" r="${r}" class="arena-yap-ring-fg" stroke-dasharray="${c.toFixed(1)}" stroke-dashoffset="${(c * (1 - pct)).toFixed(1)}"></circle></svg><b>${y.level}</b></span>`;++}++function _aYapKind(y) { return y.kind === 'relay' ? `<span class="arena-tag arena-tag-dim" title="relayed from ${_aEsc(y.platform || 'another platform')}"><i class="fa-solid fa-satellite-dish"></i> ${_aEsc(y.platform || 'relay')}</span>` : y.kind === 'anon' ? '<span class="arena-tag arena-tag-dim" title="anonymous viewer"><i class="fa-solid fa-user-secret"></i> anon</span>' : ''; }++function _aYapCard(y, i) {++ const card = y.card || {};++ return `<div class="arena-yap-card ${i === 0 ? 'is-top' : ''}" style="--yc:${_aEsc(y.user?.profile_color || '#f97316')}">++ <div class="arena-yap-rank">${i + 1}</div>++ ${_aYapRing(y, i === 0 ? 64 : 52)}++ <div class="arena-yap-main">++ <div class="arena-yap-name">${_aA(_aChatterLink(y.key), `<strong>${_aEsc(y.name)}</strong>`)} ${_aYapKind(y)} ${y.streak >= 2 ? `<span class="arena-tag arena-tag-hot arena-streak" title="days in a row"><i class="fa-solid fa-fire"></i> ${y.streak}</span>` : ''}</div>++ <div class="arena-yap-title">${_aEsc(card.title || y.title)} <small>· ${_aEsc(y.title)} · ${y.xp} XP</small></div>++ ${card.blurb ? `<p class="arena-yap-blurb">${_aEsc(card.blurb)}</p>` : (y.best_line ? `<q class="arena-yap-line">${_aEsc(y.best_line.text)}</q>` : '')}++ <div class="arena-yap-stats"><span><b>${y.moments}</b> moments</span><span><b>${y.subjects}</b> subjects</span>${y.quoted ? `<span><b>${y.quoted}</b> quoted in lore</span>` : ''}${y.gained != null ? `<span><b>+${y.gained}</b> this week</span>` : ''}</div>++ </div>++ </div>`;++}++function _aYappersSection(bd) {++ const rows = bd.yappers || [], week = bd.yappers_week || [];++ return `<h2><i class="fa-solid fa-keyboard"></i> Yappers <small>${bd.yappers_total || rows.length} chatters with a yap level · accounts, anons and relayed chatters alike</small></h2>++ ${rows.length ? `<div class="arena-yap-podium">${rows.slice(0, 3).map(_aYapCard).join('')}</div>++ ${rows.length > 3 ? `<div class="arena-mini-list">${rows.slice(3).map((y, i) => `<div class="arena-mini-row arena-yap-row">++ <span class="arena-rank">${i + 4}</span>++ ${_aYapRing(y, 34)}++ <span class="arena-chip"><span><strong>${_aA(_aChatterLink(y.key), _aEsc(y.name))} ${_aYapKind(y)}</strong><small>${_aEsc((y.card && y.card.title) || y.title)} · ${y.moments} moments on ${y.subjects} subject${y.subjects === 1 ? '' : 's'}${y.streak >= 2 ? ` · 🔥 ${y.streak}` : ''}</small></span></span>++ <span class="arena-lvl">${y.xp} XP</span>++ </div>`).join('')}</div>` : ''}++ ${week.length ? `<div class="arena-yap-week"><span class="arena-note"><i class="fa-solid fa-bolt"></i> hottest this week:</span> ${week.slice(0, 5).map(y => _aA(_aChatterLink(y.key), `${_aEsc(y.name)} <b>+${y.gained}</b>`, 'arena-tag')).join(' ')}</div>` : ''}` : '<p class="arena-note">Type about a subject on the board in any chat — your lines become moments, you get XP, a yap level and a card. Anons and relayed chatters too; OpenVibe accounts also get OpenCoins on every level-up.</p>'}`;++}++function _aFlash(el, text) {++ const f = document.createElement('div'); f.className = 'arena-flash'; f.textContent = text; el.prepend(f); setTimeout(() => f.remove(), 2200);++}++++// ── Chatter (yapper) page ────────────────────────────────────++async function _aRenderChatter(root, key) {++ root.innerHTML = _aSpinner('Pulling the yap sheet…');++ let y;++ try { y = await api(`/arena/chatter/${encodeURIComponent(key)}`); } catch (err) { root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-user-secret"></i><p>${_aEsc(err?.message || 'No yap profile yet')}</p>${_aA('/arena', 'Back to the Arena', 'btn')}</div>`; return; }++ const card = y.card || {};++ const next = y.titles.find(t => t.level > y.level);++ root.innerHTML = `++ <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')} ${y.user ? _aA(_aChannelLink(y.user), `<i class="fa-solid fa-user"></i> @${_aEsc(y.user.username)}`) : ''}</div>++ <div class="arena-yap-page" style="--yc:${_aEsc(y.user?.profile_color || '#f97316')}">++ <div class="arena-yap-hero">++ ${_aYapRing(y, 96)}++ <div class="arena-yap-hero-main">++ <h1>${_aEsc(y.name)} ${_aYapKind(y)}</h1>++ <div class="arena-yap-hero-title"><span class="arena-lvl arena-lvl-big">YAP LVL ${y.level} · ${_aEsc(y.title)}</span>${card.title ? `<span class="arena-yap-card-title">“${_aEsc(card.title)}”</span>` : ''}${card.archetype ? `<span class="arena-tag">${_aEsc(card.archetype)}</span>` : ''}</div>++ <span class="arena-xp-track"><span class="arena-xp-fill" style="width:${y.xp_for_next ? Math.round((y.xp_into_level / y.xp_for_next) * 100) : 100}%"></span></span>++ <small class="arena-note">${y.xp} XP · ${y.xp_into_level}/${y.xp_for_next} to level ${y.level + 1}${next ? ` · next title <b>${_aEsc(next.title)}</b> at level ${next.level}` : ''} · rank #${y.rank}${y.coins ? ' · OpenCoins on every level-up' : ' · sign in to bank OpenCoins for your levels'}</small>++ <div class="arena-yap-stats"><span><b>${y.moments}</b> moments</span><span><b>${y.subjects}</b> subjects</span><span><b>${y.subjects_started}</b> started</span><span><b>${y.quoted}</b> quoted in lore</span><span><b>${y.hypes}</b> hypes</span><span><b>${y.streak}</b> day streak <small>(best ${y.best_streak})</small></span></div>++ </div>++ </div>++ ${card.blurb ? `<section class="arena-yap-cardbox"><h3><i class="fa-solid fa-id-badge"></i> Yap card <small>AI-written from their chat history${y.card_at ? ` · ${_aEsc(_aAgo(y.card_at))}` : ''}</small></h3><p>${_aEsc(card.blurb)}</p>${card.catchphrase ? `<q class="arena-yap-line">${_aEsc(card.catchphrase)}</q>` : ''}${card.known_for?.length ? `<div class="arena-keywords"><span class="arena-note">known for:</span>${card.known_for.map(k => `<span>${_aEsc(k)}</span>`).join('')}</div>` : ''}</section>` : `<p class="arena-note">The AI writes a yap card at level ${3}+ from their chat history — keep yapping.</p>`}++ ${y.chat_ai?.overview ? `<section class="arena-yap-cardbox"><h3><i class="fa-solid fa-brain"></i> What the chat AI has on them</h3><p>${_aEsc(y.chat_ai.overview)}</p></section>` : ''}++ <div class="arena-topic-cols">++ <section class="arena-moments"><h3><i class="fa-solid fa-stream"></i> Their moments <small>${y.recent_moments.length}</small></h3>${y.recent_moments.length ? `<div class="arena-moment-list">${y.recent_moments.map(m => `<div class="arena-moment is-chat"><i class="fa-solid fa-keyboard"></i><div class="arena-moment-body"><q>${_aEsc(m.text)}</q><small>on ${_aA(`/arena/topic/${m.topic_id}`, _aEsc(m.subject_headline || m.subject))} · ${_aEsc(_aAgo(m.at))}</small></div></div>`).join('')}</div>` : '<p class="arena-note">Nothing yet.</p>'}</section>++ <aside class="arena-topic-aside">++ <section><h3><i class="fa-solid fa-comments"></i> Subjects they pile on</h3>${y.top_subjects.length ? `<div class="arena-mini-list">${y.top_subjects.map((t, i) => `<div class="arena-mini-row"><span class="arena-rank">${i + 1}</span><span class="arena-chip"><span><strong>${_aA(`/arena/topic/${t.id}`, _aEsc(t.headline || t.text))}</strong><small>${_aEsc(t.status)}</small></span></span><span class="arena-lvl">${t.moments}</span></div>`).join('')}</div>` : '<p class="arena-note">None yet.</p>'}</section>++ <section><h3><i class="fa-solid fa-ranking-star"></i> Titles</h3><div class="arena-mini-list">${y.titles.map(t => `<div class="arena-mini-row ${t.level <= y.level ? 'is-earned' : ''}"><span class="arena-rank">${t.level}</span><span class="arena-chip"><span><strong>${_aEsc(t.title)}</strong><small>${t.xp} XP</small></span></span><span>${t.level <= y.level ? '<i class="fa-solid fa-check" style="color:#4ade80"></i>' : ''}</span></div>`).join('')}</div></section>++ <section><h3><i class="fa-solid fa-coins"></i> How XP works</h3><ul class="arena-rules-list"><li>A chat line that lands on a subject: +3 (+6 when it's HOT), +4 the first time you touch a subject.</li><li>Starting a subject: +15. Getting quoted in a subject's lore: +10. Hyping: +1.</li><li>Show up daily: +5 × streak (up to 7).</li><li>Level = 1 + √(XP ÷ 25). OpenVibe accounts bank <b>level × 10 OpenCoins</b> on every level-up; anons and relayed chatters keep the level and the title.</li></ul></section>++ </aside>++ </div>++ </div>`;}function _aRenderList(fighters) {@@@ -4297,6 +4297,14 @@ function showChatContextMenu(event) {} else {loadContextMenuProfile(menu, coreUsername, userId, isAnon);}++ // Arena yap level (accounts, anons and relayed chatters all have one once they've hit a subject).++ try {++ let yapKey = null;++ if (isRelay) { const m = String(username || '').match(/^\[([A-Za-z]+)\]\s+(.+)$/); const plat = String(sourcePlatform || (m && m[1]) || '').toLowerCase().replace(/^yt$/, 'youtube').replace(/^robotstreamer$/, 'rs'); if (m && plat) yapKey = `relay:${plat}:${m[2].trim().toLowerCase()}`; }++ else if (isAnon) { const m = String(coreUsername || username || '').match(/^anon(\d+)$/i); if (m) yapKey = `anon:${m[1]}`; }++ else if (userId) yapKey = `user:${userId}`;++ if (yapKey) _injectYapBlock(menu, yapKey);++ } catch { /* optional */ }// Clicks inside the menu shouldn't dismiss it (needed for rename submenu toggle etc.)menu.addEventListener('click', (e) => e.stopPropagation());@@@ -4369,6 +4377,20 @@ function positionContextMenu(menu, x, y) {menu._resizeHandler = onResize;}++async function _injectYapBlock(menu, key) {++ try {++ const y = await api(`/arena/chatter/${encodeURIComponent(key)}`);++ if (!y || !menu.isConnected) return;++ const pct = y.xp_for_next ? Math.round((y.xp_into_level / y.xp_for_next) * 100) : 100;++ const card = y.card || {};++ const el = document.createElement('div');++ el.className = 'ctx-yap';++ el.innerHTML = `<a href="/arena/chatter/${encodeURIComponent(key)}" onclick="return handleLinkClick(event, '/arena/chatter/${encodeURIComponent(key)}')" class="ctx-yap-link"><span class="ctx-yap-lvl">YAP ${y.level}</span><span class="ctx-yap-title">${esc(card.title || y.title)}</span>${y.streak >= 2 ? `<span class="ctx-yap-streak">🔥 ${y.streak}</span>` : ''}</a><span class="ctx-yap-bar"><span style="width:${pct}%"></span></span><small>${y.xp} XP · ${y.moments} moments on ${y.subjects} subject${y.subjects === 1 ? '' : 's'}${card.catchphrase ? ` · “${esc(String(card.catchphrase).slice(0, 60))}”` : ''}</small>`;++ const actions = menu.querySelector('.ctx-actions');++ if (actions) actions.parentNode.insertBefore(el, actions); else menu.appendChild(el);++ } catch { /* no profile yet */ }++}++function dismissContextMenu() {if (activeContextMenu) {// Clean up observers@@@ -69,6 +69,7 @@ async function housekeeping() {try { const r = board.scanChat(); if (r.moments) console.log(`[Arena] chat scan: ${r.moments} moment(s) from ${r.scanned} message(s)`); } catch (e) { console.warn('[Arena] chat scan:', e.message); }try { await board.discoverTopics(); } catch (e) { console.warn('[Arena] discover:', e.message); } // every 5 min, only with new materialtry { await board.loreSweep(3); } catch (e) { console.warn('[Arena] lore:', e.message); } // only topics with ≥ 3 new moments++ try { await require('./chatters').cardSweep(); } catch (e) { console.warn('[Arena] yap cards:', e.message); } // ≤ 4 chatters per minute, level ≥ 3, once a day each}function stop() {@@@ -438,6 +438,9 @@ function referenceImagesFor(userId) {if (out.length >= REF_MAX) break;}} catch { /* */ }++ // Media-hosted thumbnails of their streams (what prod actually has: https://openvibe.media/t/vod-….jpg).++ try { for (const r of db.all(`SELECT thumbnail_url FROM streams WHERE user_id = ? AND thumbnail_url LIKE 'http%' ORDER BY started_at DESC LIMIT 3`, [userId])) push(r.thumbnail_url); } catch { /* */ }++ try { for (const r of db.all(`SELECT m.thumbnail_url FROM stream_memories m WHERE m.user_id = ? AND m.thumbnail_url LIKE 'http%' AND (LOWER(m.description) LIKE '%person%' OR LOWER(m.description) LIKE '%face%' OR LOWER(m.description) LIKE '%wearing%' OR LOWER(m.description) LIKE '%headphones%') ORDER BY m.id DESC LIMIT 3`, [userId])) push(r.thumbnail_url); } catch { /* */ }try { for (const v of db.all('SELECT thumbnail_url FROM vods WHERE user_id = ? AND thumbnail_url IS NOT NULL AND is_public = 1 ORDER BY created_at DESC LIMIT 3', [userId])) { if (/^https?:\/\//i.test(v.thumbnail_url)) push(v.thumbnail_url); else { const local = path.resolve('.' + v.thumbnail_url); if (fs.existsSync(local)) push(local); } } } catch { /* */ }return out;}@@@ -95,6 +95,8 @@ function ensureTables() {created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`);db.run('CREATE INDEX IF NOT EXISTS idx_arena_moments_topic ON arena_topic_moments (topic_id, created_at)');++ for (const col of ['chatter_key TEXT', 'source_platform TEXT', 'anon_id TEXT']) { try { db.run(`ALTER TABLE arena_topic_moments ADD COLUMN ${col}`); } catch { /* exists */ } }++ db.run('CREATE INDEX IF NOT EXISTS idx_arena_moments_chatter ON arena_topic_moments (chatter_key, id)');db.run(`CREATE TABLE IF NOT EXISTS arena_trash_levels (user_id INTEGER PRIMARY KEY,xp INTEGER DEFAULT 0,@@@ -285,24 +287,30 @@ async function submitTopic({ text, userId, ip, creatorName, onRoster = false })}const t = createTopic({ text: refined?.subject || raw, hint: refined?.hint || null, createdBy: onRoster ? 'streamer' : 'viewer', creatorUserId: userId, creatorName, creatorIp: ip, headline: refined?.headline || null, tagline: refined?.tagline || `Put up by ${creatorName}`, sourceNote: creatorName, keywords: refined?.keywords || null, submittedText: raw });backfillMoments([t]);++ try { require('./chatters').onSubjectStarted(`user:${userId}`, t.id, { display: creatorName }); } catch { /* */ }return t;}/** A moment: something someone said (chat or on mic) about the topic. */--function addMoment(topicId, { kind, source, userId = null, username = null, streamId = null, vodId = null, sec = null, text, quality = null, about = null }) {++function addMoment(topicId, { kind, source, userId = null, username = null, streamId = null, vodId = null, sec = null, text, quality = null, about = null, platform = null, anonId = null }) {ensureTables();-- const t = db.get(`SELECT id, kind, status FROM arena_topics WHERE id = ?`, [topicId]);++ const t = db.get(`SELECT id, kind, status, heat FROM arena_topics WHERE id = ?`, [topicId]);if (!t || t.status !== 'open') return null;const body = clip(text, 240);if (!body || arena()._isBannedText(body)) return null;-- db.run(`INSERT INTO arena_topic_moments (topic_id, kind, source, user_id, username, stream_id, vod_id, sec, text, quality, about) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,-- [topicId, kind, source, userId, username ? clip(username, 40) : null, streamId, vodId, sec, body, quality, about ? clip(about, 80) : null]);++ const chatters = require('./chatters');++ const chatterKey = chatters.keyFor({ user_id: userId, username, anon_id: anonId, source_platform: platform });++ db.run(`INSERT INTO arena_topic_moments (topic_id, kind, source, user_id, username, stream_id, vod_id, sec, text, quality, about, chatter_key, source_platform, anon_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,++ [topicId, kind, source, userId, username ? clip(username, 60) : null, streamId, vodId, sec, body, quality, about ? clip(about, 80) : null, chatterKey, platform, anonId]);db.run(`UPDATE arena_topics SET hits = hits + 1, ${kind === 'chat' ? 'chat_mentions = chat_mentions + 1' : 'mic_mentions = mic_mentions + 1'}, last_mention_at = CURRENT_TIMESTAMP, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?`, [topicId]);if (kind === 'speech' && userId && arena().loadRoster().byId[userId]) {const m = db.get('SELECT 1 FROM arena_topic_members WHERE topic_id = ? AND user_id = ?', [topicId, userId]);if (!m) { db.run('INSERT INTO arena_topic_members (topic_id, user_id, active) VALUES (?, ?, 1)', [topicId, userId]); db.run('UPDATE arena_topics SET joins = joins + 1 WHERE id = ?', [topicId]); addXp(userId, XP_JOIN, 'topic_joined', topicId, { joined: true }); }}-- return db.get('SELECT * FROM arena_topic_moments ORDER BY id DESC LIMIT 1');++ const m = db.get('SELECT * FROM arena_topic_moments ORDER BY id DESC LIMIT 1');++ // Yapper XP for chat lines (seeded backfills count too — they are real lines people typed).++ if (kind === 'chat' && chatterKey) { try { chatters.onMoment(m, t, { hot: (t.heat || 0) >= HOT_THRESHOLD }); } catch (e) { console.warn('[Arena] yapper xp:', e.message); } }++ return m;}/** Raw on-mic mention (no judge): one per stream per topic per cooldown. */@@@ -346,7 +354,7 @@ function hypeTopic(topicId, userId, voterKey) {ensureTables();if (voterKey === `user:${userId}`) throw new Error("You can't hype yourself");const ins = db.run('INSERT OR IGNORE INTO arena_topic_hype (topic_id, user_id, voter_key) VALUES (?, ?, ?)', [topicId, userId, voterKey]);-- if (ins.changes) { addXp(userId, XP_HYPE, 'hype', topicId); db.run('UPDATE arena_topics SET last_activity_at = CURRENT_TIMESTAMP WHERE id = ?', [topicId]); }++ if (ins.changes) { addXp(userId, XP_HYPE, 'hype', topicId); db.run('UPDATE arena_topics SET last_activity_at = CURRENT_TIMESTAMP WHERE id = ?', [topicId]); try { require('./chatters').onHype(voterKey, topicId); } catch { /* */ } }const n = db.get('SELECT COUNT(*) AS n FROM arena_topic_hype WHERE topic_id = ? AND user_id = ?', [topicId, userId])?.n || 0;return { added: !!ins.changes, hypers: n };}@@@ -413,13 +421,13 @@ let _scan = { lastChatId: null, lastDiscoverAt: 0, lastDiscoverSeen: 0 };function scanChat() {ensureTables();if (_scan.lastChatId == null) { _scan.lastChatId = db.get('SELECT COALESCE(MAX(id), 0) AS id FROM chat_messages')?.id || 0; return { scanned: 0, moments: 0 }; }-- const rows = db.all(`SELECT id, stream_id, user_id, username, message FROM chat_messages WHERE id > ? AND COALESCE(is_deleted, 0) = 0 AND message IS NOT NULL AND LENGTH(message) BETWEEN 6 AND 300 ORDER BY id ASC LIMIT 400`, [_scan.lastChatId]);++ const rows = db.all(`SELECT id, stream_id, user_id, username, message, source_platform, anon_id FROM chat_messages WHERE id > ? AND COALESCE(is_deleted, 0) = 0 AND message IS NOT NULL AND COALESCE(message_type, 'chat') = 'chat' AND COALESCE(source_platform, '') != 'ai' AND LENGTH(message) BETWEEN 6 AND 300 ORDER BY id ASC LIMIT 400`, [_scan.lastChatId]);if (!rows.length) return { scanned: 0, moments: 0 };_scan.lastChatId = rows[rows.length - 1].id;let moments = 0;for (const r of rows) {if (/^!/.test(r.message)) continue;-- for (const t of matchTopics(r.message)) { if (addMoment(t.id, { kind: 'chat', source: 'chat', userId: r.user_id, username: r.username, streamId: r.stream_id, text: r.message })) moments++; }++ for (const t of matchTopics(r.message)) { if (addMoment(t.id, { kind: 'chat', source: 'chat', userId: r.user_id, username: r.username, streamId: r.stream_id, text: r.message, platform: r.source_platform || null, anonId: r.anon_id || null })) moments++; }}return { scanned: rows.length, moments };}@@@ -440,7 +448,7 @@ const DISCOVER_SYSTEM = `You are the community consciousness of a live-streamingfunction discoverInput() {const out = { at: new Date().toISOString() };-- try { out.chat = db.all(`SELECT c.username, c.message, c.stream_id FROM chat_messages c WHERE COALESCE(c.is_deleted, 0) = 0 AND c.timestamp >= datetime('now', ?) AND LENGTH(c.message) BETWEEN 8 AND 200 AND c.message NOT LIKE '!%' ORDER BY c.id DESC LIMIT 80`, [`-${SCAN_WINDOW_MIN} minutes`]).reverse().filter(r => !arena()._isBannedText(r.message)).map(r => clip(`[${r.stream_id ? 'stream' : 'global'}] ${r.username}: ${r.message}`, 180)); } catch { out.chat = []; }++ try { out.chat = db.all(`SELECT c.username, c.message, c.stream_id FROM chat_messages c WHERE COALESCE(c.is_deleted, 0) = 0 AND COALESCE(c.source_platform, '') != 'ai' AND COALESCE(c.message_type, 'chat') = 'chat' AND c.timestamp >= datetime('now', ?) AND LENGTH(c.message) BETWEEN 8 AND 200 AND c.message NOT LIKE '!%' ORDER BY c.id DESC LIMIT 80`, [`-${SCAN_WINDOW_MIN} minutes`]).reverse().filter(r => !arena()._isBannedText(r.message)).map(r => clip(`[${r.stream_id ? 'stream' : 'global'}] ${r.username}: ${r.message}`, 180)); } catch { out.chat = []; }try { out.on_mic = db.all(`SELECT u.username, e.text FROM stream_timeline_events e JOIN users u ON u.id = e.user_id WHERE e.kind = 'speech' AND e.created_at >= datetime('now', ?) AND LENGTH(e.text) BETWEEN 25 AND 220 ORDER BY e.id DESC LIMIT 60`, [`-${SCAN_WINDOW_MIN} minutes`]).reverse().filter(r => !arena()._isBannedText(r.text)).map(r => clip(`${r.username}: ${r.text}`, 200)); } catch { out.on_mic = []; }try { out.chat_timeline = db.all(`SELECT label, detail FROM chat_timeline_events WHERE created_at >= datetime('now', '-6 hours') ORDER BY id DESC LIMIT 8`).map(r => clip(`${r.label}: ${r.detail || ''}`, 140)); } catch { out.chat_timeline = []; }try { const g = db.getChatAiSummary('global', 0, 'rolling'); if (g) { const ov = parseJson(g.overview, null); out.global_chat_overview = clip(ov ? (ov.today || ov.alltime) : g.overview, 500); } } catch { /* */ }@@@ -502,12 +510,12 @@ async function discoverTopics({ force = false } = {}) {/** Seed a fresh topic with the lines from the window that match it, so it never starts empty. */function backfillMoments(topics) {-- const chat = db.all(`SELECT id, stream_id, user_id, username, message FROM chat_messages WHERE COALESCE(is_deleted, 0) = 0 AND timestamp >= datetime('now', ?) AND LENGTH(message) BETWEEN 6 AND 300 ORDER BY id ASC LIMIT 400`, [`-${SCAN_WINDOW_MIN * 4} minutes`]);++ const chat = db.all(`SELECT id, stream_id, user_id, username, message, source_platform, anon_id FROM chat_messages WHERE COALESCE(is_deleted, 0) = 0 AND COALESCE(message_type, 'chat') = 'chat' AND COALESCE(source_platform, '') != 'ai' AND timestamp >= datetime('now', ?) AND LENGTH(message) BETWEEN 6 AND 300 ORDER BY id ASC LIMIT 400`, [`-${SCAN_WINDOW_MIN * 4} minutes`]);const speech = db.all(`SELECT e.stream_id, e.user_id, e.vod_id, e.start_sec, e.text, u.username FROM stream_timeline_events e LEFT JOIN users u ON u.id = e.user_id WHERE e.kind = 'speech' AND e.created_at >= datetime('now', ?) ORDER BY e.id ASC LIMIT 300`, [`-${SCAN_WINDOW_MIN * 4} minutes`]);for (const t of topics) {const kws = normalizeKeywords(parseJson(t.keywords_json, [])).map(keywordRegex);let n = 0;-- for (const c of chat) if (!/^!/.test(c.message) && kws.some(re => re.test(c.message))) { if (addMoment(t.id, { kind: 'chat', source: 'seed', userId: c.user_id, username: c.username, streamId: c.stream_id, text: c.message })) n++; }++ for (const c of chat) if (!/^!/.test(c.message) && kws.some(re => re.test(c.message))) { if (addMoment(t.id, { kind: 'chat', source: 'seed', userId: c.user_id, username: c.username, streamId: c.stream_id, text: c.message, platform: c.source_platform || null, anonId: c.anon_id || null })) n++; }for (const s of speech) if (kws.some(re => re.test(s.text || ''))) { if (addMoment(t.id, { kind: 'speech', source: 'seed', userId: s.user_id, username: s.username, streamId: s.stream_id, vodId: s.vod_id, sec: Math.max(0, Math.floor(s.start_sec) - 2), text: s.text })) n++; }if (n) console.log(`[Arena] topic #${t.id} seeded with ${n} moment(s)`);}@@@ -563,6 +571,7 @@ async function buildLore(topicId, { force = false } = {}) {db.run('UPDATE arena_topics SET lore = ?, headline = COALESCE(?, headline), tagline = COALESCE(?, tagline), keywords_json = ?, lore_updated_at = CURRENT_TIMESTAMP, lore_moment_count = ? WHERE id = ?',[clip(out.lore, 1400), out.headline ? clip(out.headline, 160) : null, out.tagline ? clip(out.tagline, 120) : null, JSON.stringify(kws), total, topicId]);invalidateMatchers();++ try { require('./chatters').onLore(topic, out.lore, moments); } catch (e) { console.warn('[Arena] lore quotes xp:', e.message); }return { lore: out.lore, headline: out.headline, tagline: out.tagline };}@@@ -590,7 +599,7 @@ function fighterBrief(userId, roster) {level: levelFor(levelRow(userId).xp || 0),};}--function momentView(m) { return { id: m.id, kind: m.kind, source: m.source, user_id: m.user_id, username: m.username, stream_id: m.stream_id, vod_id: m.vod_id, sec: m.sec, text: m.text, quality: m.quality, about: m.about, at: m.created_at }; }++function momentView(m) { return { id: m.id, kind: m.kind, source: m.source, user_id: m.user_id, username: m.username, chatter_key: m.chatter_key || null, platform: m.source_platform || null, stream_id: m.stream_id, vod_id: m.vod_id, sec: m.sec, text: m.text, quality: m.quality, about: m.about, at: m.created_at }; }function topicView(topic, roster, { detail = false } = {}) {const members = db.all(`SELECT m.*, (SELECT COUNT(*) FROM arena_topic_moments x WHERE x.topic_id = m.topic_id AND x.user_id = m.user_id AND x.kind = 'speech') AS moments, (SELECT COALESCE(SUM(quality), 0) FROM arena_topic_moments x WHERE x.topic_id = m.topic_id AND x.user_id = m.user_id) AS score FROM arena_topic_members m WHERE m.topic_id = ? ORDER BY score DESC, moments DESC`, [topic.id]);@@@ -613,7 +622,7 @@ function topicView(topic, roster, { detail = false } = {}) {if (detail) {out.submitted_text = topic.submitted_text || null;out.moments = db.all('SELECT * FROM arena_topic_moments WHERE topic_id = ? ORDER BY id DESC LIMIT 80', [topic.id]).map(momentView);-- out.top_chatters = db.all(`SELECT username, COUNT(*) AS n FROM arena_topic_moments WHERE topic_id = ? AND kind = 'chat' AND username IS NOT NULL GROUP BY username ORDER BY n DESC LIMIT 6`, [topic.id]);++ out.top_chatters = db.all(`SELECT username, chatter_key, COUNT(*) AS n FROM arena_topic_moments WHERE topic_id = ? AND kind = 'chat' AND username IS NOT NULL GROUP BY COALESCE(chatter_key, username) ORDER BY n DESC LIMIT 6`, [topic.id]).map(c => { let lvl = null; try { const r = require('./chatters').row(c.chatter_key); if (r) lvl = { level: r.level, title: require('./chatters').titleFor(r.level) }; } catch { /* */ } return { ...c, ...(lvl || {}) }; });out.best_lines = db.all(`SELECT * FROM arena_topic_moments WHERE topic_id = ? AND quality IS NOT NULL ORDER BY quality DESC, id DESC LIMIT 6`, [topic.id]).map(momentView);for (const f of out.fighters) { const bm = db.get(`SELECT * FROM arena_topic_moments WHERE topic_id = ? AND user_id = ? AND kind = 'speech' ORDER BY COALESCE(quality, -1) DESC, id DESC LIMIT 1`, [topic.id, f.user.id]); f.best = bm ? momentView(bm) : null; }}@@@ -647,11 +656,7 @@ function levelsLeaderboard(limit = 10) {}/** Viewers who keep the subjects alive from chat (moments in the last 7 days). */--function yappersLeaderboard(limit = 10) {-- ensureTables();-- return db.all(`SELECT username, user_id, COUNT(*) AS n, COUNT(DISTINCT topic_id) AS topics FROM arena_topic_moments WHERE kind = 'chat' AND username IS NOT NULL AND created_at >= datetime('now', '-7 days') GROUP BY username ORDER BY n DESC LIMIT ?`, [limit])-- .map(r => ({ name: r.username, username: r.username, user_id: r.user_id, moments: r.n, topics: r.topics, last: db.get('SELECT text FROM arena_topic_moments WHERE username = ? AND kind = ? ORDER BY id DESC LIMIT 1', [r.username, 'chat'])?.text || null }));--}++function yappersLeaderboard(limit = 10) { return require('./chatters').leaderboard(limit); }module.exports = {ensureTables, createTopic, submitTopic, assertCanSubmit, joinTopic, leaveTopic, activeTopicFor, applyTopicJudgement, hypeTopic, addMoment, noteMicMention, matchTopics, keywordsFromText,nnew file mode 100644@@@ -0,0 +1,330 @@++/**++ * chatters.js — Yapper profiles: XP, levels, titles, streaks and an AI "yap card" for EVERYONE who++ * chats — signed-in OpenVibe users, anonymous viewers (anon<N>) and chat relayed from other++ * platforms ([Twitch] bob, [Kick] bob, [RS] bob) — keyed by one polymorphic chatter key:++ *++ * user:<users.id> anon:<N> relay:<platform>:<username lowercased>++ *++ * XP comes from the board (no extra AI calls): a chat line that lands as a moment on a subject,++ * more when the subject is HOT, a bonus the first time you touch a subject, starting a subject,++ * being quoted in a subject's lore, hyping, and a daily streak of showing up. Levels use the same++ * curve as the game (1 + floor(sqrt(xp / 25))). OpenVibe accounts get site-wide OpenCoins on every++ * level-up (idempotent per level, through the network wallet); anon/relay chatters get the level,++ * the title and the card — the coins need an account.++ *++ * The yap card (title, blurb, catchphrase, "known for") is written by the AI from the chat AI's++ * existing profile of the chatter (chat_ai_summaries: user / anon / relay scopes) + their moments —++ * only for level ≥ CARD_MIN_LEVEL, at most once a day per chatter, a few per housekeeping tick.++ */++'use strict';++++const db = require('../db/database');++const llm = require('../ai/llm');++++const XP_MOMENT = 3;++const XP_MOMENT_HOT = 6;++const XP_FIRST_ON_SUBJECT = 4;++const XP_SUBJECT_STARTED = 15;++const XP_QUOTED_IN_LORE = 10;++const XP_HYPE = 1;++const XP_STREAK_DAY = 5; // × min(streak, 7)++const COINS_PER_LEVEL = 10; // OpenCoins on level-up = level × this (accounts only)++const CARD_MIN_LEVEL = 3;++const CARD_TTL_MS = 24 * 3600_000;++const CARD_BATCH = 4;++++const TITLES = [[1, 'Lurker'], [2, 'Chatter'], [3, 'Yapper'], [5, 'Instigator'], [7, 'Ragebaiter'], [9, 'Menace'], [12, 'Main Character'], [15, 'Community Consciousness'], [20, 'Final Boss']];++++let _ready = false;++function ensureTables() {++ if (_ready) return;++ db.run(`CREATE TABLE IF NOT EXISTS chatter_profiles (++ key TEXT PRIMARY KEY,++ kind TEXT NOT NULL, -- 'user' | 'anon' | 'relay'++ user_id INTEGER,++ platform TEXT,++ display_name TEXT,++ xp INTEGER DEFAULT 0,++ level INTEGER DEFAULT 1,++ moments INTEGER DEFAULT 0,++ subjects INTEGER DEFAULT 0,++ subjects_started INTEGER DEFAULT 0,++ quoted INTEGER DEFAULT 0,++ hypes INTEGER DEFAULT 0,++ streak INTEGER DEFAULT 0,++ best_streak INTEGER DEFAULT 0,++ last_day TEXT,++ best_line TEXT,++ best_line_topic_id INTEGER,++ card_json TEXT,++ card_at DATETIME,++ card_xp INTEGER DEFAULT 0,++ coins_paid_level INTEGER DEFAULT 0,++ first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,++ last_seen DATETIME DEFAULT CURRENT_TIMESTAMP++ )`);++ db.run(`CREATE TABLE IF NOT EXISTS chatter_xp_log (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ key TEXT NOT NULL,++ amount INTEGER NOT NULL,++ reason TEXT NOT NULL,++ ref_id INTEGER,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP++ )`);++ db.run('CREATE INDEX IF NOT EXISTS idx_chatter_xp_key ON chatter_xp_log (key, created_at)');++ db.run(`CREATE TABLE IF NOT EXISTS chatter_subjects (++ key TEXT NOT NULL,++ topic_id INTEGER NOT NULL,++ moments INTEGER DEFAULT 0,++ first_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ last_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ PRIMARY KEY (key, topic_id)++ )`);++ _ready = true;++}++++function aiOn() { try { return llm.isEnabled() && llm.withinBudget(); } catch { return false; } }++function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }++function clip(t, n) { return String(t || '').replace(/\s+/g, ' ').trim().slice(0, n); }++function levelFor(xp) { return 1 + Math.floor(Math.sqrt(Math.max(0, Number(xp) || 0) / 25)); }++function xpForLevel(level) { return Math.max(0, (level - 1) ** 2 * 25); }++function titleFor(level) { let t = TITLES[0][1]; for (const [l, name] of TITLES) if (level >= l) t = name; return t; }++function today() { return new Date().toISOString().slice(0, 10); }++++// ── Keys ─────────────────────────────────────────────────────++++const RELAY_LABELS = { twitch: 'twitch', kick: 'kick', yt: 'youtube', youtube: 'youtube', rs: 'rs', robotstreamer: 'rs' };++/** The chatter key for a chat_messages-shaped row / moment: { user_id, username, anon_id, source_platform }. */++function keyFor(m) {++ if (!m) return null;++ if (m.user_id) return `user:${m.user_id}`;++ const uname = String(m.username || '');++ const relay = uname.match(/^\[([A-Za-z]+)\]\s+(.+)$/);++ if (relay && (m.source_platform || RELAY_LABELS[relay[1].toLowerCase()])) return `relay:${(m.source_platform || RELAY_LABELS[relay[1].toLowerCase()] || relay[1]).toLowerCase()}:${relay[2].trim().toLowerCase()}`;++ const anon = String(m.anon_id || (/^anon\d+$/i.test(uname) ? uname : '')).match(/^anon(\d+)$/i);++ if (anon) return `anon:${anon[1]}`;++ if (uname) return `name:${uname.toLowerCase()}`;++ return null;++}++function parseKey(key) {++ const [kind, ...rest] = String(key || '').split(':');++ if (kind === 'user') return { kind, user_id: Number(rest[0]) };++ if (kind === 'anon') return { kind, anon_num: Number(rest[0]), anon_id: `anon${rest[0]}` };++ if (kind === 'relay') return { kind, platform: rest[0], username: rest.slice(1).join(':') };++ if (kind === 'name') return { kind, username: rest.join(':') };++ return null;++}++function displayFor(key, fallback = null) {++ const p = parseKey(key);++ if (!p) return fallback || 'someone';++ if (p.kind === 'user') { const u = db.getUserById(p.user_id); return u ? (u.display_name || u.username) : (fallback || `user${p.user_id}`); }++ if (p.kind === 'anon') return fallback || p.anon_id;++ if (p.kind === 'relay') return fallback || `[${p.platform}] ${p.username}`;++ return fallback || p.username;++}++++// ── XP ───────────────────────────────────────────────────────++++function row(key) { ensureTables(); return db.get('SELECT * FROM chatter_profiles WHERE key = ?', [key]) || null; }++function ensureRow(key, { display = null } = {}) {++ const p = parseKey(key);++ if (!p) return null;++ const existing = row(key);++ if (existing) { if (display && display !== existing.display_name) db.run('UPDATE chatter_profiles SET display_name = ? WHERE key = ?', [clip(display, 60), key]); return existing; }++ db.run('INSERT OR IGNORE INTO chatter_profiles (key, kind, user_id, platform, display_name) VALUES (?, ?, ?, ?, ?)', [key, p.kind, p.kind === 'user' ? p.user_id : null, p.kind === 'relay' ? p.platform : null, clip(display || displayFor(key), 60)]);++ return row(key);++}++++/** Add XP; handles level-ups (coins for accounts, an announcement in the room when a streamId is given). */++function addXp(key, amount, reason, refId = null, { display = null, streamId = null } = {}) {++ ensureTables();++ amount = Math.round(Number(amount) || 0);++ const before = ensureRow(key, { display });++ if (!before || amount <= 0) return { leveled_up: false, level: before ? before.level : 1, gained: 0 };++ const xp = (before.xp || 0) + amount;++ const level = levelFor(xp);++ db.run('UPDATE chatter_profiles SET xp = ?, level = ?, last_seen = CURRENT_TIMESTAMP WHERE key = ?', [xp, level, key]);++ db.run('INSERT INTO chatter_xp_log (key, amount, reason, ref_id) VALUES (?, ?, ?, ?)', [key, amount, reason, refId]);++ const leveled = level > (before.level || 1);++ if (leveled) onLevelUp(key, before.level || 1, level, { streamId });++ return { leveled_up: leveled, level, xp, gained: amount, title: titleFor(level) };++}++++function onLevelUp(key, from, to, { streamId = null } = {}) {++ const p = parseKey(key);++ const name = displayFor(key);++ console.log(`[Arena] yapper ${name} (${key}) → level ${to} ${titleFor(to)}`);++ // Site-wide OpenCoins for OpenVibe accounts — one payout per level, idempotent in the wallet.++ if (p && p.kind === 'user') {++ try {++ const r = row(key);++ const paidTo = r?.coins_paid_level || 0;++ let total = 0;++ for (let l = Math.max(paidTo + 1, from + 1); l <= to; l++) total += l * COINS_PER_LEVEL;++ if (total > 0) {++ const opencoins = require('../monetization/opencoins');++ Promise.resolve(opencoins.credit(p.user_id, total, 'arena_yap_level', `live:arena_yap_level:${p.user_id}:${to}`, { level: to })).then(res => {++ if (res) console.log(`[Arena] ${name}: +${total} OpenCoins for reaching yap level ${to}`);++ }).catch(e => console.warn('[Arena] yap coins:', e.message));++ db.run('UPDATE chatter_profiles SET coins_paid_level = ? WHERE key = ?', [to, key]);++ }++ } catch (e) { console.warn('[Arena] yap coins:', e.message); }++ }++ // Tell the room.++ try {++ const chat = require('../chat/chat-server');++ const line = `⬆️ ${name} hit Yap Level ${to} — ${titleFor(to)}${p && p.kind === 'user' ? ` (+${to * COINS_PER_LEVEL} OpenCoins)` : ''}. Keep talking. ${(() => { try { const c = require('../config'); return `${String(c.baseUrl || '').replace(/\/$/, '')}/arena/chatter/${encodeURIComponent(key)}`; } catch { return ''; } })()}`;++ if (streamId && chat.broadcastToStream) chat.broadcastToStream(streamId, { type: 'system', message: line });++ } catch { /* chat server not up in tests */ }++}++++/** A chat line landed as a moment on a subject. */++function onMoment(moment, topic, { hot = false } = {}) {++ if (!moment || moment.kind !== 'chat') return null;++ const key = moment.chatter_key || keyFor(moment);++ if (!key) return null;++ ensureRow(key, { display: moment.username });++ const first = !db.get('SELECT 1 FROM chatter_subjects WHERE key = ? AND topic_id = ?', [key, topic.id]);++ db.run(`INSERT INTO chatter_subjects (key, topic_id, moments) VALUES (?, ?, 1) ON CONFLICT(key, topic_id) DO UPDATE SET moments = moments + 1, last_at = CURRENT_TIMESTAMP`, [key, topic.id]);++ let xp = hot ? XP_MOMENT_HOT : XP_MOMENT;++ if (first) xp += XP_FIRST_ON_SUBJECT;++ xp += streakBonus(key);++ db.run(`UPDATE chatter_profiles SET moments = moments + 1, subjects = (SELECT COUNT(*) FROM chatter_subjects s WHERE s.key = chatter_profiles.key), best_line = CASE WHEN best_line IS NULL OR LENGTH(?) > LENGTH(best_line) THEN ? ELSE best_line END, best_line_topic_id = CASE WHEN best_line IS NULL OR LENGTH(?) > LENGTH(best_line) THEN ? ELSE best_line_topic_id END WHERE key = ?`,++ [moment.text, clip(moment.text, 200), moment.text, topic.id, key]);++ return addXp(key, xp, first ? 'moment_first' : 'moment', topic.id, { display: moment.username, streamId: moment.stream_id || null });++}++++/** Daily streak: the first moment of a day extends the streak (or restarts it) and pays a bonus. */++function streakBonus(key) {++ const r = row(key);++ const d = today();++ if (!r || r.last_day === d) return 0;++ const yesterday = new Date(Date.now() - 86400_000).toISOString().slice(0, 10);++ const streak = r.last_day === yesterday ? (r.streak || 0) + 1 : 1;++ db.run('UPDATE chatter_profiles SET streak = ?, best_streak = MAX(best_streak, ?), last_day = ? WHERE key = ?', [streak, streak, d, key]);++ return XP_STREAK_DAY * Math.min(streak, 7);++}++++function onSubjectStarted(key, topicId, { display = null } = {}) {++ if (!key) return null;++ db.run('UPDATE chatter_profiles SET subjects_started = subjects_started + 1 WHERE key = ?', [key]);++ return addXp(key, XP_SUBJECT_STARTED, 'subject_started', topicId, { display });++}++function onHype(voterKey, topicId) {++ // voter keys from the API are user:<id> / anon:<anonId> / ip:<…>; only user/anon map to a chatter.++ const m = String(voterKey || '').match(/^(user:\d+|anon:\d+)$/);++ if (!m) return null;++ db.run('UPDATE chatter_profiles SET hypes = hypes + 1 WHERE key = ?', [m[1]]);++ return addXp(m[1], XP_HYPE, 'hype', topicId);++}++/** After a lore rewrite: every chatter whose name appears in the lore text gets quoted-XP (once per rewrite). */++function onLore(topic, loreText, moments) {++ const text = String(loreText || '').toLowerCase();++ if (!text) return 0;++ const seen = new Set();++ let n = 0;++ for (const m of moments || []) {++ if (m.kind !== 'chat') continue;++ const key = m.chatter_key || keyFor(m);++ if (!key || seen.has(key)) continue;++ const name = String(m.username || '').replace(/^\[[^\]]+\]\s+/, '').toLowerCase();++ if (name.length >= 3 && text.includes(name)) { seen.add(key); db.run('UPDATE chatter_profiles SET quoted = quoted + 1 WHERE key = ?', [key]); addXp(key, XP_QUOTED_IN_LORE, 'quoted_in_lore', topic.id, { display: m.username }); n++; }++ }++ return n;++}++++// ── Yap card (AI, cheap and rare) ────────────────────────────++++const CARD_SCHEMA = { name: 'arena_yap_card', schema: { type: 'object', additionalProperties: false, required: ['title', 'blurb', 'catchphrase', 'known_for', 'archetype'], properties: {++ title: { type: 'string', description: 'a ridiculous 2–5 word title for this chatter based on what they actually do in chat' },++ blurb: { type: 'string', description: '2–3 sentences, tabloid voice, dumb-funny, about who they are in chat: their bits, grudges, subjects, timing' },++ catchphrase: { type: 'string', description: 'one line in THEIR typing style (casing, punctuation, slang) they would actually send' },++ known_for: { type: 'array', minItems: 2, maxItems: 4, items: { type: 'string' }, description: '≤ 6 words each' },++ archetype: { type: 'string', description: 'one of: instigator, hype man, lore keeper, contrarian, one-liner, spammer, moralist, lurker-turned-menace, brand ambassador, glazer' },++} } };++const CARD_SYSTEM = `You write the "yap card" for a chatter on a live-streaming site — a trading-card style profile of who they are IN CHAT. You get the chat AI's notes about them (overview, long-term memory, timeline), their best lines that landed on the community board, the subjects they pile onto, and their streak. Be specific and funny; take the piss; this platform does not censor language. Copy their typing style for the catchphrase. Hard line: no direct threats of violence, nothing sexual about minors, no doxxing. Output only the JSON.`;++++function chatAiFor(key) {++ const p = parseKey(key);++ try {++ if (p.kind === 'user') return db.getChatAiSummary('user', p.user_id, 'rolling');++ if (p.kind === 'anon') return db.getChatAiSummary('anon', p.anon_num, 'rolling');++ if (p.kind === 'relay') { const ru = db.getRelayUser ? db.getRelayUser(p.platform, p.username) : null; return ru ? db.getChatAiSummary('relay', ru.id, 'rolling') : null; }++ } catch { /* */ }++ return null;++}++++async function buildCard(key, { force = false } = {}) {++ const r = row(key);++ if (!r) return null;++ if (!force) {++ if (r.level < CARD_MIN_LEVEL) return { skipped: 'level' };++ if (r.card_json && r.card_at && Date.now() - Date.parse(r.card_at + 'Z') < CARD_TTL_MS) return { skipped: 'fresh' };++ if (r.card_json && (r.xp - (r.card_xp || 0)) < 25) return { skipped: 'no change' };++ }++ if (!aiOn()) return { skipped: 'ai off' };++ const ai = chatAiFor(key);++ const ov = ai ? parseJson(ai.overview, null) : null;++ const lines = db.all(`SELECT m.text, t.text AS subject FROM arena_topic_moments m JOIN arena_topics t ON t.id = m.topic_id WHERE m.chatter_key = ? AND m.kind = 'chat' ORDER BY m.id DESC LIMIT 12`, [key]);++ const subjects = db.all('SELECT t.text, s.moments FROM chatter_subjects s JOIN arena_topics t ON t.id = s.topic_id WHERE s.key = ? ORDER BY s.moments DESC LIMIT 6', [key]);++ let out = null;++ try {++ const res = await llm.complete({ role: 'chat', kind: 'arena_yap_card', source: 'arena', ownerUserId: r.user_id || null, system: CARD_SYSTEM, json: CARD_SCHEMA, maxTokens: 320, temperature: 0.95, timeoutMs: 25000,++ user: JSON.stringify({ name: r.display_name, kind: r.kind, level: r.level, title: titleFor(r.level), streak: r.streak, chat_ai_overview: ov ? clip([ov.alltime, ov.today].filter(Boolean).join(' '), 900) : (ai ? clip(ai.overview, 900) : null), chat_ai_memory: ai ? clip(ai.memory_json, 700) : null, chat_ai_timeline: ai ? (parseJson(ai.timeline_json, []) || []).slice(-8).map(e => clip(typeof e === 'string' ? e : `${e.label || ''}: ${e.detail || ''}`, 140)) : [], best_lines: lines.map(l => `[${l.subject}] ${l.text}`), subjects: subjects.map(s => `${s.text} (${s.moments})`) }) });++ if (res && res.json && res.json.title) out = res.json;++ } catch (e) { console.warn('[Arena] yap card:', e.message); }++ if (!out) return { skipped: 'failed' };++ db.run('UPDATE chatter_profiles SET card_json = ?, card_at = CURRENT_TIMESTAMP, card_xp = xp WHERE key = ?', [JSON.stringify(out), key]);++ console.log(`[Arena] yap card for ${r.display_name}: "${out.title}"`);++ return out;++}++++async function cardSweep(max = CARD_BATCH) {++ ensureTables();++ if (!aiOn()) return 0;++ const due = db.all(`SELECT key FROM chatter_profiles WHERE level >= ? AND (card_json IS NULL OR card_at IS NULL OR card_at < datetime('now', '-24 hours')) AND (card_json IS NULL OR xp - card_xp >= 25) ORDER BY xp DESC LIMIT ?`, [CARD_MIN_LEVEL, max]);++ let n = 0;++ for (const d of due) { try { const r = await buildCard(d.key); if (r && !r.skipped) n++; } catch { /* */ } }++ return n;++}++++// ── Views ────────────────────────────────────────────────────++++function view(r, { detail = false } = {}) {++ if (!r) return null;++ const level = r.level || levelFor(r.xp);++ const nextXp = xpForLevel(level + 1), curXp = xpForLevel(level);++ const p = parseKey(r.key) || {};++ const user = p.kind === 'user' ? db.getUserById(p.user_id) : null;++ const out = {++ key: r.key, kind: r.kind, platform: r.platform || null, name: r.display_name || displayFor(r.key),++ user: user ? { id: user.id, username: user.username, display_name: user.display_name, avatar_url: user.avatar_url, profile_color: user.profile_color } : null,++ level, title: titleFor(level), xp: r.xp || 0, xp_into_level: (r.xp || 0) - curXp, xp_for_next: nextXp - curXp, next_level_xp: nextXp,++ moments: r.moments || 0, subjects: r.subjects || 0, subjects_started: r.subjects_started || 0, quoted: r.quoted || 0, hypes: r.hypes || 0,++ streak: r.last_day === today() || r.last_day === new Date(Date.now() - 86400_000).toISOString().slice(0, 10) ? (r.streak || 0) : 0, best_streak: r.best_streak || 0,++ best_line: r.best_line ? { text: r.best_line, topic_id: r.best_line_topic_id } : null,++ card: parseJson(r.card_json, null), card_at: r.card_at, coins: p.kind === 'user', first_seen: r.first_seen, last_seen: r.last_seen,++ titles: TITLES.map(([l, t]) => ({ level: l, title: t, xp: xpForLevel(l) })),++ };++ if (detail) {++ out.recent_moments = db.all(`SELECT m.*, t.text AS subject, t.headline AS subject_headline FROM arena_topic_moments m JOIN arena_topics t ON t.id = m.topic_id WHERE m.chatter_key = ? ORDER BY m.id DESC LIMIT 40`, [r.key]).map(m => ({ id: m.id, topic_id: m.topic_id, subject: m.subject, subject_headline: m.subject_headline, text: m.text, at: m.created_at, stream_id: m.stream_id }));++ out.top_subjects = db.all('SELECT t.id, t.text, t.headline, t.status, s.moments FROM chatter_subjects s JOIN arena_topics t ON t.id = s.topic_id WHERE s.key = ? ORDER BY s.moments DESC LIMIT 8', [r.key]);++ out.xp_log = db.all('SELECT amount, reason, ref_id, created_at FROM chatter_xp_log WHERE key = ? ORDER BY id DESC LIMIT 30', [r.key]);++ const ai = chatAiFor(r.key);++ if (ai) { const ov = parseJson(ai.overview, null); out.chat_ai = { overview: ov ? (ov.today || ov.alltime) : clip(ai.overview, 600), memory: clip(ai.memory_json, 500) }; }++ out.rank = (db.get('SELECT COUNT(*) AS n FROM chatter_profiles WHERE xp > ?', [r.xp || 0])?.n || 0) + 1;++ }++ return out;++}++function profile(key) { ensureTables(); return view(row(key), { detail: true }); }++function leaderboard(limit = 10, { days = null } = {}) {++ ensureTables();++ if (days) {++ const rows = db.all(`SELECT key, SUM(amount) AS gained FROM chatter_xp_log WHERE created_at >= datetime('now', ?) GROUP BY key ORDER BY gained DESC LIMIT ?`, [`-${days} days`, limit]);++ return rows.map(x => ({ ...view(row(x.key)), gained: x.gained }));++ }++ return db.all('SELECT * FROM chatter_profiles WHERE xp > 0 ORDER BY xp DESC LIMIT ?', [limit]).map(r => view(r));++}++function count() { ensureTables(); return db.get('SELECT COUNT(*) AS n FROM chatter_profiles WHERE xp > 0')?.n || 0; }++++module.exports = { ensureTables, keyFor, parseKey, displayFor, levelFor, xpForLevel, titleFor, addXp, onMoment, onSubjectStarted, onHype, onLore, buildCard, cardSweep, profile, leaderboard, count, view, row, TITLES, XP_MOMENT, XP_MOMENT_HOT, XP_FIRST_ON_SUBJECT, XP_SUBJECT_STARTED, XP_QUOTED_IN_LORE, COINS_PER_LEVEL, CARD_MIN_LEVEL };@@@ -99,7 +99,7 @@ router.get('/board', (req, res) => {try {const v = board.boardView();res.set('Cache-Control', 'no-store');-- res.json({ ...v, levels: board.levelsLeaderboard(8), yappers: board.yappersLeaderboard(8), ai: arena.aiOn() });++ const ch = require('./chatters'); res.json({ ...v, levels: board.levelsLeaderboard(8), yappers: ch.leaderboard(8), yappers_week: ch.leaderboard(5, { days: 7 }), yappers_total: ch.count(), ai: arena.aiOn() });} catch (err) { fail(res, err, 'Failed to load the board'); }});router.post('/board/topics', requireAuth, async (req, res) => {@@@ -173,6 +173,16 @@ router.get('/voice/:user', optionalAuth, async (req, res) => {});router.get('/levels', (req, res) => { try { res.json({ levels: board.levelsLeaderboard(20) }); } catch (err) { fail(res, err, 'Failed'); } });--router.get('/yappers', (req, res) => { try { res.json({ yappers: board.yappersLeaderboard(20) }); } catch (err) { fail(res, err, 'Failed'); } });++router.get('/yappers', (req, res) => { try { const ch = require('./chatters'); res.json({ yappers: ch.leaderboard(20), week: ch.leaderboard(10, { days: 7 }), total: ch.count() }); } catch (err) { fail(res, err, 'Failed'); } });++// Chatter (yapper) profile by key: user:<id> · anon:<n> · relay:<platform>:<name> — anyone who chats has one.++router.get('/chatter/:key', (req, res) => {++ try { const p = require('./chatters').profile(String(req.params.key || '')); if (!p) return res.status(404).json({ error: 'No such chatter yet — say something about a subject first' }); res.set('Cache-Control', 'no-store'); res.json(p); }++ catch (err) { fail(res, err, 'Failed to load chatter'); }++});++router.get('/chatter/by-user/:username', (req, res) => {++ try { const u = userFrom(req.params.username); if (!u) return res.status(404).json({ error: 'No such user' }); const p = require('./chatters').profile(`user:${u.id}`); if (!p) return res.status(404).json({ error: 'No yap profile yet' }); res.json(p); }++ catch (err) { fail(res, err, 'Failed'); }++});++router.post('/chatter/:key/card', requireAuth, permissions.requireAdmin, async (req, res) => { try { res.json(await require('./chatters').buildCard(String(req.params.key), { force: true })); } catch (err) { fail(res, err, 'Card failed'); } });module.exports = router;@@@ -111,7 +111,7 @@ board.ensureTables(); beef.ensureTables();assert.strictEqual(view.cooldown_hours, 24);const yap = board.yappersLeaderboard();assert.deepStrictEqual(yap.map(y => y.name).sort(), ['anon_guy', 'grizzly_bear', 'viewer'], 'everyone who typed about a subject is a yapper');-- assert.ok(yap.every(y => y.moments === 1 && y.topics === 1));++ assert.ok(yap.every(y => y.moments === 1 && y.subjects === 1 && y.level >= 1 && y.title), 'yapper profiles carry level + title');console.log('✅ lore, hype, heat ordering, yappers');// Discovery without AI: a word ≥ 3 people keep saying becomes a subject, seeded with what was said.nnew file mode 100644@@@ -0,0 +1,67 @@++'use strict';++// Yapper profiles: one key for accounts / anon / relay chatters, XP from board moments, levels,++// titles, streaks, lore quotes, leaderboards — and level-up coins only for accounts.++const assert = require('assert');++const fs = require('fs'); const os = require('os'); const path = require('path');++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-yap-'));++process.env.DB_PATH = path.join(tmp, 'live.db');++const db = require('../server/db/database'); db.initDb();++const ch = require('../server/arena/chatters');++const board = require('../server/arena/board');++const arena = require('../server/arena/arena-service');++arena.ensureTables(); board.ensureTables(); ch.ensureTables();++++assert.strictEqual(ch.keyFor({ user_id: 7, username: 'Goosely' }), 'user:7');++assert.strictEqual(ch.keyFor({ user_id: null, anon_id: 'anon4412', username: 'anon4412' }), 'anon:4412');++assert.strictEqual(ch.keyFor({ user_id: null, username: 'anon99' }), 'anon:99', 'anon without anon_id column still keys');++assert.strictEqual(ch.keyFor({ user_id: null, username: '[Twitch] BobLol', source_platform: 'twitch' }), 'relay:twitch:boblol');++assert.strictEqual(ch.keyFor({ user_id: null, username: '[RS] dan' }), 'relay:rs:dan', 'label alone identifies the platform');++assert.deepStrictEqual(ch.parseKey('relay:kick:some:name'), { kind: 'relay', platform: 'kick', username: 'some:name' });++assert.deepStrictEqual([0, 24, 25, 100, 400, 2500].map(ch.levelFor), [1, 1, 2, 3, 5, 11]);++assert.deepStrictEqual([1, 3, 5, 12, 20].map(ch.titleFor), ['Lurker', 'Yapper', 'Instigator', 'Main Character', 'Final Boss']);++console.log('✅ chatter keys, level curve, titles');++++const uid = Number(db.createUser({ username: 'viewer', email: 'v@x', password_hash: 'x', display_name: 'Viewer', stream_key: 'v'.repeat(32) }).lastInsertRowid);++const t = board.createTopic({ text: 'The baby voice', createdBy: 'community', creatorName: 'global chat', keywords: ['baby voice'] });++const say = (uid2, uname, msg, extra = {}) => board.addMoment(t.id, { kind: 'chat', source: 'chat', userId: uid2, username: uname, text: msg, ...extra });++say(uid, 'viewer', 'the baby voice is a war crime');++let p = ch.profile('user:viewer'.replace('viewer', uid));++assert.strictEqual(p.xp, ch.XP_MOMENT + ch.XP_FIRST_ON_SUBJECT + 5, 'moment + first-on-subject + day-1 streak');++assert.strictEqual(p.streak, 1); assert.strictEqual(p.subjects, 1); assert.strictEqual(p.moments, 1);++say(uid, 'viewer', 'baby voice again');++p = ch.profile(`user:${uid}`);++assert.strictEqual(p.xp, 12 + ch.XP_MOMENT, 'second line: just the moment (streak already paid today)');++say(null, 'anon4412', 'baby voice enjoyers rise', { anonId: 'anon4412' });++say(null, '[Twitch] BobLol', 'the baby voice guy again', { platform: 'twitch' });++assert.strictEqual(ch.profile('anon:4412').kind, 'anon'); assert.strictEqual(ch.profile('relay:twitch:boblol').name, '[Twitch] BobLol');++assert.strictEqual(ch.profile('anon:4412').coins, false, 'anon: level + title, no coins');++db.run(`UPDATE arena_topics SET heat = 50 WHERE id = ?`, [t.id]);++say(uid, 'viewer', 'HOT take on the baby voice');++assert.strictEqual(ch.profile(`user:${uid}`).xp, 15 + ch.XP_MOMENT_HOT, 'hot subjects pay double');++assert.strictEqual(db.get('SELECT chatter_key FROM arena_topic_moments ORDER BY id DESC LIMIT 1').chatter_key, `user:${uid}`);++console.log('✅ moments → XP for accounts, anon and relay chatters');++++(async () => {++ const before = ch.profile(`user:${uid}`).xp;++ const lore = await board.buildLore(t.id, { force: true });++ assert.ok(/viewer/.test(lore.lore), 'template lore names the chatter');++ assert.strictEqual(ch.profile(`user:${uid}`).xp, before + ch.XP_QUOTED_IN_LORE, 'quoted in the lore → XP');++ assert.strictEqual(ch.profile(`user:${uid}`).quoted, 1);++ ch.onHype(`user:${uid}`, t.id); assert.strictEqual(ch.profile(`user:${uid}`).hypes, 1);++ ch.onSubjectStarted(`user:${uid}`, t.id, { display: 'Viewer' }); assert.strictEqual(ch.profile(`user:${uid}`).subjects_started, 1);++ // Level up: force XP over the level-3 line; coins are attempted once per level (wallet is unlinked here → no-op, but marked paid).++ const r = ch.addXp(`user:${uid}`, 200, 'test');++ assert.ok(r.leveled_up && r.level >= 3, JSON.stringify(r));++ assert.strictEqual(ch.row(`user:${uid}`).coins_paid_level, r.level, 'coins paid up to the new level (idempotent)');++ const r2 = ch.addXp(`user:${uid}`, 1, 'test'); assert.strictEqual(r2.leveled_up, false);++ const lb = ch.leaderboard(5);++ assert.strictEqual(lb[0].key, `user:${uid}`); assert.ok(lb.length === 3 && lb[0].title === ch.titleFor(lb[0].level));++ assert.strictEqual(ch.leaderboard(5, { days: 7 })[0].gained, ch.profile(`user:${uid}`).xp, 'weekly board sums the log');++ const prof = ch.profile(`user:${uid}`);++ assert.ok(prof.recent_moments.length === 3 && prof.top_subjects[0].id === t.id && prof.rank === 1 && prof.titles.length === ch.TITLES.length);++ assert.deepStrictEqual(await ch.buildCard(`user:${uid}`), { skipped: 'ai off' }, 'no AI → no card call');++ assert.strictEqual(await ch.cardSweep(), 0);++ console.log('✅ lore quotes, hype, subjects, level-ups (coins once per level), leaderboards, profile');++ console.log('\n✅ All chatter tests passed');++ process.exit(0);++})().catch(err => { console.error(err); process.exit(1); });
@@ -96,3 +96,22 @@ Tests: `node test/arena.test.js` (roster, ratings, filter, quotes) · `node test
@@ -96,3 +96,22 @@ Tests: `node test/arena.test.js` (roster, ratings, filter, quotes) · `node test
- **Hear it in their voice** — every 🔊 in the Arena calls `GET /api/arena/voice/<username>?t=<line>` (`server/arena/voice.js`): the line is synthesized in the streamer's **equipped cosmetic chat voice** (else the same per-identity auto voice chat gives `user:<username>`), written once to `data/tts-cache/<sha(voice+text)>.<mp3|wav>` and served with `Cache-Control: max-age=604800` — a repeat click never re-synthesizes (server cache + browser cache). `announcer` reads headlines/lore in `arena_announcer_voice` (default `tts_default_voice`). Cloud engines are metered into `ai_usage` (kind `tts`); `arena_voice_daily_max` (2000) caps fresh syntheses per day and per-IP limits are 8/min anonymous, 30/min signed in. Browser speech is only the fallback when the engine is down.
- **Hear it in their voice** — every 🔊 in the Arena calls `GET /api/arena/voice/<username>?t=<line>` (`server/arena/voice.js`): the line is synthesized in the streamer's **equipped cosmetic chat voice** (else the same per-identity auto voice chat gives `user:<username>`), written once to `data/tts-cache/<sha(voice+text)>.<mp3|wav>` and served with `Cache-Control: max-age=604800` — a repeat click never re-synthesizes (server cache + browser cache). `announcer` reads headlines/lore in `arena_announcer_voice` (default `tts_default_voice`). Cloud engines are metered into `ai_usage` (kind `tts`); `arena_voice_daily_max` (2000) caps fresh syntheses per day and per-IP limits are 8/min anonymous, 30/min signed in. Browser speech is only the fallback when the engine is down.
- **Portraits from their own frames** — `generateImage` now feeds up to 3 real frames (live thumbnail, AI-moment frames the vision job persisted, VOD thumbnails — frames whose description mentions a person first) to `images/edits`, asking for a character-select caricature that keeps their setup, gear, lighting, silhouette and the vibe of their scene; text-only generation is only the fallback for streamers with no frames yet. Custom stats and the persona shape the costume/props.
- **Portraits from their own frames** — `generateImage` now feeds up to 3 real frames (live thumbnail, AI-moment frames the vision job persisted, VOD thumbnails — frames whose description mentions a person first) to `images/edits`, asking for a character-select caricature that keeps their setup, gear, lighting, silhouette and the vibe of their scene; text-only generation is only the fallback for streamers with no frames yet. Custom stats and the persona shape the costume/props.
+
+## Yappers — profiles for everyone who chats (`server/arena/chatters.js`)
+
+One polymorphic key per chatter — `user:<id>`, `anon:<N>` (the site's IP-derived `anon<N>` identity) or `relay:<platform>:<name>` (chat relayed from Twitch/Kick/YouTube/RobotStreamer) — so anonymous viewers and relayed chatters get profiles too. No extra AI calls for XP: it falls out of the board pipeline.
+
+| XP | for |
+|---|---|
+| +3 / +6 | a chat line that lands as a moment on a subject (double when the subject is HOT) |
+| +4 | the first time you touch a subject |
+| +15 | starting a subject (`!topic` / the page) |
+| +10 | being quoted in a subject's lore (name appears in the rewritten lore) |
+| +1 | hyping a fighter on a subject |
+| +5 × streak (≤ 7) | the first moment of each day; streak = consecutive days |
+
+Level = `1 + floor(√(XP / 25))` (the game's curve). Titles: Lurker → Chatter (2) → Yapper (3) → Instigator (5) → Ragebaiter (7) → Menace (9) → Main Character (12) → Community Consciousness (15) → Final Boss (20). **OpenVibe accounts bank `level × 10` OpenCoins on every level-up** through the network wallet (idempotent key `live:arena_yap_level:<user>:<level>`; unlinked accounts are simply skipped); anons/relay chatters keep the level and the title. Level-ups are announced in the room the moment happened in.
+
+**Yap card** — at level ≥ 3, at most once a day and only when ≥ 25 XP changed, the AI writes a card (title, blurb, catchphrase in their typing style, "known for", archetype) from the chat AI's existing profile of them (`chat_ai_summaries` user/anon/relay) + their best lines and subjects; ≤ 4 cards per housekeeping minute.
+
+Where it shows: the Yappers block on `/arena` (podium with level rings, streak flames, cards; weekly movers), `/arena/chatter/<key>` (card, XP bar, titles ladder, moments, subjects, what the chat AI has on them), and the chat context menu (click a name → yap level + title + bar, linking to the page). API: `GET /api/arena/yappers`, `GET /api/arena/chatter/:key`, `GET /api/arena/chatter/by-user/:username`, admin `POST /api/arena/chatter/:key/card`.
@@ -14457,3 +14457,56 @@ a.arena-tag:hover { color: var(--accent-light); }
@@ -14457,3 +14457,56 @@ a.arena-tag:hover { color: var(--accent-light); }
.arena-focus small { color: var(--text-secondary); margin-left: 6px; }
.arena-focus small { color: var(--text-secondary); margin-left: 6px; }
.arena-focus-ctx { margin-top: 6px; font-size: 0.8rem; color: var(--text-muted); }
.arena-focus-ctx { margin-top: 6px; font-size: 0.8rem; color: var(--text-muted); }
.arena-focus .arena-note { margin: 6px 0 0; }
.arena-focus .arena-note { margin: 6px 0 0; }
+
+/* ── Arena: yapper profiles ── */
+.arena-ladder-yap { grid-column: 1 / -1; }
+.arena-yap-podium { display: grid; grid-template-columns: 1.3fr 1fr 1fr; gap: 10px; margin-bottom: 10px; }
+.arena-yap-card { display: grid; grid-template-columns: auto auto 1fr; gap: 10px; align-items: start; padding: 12px; border-radius: 14px; background: var(--bg-tertiary); border: 1px solid var(--border); position: relative; overflow: hidden; }
+.arena-yap-card.is-top { border-color: color-mix(in srgb, var(--yc) 60%, var(--border)); background: linear-gradient(135deg, color-mix(in srgb, var(--yc) 14%, var(--bg-tertiary)), var(--bg-tertiary) 60%); }
+.arena-yap-card.is-top::after { content: ''; position: absolute; inset: -40%; background: conic-gradient(from 0deg, transparent 0 70%, color-mix(in srgb, var(--yc) 25%, transparent) 80%, transparent 90%); animation: arena-spin 6s linear infinite; pointer-events: none; }
+@keyframes arena-spin { to { transform: rotate(360deg); } }
+.arena-yap-rank { font-weight: 900; font-size: 1.2rem; color: var(--text-muted); }
+.arena-yap-card.is-top .arena-yap-rank { color: #f5c542; }
+.arena-yap-ring { position: relative; display: inline-grid; place-items: center; flex-shrink: 0; }
+.arena-yap-ring svg { position: absolute; inset: 0; transform: rotate(-90deg); }
+.arena-yap-ring-bg { fill: none; stroke: var(--border); stroke-width: 3; }
+.arena-yap-ring-fg { fill: none; stroke: var(--yc, #f97316); stroke-width: 3; stroke-linecap: round; transition: stroke-dashoffset 0.8s cubic-bezier(0.2, 0.8, 0.2, 1); filter: drop-shadow(0 0 4px color-mix(in srgb, var(--yc, #f97316) 70%, transparent)); }
+.arena-yap-ring b { font-size: 0.9em; font-weight: 900; color: var(--text-primary); }
+.arena-yap-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; position: relative; }
+.arena-yap-name { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
+.arena-yap-name a { color: var(--text-primary); text-decoration: none; }
+.arena-yap-title { font-weight: 800; color: var(--yc, #f97316); font-size: 0.92rem; }
+.arena-yap-title small { color: var(--text-muted); font-weight: 400; font-size: 0.74rem; }
+.arena-yap-blurb { margin: 0; font-size: 0.84rem; color: var(--text-secondary); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
+.arena-yap-line { display: block; font-size: 0.86rem; color: var(--text-secondary); quotes: "“" "”"; }
+.arena-yap-stats { display: flex; gap: 10px; flex-wrap: wrap; font-size: 0.74rem; color: var(--text-muted); }
+.arena-yap-stats b { color: var(--text-primary); }
+.arena-yap-row { grid-template-columns: 28px auto 1fr auto; }
+.arena-yap-week { margin-top: 10px; display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
+.arena-streak i { animation: arena-flame 0.9s ease-in-out infinite alternate; }
+@keyframes arena-flame { from { transform: scale(1); } to { transform: scale(1.25) rotate(-6deg); } }
+.arena-flash { position: absolute; right: 14px; top: 10px; padding: 4px 10px; border-radius: 999px; background: #f97316; color: #1a1006; font-weight: 800; font-size: 0.72rem; animation: arena-pop 2.2s ease forwards; z-index: 2; }
+@keyframes arena-pop { 0% { transform: scale(0.6); opacity: 0; } 15% { transform: scale(1.1); opacity: 1; } 80% { opacity: 1; } 100% { transform: translateY(-10px); opacity: 0; } }
+.arena-ladder { position: relative; }
+.arena-yap-page { display: flex; flex-direction: column; gap: 14px; }
+.arena-yap-hero { display: grid; grid-template-columns: auto 1fr; gap: 18px; align-items: center; padding: 18px; border-radius: 18px; background: linear-gradient(135deg, color-mix(in srgb, var(--yc) 16%, var(--bg-card)), var(--bg-card) 60%); border: 1px solid color-mix(in srgb, var(--yc) 45%, var(--border)); }
+.arena-yap-hero-main { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
+.arena-yap-hero h1 { margin: 0; font-size: 1.6rem; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
+.arena-yap-hero-title { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
+.arena-yap-card-title { font-weight: 800; color: var(--yc); }
+.arena-yap-cardbox { padding: 14px; border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); }
+.arena-yap-cardbox h3 { margin: 0 0 8px; font-size: 0.98rem; display: flex; gap: 8px; align-items: center; }
+.arena-yap-cardbox h3 small { color: var(--text-muted); font-weight: 400; font-size: 0.76rem; }
+.arena-yap-cardbox p { margin: 0 0 8px; line-height: 1.55; }
+.arena-mini-row.is-earned .arena-rank { color: #f5c542; }
+@media (max-width: 900px) { .arena-yap-podium { grid-template-columns: 1fr; } .arena-yap-hero { grid-template-columns: 1fr; } }
+
+/* ── chat context menu: Arena yap level ── */
+.ctx-yap { display: flex; flex-direction: column; gap: 4px; padding: 8px 12px; border-top: 1px solid var(--border); font-size: 0.78rem; }
+.ctx-yap-link { display: flex; align-items: center; gap: 6px; color: var(--text-primary); text-decoration: none; }
+.ctx-yap-lvl { font-weight: 900; font-size: 0.68rem; letter-spacing: 0.06em; padding: 1px 6px; border-radius: 5px; background: linear-gradient(90deg, #f97316, #fbbf24); color: #1a1006; }
+.ctx-yap-title { font-weight: 700; }
+.ctx-yap-streak { color: #fb923c; font-size: 0.72rem; }
+.ctx-yap-bar { display: block; height: 4px; border-radius: 2px; background: var(--bg-tertiary); overflow: hidden; }
+.ctx-yap-bar span { display: block; height: 100%; background: linear-gradient(90deg, #f97316, #fbbf24); }
+.ctx-yap small { color: var(--text-muted); }
@@ -62,7 +62,7 @@
@@ -62,7 +62,7 @@
</script>
</script>
<!-- Stylesheets -->
<!-- Stylesheets -->
- <link rel="stylesheet" href="/css/style.css?v=148">
+ <link rel="stylesheet" href="/css/style.css?v=149">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
@@ -5041,7 +5041,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5041,7 +5041,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
<script src="/js/gif-picker.js?v=1" defer></script>
<script src="/js/gif-picker.js?v=1" defer></script>
-<script src="/js/chat.js?v=77" defer></script>
+<script src="/js/chat.js?v=78" defer></script>
<script src="/js/chat-autocomplete.js?v=1" defer></script>
<script src="/js/chat-autocomplete.js?v=1" defer></script>
<script src="/js/channel-uploads.js?v=12" defer></script>
<script src="/js/channel-uploads.js?v=12" defer></script>
<script src="/js/controls.js?v=8" defer></script>
<script src="/js/controls.js?v=8" defer></script>
@@ -5107,7 +5107,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5107,7 +5107,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/staff-console.js?v=1" defer></script>
<script src="/js/staff-console.js?v=1" defer></script>
<script src="/js/dashboard-moderation.js?v=5" defer></script>
<script src="/js/dashboard-moderation.js?v=5" defer></script>
<script src="/js/ai-viewers.js?v=2" defer></script>
<script src="/js/ai-viewers.js?v=2" defer></script>
-<script src="/js/arena.js?v=9" defer></script>
+<script src="/js/arena.js?v=10" defer></script>
<script src="/js/powerchat.js?v=10" defer></script>
<script src="/js/powerchat.js?v=10" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
@@ -180,6 +180,7 @@ async function loadArenaPage(segments = []) {
@@ -180,6 +180,7 @@ async function loadArenaPage(segments = []) {
if (first === 'beef' && second) return await _aRenderBeef(root, Number(second));
if (first === 'beef' && second) return await _aRenderBeef(root, Number(second));
if (first === 'topic' && second) return await _aRenderTopic(root, Number(second));
if (first === 'topic' && second) return await _aRenderTopic(root, Number(second));
if (first === 'live' && second) return await _aRenderConsole(root, second);
if (first === 'live' && second) return await _aRenderConsole(root, second);
+ if (first === 'chatter' && second) return await _aRenderChatter(root, decodeURIComponent(second));
if (first) return await _aRenderFighter(root, first);
if (first) return await _aRenderFighter(root, first);
return await _aRenderHome(root);
return await _aRenderHome(root);
} catch (err) {
} catch (err) {
@@ -213,9 +214,8 @@ async function _aRenderHome(root) {
@@ -213,9 +214,8 @@ async function _aRenderHome(root) {
<h2><i class="fa-solid fa-fire"></i> Trash Level ladder <small>XP from beef hits, judged moments on subjects, chat hype</small></h2>
<h2><i class="fa-solid fa-fire"></i> Trash Level ladder <small>XP from beef hits, judged moments on subjects, chat hype</small></h2>
${_aLevelsList(boardData.levels || [])}
${_aLevelsList(boardData.levels || [])}
</div>
</div>
- <div class="arena-ladder">
- <h2><i class="fa-solid fa-keyboard"></i> Yappers <small>chatters who keep the subjects alive (7 days)</small></h2>
- ${_aYappersList(boardData.yappers || [])}
+ <div class="arena-ladder arena-ladder-yap" id="arena-yappers">
+ ${_aYappersSection(boardData)}
</div>
</div>
</section>
</section>
<section class="arena-leaderboard">
<section class="arena-leaderboard">
@@ -245,7 +245,8 @@ async function _aRenderHome(root) {
@@ -245,7 +245,8 @@ async function _aRenderHome(root) {
_aEvery(20000, async () => {
_aEvery(20000, async () => {
try {
try {
const [b, l, bd] = await Promise.all([api('/arena/beefs'), api('/arena/live'), api('/arena/board')]);
const [b, l, bd] = await Promise.all([api('/arena/beefs'), api('/arena/live'), api('/arena/board')]);
- const beefsEl = document.getElementById('arena-beefs'), liveEl = document.getElementById('arena-live'), boardEl = document.getElementById('arena-board'), pulseEl = document.getElementById('arena-pulse');
+ const beefsEl = document.getElementById('arena-beefs'), liveEl = document.getElementById('arena-live'), boardEl = document.getElementById('arena-board'), pulseEl = document.getElementById('arena-pulse'), yapEl = document.getElementById('arena-yappers');
+ if (yapEl) { const prevTop = yapEl.dataset.top; yapEl.innerHTML = _aYappersSection(bd); if (prevTop && bd.yappers?.[0] && prevTop !== bd.yappers[0].key) _aFlash(yapEl, 'new #1 yapper'); yapEl.dataset.top = bd.yappers?.[0]?.key || ''; }
if (beefsEl) beefsEl.innerHTML = _aRenderBeefs(b);
if (beefsEl) beefsEl.innerHTML = _aRenderBeefs(b);
if (liveEl) liveEl.innerHTML = _aRenderLive(l.live || []);
if (liveEl) liveEl.innerHTML = _aRenderLive(l.live || []);
if (boardEl) boardEl.innerHTML = _aRenderBoard(bd);
if (boardEl) boardEl.innerHTML = _aRenderBoard(bd);
@@ -511,13 +512,74 @@ function _aLevelsList(rows) {
@@ -511,13 +512,74 @@ function _aLevelsList(rows) {
<span class="arena-lvl arena-lvl-big">LVL ${r.level}</span>
<span class="arena-lvl arena-lvl-big">LVL ${r.level}</span>
</div>`).join('')}</div>`;
</div>`).join('')}</div>`;
}
}
-function _aYappersList(rows) {
- if (!rows.length) return '<p class="arena-note">Type about a subject on the board in any chat — your lines become moments and you show up here.</p>';
- return `<div class="arena-mini-list">${rows.map((r, i) => `<div class="arena-mini-row">
- <span class="arena-rank ${i < 3 ? `arena-rank-${i + 1}` : ''}">${i + 1}</span>
- <span class="arena-chip"><span><strong>${r.username ? _aA(_aChannelLink(r), _aEsc(r.name)) : _aEsc(r.name)}</strong><small>${r.moments} moment${r.moments === 1 ? '' : 's'} on ${r.topics} subject${r.topics === 1 ? '' : 's'}${r.last ? ` · “${_aEsc(String(r.last).slice(0, 60))}”` : ''}</small></span></span>
- <span class="arena-lvl">${r.moments}</span>
- </div>`).join('')}</div>`;
+function _aChatterLink(key) { return `/arena/chatter/${encodeURIComponent(key)}`; }
+function _aYapRing(y, size = 44) {
+ const pct = y.xp_for_next ? Math.max(0.04, Math.min(1, y.xp_into_level / y.xp_for_next)) : 1;
+ const r = (size / 2) - 3, c = 2 * Math.PI * r;
+ const color = y.user?.profile_color || (y.kind === 'relay' ? '#60a5fa' : y.kind === 'anon' ? '#9ca3af' : '#f97316');
+ return `<span class="arena-yap-ring" style="--yc:${_aEsc(color)};width:${size}px;height:${size}px"><svg viewBox="0 0 ${size} ${size}" width="${size}" height="${size}"><circle cx="${size / 2}" cy="${size / 2}" r="${r}" class="arena-yap-ring-bg"></circle><circle cx="${size / 2}" cy="${size / 2}" r="${r}" class="arena-yap-ring-fg" stroke-dasharray="${c.toFixed(1)}" stroke-dashoffset="${(c * (1 - pct)).toFixed(1)}"></circle></svg><b>${y.level}</b></span>`;
+}
+function _aYapKind(y) { return y.kind === 'relay' ? `<span class="arena-tag arena-tag-dim" title="relayed from ${_aEsc(y.platform || 'another platform')}"><i class="fa-solid fa-satellite-dish"></i> ${_aEsc(y.platform || 'relay')}</span>` : y.kind === 'anon' ? '<span class="arena-tag arena-tag-dim" title="anonymous viewer"><i class="fa-solid fa-user-secret"></i> anon</span>' : ''; }
+function _aYapCard(y, i) {
+ const card = y.card || {};
+ return `<div class="arena-yap-card ${i === 0 ? 'is-top' : ''}" style="--yc:${_aEsc(y.user?.profile_color || '#f97316')}">
+ <div class="arena-yap-rank">${i + 1}</div>
+ ${_aYapRing(y, i === 0 ? 64 : 52)}
+ <div class="arena-yap-main">
+ <div class="arena-yap-name">${_aA(_aChatterLink(y.key), `<strong>${_aEsc(y.name)}</strong>`)} ${_aYapKind(y)} ${y.streak >= 2 ? `<span class="arena-tag arena-tag-hot arena-streak" title="days in a row"><i class="fa-solid fa-fire"></i> ${y.streak}</span>` : ''}</div>
+ <div class="arena-yap-title">${_aEsc(card.title || y.title)} <small>· ${_aEsc(y.title)} · ${y.xp} XP</small></div>
+ ${card.blurb ? `<p class="arena-yap-blurb">${_aEsc(card.blurb)}</p>` : (y.best_line ? `<q class="arena-yap-line">${_aEsc(y.best_line.text)}</q>` : '')}
+ <div class="arena-yap-stats"><span><b>${y.moments}</b> moments</span><span><b>${y.subjects}</b> subjects</span>${y.quoted ? `<span><b>${y.quoted}</b> quoted in lore</span>` : ''}${y.gained != null ? `<span><b>+${y.gained}</b> this week</span>` : ''}</div>
+ </div>
+ </div>`;
+}
+function _aYappersSection(bd) {
+ const rows = bd.yappers || [], week = bd.yappers_week || [];
+ return `<h2><i class="fa-solid fa-keyboard"></i> Yappers <small>${bd.yappers_total || rows.length} chatters with a yap level · accounts, anons and relayed chatters alike</small></h2>
+ ${rows.length ? `<div class="arena-yap-podium">${rows.slice(0, 3).map(_aYapCard).join('')}</div>
+ ${rows.length > 3 ? `<div class="arena-mini-list">${rows.slice(3).map((y, i) => `<div class="arena-mini-row arena-yap-row">
+ <span class="arena-rank">${i + 4}</span>
+ ${_aYapRing(y, 34)}
+ <span class="arena-chip"><span><strong>${_aA(_aChatterLink(y.key), _aEsc(y.name))} ${_aYapKind(y)}</strong><small>${_aEsc((y.card && y.card.title) || y.title)} · ${y.moments} moments on ${y.subjects} subject${y.subjects === 1 ? '' : 's'}${y.streak >= 2 ? ` · 🔥 ${y.streak}` : ''}</small></span></span>
+ <span class="arena-lvl">${y.xp} XP</span>
+ </div>`).join('')}</div>` : ''}
+ ${week.length ? `<div class="arena-yap-week"><span class="arena-note"><i class="fa-solid fa-bolt"></i> hottest this week:</span> ${week.slice(0, 5).map(y => _aA(_aChatterLink(y.key), `${_aEsc(y.name)} <b>+${y.gained}</b>`, 'arena-tag')).join(' ')}</div>` : ''}` : '<p class="arena-note">Type about a subject on the board in any chat — your lines become moments, you get XP, a yap level and a card. Anons and relayed chatters too; OpenVibe accounts also get OpenCoins on every level-up.</p>'}`;
+}
+function _aFlash(el, text) {
+ const f = document.createElement('div'); f.className = 'arena-flash'; f.textContent = text; el.prepend(f); setTimeout(() => f.remove(), 2200);
+}
+
+// ── Chatter (yapper) page ────────────────────────────────────
+async function _aRenderChatter(root, key) {
+ root.innerHTML = _aSpinner('Pulling the yap sheet…');
+ let y;
+ try { y = await api(`/arena/chatter/${encodeURIComponent(key)}`); } catch (err) { root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-user-secret"></i><p>${_aEsc(err?.message || 'No yap profile yet')}</p>${_aA('/arena', 'Back to the Arena', 'btn')}</div>`; return; }
+ const card = y.card || {};
+ const next = y.titles.find(t => t.level > y.level);
+ root.innerHTML = `
+ <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')} ${y.user ? _aA(_aChannelLink(y.user), `<i class="fa-solid fa-user"></i> @${_aEsc(y.user.username)}`) : ''}</div>
+ <div class="arena-yap-page" style="--yc:${_aEsc(y.user?.profile_color || '#f97316')}">
+ <div class="arena-yap-hero">
+ ${_aYapRing(y, 96)}
+ <div class="arena-yap-hero-main">
+ <h1>${_aEsc(y.name)} ${_aYapKind(y)}</h1>
+ <div class="arena-yap-hero-title"><span class="arena-lvl arena-lvl-big">YAP LVL ${y.level} · ${_aEsc(y.title)}</span>${card.title ? `<span class="arena-yap-card-title">“${_aEsc(card.title)}”</span>` : ''}${card.archetype ? `<span class="arena-tag">${_aEsc(card.archetype)}</span>` : ''}</div>
+ <span class="arena-xp-track"><span class="arena-xp-fill" style="width:${y.xp_for_next ? Math.round((y.xp_into_level / y.xp_for_next) * 100) : 100}%"></span></span>
+ <small class="arena-note">${y.xp} XP · ${y.xp_into_level}/${y.xp_for_next} to level ${y.level + 1}${next ? ` · next title <b>${_aEsc(next.title)}</b> at level ${next.level}` : ''} · rank #${y.rank}${y.coins ? ' · OpenCoins on every level-up' : ' · sign in to bank OpenCoins for your levels'}</small>
+ <div class="arena-yap-stats"><span><b>${y.moments}</b> moments</span><span><b>${y.subjects}</b> subjects</span><span><b>${y.subjects_started}</b> started</span><span><b>${y.quoted}</b> quoted in lore</span><span><b>${y.hypes}</b> hypes</span><span><b>${y.streak}</b> day streak <small>(best ${y.best_streak})</small></span></div>
+ </div>
+ </div>
+ ${card.blurb ? `<section class="arena-yap-cardbox"><h3><i class="fa-solid fa-id-badge"></i> Yap card <small>AI-written from their chat history${y.card_at ? ` · ${_aEsc(_aAgo(y.card_at))}` : ''}</small></h3><p>${_aEsc(card.blurb)}</p>${card.catchphrase ? `<q class="arena-yap-line">${_aEsc(card.catchphrase)}</q>` : ''}${card.known_for?.length ? `<div class="arena-keywords"><span class="arena-note">known for:</span>${card.known_for.map(k => `<span>${_aEsc(k)}</span>`).join('')}</div>` : ''}</section>` : `<p class="arena-note">The AI writes a yap card at level ${3}+ from their chat history — keep yapping.</p>`}
+ ${y.chat_ai?.overview ? `<section class="arena-yap-cardbox"><h3><i class="fa-solid fa-brain"></i> What the chat AI has on them</h3><p>${_aEsc(y.chat_ai.overview)}</p></section>` : ''}
+ <div class="arena-topic-cols">
+ <section class="arena-moments"><h3><i class="fa-solid fa-stream"></i> Their moments <small>${y.recent_moments.length}</small></h3>${y.recent_moments.length ? `<div class="arena-moment-list">${y.recent_moments.map(m => `<div class="arena-moment is-chat"><i class="fa-solid fa-keyboard"></i><div class="arena-moment-body"><q>${_aEsc(m.text)}</q><small>on ${_aA(`/arena/topic/${m.topic_id}`, _aEsc(m.subject_headline || m.subject))} · ${_aEsc(_aAgo(m.at))}</small></div></div>`).join('')}</div>` : '<p class="arena-note">Nothing yet.</p>'}</section>
+ <aside class="arena-topic-aside">
+ <section><h3><i class="fa-solid fa-comments"></i> Subjects they pile on</h3>${y.top_subjects.length ? `<div class="arena-mini-list">${y.top_subjects.map((t, i) => `<div class="arena-mini-row"><span class="arena-rank">${i + 1}</span><span class="arena-chip"><span><strong>${_aA(`/arena/topic/${t.id}`, _aEsc(t.headline || t.text))}</strong><small>${_aEsc(t.status)}</small></span></span><span class="arena-lvl">${t.moments}</span></div>`).join('')}</div>` : '<p class="arena-note">None yet.</p>'}</section>
+ <section><h3><i class="fa-solid fa-ranking-star"></i> Titles</h3><div class="arena-mini-list">${y.titles.map(t => `<div class="arena-mini-row ${t.level <= y.level ? 'is-earned' : ''}"><span class="arena-rank">${t.level}</span><span class="arena-chip"><span><strong>${_aEsc(t.title)}</strong><small>${t.xp} XP</small></span></span><span>${t.level <= y.level ? '<i class="fa-solid fa-check" style="color:#4ade80"></i>' : ''}</span></div>`).join('')}</div></section>
+ <section><h3><i class="fa-solid fa-coins"></i> How XP works</h3><ul class="arena-rules-list"><li>A chat line that lands on a subject: +3 (+6 when it's HOT), +4 the first time you touch a subject.</li><li>Starting a subject: +15. Getting quoted in a subject's lore: +10. Hyping: +1.</li><li>Show up daily: +5 × streak (up to 7).</li><li>Level = 1 + √(XP ÷ 25). OpenVibe accounts bank <b>level × 10 OpenCoins</b> on every level-up; anons and relayed chatters keep the level and the title.</li></ul></section>
+ </aside>
+ </div>
+ </div>`;
}
}
function _aRenderList(fighters) {
function _aRenderList(fighters) {
@@ -4297,6 +4297,14 @@ function showChatContextMenu(event) {
@@ -4297,6 +4297,14 @@ function showChatContextMenu(event) {
} else {
} else {
loadContextMenuProfile(menu, coreUsername, userId, isAnon);
loadContextMenuProfile(menu, coreUsername, userId, isAnon);
}
}
+ // Arena yap level (accounts, anons and relayed chatters all have one once they've hit a subject).
+ try {
+ let yapKey = null;
+ if (isRelay) { const m = String(username || '').match(/^\[([A-Za-z]+)\]\s+(.+)$/); const plat = String(sourcePlatform || (m && m[1]) || '').toLowerCase().replace(/^yt$/, 'youtube').replace(/^robotstreamer$/, 'rs'); if (m && plat) yapKey = `relay:${plat}:${m[2].trim().toLowerCase()}`; }
+ else if (isAnon) { const m = String(coreUsername || username || '').match(/^anon(\d+)$/i); if (m) yapKey = `anon:${m[1]}`; }
+ else if (userId) yapKey = `user:${userId}`;
+ if (yapKey) _injectYapBlock(menu, yapKey);
+ } catch { /* optional */ }
// Clicks inside the menu shouldn't dismiss it (needed for rename submenu toggle etc.)
// Clicks inside the menu shouldn't dismiss it (needed for rename submenu toggle etc.)
menu.addEventListener('click', (e) => e.stopPropagation());
menu.addEventListener('click', (e) => e.stopPropagation());
@@ -4369,6 +4377,20 @@ function positionContextMenu(menu, x, y) {
@@ -4369,6 +4377,20 @@ function positionContextMenu(menu, x, y) {
menu._resizeHandler = onResize;
menu._resizeHandler = onResize;
}
}
+async function _injectYapBlock(menu, key) {
+ try {
+ const y = await api(`/arena/chatter/${encodeURIComponent(key)}`);
+ if (!y || !menu.isConnected) return;
+ const pct = y.xp_for_next ? Math.round((y.xp_into_level / y.xp_for_next) * 100) : 100;
+ const card = y.card || {};
+ const el = document.createElement('div');
+ el.className = 'ctx-yap';
+ el.innerHTML = `<a href="/arena/chatter/${encodeURIComponent(key)}" onclick="return handleLinkClick(event, '/arena/chatter/${encodeURIComponent(key)}')" class="ctx-yap-link"><span class="ctx-yap-lvl">YAP ${y.level}</span><span class="ctx-yap-title">${esc(card.title || y.title)}</span>${y.streak >= 2 ? `<span class="ctx-yap-streak">🔥 ${y.streak}</span>` : ''}</a><span class="ctx-yap-bar"><span style="width:${pct}%"></span></span><small>${y.xp} XP · ${y.moments} moments on ${y.subjects} subject${y.subjects === 1 ? '' : 's'}${card.catchphrase ? ` · “${esc(String(card.catchphrase).slice(0, 60))}”` : ''}</small>`;
+ const actions = menu.querySelector('.ctx-actions');
+ if (actions) actions.parentNode.insertBefore(el, actions); else menu.appendChild(el);
+ } catch { /* no profile yet */ }
+}
+
function dismissContextMenu() {
function dismissContextMenu() {
if (activeContextMenu) {
if (activeContextMenu) {
// Clean up observers
// Clean up observers
@@ -69,6 +69,7 @@ async function housekeeping() {
@@ -69,6 +69,7 @@ async function housekeeping() {
try { const r = board.scanChat(); if (r.moments) console.log(`[Arena] chat scan: ${r.moments} moment(s) from ${r.scanned} message(s)`); } catch (e) { console.warn('[Arena] chat scan:', e.message); }
try { const r = board.scanChat(); if (r.moments) console.log(`[Arena] chat scan: ${r.moments} moment(s) from ${r.scanned} message(s)`); } catch (e) { console.warn('[Arena] chat scan:', e.message); }
try { await board.discoverTopics(); } catch (e) { console.warn('[Arena] discover:', e.message); } // every 5 min, only with new material
try { await board.discoverTopics(); } catch (e) { console.warn('[Arena] discover:', e.message); } // every 5 min, only with new material
try { await board.loreSweep(3); } catch (e) { console.warn('[Arena] lore:', e.message); } // only topics with ≥ 3 new moments
try { await board.loreSweep(3); } catch (e) { console.warn('[Arena] lore:', e.message); } // only topics with ≥ 3 new moments
+ try { await require('./chatters').cardSweep(); } catch (e) { console.warn('[Arena] yap cards:', e.message); } // ≤ 4 chatters per minute, level ≥ 3, once a day each
}
}
function stop() {
function stop() {
@@ -438,6 +438,9 @@ function referenceImagesFor(userId) {
@@ -438,6 +438,9 @@ function referenceImagesFor(userId) {
if (out.length >= REF_MAX) break;
if (out.length >= REF_MAX) break;
}
}
} catch { /* */ }
} catch { /* */ }
+ // Media-hosted thumbnails of their streams (what prod actually has: https://openvibe.media/t/vod-….jpg).
+ try { for (const r of db.all(`SELECT thumbnail_url FROM streams WHERE user_id = ? AND thumbnail_url LIKE 'http%' ORDER BY started_at DESC LIMIT 3`, [userId])) push(r.thumbnail_url); } catch { /* */ }
+ try { for (const r of db.all(`SELECT m.thumbnail_url FROM stream_memories m WHERE m.user_id = ? AND m.thumbnail_url LIKE 'http%' AND (LOWER(m.description) LIKE '%person%' OR LOWER(m.description) LIKE '%face%' OR LOWER(m.description) LIKE '%wearing%' OR LOWER(m.description) LIKE '%headphones%') ORDER BY m.id DESC LIMIT 3`, [userId])) push(r.thumbnail_url); } catch { /* */ }
try { for (const v of db.all('SELECT thumbnail_url FROM vods WHERE user_id = ? AND thumbnail_url IS NOT NULL AND is_public = 1 ORDER BY created_at DESC LIMIT 3', [userId])) { if (/^https?:\/\//i.test(v.thumbnail_url)) push(v.thumbnail_url); else { const local = path.resolve('.' + v.thumbnail_url); if (fs.existsSync(local)) push(local); } } } catch { /* */ }
try { for (const v of db.all('SELECT thumbnail_url FROM vods WHERE user_id = ? AND thumbnail_url IS NOT NULL AND is_public = 1 ORDER BY created_at DESC LIMIT 3', [userId])) { if (/^https?:\/\//i.test(v.thumbnail_url)) push(v.thumbnail_url); else { const local = path.resolve('.' + v.thumbnail_url); if (fs.existsSync(local)) push(local); } } } catch { /* */ }
return out;
return out;
}
}
@@ -95,6 +95,8 @@ function ensureTables() {
@@ -95,6 +95,8 @@ function ensureTables() {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
)`);
db.run('CREATE INDEX IF NOT EXISTS idx_arena_moments_topic ON arena_topic_moments (topic_id, created_at)');
db.run('CREATE INDEX IF NOT EXISTS idx_arena_moments_topic ON arena_topic_moments (topic_id, created_at)');
+ for (const col of ['chatter_key TEXT', 'source_platform TEXT', 'anon_id TEXT']) { try { db.run(`ALTER TABLE arena_topic_moments ADD COLUMN ${col}`); } catch { /* exists */ } }
+ db.run('CREATE INDEX IF NOT EXISTS idx_arena_moments_chatter ON arena_topic_moments (chatter_key, id)');
db.run(`CREATE TABLE IF NOT EXISTS arena_trash_levels (
db.run(`CREATE TABLE IF NOT EXISTS arena_trash_levels (
user_id INTEGER PRIMARY KEY,
user_id INTEGER PRIMARY KEY,
xp INTEGER DEFAULT 0,
xp INTEGER DEFAULT 0,
@@ -285,24 +287,30 @@ async function submitTopic({ text, userId, ip, creatorName, onRoster = false })
@@ -285,24 +287,30 @@ async function submitTopic({ text, userId, ip, creatorName, onRoster = false })
}
}
const t = createTopic({ text: refined?.subject || raw, hint: refined?.hint || null, createdBy: onRoster ? 'streamer' : 'viewer', creatorUserId: userId, creatorName, creatorIp: ip, headline: refined?.headline || null, tagline: refined?.tagline || `Put up by ${creatorName}`, sourceNote: creatorName, keywords: refined?.keywords || null, submittedText: raw });
const t = createTopic({ text: refined?.subject || raw, hint: refined?.hint || null, createdBy: onRoster ? 'streamer' : 'viewer', creatorUserId: userId, creatorName, creatorIp: ip, headline: refined?.headline || null, tagline: refined?.tagline || `Put up by ${creatorName}`, sourceNote: creatorName, keywords: refined?.keywords || null, submittedText: raw });
backfillMoments([t]);
backfillMoments([t]);
+ try { require('./chatters').onSubjectStarted(`user:${userId}`, t.id, { display: creatorName }); } catch { /* */ }
return t;
return t;
}
}
/** A moment: something someone said (chat or on mic) about the topic. */
/** A moment: something someone said (chat or on mic) about the topic. */
-function addMoment(topicId, { kind, source, userId = null, username = null, streamId = null, vodId = null, sec = null, text, quality = null, about = null }) {
+function addMoment(topicId, { kind, source, userId = null, username = null, streamId = null, vodId = null, sec = null, text, quality = null, about = null, platform = null, anonId = null }) {
ensureTables();
ensureTables();
- const t = db.get(`SELECT id, kind, status FROM arena_topics WHERE id = ?`, [topicId]);
+ const t = db.get(`SELECT id, kind, status, heat FROM arena_topics WHERE id = ?`, [topicId]);
if (!t || t.status !== 'open') return null;
if (!t || t.status !== 'open') return null;
const body = clip(text, 240);
const body = clip(text, 240);
if (!body || arena()._isBannedText(body)) return null;
if (!body || arena()._isBannedText(body)) return null;
- db.run(`INSERT INTO arena_topic_moments (topic_id, kind, source, user_id, username, stream_id, vod_id, sec, text, quality, about) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- [topicId, kind, source, userId, username ? clip(username, 40) : null, streamId, vodId, sec, body, quality, about ? clip(about, 80) : null]);
+ const chatters = require('./chatters');
+ const chatterKey = chatters.keyFor({ user_id: userId, username, anon_id: anonId, source_platform: platform });
+ db.run(`INSERT INTO arena_topic_moments (topic_id, kind, source, user_id, username, stream_id, vod_id, sec, text, quality, about, chatter_key, source_platform, anon_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [topicId, kind, source, userId, username ? clip(username, 60) : null, streamId, vodId, sec, body, quality, about ? clip(about, 80) : null, chatterKey, platform, anonId]);
db.run(`UPDATE arena_topics SET hits = hits + 1, ${kind === 'chat' ? 'chat_mentions = chat_mentions + 1' : 'mic_mentions = mic_mentions + 1'}, last_mention_at = CURRENT_TIMESTAMP, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?`, [topicId]);
db.run(`UPDATE arena_topics SET hits = hits + 1, ${kind === 'chat' ? 'chat_mentions = chat_mentions + 1' : 'mic_mentions = mic_mentions + 1'}, last_mention_at = CURRENT_TIMESTAMP, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?`, [topicId]);
if (kind === 'speech' && userId && arena().loadRoster().byId[userId]) {
if (kind === 'speech' && userId && arena().loadRoster().byId[userId]) {
const m = db.get('SELECT 1 FROM arena_topic_members WHERE topic_id = ? AND user_id = ?', [topicId, userId]);
const m = db.get('SELECT 1 FROM arena_topic_members WHERE topic_id = ? AND user_id = ?', [topicId, userId]);
if (!m) { db.run('INSERT INTO arena_topic_members (topic_id, user_id, active) VALUES (?, ?, 1)', [topicId, userId]); db.run('UPDATE arena_topics SET joins = joins + 1 WHERE id = ?', [topicId]); addXp(userId, XP_JOIN, 'topic_joined', topicId, { joined: true }); }
if (!m) { db.run('INSERT INTO arena_topic_members (topic_id, user_id, active) VALUES (?, ?, 1)', [topicId, userId]); db.run('UPDATE arena_topics SET joins = joins + 1 WHERE id = ?', [topicId]); addXp(userId, XP_JOIN, 'topic_joined', topicId, { joined: true }); }
}
}
- return db.get('SELECT * FROM arena_topic_moments ORDER BY id DESC LIMIT 1');
+ const m = db.get('SELECT * FROM arena_topic_moments ORDER BY id DESC LIMIT 1');
+ // Yapper XP for chat lines (seeded backfills count too — they are real lines people typed).
+ if (kind === 'chat' && chatterKey) { try { chatters.onMoment(m, t, { hot: (t.heat || 0) >= HOT_THRESHOLD }); } catch (e) { console.warn('[Arena] yapper xp:', e.message); } }
+ return m;
}
}
/** Raw on-mic mention (no judge): one per stream per topic per cooldown. */
/** Raw on-mic mention (no judge): one per stream per topic per cooldown. */
@@ -346,7 +354,7 @@ function hypeTopic(topicId, userId, voterKey) {
@@ -346,7 +354,7 @@ function hypeTopic(topicId, userId, voterKey) {
ensureTables();
ensureTables();
if (voterKey === `user:${userId}`) throw new Error("You can't hype yourself");
if (voterKey === `user:${userId}`) throw new Error("You can't hype yourself");
const ins = db.run('INSERT OR IGNORE INTO arena_topic_hype (topic_id, user_id, voter_key) VALUES (?, ?, ?)', [topicId, userId, voterKey]);
const ins = db.run('INSERT OR IGNORE INTO arena_topic_hype (topic_id, user_id, voter_key) VALUES (?, ?, ?)', [topicId, userId, voterKey]);
- if (ins.changes) { addXp(userId, XP_HYPE, 'hype', topicId); db.run('UPDATE arena_topics SET last_activity_at = CURRENT_TIMESTAMP WHERE id = ?', [topicId]); }
+ if (ins.changes) { addXp(userId, XP_HYPE, 'hype', topicId); db.run('UPDATE arena_topics SET last_activity_at = CURRENT_TIMESTAMP WHERE id = ?', [topicId]); try { require('./chatters').onHype(voterKey, topicId); } catch { /* */ } }
const n = db.get('SELECT COUNT(*) AS n FROM arena_topic_hype WHERE topic_id = ? AND user_id = ?', [topicId, userId])?.n || 0;
const n = db.get('SELECT COUNT(*) AS n FROM arena_topic_hype WHERE topic_id = ? AND user_id = ?', [topicId, userId])?.n || 0;
return { added: !!ins.changes, hypers: n };
return { added: !!ins.changes, hypers: n };
}
}
@@ -413,13 +421,13 @@ let _scan = { lastChatId: null, lastDiscoverAt: 0, lastDiscoverSeen: 0 };
@@ -413,13 +421,13 @@ let _scan = { lastChatId: null, lastDiscoverAt: 0, lastDiscoverSeen: 0 };
function scanChat() {
function scanChat() {
ensureTables();
ensureTables();
if (_scan.lastChatId == null) { _scan.lastChatId = db.get('SELECT COALESCE(MAX(id), 0) AS id FROM chat_messages')?.id || 0; return { scanned: 0, moments: 0 }; }
if (_scan.lastChatId == null) { _scan.lastChatId = db.get('SELECT COALESCE(MAX(id), 0) AS id FROM chat_messages')?.id || 0; return { scanned: 0, moments: 0 }; }
- const rows = db.all(`SELECT id, stream_id, user_id, username, message FROM chat_messages WHERE id > ? AND COALESCE(is_deleted, 0) = 0 AND message IS NOT NULL AND LENGTH(message) BETWEEN 6 AND 300 ORDER BY id ASC LIMIT 400`, [_scan.lastChatId]);
+ const rows = db.all(`SELECT id, stream_id, user_id, username, message, source_platform, anon_id FROM chat_messages WHERE id > ? AND COALESCE(is_deleted, 0) = 0 AND message IS NOT NULL AND COALESCE(message_type, 'chat') = 'chat' AND COALESCE(source_platform, '') != 'ai' AND LENGTH(message) BETWEEN 6 AND 300 ORDER BY id ASC LIMIT 400`, [_scan.lastChatId]);
if (!rows.length) return { scanned: 0, moments: 0 };
if (!rows.length) return { scanned: 0, moments: 0 };
_scan.lastChatId = rows[rows.length - 1].id;
_scan.lastChatId = rows[rows.length - 1].id;
let moments = 0;
let moments = 0;
for (const r of rows) {
for (const r of rows) {
if (/^!/.test(r.message)) continue;
if (/^!/.test(r.message)) continue;
- for (const t of matchTopics(r.message)) { if (addMoment(t.id, { kind: 'chat', source: 'chat', userId: r.user_id, username: r.username, streamId: r.stream_id, text: r.message })) moments++; }
+ for (const t of matchTopics(r.message)) { if (addMoment(t.id, { kind: 'chat', source: 'chat', userId: r.user_id, username: r.username, streamId: r.stream_id, text: r.message, platform: r.source_platform || null, anonId: r.anon_id || null })) moments++; }
}
}
return { scanned: rows.length, moments };
return { scanned: rows.length, moments };
}
}
@@ -440,7 +448,7 @@ const DISCOVER_SYSTEM = `You are the community consciousness of a live-streaming
@@ -440,7 +448,7 @@ const DISCOVER_SYSTEM = `You are the community consciousness of a live-streaming
function discoverInput() {
function discoverInput() {
const out = { at: new Date().toISOString() };
const out = { at: new Date().toISOString() };
- try { out.chat = db.all(`SELECT c.username, c.message, c.stream_id FROM chat_messages c WHERE COALESCE(c.is_deleted, 0) = 0 AND c.timestamp >= datetime('now', ?) AND LENGTH(c.message) BETWEEN 8 AND 200 AND c.message NOT LIKE '!%' ORDER BY c.id DESC LIMIT 80`, [`-${SCAN_WINDOW_MIN} minutes`]).reverse().filter(r => !arena()._isBannedText(r.message)).map(r => clip(`[${r.stream_id ? 'stream' : 'global'}] ${r.username}: ${r.message}`, 180)); } catch { out.chat = []; }
+ try { out.chat = db.all(`SELECT c.username, c.message, c.stream_id FROM chat_messages c WHERE COALESCE(c.is_deleted, 0) = 0 AND COALESCE(c.source_platform, '') != 'ai' AND COALESCE(c.message_type, 'chat') = 'chat' AND c.timestamp >= datetime('now', ?) AND LENGTH(c.message) BETWEEN 8 AND 200 AND c.message NOT LIKE '!%' ORDER BY c.id DESC LIMIT 80`, [`-${SCAN_WINDOW_MIN} minutes`]).reverse().filter(r => !arena()._isBannedText(r.message)).map(r => clip(`[${r.stream_id ? 'stream' : 'global'}] ${r.username}: ${r.message}`, 180)); } catch { out.chat = []; }
try { out.on_mic = db.all(`SELECT u.username, e.text FROM stream_timeline_events e JOIN users u ON u.id = e.user_id WHERE e.kind = 'speech' AND e.created_at >= datetime('now', ?) AND LENGTH(e.text) BETWEEN 25 AND 220 ORDER BY e.id DESC LIMIT 60`, [`-${SCAN_WINDOW_MIN} minutes`]).reverse().filter(r => !arena()._isBannedText(r.text)).map(r => clip(`${r.username}: ${r.text}`, 200)); } catch { out.on_mic = []; }
try { out.on_mic = db.all(`SELECT u.username, e.text FROM stream_timeline_events e JOIN users u ON u.id = e.user_id WHERE e.kind = 'speech' AND e.created_at >= datetime('now', ?) AND LENGTH(e.text) BETWEEN 25 AND 220 ORDER BY e.id DESC LIMIT 60`, [`-${SCAN_WINDOW_MIN} minutes`]).reverse().filter(r => !arena()._isBannedText(r.text)).map(r => clip(`${r.username}: ${r.text}`, 200)); } catch { out.on_mic = []; }
try { out.chat_timeline = db.all(`SELECT label, detail FROM chat_timeline_events WHERE created_at >= datetime('now', '-6 hours') ORDER BY id DESC LIMIT 8`).map(r => clip(`${r.label}: ${r.detail || ''}`, 140)); } catch { out.chat_timeline = []; }
try { out.chat_timeline = db.all(`SELECT label, detail FROM chat_timeline_events WHERE created_at >= datetime('now', '-6 hours') ORDER BY id DESC LIMIT 8`).map(r => clip(`${r.label}: ${r.detail || ''}`, 140)); } catch { out.chat_timeline = []; }
try { const g = db.getChatAiSummary('global', 0, 'rolling'); if (g) { const ov = parseJson(g.overview, null); out.global_chat_overview = clip(ov ? (ov.today || ov.alltime) : g.overview, 500); } } catch { /* */ }
try { const g = db.getChatAiSummary('global', 0, 'rolling'); if (g) { const ov = parseJson(g.overview, null); out.global_chat_overview = clip(ov ? (ov.today || ov.alltime) : g.overview, 500); } } catch { /* */ }
@@ -502,12 +510,12 @@ async function discoverTopics({ force = false } = {}) {
@@ -502,12 +510,12 @@ async function discoverTopics({ force = false } = {}) {
/** Seed a fresh topic with the lines from the window that match it, so it never starts empty. */
/** Seed a fresh topic with the lines from the window that match it, so it never starts empty. */
function backfillMoments(topics) {
function backfillMoments(topics) {
- const chat = db.all(`SELECT id, stream_id, user_id, username, message FROM chat_messages WHERE COALESCE(is_deleted, 0) = 0 AND timestamp >= datetime('now', ?) AND LENGTH(message) BETWEEN 6 AND 300 ORDER BY id ASC LIMIT 400`, [`-${SCAN_WINDOW_MIN * 4} minutes`]);
+ const chat = db.all(`SELECT id, stream_id, user_id, username, message, source_platform, anon_id FROM chat_messages WHERE COALESCE(is_deleted, 0) = 0 AND COALESCE(message_type, 'chat') = 'chat' AND COALESCE(source_platform, '') != 'ai' AND timestamp >= datetime('now', ?) AND LENGTH(message) BETWEEN 6 AND 300 ORDER BY id ASC LIMIT 400`, [`-${SCAN_WINDOW_MIN * 4} minutes`]);
const speech = db.all(`SELECT e.stream_id, e.user_id, e.vod_id, e.start_sec, e.text, u.username FROM stream_timeline_events e LEFT JOIN users u ON u.id = e.user_id WHERE e.kind = 'speech' AND e.created_at >= datetime('now', ?) ORDER BY e.id ASC LIMIT 300`, [`-${SCAN_WINDOW_MIN * 4} minutes`]);
const speech = db.all(`SELECT e.stream_id, e.user_id, e.vod_id, e.start_sec, e.text, u.username FROM stream_timeline_events e LEFT JOIN users u ON u.id = e.user_id WHERE e.kind = 'speech' AND e.created_at >= datetime('now', ?) ORDER BY e.id ASC LIMIT 300`, [`-${SCAN_WINDOW_MIN * 4} minutes`]);
for (const t of topics) {
for (const t of topics) {
const kws = normalizeKeywords(parseJson(t.keywords_json, [])).map(keywordRegex);
const kws = normalizeKeywords(parseJson(t.keywords_json, [])).map(keywordRegex);
let n = 0;
let n = 0;
- for (const c of chat) if (!/^!/.test(c.message) && kws.some(re => re.test(c.message))) { if (addMoment(t.id, { kind: 'chat', source: 'seed', userId: c.user_id, username: c.username, streamId: c.stream_id, text: c.message })) n++; }
+ for (const c of chat) if (!/^!/.test(c.message) && kws.some(re => re.test(c.message))) { if (addMoment(t.id, { kind: 'chat', source: 'seed', userId: c.user_id, username: c.username, streamId: c.stream_id, text: c.message, platform: c.source_platform || null, anonId: c.anon_id || null })) n++; }
for (const s of speech) if (kws.some(re => re.test(s.text || ''))) { if (addMoment(t.id, { kind: 'speech', source: 'seed', userId: s.user_id, username: s.username, streamId: s.stream_id, vodId: s.vod_id, sec: Math.max(0, Math.floor(s.start_sec) - 2), text: s.text })) n++; }
for (const s of speech) if (kws.some(re => re.test(s.text || ''))) { if (addMoment(t.id, { kind: 'speech', source: 'seed', userId: s.user_id, username: s.username, streamId: s.stream_id, vodId: s.vod_id, sec: Math.max(0, Math.floor(s.start_sec) - 2), text: s.text })) n++; }
if (n) console.log(`[Arena] topic #${t.id} seeded with ${n} moment(s)`);
if (n) console.log(`[Arena] topic #${t.id} seeded with ${n} moment(s)`);
}
}
@@ -563,6 +571,7 @@ async function buildLore(topicId, { force = false } = {}) {
@@ -563,6 +571,7 @@ async function buildLore(topicId, { force = false } = {}) {
db.run('UPDATE arena_topics SET lore = ?, headline = COALESCE(?, headline), tagline = COALESCE(?, tagline), keywords_json = ?, lore_updated_at = CURRENT_TIMESTAMP, lore_moment_count = ? WHERE id = ?',
db.run('UPDATE arena_topics SET lore = ?, headline = COALESCE(?, headline), tagline = COALESCE(?, tagline), keywords_json = ?, lore_updated_at = CURRENT_TIMESTAMP, lore_moment_count = ? WHERE id = ?',
[clip(out.lore, 1400), out.headline ? clip(out.headline, 160) : null, out.tagline ? clip(out.tagline, 120) : null, JSON.stringify(kws), total, topicId]);
[clip(out.lore, 1400), out.headline ? clip(out.headline, 160) : null, out.tagline ? clip(out.tagline, 120) : null, JSON.stringify(kws), total, topicId]);
invalidateMatchers();
invalidateMatchers();
+ try { require('./chatters').onLore(topic, out.lore, moments); } catch (e) { console.warn('[Arena] lore quotes xp:', e.message); }
return { lore: out.lore, headline: out.headline, tagline: out.tagline };
return { lore: out.lore, headline: out.headline, tagline: out.tagline };
}
}
@@ -590,7 +599,7 @@ function fighterBrief(userId, roster) {
@@ -590,7 +599,7 @@ function fighterBrief(userId, roster) {
level: levelFor(levelRow(userId).xp || 0),
level: levelFor(levelRow(userId).xp || 0),
};
};
}
}
-function momentView(m) { return { id: m.id, kind: m.kind, source: m.source, user_id: m.user_id, username: m.username, stream_id: m.stream_id, vod_id: m.vod_id, sec: m.sec, text: m.text, quality: m.quality, about: m.about, at: m.created_at }; }
+function momentView(m) { return { id: m.id, kind: m.kind, source: m.source, user_id: m.user_id, username: m.username, chatter_key: m.chatter_key || null, platform: m.source_platform || null, stream_id: m.stream_id, vod_id: m.vod_id, sec: m.sec, text: m.text, quality: m.quality, about: m.about, at: m.created_at }; }
function topicView(topic, roster, { detail = false } = {}) {
function topicView(topic, roster, { detail = false } = {}) {
const members = db.all(`SELECT m.*, (SELECT COUNT(*) FROM arena_topic_moments x WHERE x.topic_id = m.topic_id AND x.user_id = m.user_id AND x.kind = 'speech') AS moments, (SELECT COALESCE(SUM(quality), 0) FROM arena_topic_moments x WHERE x.topic_id = m.topic_id AND x.user_id = m.user_id) AS score FROM arena_topic_members m WHERE m.topic_id = ? ORDER BY score DESC, moments DESC`, [topic.id]);
const members = db.all(`SELECT m.*, (SELECT COUNT(*) FROM arena_topic_moments x WHERE x.topic_id = m.topic_id AND x.user_id = m.user_id AND x.kind = 'speech') AS moments, (SELECT COALESCE(SUM(quality), 0) FROM arena_topic_moments x WHERE x.topic_id = m.topic_id AND x.user_id = m.user_id) AS score FROM arena_topic_members m WHERE m.topic_id = ? ORDER BY score DESC, moments DESC`, [topic.id]);
@@ -613,7 +622,7 @@ function topicView(topic, roster, { detail = false } = {}) {
@@ -613,7 +622,7 @@ function topicView(topic, roster, { detail = false } = {}) {
if (detail) {
if (detail) {
out.submitted_text = topic.submitted_text || null;
out.submitted_text = topic.submitted_text || null;
out.moments = db.all('SELECT * FROM arena_topic_moments WHERE topic_id = ? ORDER BY id DESC LIMIT 80', [topic.id]).map(momentView);
out.moments = db.all('SELECT * FROM arena_topic_moments WHERE topic_id = ? ORDER BY id DESC LIMIT 80', [topic.id]).map(momentView);
- out.top_chatters = db.all(`SELECT username, COUNT(*) AS n FROM arena_topic_moments WHERE topic_id = ? AND kind = 'chat' AND username IS NOT NULL GROUP BY username ORDER BY n DESC LIMIT 6`, [topic.id]);
+ out.top_chatters = db.all(`SELECT username, chatter_key, COUNT(*) AS n FROM arena_topic_moments WHERE topic_id = ? AND kind = 'chat' AND username IS NOT NULL GROUP BY COALESCE(chatter_key, username) ORDER BY n DESC LIMIT 6`, [topic.id]).map(c => { let lvl = null; try { const r = require('./chatters').row(c.chatter_key); if (r) lvl = { level: r.level, title: require('./chatters').titleFor(r.level) }; } catch { /* */ } return { ...c, ...(lvl || {}) }; });
out.best_lines = db.all(`SELECT * FROM arena_topic_moments WHERE topic_id = ? AND quality IS NOT NULL ORDER BY quality DESC, id DESC LIMIT 6`, [topic.id]).map(momentView);
out.best_lines = db.all(`SELECT * FROM arena_topic_moments WHERE topic_id = ? AND quality IS NOT NULL ORDER BY quality DESC, id DESC LIMIT 6`, [topic.id]).map(momentView);
for (const f of out.fighters) { const bm = db.get(`SELECT * FROM arena_topic_moments WHERE topic_id = ? AND user_id = ? AND kind = 'speech' ORDER BY COALESCE(quality, -1) DESC, id DESC LIMIT 1`, [topic.id, f.user.id]); f.best = bm ? momentView(bm) : null; }
for (const f of out.fighters) { const bm = db.get(`SELECT * FROM arena_topic_moments WHERE topic_id = ? AND user_id = ? AND kind = 'speech' ORDER BY COALESCE(quality, -1) DESC, id DESC LIMIT 1`, [topic.id, f.user.id]); f.best = bm ? momentView(bm) : null; }
}
}
@@ -647,11 +656,7 @@ function levelsLeaderboard(limit = 10) {
@@ -647,11 +656,7 @@ function levelsLeaderboard(limit = 10) {
}
}
/** Viewers who keep the subjects alive from chat (moments in the last 7 days). */
/** Viewers who keep the subjects alive from chat (moments in the last 7 days). */
-function yappersLeaderboard(limit = 10) {
- ensureTables();
- return db.all(`SELECT username, user_id, COUNT(*) AS n, COUNT(DISTINCT topic_id) AS topics FROM arena_topic_moments WHERE kind = 'chat' AND username IS NOT NULL AND created_at >= datetime('now', '-7 days') GROUP BY username ORDER BY n DESC LIMIT ?`, [limit])
- .map(r => ({ name: r.username, username: r.username, user_id: r.user_id, moments: r.n, topics: r.topics, last: db.get('SELECT text FROM arena_topic_moments WHERE username = ? AND kind = ? ORDER BY id DESC LIMIT 1', [r.username, 'chat'])?.text || null }));
-}
+function yappersLeaderboard(limit = 10) { return require('./chatters').leaderboard(limit); }
module.exports = {
module.exports = {
ensureTables, createTopic, submitTopic, assertCanSubmit, joinTopic, leaveTopic, activeTopicFor, applyTopicJudgement, hypeTopic, addMoment, noteMicMention, matchTopics, keywordsFromText,
ensureTables, createTopic, submitTopic, assertCanSubmit, joinTopic, leaveTopic, activeTopicFor, applyTopicJudgement, hypeTopic, addMoment, noteMicMention, matchTopics, keywordsFromText,
new file mode 100644
new file mode 100644
@@ -0,0 +1,330 @@
@@ -0,0 +1,330 @@
+/**
+ * chatters.js — Yapper profiles: XP, levels, titles, streaks and an AI "yap card" for EVERYONE who
+ * chats — signed-in OpenVibe users, anonymous viewers (anon<N>) and chat relayed from other
+ * platforms ([Twitch] bob, [Kick] bob, [RS] bob) — keyed by one polymorphic chatter key:
+ *
+ * user:<users.id> anon:<N> relay:<platform>:<username lowercased>
+ *
+ * XP comes from the board (no extra AI calls): a chat line that lands as a moment on a subject,
+ * more when the subject is HOT, a bonus the first time you touch a subject, starting a subject,
+ * being quoted in a subject's lore, hyping, and a daily streak of showing up. Levels use the same
+ * curve as the game (1 + floor(sqrt(xp / 25))). OpenVibe accounts get site-wide OpenCoins on every
+ * level-up (idempotent per level, through the network wallet); anon/relay chatters get the level,
+ * the title and the card — the coins need an account.
+ *
+ * The yap card (title, blurb, catchphrase, "known for") is written by the AI from the chat AI's
+ * existing profile of the chatter (chat_ai_summaries: user / anon / relay scopes) + their moments —
+ * only for level ≥ CARD_MIN_LEVEL, at most once a day per chatter, a few per housekeeping tick.
+ */
+'use strict';
+
+const db = require('../db/database');
+const llm = require('../ai/llm');
+
+const XP_MOMENT = 3;
+const XP_MOMENT_HOT = 6;
+const XP_FIRST_ON_SUBJECT = 4;
+const XP_SUBJECT_STARTED = 15;
+const XP_QUOTED_IN_LORE = 10;
+const XP_HYPE = 1;
+const XP_STREAK_DAY = 5; // × min(streak, 7)
+const COINS_PER_LEVEL = 10; // OpenCoins on level-up = level × this (accounts only)
+const CARD_MIN_LEVEL = 3;
+const CARD_TTL_MS = 24 * 3600_000;
+const CARD_BATCH = 4;
+
+const TITLES = [[1, 'Lurker'], [2, 'Chatter'], [3, 'Yapper'], [5, 'Instigator'], [7, 'Ragebaiter'], [9, 'Menace'], [12, 'Main Character'], [15, 'Community Consciousness'], [20, 'Final Boss']];
+
+let _ready = false;
+function ensureTables() {
+ if (_ready) return;
+ db.run(`CREATE TABLE IF NOT EXISTS chatter_profiles (
+ key TEXT PRIMARY KEY,
+ kind TEXT NOT NULL, -- 'user' | 'anon' | 'relay'
+ user_id INTEGER,
+ platform TEXT,
+ display_name TEXT,
+ xp INTEGER DEFAULT 0,
+ level INTEGER DEFAULT 1,
+ moments INTEGER DEFAULT 0,
+ subjects INTEGER DEFAULT 0,
+ subjects_started INTEGER DEFAULT 0,
+ quoted INTEGER DEFAULT 0,
+ hypes INTEGER DEFAULT 0,
+ streak INTEGER DEFAULT 0,
+ best_streak INTEGER DEFAULT 0,
+ last_day TEXT,
+ best_line TEXT,
+ best_line_topic_id INTEGER,
+ card_json TEXT,
+ card_at DATETIME,
+ card_xp INTEGER DEFAULT 0,
+ coins_paid_level INTEGER DEFAULT 0,
+ first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
+ last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS chatter_xp_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ key TEXT NOT NULL,
+ amount INTEGER NOT NULL,
+ reason TEXT NOT NULL,
+ ref_id INTEGER,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`);
+ db.run('CREATE INDEX IF NOT EXISTS idx_chatter_xp_key ON chatter_xp_log (key, created_at)');
+ db.run(`CREATE TABLE IF NOT EXISTS chatter_subjects (
+ key TEXT NOT NULL,
+ topic_id INTEGER NOT NULL,
+ moments INTEGER DEFAULT 0,
+ first_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ last_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (key, topic_id)
+ )`);
+ _ready = true;
+}
+
+function aiOn() { try { return llm.isEnabled() && llm.withinBudget(); } catch { return false; } }
+function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }
+function clip(t, n) { return String(t || '').replace(/\s+/g, ' ').trim().slice(0, n); }
+function levelFor(xp) { return 1 + Math.floor(Math.sqrt(Math.max(0, Number(xp) || 0) / 25)); }
+function xpForLevel(level) { return Math.max(0, (level - 1) ** 2 * 25); }
+function titleFor(level) { let t = TITLES[0][1]; for (const [l, name] of TITLES) if (level >= l) t = name; return t; }
+function today() { return new Date().toISOString().slice(0, 10); }
+
+// ── Keys ─────────────────────────────────────────────────────
+
+const RELAY_LABELS = { twitch: 'twitch', kick: 'kick', yt: 'youtube', youtube: 'youtube', rs: 'rs', robotstreamer: 'rs' };
+/** The chatter key for a chat_messages-shaped row / moment: { user_id, username, anon_id, source_platform }. */
+function keyFor(m) {
+ if (!m) return null;
+ if (m.user_id) return `user:${m.user_id}`;
+ const uname = String(m.username || '');
+ const relay = uname.match(/^\[([A-Za-z]+)\]\s+(.+)$/);
+ if (relay && (m.source_platform || RELAY_LABELS[relay[1].toLowerCase()])) return `relay:${(m.source_platform || RELAY_LABELS[relay[1].toLowerCase()] || relay[1]).toLowerCase()}:${relay[2].trim().toLowerCase()}`;
+ const anon = String(m.anon_id || (/^anon\d+$/i.test(uname) ? uname : '')).match(/^anon(\d+)$/i);
+ if (anon) return `anon:${anon[1]}`;
+ if (uname) return `name:${uname.toLowerCase()}`;
+ return null;
+}
+function parseKey(key) {
+ const [kind, ...rest] = String(key || '').split(':');
+ if (kind === 'user') return { kind, user_id: Number(rest[0]) };
+ if (kind === 'anon') return { kind, anon_num: Number(rest[0]), anon_id: `anon${rest[0]}` };
+ if (kind === 'relay') return { kind, platform: rest[0], username: rest.slice(1).join(':') };
+ if (kind === 'name') return { kind, username: rest.join(':') };
+ return null;
+}
+function displayFor(key, fallback = null) {
+ const p = parseKey(key);
+ if (!p) return fallback || 'someone';
+ if (p.kind === 'user') { const u = db.getUserById(p.user_id); return u ? (u.display_name || u.username) : (fallback || `user${p.user_id}`); }
+ if (p.kind === 'anon') return fallback || p.anon_id;
+ if (p.kind === 'relay') return fallback || `[${p.platform}] ${p.username}`;
+ return fallback || p.username;
+}
+
+// ── XP ───────────────────────────────────────────────────────
+
+function row(key) { ensureTables(); return db.get('SELECT * FROM chatter_profiles WHERE key = ?', [key]) || null; }
+function ensureRow(key, { display = null } = {}) {
+ const p = parseKey(key);
+ if (!p) return null;
+ const existing = row(key);
+ if (existing) { if (display && display !== existing.display_name) db.run('UPDATE chatter_profiles SET display_name = ? WHERE key = ?', [clip(display, 60), key]); return existing; }
+ db.run('INSERT OR IGNORE INTO chatter_profiles (key, kind, user_id, platform, display_name) VALUES (?, ?, ?, ?, ?)', [key, p.kind, p.kind === 'user' ? p.user_id : null, p.kind === 'relay' ? p.platform : null, clip(display || displayFor(key), 60)]);
+ return row(key);
+}
+
+/** Add XP; handles level-ups (coins for accounts, an announcement in the room when a streamId is given). */
+function addXp(key, amount, reason, refId = null, { display = null, streamId = null } = {}) {
+ ensureTables();
+ amount = Math.round(Number(amount) || 0);
+ const before = ensureRow(key, { display });
+ if (!before || amount <= 0) return { leveled_up: false, level: before ? before.level : 1, gained: 0 };
+ const xp = (before.xp || 0) + amount;
+ const level = levelFor(xp);
+ db.run('UPDATE chatter_profiles SET xp = ?, level = ?, last_seen = CURRENT_TIMESTAMP WHERE key = ?', [xp, level, key]);
+ db.run('INSERT INTO chatter_xp_log (key, amount, reason, ref_id) VALUES (?, ?, ?, ?)', [key, amount, reason, refId]);
+ const leveled = level > (before.level || 1);
+ if (leveled) onLevelUp(key, before.level || 1, level, { streamId });
+ return { leveled_up: leveled, level, xp, gained: amount, title: titleFor(level) };
+}
+
+function onLevelUp(key, from, to, { streamId = null } = {}) {
+ const p = parseKey(key);
+ const name = displayFor(key);
+ console.log(`[Arena] yapper ${name} (${key}) → level ${to} ${titleFor(to)}`);
+ // Site-wide OpenCoins for OpenVibe accounts — one payout per level, idempotent in the wallet.
+ if (p && p.kind === 'user') {
+ try {
+ const r = row(key);
+ const paidTo = r?.coins_paid_level || 0;
+ let total = 0;
+ for (let l = Math.max(paidTo + 1, from + 1); l <= to; l++) total += l * COINS_PER_LEVEL;
+ if (total > 0) {
+ const opencoins = require('../monetization/opencoins');
+ Promise.resolve(opencoins.credit(p.user_id, total, 'arena_yap_level', `live:arena_yap_level:${p.user_id}:${to}`, { level: to })).then(res => {
+ if (res) console.log(`[Arena] ${name}: +${total} OpenCoins for reaching yap level ${to}`);
+ }).catch(e => console.warn('[Arena] yap coins:', e.message));
+ db.run('UPDATE chatter_profiles SET coins_paid_level = ? WHERE key = ?', [to, key]);
+ }
+ } catch (e) { console.warn('[Arena] yap coins:', e.message); }
+ }
+ // Tell the room.
+ try {
+ const chat = require('../chat/chat-server');
+ const line = `⬆️ ${name} hit Yap Level ${to} — ${titleFor(to)}${p && p.kind === 'user' ? ` (+${to * COINS_PER_LEVEL} OpenCoins)` : ''}. Keep talking. ${(() => { try { const c = require('../config'); return `${String(c.baseUrl || '').replace(/\/$/, '')}/arena/chatter/${encodeURIComponent(key)}`; } catch { return ''; } })()}`;
+ if (streamId && chat.broadcastToStream) chat.broadcastToStream(streamId, { type: 'system', message: line });
+ } catch { /* chat server not up in tests */ }
+}
+
+/** A chat line landed as a moment on a subject. */
+function onMoment(moment, topic, { hot = false } = {}) {
+ if (!moment || moment.kind !== 'chat') return null;
+ const key = moment.chatter_key || keyFor(moment);
+ if (!key) return null;
+ ensureRow(key, { display: moment.username });
+ const first = !db.get('SELECT 1 FROM chatter_subjects WHERE key = ? AND topic_id = ?', [key, topic.id]);
+ db.run(`INSERT INTO chatter_subjects (key, topic_id, moments) VALUES (?, ?, 1) ON CONFLICT(key, topic_id) DO UPDATE SET moments = moments + 1, last_at = CURRENT_TIMESTAMP`, [key, topic.id]);
+ let xp = hot ? XP_MOMENT_HOT : XP_MOMENT;
+ if (first) xp += XP_FIRST_ON_SUBJECT;
+ xp += streakBonus(key);
+ db.run(`UPDATE chatter_profiles SET moments = moments + 1, subjects = (SELECT COUNT(*) FROM chatter_subjects s WHERE s.key = chatter_profiles.key), best_line = CASE WHEN best_line IS NULL OR LENGTH(?) > LENGTH(best_line) THEN ? ELSE best_line END, best_line_topic_id = CASE WHEN best_line IS NULL OR LENGTH(?) > LENGTH(best_line) THEN ? ELSE best_line_topic_id END WHERE key = ?`,
+ [moment.text, clip(moment.text, 200), moment.text, topic.id, key]);
+ return addXp(key, xp, first ? 'moment_first' : 'moment', topic.id, { display: moment.username, streamId: moment.stream_id || null });
+}
+
+/** Daily streak: the first moment of a day extends the streak (or restarts it) and pays a bonus. */
+function streakBonus(key) {
+ const r = row(key);
+ const d = today();
+ if (!r || r.last_day === d) return 0;
+ const yesterday = new Date(Date.now() - 86400_000).toISOString().slice(0, 10);
+ const streak = r.last_day === yesterday ? (r.streak || 0) + 1 : 1;
+ db.run('UPDATE chatter_profiles SET streak = ?, best_streak = MAX(best_streak, ?), last_day = ? WHERE key = ?', [streak, streak, d, key]);
+ return XP_STREAK_DAY * Math.min(streak, 7);
+}
+
+function onSubjectStarted(key, topicId, { display = null } = {}) {
+ if (!key) return null;
+ db.run('UPDATE chatter_profiles SET subjects_started = subjects_started + 1 WHERE key = ?', [key]);
+ return addXp(key, XP_SUBJECT_STARTED, 'subject_started', topicId, { display });
+}
+function onHype(voterKey, topicId) {
+ // voter keys from the API are user:<id> / anon:<anonId> / ip:<…>; only user/anon map to a chatter.
+ const m = String(voterKey || '').match(/^(user:\d+|anon:\d+)$/);
+ if (!m) return null;
+ db.run('UPDATE chatter_profiles SET hypes = hypes + 1 WHERE key = ?', [m[1]]);
+ return addXp(m[1], XP_HYPE, 'hype', topicId);
+}
+/** After a lore rewrite: every chatter whose name appears in the lore text gets quoted-XP (once per rewrite). */
+function onLore(topic, loreText, moments) {
+ const text = String(loreText || '').toLowerCase();
+ if (!text) return 0;
+ const seen = new Set();
+ let n = 0;
+ for (const m of moments || []) {
+ if (m.kind !== 'chat') continue;
+ const key = m.chatter_key || keyFor(m);
+ if (!key || seen.has(key)) continue;
+ const name = String(m.username || '').replace(/^\[[^\]]+\]\s+/, '').toLowerCase();
+ if (name.length >= 3 && text.includes(name)) { seen.add(key); db.run('UPDATE chatter_profiles SET quoted = quoted + 1 WHERE key = ?', [key]); addXp(key, XP_QUOTED_IN_LORE, 'quoted_in_lore', topic.id, { display: m.username }); n++; }
+ }
+ return n;
+}
+
+// ── Yap card (AI, cheap and rare) ────────────────────────────
+
+const CARD_SCHEMA = { name: 'arena_yap_card', schema: { type: 'object', additionalProperties: false, required: ['title', 'blurb', 'catchphrase', 'known_for', 'archetype'], properties: {
+ title: { type: 'string', description: 'a ridiculous 2–5 word title for this chatter based on what they actually do in chat' },
+ blurb: { type: 'string', description: '2–3 sentences, tabloid voice, dumb-funny, about who they are in chat: their bits, grudges, subjects, timing' },
+ catchphrase: { type: 'string', description: 'one line in THEIR typing style (casing, punctuation, slang) they would actually send' },
+ known_for: { type: 'array', minItems: 2, maxItems: 4, items: { type: 'string' }, description: '≤ 6 words each' },
+ archetype: { type: 'string', description: 'one of: instigator, hype man, lore keeper, contrarian, one-liner, spammer, moralist, lurker-turned-menace, brand ambassador, glazer' },
+} } };
+const CARD_SYSTEM = `You write the "yap card" for a chatter on a live-streaming site — a trading-card style profile of who they are IN CHAT. You get the chat AI's notes about them (overview, long-term memory, timeline), their best lines that landed on the community board, the subjects they pile onto, and their streak. Be specific and funny; take the piss; this platform does not censor language. Copy their typing style for the catchphrase. Hard line: no direct threats of violence, nothing sexual about minors, no doxxing. Output only the JSON.`;
+
+function chatAiFor(key) {
+ const p = parseKey(key);
+ try {
+ if (p.kind === 'user') return db.getChatAiSummary('user', p.user_id, 'rolling');
+ if (p.kind === 'anon') return db.getChatAiSummary('anon', p.anon_num, 'rolling');
+ if (p.kind === 'relay') { const ru = db.getRelayUser ? db.getRelayUser(p.platform, p.username) : null; return ru ? db.getChatAiSummary('relay', ru.id, 'rolling') : null; }
+ } catch { /* */ }
+ return null;
+}
+
+async function buildCard(key, { force = false } = {}) {
+ const r = row(key);
+ if (!r) return null;
+ if (!force) {
+ if (r.level < CARD_MIN_LEVEL) return { skipped: 'level' };
+ if (r.card_json && r.card_at && Date.now() - Date.parse(r.card_at + 'Z') < CARD_TTL_MS) return { skipped: 'fresh' };
+ if (r.card_json && (r.xp - (r.card_xp || 0)) < 25) return { skipped: 'no change' };
+ }
+ if (!aiOn()) return { skipped: 'ai off' };
+ const ai = chatAiFor(key);
+ const ov = ai ? parseJson(ai.overview, null) : null;
+ const lines = db.all(`SELECT m.text, t.text AS subject FROM arena_topic_moments m JOIN arena_topics t ON t.id = m.topic_id WHERE m.chatter_key = ? AND m.kind = 'chat' ORDER BY m.id DESC LIMIT 12`, [key]);
+ const subjects = db.all('SELECT t.text, s.moments FROM chatter_subjects s JOIN arena_topics t ON t.id = s.topic_id WHERE s.key = ? ORDER BY s.moments DESC LIMIT 6', [key]);
+ let out = null;
+ try {
+ const res = await llm.complete({ role: 'chat', kind: 'arena_yap_card', source: 'arena', ownerUserId: r.user_id || null, system: CARD_SYSTEM, json: CARD_SCHEMA, maxTokens: 320, temperature: 0.95, timeoutMs: 25000,
+ user: JSON.stringify({ name: r.display_name, kind: r.kind, level: r.level, title: titleFor(r.level), streak: r.streak, chat_ai_overview: ov ? clip([ov.alltime, ov.today].filter(Boolean).join(' '), 900) : (ai ? clip(ai.overview, 900) : null), chat_ai_memory: ai ? clip(ai.memory_json, 700) : null, chat_ai_timeline: ai ? (parseJson(ai.timeline_json, []) || []).slice(-8).map(e => clip(typeof e === 'string' ? e : `${e.label || ''}: ${e.detail || ''}`, 140)) : [], best_lines: lines.map(l => `[${l.subject}] ${l.text}`), subjects: subjects.map(s => `${s.text} (${s.moments})`) }) });
+ if (res && res.json && res.json.title) out = res.json;
+ } catch (e) { console.warn('[Arena] yap card:', e.message); }
+ if (!out) return { skipped: 'failed' };
+ db.run('UPDATE chatter_profiles SET card_json = ?, card_at = CURRENT_TIMESTAMP, card_xp = xp WHERE key = ?', [JSON.stringify(out), key]);
+ console.log(`[Arena] yap card for ${r.display_name}: "${out.title}"`);
+ return out;
+}
+
+async function cardSweep(max = CARD_BATCH) {
+ ensureTables();
+ if (!aiOn()) return 0;
+ const due = db.all(`SELECT key FROM chatter_profiles WHERE level >= ? AND (card_json IS NULL OR card_at IS NULL OR card_at < datetime('now', '-24 hours')) AND (card_json IS NULL OR xp - card_xp >= 25) ORDER BY xp DESC LIMIT ?`, [CARD_MIN_LEVEL, max]);
+ let n = 0;
+ for (const d of due) { try { const r = await buildCard(d.key); if (r && !r.skipped) n++; } catch { /* */ } }
+ return n;
+}
+
+// ── Views ────────────────────────────────────────────────────
+
+function view(r, { detail = false } = {}) {
+ if (!r) return null;
+ const level = r.level || levelFor(r.xp);
+ const nextXp = xpForLevel(level + 1), curXp = xpForLevel(level);
+ const p = parseKey(r.key) || {};
+ const user = p.kind === 'user' ? db.getUserById(p.user_id) : null;
+ const out = {
+ key: r.key, kind: r.kind, platform: r.platform || null, name: r.display_name || displayFor(r.key),
+ user: user ? { id: user.id, username: user.username, display_name: user.display_name, avatar_url: user.avatar_url, profile_color: user.profile_color } : null,
+ level, title: titleFor(level), xp: r.xp || 0, xp_into_level: (r.xp || 0) - curXp, xp_for_next: nextXp - curXp, next_level_xp: nextXp,
+ moments: r.moments || 0, subjects: r.subjects || 0, subjects_started: r.subjects_started || 0, quoted: r.quoted || 0, hypes: r.hypes || 0,
+ streak: r.last_day === today() || r.last_day === new Date(Date.now() - 86400_000).toISOString().slice(0, 10) ? (r.streak || 0) : 0, best_streak: r.best_streak || 0,
+ best_line: r.best_line ? { text: r.best_line, topic_id: r.best_line_topic_id } : null,
+ card: parseJson(r.card_json, null), card_at: r.card_at, coins: p.kind === 'user', first_seen: r.first_seen, last_seen: r.last_seen,
+ titles: TITLES.map(([l, t]) => ({ level: l, title: t, xp: xpForLevel(l) })),
+ };
+ if (detail) {
+ out.recent_moments = db.all(`SELECT m.*, t.text AS subject, t.headline AS subject_headline FROM arena_topic_moments m JOIN arena_topics t ON t.id = m.topic_id WHERE m.chatter_key = ? ORDER BY m.id DESC LIMIT 40`, [r.key]).map(m => ({ id: m.id, topic_id: m.topic_id, subject: m.subject, subject_headline: m.subject_headline, text: m.text, at: m.created_at, stream_id: m.stream_id }));
+ out.top_subjects = db.all('SELECT t.id, t.text, t.headline, t.status, s.moments FROM chatter_subjects s JOIN arena_topics t ON t.id = s.topic_id WHERE s.key = ? ORDER BY s.moments DESC LIMIT 8', [r.key]);
+ out.xp_log = db.all('SELECT amount, reason, ref_id, created_at FROM chatter_xp_log WHERE key = ? ORDER BY id DESC LIMIT 30', [r.key]);
+ const ai = chatAiFor(r.key);
+ if (ai) { const ov = parseJson(ai.overview, null); out.chat_ai = { overview: ov ? (ov.today || ov.alltime) : clip(ai.overview, 600), memory: clip(ai.memory_json, 500) }; }
+ out.rank = (db.get('SELECT COUNT(*) AS n FROM chatter_profiles WHERE xp > ?', [r.xp || 0])?.n || 0) + 1;
+ }
+ return out;
+}
+function profile(key) { ensureTables(); return view(row(key), { detail: true }); }
+function leaderboard(limit = 10, { days = null } = {}) {
+ ensureTables();
+ if (days) {
+ const rows = db.all(`SELECT key, SUM(amount) AS gained FROM chatter_xp_log WHERE created_at >= datetime('now', ?) GROUP BY key ORDER BY gained DESC LIMIT ?`, [`-${days} days`, limit]);
+ return rows.map(x => ({ ...view(row(x.key)), gained: x.gained }));
+ }
+ return db.all('SELECT * FROM chatter_profiles WHERE xp > 0 ORDER BY xp DESC LIMIT ?', [limit]).map(r => view(r));
+}
+function count() { ensureTables(); return db.get('SELECT COUNT(*) AS n FROM chatter_profiles WHERE xp > 0')?.n || 0; }
+
+module.exports = { ensureTables, keyFor, parseKey, displayFor, levelFor, xpForLevel, titleFor, addXp, onMoment, onSubjectStarted, onHype, onLore, buildCard, cardSweep, profile, leaderboard, count, view, row, TITLES, XP_MOMENT, XP_MOMENT_HOT, XP_FIRST_ON_SUBJECT, XP_SUBJECT_STARTED, XP_QUOTED_IN_LORE, COINS_PER_LEVEL, CARD_MIN_LEVEL };
@@ -99,7 +99,7 @@ router.get('/board', (req, res) => {
@@ -99,7 +99,7 @@ router.get('/board', (req, res) => {
try {
try {
const v = board.boardView();
const v = board.boardView();
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
- res.json({ ...v, levels: board.levelsLeaderboard(8), yappers: board.yappersLeaderboard(8), ai: arena.aiOn() });
+ const ch = require('./chatters'); res.json({ ...v, levels: board.levelsLeaderboard(8), yappers: ch.leaderboard(8), yappers_week: ch.leaderboard(5, { days: 7 }), yappers_total: ch.count(), ai: arena.aiOn() });
} catch (err) { fail(res, err, 'Failed to load the board'); }
} catch (err) { fail(res, err, 'Failed to load the board'); }
});
});
router.post('/board/topics', requireAuth, async (req, res) => {
router.post('/board/topics', requireAuth, async (req, res) => {
@@ -173,6 +173,16 @@ router.get('/voice/:user', optionalAuth, async (req, res) => {
@@ -173,6 +173,16 @@ router.get('/voice/:user', optionalAuth, async (req, res) => {
});
});
router.get('/levels', (req, res) => { try { res.json({ levels: board.levelsLeaderboard(20) }); } catch (err) { fail(res, err, 'Failed'); } });
router.get('/levels', (req, res) => { try { res.json({ levels: board.levelsLeaderboard(20) }); } catch (err) { fail(res, err, 'Failed'); } });
-router.get('/yappers', (req, res) => { try { res.json({ yappers: board.yappersLeaderboard(20) }); } catch (err) { fail(res, err, 'Failed'); } });
+router.get('/yappers', (req, res) => { try { const ch = require('./chatters'); res.json({ yappers: ch.leaderboard(20), week: ch.leaderboard(10, { days: 7 }), total: ch.count() }); } catch (err) { fail(res, err, 'Failed'); } });
+// Chatter (yapper) profile by key: user:<id> · anon:<n> · relay:<platform>:<name> — anyone who chats has one.
+router.get('/chatter/:key', (req, res) => {
+ try { const p = require('./chatters').profile(String(req.params.key || '')); if (!p) return res.status(404).json({ error: 'No such chatter yet — say something about a subject first' }); res.set('Cache-Control', 'no-store'); res.json(p); }
+ catch (err) { fail(res, err, 'Failed to load chatter'); }
+});
+router.get('/chatter/by-user/:username', (req, res) => {
+ try { const u = userFrom(req.params.username); if (!u) return res.status(404).json({ error: 'No such user' }); const p = require('./chatters').profile(`user:${u.id}`); if (!p) return res.status(404).json({ error: 'No yap profile yet' }); res.json(p); }
+ catch (err) { fail(res, err, 'Failed'); }
+});
+router.post('/chatter/:key/card', requireAuth, permissions.requireAdmin, async (req, res) => { try { res.json(await require('./chatters').buildCard(String(req.params.key), { force: true })); } catch (err) { fail(res, err, 'Card failed'); } });
module.exports = router;
module.exports = router;
@@ -111,7 +111,7 @@ board.ensureTables(); beef.ensureTables();
@@ -111,7 +111,7 @@ board.ensureTables(); beef.ensureTables();
assert.strictEqual(view.cooldown_hours, 24);
assert.strictEqual(view.cooldown_hours, 24);
const yap = board.yappersLeaderboard();
const yap = board.yappersLeaderboard();
assert.deepStrictEqual(yap.map(y => y.name).sort(), ['anon_guy', 'grizzly_bear', 'viewer'], 'everyone who typed about a subject is a yapper');
assert.deepStrictEqual(yap.map(y => y.name).sort(), ['anon_guy', 'grizzly_bear', 'viewer'], 'everyone who typed about a subject is a yapper');
- assert.ok(yap.every(y => y.moments === 1 && y.topics === 1));
+ assert.ok(yap.every(y => y.moments === 1 && y.subjects === 1 && y.level >= 1 && y.title), 'yapper profiles carry level + title');
console.log('✅ lore, hype, heat ordering, yappers');
console.log('✅ lore, hype, heat ordering, yappers');
// Discovery without AI: a word ≥ 3 people keep saying becomes a subject, seeded with what was said.
// Discovery without AI: a word ≥ 3 people keep saying becomes a subject, seeded with what was said.
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+'use strict';
+// Yapper profiles: one key for accounts / anon / relay chatters, XP from board moments, levels,
+// titles, streaks, lore quotes, leaderboards — and level-up coins only for accounts.
+const assert = require('assert');
+const fs = require('fs'); const os = require('os'); const path = require('path');
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-yap-'));
+process.env.DB_PATH = path.join(tmp, 'live.db');
+const db = require('../server/db/database'); db.initDb();
+const ch = require('../server/arena/chatters');
+const board = require('../server/arena/board');
+const arena = require('../server/arena/arena-service');
+arena.ensureTables(); board.ensureTables(); ch.ensureTables();
+
+assert.strictEqual(ch.keyFor({ user_id: 7, username: 'Goosely' }), 'user:7');
+assert.strictEqual(ch.keyFor({ user_id: null, anon_id: 'anon4412', username: 'anon4412' }), 'anon:4412');
+assert.strictEqual(ch.keyFor({ user_id: null, username: 'anon99' }), 'anon:99', 'anon without anon_id column still keys');
+assert.strictEqual(ch.keyFor({ user_id: null, username: '[Twitch] BobLol', source_platform: 'twitch' }), 'relay:twitch:boblol');
+assert.strictEqual(ch.keyFor({ user_id: null, username: '[RS] dan' }), 'relay:rs:dan', 'label alone identifies the platform');
+assert.deepStrictEqual(ch.parseKey('relay:kick:some:name'), { kind: 'relay', platform: 'kick', username: 'some:name' });
+assert.deepStrictEqual([0, 24, 25, 100, 400, 2500].map(ch.levelFor), [1, 1, 2, 3, 5, 11]);
+assert.deepStrictEqual([1, 3, 5, 12, 20].map(ch.titleFor), ['Lurker', 'Yapper', 'Instigator', 'Main Character', 'Final Boss']);
+console.log('✅ chatter keys, level curve, titles');
+
+const uid = Number(db.createUser({ username: 'viewer', email: 'v@x', password_hash: 'x', display_name: 'Viewer', stream_key: 'v'.repeat(32) }).lastInsertRowid);
+const t = board.createTopic({ text: 'The baby voice', createdBy: 'community', creatorName: 'global chat', keywords: ['baby voice'] });
+const say = (uid2, uname, msg, extra = {}) => board.addMoment(t.id, { kind: 'chat', source: 'chat', userId: uid2, username: uname, text: msg, ...extra });
+say(uid, 'viewer', 'the baby voice is a war crime');
+let p = ch.profile('user:viewer'.replace('viewer', uid));
+assert.strictEqual(p.xp, ch.XP_MOMENT + ch.XP_FIRST_ON_SUBJECT + 5, 'moment + first-on-subject + day-1 streak');
+assert.strictEqual(p.streak, 1); assert.strictEqual(p.subjects, 1); assert.strictEqual(p.moments, 1);
+say(uid, 'viewer', 'baby voice again');
+p = ch.profile(`user:${uid}`);
+assert.strictEqual(p.xp, 12 + ch.XP_MOMENT, 'second line: just the moment (streak already paid today)');
+say(null, 'anon4412', 'baby voice enjoyers rise', { anonId: 'anon4412' });
+say(null, '[Twitch] BobLol', 'the baby voice guy again', { platform: 'twitch' });
+assert.strictEqual(ch.profile('anon:4412').kind, 'anon'); assert.strictEqual(ch.profile('relay:twitch:boblol').name, '[Twitch] BobLol');
+assert.strictEqual(ch.profile('anon:4412').coins, false, 'anon: level + title, no coins');
+db.run(`UPDATE arena_topics SET heat = 50 WHERE id = ?`, [t.id]);
+say(uid, 'viewer', 'HOT take on the baby voice');
+assert.strictEqual(ch.profile(`user:${uid}`).xp, 15 + ch.XP_MOMENT_HOT, 'hot subjects pay double');
+assert.strictEqual(db.get('SELECT chatter_key FROM arena_topic_moments ORDER BY id DESC LIMIT 1').chatter_key, `user:${uid}`);
+console.log('✅ moments → XP for accounts, anon and relay chatters');
+
+(async () => {
+ const before = ch.profile(`user:${uid}`).xp;
+ const lore = await board.buildLore(t.id, { force: true });
+ assert.ok(/viewer/.test(lore.lore), 'template lore names the chatter');
+ assert.strictEqual(ch.profile(`user:${uid}`).xp, before + ch.XP_QUOTED_IN_LORE, 'quoted in the lore → XP');
+ assert.strictEqual(ch.profile(`user:${uid}`).quoted, 1);
+ ch.onHype(`user:${uid}`, t.id); assert.strictEqual(ch.profile(`user:${uid}`).hypes, 1);
+ ch.onSubjectStarted(`user:${uid}`, t.id, { display: 'Viewer' }); assert.strictEqual(ch.profile(`user:${uid}`).subjects_started, 1);
+ // Level up: force XP over the level-3 line; coins are attempted once per level (wallet is unlinked here → no-op, but marked paid).
+ const r = ch.addXp(`user:${uid}`, 200, 'test');
+ assert.ok(r.leveled_up && r.level >= 3, JSON.stringify(r));
+ assert.strictEqual(ch.row(`user:${uid}`).coins_paid_level, r.level, 'coins paid up to the new level (idempotent)');
+ const r2 = ch.addXp(`user:${uid}`, 1, 'test'); assert.strictEqual(r2.leveled_up, false);
+ const lb = ch.leaderboard(5);
+ assert.strictEqual(lb[0].key, `user:${uid}`); assert.ok(lb.length === 3 && lb[0].title === ch.titleFor(lb[0].level));
+ assert.strictEqual(ch.leaderboard(5, { days: 7 })[0].gained, ch.profile(`user:${uid}`).xp, 'weekly board sums the log');
+ const prof = ch.profile(`user:${uid}`);
+ assert.ok(prof.recent_moments.length === 3 && prof.top_subjects[0].id === t.id && prof.rank === 1 && prof.titles.length === ch.TITLES.length);
+ assert.deepStrictEqual(await ch.buildCard(`user:${uid}`), { skipped: 'ai off' }, 'no AI → no card call');
+ assert.strictEqual(await ch.cardSweep(), 0);
+ console.log('✅ lore quotes, hype, subjects, level-ups (coins once per level), leaderboards, profile');
+ console.log('\n✅ All chatter tests passed');
+ process.exit(0);
+})().catch(err => { console.error(err); process.exit(1); });