openvibers/openvibe.live Commits
Arena board v3: umbrella subjects with threads, community voice, no joining, compact mobile layout
Changes
@@@ -32,9 +32,10 @@ Headlines (open + result) are AI-written (`chat` role) with templates as fallbacNo voting anywhere. The board is a list of **subjects** the community is actually on about, each a lore profile that writes itself:--- **Discovery** — every 5 min (only when ≥ 8 new lines exist, so a dead room costs nothing) the AI reads the last 30 min of global + stream chat and live transcripts and returns the subjects people are really on about (a person, a group, a joke, a drama…) with a tabloid headline, a tagline, the **keywords/slang** people use for it, and optionally one **bounty** on a streamer the community keeps naming. Without AI, a word ≥ 3 people said ≥ 4 times becomes a subject. New subjects are **backfilled** with every matching line from the last 2 h so they never start empty.--- **Moments** — every minute new `chat_messages` are scanned; any line matching a subject's keywords becomes a chat **moment** on it. The listener does the same for transcript lines (an on-mic moment with a VOD deep link, one per stream per 45 s) and **auto-joins** the fighter to the subject. Judged chunks (≥ 20 words, AI quality 0–10) are moments with a score → XP (quality × 0.8) → Trash Level.--- **Lore** — when a subject has ≥ 3 new moments (and ≥ 8 min since the last rewrite) the AI rewrites its lore: who started it, who said what (verbatim, with usernames), who is on which end, escalations. Templated when AI is off. Lore stays in the archive after the subject cools off (36 h idle).++- **Discovery** — every 5 min (only when ≥ 8 new lines exist, so a dead room costs nothing; window `arena_discover_window_min`, default 30) the AI reads global + stream chat and live transcripts and returns the **umbrella subjects** people are really on about (1–4 words: a person, a group, a joke, a drama…) each with 2–4 **threads** — the specific angles being argued (name + keywords + hint) — a short headline and tagline in the community's own voice, the keywords/slang people use, and optionally one **bounty** on a streamer the community keeps naming. Voice rules: write like a regular of this chat (its casing, slang, rhythm), no tabloid adjective soup, no invented words; the prompt gets recent chat lines, the global chat AI overview and the chat AI's profiles of the people involved. Without AI, a word ≥ 3 people said ≥ 4 times becomes a subject. New subjects are **backfilled** with every matching line from the last 2 h so they never start empty.++- **Moments** — every minute new `chat_messages` are scanned; any line matching a subject's keywords becomes a chat **moment** on it and is filed into the first thread whose keywords match (`thread_id`). The listener does the same for transcript lines (an on-mic moment with a VOD deep link, one per stream per 45 s) and **auto-joins** the fighter to the subject. Judged chunks (≥ 20 words, AI quality 0–10) are moments with a score → XP (quality × 0.8) → Trash Level.++- **Nobody joins anything** — the ears detect who is on what: a fighter saying a subject on mic is auto-filed as a member; there are no join/leave endpoints or buttons.++- **Lore** — when a subject has ≥ 3 new moments (and ≥ 8 min since the last rewrite) the AI rewrites its short (≤ 450 chars) story so far and returns the updated threads (existing kept, new angles added, ≤ 6 per subject; unfiled moments are re-filed): who started it, who said what (verbatim, with usernames), who is on which end, escalations. Templated when AI is off. Lore stays in the archive after the subject cools off (36 h idle).- **User subjects** — signed-in only, **one per person and one per IP per 24 h**. The AI rewrites what was typed into a subject + headline + keywords (`submitTopic`, one small call) and backfills moments. `!topic <text>` in chat does the same.- **Heat** = on-mic moments ×3 + chat moments + hype + fighters talking (last hour); ≥ 12 is HOT; the hottest subject is featured at the top.- **Ladders** — Trash Level (XP) and **yappers** (chatters with the most moments in 7 days).@@@ -53,9 +54,9 @@ Every 15 s, for every roster stream that is live **and** has a transcript line i## Pages--`/arena` — pulse (what the site is on about + live counters) · on the mic now · open + settled beefs · the board (featured subject with lore + latest moments, then a feed of subjects, lore archive) · Trash Level ladder · yappers · power ladder (rows expand) · rules.++`/arena` — pulse · on the mic now · open + settled beefs · the board (subject cards: headline, subject + tagline, thread chips with counts, latest line, mention counts, fighters; first card featured; cooled-off list) · Trash Level ladder · yappers · power ladder (rows expand; compact on phones) · rules.`/arena/beef/<id>` — tale of the tape, tug-of-war, live clock, ringside feed (every judged hit with ▶ to the VOD second and 🔊), receipts from earlier beefs, hype/side buttons.--`/arena/topic/<id>` — headline, subject, lore, keywords, the full moment timeline (chat + on-mic with ▶ to the VOD second), fighters on it with their best line, loudest chatters, join/leave.++`/arena/topic/<id>` — headline, subject + tagline, counts, story so far, **thread tabs** filtering the moment timeline (chat + on-mic with ▶ to the VOD second), fighters heard on it with their best line, loudest chatters with yap levels.`/arena/<username>` — stats drill-down, voice + quotes, Trash Level card, rivalries with receipts, beef history.## Chat commands (`server/arena/arena-chat.js`)@@@ -14510,3 +14510,49 @@ a.arena-tag:hover { color: var(--accent-light); }.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); }++++/* ── Arena board v3: subject cards + threads ── */++.arena-subject-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }++.arena-subject { display: flex; flex-direction: column; gap: 8px; padding: 14px 16px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); min-width: 0; }++.arena-subject.is-hot { border-color: rgba(249, 115, 22, 0.5); }++.arena-subject.is-featured { grid-column: 1 / -1; background: linear-gradient(135deg, rgba(249, 115, 22, 0.1), var(--bg-card) 50%); }++.arena-subject.arena-topic-bounty { border-left: 4px solid #f5c542; }++.arena-subject-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 0.74rem; color: var(--text-muted); }++.arena-subject-headline { margin: 0; font-size: 1.15rem; line-height: 1.25; font-weight: 900; color: var(--text-primary); }++.arena-subject.is-featured .arena-subject-headline { font-size: 1.45rem; }++.arena-topic-link:hover .arena-subject-headline { color: #fb923c; }++.arena-subject-sub { font-size: 0.86rem; color: var(--text-secondary); }++.arena-subject-sub b { color: var(--text-primary); }++.arena-threads { display: flex; gap: 6px; flex-wrap: wrap; }++.arena-thread { display: inline-flex; align-items: center; gap: 4px; padding: 3px 9px; border-radius: 999px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.76rem; font-weight: 600; }++.arena-thread i { font-size: 0.65rem; color: #fb923c; }++.arena-thread b { color: var(--text-primary); }++.arena-thread.is-more { opacity: 0.6; }++a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srgb, var(--bg-tertiary) 70%, #fb923c 30%); }++.arena-subject-last { display: block; font-size: 0.84rem; color: var(--text-secondary); quotes: "" ""; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }++.arena-subject-last i { font-size: 0.7rem; margin-right: 3px; color: var(--text-muted); }++.arena-subject-foot { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: auto; }++.arena-subject-open { margin-left: auto; font-size: 0.8rem; color: var(--accent-light); text-decoration: none; font-weight: 700; }++.arena-strip { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 8px 0; }++.arena-strip-people { display: inline-flex; align-items: center; gap: 8px; }++.arena-strip-people small { color: var(--text-muted); }++.arena-story { padding: 12px 14px; border-radius: 14px; background: var(--bg-tertiary); border-left: 3px solid #fb923c; }++.arena-story h3 { margin: 0 0 6px; font-size: 0.9rem; display: flex; gap: 8px; align-items: center; }++.arena-story h3 small { color: var(--text-muted); font-weight: 400; font-size: 0.74rem; }++.arena-tabs { display: flex; gap: 6px; flex-wrap: wrap; }++.arena-tab { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text-secondary); font: inherit; font-size: 0.82rem; font-weight: 600; cursor: pointer; }++.arena-tab b { color: var(--text-primary); margin-left: 2px; }++.arena-tab.is-on { border-color: #fb923c; color: var(--text-primary); background: rgba(249, 115, 22, 0.12); }++.arena-tab-hint { margin: 0; }++.arena-row-lore.is-clamp { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }++.arena-row-taunts em { display: flex; align-items: center; gap: 6px; }++@media (max-width: 900px) { .arena-subject-grid { grid-template-columns: 1fr; } }++@media (max-width: 600px) {++ .arena-subject { padding: 12px; } .arena-subject.is-featured .arena-subject-headline { font-size: 1.2rem; }++ .arena-row-expand { grid-template-columns: 1fr; }++ .arena-row-expand .arena-radar { max-width: 170px; margin: 0 auto; }++ .arena-quips-custom, .arena-quips { grid-template-columns: 1fr 1fr; gap: 4px 10px; }++ .arena-quip { font-size: 0.78rem; }++ .arena-row-expand-actions .btn { padding: 6px 10px; font-size: 0.82rem; }++ .arena-topic-headline-big { font-size: 1.2rem; }++}@@@ -62,7 +62,7 @@</script><!-- Stylesheets -->-- <link rel="stylesheet" href="/css/style.css?v=149">++ <link rel="stylesheet" href="/css/style.css?v=150"><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=10" defer></script>++<script src="/js/arena.js?v=11" 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>@@@ -229,7 +229,7 @@ async function _aRenderHome(root) {<h3>How the Arena works</h3><ul><li><b>Beefs.</b> Say another fighter's name on stream while talking shit → a beef opens and they go on the clock (15 min if they're live, 24 h if not; the clock resets every time a side answers). Silence is a forfeit. Best mouth after 24 h wins. Beat someone ranked 4+ spots above you and it's an <b>upset</b>.</li>-- <li><b>The board.</b> Subjects show up on their own from what chat and streamers are actually saying (the AI reads global chat + live transcripts every few minutes). Every chat line or on-mic line that mentions a subject becomes a <b>moment</b> on it, and its <b>lore</b> gets rewritten as it escalates — who started it, who said what, who's on which end. Streamers "talk on" a subject → judged moments → XP → Trash Level. Bounties come from chat (<code>!bounty</code>).</li>++ <li><b>The board.</b> Subjects show up on their own from what chat and streamers are saying (the AI reads global chat + live mics every few minutes). Each subject has <b>threads</b> — the angles people are actually arguing. Every chat or on-mic line that touches one becomes a <b>moment</b>; the story gets rewritten as it escalates. Nobody joins anything: the ears detect who's on what. Streamers' judged lines → XP → Trash Level; chatters' lines → yap XP. Bounties come from chat (<code>!bounty</code>).</li><li><b>Start one.</b> Signed-in users can put up one subject per 24 h (per person and per connection); the AI rewrites it into a proper headline. No voting anywhere — you don't pick sides, you pile on.</li><li><b>Chat.</b> <code>!hype</code> boosts your streamer · <code>!topic <text></code> · <code>!bounty <user></code> · <code>!beef</code> · <code>!board</code> · <code>!arena</code>.</li><li><b>Language.</b> Nothing gets censored for being offensive. The only lines that don't count: real threats, anything sexual about minors, and doxxing.</li>@@@ -376,11 +376,6 @@ function _aBindHome(root) {try { const r = await api(`/arena/board/topics/${btn.dataset.topic}/hype`, { method: 'POST', body: { user_id: Number(btn.dataset.user) } }); _aToast(r.added ? `🔥 Hyped (${r.hypers} hyping)` : 'Already hyped', r.added ? 'success' : 'info'); btn.classList.add('is-hyped'); }catch (err) { _aToast(err?.message || 'Failed', 'error'); }});-- root.querySelectorAll('.arena-topic-join').forEach(btn => btn.onclick = async (e) => {-- e.preventDefault();-- try { await api(`/arena/board/topics/${btn.dataset.topic}/join`, { method: 'POST' }); _aToast('🎤 You\'re on it. Start talking on stream — the ears are listening.', 'success'); navigate(`/arena/topic/${btn.dataset.topic}`); }-- catch (err) { _aToast(err?.message || 'Failed', 'error'); }-- });_aBindSpeak(root);}@@@ -415,67 +410,39 @@ function _aMentionChips(t) {${t.talking_now.length ? `<span class="arena-chip-stat is-live" title="fighters on it right now"><span class="arena-live-dot arena-live-dot-sm"></span> ${t.talking_now.length} on mic</span>` : ''}</span>`;}--function _aJoinBtn(t, me) {-- const onIt = me && t.talking_now.some(f => f.user.id === me.id);-- const onRoster = me && _arenaRoster?.fighters?.some(f => f.user.id === me.id);-- if (!onRoster || t.kind === 'bounty' || t.status !== 'open') return '';-- return onIt ? '<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-microphone"></i> you\'re on it</span>' : `<button class="btn btn-primary btn-sm arena-topic-join" data-topic="${t.id}"><i class="fa-solid fa-microphone"></i> Talk on this</button>`;--}--function _aFeatured(t, hot, me) {-- const k = ARENA_KIND[t.kind] || ARENA_KIND.topic;-- return `<div class="arena-featured arena-topic-${t.kind}" data-topic="${t.id}">-- <div class="arena-featured-main">-- <div class="arena-topic-head">-- <span class="arena-kind"><i class="fa-solid ${k.icon}"></i> ${t.hot ? 'Hottest right now' : 'Top of the board'}</span>-- ${_aHeat(t, hot)}-- <span class="arena-topic-by">${t.created_by === 'community' ? `<i class="fa-solid fa-satellite-dish"></i> ${_aEsc(t.source_note || 'the community')}` : `<i class="fa-solid fa-user"></i> ${_aEsc(t.creator_name || t.created_by)}`} · ${_aEsc(_aAgo(t.created_at))}</span>-- </div>-- ${_aA(_aTopicLink(t), `<h3 class="arena-featured-headline">${_aEsc(t.headline || t.text)}</h3>`, 'arena-topic-link')}-- <div class="arena-featured-subject">${_aTopicTitle(t)}${t.tagline ? ` <span class="arena-tagline">— ${_aEsc(t.tagline)}</span>` : ''}</div>-- <p class="arena-lore-p">${_aEsc(t.lore || t.hint || 'No lore yet — nobody has said anything worth writing down. Fix that.')}</p>-- <div class="arena-topic-foot">-- ${_aMentionChips(t)}-- ${t.fighters.length ? _aAvatarStack(t.fighters) : ''}-- <span class="arena-topic-actions">${_aJoinBtn(t, me)} ${_aA(_aTopicLink(t), 'Full lore <i class="fa-solid fa-arrow-right"></i>', 'btn btn-ghost btn-sm')}</span>-- </div>-- </div>-- <div class="arena-featured-side">-- <h4><i class="fa-solid fa-bolt"></i> Latest</h4>-- ${t.best_moment ? _aMomentLine(t.best_moment) : ''}-- ${t.last_moment && t.last_moment.id !== t.best_moment?.id ? _aMomentLine(t.last_moment) : ''}-- ${!t.last_moment ? '<p class="arena-note">Nothing yet. Type its name in chat or say it on stream.</p>' : ''}-- ${t.keywords?.length ? `<div class="arena-keywords">${t.keywords.slice(0, 6).map(k => `<span>${_aEsc(k)}</span>`).join('')}</div>` : ''}-- </div>-- </div>`;++function _aThreadChips(t, { max = 4, link = true } = {}) {++ const th = (t.threads || []).slice(0, max);++ if (!th.length) return '';++ return `<div class="arena-threads">${th.map(x => link ? _aA(`${_aTopicLink(t)}#thread-${x.id}`, `<span class="arena-thread"><i class="fa-solid fa-angle-right"></i> ${_aEsc(x.name)}${x.moments ? ` <b>${x.moments}</b>` : ''}</span>`) : `<span class="arena-thread"><i class="fa-solid fa-angle-right"></i> ${_aEsc(x.name)}${x.moments ? ` <b>${x.moments}</b>` : ''}</span>`).join('')}${(t.threads || []).length > max ? `<span class="arena-thread is-more">+${t.threads.length - max}</span>` : ''}</div>`;}--function _aTopicRow(t, hot, me) {++function _aSubjectCard(t, hot, { featured = false } = {}) {const k = ARENA_KIND[t.kind] || ARENA_KIND.topic;-- return `<div class="arena-topic-row arena-topic-${t.kind} ${t.hot ? 'is-hot' : ''}" data-topic="${t.id}">-- <div class="arena-topic-row-heat">${_aHeat(t, hot)}</div>-- <div class="arena-topic-row-main">-- ${_aA(_aTopicLink(t), `<strong>${_aEsc(t.headline || t.text)}</strong>`, 'arena-topic-link')}-- <div class="arena-topic-row-sub"><span class="arena-kind"><i class="fa-solid ${k.icon}"></i> ${_aTopicTitle(t)}</span>${t.tagline ? ` · ${_aEsc(t.tagline)}` : ''}</div>-- ${t.last_moment ? `<q class="arena-topic-row-last"><i class="fa-solid ${t.last_moment.kind === 'chat' ? 'fa-keyboard' : 'fa-microphone'}"></i> ${_aEsc(t.last_moment.username || 'anon')}: ${_aEsc(t.last_moment.text)}</q>` : `<span class="arena-note">${_aEsc(t.hint || 'nothing said yet')}</span>`}++ return `<div class="arena-subject ${featured ? 'is-featured' : ''} ${t.hot ? 'is-hot' : ''} arena-topic-${t.kind}" data-topic="${t.id}">++ <div class="arena-subject-head">++ ${_aHeat(t, hot)}++ <span class="arena-topic-by">${t.kind === 'bounty' ? '<i class="fa-solid fa-sack-dollar"></i> bounty · ' : ''}${t.created_by === 'community' ? `<i class="fa-solid fa-satellite-dish"></i> ${_aEsc(t.source_note || 'the site')}` : `<i class="fa-solid fa-user"></i> ${_aEsc(t.creator_name || t.created_by)}`} · ${_aEsc(_aAgo(t.last_mention_at || t.created_at))}</span></div>-- <div class="arena-topic-row-side">++ ${_aA(_aTopicLink(t), `<h3 class="arena-subject-headline">${_aEsc(t.headline || t.text)}</h3>`, 'arena-topic-link')}++ <div class="arena-subject-sub"><b>${_aTopicTitle(t)}</b>${t.tagline ? ` — ${_aEsc(t.tagline)}` : ''}</div>++ ${_aThreadChips(t)}++ ${t.last_moment ? `<q class="arena-subject-last"><i class="fa-solid ${t.last_moment.kind === 'chat' ? 'fa-keyboard' : 'fa-microphone'}"></i> <b>${_aEsc(t.last_moment.username || 'anon')}</b>: ${_aEsc(t.last_moment.text)}</q>` : `<span class="arena-note">${_aEsc(t.hint || 'nobody has said anything yet')}</span>`}++ <div class="arena-subject-foot">${_aMentionChips(t)}${t.fighters.length ? _aAvatarStack(t.fighters, 4) : ''}-- ${_aJoinBtn(t, me)}++ ${_aA(_aTopicLink(t), 'open <i class="fa-solid fa-arrow-right"></i>', 'arena-subject-open')}</div></div>`;}function _aRenderBoard(bd) {const me = _aMe();const open = bd.open || [], archive = bd.archive || [];-- const [top, ...rest] = open;return `<div class="arena-board-head">-- <h2><i class="fa-solid fa-comments"></i> The board <small>${open.length} subject${open.length === 1 ? '' : 's'} the site is on about · sorted by heat</small></h2>++ <h2><i class="fa-solid fa-comments"></i> The board <small>what the site is on about · ${open.length} subject${open.length === 1 ? '' : 's'} · say one on mic or type it in chat and it counts</small></h2>${me ? `<button class="btn btn-ghost btn-sm" id="arena-board-new"><i class="fa-solid fa-plus"></i> Start a subject</button>` : '<span class="arena-note">sign in to start one (or <code>!topic</code> in any chat)</span>'}</div>-- ${top ? _aFeatured(top, bd.hot_threshold || 12, me) : '<p class="arena-note">The board is empty — the AI is listening to chat and live mics; the moment people start on about something it shows up here.</p>'}-- ${rest.length ? `<div class="arena-topic-feed">${rest.map(t => _aTopicRow(t, bd.hot_threshold || 12, me)).join('')}</div>` : ''}-- ${archive.length ? `<details class="arena-done"><summary>Lore archive <small>${archive.length} cooled off</small></summary><div class="arena-topic-feed is-archive">${archive.map(t => `<div class="arena-topic-row is-archived">-- <div class="arena-topic-row-main">${_aA(_aTopicLink(t), `<strong>${_aEsc(t.headline || t.text)}</strong>`, 'arena-topic-link')}<div class="arena-topic-row-sub">${_aTopicTitle(t)} · ${t.mentions.total} mentions · ${_aEsc(_aAgo(t.last_activity_at))}${t.resolved?.headline ? ` · ${_aEsc(t.resolved.headline)}` : ''}</div>${t.lore ? `<p class="arena-lore-p is-clamp">${_aEsc(t.lore)}</p>` : ''}</div>++ ${open.length ? `<div class="arena-subject-grid">${open.map((t, i) => _aSubjectCard(t, bd.hot_threshold || 12, { featured: i === 0 })).join('')}</div>` : '<p class="arena-note">Empty for now — the AI reads chat and live mics every few minutes; the moment people are on about something, it shows up here.</p>'}++ ${archive.length ? `<details class="arena-done"><summary>Cooled off <small>${archive.length}</small></summary><div class="arena-topic-feed is-archive">${archive.map(t => `<div class="arena-topic-row is-archived">++ <div class="arena-topic-row-main">${_aA(_aTopicLink(t), `<strong>${_aEsc(t.headline || t.text)}</strong>`, 'arena-topic-link')}<div class="arena-topic-row-sub">${_aTopicTitle(t)} · ${t.mentions.total} mentions · ${_aEsc(_aAgo(t.last_activity_at))}${t.resolved?.headline ? ` · ${_aEsc(t.resolved.headline)}` : ''}</div></div></div>`).join('')}</div></details>` : ''}`;}function _aTopicComposer() {@@@ -602,8 +569,8 @@ function _aRenderList(fighters) {<div class="arena-row-expand"><div>${_aCustomRadar(f, f.user.profile_color || '#8b5cf6', 200)}<div class="arena-mini-record">${(f.persona.custom_stats || []).length ? 'their own stats, from their profile · ' : ''}${f.category ? _aEsc(f.category) + ' · ' : ''}last live ${_aEsc(f.last_live_at ? _aDate(f.last_live_at) : '—')}</div></div><div>-- <p class="arena-row-lore">${_aEsc(f.persona.lore)}</p>-- ${(f.persona.taunts || []).length ? `<div class="arena-row-taunts">${f.persona.taunts.map(t => `<em>“${_aEsc(t)}”</em>`).join('')}${f.persona.typing_style ? `<small><i class="fa-solid fa-keyboard"></i> ${_aEsc(f.persona.typing_style)}</small>` : ''}</div>` : ''}++ <p class="arena-row-lore is-clamp">${_aEsc(f.persona.lore)}</p>++ ${(f.persona.taunts || []).length ? `<div class="arena-row-taunts">${f.persona.taunts.slice(0, 2).map(t => `<em>“${_aEsc(t)}” ${_aSpeakBtn(t, '', f.user.username)}</em>`).join('')}${f.persona.typing_style ? `<small><i class="fa-solid fa-keyboard"></i> ${_aEsc(f.persona.typing_style)}</small>` : ''}</div>` : ''}${(f.persona.custom_stats || []).length ? _aCustomQuips(f) : `<div class="arena-quips">${ARENA_STATS.map(k => `<div class="arena-quip"><b>${_aEsc(ARENA_STAT_LABEL[k])} ${f.ratings[k]}</b><span>${_aEsc((f.persona.stat_quips || {})[k] || '')}</span></div>`).join('')}</div>`}${f.persona.signature_move ? `<div class="arena-mini-record"><b>Signature:</b> ${_aEsc(f.persona.signature_move.name)} — ${_aEsc(f.persona.signature_move.description)}</div>` : ''}</div>@@@ -692,62 +659,51 @@ async function _aRenderBeef(root, id) {// ── Topic detail ─────────────────────────────────────────────async function _aRenderTopic(root, id) {-- root.innerHTML = _aSpinner('Opening the lore…');++ root.innerHTML = _aSpinner('Opening the subject…');let t = await api(`/arena/board/topics/${id}`);const me = _aMe();++ let thread = (location.hash.match(/#thread-(\d+)/) || [])[1] ? Number((location.hash.match(/#thread-(\d+)/) || [])[1]) : 0;const draw = () => {const k = ARENA_KIND[t.kind] || ARENA_KIND.topic;-- const onIt = me && t.talking_now.some(f => f.user.id === me.id);-- const onRoster = me && (_arenaRoster?.fighters?.some(f => f.user.id === me.id) ?? true);-- const moments = t.moments || [];++ const moments = (t.moments || []).filter(m => !thread || m.thread_id === thread);++ const cur = (t.threads || []).find(x => x.id === thread);root.innerHTML = `<div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')}</div><div class="arena-topic-page arena-topic-${t.kind} ${t.hot ? 'is-hot' : ''} ${t.status !== 'open' ? 'is-archived' : ''}"><div class="arena-topic-head"><span class="arena-kind"><i class="fa-solid ${k.icon}"></i> ${k.label}${t.status !== 'open' ? ` · ${_aEsc(t.status)}` : ''}</span>${t.status === 'open' ? _aHeat(t, 12) : ''}-- <span class="arena-topic-by">${t.created_by === 'community' ? `<i class="fa-solid fa-satellite-dish"></i> surfaced from ${_aEsc(t.source_note || 'the community')}` : `started by ${_aEsc(t.creator_name || t.created_by)}`} · ${_aEsc(_aAgo(t.created_at))}${t.expires_at && t.status === 'open' ? ` · <span class="arena-expires" data-until="${_aEsc(t.expires_at)}">${_aEsc(_aClock((Date.parse(t.expires_at) - Date.now()) / 1000))} left</span>` : ''}</span>++ <span class="arena-topic-by">${t.created_by === 'community' ? `<i class="fa-solid fa-satellite-dish"></i> ${_aEsc(t.source_note || 'the site')}` : `started by ${_aEsc(t.creator_name || t.created_by)}`} · ${_aEsc(_aAgo(t.created_at))}${t.expires_at && t.status === 'open' ? ` · <span class="arena-expires" data-until="${_aEsc(t.expires_at)}">${_aEsc(_aClock((Date.parse(t.expires_at) - Date.now()) / 1000))} left</span>` : ''}</span></div><h1 class="arena-topic-headline-big">${_aEsc(t.headline || t.text)} ${_aSpeakBtn(t.headline || t.text, '')}</h1>-- <div class="arena-featured-subject">${_aTopicTitle(t)}${t.tagline ? ` <span class="arena-tagline">— ${_aEsc(t.tagline)}</span>` : ''}</div>++ <div class="arena-subject-sub"><b>${_aTopicTitle(t)}</b>${t.tagline ? ` — ${_aEsc(t.tagline)}` : ''}</div>${t.submitted_text && t.submitted_text.toLowerCase() !== t.text.toLowerCase() ? `<p class="arena-note">as typed by ${_aEsc(t.creator_name || 'someone')}: “${_aEsc(t.submitted_text)}”</p>` : ''}${t.resolved?.headline ? `<div class="arena-topic-result"><i class="fa-solid fa-flag-checkered"></i> ${_aEsc(t.resolved.headline)}</div>` : ''}++ ${t.kind === 'bounty' && t.target ? `<div class="arena-bounty-target">${_aBriefChip(t.target, ' · the mark')}<span class="arena-note">Any fighter who lands judged shit talk on ${_aEsc(t.target.fighter_name)} while this is open gets <b>double XP</b>.</span></div>` : ''}++ <div class="arena-strip">++ ${_aMentionChips(t)}++ ${t.fighters.length ? `<span class="arena-strip-people">${_aAvatarStack(t.fighters, 6)} <small>${t.fighters.length} fighter${t.fighters.length === 1 ? '' : 's'} heard on it</small></span>` : '<small class="arena-note">no fighter has said it on mic yet — the ears auto-detect it the moment one does</small>'}++ </div>++ ${t.lore ? `<section class="arena-story"><h3><i class="fa-solid fa-book-skull"></i> Story so far <small>${t.lore_updated_at ? _aEsc(_aAgo(t.lore_updated_at)) : ''}</small></h3><p class="arena-lore-p">${_aEsc(t.lore)}</p></section>` : ''}++ <div class="arena-tabs">++ <button class="arena-tab ${!thread ? 'is-on' : ''}" data-thread="0">All <b>${(t.moments || []).length}</b></button>++ ${(t.threads || []).map(x => `<button class="arena-tab ${thread === x.id ? 'is-on' : ''}" data-thread="${x.id}" id="thread-${x.id}" title="${_aEsc(x.hint || '')}">${_aEsc(x.name)} <b>${x.moments}</b></button>`).join('')}++ </div>++ ${cur && cur.hint ? `<p class="arena-note arena-tab-hint"><i class="fa-solid fa-angle-right"></i> ${_aEsc(cur.hint)}</p>` : ''}<div class="arena-topic-cols">-- <div class="arena-topic-main">-- <section class="arena-lore-box">-- <h3><i class="fa-solid fa-book-skull"></i> Lore <small>${t.lore_updated_at ? `rewritten ${_aEsc(_aAgo(t.lore_updated_at))}` : 'not written yet'}</small></h3>-- <p class="arena-lore-p arena-lore-big">${_aEsc(t.lore || t.hint || 'Nothing on record. Say something about it in chat or on mic and the lore writes itself.')}</p>-- ${t.keywords?.length ? `<div class="arena-keywords"><span class="arena-note">the words that count:</span>${t.keywords.map(k => `<span>${_aEsc(k)}</span>`).join('')}</div>` : ''}-- </section>-- ${t.kind === 'bounty' && t.target ? `<div class="arena-bounty-target">${_aBriefChip(t.target, ' · the mark')}<span class="arena-note">Any fighter who lands judged shit talk on ${_aEsc(t.target.fighter_name)} while this is open gets <b>double XP</b>. The mark can answer back on their own stream.</span></div>` : ''}-- <div class="arena-topic-cta">-- ${onRoster && t.status === 'open' && t.kind !== 'bounty' ? (onIt ? `<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-microphone"></i> You're on this — talk on stream, the ears are listening</span> <button class="btn btn-ghost btn-sm" id="arena-topic-leave">Leave</button> ${_aA(_aConsoleLink(me), '<i class="fa-solid fa-ear-listen"></i> my ears', 'btn btn-ghost btn-sm')}` : `<button class="btn btn-primary" id="arena-topic-join"><i class="fa-solid fa-microphone"></i> Talk on this</button><span class="arena-note">Or just say it on mic — the ears pick it up either way. Every judged chunk = a moment + XP.</span>`) : ''}-- ${!me ? '<span class="arena-note">Sign in to talk on this (streamers) or hype whoever\'s on it. Anyone can pile on from chat.</span>' : ''}-- </div>-- <section class="arena-moments">-- <h3><i class="fa-solid fa-stream"></i> Moments <small>${moments.length} · chat and on-mic, newest first</small></h3>-- ${moments.length ? `<div class="arena-moment-list">${moments.map(m => _aMomentLine(m)).join('')}</div>` : '<p class="arena-note">Nothing yet.</p>'}-- </section>-- </div>++ <section class="arena-moments">++ <h3><i class="fa-solid fa-stream"></i> ${cur ? _aEsc(cur.name) : 'Moments'} <small>${moments.length} · chat and on-mic, newest first</small></h3>++ ${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">-- <section>-- <h3><i class="fa-solid fa-microphone-lines"></i> Fighters on it <small>${t.talking_now.length} live</small></h3>-- ${t.fighters.length ? 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('') : '<p class="arena-note">No streamer has touched it yet.</p>'}-- </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>${_aEsc(c.username)}</strong></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 lines</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>` : ''}++ ${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>` : ''}++ <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></aside></div></div>`;-- document.getElementById('arena-topic-join')?.addEventListener('click', async () => { try { await api(`/arena/board/topics/${t.id}/join`, { method: 'POST' }); t = await api(`/arena/board/topics/${id}`); _aToast('🎤 You\'re on it. Go talk.', 'success'); draw(); } catch (err) { _aToast(err?.message || 'Failed', 'error'); } });-- document.getElementById('arena-topic-leave')?.addEventListener('click', async () => { try { await api(`/arena/board/topics/${t.id}/leave`, { method: 'POST' }); t = await api(`/arena/board/topics/${id}`); draw(); } catch (err) { _aToast(err?.message || 'Failed', 'error'); } });++ root.querySelectorAll('.arena-tab').forEach(b => b.onclick = () => { thread = Number(b.dataset.thread) || 0; draw(); });_aBindHome(root);};draw();@@@ -796,7 +752,7 @@ async function _aRenderConsole(root, username) {${c.active_topic ? `${_aA(_aTopicLink(c.active_topic), `<b>${_aEsc(c.active_topic.headline || c.active_topic.text)}</b>`)}<p class="arena-lore-p is-clamp">${_aEsc(c.active_topic.lore || c.active_topic.tagline || c.active_topic.hint || '')}</p>${c.active_topic.keywords?.length ? `<div class="arena-keywords">${c.active_topic.keywords.slice(0, 6).map(k => `<span>${_aEsc(k)}</span>`).join('')}</div>` : ''}-- <small class="arena-note">${(c.active_topic.fighters || []).find(f => f.user.id === f0(c))?.moments || 0} of your on-mic moments on it</small>` : `<p class="arena-note">${mine ? 'No active subject. Pick one on the board — or just say one on mic, the ears auto-join you.' : 'Not on a board subject.'}</p>${mine ? _aA('/arena', 'Open the board', 'btn btn-ghost btn-sm') : ''}`}++ <small class="arena-note">${(c.active_topic.fighters || []).find(f => f.user.id === f0(c))?.moments || 0} of your on-mic moments on it</small>` : `<p class="arena-note">${mine ? 'Nothing detected yet — say any board subject on mic and the ears file it here.' : 'Not heard on a board subject yet.'}</p>${mine ? _aA('/arena', 'See the board', 'btn btn-ghost btn-sm') : ''}`}</section><section><h3><i class="fa-solid fa-fire-flame-curved"></i> Open beefs</h3>@@@ -874,7 +830,7 @@ async function _aRenderFighter(root, username) {${_aPortrait(f, 'lg')}${f.image_pending ? '<div class="arena-portrait-pending"><i class="fa-solid fa-wand-magic-sparkles fa-fade"></i> painting portrait…</div>' : ''}<div class="arena-profile-rank">#${f.rank} <small>of ${f.roster_size}</small></div>-- ${f.active_topic ? `<div class="arena-mini-record"><i class="fa-solid fa-comment-dots"></i> Talking on: ${_aA(_aTopicLink(f.active_topic), _aEsc(f.active_topic.text))}</div>` : ''}++ ${f.active_topic ? `<div class="arena-mini-record"><i class="fa-solid fa-comment-dots"></i> Last heard on: ${_aA(_aTopicLink(f.active_topic), _aEsc(f.active_topic.text))}</div>` : ''}</div><div class="arena-profile-main"><div class="arena-profile-name">@@@ -98,7 +98,7 @@ function handle(chat, ws, client, cmd, parts) {return;}const t = board.activeTopicFor(streamer.id);-- if (!t) return reply(`${streamer.display_name || streamer.username} has no beef open and isn't on a board topic. Start one: !topic <text> · ${base()}/arena`);++ if (!t) return reply(`${streamer.display_name || streamer.username} has no beef open and hasn't been heard on a board subject yet — the ears auto-detect it when they say one. ${base()}/arena`);const r = board.hypeTopic(t.id, streamer.id, key);if (!r.added) return reply(`You already hyped them on “${t.text}”. ${r.hypers} hyping.`);reply(`🔥 Hyped ${streamer.display_name || streamer.username} on “${t.text}” — ${r.hypers} hyping → ${base()}/arena/topic/${t.id}`);@@@ -96,7 +96,19 @@ 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 */ } }++ 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 */ } }++ // 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,++ topic_id INTEGER NOT NULL,++ name TEXT NOT NULL,++ hint TEXT,++ keywords_json TEXT,++ moments INTEGER DEFAULT 0,++ last_at DATETIME,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP++ )`);++ db.run('CREATE INDEX IF NOT EXISTS idx_arena_threads_topic ON arena_topic_threads (topic_id)');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,@@@ -224,7 +236,7 @@ function matchTopics(text) {// ── Topics ───────────────────────────────────────────────────--function createTopic({ text, hint = null, createdBy, creatorUserId = null, creatorName = null, creatorIp = null, kind = 'topic', targetUserId = null, headline = null, sourceNote = null, keywords = null, tagline = null, origin = null, submittedText = null }) {++function createTopic({ text, hint = null, createdBy, creatorUserId = null, creatorName = null, creatorIp = null, kind = 'topic', targetUserId = null, headline = null, sourceNote = null, keywords = null, tagline = null, origin = null, submittedText = null, threads = null }) {ensureTables();const clean = cleanTopicText(text);kind = ['topic', 'bounty'].includes(kind) ? kind : 'topic';@@@ -232,7 +244,9 @@ function createTopic({ text, hint = null, createdBy, creatorUserId = null, creatif (kind === 'bounty' && db.get(`SELECT id FROM arena_topics WHERE status = 'open' AND kind = 'bounty' AND target_user_id = ?`, [targetUserId])) throw new Error('There is already a bounty on them');const dup = db.get(`SELECT id FROM arena_topics WHERE status = 'open' AND LOWER(text) = LOWER(?)`, [clean]);if (dup) throw new Error('That topic is already on the board');-- let kws = normalizeKeywords(keywords);++ const threadList = (Array.isArray(threads) ? threads : []).map(t => ({ name: clip(t?.name, 40), hint: t?.hint ? clip(t.hint, 90) : null, keywords: normalizeKeywords(t?.keywords || []) })).filter(t => t.name).slice(0, 6);++ for (const t of threadList) if (!t.keywords.length) t.keywords = keywordsFromText(t.name);++ let kws = normalizeKeywords([...(Array.isArray(keywords) ? keywords : []), ...threadList.flatMap(t => t.keywords)]);if (!kws.length) kws = keywordsFromText(clean);if (kind === 'bounty' && targetUserId) { const u = db.getUserById(targetUserId); if (u) { const nm = require('./names'); kws = normalizeKeywords([...kws, u.username, u.display_name, ...nm.variants(u.username), ...nm.variants(u.display_name)]); } }if (kind === 'topic' && kws.length >= 2) {@@@ -245,8 +259,9 @@ function createTopic({ text, hint = null, createdBy, creatorUserId = null, creatVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 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');++ 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(', ')}]`);++ console.log(`[Arena] ${kind} #${row.id} by ${createdBy}${creatorName ? ' ' + creatorName : ''}: "${clean}" [${kws.join(', ')}]${threadList.length ? ` threads: ${threadList.map(t => t.name).join(' | ')}` : ''}`);return row;}@@@ -265,14 +280,15 @@ function assertCanSubmit(userId, ip) {}const REFINE_SCHEMA = { name: 'arena_refine_topic', schema: { type: 'object', additionalProperties: false, required: ['subject', 'headline', 'tagline', 'hint', 'keywords', 'reject'], properties: {-- subject: { type: 'string', description: 'the subject as a short noun phrase ≤ 70 chars, punchy, dumb-funny' },++ subject: { type: 'string', description: 'the umbrella subject, 1–4 words, punchy, dumb-funny' },headline: { type: 'string', description: 'tabloid headline ≤ 90 chars, inflammatory and stupid in a good way' },tagline: { type: 'string', description: '≤ 100 chars, who submitted it and why everyone should care' },hint: { type: 'string', description: '≤ 90 chars: how to pile on / bait for streamers' },keywords: { type: 'array', minItems: 2, maxItems: 8, items: { type: 'string' }, description: 'lowercase words/slang people would actually type or say about it' },++ threads: { type: 'array', minItems: 2, maxItems: 4, items: { type: 'object', additionalProperties: false, required: ['name', 'keywords', 'hint'], properties: { name: { type: 'string', description: '2–5 words, a sub-angle inside the subject' }, keywords: { type: 'array', minItems: 1, maxItems: 4, items: { type: 'string' } }, hint: { type: 'string', description: '≤ 70 chars' } } } },reject: { type: 'boolean', description: 'true ONLY if it is a direct threat of violence, sexual content about a minor, or doxxing' },} } };--const REFINE_SYSTEM = `A user typed a topic for the shit-talking board of a live-streaming site. Rewrite it into a proper board SUBJECT that looks good and baits people: a short noun-phrase subject, a tabloid headline, a tagline crediting the submitter, a hint on how to pile on, and the keywords people would actually type or say about it (slang, variants, misspellings). Make it dumber, funnier and more provocative than what they typed; keep their actual meaning. Slurs, profanity and taboo topics are allowed and never a reason to reject — this platform does not censor language. Reject ONLY direct threats of violence, sexual content about minors, or doxxing. Output only the JSON.`;++const REFINE_SYSTEM = `A user typed a topic for the shit-talking board of a live-streaming site. Rewrite it into a proper board SUBJECT that looks good and baits people: a short, VAGUE umbrella subject (1–4 words), a tabloid headline, a tagline crediting the submitter, a hint on how to pile on, the keywords people would actually type or say about it (slang, variants, misspellings), and 2–4 THREADS — the specific angles inside it people will argue. Make it dumber, funnier and more provocative than what they typed; keep their actual meaning. Slurs, profanity and taboo topics are allowed and never a reason to reject — this platform does not censor language. Reject ONLY direct threats of violence, sexual content about minors, or doxxing. Output only the JSON.`;/** A user-submitted topic → the AI makes it look good (one call). Falls back to the raw text. */async function submitTopic({ text, userId, ip, creatorName, onRoster = false }) {@@@ -286,7 +302,7 @@ async function submitTopic({ text, userId, ip, creatorName, onRoster = false })if (r && r.json) { if (r.json.reject) throw new Error('That crosses the line (threats, minors, doxxing)'); refined = r.json; }} catch (e) { if (/crosses the line/.test(e.message)) throw e; console.warn('[Arena] refine topic:', e.message); }}-- 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, threads: refined?.threads || null, submittedText: raw });backfillMoments([t]);try { require('./chatters').onSubjectStarted(`user:${userId}`, t.id, { display: creatorName }); } catch { /* */ }return t;@@@ -301,8 +317,10 @@ function addMoment(topicId, { kind, source, userId = null, username = null, streif (!body || arena()._isBannedText(body)) return 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]);++ 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]);++ 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]);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]);@@@ -314,6 +332,31 @@ function addMoment(topicId, { kind, source, userId = null, username = null, strereturn m;}++/** Which thread of a subject a line belongs to (first thread whose keywords match), or null. */++let _threadCache = { at: 0, byTopic: new Map() };++function threadsOf(topicId) {++ if (Date.now() - _threadCache.at > 20 * 1000) { _threadCache = { at: Date.now(), byTopic: new Map() }; }++ if (!_threadCache.byTopic.has(topicId)) _threadCache.byTopic.set(topicId, db.all('SELECT * FROM arena_topic_threads WHERE topic_id = ? ORDER BY id ASC', [topicId]).map(t => ({ ...t, res: normalizeKeywords(parseJson(t.keywords_json, [])).map(keywordRegex) })));++ return _threadCache.byTopic.get(topicId);++}++function threadFor(topicId, text) { for (const t of threadsOf(topicId)) if (t.res.some(re => re.test(text))) return t.id; return null; }++function upsertThreads(topicId, threads) {++ const existing = db.all('SELECT id, name, keywords_json FROM arena_topic_threads WHERE topic_id = ?', [topicId]);++ let added = 0;++ for (const t of (Array.isArray(threads) ? threads : []).slice(0, 8)) {++ const name = clip(t?.name, 40); if (!name) continue;++ const kws = normalizeKeywords(t?.keywords || []); if (!kws.length) kws.push(...keywordsFromText(name));++ const same = existing.find(e => e.name.toLowerCase() === name.toLowerCase() || normalizeKeywords(parseJson(e.keywords_json, [])).some(k => kws.includes(k)));++ if (same) { const merged = normalizeKeywords([...(parseJson(same.keywords_json, []) || []), ...kws]); db.run('UPDATE arena_topic_threads SET keywords_json = ?, hint = COALESCE(?, hint) WHERE id = ?', [JSON.stringify(merged), t?.hint ? clip(t.hint, 90) : null, same.id]); continue; }++ if (existing.length + added >= 6) continue;++ db.run('INSERT INTO arena_topic_threads (topic_id, name, hint, keywords_json) VALUES (?, ?, ?, ?)', [topicId, name, t?.hint ? clip(t.hint, 90) : null, JSON.stringify(kws)]); added++;++ }++ if (added) { _threadCache.at = 0; }++ // Re-file moments that have no thread yet.++ for (const m of db.all('SELECT id, text FROM arena_topic_moments WHERE topic_id = ? AND thread_id IS NULL ORDER BY id DESC LIMIT 200', [topicId])) { const tid = threadFor(topicId, m.text); if (tid) { db.run('UPDATE arena_topic_moments SET thread_id = ? WHERE id = ?', [tid, m.id]); db.run('UPDATE arena_topic_threads SET moments = moments + 1 WHERE id = ?', [tid]); } }++ return added;++}++/** Raw on-mic mention (no judge): one per stream per topic per cooldown. */function noteMicMention(topicId, { userId, username, streamId, vodId, sec, text }) {const recent = db.get(`SELECT id FROM arena_topic_moments WHERE topic_id = ? AND stream_id = ? AND kind = 'speech' AND created_at >= datetime('now', ?)`, [topicId, streamId, `-${MENTION_COOLDOWN_SEC} seconds`]);@@@ -436,16 +479,17 @@ function scanChat() {const DISCOVER_SCHEMA = { name: 'arena_discover', schema: { type: 'object', additionalProperties: false, required: ['pulse', 'topics', 'bounties'], properties: {pulse: { type: 'string', description: 'One sentence ≤ 140 chars: what the community is on about right now, dumb hype-caster voice, name names, funny' },topics: { type: 'array', minItems: 0, maxItems: 4, items: { type: 'object', additionalProperties: false, required: ['subject', 'headline', 'tagline', 'keywords', 'source', 'hint'], properties: {-- subject: { type: 'string', description: 'The SUBJECT people are talking about, as a short noun phrase ≤ 70 chars — e.g. "Pakistanis", "the cat enema joke", "Maticus\'s vet bill", "Meshtastic tent streams". Not a question, not a prompt.' },-- headline: { type: 'string', description: 'Tabloid headline ≤ 90 chars, inflammatory, stupid-funny' },-- tagline: { type: 'string', description: '≤ 100 chars: current state of it — who started it, who is on which end' },-- keywords: { type: 'array', minItems: 2, maxItems: 8, items: { type: 'string' }, description: 'lowercase words/short phrases people actually use for this subject (incl. slang and misspellings) — used to match future chat and speech' },++ subject: { type: 'string', description: 'The UMBRELLA subject, 1–4 words, a noun: the thing, person, group, joke or drama — e.g. "Cat enema drama", "Pakistanis", "Goosely", "Tent streams". Vague on purpose; the detail lives in threads.' },++ headline: { type: 'string', description: 'Tabloid headline ≤ 70 chars, inflammatory, stupid-funny' },++ tagline: { type: 'string', description: '≤ 80 chars: where it stands — who started it, who is on which end' },++ keywords: { type: 'array', minItems: 2, maxItems: 6, items: { type: 'string' }, description: 'lowercase words/short phrases people use for the umbrella subject (slang, misspellings)' },++ threads: { type: 'array', minItems: 2, maxItems: 4, items: { type: 'object', additionalProperties: false, required: ['name', 'keywords', 'hint'], properties: { name: { type: 'string', description: 'a sub-angle inside the subject, 2–5 words ("the vet bill", "the bot roasts", "global vs local")' }, keywords: { type: 'array', minItems: 1, maxItems: 4, items: { type: 'string' } }, hint: { type: 'string', description: '≤ 70 chars, how to pile on this thread' } } }, description: 'the 2–4 threads people are actually arguing inside the subject' },source: { type: 'string', description: '≤ 6 words: where it came from ("global chat", "Goosely\'s stream")' },-- hint: { type: 'string', description: '≤ 90 chars, how to pile on' },++ hint: { type: 'string', description: '≤ 70 chars, how to pile on' },} } },bounties: { type: 'array', minItems: 0, maxItems: 1, items: { type: 'object', additionalProperties: false, required: ['target_username', 'headline', 'why'], properties: { target_username: { type: 'string', description: 'exact username from the snapshot the community keeps naming/dunking on' }, headline: { type: 'string' }, why: { type: 'string', description: '≤ 90 chars' } } } },} } };--const DISCOVER_SYSTEM = `You are the community consciousness of a live-streaming site's Arena — part tabloid editor, part fight promoter, part 4chan ragebaiter with a head injury. You get what people ACTUALLY said in the last while: global + stream chat lines and what streamers said on mic, plus the subjects already on the board. Find the SUBJECTS the community is really on about — a group of people, a person, a running joke, an argument, a product, a drama, a meme — the more provocative, taboo, petty or stupid the better; nothing generic, nothing wholesome. Ignore small talk. Do not repeat subjects already on the board (match by meaning). For each: a noun-phrase subject, a tabloid headline, a tagline (who started it, who is where on it), the keywords/slang people use for it, the source, and a hint on how to pile on. If the community keeps naming one specific streamer (dunking, defending, calling out), add ONE bounty on that exact username. Profanity, disrespect, slurs and trolling are welcome and are never a reason to skip a subject — this platform does not censor language. Hard line: no direct threats of violence, nothing sexual about minors, no doxxing. Return empty arrays if nothing real is going on. Output only the JSON.`;++const DISCOVER_SYSTEM = `You are the community consciousness of a live-streaming site's Arena — a regular who has been in this chat for years, not a copywriter. VOICE RULES: write the way the people in the sample lines type — same slang, same casing (if chat is lowercase, you are lowercase), short, blunt, funny. No tabloid adjective soup, no invented compound words (no "roastmare", no "chaos escalates"), no exclamation marks for hype, no "epic"/"wild"/"erupts"/"explodes". A headline should read like something a chatter would actually type as a title ("goosely is losing the cat enema war", "nobody asked about the vet bill"), ≤ 60 chars. Use people's names exactly as they appear. You get what people ACTUALLY said in the last while: global + stream chat lines and what streamers said on mic, plus the subjects already on the board. Find the UMBRELLA SUBJECTS the community is really on about — a group of people, a person, a running joke, an argument, a product, a drama, a meme — the more provocative, taboo, petty or stupid the better; nothing generic, nothing wholesome. Keep subjects VAGUE and short (1–4 words) and put the detail into 2–4 THREADS inside each (the specific angles people are arguing). Ignore small talk. Do not repeat subjects already on the board (match by meaning — if it fits an existing subject, skip it, the lore rewrite adds threads there). For each: the subject, a tabloid headline, a tagline (who started it, who is where on it), the keywords/slang people use for it, the threads, the source, and a hint on how to pile on. If the community keeps naming one specific streamer (dunking, defending, calling out), add ONE bounty on that exact username. Profanity, disrespect, slurs and trolling are welcome and are never a reason to skip a subject — this platform does not censor language. Hard line: no direct threats of violence, nothing sexual about minors, no doxxing. Return empty arrays if nothing real is going on. Output only the JSON.`;function discoverInput() {const out = { at: new Date().toISOString() };@@@ -469,7 +513,7 @@ function heuristicDiscover(input) {}const existing = new Set(openTopicMatchers().flatMap(m => m.keywords));return [...counts.entries()].filter(([w, c]) => c.n >= 4 && c.people.size >= 3 && !existing.has(w)).sort((a, b) => b[1].n - a[1].n).slice(0, 2)-- .map(([w, c]) => ({ subject: w.charAt(0).toUpperCase() + w.slice(1), headline: `Everyone is suddenly on about "${w}"`, tagline: `${c.people.size} people, ${c.n} mentions in ${SCAN_WINDOW_MIN} minutes`, keywords: [w], source: 'chat burst', hint: 'Say it on mic. Make it worse.' }));++ .map(([w, c]) => ({ subject: w.charAt(0).toUpperCase() + w.slice(1), headline: `Everyone is suddenly on about "${w}"`, tagline: `${c.people.size} people, ${c.n} mentions in ${SCAN_WINDOW_MIN} minutes`, keywords: [w], threads: [{ name: `why ${w}`, keywords: [w], hint: 'Say it on mic. Make it worse.' }], source: 'chat burst', hint: 'Say it on mic. Make it worse.' }));}let _pulse = { text: null, at: null, sources: [] };@@@ -497,7 +541,7 @@ async function discoverTopics({ force = false } = {}) {let made = 0;const created = [];for (const t of topics.slice(0, 4)) {-- try { created.push(createTopic({ text: t.subject, hint: t.hint, createdBy: 'community', creatorName: clip(t.source || 'the community', 40), headline: t.headline, tagline: t.tagline, sourceNote: t.source, keywords: t.keywords })); made++; } catch { /* dup */ }++ try { created.push(createTopic({ text: t.subject, hint: t.hint, createdBy: 'community', creatorName: clip(t.source || 'the community', 40), headline: t.headline, tagline: t.tagline, sourceNote: t.source, keywords: t.keywords, threads: t.threads })); made++; } catch { /* dup */ }}for (const b of bounties.slice(0, 1)) {try {@@@ -528,12 +572,32 @@ function pulse() { const p = loadPulse(); return { text: p.text, at: p.at ? new// ── Lore ─────────────────────────────────────────────────────const LORE_SCHEMA = { name: 'arena_lore', schema: { type: 'object', additionalProperties: false, required: ['lore', 'headline', 'tagline', 'keywords'], properties: {-- lore: { type: 'string', description: '3–6 sentences. The evolving story of this subject on the site: who brought it up and where, who said what (quote the best lines verbatim, name usernames), who is on which end of it, how it escalated, current state. Tabloid / fight-promoter voice, dumb, petty, funny.' },-- headline: { type: 'string', description: 'Updated tabloid headline ≤ 90 chars' },-- tagline: { type: 'string', description: '≤ 100 chars: where it stands right now' },++ lore: { type: 'string', description: 'SHORT: 2–4 punchy sentences, ≤ 450 characters total. The story so far: who brought it up, the wildest line (quote it verbatim with the username), who is on which end, the latest twist. Tabloid / fight-promoter voice, dumb, petty, funny. No filler, no recap of every line.' },++ headline: { type: 'string', description: 'Updated tabloid headline ≤ 70 chars' },++ tagline: { type: 'string', description: '≤ 80 chars: where it stands right now' },keywords: { type: 'array', minItems: 2, maxItems: 10, items: { type: 'string' }, description: 'updated lowercase keywords/slang people use for it (keep the good existing ones)' },++ threads: { type: 'array', minItems: 1, maxItems: 5, items: { type: 'object', additionalProperties: false, required: ['name', 'keywords', 'hint'], properties: { name: { type: 'string', description: '2–5 words' }, keywords: { type: 'array', minItems: 1, maxItems: 4, items: { type: 'string' } }, hint: { type: 'string', description: '≤ 70 chars' } } }, description: 'the threads (sub-angles) inside this subject — keep the existing ones by name, add new angles that emerged in the new moments' },} } };--const LORE_SYSTEM = `You write and update the LORE of a subject the community of a live-streaming site is talking about. You get the subject, the previous lore (if any), and the newest moments: chat lines and on-mic lines with usernames. Rewrite the lore so it tells the whole story so far and the new developments — who started it, who said the wildest things (quote them verbatim with their username), who is defending, who is dunking, streamers vs chat, escalations, plot twists. Keep what still matters from the previous lore, drop filler. Voice: tabloid meets fight promoter meets the dumbest guy in the group chat; provocative and funny; take the piss out of everyone. This platform does not censor language: slurs, profanity and taboo talk are allowed and should be quoted as said. Hard line: no direct threats of violence, nothing sexual about minors, no doxxing. Output only the JSON.`;++const LORE_SYSTEM = `You write and update the LORE of a subject the community of a live-streaming site is talking about — as a regular of this chat, not a copywriter. VOICE RULES: match the way the moments are typed (slang, casing, rhythm; lowercase if chat is lowercase), blunt, dry, funny; quote real lines instead of describing them; use the site's own words; no tabloid adjective soup, no invented compound words, no "erupts"/"explodes"/"chaos"/"saga"/"epic", no exclamation marks. Use what the chat AI knows about the people involved (their known bits, grudges, history) to make it specific to THEM. You get the subject, the previous lore (if any), and the newest moments: chat lines and on-mic lines with usernames. Rewrite the lore SHORT (2–4 sentences, under 450 characters): who started it, the wildest line (verbatim, with username), who is on which end, the latest twist. Drop everything else. Also return the THREADS inside the subject: keep the existing thread names, add a new one only when a genuinely new angle shows up in the new moments. Voice: tabloid meets fight promoter meets the dumbest guy in the group chat; provocative and funny; take the piss out of everyone. This platform does not censor language: slurs, profanity and taboo talk are allowed and should be quoted as said. Hard line: no direct threats of violence, nothing sexual about minors, no doxxing. Output only the JSON.`;++++/** 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 { 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');++ const seen = new Set(); const people = [];++ for (const m of moments || []) {++ const key = m.chatter_key || chatters.keyFor(m); if (!key || seen.has(key) || people.length >= 5) continue; seen.add(key);++ const p = chatters.parseKey(key); let ai = null;++ try { if (p.kind === 'user') ai = db.getChatAiSummary('user', p.user_id, 'rolling'); else if (p.kind === 'anon') ai = db.getChatAiSummary('anon', p.anon_num, 'rolling'); else if (p.kind === 'relay' && db.getRelayUser) { const ru = db.getRelayUser(p.platform, p.username); if (ru) ai = db.getChatAiSummary('relay', ru.id, 'rolling'); } } catch { /* */ }++ if (ai) { const ov = parseJson(ai.overview, null); people.push({ name: m.username, known_for: clip(ov ? (ov.alltime || ov.today) : ai.overview, 260), memory: clip(ai.memory_json, 200) }); }++ }++ if (people.length) out.what_the_chat_ai_knows_about_these_people = people;++ } catch { /* */ }++ return out;++}function templateLore(topic, moments, members) {const first = moments[moments.length - 1];@@@ -564,7 +628,7 @@ async function buildLore(topicId, { force = false } = {}) {if (aiOn() && moments.length) {try {const r = await llm.complete({ role: 'summary', kind: 'arena_lore', source: 'arena', system: LORE_SYSTEM, json: LORE_SCHEMA, maxTokens: 600, temperature: 0.85, timeoutMs: 30000,-- user: JSON.stringify({ subject: topic.text, kind: topic.kind, bounty_target: topic.target_user_id ? nameOf(topic.target_user_id) : null, started_by: topic.creator_name || topic.created_by, source: topic.source_note, previous_lore: topic.lore, previous_headline: topic.headline, keywords: parseJson(topic.keywords_json, []),++ user: JSON.stringify({ subject: topic.text, kind: topic.kind, bounty_target: topic.target_user_id ? nameOf(topic.target_user_id) : null, started_by: topic.creator_name || topic.created_by, source: topic.source_note, previous_lore: topic.lore, previous_headline: topic.headline, keywords: parseJson(topic.keywords_json, []), existing_threads: threadsOf(topic.id).map(t => t.name), ...communityContext(moments),fighters_on_it: members.map(m => m.fighter_name), moments: moments.slice().reverse().map(m => ({ who: m.username || 'anon', where: m.kind === 'chat' ? 'chat' : 'on mic', text: m.text, quality: m.quality })) }) });if (r && r.json && r.json.lore) out = r.json;} catch (e) { console.warn('[Arena] lore:', e.message); }@@@ -572,7 +636,8 @@ async function buildLore(topicId, { force = false } = {}) {if (!out) out = { lore: templateLore(topic, moments, members), headline: topic.headline, tagline: topic.tagline, keywords: parseJson(topic.keywords_json, []) };const kws = normalizeKeywords([...(parseJson(topic.keywords_json, []) || []), ...(out.keywords || [])]);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, 600), out.headline ? clip(out.headline, 120) : null, out.tagline ? clip(out.tagline, 100) : null, JSON.stringify(kws), total, topicId]);++ try { if (Array.isArray(out.threads) && out.threads.length) { upsertThreads(topicId, out.threads); const merged = normalizeKeywords([...kws, ...threadsOf(topicId).flatMap(t => parseJson(t.keywords_json, []) || [])]); db.run('UPDATE arena_topics SET keywords_json = ? WHERE id = ?', [JSON.stringify(merged), topicId]); } } catch (e) { console.warn('[Arena] threads:', e.message); }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 };@@@ -602,7 +667,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, 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 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 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]);@@@ -617,6 +682,7 @@ function topicView(topic, roster, { detail = false } = {}) {expires_at: topic.expires_at && topic.kind === 'bounty' ? new Date(topic.expires_at + 'Z').toISOString() : null, resolved: parseJson(topic.resolved_json),mentions: { chat: topic.chat_mentions || 0, mic: topic.mic_mentions || 0, total: (topic.chat_mentions || 0) + (topic.mic_mentions || 0) },chatters: db.get(`SELECT COUNT(DISTINCT COALESCE(username, user_id)) AS n FROM arena_topic_moments WHERE topic_id = ? AND kind = 'chat'`, [topic.id])?.n || 0,++ threads: db.all('SELECT * FROM arena_topic_threads WHERE topic_id = ? ORDER BY moments DESC, id ASC', [topic.id]).map(t => ({ id: t.id, name: t.name, hint: t.hint, moments: t.moments || 0, last_at: t.last_at, keywords: parseJson(t.keywords_json, []) || [] })),talking_now: members.filter(m => m.active).map(m => fighterBrief(m.user_id, roster)),fighters: members.slice(0, 12).map(m => ({ ...fighterBrief(m.user_id, roster), active: !!m.active, moments: m.moments, score: Number((m.score || 0).toFixed(1)) })),last_moment: last ? momentView(last) : null, best_moment: best ? momentView(best) : null,@@@ -662,7 +728,7 @@ function levelsLeaderboard(limit = 10) {function yappersLeaderboard(limit = 10) { return require('./chatters').leaderboard(limit); }module.exports = {-- ensureTables, createTopic, submitTopic, assertCanSubmit, joinTopic, leaveTopic, activeTopicFor, applyTopicJudgement, hypeTopic, addMoment, noteMicMention, matchTopics, keywordsFromText,++ ensureTables, createTopic, submitTopic, assertCanSubmit, joinTopic, leaveTopic, activeTopicFor, threadsOf, threadFor, upsertThreads, 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,@@@ -6,7 +6,7 @@* GET /console/:user what the listener hears for a live fighter* GET /board pulse + open events (topics/debates/phrases/bounties) + leaderboards* POST /board/topics {text} signed in, 1 per person + per IP per 24 h; AI rewrites it-- * GET /board/topics/:id · POST …/join · …/leave · …/hype {user_id}++ * GET /board/topics/:id · POST …/hype {user_id} (no joining — the ears auto-detect who talks on what)* POST /board/bounty {username} put a bounty on a fighter* GET /beefs · /beefs/:id · POST /beefs/:id/hype {side}* GET /levels · /yappers Trash Level ladder · viewers who keep subjects alive from chat@@@ -129,11 +129,6 @@ router.get('/board/topics/:id', (req, res) => {res.json(t);} catch (err) { fail(res, err, 'Failed to load topic'); }});--router.post('/board/topics/:id/join', requireAuth, async (req, res) => {-- try { const r = await board.joinTopic(Number(req.params.id), req.user.id); res.json({ ok: true, topic: board.topicDetail(r.topic.id) }); }-- catch (err) { res.status(400).json({ error: err.message }); }--});--router.post('/board/topics/:id/leave', requireAuth, (req, res) => { try { board.leaveTopic(req.user.id); res.json({ ok: true }); } catch (err) { res.status(400).json({ error: err.message }); } });router.post('/board/topics/:id/hype', optionalAuth, (req, res) => {try { res.json(board.hypeTopic(Number(req.params.id), Number(req.body?.user_id), arena.voterKeyFor(req))); }catch (err) { res.status(400).json({ error: err.message }); }@@@ -38,9 +38,13 @@ board.ensureTables(); beef.ensureTables();(async () => {// ── Board: subjects with keywords, moments from chat + mic, lore, Trash Level ──board._resetScan();-- const t1 = board.createTopic({ text: 'People who read donations in a baby voice', createdBy: 'community', creatorName: 'global chat', headline: 'BABY VOICE GATE', keywords: ['baby voice', 'dono voice'] });-- assert.deepStrictEqual(JSON.parse(t1.keywords_json), ['baby voice', 'dono voice']);-- assert.throws(() => board.createTopic({ text: 'people who read donations in a baby voice', createdBy: 'chat' }), /already on the board/);++ const t1 = board.createTopic({ text: 'The baby voice', createdBy: 'community', creatorName: 'global chat', headline: 'baby voice gate', keywords: ['baby voice', 'dono voice'], threads: [{ name: 'the fax machine voice', keywords: ['fax machine'], hint: 'roast the voice' }, { name: 'who does it for $2', keywords: ['dono', 'donation'] }] });++ assert.deepStrictEqual(JSON.parse(t1.keywords_json), ['baby voice', 'dono voice', 'fax machine', 'dono', 'donation'], 'subject keywords = its own + every thread\'s');++ assert.deepStrictEqual(board.threadsOf(t1.id).map(x => x.name), ['the fax machine voice', 'who does it for $2']);++ assert.strictEqual(board.threadFor(t1.id, 'sounds like a FAX MACHINE'), board.threadsOf(t1.id)[0].id, 'lines file into the matching thread');++ assert.strictEqual(board.threadFor(t1.id, 'baby voice in general'), null, 'no thread match → subject-level moment');++ assert.throws(() => board.createTopic({ text: 'the BABY voice', createdBy: 'chat' }), /already on the board/);++ assert.throws(() => board.createTopic({ text: 'Dono donation drama', createdBy: 'chat' }), /already on the board/, 'two shared keywords = same subject');assert.throws(() => board.createTopic({ text: 'ok', createdBy: 'chat' }), /short/i);const auto = board.createTopic({ text: 'Pakistanis in the global chat again', createdBy: 'chat', creatorUserId: viewer, creatorName: 'Viewer', creatorIp: '9.9.9.9' });assert.deepStrictEqual(JSON.parse(auto.keywords_json), ['pakistanis', 'global'], 'keywords fall out of the subject text (stopwords dropped)');@@@ -90,6 +94,10 @@ board.ensureTables(); beef.ensureTables();assert.strictEqual(lv.best_line.text, 'Read my dono in your real voice, coward', 'best line is the highest-quality one');d1 = board.topicDetail(t1.id);assert.strictEqual(d1.mentions.mic, 3); assert.strictEqual(d1.fighters[0].user.id, u1); assert.strictEqual(d1.fighters[0].moments, 3);++ assert.strictEqual(d1.threads.find(x => x.name === 'the fax machine voice').moments, 1, 'the fax-machine line landed in its thread');++ assert.ok(d1.moments.some(m => m.thread_id === d1.threads[0].id || m.thread_id === d1.threads[1]?.id));++ assert.strictEqual(board.upsertThreads(t1.id, [{ name: 'The Fax Machine Voice', keywords: ['fax'] }, { name: 'nova vs grizzly', keywords: ['grizzly'] }]), 1, 'same thread merges, new angle added');++ assert.strictEqual(board.threadsOf(t1.id).length, 3);assert.strictEqual(d1.best_moment.quality, 8); assert.strictEqual(d1.best_lines.length, 2);assert.ok(d1.moments.length === 5 && d1.moments[0].source === 'judge');await board.joinTopic(auto.id, u1);@@@ -329,7 +337,7 @@ board.ensureTables(); beef.ensureTables();const one = await get(`/beefs/${opened[0].id}`); assert.strictEqual(one.body.a.user.username, 'nova'); assert.ok(one.body.rules.response_live_min === 15);const con = await get('/console/nova'); assert.strictEqual(con.body.listener.listening, true); assert.strictEqual(con.body.open_beefs.length, 1); assert.ok(con.body.hot_mic.length >= 2 && con.body.listener.focus === null);const lv2 = await get('/live'); assert.strictEqual(lv2.body.live[0].open_beefs, 1);-- const tp = await get(`/board/topics/${t1.id}`); assert.ok(tp.body.lore && tp.body.moments.length >= 5 && tp.body.keywords.includes('baby voice'));++ const tp = await get(`/board/topics/${t1.id}`); assert.ok(tp.body.lore && tp.body.moments.length >= 5 && tp.body.keywords.includes('baby voice') && tp.body.threads.length === 3);assert.strictEqual((await get('/beefs/999')).status, 404);assert.strictEqual((await get('/console/nobody')).status, 404);srv.close();
@@ -32,9 +32,10 @@ Headlines (open + result) are AI-written (`chat` role) with templates as fallbac
@@ -32,9 +32,10 @@ Headlines (open + result) are AI-written (`chat` role) with templates as fallbac
No voting anywhere. The board is a list of **subjects** the community is actually on about, each a lore profile that writes itself:
No voting anywhere. The board is a list of **subjects** the community is actually on about, each a lore profile that writes itself:
-- **Discovery** — every 5 min (only when ≥ 8 new lines exist, so a dead room costs nothing) the AI reads the last 30 min of global + stream chat and live transcripts and returns the subjects people are really on about (a person, a group, a joke, a drama…) with a tabloid headline, a tagline, the **keywords/slang** people use for it, and optionally one **bounty** on a streamer the community keeps naming. Without AI, a word ≥ 3 people said ≥ 4 times becomes a subject. New subjects are **backfilled** with every matching line from the last 2 h so they never start empty.
-- **Moments** — every minute new `chat_messages` are scanned; any line matching a subject's keywords becomes a chat **moment** on it. The listener does the same for transcript lines (an on-mic moment with a VOD deep link, one per stream per 45 s) and **auto-joins** the fighter to the subject. Judged chunks (≥ 20 words, AI quality 0–10) are moments with a score → XP (quality × 0.8) → Trash Level.
-- **Lore** — when a subject has ≥ 3 new moments (and ≥ 8 min since the last rewrite) the AI rewrites its lore: who started it, who said what (verbatim, with usernames), who is on which end, escalations. Templated when AI is off. Lore stays in the archive after the subject cools off (36 h idle).
+- **Discovery** — every 5 min (only when ≥ 8 new lines exist, so a dead room costs nothing; window `arena_discover_window_min`, default 30) the AI reads global + stream chat and live transcripts and returns the **umbrella subjects** people are really on about (1–4 words: a person, a group, a joke, a drama…) each with 2–4 **threads** — the specific angles being argued (name + keywords + hint) — a short headline and tagline in the community's own voice, the keywords/slang people use, and optionally one **bounty** on a streamer the community keeps naming. Voice rules: write like a regular of this chat (its casing, slang, rhythm), no tabloid adjective soup, no invented words; the prompt gets recent chat lines, the global chat AI overview and the chat AI's profiles of the people involved. Without AI, a word ≥ 3 people said ≥ 4 times becomes a subject. New subjects are **backfilled** with every matching line from the last 2 h so they never start empty.
+- **Moments** — every minute new `chat_messages` are scanned; any line matching a subject's keywords becomes a chat **moment** on it and is filed into the first thread whose keywords match (`thread_id`). The listener does the same for transcript lines (an on-mic moment with a VOD deep link, one per stream per 45 s) and **auto-joins** the fighter to the subject. Judged chunks (≥ 20 words, AI quality 0–10) are moments with a score → XP (quality × 0.8) → Trash Level.
+- **Nobody joins anything** — the ears detect who is on what: a fighter saying a subject on mic is auto-filed as a member; there are no join/leave endpoints or buttons.
+- **Lore** — when a subject has ≥ 3 new moments (and ≥ 8 min since the last rewrite) the AI rewrites its short (≤ 450 chars) story so far and returns the updated threads (existing kept, new angles added, ≤ 6 per subject; unfiled moments are re-filed): who started it, who said what (verbatim, with usernames), who is on which end, escalations. Templated when AI is off. Lore stays in the archive after the subject cools off (36 h idle).
- **User subjects** — signed-in only, **one per person and one per IP per 24 h**. The AI rewrites what was typed into a subject + headline + keywords (`submitTopic`, one small call) and backfills moments. `!topic <text>` in chat does the same.
- **User subjects** — signed-in only, **one per person and one per IP per 24 h**. The AI rewrites what was typed into a subject + headline + keywords (`submitTopic`, one small call) and backfills moments. `!topic <text>` in chat does the same.
- **Heat** = on-mic moments ×3 + chat moments + hype + fighters talking (last hour); ≥ 12 is HOT; the hottest subject is featured at the top.
- **Heat** = on-mic moments ×3 + chat moments + hype + fighters talking (last hour); ≥ 12 is HOT; the hottest subject is featured at the top.
- **Ladders** — Trash Level (XP) and **yappers** (chatters with the most moments in 7 days).
- **Ladders** — Trash Level (XP) and **yappers** (chatters with the most moments in 7 days).
@@ -53,9 +54,9 @@ Every 15 s, for every roster stream that is live **and** has a transcript line i
@@ -53,9 +54,9 @@ Every 15 s, for every roster stream that is live **and** has a transcript line i
## Pages
## Pages
-`/arena` — pulse (what the site is on about + live counters) · on the mic now · open + settled beefs · the board (featured subject with lore + latest moments, then a feed of subjects, lore archive) · Trash Level ladder · yappers · power ladder (rows expand) · rules.
+`/arena` — pulse · on the mic now · open + settled beefs · the board (subject cards: headline, subject + tagline, thread chips with counts, latest line, mention counts, fighters; first card featured; cooled-off list) · Trash Level ladder · yappers · power ladder (rows expand; compact on phones) · rules.
`/arena/beef/<id>` — tale of the tape, tug-of-war, live clock, ringside feed (every judged hit with ▶ to the VOD second and 🔊), receipts from earlier beefs, hype/side buttons.
`/arena/beef/<id>` — tale of the tape, tug-of-war, live clock, ringside feed (every judged hit with ▶ to the VOD second and 🔊), receipts from earlier beefs, hype/side buttons.
-`/arena/topic/<id>` — headline, subject, lore, keywords, the full moment timeline (chat + on-mic with ▶ to the VOD second), fighters on it with their best line, loudest chatters, join/leave.
+`/arena/topic/<id>` — headline, subject + tagline, counts, story so far, **thread tabs** filtering the moment timeline (chat + on-mic with ▶ to the VOD second), fighters heard on it with their best line, loudest chatters with yap levels.
`/arena/<username>` — stats drill-down, voice + quotes, Trash Level card, rivalries with receipts, beef history.
`/arena/<username>` — stats drill-down, voice + quotes, Trash Level card, rivalries with receipts, beef history.
## Chat commands (`server/arena/arena-chat.js`)
## Chat commands (`server/arena/arena-chat.js`)
@@ -14510,3 +14510,49 @@ a.arena-tag:hover { color: var(--accent-light); }
@@ -14510,3 +14510,49 @@ a.arena-tag:hover { color: var(--accent-light); }
.ctx-yap-bar { display: block; height: 4px; border-radius: 2px; background: var(--bg-tertiary); overflow: hidden; }
.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-bar span { display: block; height: 100%; background: linear-gradient(90deg, #f97316, #fbbf24); }
.ctx-yap small { color: var(--text-muted); }
.ctx-yap small { color: var(--text-muted); }
+
+/* ── Arena board v3: subject cards + threads ── */
+.arena-subject-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
+.arena-subject { display: flex; flex-direction: column; gap: 8px; padding: 14px 16px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); min-width: 0; }
+.arena-subject.is-hot { border-color: rgba(249, 115, 22, 0.5); }
+.arena-subject.is-featured { grid-column: 1 / -1; background: linear-gradient(135deg, rgba(249, 115, 22, 0.1), var(--bg-card) 50%); }
+.arena-subject.arena-topic-bounty { border-left: 4px solid #f5c542; }
+.arena-subject-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 0.74rem; color: var(--text-muted); }
+.arena-subject-headline { margin: 0; font-size: 1.15rem; line-height: 1.25; font-weight: 900; color: var(--text-primary); }
+.arena-subject.is-featured .arena-subject-headline { font-size: 1.45rem; }
+.arena-topic-link:hover .arena-subject-headline { color: #fb923c; }
+.arena-subject-sub { font-size: 0.86rem; color: var(--text-secondary); }
+.arena-subject-sub b { color: var(--text-primary); }
+.arena-threads { display: flex; gap: 6px; flex-wrap: wrap; }
+.arena-thread { display: inline-flex; align-items: center; gap: 4px; padding: 3px 9px; border-radius: 999px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.76rem; font-weight: 600; }
+.arena-thread i { font-size: 0.65rem; color: #fb923c; }
+.arena-thread b { color: var(--text-primary); }
+.arena-thread.is-more { opacity: 0.6; }
+a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srgb, var(--bg-tertiary) 70%, #fb923c 30%); }
+.arena-subject-last { display: block; font-size: 0.84rem; color: var(--text-secondary); quotes: "" ""; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.arena-subject-last i { font-size: 0.7rem; margin-right: 3px; color: var(--text-muted); }
+.arena-subject-foot { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: auto; }
+.arena-subject-open { margin-left: auto; font-size: 0.8rem; color: var(--accent-light); text-decoration: none; font-weight: 700; }
+.arena-strip { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 8px 0; }
+.arena-strip-people { display: inline-flex; align-items: center; gap: 8px; }
+.arena-strip-people small { color: var(--text-muted); }
+.arena-story { padding: 12px 14px; border-radius: 14px; background: var(--bg-tertiary); border-left: 3px solid #fb923c; }
+.arena-story h3 { margin: 0 0 6px; font-size: 0.9rem; display: flex; gap: 8px; align-items: center; }
+.arena-story h3 small { color: var(--text-muted); font-weight: 400; font-size: 0.74rem; }
+.arena-tabs { display: flex; gap: 6px; flex-wrap: wrap; }
+.arena-tab { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text-secondary); font: inherit; font-size: 0.82rem; font-weight: 600; cursor: pointer; }
+.arena-tab b { color: var(--text-primary); margin-left: 2px; }
+.arena-tab.is-on { border-color: #fb923c; color: var(--text-primary); background: rgba(249, 115, 22, 0.12); }
+.arena-tab-hint { margin: 0; }
+.arena-row-lore.is-clamp { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
+.arena-row-taunts em { display: flex; align-items: center; gap: 6px; }
+@media (max-width: 900px) { .arena-subject-grid { grid-template-columns: 1fr; } }
+@media (max-width: 600px) {
+ .arena-subject { padding: 12px; } .arena-subject.is-featured .arena-subject-headline { font-size: 1.2rem; }
+ .arena-row-expand { grid-template-columns: 1fr; }
+ .arena-row-expand .arena-radar { max-width: 170px; margin: 0 auto; }
+ .arena-quips-custom, .arena-quips { grid-template-columns: 1fr 1fr; gap: 4px 10px; }
+ .arena-quip { font-size: 0.78rem; }
+ .arena-row-expand-actions .btn { padding: 6px 10px; font-size: 0.82rem; }
+ .arena-topic-headline-big { font-size: 1.2rem; }
+}
@@ -62,7 +62,7 @@
@@ -62,7 +62,7 @@
</script>
</script>
<!-- Stylesheets -->
<!-- Stylesheets -->
- <link rel="stylesheet" href="/css/style.css?v=149">
+ <link rel="stylesheet" href="/css/style.css?v=150">
<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">
@@ -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=10" defer></script>
+<script src="/js/arena.js?v=11" 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>
@@ -229,7 +229,7 @@ async function _aRenderHome(root) {
@@ -229,7 +229,7 @@ async function _aRenderHome(root) {
<h3>How the Arena works</h3>
<h3>How the Arena works</h3>
<ul>
<ul>
<li><b>Beefs.</b> Say another fighter's name on stream while talking shit → a beef opens and they go on the clock (15 min if they're live, 24 h if not; the clock resets every time a side answers). Silence is a forfeit. Best mouth after 24 h wins. Beat someone ranked 4+ spots above you and it's an <b>upset</b>.</li>
<li><b>Beefs.</b> Say another fighter's name on stream while talking shit → a beef opens and they go on the clock (15 min if they're live, 24 h if not; the clock resets every time a side answers). Silence is a forfeit. Best mouth after 24 h wins. Beat someone ranked 4+ spots above you and it's an <b>upset</b>.</li>
- <li><b>The board.</b> Subjects show up on their own from what chat and streamers are actually saying (the AI reads global chat + live transcripts every few minutes). Every chat line or on-mic line that mentions a subject becomes a <b>moment</b> on it, and its <b>lore</b> gets rewritten as it escalates — who started it, who said what, who's on which end. Streamers "talk on" a subject → judged moments → XP → Trash Level. Bounties come from chat (<code>!bounty</code>).</li>
+ <li><b>The board.</b> Subjects show up on their own from what chat and streamers are saying (the AI reads global chat + live mics every few minutes). Each subject has <b>threads</b> — the angles people are actually arguing. Every chat or on-mic line that touches one becomes a <b>moment</b>; the story gets rewritten as it escalates. Nobody joins anything: the ears detect who's on what. Streamers' judged lines → XP → Trash Level; chatters' lines → yap XP. Bounties come from chat (<code>!bounty</code>).</li>
<li><b>Start one.</b> Signed-in users can put up one subject per 24 h (per person and per connection); the AI rewrites it into a proper headline. No voting anywhere — you don't pick sides, you pile on.</li>
<li><b>Start one.</b> Signed-in users can put up one subject per 24 h (per person and per connection); the AI rewrites it into a proper headline. No voting anywhere — you don't pick sides, you pile on.</li>
<li><b>Chat.</b> <code>!hype</code> boosts your streamer · <code>!topic <text></code> · <code>!bounty <user></code> · <code>!beef</code> · <code>!board</code> · <code>!arena</code>.</li>
<li><b>Chat.</b> <code>!hype</code> boosts your streamer · <code>!topic <text></code> · <code>!bounty <user></code> · <code>!beef</code> · <code>!board</code> · <code>!arena</code>.</li>
<li><b>Language.</b> Nothing gets censored for being offensive. The only lines that don't count: real threats, anything sexual about minors, and doxxing.</li>
<li><b>Language.</b> Nothing gets censored for being offensive. The only lines that don't count: real threats, anything sexual about minors, and doxxing.</li>
@@ -376,11 +376,6 @@ function _aBindHome(root) {
@@ -376,11 +376,6 @@ function _aBindHome(root) {
try { const r = await api(`/arena/board/topics/${btn.dataset.topic}/hype`, { method: 'POST', body: { user_id: Number(btn.dataset.user) } }); _aToast(r.added ? `🔥 Hyped (${r.hypers} hyping)` : 'Already hyped', r.added ? 'success' : 'info'); btn.classList.add('is-hyped'); }
try { const r = await api(`/arena/board/topics/${btn.dataset.topic}/hype`, { method: 'POST', body: { user_id: Number(btn.dataset.user) } }); _aToast(r.added ? `🔥 Hyped (${r.hypers} hyping)` : 'Already hyped', r.added ? 'success' : 'info'); btn.classList.add('is-hyped'); }
catch (err) { _aToast(err?.message || 'Failed', 'error'); }
catch (err) { _aToast(err?.message || 'Failed', 'error'); }
});
});
- root.querySelectorAll('.arena-topic-join').forEach(btn => btn.onclick = async (e) => {
- e.preventDefault();
- try { await api(`/arena/board/topics/${btn.dataset.topic}/join`, { method: 'POST' }); _aToast('🎤 You\'re on it. Start talking on stream — the ears are listening.', 'success'); navigate(`/arena/topic/${btn.dataset.topic}`); }
- catch (err) { _aToast(err?.message || 'Failed', 'error'); }
- });
_aBindSpeak(root);
_aBindSpeak(root);
}
}
@@ -415,67 +410,39 @@ function _aMentionChips(t) {
@@ -415,67 +410,39 @@ function _aMentionChips(t) {
${t.talking_now.length ? `<span class="arena-chip-stat is-live" title="fighters on it right now"><span class="arena-live-dot arena-live-dot-sm"></span> ${t.talking_now.length} on mic</span>` : ''}
${t.talking_now.length ? `<span class="arena-chip-stat is-live" title="fighters on it right now"><span class="arena-live-dot arena-live-dot-sm"></span> ${t.talking_now.length} on mic</span>` : ''}
</span>`;
</span>`;
}
}
-function _aJoinBtn(t, me) {
- const onIt = me && t.talking_now.some(f => f.user.id === me.id);
- const onRoster = me && _arenaRoster?.fighters?.some(f => f.user.id === me.id);
- if (!onRoster || t.kind === 'bounty' || t.status !== 'open') return '';
- return onIt ? '<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-microphone"></i> you\'re on it</span>' : `<button class="btn btn-primary btn-sm arena-topic-join" data-topic="${t.id}"><i class="fa-solid fa-microphone"></i> Talk on this</button>`;
-}
-function _aFeatured(t, hot, me) {
- const k = ARENA_KIND[t.kind] || ARENA_KIND.topic;
- return `<div class="arena-featured arena-topic-${t.kind}" data-topic="${t.id}">
- <div class="arena-featured-main">
- <div class="arena-topic-head">
- <span class="arena-kind"><i class="fa-solid ${k.icon}"></i> ${t.hot ? 'Hottest right now' : 'Top of the board'}</span>
- ${_aHeat(t, hot)}
- <span class="arena-topic-by">${t.created_by === 'community' ? `<i class="fa-solid fa-satellite-dish"></i> ${_aEsc(t.source_note || 'the community')}` : `<i class="fa-solid fa-user"></i> ${_aEsc(t.creator_name || t.created_by)}`} · ${_aEsc(_aAgo(t.created_at))}</span>
- </div>
- ${_aA(_aTopicLink(t), `<h3 class="arena-featured-headline">${_aEsc(t.headline || t.text)}</h3>`, 'arena-topic-link')}
- <div class="arena-featured-subject">${_aTopicTitle(t)}${t.tagline ? ` <span class="arena-tagline">— ${_aEsc(t.tagline)}</span>` : ''}</div>
- <p class="arena-lore-p">${_aEsc(t.lore || t.hint || 'No lore yet — nobody has said anything worth writing down. Fix that.')}</p>
- <div class="arena-topic-foot">
- ${_aMentionChips(t)}
- ${t.fighters.length ? _aAvatarStack(t.fighters) : ''}
- <span class="arena-topic-actions">${_aJoinBtn(t, me)} ${_aA(_aTopicLink(t), 'Full lore <i class="fa-solid fa-arrow-right"></i>', 'btn btn-ghost btn-sm')}</span>
- </div>
- </div>
- <div class="arena-featured-side">
- <h4><i class="fa-solid fa-bolt"></i> Latest</h4>
- ${t.best_moment ? _aMomentLine(t.best_moment) : ''}
- ${t.last_moment && t.last_moment.id !== t.best_moment?.id ? _aMomentLine(t.last_moment) : ''}
- ${!t.last_moment ? '<p class="arena-note">Nothing yet. Type its name in chat or say it on stream.</p>' : ''}
- ${t.keywords?.length ? `<div class="arena-keywords">${t.keywords.slice(0, 6).map(k => `<span>${_aEsc(k)}</span>`).join('')}</div>` : ''}
- </div>
- </div>`;
+function _aThreadChips(t, { max = 4, link = true } = {}) {
+ const th = (t.threads || []).slice(0, max);
+ if (!th.length) return '';
+ return `<div class="arena-threads">${th.map(x => link ? _aA(`${_aTopicLink(t)}#thread-${x.id}`, `<span class="arena-thread"><i class="fa-solid fa-angle-right"></i> ${_aEsc(x.name)}${x.moments ? ` <b>${x.moments}</b>` : ''}</span>`) : `<span class="arena-thread"><i class="fa-solid fa-angle-right"></i> ${_aEsc(x.name)}${x.moments ? ` <b>${x.moments}</b>` : ''}</span>`).join('')}${(t.threads || []).length > max ? `<span class="arena-thread is-more">+${t.threads.length - max}</span>` : ''}</div>`;
}
}
-function _aTopicRow(t, hot, me) {
+function _aSubjectCard(t, hot, { featured = false } = {}) {
const k = ARENA_KIND[t.kind] || ARENA_KIND.topic;
const k = ARENA_KIND[t.kind] || ARENA_KIND.topic;
- return `<div class="arena-topic-row arena-topic-${t.kind} ${t.hot ? 'is-hot' : ''}" data-topic="${t.id}">
- <div class="arena-topic-row-heat">${_aHeat(t, hot)}</div>
- <div class="arena-topic-row-main">
- ${_aA(_aTopicLink(t), `<strong>${_aEsc(t.headline || t.text)}</strong>`, 'arena-topic-link')}
- <div class="arena-topic-row-sub"><span class="arena-kind"><i class="fa-solid ${k.icon}"></i> ${_aTopicTitle(t)}</span>${t.tagline ? ` · ${_aEsc(t.tagline)}` : ''}</div>
- ${t.last_moment ? `<q class="arena-topic-row-last"><i class="fa-solid ${t.last_moment.kind === 'chat' ? 'fa-keyboard' : 'fa-microphone'}"></i> ${_aEsc(t.last_moment.username || 'anon')}: ${_aEsc(t.last_moment.text)}</q>` : `<span class="arena-note">${_aEsc(t.hint || 'nothing said yet')}</span>`}
+ return `<div class="arena-subject ${featured ? 'is-featured' : ''} ${t.hot ? 'is-hot' : ''} arena-topic-${t.kind}" data-topic="${t.id}">
+ <div class="arena-subject-head">
+ ${_aHeat(t, hot)}
+ <span class="arena-topic-by">${t.kind === 'bounty' ? '<i class="fa-solid fa-sack-dollar"></i> bounty · ' : ''}${t.created_by === 'community' ? `<i class="fa-solid fa-satellite-dish"></i> ${_aEsc(t.source_note || 'the site')}` : `<i class="fa-solid fa-user"></i> ${_aEsc(t.creator_name || t.created_by)}`} · ${_aEsc(_aAgo(t.last_mention_at || t.created_at))}</span>
</div>
</div>
- <div class="arena-topic-row-side">
+ ${_aA(_aTopicLink(t), `<h3 class="arena-subject-headline">${_aEsc(t.headline || t.text)}</h3>`, 'arena-topic-link')}
+ <div class="arena-subject-sub"><b>${_aTopicTitle(t)}</b>${t.tagline ? ` — ${_aEsc(t.tagline)}` : ''}</div>
+ ${_aThreadChips(t)}
+ ${t.last_moment ? `<q class="arena-subject-last"><i class="fa-solid ${t.last_moment.kind === 'chat' ? 'fa-keyboard' : 'fa-microphone'}"></i> <b>${_aEsc(t.last_moment.username || 'anon')}</b>: ${_aEsc(t.last_moment.text)}</q>` : `<span class="arena-note">${_aEsc(t.hint || 'nobody has said anything yet')}</span>`}
+ <div class="arena-subject-foot">
${_aMentionChips(t)}
${_aMentionChips(t)}
${t.fighters.length ? _aAvatarStack(t.fighters, 4) : ''}
${t.fighters.length ? _aAvatarStack(t.fighters, 4) : ''}
- ${_aJoinBtn(t, me)}
+ ${_aA(_aTopicLink(t), 'open <i class="fa-solid fa-arrow-right"></i>', 'arena-subject-open')}
</div>
</div>
</div>`;
</div>`;
}
}
function _aRenderBoard(bd) {
function _aRenderBoard(bd) {
const me = _aMe();
const me = _aMe();
const open = bd.open || [], archive = bd.archive || [];
const open = bd.open || [], archive = bd.archive || [];
- const [top, ...rest] = open;
return `<div class="arena-board-head">
return `<div class="arena-board-head">
- <h2><i class="fa-solid fa-comments"></i> The board <small>${open.length} subject${open.length === 1 ? '' : 's'} the site is on about · sorted by heat</small></h2>
+ <h2><i class="fa-solid fa-comments"></i> The board <small>what the site is on about · ${open.length} subject${open.length === 1 ? '' : 's'} · say one on mic or type it in chat and it counts</small></h2>
${me ? `<button class="btn btn-ghost btn-sm" id="arena-board-new"><i class="fa-solid fa-plus"></i> Start a subject</button>` : '<span class="arena-note">sign in to start one (or <code>!topic</code> in any chat)</span>'}
${me ? `<button class="btn btn-ghost btn-sm" id="arena-board-new"><i class="fa-solid fa-plus"></i> Start a subject</button>` : '<span class="arena-note">sign in to start one (or <code>!topic</code> in any chat)</span>'}
</div>
</div>
- ${top ? _aFeatured(top, bd.hot_threshold || 12, me) : '<p class="arena-note">The board is empty — the AI is listening to chat and live mics; the moment people start on about something it shows up here.</p>'}
- ${rest.length ? `<div class="arena-topic-feed">${rest.map(t => _aTopicRow(t, bd.hot_threshold || 12, me)).join('')}</div>` : ''}
- ${archive.length ? `<details class="arena-done"><summary>Lore archive <small>${archive.length} cooled off</small></summary><div class="arena-topic-feed is-archive">${archive.map(t => `<div class="arena-topic-row is-archived">
- <div class="arena-topic-row-main">${_aA(_aTopicLink(t), `<strong>${_aEsc(t.headline || t.text)}</strong>`, 'arena-topic-link')}<div class="arena-topic-row-sub">${_aTopicTitle(t)} · ${t.mentions.total} mentions · ${_aEsc(_aAgo(t.last_activity_at))}${t.resolved?.headline ? ` · ${_aEsc(t.resolved.headline)}` : ''}</div>${t.lore ? `<p class="arena-lore-p is-clamp">${_aEsc(t.lore)}</p>` : ''}</div>
+ ${open.length ? `<div class="arena-subject-grid">${open.map((t, i) => _aSubjectCard(t, bd.hot_threshold || 12, { featured: i === 0 })).join('')}</div>` : '<p class="arena-note">Empty for now — the AI reads chat and live mics every few minutes; the moment people are on about something, it shows up here.</p>'}
+ ${archive.length ? `<details class="arena-done"><summary>Cooled off <small>${archive.length}</small></summary><div class="arena-topic-feed is-archive">${archive.map(t => `<div class="arena-topic-row is-archived">
+ <div class="arena-topic-row-main">${_aA(_aTopicLink(t), `<strong>${_aEsc(t.headline || t.text)}</strong>`, 'arena-topic-link')}<div class="arena-topic-row-sub">${_aTopicTitle(t)} · ${t.mentions.total} mentions · ${_aEsc(_aAgo(t.last_activity_at))}${t.resolved?.headline ? ` · ${_aEsc(t.resolved.headline)}` : ''}</div></div>
</div>`).join('')}</div></details>` : ''}`;
</div>`).join('')}</div></details>` : ''}`;
}
}
function _aTopicComposer() {
function _aTopicComposer() {
@@ -602,8 +569,8 @@ function _aRenderList(fighters) {
@@ -602,8 +569,8 @@ function _aRenderList(fighters) {
<div class="arena-row-expand">
<div class="arena-row-expand">
<div>${_aCustomRadar(f, f.user.profile_color || '#8b5cf6', 200)}<div class="arena-mini-record">${(f.persona.custom_stats || []).length ? 'their own stats, from their profile · ' : ''}${f.category ? _aEsc(f.category) + ' · ' : ''}last live ${_aEsc(f.last_live_at ? _aDate(f.last_live_at) : '—')}</div></div>
<div>${_aCustomRadar(f, f.user.profile_color || '#8b5cf6', 200)}<div class="arena-mini-record">${(f.persona.custom_stats || []).length ? 'their own stats, from their profile · ' : ''}${f.category ? _aEsc(f.category) + ' · ' : ''}last live ${_aEsc(f.last_live_at ? _aDate(f.last_live_at) : '—')}</div></div>
<div>
<div>
- <p class="arena-row-lore">${_aEsc(f.persona.lore)}</p>
- ${(f.persona.taunts || []).length ? `<div class="arena-row-taunts">${f.persona.taunts.map(t => `<em>“${_aEsc(t)}”</em>`).join('')}${f.persona.typing_style ? `<small><i class="fa-solid fa-keyboard"></i> ${_aEsc(f.persona.typing_style)}</small>` : ''}</div>` : ''}
+ <p class="arena-row-lore is-clamp">${_aEsc(f.persona.lore)}</p>
+ ${(f.persona.taunts || []).length ? `<div class="arena-row-taunts">${f.persona.taunts.slice(0, 2).map(t => `<em>“${_aEsc(t)}” ${_aSpeakBtn(t, '', f.user.username)}</em>`).join('')}${f.persona.typing_style ? `<small><i class="fa-solid fa-keyboard"></i> ${_aEsc(f.persona.typing_style)}</small>` : ''}</div>` : ''}
${(f.persona.custom_stats || []).length ? _aCustomQuips(f) : `<div class="arena-quips">${ARENA_STATS.map(k => `<div class="arena-quip"><b>${_aEsc(ARENA_STAT_LABEL[k])} ${f.ratings[k]}</b><span>${_aEsc((f.persona.stat_quips || {})[k] || '')}</span></div>`).join('')}</div>`}
${(f.persona.custom_stats || []).length ? _aCustomQuips(f) : `<div class="arena-quips">${ARENA_STATS.map(k => `<div class="arena-quip"><b>${_aEsc(ARENA_STAT_LABEL[k])} ${f.ratings[k]}</b><span>${_aEsc((f.persona.stat_quips || {})[k] || '')}</span></div>`).join('')}</div>`}
${f.persona.signature_move ? `<div class="arena-mini-record"><b>Signature:</b> ${_aEsc(f.persona.signature_move.name)} — ${_aEsc(f.persona.signature_move.description)}</div>` : ''}
${f.persona.signature_move ? `<div class="arena-mini-record"><b>Signature:</b> ${_aEsc(f.persona.signature_move.name)} — ${_aEsc(f.persona.signature_move.description)}</div>` : ''}
</div>
</div>
@@ -692,62 +659,51 @@ async function _aRenderBeef(root, id) {
@@ -692,62 +659,51 @@ async function _aRenderBeef(root, id) {
// ── Topic detail ─────────────────────────────────────────────
// ── Topic detail ─────────────────────────────────────────────
async function _aRenderTopic(root, id) {
async function _aRenderTopic(root, id) {
- root.innerHTML = _aSpinner('Opening the lore…');
+ root.innerHTML = _aSpinner('Opening the subject…');
let t = await api(`/arena/board/topics/${id}`);
let t = await api(`/arena/board/topics/${id}`);
const me = _aMe();
const me = _aMe();
+ let thread = (location.hash.match(/#thread-(\d+)/) || [])[1] ? Number((location.hash.match(/#thread-(\d+)/) || [])[1]) : 0;
const draw = () => {
const draw = () => {
const k = ARENA_KIND[t.kind] || ARENA_KIND.topic;
const k = ARENA_KIND[t.kind] || ARENA_KIND.topic;
- const onIt = me && t.talking_now.some(f => f.user.id === me.id);
- const onRoster = me && (_arenaRoster?.fighters?.some(f => f.user.id === me.id) ?? true);
- const moments = t.moments || [];
+ const moments = (t.moments || []).filter(m => !thread || m.thread_id === thread);
+ const cur = (t.threads || []).find(x => x.id === thread);
root.innerHTML = `
root.innerHTML = `
<div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')}</div>
<div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')}</div>
<div class="arena-topic-page arena-topic-${t.kind} ${t.hot ? 'is-hot' : ''} ${t.status !== 'open' ? 'is-archived' : ''}">
<div class="arena-topic-page arena-topic-${t.kind} ${t.hot ? 'is-hot' : ''} ${t.status !== 'open' ? 'is-archived' : ''}">
<div class="arena-topic-head">
<div class="arena-topic-head">
<span class="arena-kind"><i class="fa-solid ${k.icon}"></i> ${k.label}${t.status !== 'open' ? ` · ${_aEsc(t.status)}` : ''}</span>
<span class="arena-kind"><i class="fa-solid ${k.icon}"></i> ${k.label}${t.status !== 'open' ? ` · ${_aEsc(t.status)}` : ''}</span>
${t.status === 'open' ? _aHeat(t, 12) : ''}
${t.status === 'open' ? _aHeat(t, 12) : ''}
- <span class="arena-topic-by">${t.created_by === 'community' ? `<i class="fa-solid fa-satellite-dish"></i> surfaced from ${_aEsc(t.source_note || 'the community')}` : `started by ${_aEsc(t.creator_name || t.created_by)}`} · ${_aEsc(_aAgo(t.created_at))}${t.expires_at && t.status === 'open' ? ` · <span class="arena-expires" data-until="${_aEsc(t.expires_at)}">${_aEsc(_aClock((Date.parse(t.expires_at) - Date.now()) / 1000))} left</span>` : ''}</span>
+ <span class="arena-topic-by">${t.created_by === 'community' ? `<i class="fa-solid fa-satellite-dish"></i> ${_aEsc(t.source_note || 'the site')}` : `started by ${_aEsc(t.creator_name || t.created_by)}`} · ${_aEsc(_aAgo(t.created_at))}${t.expires_at && t.status === 'open' ? ` · <span class="arena-expires" data-until="${_aEsc(t.expires_at)}">${_aEsc(_aClock((Date.parse(t.expires_at) - Date.now()) / 1000))} left</span>` : ''}</span>
</div>
</div>
<h1 class="arena-topic-headline-big">${_aEsc(t.headline || t.text)} ${_aSpeakBtn(t.headline || t.text, '')}</h1>
<h1 class="arena-topic-headline-big">${_aEsc(t.headline || t.text)} ${_aSpeakBtn(t.headline || t.text, '')}</h1>
- <div class="arena-featured-subject">${_aTopicTitle(t)}${t.tagline ? ` <span class="arena-tagline">— ${_aEsc(t.tagline)}</span>` : ''}</div>
+ <div class="arena-subject-sub"><b>${_aTopicTitle(t)}</b>${t.tagline ? ` — ${_aEsc(t.tagline)}` : ''}</div>
${t.submitted_text && t.submitted_text.toLowerCase() !== t.text.toLowerCase() ? `<p class="arena-note">as typed by ${_aEsc(t.creator_name || 'someone')}: “${_aEsc(t.submitted_text)}”</p>` : ''}
${t.submitted_text && t.submitted_text.toLowerCase() !== t.text.toLowerCase() ? `<p class="arena-note">as typed by ${_aEsc(t.creator_name || 'someone')}: “${_aEsc(t.submitted_text)}”</p>` : ''}
${t.resolved?.headline ? `<div class="arena-topic-result"><i class="fa-solid fa-flag-checkered"></i> ${_aEsc(t.resolved.headline)}</div>` : ''}
${t.resolved?.headline ? `<div class="arena-topic-result"><i class="fa-solid fa-flag-checkered"></i> ${_aEsc(t.resolved.headline)}</div>` : ''}
+ ${t.kind === 'bounty' && t.target ? `<div class="arena-bounty-target">${_aBriefChip(t.target, ' · the mark')}<span class="arena-note">Any fighter who lands judged shit talk on ${_aEsc(t.target.fighter_name)} while this is open gets <b>double XP</b>.</span></div>` : ''}
+ <div class="arena-strip">
+ ${_aMentionChips(t)}
+ ${t.fighters.length ? `<span class="arena-strip-people">${_aAvatarStack(t.fighters, 6)} <small>${t.fighters.length} fighter${t.fighters.length === 1 ? '' : 's'} heard on it</small></span>` : '<small class="arena-note">no fighter has said it on mic yet — the ears auto-detect it the moment one does</small>'}
+ </div>
+ ${t.lore ? `<section class="arena-story"><h3><i class="fa-solid fa-book-skull"></i> Story so far <small>${t.lore_updated_at ? _aEsc(_aAgo(t.lore_updated_at)) : ''}</small></h3><p class="arena-lore-p">${_aEsc(t.lore)}</p></section>` : ''}
+ <div class="arena-tabs">
+ <button class="arena-tab ${!thread ? 'is-on' : ''}" data-thread="0">All <b>${(t.moments || []).length}</b></button>
+ ${(t.threads || []).map(x => `<button class="arena-tab ${thread === x.id ? 'is-on' : ''}" data-thread="${x.id}" id="thread-${x.id}" title="${_aEsc(x.hint || '')}">${_aEsc(x.name)} <b>${x.moments}</b></button>`).join('')}
+ </div>
+ ${cur && cur.hint ? `<p class="arena-note arena-tab-hint"><i class="fa-solid fa-angle-right"></i> ${_aEsc(cur.hint)}</p>` : ''}
<div class="arena-topic-cols">
<div class="arena-topic-cols">
- <div class="arena-topic-main">
- <section class="arena-lore-box">
- <h3><i class="fa-solid fa-book-skull"></i> Lore <small>${t.lore_updated_at ? `rewritten ${_aEsc(_aAgo(t.lore_updated_at))}` : 'not written yet'}</small></h3>
- <p class="arena-lore-p arena-lore-big">${_aEsc(t.lore || t.hint || 'Nothing on record. Say something about it in chat or on mic and the lore writes itself.')}</p>
- ${t.keywords?.length ? `<div class="arena-keywords"><span class="arena-note">the words that count:</span>${t.keywords.map(k => `<span>${_aEsc(k)}</span>`).join('')}</div>` : ''}
- </section>
- ${t.kind === 'bounty' && t.target ? `<div class="arena-bounty-target">${_aBriefChip(t.target, ' · the mark')}<span class="arena-note">Any fighter who lands judged shit talk on ${_aEsc(t.target.fighter_name)} while this is open gets <b>double XP</b>. The mark can answer back on their own stream.</span></div>` : ''}
- <div class="arena-topic-cta">
- ${onRoster && t.status === 'open' && t.kind !== 'bounty' ? (onIt ? `<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-microphone"></i> You're on this — talk on stream, the ears are listening</span> <button class="btn btn-ghost btn-sm" id="arena-topic-leave">Leave</button> ${_aA(_aConsoleLink(me), '<i class="fa-solid fa-ear-listen"></i> my ears', 'btn btn-ghost btn-sm')}` : `<button class="btn btn-primary" id="arena-topic-join"><i class="fa-solid fa-microphone"></i> Talk on this</button><span class="arena-note">Or just say it on mic — the ears pick it up either way. Every judged chunk = a moment + XP.</span>`) : ''}
- ${!me ? '<span class="arena-note">Sign in to talk on this (streamers) or hype whoever\'s on it. Anyone can pile on from chat.</span>' : ''}
- </div>
- <section class="arena-moments">
- <h3><i class="fa-solid fa-stream"></i> Moments <small>${moments.length} · chat and on-mic, newest first</small></h3>
- ${moments.length ? `<div class="arena-moment-list">${moments.map(m => _aMomentLine(m)).join('')}</div>` : '<p class="arena-note">Nothing yet.</p>'}
- </section>
- </div>
+ <section class="arena-moments">
+ <h3><i class="fa-solid fa-stream"></i> ${cur ? _aEsc(cur.name) : 'Moments'} <small>${moments.length} · chat and on-mic, newest first</small></h3>
+ ${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">
<aside class="arena-topic-aside">
- <section>
- <h3><i class="fa-solid fa-microphone-lines"></i> Fighters on it <small>${t.talking_now.length} live</small></h3>
- ${t.fighters.length ? 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('') : '<p class="arena-note">No streamer has touched it yet.</p>'}
- </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>${_aEsc(c.username)}</strong></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 lines</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>` : ''}
+ ${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>` : ''}
+ <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>
</aside>
</aside>
</div>
</div>
</div>`;
</div>`;
- document.getElementById('arena-topic-join')?.addEventListener('click', async () => { try { await api(`/arena/board/topics/${t.id}/join`, { method: 'POST' }); t = await api(`/arena/board/topics/${id}`); _aToast('🎤 You\'re on it. Go talk.', 'success'); draw(); } catch (err) { _aToast(err?.message || 'Failed', 'error'); } });
- document.getElementById('arena-topic-leave')?.addEventListener('click', async () => { try { await api(`/arena/board/topics/${t.id}/leave`, { method: 'POST' }); t = await api(`/arena/board/topics/${id}`); draw(); } catch (err) { _aToast(err?.message || 'Failed', 'error'); } });
+ root.querySelectorAll('.arena-tab').forEach(b => b.onclick = () => { thread = Number(b.dataset.thread) || 0; draw(); });
_aBindHome(root);
_aBindHome(root);
};
};
draw();
draw();
@@ -796,7 +752,7 @@ async function _aRenderConsole(root, username) {
@@ -796,7 +752,7 @@ async function _aRenderConsole(root, username) {
${c.active_topic ? `${_aA(_aTopicLink(c.active_topic), `<b>${_aEsc(c.active_topic.headline || c.active_topic.text)}</b>`)}
${c.active_topic ? `${_aA(_aTopicLink(c.active_topic), `<b>${_aEsc(c.active_topic.headline || c.active_topic.text)}</b>`)}
<p class="arena-lore-p is-clamp">${_aEsc(c.active_topic.lore || c.active_topic.tagline || c.active_topic.hint || '')}</p>
<p class="arena-lore-p is-clamp">${_aEsc(c.active_topic.lore || c.active_topic.tagline || c.active_topic.hint || '')}</p>
${c.active_topic.keywords?.length ? `<div class="arena-keywords">${c.active_topic.keywords.slice(0, 6).map(k => `<span>${_aEsc(k)}</span>`).join('')}</div>` : ''}
${c.active_topic.keywords?.length ? `<div class="arena-keywords">${c.active_topic.keywords.slice(0, 6).map(k => `<span>${_aEsc(k)}</span>`).join('')}</div>` : ''}
- <small class="arena-note">${(c.active_topic.fighters || []).find(f => f.user.id === f0(c))?.moments || 0} of your on-mic moments on it</small>` : `<p class="arena-note">${mine ? 'No active subject. Pick one on the board — or just say one on mic, the ears auto-join you.' : 'Not on a board subject.'}</p>${mine ? _aA('/arena', 'Open the board', 'btn btn-ghost btn-sm') : ''}`}
+ <small class="arena-note">${(c.active_topic.fighters || []).find(f => f.user.id === f0(c))?.moments || 0} of your on-mic moments on it</small>` : `<p class="arena-note">${mine ? 'Nothing detected yet — say any board subject on mic and the ears file it here.' : 'Not heard on a board subject yet.'}</p>${mine ? _aA('/arena', 'See the board', 'btn btn-ghost btn-sm') : ''}`}
</section>
</section>
<section>
<section>
<h3><i class="fa-solid fa-fire-flame-curved"></i> Open beefs</h3>
<h3><i class="fa-solid fa-fire-flame-curved"></i> Open beefs</h3>
@@ -874,7 +830,7 @@ async function _aRenderFighter(root, username) {
@@ -874,7 +830,7 @@ async function _aRenderFighter(root, username) {
${_aPortrait(f, 'lg')}
${_aPortrait(f, 'lg')}
${f.image_pending ? '<div class="arena-portrait-pending"><i class="fa-solid fa-wand-magic-sparkles fa-fade"></i> painting portrait…</div>' : ''}
${f.image_pending ? '<div class="arena-portrait-pending"><i class="fa-solid fa-wand-magic-sparkles fa-fade"></i> painting portrait…</div>' : ''}
<div class="arena-profile-rank">#${f.rank} <small>of ${f.roster_size}</small></div>
<div class="arena-profile-rank">#${f.rank} <small>of ${f.roster_size}</small></div>
- ${f.active_topic ? `<div class="arena-mini-record"><i class="fa-solid fa-comment-dots"></i> Talking on: ${_aA(_aTopicLink(f.active_topic), _aEsc(f.active_topic.text))}</div>` : ''}
+ ${f.active_topic ? `<div class="arena-mini-record"><i class="fa-solid fa-comment-dots"></i> Last heard on: ${_aA(_aTopicLink(f.active_topic), _aEsc(f.active_topic.text))}</div>` : ''}
</div>
</div>
<div class="arena-profile-main">
<div class="arena-profile-main">
<div class="arena-profile-name">
<div class="arena-profile-name">
@@ -98,7 +98,7 @@ function handle(chat, ws, client, cmd, parts) {
@@ -98,7 +98,7 @@ function handle(chat, ws, client, cmd, parts) {
return;
return;
}
}
const t = board.activeTopicFor(streamer.id);
const t = board.activeTopicFor(streamer.id);
- if (!t) return reply(`${streamer.display_name || streamer.username} has no beef open and isn't on a board topic. Start one: !topic <text> · ${base()}/arena`);
+ if (!t) return reply(`${streamer.display_name || streamer.username} has no beef open and hasn't been heard on a board subject yet — the ears auto-detect it when they say one. ${base()}/arena`);
const r = board.hypeTopic(t.id, streamer.id, key);
const r = board.hypeTopic(t.id, streamer.id, key);
if (!r.added) return reply(`You already hyped them on “${t.text}”. ${r.hypers} hyping.`);
if (!r.added) return reply(`You already hyped them on “${t.text}”. ${r.hypers} hyping.`);
reply(`🔥 Hyped ${streamer.display_name || streamer.username} on “${t.text}” — ${r.hypers} hyping → ${base()}/arena/topic/${t.id}`);
reply(`🔥 Hyped ${streamer.display_name || streamer.username} on “${t.text}” — ${r.hypers} hyping → ${base()}/arena/topic/${t.id}`);
@@ -96,7 +96,19 @@ function ensureTables() {
@@ -96,7 +96,19 @@ 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 */ } }
+ 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 */ } }
+ // 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,
+ topic_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ hint TEXT,
+ keywords_json TEXT,
+ moments INTEGER DEFAULT 0,
+ last_at DATETIME,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`);
+ db.run('CREATE INDEX IF NOT EXISTS idx_arena_threads_topic ON arena_topic_threads (topic_id)');
db.run('CREATE INDEX IF NOT EXISTS idx_arena_moments_chatter ON arena_topic_moments (chatter_key, id)');
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,
@@ -224,7 +236,7 @@ function matchTopics(text) {
@@ -224,7 +236,7 @@ function matchTopics(text) {
// ── Topics ───────────────────────────────────────────────────
// ── Topics ───────────────────────────────────────────────────
-function createTopic({ text, hint = null, createdBy, creatorUserId = null, creatorName = null, creatorIp = null, kind = 'topic', targetUserId = null, headline = null, sourceNote = null, keywords = null, tagline = null, origin = null, submittedText = null }) {
+function createTopic({ text, hint = null, createdBy, creatorUserId = null, creatorName = null, creatorIp = null, kind = 'topic', targetUserId = null, headline = null, sourceNote = null, keywords = null, tagline = null, origin = null, submittedText = null, threads = null }) {
ensureTables();
ensureTables();
const clean = cleanTopicText(text);
const clean = cleanTopicText(text);
kind = ['topic', 'bounty'].includes(kind) ? kind : 'topic';
kind = ['topic', 'bounty'].includes(kind) ? kind : 'topic';
@@ -232,7 +244,9 @@ function createTopic({ text, hint = null, createdBy, creatorUserId = null, creat
@@ -232,7 +244,9 @@ function createTopic({ text, hint = null, createdBy, creatorUserId = null, creat
if (kind === 'bounty' && db.get(`SELECT id FROM arena_topics WHERE status = 'open' AND kind = 'bounty' AND target_user_id = ?`, [targetUserId])) throw new Error('There is already a bounty on them');
if (kind === 'bounty' && db.get(`SELECT id FROM arena_topics WHERE status = 'open' AND kind = 'bounty' AND target_user_id = ?`, [targetUserId])) throw new Error('There is already a bounty on them');
const dup = db.get(`SELECT id FROM arena_topics WHERE status = 'open' AND LOWER(text) = LOWER(?)`, [clean]);
const dup = db.get(`SELECT id FROM arena_topics WHERE status = 'open' AND LOWER(text) = LOWER(?)`, [clean]);
if (dup) throw new Error('That topic is already on the board');
if (dup) throw new Error('That topic is already on the board');
- let kws = normalizeKeywords(keywords);
+ const threadList = (Array.isArray(threads) ? threads : []).map(t => ({ name: clip(t?.name, 40), hint: t?.hint ? clip(t.hint, 90) : null, keywords: normalizeKeywords(t?.keywords || []) })).filter(t => t.name).slice(0, 6);
+ for (const t of threadList) if (!t.keywords.length) t.keywords = keywordsFromText(t.name);
+ let kws = normalizeKeywords([...(Array.isArray(keywords) ? keywords : []), ...threadList.flatMap(t => t.keywords)]);
if (!kws.length) kws = keywordsFromText(clean);
if (!kws.length) kws = keywordsFromText(clean);
if (kind === 'bounty' && targetUserId) { const u = db.getUserById(targetUserId); if (u) { const nm = require('./names'); kws = normalizeKeywords([...kws, u.username, u.display_name, ...nm.variants(u.username), ...nm.variants(u.display_name)]); } }
if (kind === 'bounty' && targetUserId) { const u = db.getUserById(targetUserId); if (u) { const nm = require('./names'); kws = normalizeKeywords([...kws, u.username, u.display_name, ...nm.variants(u.username), ...nm.variants(u.display_name)]); } }
if (kind === 'topic' && kws.length >= 2) {
if (kind === 'topic' && kws.length >= 2) {
@@ -245,8 +259,9 @@ function createTopic({ text, hint = null, createdBy, creatorUserId = null, creat
@@ -245,8 +259,9 @@ function createTopic({ text, hint = null, createdBy, creatorUserId = null, creat
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
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]);
[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');
const row = db.get('SELECT * FROM arena_topics ORDER BY id DESC LIMIT 1');
+ 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();
invalidateMatchers();
- console.log(`[Arena] ${kind} #${row.id} by ${createdBy}${creatorName ? ' ' + creatorName : ''}: "${clean}" [${kws.join(', ')}]`);
+ console.log(`[Arena] ${kind} #${row.id} by ${createdBy}${creatorName ? ' ' + creatorName : ''}: "${clean}" [${kws.join(', ')}]${threadList.length ? ` threads: ${threadList.map(t => t.name).join(' | ')}` : ''}`);
return row;
return row;
}
}
@@ -265,14 +280,15 @@ function assertCanSubmit(userId, ip) {
@@ -265,14 +280,15 @@ function assertCanSubmit(userId, ip) {
}
}
const REFINE_SCHEMA = { name: 'arena_refine_topic', schema: { type: 'object', additionalProperties: false, required: ['subject', 'headline', 'tagline', 'hint', 'keywords', 'reject'], properties: {
const REFINE_SCHEMA = { name: 'arena_refine_topic', schema: { type: 'object', additionalProperties: false, required: ['subject', 'headline', 'tagline', 'hint', 'keywords', 'reject'], properties: {
- subject: { type: 'string', description: 'the subject as a short noun phrase ≤ 70 chars, punchy, dumb-funny' },
+ subject: { type: 'string', description: 'the umbrella subject, 1–4 words, punchy, dumb-funny' },
headline: { type: 'string', description: 'tabloid headline ≤ 90 chars, inflammatory and stupid in a good way' },
headline: { type: 'string', description: 'tabloid headline ≤ 90 chars, inflammatory and stupid in a good way' },
tagline: { type: 'string', description: '≤ 100 chars, who submitted it and why everyone should care' },
tagline: { type: 'string', description: '≤ 100 chars, who submitted it and why everyone should care' },
hint: { type: 'string', description: '≤ 90 chars: how to pile on / bait for streamers' },
hint: { type: 'string', description: '≤ 90 chars: how to pile on / bait for streamers' },
keywords: { type: 'array', minItems: 2, maxItems: 8, items: { type: 'string' }, description: 'lowercase words/slang people would actually type or say about it' },
keywords: { type: 'array', minItems: 2, maxItems: 8, items: { type: 'string' }, description: 'lowercase words/slang people would actually type or say about it' },
+ threads: { type: 'array', minItems: 2, maxItems: 4, items: { type: 'object', additionalProperties: false, required: ['name', 'keywords', 'hint'], properties: { name: { type: 'string', description: '2–5 words, a sub-angle inside the subject' }, keywords: { type: 'array', minItems: 1, maxItems: 4, items: { type: 'string' } }, hint: { type: 'string', description: '≤ 70 chars' } } } },
reject: { type: 'boolean', description: 'true ONLY if it is a direct threat of violence, sexual content about a minor, or doxxing' },
reject: { type: 'boolean', description: 'true ONLY if it is a direct threat of violence, sexual content about a minor, or doxxing' },
} } };
} } };
-const REFINE_SYSTEM = `A user typed a topic for the shit-talking board of a live-streaming site. Rewrite it into a proper board SUBJECT that looks good and baits people: a short noun-phrase subject, a tabloid headline, a tagline crediting the submitter, a hint on how to pile on, and the keywords people would actually type or say about it (slang, variants, misspellings). Make it dumber, funnier and more provocative than what they typed; keep their actual meaning. Slurs, profanity and taboo topics are allowed and never a reason to reject — this platform does not censor language. Reject ONLY direct threats of violence, sexual content about minors, or doxxing. Output only the JSON.`;
+const REFINE_SYSTEM = `A user typed a topic for the shit-talking board of a live-streaming site. Rewrite it into a proper board SUBJECT that looks good and baits people: a short, VAGUE umbrella subject (1–4 words), a tabloid headline, a tagline crediting the submitter, a hint on how to pile on, the keywords people would actually type or say about it (slang, variants, misspellings), and 2–4 THREADS — the specific angles inside it people will argue. Make it dumber, funnier and more provocative than what they typed; keep their actual meaning. Slurs, profanity and taboo topics are allowed and never a reason to reject — this platform does not censor language. Reject ONLY direct threats of violence, sexual content about minors, or doxxing. Output only the JSON.`;
/** A user-submitted topic → the AI makes it look good (one call). Falls back to the raw text. */
/** A user-submitted topic → the AI makes it look good (one call). Falls back to the raw text. */
async function submitTopic({ text, userId, ip, creatorName, onRoster = false }) {
async function submitTopic({ text, userId, ip, creatorName, onRoster = false }) {
@@ -286,7 +302,7 @@ async function submitTopic({ text, userId, ip, creatorName, onRoster = false })
@@ -286,7 +302,7 @@ async function submitTopic({ text, userId, ip, creatorName, onRoster = false })
if (r && r.json) { if (r.json.reject) throw new Error('That crosses the line (threats, minors, doxxing)'); refined = r.json; }
if (r && r.json) { if (r.json.reject) throw new Error('That crosses the line (threats, minors, doxxing)'); refined = r.json; }
} catch (e) { if (/crosses the line/.test(e.message)) throw e; console.warn('[Arena] refine topic:', e.message); }
} catch (e) { if (/crosses the line/.test(e.message)) throw e; console.warn('[Arena] refine topic:', e.message); }
}
}
- 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, threads: refined?.threads || null, submittedText: raw });
backfillMoments([t]);
backfillMoments([t]);
try { require('./chatters').onSubjectStarted(`user:${userId}`, t.id, { display: creatorName }); } catch { /* */ }
try { require('./chatters').onSubjectStarted(`user:${userId}`, t.id, { display: creatorName }); } catch { /* */ }
return t;
return t;
@@ -301,8 +317,10 @@ function addMoment(topicId, { kind, source, userId = null, username = null, stre
@@ -301,8 +317,10 @@ function addMoment(topicId, { kind, source, userId = null, username = null, stre
if (!body || arena()._isBannedText(body)) return null;
if (!body || arena()._isBannedText(body)) return null;
const chatters = require('./chatters');
const chatters = require('./chatters');
const chatterKey = chatters.keyFor({ user_id: userId, username, anon_id: anonId, source_platform: platform });
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]);
+ 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]);
+ 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 = 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]);
@@ -314,6 +332,31 @@ function addMoment(topicId, { kind, source, userId = null, username = null, stre
@@ -314,6 +332,31 @@ function addMoment(topicId, { kind, source, userId = null, username = null, stre
return m;
return m;
}
}
+/** Which thread of a subject a line belongs to (first thread whose keywords match), or null. */
+let _threadCache = { at: 0, byTopic: new Map() };
+function threadsOf(topicId) {
+ if (Date.now() - _threadCache.at > 20 * 1000) { _threadCache = { at: Date.now(), byTopic: new Map() }; }
+ if (!_threadCache.byTopic.has(topicId)) _threadCache.byTopic.set(topicId, db.all('SELECT * FROM arena_topic_threads WHERE topic_id = ? ORDER BY id ASC', [topicId]).map(t => ({ ...t, res: normalizeKeywords(parseJson(t.keywords_json, [])).map(keywordRegex) })));
+ return _threadCache.byTopic.get(topicId);
+}
+function threadFor(topicId, text) { for (const t of threadsOf(topicId)) if (t.res.some(re => re.test(text))) return t.id; return null; }
+function upsertThreads(topicId, threads) {
+ const existing = db.all('SELECT id, name, keywords_json FROM arena_topic_threads WHERE topic_id = ?', [topicId]);
+ let added = 0;
+ for (const t of (Array.isArray(threads) ? threads : []).slice(0, 8)) {
+ const name = clip(t?.name, 40); if (!name) continue;
+ const kws = normalizeKeywords(t?.keywords || []); if (!kws.length) kws.push(...keywordsFromText(name));
+ const same = existing.find(e => e.name.toLowerCase() === name.toLowerCase() || normalizeKeywords(parseJson(e.keywords_json, [])).some(k => kws.includes(k)));
+ if (same) { const merged = normalizeKeywords([...(parseJson(same.keywords_json, []) || []), ...kws]); db.run('UPDATE arena_topic_threads SET keywords_json = ?, hint = COALESCE(?, hint) WHERE id = ?', [JSON.stringify(merged), t?.hint ? clip(t.hint, 90) : null, same.id]); continue; }
+ if (existing.length + added >= 6) continue;
+ db.run('INSERT INTO arena_topic_threads (topic_id, name, hint, keywords_json) VALUES (?, ?, ?, ?)', [topicId, name, t?.hint ? clip(t.hint, 90) : null, JSON.stringify(kws)]); added++;
+ }
+ if (added) { _threadCache.at = 0; }
+ // Re-file moments that have no thread yet.
+ for (const m of db.all('SELECT id, text FROM arena_topic_moments WHERE topic_id = ? AND thread_id IS NULL ORDER BY id DESC LIMIT 200', [topicId])) { const tid = threadFor(topicId, m.text); if (tid) { db.run('UPDATE arena_topic_moments SET thread_id = ? WHERE id = ?', [tid, m.id]); db.run('UPDATE arena_topic_threads SET moments = moments + 1 WHERE id = ?', [tid]); } }
+ return added;
+}
+
/** 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. */
function noteMicMention(topicId, { userId, username, streamId, vodId, sec, text }) {
function noteMicMention(topicId, { userId, username, streamId, vodId, sec, text }) {
const recent = db.get(`SELECT id FROM arena_topic_moments WHERE topic_id = ? AND stream_id = ? AND kind = 'speech' AND created_at >= datetime('now', ?)`, [topicId, streamId, `-${MENTION_COOLDOWN_SEC} seconds`]);
const recent = db.get(`SELECT id FROM arena_topic_moments WHERE topic_id = ? AND stream_id = ? AND kind = 'speech' AND created_at >= datetime('now', ?)`, [topicId, streamId, `-${MENTION_COOLDOWN_SEC} seconds`]);
@@ -436,16 +479,17 @@ function scanChat() {
@@ -436,16 +479,17 @@ function scanChat() {
const DISCOVER_SCHEMA = { name: 'arena_discover', schema: { type: 'object', additionalProperties: false, required: ['pulse', 'topics', 'bounties'], properties: {
const DISCOVER_SCHEMA = { name: 'arena_discover', schema: { type: 'object', additionalProperties: false, required: ['pulse', 'topics', 'bounties'], properties: {
pulse: { type: 'string', description: 'One sentence ≤ 140 chars: what the community is on about right now, dumb hype-caster voice, name names, funny' },
pulse: { type: 'string', description: 'One sentence ≤ 140 chars: what the community is on about right now, dumb hype-caster voice, name names, funny' },
topics: { type: 'array', minItems: 0, maxItems: 4, items: { type: 'object', additionalProperties: false, required: ['subject', 'headline', 'tagline', 'keywords', 'source', 'hint'], properties: {
topics: { type: 'array', minItems: 0, maxItems: 4, items: { type: 'object', additionalProperties: false, required: ['subject', 'headline', 'tagline', 'keywords', 'source', 'hint'], properties: {
- subject: { type: 'string', description: 'The SUBJECT people are talking about, as a short noun phrase ≤ 70 chars — e.g. "Pakistanis", "the cat enema joke", "Maticus\'s vet bill", "Meshtastic tent streams". Not a question, not a prompt.' },
- headline: { type: 'string', description: 'Tabloid headline ≤ 90 chars, inflammatory, stupid-funny' },
- tagline: { type: 'string', description: '≤ 100 chars: current state of it — who started it, who is on which end' },
- keywords: { type: 'array', minItems: 2, maxItems: 8, items: { type: 'string' }, description: 'lowercase words/short phrases people actually use for this subject (incl. slang and misspellings) — used to match future chat and speech' },
+ subject: { type: 'string', description: 'The UMBRELLA subject, 1–4 words, a noun: the thing, person, group, joke or drama — e.g. "Cat enema drama", "Pakistanis", "Goosely", "Tent streams". Vague on purpose; the detail lives in threads.' },
+ headline: { type: 'string', description: 'Tabloid headline ≤ 70 chars, inflammatory, stupid-funny' },
+ tagline: { type: 'string', description: '≤ 80 chars: where it stands — who started it, who is on which end' },
+ keywords: { type: 'array', minItems: 2, maxItems: 6, items: { type: 'string' }, description: 'lowercase words/short phrases people use for the umbrella subject (slang, misspellings)' },
+ threads: { type: 'array', minItems: 2, maxItems: 4, items: { type: 'object', additionalProperties: false, required: ['name', 'keywords', 'hint'], properties: { name: { type: 'string', description: 'a sub-angle inside the subject, 2–5 words ("the vet bill", "the bot roasts", "global vs local")' }, keywords: { type: 'array', minItems: 1, maxItems: 4, items: { type: 'string' } }, hint: { type: 'string', description: '≤ 70 chars, how to pile on this thread' } } }, description: 'the 2–4 threads people are actually arguing inside the subject' },
source: { type: 'string', description: '≤ 6 words: where it came from ("global chat", "Goosely\'s stream")' },
source: { type: 'string', description: '≤ 6 words: where it came from ("global chat", "Goosely\'s stream")' },
- hint: { type: 'string', description: '≤ 90 chars, how to pile on' },
+ hint: { type: 'string', description: '≤ 70 chars, how to pile on' },
} } },
} } },
bounties: { type: 'array', minItems: 0, maxItems: 1, items: { type: 'object', additionalProperties: false, required: ['target_username', 'headline', 'why'], properties: { target_username: { type: 'string', description: 'exact username from the snapshot the community keeps naming/dunking on' }, headline: { type: 'string' }, why: { type: 'string', description: '≤ 90 chars' } } } },
bounties: { type: 'array', minItems: 0, maxItems: 1, items: { type: 'object', additionalProperties: false, required: ['target_username', 'headline', 'why'], properties: { target_username: { type: 'string', description: 'exact username from the snapshot the community keeps naming/dunking on' }, headline: { type: 'string' }, why: { type: 'string', description: '≤ 90 chars' } } } },
} } };
} } };
-const DISCOVER_SYSTEM = `You are the community consciousness of a live-streaming site's Arena — part tabloid editor, part fight promoter, part 4chan ragebaiter with a head injury. You get what people ACTUALLY said in the last while: global + stream chat lines and what streamers said on mic, plus the subjects already on the board. Find the SUBJECTS the community is really on about — a group of people, a person, a running joke, an argument, a product, a drama, a meme — the more provocative, taboo, petty or stupid the better; nothing generic, nothing wholesome. Ignore small talk. Do not repeat subjects already on the board (match by meaning). For each: a noun-phrase subject, a tabloid headline, a tagline (who started it, who is where on it), the keywords/slang people use for it, the source, and a hint on how to pile on. If the community keeps naming one specific streamer (dunking, defending, calling out), add ONE bounty on that exact username. Profanity, disrespect, slurs and trolling are welcome and are never a reason to skip a subject — this platform does not censor language. Hard line: no direct threats of violence, nothing sexual about minors, no doxxing. Return empty arrays if nothing real is going on. Output only the JSON.`;
+const DISCOVER_SYSTEM = `You are the community consciousness of a live-streaming site's Arena — a regular who has been in this chat for years, not a copywriter. VOICE RULES: write the way the people in the sample lines type — same slang, same casing (if chat is lowercase, you are lowercase), short, blunt, funny. No tabloid adjective soup, no invented compound words (no "roastmare", no "chaos escalates"), no exclamation marks for hype, no "epic"/"wild"/"erupts"/"explodes". A headline should read like something a chatter would actually type as a title ("goosely is losing the cat enema war", "nobody asked about the vet bill"), ≤ 60 chars. Use people's names exactly as they appear. You get what people ACTUALLY said in the last while: global + stream chat lines and what streamers said on mic, plus the subjects already on the board. Find the UMBRELLA SUBJECTS the community is really on about — a group of people, a person, a running joke, an argument, a product, a drama, a meme — the more provocative, taboo, petty or stupid the better; nothing generic, nothing wholesome. Keep subjects VAGUE and short (1–4 words) and put the detail into 2–4 THREADS inside each (the specific angles people are arguing). Ignore small talk. Do not repeat subjects already on the board (match by meaning — if it fits an existing subject, skip it, the lore rewrite adds threads there). For each: the subject, a tabloid headline, a tagline (who started it, who is where on it), the keywords/slang people use for it, the threads, the source, and a hint on how to pile on. If the community keeps naming one specific streamer (dunking, defending, calling out), add ONE bounty on that exact username. Profanity, disrespect, slurs and trolling are welcome and are never a reason to skip a subject — this platform does not censor language. Hard line: no direct threats of violence, nothing sexual about minors, no doxxing. Return empty arrays if nothing real is going on. Output only the JSON.`;
function discoverInput() {
function discoverInput() {
const out = { at: new Date().toISOString() };
const out = { at: new Date().toISOString() };
@@ -469,7 +513,7 @@ function heuristicDiscover(input) {
@@ -469,7 +513,7 @@ function heuristicDiscover(input) {
}
}
const existing = new Set(openTopicMatchers().flatMap(m => m.keywords));
const existing = new Set(openTopicMatchers().flatMap(m => m.keywords));
return [...counts.entries()].filter(([w, c]) => c.n >= 4 && c.people.size >= 3 && !existing.has(w)).sort((a, b) => b[1].n - a[1].n).slice(0, 2)
return [...counts.entries()].filter(([w, c]) => c.n >= 4 && c.people.size >= 3 && !existing.has(w)).sort((a, b) => b[1].n - a[1].n).slice(0, 2)
- .map(([w, c]) => ({ subject: w.charAt(0).toUpperCase() + w.slice(1), headline: `Everyone is suddenly on about "${w}"`, tagline: `${c.people.size} people, ${c.n} mentions in ${SCAN_WINDOW_MIN} minutes`, keywords: [w], source: 'chat burst', hint: 'Say it on mic. Make it worse.' }));
+ .map(([w, c]) => ({ subject: w.charAt(0).toUpperCase() + w.slice(1), headline: `Everyone is suddenly on about "${w}"`, tagline: `${c.people.size} people, ${c.n} mentions in ${SCAN_WINDOW_MIN} minutes`, keywords: [w], threads: [{ name: `why ${w}`, keywords: [w], hint: 'Say it on mic. Make it worse.' }], source: 'chat burst', hint: 'Say it on mic. Make it worse.' }));
}
}
let _pulse = { text: null, at: null, sources: [] };
let _pulse = { text: null, at: null, sources: [] };
@@ -497,7 +541,7 @@ async function discoverTopics({ force = false } = {}) {
@@ -497,7 +541,7 @@ async function discoverTopics({ force = false } = {}) {
let made = 0;
let made = 0;
const created = [];
const created = [];
for (const t of topics.slice(0, 4)) {
for (const t of topics.slice(0, 4)) {
- try { created.push(createTopic({ text: t.subject, hint: t.hint, createdBy: 'community', creatorName: clip(t.source || 'the community', 40), headline: t.headline, tagline: t.tagline, sourceNote: t.source, keywords: t.keywords })); made++; } catch { /* dup */ }
+ try { created.push(createTopic({ text: t.subject, hint: t.hint, createdBy: 'community', creatorName: clip(t.source || 'the community', 40), headline: t.headline, tagline: t.tagline, sourceNote: t.source, keywords: t.keywords, threads: t.threads })); made++; } catch { /* dup */ }
}
}
for (const b of bounties.slice(0, 1)) {
for (const b of bounties.slice(0, 1)) {
try {
try {
@@ -528,12 +572,32 @@ function pulse() { const p = loadPulse(); return { text: p.text, at: p.at ? new
@@ -528,12 +572,32 @@ function pulse() { const p = loadPulse(); return { text: p.text, at: p.at ? new
// ── Lore ─────────────────────────────────────────────────────
// ── Lore ─────────────────────────────────────────────────────
const LORE_SCHEMA = { name: 'arena_lore', schema: { type: 'object', additionalProperties: false, required: ['lore', 'headline', 'tagline', 'keywords'], properties: {
const LORE_SCHEMA = { name: 'arena_lore', schema: { type: 'object', additionalProperties: false, required: ['lore', 'headline', 'tagline', 'keywords'], properties: {
- lore: { type: 'string', description: '3–6 sentences. The evolving story of this subject on the site: who brought it up and where, who said what (quote the best lines verbatim, name usernames), who is on which end of it, how it escalated, current state. Tabloid / fight-promoter voice, dumb, petty, funny.' },
- headline: { type: 'string', description: 'Updated tabloid headline ≤ 90 chars' },
- tagline: { type: 'string', description: '≤ 100 chars: where it stands right now' },
+ lore: { type: 'string', description: 'SHORT: 2–4 punchy sentences, ≤ 450 characters total. The story so far: who brought it up, the wildest line (quote it verbatim with the username), who is on which end, the latest twist. Tabloid / fight-promoter voice, dumb, petty, funny. No filler, no recap of every line.' },
+ headline: { type: 'string', description: 'Updated tabloid headline ≤ 70 chars' },
+ tagline: { type: 'string', description: '≤ 80 chars: where it stands right now' },
keywords: { type: 'array', minItems: 2, maxItems: 10, items: { type: 'string' }, description: 'updated lowercase keywords/slang people use for it (keep the good existing ones)' },
keywords: { type: 'array', minItems: 2, maxItems: 10, items: { type: 'string' }, description: 'updated lowercase keywords/slang people use for it (keep the good existing ones)' },
+ threads: { type: 'array', minItems: 1, maxItems: 5, items: { type: 'object', additionalProperties: false, required: ['name', 'keywords', 'hint'], properties: { name: { type: 'string', description: '2–5 words' }, keywords: { type: 'array', minItems: 1, maxItems: 4, items: { type: 'string' } }, hint: { type: 'string', description: '≤ 70 chars' } } }, description: 'the threads (sub-angles) inside this subject — keep the existing ones by name, add new angles that emerged in the new moments' },
} } };
} } };
-const LORE_SYSTEM = `You write and update the LORE of a subject the community of a live-streaming site is talking about. You get the subject, the previous lore (if any), and the newest moments: chat lines and on-mic lines with usernames. Rewrite the lore so it tells the whole story so far and the new developments — who started it, who said the wildest things (quote them verbatim with their username), who is defending, who is dunking, streamers vs chat, escalations, plot twists. Keep what still matters from the previous lore, drop filler. Voice: tabloid meets fight promoter meets the dumbest guy in the group chat; provocative and funny; take the piss out of everyone. This platform does not censor language: slurs, profanity and taboo talk are allowed and should be quoted as said. Hard line: no direct threats of violence, nothing sexual about minors, no doxxing. Output only the JSON.`;
+const LORE_SYSTEM = `You write and update the LORE of a subject the community of a live-streaming site is talking about — as a regular of this chat, not a copywriter. VOICE RULES: match the way the moments are typed (slang, casing, rhythm; lowercase if chat is lowercase), blunt, dry, funny; quote real lines instead of describing them; use the site's own words; no tabloid adjective soup, no invented compound words, no "erupts"/"explodes"/"chaos"/"saga"/"epic", no exclamation marks. Use what the chat AI knows about the people involved (their known bits, grudges, history) to make it specific to THEM. You get the subject, the previous lore (if any), and the newest moments: chat lines and on-mic lines with usernames. Rewrite the lore SHORT (2–4 sentences, under 450 characters): who started it, the wildest line (verbatim, with username), who is on which end, the latest twist. Drop everything else. Also return the THREADS inside the subject: keep the existing thread names, add a new one only when a genuinely new angle shows up in the new moments. Voice: tabloid meets fight promoter meets the dumbest guy in the group chat; provocative and funny; take the piss out of everyone. This platform does not censor language: slurs, profanity and taboo talk are allowed and should be quoted as said. Hard line: no direct threats of violence, nothing sexual about minors, no doxxing. Output only the JSON.`;
+
+/** 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 { 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');
+ const seen = new Set(); const people = [];
+ for (const m of moments || []) {
+ const key = m.chatter_key || chatters.keyFor(m); if (!key || seen.has(key) || people.length >= 5) continue; seen.add(key);
+ const p = chatters.parseKey(key); let ai = null;
+ try { if (p.kind === 'user') ai = db.getChatAiSummary('user', p.user_id, 'rolling'); else if (p.kind === 'anon') ai = db.getChatAiSummary('anon', p.anon_num, 'rolling'); else if (p.kind === 'relay' && db.getRelayUser) { const ru = db.getRelayUser(p.platform, p.username); if (ru) ai = db.getChatAiSummary('relay', ru.id, 'rolling'); } } catch { /* */ }
+ if (ai) { const ov = parseJson(ai.overview, null); people.push({ name: m.username, known_for: clip(ov ? (ov.alltime || ov.today) : ai.overview, 260), memory: clip(ai.memory_json, 200) }); }
+ }
+ if (people.length) out.what_the_chat_ai_knows_about_these_people = people;
+ } catch { /* */ }
+ return out;
+}
function templateLore(topic, moments, members) {
function templateLore(topic, moments, members) {
const first = moments[moments.length - 1];
const first = moments[moments.length - 1];
@@ -564,7 +628,7 @@ async function buildLore(topicId, { force = false } = {}) {
@@ -564,7 +628,7 @@ async function buildLore(topicId, { force = false } = {}) {
if (aiOn() && moments.length) {
if (aiOn() && moments.length) {
try {
try {
const r = await llm.complete({ role: 'summary', kind: 'arena_lore', source: 'arena', system: LORE_SYSTEM, json: LORE_SCHEMA, maxTokens: 600, temperature: 0.85, timeoutMs: 30000,
const r = await llm.complete({ role: 'summary', kind: 'arena_lore', source: 'arena', system: LORE_SYSTEM, json: LORE_SCHEMA, maxTokens: 600, temperature: 0.85, timeoutMs: 30000,
- user: JSON.stringify({ subject: topic.text, kind: topic.kind, bounty_target: topic.target_user_id ? nameOf(topic.target_user_id) : null, started_by: topic.creator_name || topic.created_by, source: topic.source_note, previous_lore: topic.lore, previous_headline: topic.headline, keywords: parseJson(topic.keywords_json, []),
+ user: JSON.stringify({ subject: topic.text, kind: topic.kind, bounty_target: topic.target_user_id ? nameOf(topic.target_user_id) : null, started_by: topic.creator_name || topic.created_by, source: topic.source_note, previous_lore: topic.lore, previous_headline: topic.headline, keywords: parseJson(topic.keywords_json, []), existing_threads: threadsOf(topic.id).map(t => t.name), ...communityContext(moments),
fighters_on_it: members.map(m => m.fighter_name), moments: moments.slice().reverse().map(m => ({ who: m.username || 'anon', where: m.kind === 'chat' ? 'chat' : 'on mic', text: m.text, quality: m.quality })) }) });
fighters_on_it: members.map(m => m.fighter_name), moments: moments.slice().reverse().map(m => ({ who: m.username || 'anon', where: m.kind === 'chat' ? 'chat' : 'on mic', text: m.text, quality: m.quality })) }) });
if (r && r.json && r.json.lore) out = r.json;
if (r && r.json && r.json.lore) out = r.json;
} catch (e) { console.warn('[Arena] lore:', e.message); }
} catch (e) { console.warn('[Arena] lore:', e.message); }
@@ -572,7 +636,8 @@ async function buildLore(topicId, { force = false } = {}) {
@@ -572,7 +636,8 @@ async function buildLore(topicId, { force = false } = {}) {
if (!out) out = { lore: templateLore(topic, moments, members), headline: topic.headline, tagline: topic.tagline, keywords: parseJson(topic.keywords_json, []) };
if (!out) out = { lore: templateLore(topic, moments, members), headline: topic.headline, tagline: topic.tagline, keywords: parseJson(topic.keywords_json, []) };
const kws = normalizeKeywords([...(parseJson(topic.keywords_json, []) || []), ...(out.keywords || [])]);
const kws = normalizeKeywords([...(parseJson(topic.keywords_json, []) || []), ...(out.keywords || [])]);
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, 600), out.headline ? clip(out.headline, 120) : null, out.tagline ? clip(out.tagline, 100) : null, JSON.stringify(kws), total, topicId]);
+ try { if (Array.isArray(out.threads) && out.threads.length) { upsertThreads(topicId, out.threads); const merged = normalizeKeywords([...kws, ...threadsOf(topicId).flatMap(t => parseJson(t.keywords_json, []) || [])]); db.run('UPDATE arena_topics SET keywords_json = ? WHERE id = ?', [JSON.stringify(merged), topicId]); } } catch (e) { console.warn('[Arena] threads:', e.message); }
invalidateMatchers();
invalidateMatchers();
try { require('./chatters').onLore(topic, out.lore, moments); } catch (e) { console.warn('[Arena] lore quotes xp:', e.message); }
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 };
@@ -602,7 +667,7 @@ function fighterBrief(userId, roster) {
@@ -602,7 +667,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, 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 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 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]);
@@ -617,6 +682,7 @@ function topicView(topic, roster, { detail = false } = {}) {
@@ -617,6 +682,7 @@ function topicView(topic, roster, { detail = false } = {}) {
expires_at: topic.expires_at && topic.kind === 'bounty' ? new Date(topic.expires_at + 'Z').toISOString() : null, resolved: parseJson(topic.resolved_json),
expires_at: topic.expires_at && topic.kind === 'bounty' ? new Date(topic.expires_at + 'Z').toISOString() : null, resolved: parseJson(topic.resolved_json),
mentions: { chat: topic.chat_mentions || 0, mic: topic.mic_mentions || 0, total: (topic.chat_mentions || 0) + (topic.mic_mentions || 0) },
mentions: { chat: topic.chat_mentions || 0, mic: topic.mic_mentions || 0, total: (topic.chat_mentions || 0) + (topic.mic_mentions || 0) },
chatters: db.get(`SELECT COUNT(DISTINCT COALESCE(username, user_id)) AS n FROM arena_topic_moments WHERE topic_id = ? AND kind = 'chat'`, [topic.id])?.n || 0,
chatters: db.get(`SELECT COUNT(DISTINCT COALESCE(username, user_id)) AS n FROM arena_topic_moments WHERE topic_id = ? AND kind = 'chat'`, [topic.id])?.n || 0,
+ threads: db.all('SELECT * FROM arena_topic_threads WHERE topic_id = ? ORDER BY moments DESC, id ASC', [topic.id]).map(t => ({ id: t.id, name: t.name, hint: t.hint, moments: t.moments || 0, last_at: t.last_at, keywords: parseJson(t.keywords_json, []) || [] })),
talking_now: members.filter(m => m.active).map(m => fighterBrief(m.user_id, roster)),
talking_now: members.filter(m => m.active).map(m => fighterBrief(m.user_id, roster)),
fighters: members.slice(0, 12).map(m => ({ ...fighterBrief(m.user_id, roster), active: !!m.active, moments: m.moments, score: Number((m.score || 0).toFixed(1)) })),
fighters: members.slice(0, 12).map(m => ({ ...fighterBrief(m.user_id, roster), active: !!m.active, moments: m.moments, score: Number((m.score || 0).toFixed(1)) })),
last_moment: last ? momentView(last) : null, best_moment: best ? momentView(best) : null,
last_moment: last ? momentView(last) : null, best_moment: best ? momentView(best) : null,
@@ -662,7 +728,7 @@ function levelsLeaderboard(limit = 10) {
@@ -662,7 +728,7 @@ function levelsLeaderboard(limit = 10) {
function yappersLeaderboard(limit = 10) { return require('./chatters').leaderboard(limit); }
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, threadsOf, threadFor, upsertThreads, applyTopicJudgement, hypeTopic, addMoment, noteMicMention, matchTopics, keywordsFromText,
addXp, levelRow, levelView, levelFor, recentXp, boardView, topicDetail, levelsLeaderboard, yappersLeaderboard, fighterBrief, archiveStale,
addXp, levelRow, levelView, levelFor, recentXp, boardView, topicDetail, levelsLeaderboard, yappersLeaderboard, fighterBrief, archiveStale,
scanChat, discoverTopics, discoverInput, heuristicDiscover, backfillMoments, buildLore, loreSweep, templateLore, pulse, nameOf,
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,
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,
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
* GET /console/:user what the listener hears for a live fighter
* GET /console/:user what the listener hears for a live fighter
* GET /board pulse + open events (topics/debates/phrases/bounties) + leaderboards
* GET /board pulse + open events (topics/debates/phrases/bounties) + leaderboards
* POST /board/topics {text} signed in, 1 per person + per IP per 24 h; AI rewrites it
* POST /board/topics {text} signed in, 1 per person + per IP per 24 h; AI rewrites it
- * GET /board/topics/:id · POST …/join · …/leave · …/hype {user_id}
+ * GET /board/topics/:id · POST …/hype {user_id} (no joining — the ears auto-detect who talks on what)
* POST /board/bounty {username} put a bounty on a fighter
* POST /board/bounty {username} put a bounty on a fighter
* GET /beefs · /beefs/:id · POST /beefs/:id/hype {side}
* GET /beefs · /beefs/:id · POST /beefs/:id/hype {side}
* GET /levels · /yappers Trash Level ladder · viewers who keep subjects alive from chat
* GET /levels · /yappers Trash Level ladder · viewers who keep subjects alive from chat
@@ -129,11 +129,6 @@ router.get('/board/topics/:id', (req, res) => {
@@ -129,11 +129,6 @@ router.get('/board/topics/:id', (req, res) => {
res.json(t);
res.json(t);
} catch (err) { fail(res, err, 'Failed to load topic'); }
} catch (err) { fail(res, err, 'Failed to load topic'); }
});
});
-router.post('/board/topics/:id/join', requireAuth, async (req, res) => {
- try { const r = await board.joinTopic(Number(req.params.id), req.user.id); res.json({ ok: true, topic: board.topicDetail(r.topic.id) }); }
- catch (err) { res.status(400).json({ error: err.message }); }
-});
-router.post('/board/topics/:id/leave', requireAuth, (req, res) => { try { board.leaveTopic(req.user.id); res.json({ ok: true }); } catch (err) { res.status(400).json({ error: err.message }); } });
router.post('/board/topics/:id/hype', optionalAuth, (req, res) => {
router.post('/board/topics/:id/hype', optionalAuth, (req, res) => {
try { res.json(board.hypeTopic(Number(req.params.id), Number(req.body?.user_id), arena.voterKeyFor(req))); }
try { res.json(board.hypeTopic(Number(req.params.id), Number(req.body?.user_id), arena.voterKeyFor(req))); }
catch (err) { res.status(400).json({ error: err.message }); }
catch (err) { res.status(400).json({ error: err.message }); }
@@ -38,9 +38,13 @@ board.ensureTables(); beef.ensureTables();
@@ -38,9 +38,13 @@ board.ensureTables(); beef.ensureTables();
(async () => {
(async () => {
// ── Board: subjects with keywords, moments from chat + mic, lore, Trash Level ──
// ── Board: subjects with keywords, moments from chat + mic, lore, Trash Level ──
board._resetScan();
board._resetScan();
- const t1 = board.createTopic({ text: 'People who read donations in a baby voice', createdBy: 'community', creatorName: 'global chat', headline: 'BABY VOICE GATE', keywords: ['baby voice', 'dono voice'] });
- assert.deepStrictEqual(JSON.parse(t1.keywords_json), ['baby voice', 'dono voice']);
- assert.throws(() => board.createTopic({ text: 'people who read donations in a baby voice', createdBy: 'chat' }), /already on the board/);
+ const t1 = board.createTopic({ text: 'The baby voice', createdBy: 'community', creatorName: 'global chat', headline: 'baby voice gate', keywords: ['baby voice', 'dono voice'], threads: [{ name: 'the fax machine voice', keywords: ['fax machine'], hint: 'roast the voice' }, { name: 'who does it for $2', keywords: ['dono', 'donation'] }] });
+ assert.deepStrictEqual(JSON.parse(t1.keywords_json), ['baby voice', 'dono voice', 'fax machine', 'dono', 'donation'], 'subject keywords = its own + every thread\'s');
+ assert.deepStrictEqual(board.threadsOf(t1.id).map(x => x.name), ['the fax machine voice', 'who does it for $2']);
+ assert.strictEqual(board.threadFor(t1.id, 'sounds like a FAX MACHINE'), board.threadsOf(t1.id)[0].id, 'lines file into the matching thread');
+ assert.strictEqual(board.threadFor(t1.id, 'baby voice in general'), null, 'no thread match → subject-level moment');
+ assert.throws(() => board.createTopic({ text: 'the BABY voice', createdBy: 'chat' }), /already on the board/);
+ assert.throws(() => board.createTopic({ text: 'Dono donation drama', createdBy: 'chat' }), /already on the board/, 'two shared keywords = same subject');
assert.throws(() => board.createTopic({ text: 'ok', createdBy: 'chat' }), /short/i);
assert.throws(() => board.createTopic({ text: 'ok', createdBy: 'chat' }), /short/i);
const auto = board.createTopic({ text: 'Pakistanis in the global chat again', createdBy: 'chat', creatorUserId: viewer, creatorName: 'Viewer', creatorIp: '9.9.9.9' });
const auto = board.createTopic({ text: 'Pakistanis in the global chat again', createdBy: 'chat', creatorUserId: viewer, creatorName: 'Viewer', creatorIp: '9.9.9.9' });
assert.deepStrictEqual(JSON.parse(auto.keywords_json), ['pakistanis', 'global'], 'keywords fall out of the subject text (stopwords dropped)');
assert.deepStrictEqual(JSON.parse(auto.keywords_json), ['pakistanis', 'global'], 'keywords fall out of the subject text (stopwords dropped)');
@@ -90,6 +94,10 @@ board.ensureTables(); beef.ensureTables();
@@ -90,6 +94,10 @@ board.ensureTables(); beef.ensureTables();
assert.strictEqual(lv.best_line.text, 'Read my dono in your real voice, coward', 'best line is the highest-quality one');
assert.strictEqual(lv.best_line.text, 'Read my dono in your real voice, coward', 'best line is the highest-quality one');
d1 = board.topicDetail(t1.id);
d1 = board.topicDetail(t1.id);
assert.strictEqual(d1.mentions.mic, 3); assert.strictEqual(d1.fighters[0].user.id, u1); assert.strictEqual(d1.fighters[0].moments, 3);
assert.strictEqual(d1.mentions.mic, 3); assert.strictEqual(d1.fighters[0].user.id, u1); assert.strictEqual(d1.fighters[0].moments, 3);
+ assert.strictEqual(d1.threads.find(x => x.name === 'the fax machine voice').moments, 1, 'the fax-machine line landed in its thread');
+ assert.ok(d1.moments.some(m => m.thread_id === d1.threads[0].id || m.thread_id === d1.threads[1]?.id));
+ assert.strictEqual(board.upsertThreads(t1.id, [{ name: 'The Fax Machine Voice', keywords: ['fax'] }, { name: 'nova vs grizzly', keywords: ['grizzly'] }]), 1, 'same thread merges, new angle added');
+ assert.strictEqual(board.threadsOf(t1.id).length, 3);
assert.strictEqual(d1.best_moment.quality, 8); assert.strictEqual(d1.best_lines.length, 2);
assert.strictEqual(d1.best_moment.quality, 8); assert.strictEqual(d1.best_lines.length, 2);
assert.ok(d1.moments.length === 5 && d1.moments[0].source === 'judge');
assert.ok(d1.moments.length === 5 && d1.moments[0].source === 'judge');
await board.joinTopic(auto.id, u1);
await board.joinTopic(auto.id, u1);
@@ -329,7 +337,7 @@ board.ensureTables(); beef.ensureTables();
@@ -329,7 +337,7 @@ board.ensureTables(); beef.ensureTables();
const one = await get(`/beefs/${opened[0].id}`); assert.strictEqual(one.body.a.user.username, 'nova'); assert.ok(one.body.rules.response_live_min === 15);
const one = await get(`/beefs/${opened[0].id}`); assert.strictEqual(one.body.a.user.username, 'nova'); assert.ok(one.body.rules.response_live_min === 15);
const con = await get('/console/nova'); assert.strictEqual(con.body.listener.listening, true); assert.strictEqual(con.body.open_beefs.length, 1); assert.ok(con.body.hot_mic.length >= 2 && con.body.listener.focus === null);
const con = await get('/console/nova'); assert.strictEqual(con.body.listener.listening, true); assert.strictEqual(con.body.open_beefs.length, 1); assert.ok(con.body.hot_mic.length >= 2 && con.body.listener.focus === null);
const lv2 = await get('/live'); assert.strictEqual(lv2.body.live[0].open_beefs, 1);
const lv2 = await get('/live'); assert.strictEqual(lv2.body.live[0].open_beefs, 1);
- const tp = await get(`/board/topics/${t1.id}`); assert.ok(tp.body.lore && tp.body.moments.length >= 5 && tp.body.keywords.includes('baby voice'));
+ const tp = await get(`/board/topics/${t1.id}`); assert.ok(tp.body.lore && tp.body.moments.length >= 5 && tp.body.keywords.includes('baby voice') && tp.body.threads.length === 3);
assert.strictEqual((await get('/beefs/999')).status, 404);
assert.strictEqual((await get('/beefs/999')).status, 404);
assert.strictEqual((await get('/console/nobody')).status, 404);
assert.strictEqual((await get('/console/nobody')).status, 404);
srv.close();
srv.close();