openvibers/openvibe.live Commits

Arena: said_at on moments, bots excluded, Your Arena strip + daily check-in, notifications for beefs/levels/quotes/bounties/HOT/cards, bars layout fix

OP OpenVibers committed 30 Aug 2026 · 2d20f0dd5a · 11 files, +184 −29

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017AyeSFu7qdLdR64Zv99QNm

Changes

ddiff --git a/docs/arena.md b/docs/arena.md
iindex 7fbba5aa..e8d7b8e1 100644
---- a/docs/arena.md
++++ b/docs/arena.md
@@@ -116,3 +116,11 @@ Level = `1 + floor(√(XP / 25))` (the game's curve). Titles: Lurker → Chatter
**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`.
++
++## Coming back: your arena, check-ins, notifications
++
++- `GET /api/arena/me` (signed in) → your yap profile, XP today, coins banked from levels, fighter card if on the roster, open beefs where **you** are on the clock, subjects you're in, what's HOT. Rendered as the "Your Arena" strip at the top of `/arena`.
++- `POST /api/arena/checkin` → +5 XP once a day (+ the streak bonus) — the page calls it on the first visit of the day.
++- Arena events go to the site notification bell (`server/arena/notify.js` → `utils/notify.pushNotification`, deduped 10 min per user/type/key; users without a linked network account are skipped like everywhere else): a beef opened on you (with the clock), answers/hits while a beef is open, beef over (win/loss/draw), yap level-ups (+coins), getting quoted in lore, a bounty on you, your subject going HOT, your yap card being written.
++- Bots are never chatters: AI viewers (`source_platform='ai'`), channel AI bots (`channel_ai_bots`) and the admin list `arena_bot_usernames` (comma-separated) are excluded from moments, discovery input, voice samples and yapper XP.
++- Moments store `said_at` (the chat timestamp / stream start + offset) and every view uses it — never the time the line was filed.
ddiff --git a/public/css/style.css b/public/css/style.css
iindex fb6916e5..2d96b5b5 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -14570,3 +14570,21 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
.arena-bars-head { font-size: 0.85rem; } .arena-bars-head small { color: var(--text-muted); }
.arena-bar.is-clickable { cursor: pointer; }
@media (max-width: 600px) { .arena-profile-custom { grid-template-columns: 1fr; } .arena-radar-v2 { max-width: 260px; margin: 0 auto; } }
++
++/* ── Arena: your arena strip + bars fix ── */
++.arena-me { margin: 0 0 20px; }
++.arena-me-inner { display: grid; grid-template-columns: minmax(260px, 1fr) 1.4fr; gap: 14px; padding: 12px 16px; border-radius: 16px; background: var(--bg-card); border: 1px solid color-mix(in srgb, #f97316 40%, var(--border)); }
++.arena-me-left { display: flex; gap: 12px; align-items: center; min-width: 0; }
++.arena-me-left > div { display: flex; flex-direction: column; gap: 5px; min-width: 0; flex: 1; }
++.arena-me-name { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
++.arena-me-right { display: flex; flex-direction: column; gap: 6px; font-size: 0.86rem; min-width: 0; justify-content: center; }
++.arena-me-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
++.arena-me-alert { padding: 6px 10px; border-radius: 10px; background: rgba(239, 68, 68, 0.12); border: 1px solid rgba(239, 68, 68, 0.35); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
++.arena-me-alert a { color: var(--text-primary); text-decoration: none; }
++.arena-me-guest { padding: 12px 16px; border-radius: 16px; background: var(--bg-card); border: 1px dashed var(--border); color: var(--text-secondary); font-size: 0.9rem; }
++.arena-profile-stats.is-bars .arena-bars { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 18px; }
++.arena-profile-stats.is-bars .arena-bar { display: grid; grid-template-columns: 62px 1fr 34px; align-items: center; gap: 8px; }
++.arena-profile-stats.is-bars .arena-bar-track { display: block; height: 8px; border-radius: 4px; background: var(--bg-tertiary); overflow: hidden; }
++.arena-profile-stats.is-bars .arena-bar-fill { display: block; height: 100%; border-radius: 4px; }
++.arena-profile-stats.is-bars .arena-bar-val { text-align: right; font-weight: 800; font-variant-numeric: tabular-nums; }
++@media (max-width: 700px) { .arena-me-inner { grid-template-columns: 1fr; } .arena-profile-stats.is-bars .arena-bars { grid-template-columns: 1fr; } }
ddiff --git a/public/index.html b/public/index.html
iindex 61647ceb..02153893 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -62,7 +62,7 @@
</script>
<!-- Stylesheets -->
-- <link rel="stylesheet" href="/css/style.css?v=151">
++ <link rel="stylesheet" href="/css/style.css?v=152">
<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">
@@@ -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=12" defer></script>
++<script src="/js/arena.js?v=13" 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>
ddiff --git a/public/js/arena.js b/public/js/arena.js
iindex 0d957edf..81626566 100644
---- a/public/js/arena.js
++++ b/public/js/arena.js
@@@ -213,6 +213,7 @@ async function _aRenderHome(root) {
</div>
</div>
<section class="arena-pulse" id="arena-pulse">${_aPulse(boardData.pulse, boardData)}</section>
++ <section class="arena-me" id="arena-me">${_aMe() ? _aSpinner('Loading your arena…') : `<div class="arena-me-guest"><i class="fa-solid fa-user-plus"></i> <b>Sign in</b> and every line you type about a subject on the board is XP, a yap level, a card — and OpenCoins on each level-up.</div>`}</section>
<section class="arena-live" id="arena-live">${_aRenderLive(live.live || [])}</section>
<section class="arena-beefs" id="arena-beefs">${_aRenderBeefs(beefs)}</section>
<section class="arena-board" id="arena-board">${_aRenderBoard(boardData)}</section>
@@@ -244,6 +245,7 @@ async function _aRenderHome(root) {
</section>`;
_aRenderList(roster.fighters);
_aBindHome(root);
++ if (_aMe()) _aRenderMe().catch(() => { const el = document.getElementById('arena-me'); if (el) el.innerHTML = ''; });
document.getElementById('arena-search')?.addEventListener('input', (e) => {
const q = e.target.value.trim().toLowerCase();
_aRenderList(!q ? roster.fighters : roster.fighters.filter(f => [f.persona.fighter_name, f.user.display_name, f.user.username, f.persona.class, f.persona.element].filter(Boolean).some(s => s.toLowerCase().includes(q))));
@@@ -264,6 +266,27 @@ async function _aRenderHome(root) {
_aEvery(1000, () => _aTickClocks(root));
}
++async function _aRenderMe() {
++ const el = document.getElementById('arena-me');
++ if (!el) return;
++ let me = await api('/arena/me');
++ // Daily check-in: first visit of the day pays XP (+ streak). One call, idempotent.
++ if (!me.checked_in) { try { const r = await api('/arena/checkin', { method: 'POST' }); if (!r.already) { _aToast(`+${r.gained} XP — daily check-in${r.streak >= 2 ? ` · ${r.streak}-day streak` : ''}${r.leveled_up ? ` · LEVEL ${r.level}!` : ''}`, 'success'); if (r.leveled_up) _aLevelUp(r.level); me = await api('/arena/me'); } } catch { /* */ } }
++ const c = me.chatter, pct = c.xp_for_next ? Math.round((c.xp_into_level / c.xp_for_next) * 100) : 100;
++ el.innerHTML = `<div class="arena-me-inner">
++ <div class="arena-me-left">${_aYapRing(c, 56)}<div><div class="arena-me-name"><b>${_aEsc(c.name)}</b> <span class="arena-lvl">YAP ${c.level} · ${_aEsc((c.card && c.card.title) || c.title)}</span>${c.streak >= 2 ? `<span class="arena-tag arena-tag-hot arena-streak"><i class="fa-solid fa-fire"></i> ${c.streak}-day streak</span>` : ''}</div>
++ <span class="arena-xp-track"><span class="arena-xp-fill" style="width:${pct}%"></span></span>
++ <small class="arena-note">${c.xp_into_level}/${c.xp_for_next} to level ${c.level + 1} · <b>+${me.xp_today} XP today</b> · ${me.coins_from_arena} OpenCoins banked from levels</small></div></div>
++ <div class="arena-me-right">
++ ${me.on_clock.length ? me.on_clock.map(b => `<div class="arena-me-alert">${_aA(_aBeefLink(b), `<i class="fa-solid fa-stopwatch"></i> <b>${_aEsc((b.a.user.id === c.user?.id ? b.b : b.a).fighter_name)}</b> has words for you — answer on stream`)} ${_aClockTag(b)}</div>`).join('') : ''}
++ ${me.fighter ? `<div class="arena-me-row"><i class="fa-solid fa-hand-fist"></i> Fighter #${me.fighter.rank} · PWR ${me.fighter.power} · TL ${me.fighter.level.level} · ${me.fighter.record.wins}W–${me.fighter.record.losses}L ${me.fighter.live ? _aA(_aConsoleLink(me.fighter.user), '<i class="fa-solid fa-ear-listen"></i> your ears', 'arena-tag arena-tag-hot') : ''}</div>` : ''}
++ ${me.subjects.length ? `<div class="arena-me-row"><i class="fa-solid fa-comments"></i> You're in: ${me.subjects.map(s => _aA(`/arena/topic/${s.id}`, `${_aEsc(s.headline || s.text)} <b>${s.moments}</b>`, 'arena-thread')).join(' ')}</div>` : `<div class="arena-me-row arena-note"><i class="fa-solid fa-keyboard"></i> Type about ${me.hot_now.length ? me.hot_now.map(h => _aA(`/arena/topic/${h.id}`, _aEsc(h.headline), 'arena-thread')).join(' ') : 'any subject on the board'} in chat — every line that lands is XP.</div>`}
++ ${_aA(_aChatterLink(c.key), 'your yap page <i class="fa-solid fa-arrow-right"></i>', 'arena-subject-open')}
++ </div>
++ </div>`;
++ _aEvery(1000, () => _aTickClocks(el));
++}
++
function _aPulse(p, boardData) {
const open = boardData?.open || [];
const hot = open.filter(t => t.hot).length;
@@@ -501,7 +524,7 @@ function _aYapCard(y, i) {
${_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>
++ <div class="arena-yap-title">${_aEsc(card.title || y.title)} <small>· ${card.title ? `${_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>
@@@ -514,7 +537,7 @@ function _aYappersSection(bd) {
${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-chip"><span><strong>${_aA(_aChatterLink(y.key), _aEsc(y.name))} ${_aYapKind(y)}</strong><small>${_aEsc((y.card && y.card.title) || y.title)} · lvl ${y.level} · ${y.moments} moment${y.moments === 1 ? '' : 's'} 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>'}`;
@@@ -703,7 +726,7 @@ async function _aRenderTopic(root, id) {
${moments.length ? `<div class="arena-moment-list">${moments.map(m => _aMomentLine(m)).join('')}</div>` : '<p class="arena-note">Nothing on this thread yet.</p>'}
</section>
<aside class="arena-topic-aside">
-- ${t.fighters.length ? `<section><h3><i class="fa-solid fa-microphone-lines"></i> Heard on mic</h3>${t.fighters.map(f => `<div class="arena-member ${f.active ? 'is-active' : ''}">${_aBriefChip(f, ` · ${f.moments} on-mic · ${f.score} pts`)}${f.best ? `<q class="arena-receipt-mini">${_aEsc(f.best.text)}</q>` : ''}${me && f.user.id !== me.id && t.status === 'open' ? `<button class="btn btn-ghost btn-sm arena-topic-hype" data-topic="${t.id}" data-user="${f.user.id}"><i class="fa-solid fa-fire"></i> Hype</button>` : ''}</div>`).join('')}</section>` : ''}
++ ${t.fighters.length ? `<section><h3><i class="fa-solid fa-microphone-lines"></i> Heard on mic</h3>${t.fighters.map(f => `<div class="arena-member ${f.active ? 'is-active' : ''}">${_aBriefChip(f, ` · ${f.moments} on-mic${f.score ? ` · ${f.score} pts` : ''}`)}${f.best ? `<q class="arena-receipt-mini">${_aEsc(f.best.text)}</q>` : ''}${me && f.user.id !== me.id && t.status === 'open' ? `<button class="btn btn-ghost btn-sm arena-topic-hype" data-topic="${t.id}" data-user="${f.user.id}"><i class="fa-solid fa-fire"></i> Hype</button>` : ''}</div>`).join('')}</section>` : ''}
<section><h3><i class="fa-solid fa-keyboard"></i> Loudest in chat</h3>${t.top_chatters?.length ? `<div class="arena-mini-list">${t.top_chatters.map((c, i) => `<div class="arena-mini-row"><span class="arena-rank">${i + 1}</span><span class="arena-chip"><span><strong>${c.chatter_key ? _aA(_aChatterLink(c.chatter_key), _aEsc(c.username)) : _aEsc(c.username)}</strong>${c.level ? `<small>yap ${c.level} · ${_aEsc(c.title)}</small>` : ''}</span></span><span class="arena-lvl">${c.n}</span></div>`).join('')}</div>` : '<p class="arena-note">Nobody yet. Type about it in any chat.</p>'}</section>
${t.best_lines?.length ? `<section><h3><i class="fa-solid fa-quote-left"></i> Best on mic</h3>${t.best_lines.map(l => `<div class="arena-quote"><div><q>${_aEsc(l.text)}</q><small>${_aEsc(l.username || 'anon')} · ${l.quality}/10</small></div><div class="arena-quote-actions">${_aPlay(l.vod_id, l.sec)} ${_aSpeakBtn(l.text, '', l.username)}</div></div>`).join('')}</section>` : ''}
<section><h3><i class="fa-solid fa-circle-info"></i> How it works</h3><ul class="arena-rules-list"><li>Say it on mic or type it in chat — it lands here on its own. No joining.</li><li>Threads are the angles inside the subject; new ones appear as the argument moves.</li><li>Chat lines = yap XP · on-mic lines are judged for streamer XP.</li></ul></section>
@@@ -857,7 +880,7 @@ async function _aRenderFighter(root, username) {
</div>
<div class="arena-profile-stats is-bars">
<div class="arena-bars-head"><b>The numbers</b> <small>percentile across the roster · these make POWER · tap one for the breakdown</small></div>
-- <div class="arena-bars">${ARENA_STATS.map(k => `<div class="arena-bar is-clickable arena-quip" data-stat="${k}" title="${_aEsc((p.stat_quips || {})[k] || ARENA_STAT_LABEL[k])}"><span class="arena-bar-label">${_aEsc(ARENA_STAT_LABEL[k])}</span><span class="arena-bar-track"><span class="arena-bar-fill" style="width:${Math.max(0, Math.min(100, f.ratings[k] || 0))}%;background:${_aEsc(color)}"></span></span><span class="arena-bar-val">${f.ratings[k] ?? '–'}</span></div>`).join('')}</div>
++ <div class="arena-bars">${ARENA_STATS.map(k => `<div class="arena-bar is-clickable" data-stat="${k}" title="${_aEsc((p.stat_quips || {})[k] || ARENA_STAT_LABEL[k])}"><span class="arena-bar-label">${_aEsc(ARENA_STAT_LABEL[k])}</span><span class="arena-bar-track"><span class="arena-bar-fill" style="width:${Math.max(0, Math.min(100, f.ratings[k] || 0))}%;background:${_aEsc(color)}"></span></span><span class="arena-bar-val">${f.ratings[k] ?? '–'}</span></div>`).join('')}</div>
<div id="arena-stat-detail"></div>
</div>
<div class="arena-lore">
@@@ -889,7 +912,7 @@ async function _aRenderFighter(root, username) {
_aBindHome(root);
_aEvery(1000, () => _aTickClocks(root));
-- root.querySelectorAll('.arena-quip.is-clickable').forEach(el => el.addEventListener('click', async () => {
++ root.querySelectorAll('.arena-bar.is-clickable, .arena-quip.is-clickable').forEach(el => el.addEventListener('click', async () => {
const stat = el.dataset.stat;
const box = document.getElementById('arena-stat-detail');
if (box.dataset.stat === stat) { box.innerHTML = ''; box.dataset.stat = ''; return; }
ddiff --git a/server/arena/beef.js b/server/arena/beef.js
iindex 27892ded..038dd97c 100644
---- a/server/arena/beef.js
++++ b/server/arena/beef.js
@@@ -141,6 +141,7 @@ function recordHit(speakerId, targetId, hit) {
opened = true;
board().addXp(speakerId, XP_BEEF_OPEN, 'beef_open', beef.id);
console.log(`[Arena] beef #${beef.id} opened: user ${speakerId} → user ${targetId}${history.fights ? ' (rematch)' : ''}${bounty ? ' (bounty!)' : ''}`);
++ try { require('./notify').arenaNotify(targetId, { type: 'beef_open', title: `${nameOf(speakerId)} has words for you${history.fights ? ' — rematch' : ''}`, message: `${hit.best_line ? `“${String(hit.best_line).slice(0, 120)}” — ` : ''}answer on your own stream within ${isLive(targetId) ? `${RESPONSE_LIVE_MIN} min` : `${RESPONSE_OFFLINE_HOURS} h`} or forfeit.`, icon: '🥊', url: `/arena/beef/${beef.id}`, key: `open:${beef.id}`, senderId: speakerId, senderName: nameOf(speakerId) }); } catch { /* */ }
const id = beef.id;
headlineFor('open', { a: nameOf(speakerId), b: nameOf(targetId), opening_line: hit.best_line, about: hit.about, rematch: history.fights > 0, record_between: history, bounty: !!bounty }).then(h => { if (h) db.run('UPDATE arena_beefs SET headline = ? WHERE id = ?', [h, id]); }).catch(() => {});
}
@@@ -156,6 +157,7 @@ function recordHit(speakerId, targetId, hit) {
// Bounty: anyone with a bounty on their head pays double to whoever collects.
const bounty = board().openBountyOn(targetId);
board().addXp(speakerId, quality * XP_BEEF_HIT * (bounty ? 2 : 1), 'beef_hit', beef.id, { beefHit: true, line: hit.best_line, lineScore: quality, lineVodId: hit.vod_id, lineSec: hit.sec });
++ if (!opened) { try { require('./notify').arenaNotify(otherId, { type: firstResponse ? 'beef_answered' : 'beef_hit', title: `${nameOf(speakerId)} ${firstResponse ? 'answered' : 'hit back'} (${quality}/10)`, message: `${hit.best_line ? `“${String(hit.best_line).slice(0, 120)}” — ` : ''}you're on the clock now.`, icon: '🔥', url: `/arena/beef/${beef.id}`, key: `hit:${beef.id}:${beef.hits_a + beef.hits_b}`, senderId: speakerId, senderName: nameOf(speakerId) }); } catch { /* */ } }
if (bounty) board().recordBountyHit(speakerId, bounty, quality, hit.best_line || null);
return { beef: db.get('SELECT * FROM arena_beefs WHERE id = ?', [beef.id]), opened, side, first_response: firstResponse, bounty: !!bounty };
}
@@@ -188,6 +190,7 @@ function resolve(beef, resolution, winnerId) {
db.run(`UPDATE arena_beefs SET status = 'resolved', resolution = ?, winner_user_id = ?, resolved_at = CURRENT_TIMESTAMP, on_clock = NULL, clock_until = NULL, feed_json = ?, upset = ?, result_headline = ? WHERE id = ? AND status = 'open'`, [resolution, winnerId, feed, upset, templateHeadline(resolution === 'forfeit' ? 'forfeit' : 'score', ctx), beef.id]);
if (winnerId) board().addXp(winnerId, XP_BEEF_WIN + (upset ? 20 : 0), upset ? 'beef_upset_win' : 'beef_win', beef.id);
console.log(`[Arena] beef #${beef.id} resolved: ${resolution}${winnerId ? ` winner user ${winnerId}` : ' draw'}${upset ? ' UPSET' : ''}`);
++ try { const n = require('./notify'); for (const uid of [beef.a_user_id, beef.b_user_id]) { const won = winnerId === uid, draw = winnerId == null; n.arenaNotify(uid, { type: 'beef_over', title: draw ? `Draw with ${nameOf(uid === beef.a_user_id ? beef.b_user_id : beef.a_user_id)}` : won ? `You won the beef with ${nameOf(loserId)}${upset ? ' — UPSET' : ''}` : `${nameOf(winnerId)} won the beef${resolution === 'forfeit' ? ' — you ran out the clock' : ' on points'}`, message: won ? `+${XP_BEEF_WIN + (upset ? 20 : 0)} XP. ${t.a}–${t.b}.` : `${t.a}–${t.b}. Say their name on mic for a rematch.`, icon: won ? '🏆' : draw ? '🤝' : '💀', url: `/arena/beef/${beef.id}`, key: `over:${beef.id}` }); } } catch { /* */ }
const id = beef.id;
headlineFor(resolution, ctx).then(h => { if (h) db.run('UPDATE arena_beefs SET result_headline = ? WHERE id = ?', [h, id]); }).catch(() => {});
try { arena().loadRoster(true); } catch { /* */ }
ddiff --git a/server/arena/board.js b/server/arena/board.js
iindex e3a65c6a..72f73403 100644
---- a/server/arena/board.js
++++ b/server/arena/board.js
@@@ -96,7 +96,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', 'thread_id INTEGER']) { try { db.run(`ALTER TABLE arena_topic_moments ADD COLUMN ${col}`); } catch { /* exists */ } }
++ for (const col of ['chatter_key TEXT', 'source_platform TEXT', 'anon_id TEXT', 'thread_id INTEGER', 'said_at DATETIME']) { try { db.run(`ALTER TABLE arena_topic_moments ADD COLUMN ${col}`); } catch { /* exists */ } }
++ db.run('UPDATE arena_topic_moments SET said_at = created_at WHERE said_at IS NULL');
// Threads: the sub-angles INSIDE an umbrella subject ("Cat enema drama" → "the vet bill", "the bot roasts", "global vs local").
db.run(`CREATE TABLE IF NOT EXISTS arena_topic_threads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@@ -259,6 +260,7 @@ function createTopic({ text, hint = null, createdBy, creatorUserId = null, creat
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
[clean, hint ? clip(hint, 120) : null, createdBy, creatorUserId, creatorName ? clip(creatorName, 40) : null, ipHash(creatorIp), kind, targetUserId, headline ? clip(headline, 160) : null, sourceNote ? clip(sourceNote, 80) : null, expires, JSON.stringify(kws), tagline ? clip(tagline, 120) : null, origin ? JSON.stringify(origin) : null, submittedText ? clip(submittedText, 200) : null]);
const row = db.get('SELECT * FROM arena_topics ORDER BY id DESC LIMIT 1');
++ if (kind === 'bounty' && targetUserId) { try { require('./notify').arenaNotify(targetUserId, { type: 'bounty', title: `Chat put a bounty on you`, message: `${creatorName || 'The community'}: double XP for anyone who talks shit about you on stream for the next ${KIND_TTL_HOURS.bounty} h. Answer on mic.`, icon: '💰', url: `/arena/topic/${row.id}`, key: `bounty:${row.id}` }); } catch { /* */ } }
for (const t of threadList) db.run('INSERT INTO arena_topic_threads (topic_id, name, hint, keywords_json) VALUES (?, ?, ?, ?)', [row.id, t.name, t.hint, JSON.stringify(t.keywords)]);
invalidateMatchers();
console.log(`[Arena] ${kind} #${row.id} by ${createdBy}${creatorName ? ' ' + creatorName : ''}: "${clean}" [${kws.join(', ')}]${threadList.length ? ` threads: ${threadList.map(t => t.name).join(' | ')}` : ''}`);
@@@ -309,7 +311,7 @@ async function submitTopic({ text, userId, ip, creatorName, onRoster = false })
}
/** 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, platform = null, anonId = 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, saidAt = null }) {
ensureTables();
const t = db.get(`SELECT id, kind, status, heat FROM arena_topics WHERE id = ?`, [topicId]);
if (!t || t.status !== 'open') return null;
@@@ -318,10 +320,13 @@ function addMoment(topicId, { kind, source, userId = null, username = null, stre
const chatters = require('./chatters');
const chatterKey = chatters.keyFor({ user_id: userId, username, anon_id: anonId, source_platform: platform });
const threadId = threadFor(topicId, body);
-- 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, thread_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
-- [topicId, kind, source, userId, username ? clip(username, 60) : null, streamId, vodId, sec, body, quality, about ? clip(about, 80) : null, chatterKey, platform, anonId, threadId]);
++ // When the line was actually SAID (chat timestamp / stream start + offset) — not when we filed it.
++ let said = saidAt ? String(saidAt).replace('T', ' ').replace(/\.\d+Z?$/, '').replace('Z', '') : null;
++ if (!said && kind === 'speech' && streamId && sec != null) { const st = db.get('SELECT started_at FROM streams WHERE id = ?', [streamId]); if (st?.started_at) { const ms = Date.parse(String(st.started_at).replace(' ', 'T') + 'Z'); if (!isNaN(ms)) said = new Date(ms + sec * 1000).toISOString().replace('T', ' ').slice(0, 19); } }
++ 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, thread_id, said_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP))`,
++ [topicId, kind, source, userId, username ? clip(username, 60) : null, streamId, vodId, sec, body, quality, about ? clip(about, 80) : null, chatterKey, platform, anonId, threadId, said]);
if (threadId) db.run('UPDATE arena_topic_threads SET moments = moments + 1, last_at = CURRENT_TIMESTAMP WHERE id = ?', [threadId]);
-- 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 = MAX(COALESCE(last_mention_at, ''), COALESCE(?, CURRENT_TIMESTAMP)), last_activity_at = CURRENT_TIMESTAMP WHERE id = ?`, [said, 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 }); }
@@@ -409,7 +414,11 @@ function computeHeat(topicId) {
const hype = db.get(`SELECT COUNT(*) AS n FROM arena_topic_hype WHERE topic_id = ? AND created_at >= datetime('now', '-60 minutes')`, [topicId])?.n || 0;
const talking = db.get(`SELECT COUNT(*) AS n FROM arena_topic_members WHERE topic_id = ? AND active = 1`, [topicId])?.n || 0;
const heat = (m.mic || 0) * 3 + (m.chat || 0) + hype + talking * 4;
++ const prev = db.get('SELECT heat, creator_user_id, headline, text FROM arena_topics WHERE id = ?', [topicId]);
db.run('UPDATE arena_topics SET heat = ? WHERE id = ?', [heat, topicId]);
++ if (prev && (prev.heat || 0) < HOT_THRESHOLD && heat >= HOT_THRESHOLD) {
++ try { const n = require('./notify'); const who = new Set([prev.creator_user_id, ...db.all('SELECT user_id FROM arena_topic_members WHERE topic_id = ?', [topicId]).map(r => r.user_id), ...db.all(`SELECT user_id FROM arena_topic_moments WHERE topic_id = ? AND user_id IS NOT NULL GROUP BY user_id`, [topicId]).map(r => r.user_id)].filter(Boolean)); for (const uid of who) n.arenaNotify(uid, { type: 'hot', title: `“${String(prev.headline || prev.text).slice(0, 60)}” is HOT`, message: `Your subject is the loudest thing on the board right now (${heat} heat).`, icon: '🔥', url: `/arena/topic/${topicId}`, key: `hot:${topicId}` }); } catch { /* */ }
++ }
return heat;
}
@@@ -457,6 +466,29 @@ function nameOf(userId) {
try { return fighterBrief(userId, arena().loadRoster()).fighter_name; } catch { const u = db.getUserById(userId); return u ? (u.display_name || u.username) : `user${userId}`; }
}
++// ── Bots are not chatters ────────────────────────────────────
++// AI viewers mark their rows with source_platform 'ai'; channel AI bots post as real accounts, so
++// their usernames come from channel_ai_bots / ai_chatbot_configs (cached a minute).
++let _bots = { at: 0, names: new Set(), ids: new Set() };
++function botSet() {
++ if (Date.now() - _bots.at < 60 * 1000) return _bots;
++ const names = new Set(), ids = new Set();
++ for (const t of ['channel_ai_bots', 'ai_chatbot_configs']) {
++ try { for (const r of db.all(`SELECT * FROM ${t}`)) { for (const k of ['username', 'bot_username', 'name', 'bot_name', 'display_name']) if (r[k]) names.add(String(r[k]).toLowerCase()); for (const k of ['bot_user_id', 'user_id']) if (r[k] && k === 'bot_user_id') ids.add(Number(r[k])); } } catch { /* table shape varies */ }
++ }
++ // Admin list for bots that post from real accounts (site setting, comma-separated usernames).
++ try { for (const n of String(db.getSetting('arena_bot_usernames') || '').split(/[,\s]+/)) if (n) names.add(n.toLowerCase()); } catch { /* */ }
++ _bots = { at: Date.now(), names, ids };
++ return _bots;
++}
++function isBotChatter(row) {
++ if (!row) return false;
++ if (String(row.source_platform || '') === 'ai') return true;
++ const b = botSet();
++ if (row.user_id && b.ids.has(Number(row.user_id))) return true;
++ return !!row.username && b.names.has(String(row.username).toLowerCase());
++}
++
// ── Discovery: what is the community actually talking about? ─
let _scan = { lastChatId: null, lastDiscoverAt: 0, lastDiscoverSeen: 0 };
@@@ -465,13 +497,14 @@ 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, 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]);
++ const rows = db.all(`SELECT id, stream_id, user_id, username, message, source_platform, anon_id, timestamp 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, platform: r.source_platform || null, anonId: r.anon_id || null })) moments++; }
++ if (isBotChatter(r)) 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, platform: r.source_platform || null, anonId: r.anon_id || null, saidAt: r.timestamp })) moments++; }
}
return { scanned: rows.length, moments };
}
@@@ -493,7 +526,7 @@ const DISCOVER_SYSTEM = `You are the community consciousness of a live-streaming
function 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 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`, [`-${scanWindowMin()} 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, c.user_id, c.source_platform 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`, [`-${scanWindowMin()} minutes`]).reverse().filter(r => !isBotChatter(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`, [`-${scanWindowMin()} 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 { /* */ }
@@@ -559,14 +592,14 @@ 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, { windowMin = null } = {}) {
const win = Math.max(30, Number(windowMin) || scanWindowMin() * 4);
-- 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 DESC LIMIT 600`, [`-${win} minutes`]).reverse();
-- 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 DESC LIMIT 400`, [`-${win} minutes`]).reverse();
++ const chat = db.all(`SELECT id, stream_id, user_id, username, message, source_platform, anon_id, timestamp 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 DESC LIMIT 600`, [`-${win} minutes`]).reverse();
++ const speech = db.all(`SELECT e.stream_id, e.user_id, e.vod_id, e.start_sec, e.text, e.created_at, 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 DESC LIMIT 400`, [`-${win} minutes`]).reverse();
let seeded = 0;
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, 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 c of chat) if (!isBotChatter(c) && !/^!/.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, saidAt: c.timestamp })) 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, saidAt: s.created_at })) n++; }
if (n) console.log(`[Arena] topic #${t.id} seeded with ${n} moment(s) from the last ${win} min`);
seeded += n;
}
@@@ -588,7 +621,7 @@ const LORE_SYSTEM = `You write and update the LORE of a subject the community of
/** What the site already knows: how the community types, the global chat AI overview, and the chat AI's profile of the people in these moments. Cheap (all cached rows), no calls. */
function communityContext(moments) {
const out = {};
-- try { out.how_this_chat_types = db.all(`SELECT message FROM chat_messages WHERE COALESCE(is_deleted, 0) = 0 AND COALESCE(source_platform, '') != 'ai' AND COALESCE(message_type, 'chat') = 'chat' AND LENGTH(message) BETWEEN 8 AND 140 AND message NOT LIKE '!%' ORDER BY id DESC LIMIT 25`).map(r => r.message); } catch { out.how_this_chat_types = []; }
++ try { out.how_this_chat_types = db.all(`SELECT message, username, user_id, source_platform FROM chat_messages WHERE COALESCE(is_deleted, 0) = 0 AND COALESCE(source_platform, '') != 'ai' AND COALESCE(message_type, 'chat') = 'chat' AND LENGTH(message) BETWEEN 8 AND 140 AND message NOT LIKE '!%' ORDER BY id DESC LIMIT 40`).filter(r => !isBotChatter(r)).slice(0, 25).map(r => r.message); } catch { out.how_this_chat_types = []; }
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); out.global_chat_memory = clip(g.memory_json, 400); } } catch { /* */ }
try {
const chatters = require('./chatters');
@@@ -672,12 +705,12 @@ 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, chatter_key: m.chatter_key || null, platform: m.source_platform || null, thread_id: m.thread_id || 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 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, thread_id: m.thread_id || 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.said_at || m.created_at, filed_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]);
const heat = topic.heat || 0;
-- const last = db.get('SELECT * FROM arena_topic_moments WHERE topic_id = ? ORDER BY id DESC LIMIT 1', [topic.id]);
++ const last = db.get('SELECT * FROM arena_topic_moments WHERE topic_id = ? ORDER BY COALESCE(said_at, created_at) DESC, id DESC LIMIT 1', [topic.id]);
const best = db.get('SELECT * FROM arena_topic_moments WHERE topic_id = ? AND quality IS NOT NULL ORDER BY quality DESC, id DESC LIMIT 1', [topic.id]);
const out = {
id: topic.id, text: topic.text, hint: topic.hint, created_by: topic.created_by, creator_name: topic.creator_name, status: topic.status,
@@@ -695,7 +728,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.moments = db.all('SELECT * FROM arena_topic_moments WHERE topic_id = ? ORDER BY COALESCE(said_at, created_at) DESC, id DESC LIMIT 80', [topic.id]).map(momentView);
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; }
@@@ -733,7 +766,7 @@ function levelsLeaderboard(limit = 10) {
function yappersLeaderboard(limit = 10) { return require('./chatters').leaderboard(limit); }
module.exports = {
-- ensureTables, createTopic, submitTopic, assertCanSubmit, joinTopic, leaveTopic, activeTopicFor, threadsOf, threadFor, upsertThreads, applyTopicJudgement, hypeTopic, addMoment, noteMicMention, matchTopics, keywordsFromText,
++ ensureTables, createTopic, submitTopic, assertCanSubmit, joinTopic, leaveTopic, activeTopicFor, threadsOf, threadFor, upsertThreads, isBotChatter, applyTopicJudgement, hypeTopic, addMoment, noteMicMention, matchTopics, keywordsFromText,
addXp, levelRow, levelView, levelFor, recentXp, boardView, topicDetail, levelsLeaderboard, yappersLeaderboard, fighterBrief, archiveStale,
scanChat, discoverTopics, discoverInput, heuristicDiscover, backfillMoments, buildLore, loreSweep, templateLore, pulse, nameOf,
computeHeat, resolveExpired, openBountyOn, recordBountyHit, HOT_THRESHOLD, KIND_TTL_HOURS, XP_PER_LEVEL, XP_JOIN, XP_HYPE, TOPIC_TTL_HOURS, SCAN_WINDOW_MIN, USER_TOPIC_COOLDOWN_HOURS,
ddiff --git a/server/arena/chatters.js b/server/arena/chatters.js
iindex 2e72de7f..1eed507b 100644
---- a/server/arena/chatters.js
++++ b/server/arena/chatters.js
@@@ -164,6 +164,7 @@ function onLevelUp(key, from, to, { streamId = null } = {}) {
if (total > 0) payCoins(key, p.user_id, name, total, to);
} catch (e) { console.warn('[Arena] yap coins:', e.message); }
}
++ if (p && p.kind === 'user') { try { require('./notify').arenaNotify(p.user_id, { type: 'yap_level', title: `Yap level ${to} — ${titleFor(to)}`, message: `${to * COINS_PER_LEVEL} OpenCoins banked for the level. Keep typing about the board's subjects.`, icon: '⬆️', url: `/arena/chatter/${encodeURIComponent(key)}`, key: `lvl:${to}` }); } catch { /* */ } }
// Tell the room.
try {
const chat = require('../chat/chat-server');
@@@ -196,6 +197,7 @@ async function settleCoins(max = 5) {
function onMoment(moment, topic, { hot = false } = {}) {
if (!moment || moment.kind !== 'chat') return null;
++ try { if (require('./board').isBotChatter(moment)) return null; } catch { /* */ }
const key = moment.chatter_key || keyFor(moment);
if (!key) return null;
ensureRow(key, { display: moment.username });
@@@ -243,7 +245,7 @@ function onLore(topic, loreText, moments) {
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++; }
++ 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++; const p = parseKey(key); if (p?.kind === 'user') { try { require('./notify').arenaNotify(p.user_id, { type: 'quoted', title: `You made the lore of “${String(topic.headline || topic.text).slice(0, 60)}”`, message: `+${XP_QUOTED_IN_LORE} XP for getting quoted.`, icon: '📜', url: `/arena/topic/${topic.id}`, key: `quoted:${topic.id}` }); } catch { /* */ } } }
}
return n;
}
@@@ -291,6 +293,7 @@ async function buildCard(key, { force = false } = {}) {
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}"`);
++ if (r.kind === 'user' && r.user_id) { try { require('./notify').arenaNotify(r.user_id, { type: 'yap_card', title: `Your yap card: “${String(out.title).slice(0, 60)}”`, message: String(out.blurb || '').slice(0, 160), icon: '🃏', url: `/arena/chatter/${encodeURIComponent(key)}`, key: `card:${new Date().toISOString().slice(0, 10)}` }); } catch { /* */ } }
return out;
}
@@@ -342,4 +345,16 @@ function leaderboard(limit = 10, { days = null } = {}) {
}
function count() { ensureTables(); return db.get('SELECT COUNT(*) AS n FROM chatter_profiles WHERE xp > 0')?.n || 0; }
--module.exports = { ensureTables, settleCoins, payCoins, 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 };
++const XP_CHECKIN = 5;
++/** Daily Arena check-in for signed-in users: +5 XP once a day (+ streak bonus via the normal path). */
++function checkin(userId, { display = null } = {}) {
++ ensureTables();
++ const key = `user:${userId}`;
++ ensureRow(key, { display });
++ const d = today();
++ if (db.get(`SELECT 1 FROM chatter_xp_log WHERE key = ? AND reason = 'checkin' AND created_at >= ?`, [key, `${d} 00:00:00`])) return { already: true, ...view(row(key)) };
++ const bonus = streakBonus(key);
++ const r = addXp(key, XP_CHECKIN + bonus, 'checkin', null, { display });
++ return { already: false, gained: XP_CHECKIN + bonus, ...r, ...view(row(key)) };
++}
++module.exports = { ensureTables, checkin, settleCoins, payCoins, 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 };
ddiff --git a/server/arena/notify.js b/server/arena/notify.js
nnew file mode 100644
iindex 00000000..057bf2b7
---- /dev/null
++++ b/server/arena/notify.js
@@@ -0,0 +1,19 @@
++/**
++ * notify.js — Arena events → the site notification bell (openvibe.network store, via utils/notify).
++ * Fire-and-forget, deduped per (user, type, key) for 10 min so clock ticks never spam.
++ */
++'use strict';
++
++const { pushNotification } = require('../utils/notify');
++let base = 'https://openvibe.live'; try { base = String(require('../config').baseUrl || base).replace(/\/$/, ''); } catch { /* */ }
++const _recent = new Map();
++function arenaNotify(userId, { type, title, message, icon = '🥊', url = '/arena', key = null, senderId = null, senderName = null }) {
++ if (!userId) return;
++ const k = `${userId}:${type}:${key || url}`;
++ const now = Date.now();
++ if (now - (_recent.get(k) || 0) < 10 * 60 * 1000) return;
++ _recent.set(k, now);
++ if (_recent.size > 5000) for (const [kk, t] of _recent) if (now - t > 10 * 60 * 1000) _recent.delete(kk);
++ try { pushNotification({ user_id: userId, type: `ARENA_${String(type).toUpperCase()}`, title: String(title).slice(0, 120), message: String(message || '').slice(0, 240), icon, url: url.startsWith('http') ? url : `${base}${url}`, sender_id: senderId || undefined, sender_name: senderName || undefined }); } catch { /* */ }
++}
++module.exports = { arenaNotify };
ddiff --git a/server/arena/routes.js b/server/arena/routes.js
iindex 7d1b5d43..6d779e37 100644
---- a/server/arena/routes.js
++++ b/server/arena/routes.js
@@@ -167,6 +167,29 @@ router.get('/voice/:user', optionalAuth, async (req, res) => {
} catch (err) { res.status(err.message && /budget|hoarse/.test(err.message) ? 429 : 500).json({ error: err.message || 'Voice failed' }); }
});
++// ── Your Arena: the signed-in user's own state (yap level, streak, XP today, coins, beefs on them) ──
++router.get('/me', requireAuth, (req, res) => {
++ try {
++ const ch = require('./chatters');
++ const roster = arena.loadRoster();
++ const key = `user:${req.user.id}`;
++ const chatter = ch.profile(key) || { ...ch.view({ key, kind: 'user', user_id: req.user.id, display_name: req.user.display_name || req.user.username, xp: 0, level: 1 }), recent_moments: [], top_subjects: [], xp_log: [] };
++ const xpToday = db.get(`SELECT COALESCE(SUM(amount), 0) AS n FROM chatter_xp_log WHERE key = ? AND created_at >= date('now')`, [key])?.n || 0;
++ const checkedIn = !!db.get(`SELECT 1 FROM chatter_xp_log WHERE key = ? AND reason = 'checkin' AND created_at >= date('now')`, [key]);
++ const paid = ch.row(key)?.coins_paid_level || 0; let coins = 0; for (let l = 2; l <= paid; l++) coins += l * ch.COINS_PER_LEVEL;
++ const onRoster = !!roster.byId[req.user.id];
++ const fighter = onRoster ? { ...board.fighterBrief(req.user.id, roster), level: board.levelView(req.user.id), record: beef.recordFor(req.user.id), power: roster.byId[req.user.id].ratings.power } : null;
++ const beefs = onRoster ? beef.openBeefsFor(req.user.id).map(b => beef.beefView(b, roster)) : [];
++ const subjects = db.all(`SELECT t.id, t.headline, t.text, t.heat, s.moments FROM chatter_subjects s JOIN arena_topics t ON t.id = s.topic_id WHERE s.key = ? AND t.status = 'open' ORDER BY t.heat DESC LIMIT 5`, [key]);
++ const onClock = beefs.filter(b => (b.on_clock === 'a' ? b.a.user.id : b.b.user.id) === req.user.id);
++ res.set('Cache-Control', 'no-store');
++ res.json({ chatter, xp_today: xpToday, checked_in: checkedIn, coins_from_arena: coins, fighter, beefs, on_clock: onClock, subjects, hot_now: board.boardView().open.filter(t => t.hot).slice(0, 3).map(t => ({ id: t.id, headline: t.headline || t.text })) });
++ } catch (err) { fail(res, err, 'Failed to load your arena'); }
++});
++router.post('/checkin', requireAuth, (req, res) => {
++ try { res.json(require('./chatters').checkin(req.user.id, { display: req.user.display_name || req.user.username })); } catch (err) { fail(res, err, 'Check-in 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 { 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.
ddiff --git a/test/arena-beef-board.test.js b/test/arena-beef-board.test.js
iindex 29d0432a..0fcab1e5 100644
---- a/test/arena-beef-board.test.js
++++ b/test/arena-beef-board.test.js
@@@ -69,11 +69,21 @@ board.ensureTables(); beef.ensureTables();
say(u2, 'grizzly_bear', 'BABY VOICE gang rise up');
say(viewer, 'viewer', '!topic should be ignored baby voice');
say(null, 'anon_guy', 'pakistanis run this chat lol');
++ db.run('INSERT INTO chat_messages (stream_id, user_id, username, message, source_platform) VALUES (NULL, NULL, ?, ?, ?)', ['sleepyotter_ttv', 'baby voice is peak content honestly', 'ai']);
++ db.setSetting('arena_bot_usernames', 'ChuckBot');
++ db.run('INSERT INTO chat_messages (stream_id, user_id, username, message) VALUES (NULL, NULL, ?, ?)', ['ChuckBot', 'baby voice detected, deploying roast']);
const scan = board.scanChat();
-- assert.strictEqual(scan.moments, 3, `three moments from four new messages (commands skipped): ${JSON.stringify(scan)}`);
++ assert.strictEqual(scan.moments, 3, `three moments from six new messages (commands + bots skipped): ${JSON.stringify(scan)}`);
++ assert.ok(board.isBotChatter({ username: 'chuckbot' }) && board.isBotChatter({ source_platform: 'ai' }) && !board.isBotChatter({ username: 'viewer' }));
let d1 = board.topicDetail(t1.id);
assert.strictEqual(d1.mentions.chat, 2); assert.strictEqual(d1.chatters, 2);
assert.strictEqual(d1.last_moment.username, 'grizzly_bear');
++ db.run(`UPDATE chat_messages SET timestamp = datetime('now', '-3 hours') WHERE message LIKE 'nova does the baby voice%'`);
++ const old = board.createTopic({ text: 'Nova dono voice', createdBy: 'chat', creatorName: 'x', keywords: ['nova does'] });
++ board.backfillMoments([old], { windowMin: 600 });
++ const om = board.topicDetail(old.id).moments[0];
++ assert.ok(om && Date.now() - Date.parse(om.at.replace(' ', 'T') + 'Z') > 2.5 * 3600_000, `moment time is when it was said, not when it was filed: ${om && om.at} vs ${om && om.filed_at}`);
++ db.run(`UPDATE arena_topics SET status = 'archived' WHERE id = ?`, [old.id]);
assert.strictEqual(board.topicDetail(auto.id).mentions.chat, 1);
assert.ok(!board.addMoment(t1.id, { kind: 'chat', source: 'chat', username: 'x', text: 'kys baby voice people' }), 'threat lines never become moments');
@@@ -119,7 +129,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.subjects === 1 && y.level >= 1 && y.title), 'yapper profiles carry level + title');
++ 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.
ddiff --git a/test/arena-chatters.test.js b/test/arena-chatters.test.js
iindex e63a128e..83d4f1cf 100644
---- a/test/arena-chatters.test.js
++++ b/test/arena-chatters.test.js
@@@ -63,7 +63,10 @@ console.log('✅ moments → XP for accounts, anon and relay chatters');
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');
++ const c1 = ch.checkin(uid, { display: 'Viewer' });
++ assert.strictEqual(c1.already, false); assert.ok(c1.gained >= 5, 'daily check-in pays');
++ const c2 = ch.checkin(uid); assert.strictEqual(c2.already, true, 'once a day');
++ console.log('✅ lore quotes, hype, subjects, level-ups (coins once per level), leaderboards, profile, daily check-in');
console.log('\n✅ All chatter tests passed');
process.exit(0);
})().catch(err => { console.error(err); process.exit(1); });