openvibers/openvibe.live Commits

Arena: live trash-talk sessions (transcription-driven power leveling) + phone layout pass

OP OpenVibers committed 29 Aug 2026 · 8147a66ea1 · 11 files, +851 −5

Sessions (server/arena/talk-session.js, /arena/talk/<username>):
- A live streamer with transcription starts a session; every 15 s new transcript lines
  are buffered against the CURRENT topic. Once ≥ 25 new words and ≥ 30 s have passed the
  judge scores the chunk: is it trash talk on the topic, Spice/Wit/On-topic/Delivery,
  progress_gain (0–60), best verbatim line, and a ≤ 10-word "what they talked about" tag.
- Progress fills the bar; at 100 the topic is cleared → becomes a Trash Talk entry (POWER
  bonus, Hall of Trash) and the next topic is generated from what was already cleared.
  Talk enough → the topic changes. Skip / end controls for the streamer.
- XP and Trash Level (40 XP per level, quality × trash-talkiness per chunk); `!hype` in
  chat or the Hype button adds XP + progress (one per person per topic). Chat's !hype
  prefers a live session over a one-shot entry.
- Public console polled every 4 s: topic + bar, latest judgement, best line (▶ VOD), hot
  mic feed, talked-about tags, topics cleared/skipped, level-up flash. Live sessions
  are listed on /arena/talk. Ends on stream end / 20 min idle / 3 h / 300 judge calls;
  the slur filter voids chunks and hides lines.
- Tests: test/arena-session.test.js (start gating, chunk judging, hype, clear → next
  topic + entry + bonus, skip, end with stream). Docs updated.

Phone layout: leaderboard rows (portrait no longer overlaps names), main event, live
matches, profile, battle, talk page and session console all stack cleanly ≤ 900px.

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

Changes

ddiff --git a/docs/arena.md b/docs/arena.md
iindex bd052ef8..6ce2bbf5 100644
---- a/docs/arena.md
++++ b/docs/arena.md
@@@ -67,6 +67,19 @@ The part streamers and viewers play themselves.
- **Power** — total (/50) × 12 → a Trash Talk bonus on POWER (max +12), decaying linearly over 7 days; shown on the profile and included in the leaderboard order.
- **Hall of Trash** — the ten best lines of the last 30 days.
++
++### Live sessions (`/arena/talk/<username>`)
++
++The continuous version: a streamer who is live with the timeline transcription running presses **Start live session** on `/arena/talk`. From then on (`server/arena/talk-session.js`, ticker every 15 s):
++
++- New transcript lines are buffered against the **current topic**; once ≥ 25 new words have arrived and ≥ 30 s passed since the last call, the judge scores the chunk: *is this trash talk on the topic?*, Spice/Wit/On-topic/Delivery, a `progress_gain` (0–60 — how much of the topic that chunk covered), the best verbatim line, and a ≤ 10-word "what they talked about" tag.
++- **Progress** fills the topic bar; at 100 the topic is **cleared** → it becomes a regular Trash Talk entry (POWER bonus, Hall of Trash) and the **next topic is generated**, shaped by what was already cleared. Talk enough → the topic changes. Streamers can skip a topic or end the session.
++- **XP / Trash Level**: each judged chunk adds XP = quality × (trash talk ? 1 : 0.15); a level every 40 XP. Viewers add +2 XP and +4 % progress with `!hype` (one per person per topic).
++- The public console shows the live topic + bar, the latest judgement ("that was trash talk" / "not trash talk yet"), the best line so far (▶ VOD), the **hot mic** feed, "what they talked about" tags, topics cleared/skipped, and a level-up flash. Live sessions are listed on `/arena/talk` under "Talking trash right now".
++- Guard rails: one live session per streamer; ends when the stream ends, after 20 idle minutes, at 3 h, or at 300 judge calls; the slur filter voids chunks before the model sees them; lines shown on the console are filtered too.
++
++API: `GET /api/arena/talk/sessions` · `GET /api/arena/talk/session/:user` · `POST /api/arena/talk/session/start|stop|skip` (auth) · `POST /api/arena/talk/session/:user/hype`.
++
Chat commands (`server/arena/arena-chat.js`, hooked into `ChatServer.handleBangCommand`): `!hype`, `!talk`, `!arena [user]`, `!vote a|b` (main event, signed-in), `!fight <user>`. Rate-limited to one per person every 4 s; replies are private system lines, hype milestones and call-outs are announced to the room.
API: `GET /api/arena/talk` (board: topic, entries, hall, my entry, mic availability) · `POST /api/arena/talk/mic/start` · `GET /api/arena/talk/mic/feed` · `POST /api/arena/talk/submit {mode:'mic'|'text', text?}` · `POST /api/arena/talk/:id/hype`.
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 67e786da..62b4d5ff 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -14254,3 +14254,112 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
.arena-talk-reveal-total b { font-size: 3rem; font-weight: 900; color: var(--accent-light); font-variant-numeric: tabular-nums; } .arena-talk-reveal-total small { color: var(--text-muted); font-size: 1rem; }
.arena-talk-bonus { font-size: 0.85rem; font-weight: 700; color: #f97316; }
@media (max-width: 900px) { .arena-talk-hero { grid-template-columns: 1fr; } .arena-talk-score { grid-template-columns: 74px 1fr 22px; } }
++
++/* ── Arena: phone layout pass ── */
++@media (max-width: 900px) {
++ .arena-row { grid-template-columns: 28px 56px minmax(0, 1fr) auto; gap: 10px; padding: 10px 12px; }
++ .arena-row .arena-portrait-sm { width: 56px; height: 56px; }
++ .arena-row-main strong { font-size: 0.95rem; white-space: normal; line-height: 1.2; }
++ .arena-row-sub { white-space: normal; line-height: 1.3; }
++ .arena-rank { font-size: 1rem; }
++ .arena-row-stats { flex-direction: column; gap: 2px; align-items: flex-end; }
++ .arena-power b { font-size: 1.3rem; }
++ .arena-record { font-size: 0.75rem; }
++ .arena-hero { padding: 16px 0 10px; } .arena-hero h1 { font-size: 1.5rem; }
++ .arena-board-head { flex-direction: column; align-items: stretch; }
++ .arena-board-head input { min-width: 0; width: 100%; }
++ .arena-live-match { grid-template-columns: 1fr; gap: 8px; }
++ .arena-live-b { flex-direction: row; text-align: left; }
++ .arena-live-vs { justify-self: center; width: 44px; height: 44px; font-size: 0.8rem; }
++ .arena-live-thumb { width: 80px; height: 45px; }
++ .arena-main-event-body { gap: 10px; }
++ .arena-me-side.is-b { flex-direction: row; text-align: left; }
++ .arena-talk-card-foot { flex-direction: column; gap: 6px; }
++ .arena-profile { padding: 14px; gap: 16px; }
++ .arena-profile-portrait { flex-direction: column; }
++ .arena-portrait-lg { max-width: 220px; }
++ .arena-profile-name h1 { font-size: 1.5rem; }
++ .arena-quips { grid-template-columns: 1fr; }
++ .arena-numbers { grid-template-columns: repeat(2, 1fr); }
++ .arena-side { padding: 12px; }
++ .arena-portrait-md { max-width: 180px; }
++ .arena-vs { font-size: 2.2rem; }
++ .arena-battle-actions .btn { font-size: 0.85rem; padding: 8px 12px; }
++ .arena-talk-hero-main { padding: 16px; }
++ .arena-talk-scores { grid-template-columns: 1fr; }
++ .arena-talk-entry-head { align-items: flex-start; }
++ .arena-quote { grid-template-columns: 1fr; }
++ .arena-voice-meters { grid-template-columns: repeat(2, 1fr); }
++}
++@media (max-width: 480px) {
++ .arena-row { grid-template-columns: 22px 48px minmax(0, 1fr) auto; gap: 8px; padding: 8px 10px; }
++ .arena-row .arena-portrait-sm { width: 48px; height: 48px; }
++ .arena-row-main strong { font-size: 0.9rem; }
++ .arena-row-sub { font-size: 0.74rem; }
++ .arena-row-sub .arena-live-pill { margin-left: 4px; }
++ .arena-numbers { grid-template-columns: repeat(2, 1fr); }
++ .arena-hero-actions { width: 100%; }
++ .arena-hero-actions .btn { width: 100%; justify-content: center; }
++}
++
++/* ── Arena: live trash-talk sessions ── */
++.arena-sessions-live { margin: 0 0 20px; }
++.arena-sessions-live h2 { font-size: 1.05rem; margin: 0 0 10px; display: flex; align-items: center; gap: 8px; }
++.arena-session-strip { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; }
++.arena-session-chip { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 12px; background: var(--bg-card); border: 1px solid color-mix(in srgb, #ef4444 40%, var(--border)); color: var(--text-primary); text-decoration: none; }
++.arena-session-chip .arena-live-dot { flex-shrink: 0; }
++.arena-session-chip span { display: flex; flex-direction: column; min-width: 0; }
++.arena-session-chip strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
++.arena-session-chip small { color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
++.arena-session-chip .arena-session-lvl { margin-left: auto; font-weight: 800; color: #f97316; white-space: nowrap; }
++.arena-session { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: 16px; margin-bottom: 24px; }
++.arena-session-main, .arena-session-side { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
++.arena-session-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; padding: 12px 16px; border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); }
++.arena-session-who { display: flex; align-items: center; gap: 10px; min-width: 0; }
++.arena-session-who strong { font-size: 1.05rem; }
++.arena-session-status { display: inline-flex; align-items: center; gap: 6px; font-size: 0.75rem; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; color: #ef4444; }
++.arena-session-status.is-ended { color: var(--text-muted); }
++.arena-session-level { display: flex; align-items: center; gap: 12px; }
++.arena-session-level b { font-size: 1.6rem; color: #f97316; line-height: 1; }
++.arena-session-level small { display: block; font-size: 0.62rem; letter-spacing: 0.1em; color: var(--text-muted); }
++.arena-xp { width: 140px; }
++.arena-xp-track { height: 8px; border-radius: 4px; background: var(--bg-tertiary); overflow: hidden; }
++.arena-xp-fill { display: block; height: 100%; background: linear-gradient(90deg, #f97316, #fbbf24); transition: width 0.6s cubic-bezier(0.2, 0.8, 0.3, 1); }
++.arena-xp-label { font-size: 0.7rem; color: var(--text-muted); margin-top: 3px; text-align: right; }
++.arena-session-topic { padding: 18px 20px; border-radius: 16px; border: 2px solid color-mix(in srgb, #f97316 55%, var(--border)); background: radial-gradient(80% 60% at 0% 0%, color-mix(in srgb, #f97316 18%, transparent), transparent), var(--bg-card); position: relative; overflow: hidden; }
++.arena-session-topic.is-cleared-flash { animation: arena-cleared 0.9s ease-out; }
++@keyframes arena-cleared { 0% { box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.6); } 100% { box-shadow: 0 0 0 30px rgba(249, 115, 22, 0); } }
++.arena-session-topic-kicker { font-size: 0.72rem; letter-spacing: 0.1em; text-transform: uppercase; color: #f97316; font-weight: 800; margin-bottom: 6px; display: flex; justify-content: space-between; gap: 8px; }
++.arena-session-topic-kicker span { color: var(--text-muted); letter-spacing: 0; text-transform: none; font-weight: 500; }
++.arena-session-topic h2 { margin: 0 0 8px; font-size: clamp(1.15rem, 2.4vw, 1.6rem); line-height: 1.3; }
++.arena-session-hint { color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 12px; }
++.arena-progress { height: 14px; border-radius: 7px; background: var(--bg-tertiary); overflow: hidden; position: relative; }
++.arena-progress-fill { display: block; height: 100%; background: linear-gradient(90deg, #f97316, #fbbf24 70%, #22c55e); transition: width 0.8s cubic-bezier(0.2, 0.8, 0.3, 1); box-shadow: 0 0 12px rgba(249, 115, 22, 0.5); }
++.arena-progress-label { display: flex; justify-content: space-between; font-size: 0.75rem; color: var(--text-muted); margin-top: 5px; }
++.arena-session-controls { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
++.arena-session-verdict { margin-top: 10px; font-size: 0.85rem; color: var(--text-secondary); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
++.arena-session-verdict .is-hit { color: #22c55e; font-weight: 700; } .arena-session-verdict .is-miss { color: var(--text-muted); font-weight: 700; }
++.arena-session-feed { padding: 12px 14px; border-radius: 14px; background: var(--bg-secondary); border: 1px dashed var(--border); min-height: 160px; max-height: 320px; overflow-y: auto; }
++.arena-session-feed-head { display: flex; align-items: center; justify-content: space-between; font-size: 0.75rem; color: var(--text-muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.06em; }
++.arena-session-line { display: grid; grid-template-columns: 42px 1fr auto; gap: 8px; padding: 4px 0; font-size: 0.92rem; animation: arena-in 0.3s; align-items: baseline; }
++.arena-session-line span:first-child { color: var(--text-muted); font-variant-numeric: tabular-nums; font-size: 0.75rem; }
++.arena-session-line a { color: var(--text-muted); font-size: 0.75rem; text-decoration: none; } .arena-session-line a:hover { color: var(--accent-light); }
++.arena-session-line.is-new { color: var(--text-primary); }
++.arena-session-card { padding: 12px 14px; border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); }
++.arena-session-card h3 { margin: 0 0 8px; font-size: 0.95rem; display: flex; align-items: center; gap: 8px; } .arena-session-card h3 i { color: #f97316; }
++.arena-about { display: flex; flex-wrap: wrap; gap: 6px; }
++.arena-about span { font-size: 0.75rem; padding: 3px 9px; border-radius: 999px; background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text-secondary); animation: arena-in 0.3s; }
++.arena-about span.is-hit { border-color: color-mix(in srgb, #f97316 60%, var(--border)); color: var(--text-primary); }
++.arena-cleared-list { display: flex; flex-direction: column; gap: 8px; }
++.arena-cleared { padding: 10px 12px; border-radius: 10px; background: var(--bg-tertiary); border-left: 3px solid #22c55e; font-size: 0.85rem; }
++.arena-cleared.is-skipped { border-left-color: var(--text-muted); opacity: 0.75; }
++.arena-cleared b { display: block; margin-bottom: 2px; }
++.arena-cleared q { color: var(--text-secondary); font-style: italic; }
++.arena-cleared small { color: var(--text-muted); display: block; margin-top: 2px; }
++.arena-session-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
++.arena-session-stat { padding: 8px 10px; border-radius: 10px; background: var(--bg-tertiary); text-align: center; }
++.arena-session-stat b { display: block; font-size: 1.1rem; } .arena-session-stat span { font-size: 0.65rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
++.arena-levelup { position: fixed; left: 50%; top: 22%; transform: translateX(-50%); z-index: 3500; padding: 14px 26px; border-radius: 16px; background: linear-gradient(135deg, #f97316, #fbbf24); color: #111; font-weight: 900; font-size: 1.4rem; letter-spacing: 0.06em; box-shadow: 0 20px 60px rgba(249, 115, 22, 0.45); animation: arena-levelup 2.2s ease-out forwards; pointer-events: none; }
++@keyframes arena-levelup { 0% { transform: translate(-50%, 20px) scale(0.6); opacity: 0; } 15% { transform: translate(-50%, 0) scale(1.08); opacity: 1; } 80% { opacity: 1; } 100% { transform: translate(-50%, -30px) scale(1); opacity: 0; } }
++@media (max-width: 900px) { .arena-session { grid-template-columns: 1fr; } .arena-session-stats { grid-template-columns: repeat(3, 1fr); } .arena-xp { width: 110px; } }
++@media (max-width: 480px) { .arena-hero-actions { flex-direction: column; align-items: stretch; } }
ddiff --git a/public/index.html b/public/index.html
iindex c92b52e6..b096e178 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -62,7 +62,7 @@
</script>
<!-- Stylesheets -->
-- <link rel="stylesheet" href="/css/style.css?v=144">
++ <link rel="stylesheet" href="/css/style.css?v=145">
<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=3" defer></script>
++<script src="/js/arena.js?v=4" defer></script>
<script src="/js/powerchat.js?v=10" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
ddiff --git a/public/js/arena.js b/public/js/arena.js
iindex f634863a..3d72af08 100644
---- a/public/js/arena.js
++++ b/public/js/arena.js
@@@ -131,7 +131,7 @@ async function loadArenaPage(segments = []) {
const [, first, second, third] = segments;
try {
if (first === 'battle' && second && third) return await _aRenderBattle(root, second, third);
-- if (first === 'talk') return await _aRenderTalk(root);
++ if (first === 'talk') return second ? await _aRenderSession(root, second) : await _aRenderTalk(root);
if (first && first !== 'battle') return await _aRenderFighter(root, first);
return await _aRenderHome(root);
} catch (err) {
@@@ -709,6 +709,8 @@ async function _aRenderTalk(root) {
<p class="arena-note">Chat commands: <code>!talk</code> topic · <code>!hype</code> · <code>!arena</code> card · <code>!vote a|b</code> main event · <code>!fight &lt;user&gt;</code></p>
</div>
</div>
++ <section class="arena-sessions-live" id="arena-live-sessions" style="display:none"></section>
++ <section id="arena-session-card"></section>
<section class="arena-talk-enter" id="arena-talk-enter"></section>
<section class="arena-board">
<div class="arena-board-head"><h2>This topic's entries <small class="arena-note">${t.entries.length} so far</small></h2>${t.ai ? '' : '<span class="arena-note">AI judge is off — heuristic scoring</span>'}</div>
@@@ -718,6 +720,8 @@ async function _aRenderTalk(root) {
_aBindSpeak(root);
_aBindHype(root);
_aRenderTalkEnter(t, me);
++ _aRenderSessionCard(t, me);
++ api('/arena/talk/sessions').then(r => _aRenderLiveSessions(r.sessions || [])).catch(() => {});
_arenaTalkTimers.push(setInterval(() => { const el = document.getElementById('arena-talk-countdown'); if (el) el.textContent = _aCountdown(topic.ends_at); }, 30000));
}
@@@ -823,4 +827,119 @@ function _aRenderTalkEnter(t, me) {
renderMode(mic.available ? 'mic' : 'text');
}
++// ── Live trash-talk sessions ─────────────────────────────────
++
++function _aSessionLink(u) { return `/arena/talk/${encodeURIComponent(u.username)}`; }
++
++function _aRenderLiveSessions(list) {
++ const el = document.getElementById('arena-live-sessions');
++ if (!el) return;
++ if (!list.length) { el.style.display = 'none'; return; }
++ el.style.display = '';
++ el.innerHTML = `<h2><span class="arena-live-dot"></span> Talking trash right now</h2><div class="arena-session-strip">${list.map(s => _aA(_aSessionLink(s.user), `<span class="arena-live-dot"></span><span><strong>${_aEsc(s.fighter_name)}</strong><small>${_aEsc(s.topic || '…')} · ${s.progress}%</small></span><span class="arena-session-lvl">LVL ${s.level}</span>`, 'arena-session-chip')).join('')}</div>`;
++}
++
++/** Streamer-side card on /arena/talk: start a live session, or jump to the running one. */
++async function _aRenderSessionCard(t, me) {
++ const el = document.getElementById('arena-session-card');
++ if (!el || !me || !t.on_roster) return;
++ let view = null;
++ try { view = await api(`/arena/talk/session/${encodeURIComponent(me.username)}`); } catch { view = null; }
++ const live = view?.session && view.session.status === 'live';
++ const mic = t.mic || {};
++ el.innerHTML = `<div class="arena-talk-panel">
++ <div class="arena-talk-panel-head"><h2><i class="fa-solid fa-tower-broadcast"></i> Live session <span class="arena-note" style="font-weight:400">power-level by talking trash on stream</span></h2>${live ? '<span class="arena-session-status"><span class="arena-live-dot"></span> live now</span>' : ''}</div>
++ <p class="arena-note" style="margin:0 0 10px">Start a session while you're live and the transcription does the rest: talk trash on the current topic, the judge scores it every ~30 s, the bar fills, the topic changes when you've cleared it, and every cleared topic counts for your POWER. Viewers push it with <code>!hype</code>.</p>
++ <div class="arena-session-controls">
++ ${live ? _aA(_aSessionLink(me), '<i class="fa-solid fa-satellite-dish"></i> Open your live console', 'btn btn-primary')
++ : `<button class="btn btn-primary" id="arena-session-start" ${mic.available ? '' : 'disabled'} title="${mic.available ? '' : mic.reason === 'not_live' ? 'Go live first' : 'Live, but no transcription yet — talk for a few seconds and reload'}"><i class="fa-solid fa-play"></i> Start live session${mic.available ? '' : mic.reason === 'not_live' ? ' <small>(go live first)</small>' : ' <small>(waiting for transcription)</small>'}</button>`}
++ ${view?.session && !live ? `<span class="arena-note">Last session: level ${view.session.level}, ${view.session.topics_cleared} topic${view.session.topics_cleared === 1 ? '' : 's'} cleared${view.session.end_reason ? ` (${_aEsc(String(view.session.end_reason).replace('_', ' '))})` : ''}</span>` : ''}
++ </div>
++ </div>`;
++ el.querySelector('#arena-session-start')?.addEventListener('click', async (e) => {
++ e.target.disabled = true;
++ try { await api('/arena/talk/session/start', { method: 'POST' }); navigate(_aSessionLink(me)); }
++ catch (err) { e.target.disabled = false; if (typeof showToast === 'function') showToast(err?.message || 'Could not start', 'error'); }
++ });
++}
++
++async function _aRenderSession(root, username) {
++ _aTalkStop();
++ root.innerHTML = _aSpinner('Tuning in…');
++ let v;
++ try { v = await api(`/arena/talk/session/${encodeURIComponent(username)}`); }
++ catch (err) { root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-user-slash"></i><p>${_aEsc(err?.message || 'No such fighter')}</p></div>`; return; }
++ const me = (typeof currentUser !== 'undefined' && currentUser) ? currentUser : null;
++ const mine = !!(me && me.id === v.user.id);
++ if (!v.session) {
++ root.innerHTML = `<div class="arena-back">${_aA('/arena/talk', '<i class="fa-solid fa-arrow-left"></i> Trash Talk')}</div><div class="arena-empty"><i class="fa-solid fa-microphone-slash"></i><p><strong>${_aEsc(v.fighter_name)}</strong> hasn't run a live trash-talk session yet.${mine ? ' Start one from the Trash Talk page while you are live.' : ''}</p>${_aA('/arena/talk', 'Back', 'btn')}</div>`;
++ return;
++ }
++ let last = null;
++ const render = (s) => {
++ const t = s.active_topic;
++ const live = s.status === 'live';
++ const xpInLevel = s.xp - (s.level - 1) * s.xp_per_level;
++ const prev = last; last = s;
++ if (prev && s.level > prev.level) _aLevelUp(s.level);
++ const clearedFlash = prev && s.topics_cleared > prev.topics_cleared;
++ const lines = s.recent_lines || [];
++ const newestAt = prev ? Math.max(0, ...(prev.recent_lines || []).map(l => l.at)) : -1;
++ root.innerHTML = `
++ <div class="arena-back">${_aA('/arena/talk', '<i class="fa-solid fa-arrow-left"></i> Trash Talk')} ${_aA(_aFighterLink(v.user), `<i class="fa-solid fa-id-card"></i> ${_aEsc(v.fighter_name)}`)}</div>
++ <div class="arena-session">
++ <div class="arena-session-main">
++ <div class="arena-session-head">
++ <div class="arena-session-who">${_aPortrait({ user: v.user, image_url: v.image_url }, 'sm')}<div><strong>${_aEsc(v.fighter_name)}</strong><div class="arena-session-status ${live ? '' : 'is-ended'}">${live ? '<span class="arena-live-dot"></span> talking trash live' : `session ended${s.end_reason ? ' · ' + _aEsc(String(s.end_reason).replace('_', ' ')) : ''}`}</div></div></div>
++ <div class="arena-session-level"><div><b>LVL ${s.level}</b><small>TRASH LEVEL</small></div><div class="arena-xp"><div class="arena-xp-track"><span class="arena-xp-fill" style="width:${Math.min(100, (xpInLevel / s.xp_per_level) * 100)}%"></span></div><div class="arena-xp-label">${s.xp} XP · ${s.next_level_xp - s.xp} to next</div></div></div>
++ </div>
++ ${t ? `<div class="arena-session-topic ${clearedFlash ? 'is-cleared-flash' : ''}">
++ <div class="arena-session-topic-kicker">Topic ${t.idx + 1} · ${_aEsc(t.tone || '')} <span>${live ? 'talk about this — the judge listens every ~30 s' : 'session over'}</span></div>
++ <h2>“${_aEsc(t.topic)}”</h2>
++ ${t.hint ? `<p class="arena-session-hint"><i class="fa-solid fa-lightbulb"></i> ${_aEsc(t.hint)}</p>` : ''}
++ <div class="arena-progress"><span class="arena-progress-fill" style="width:${t.progress}%"></span></div>
++ <div class="arena-progress-label"><span>${t.progress}% cleared · ${t.hits} hit${t.hits === 1 ? '' : 's'} · ${t.hypers} hyping</span><span>${t.progress >= 100 ? 'CLEARED' : `${100 - t.progress}% to the next topic`}</span></div>
++ ${t.last_judgement ? `<div class="arena-session-verdict"><span class="${t.last_judgement.is_trash_talk ? 'is-hit' : 'is-miss'}">${t.last_judgement.is_trash_talk ? '<i class="fa-solid fa-fire"></i> that was trash talk' : '<i class="fa-solid fa-ellipsis"></i> not trash talk (yet)'}</span> · quality ${t.last_judgement.quality}/10 · “${_aEsc(t.last_judgement.about || '')}”${t.last_judgement.fallback ? ' · heuristic judge' : ''}</div>` : (live ? '<div class="arena-session-verdict"><span class="is-miss"><i class="fa-solid fa-ear-listen"></i> listening…</span> say something spicy</div>' : '')}
++ ${t.best_line ? `<p class="arena-talk-note">Best line so far: “${_aEsc(t.best_line)}” ${t.best_vod_id ? `<a class="arena-talk-hear" href="/vod/${t.best_vod_id}?t=${t.best_line_sec}" onclick="return handleLinkClick(event, '/vod/${t.best_vod_id}?t=${t.best_line_sec}')"><i class="fa-solid fa-play"></i> hear it</a>` : ''}</p>` : ''}
++ <div class="arena-session-controls">
++ ${mine && live ? `<button class="btn btn-ghost" id="arena-session-skip"><i class="fa-solid fa-forward"></i> Skip topic</button><button class="btn btn-ghost" id="arena-session-stop"><i class="fa-solid fa-stop"></i> End session</button>` : ''}
++ ${!mine && live ? `<button class="btn btn-primary" id="arena-session-hype"><i class="fa-solid fa-fire"></i> Hype</button><span class="arena-note">or type <code>!hype</code> in their chat</span>` : ''}
++ ${_aA(_aChannelLink(v.user), '<i class="fa-solid fa-tv"></i> Watch the stream', 'btn btn-ghost')}
++ </div>
++ </div>` : ''}
++ <div class="arena-session-feed">
++ <div class="arena-session-feed-head"><span><i class="fa-solid fa-microphone"></i> Hot mic</span><span>${s.lines_seen} lines · ${_aNum(s.words_seen)} words this session</span></div>
++ ${lines.length ? lines.map(l => `<div class="arena-session-line ${l.at > newestAt ? 'is-new' : ''}"><span>${_aStamp(l.at)}</span><span>${_aEsc(l.text)}</span>${l.vod_id ? `<a href="/vod/${l.vod_id}?t=${Math.max(0, l.sec - 2)}" onclick="return handleLinkClick(event, '/vod/${l.vod_id}?t=${Math.max(0, l.sec - 2)}')" title="Hear it"><i class="fa-solid fa-play"></i></a>` : '<span></span>'}</div>`).join('') : '<p class="arena-voice-empty">Nothing heard yet — the transcription runs a few seconds behind.</p>'}
++ </div>
++ </div>
++ <div class="arena-session-side">
++ <div class="arena-session-stats"><div class="arena-session-stat"><b>${s.topics_cleared}</b><span>topics cleared</span></div><div class="arena-session-stat"><b>${s.xp}</b><span>xp</span></div><div class="arena-session-stat"><b>${v.talk_bonus || 0}</b><span>power bonus</span></div></div>
++ <div class="arena-session-card"><h3><i class="fa-solid fa-tags"></i> What they talked about</h3>${s.talked_about.length ? `<div class="arena-about">${s.talked_about.map(a => `<span class="${a.hit ? 'is-hit' : ''}" title="topic ${a.topic_idx + 1}">${_aEsc(a.text)}</span>`).join('')}</div>` : '<p class="arena-voice-empty">Tags appear after the first judged chunk.</p>'}</div>
++ <div class="arena-session-card"><h3><i class="fa-solid fa-list-check"></i> Topics this session</h3>${s.cleared_topics.length ? `<div class="arena-cleared-list">${s.cleared_topics.slice().reverse().map(c => `<div class="arena-cleared ${c.status === 'skipped' ? 'is-skipped' : ''}"><b>${c.status === 'cleared' ? '<i class="fa-solid fa-check"></i>' : '<i class="fa-solid fa-forward"></i>'} ${_aEsc(c.topic)}</b>${c.best_line ? `<q>${_aEsc(c.best_line)}</q> ${c.best_vod_id ? `<a class="arena-talk-hear" href="/vod/${c.best_vod_id}?t=${c.best_line_sec}" onclick="return handleLinkClick(event, '/vod/${c.best_vod_id}?t=${c.best_line_sec}')"><i class="fa-solid fa-play"></i></a>` : ''}` : ''}<small>${c.status === 'cleared' ? `cleared · score ${c.score} · ${c.hits} hit${c.hits === 1 ? '' : 's'}` : 'skipped'}</small></div>`).join('')}</div>` : '<p class="arena-voice-empty">No topics cleared yet.</p>'}</div>
++ </div>
++ </div>`;
++ root.querySelector('#arena-session-skip')?.addEventListener('click', async () => { try { await api('/arena/talk/session/skip', { method: 'POST' }); refresh(); } catch (err) { if (typeof showToast === 'function') showToast(err?.message, 'error'); } });
++ root.querySelector('#arena-session-stop')?.addEventListener('click', async () => { if (!confirm('End your trash-talk session?')) return; try { await api('/arena/talk/session/stop', { method: 'POST' }); refresh(); } catch (err) { if (typeof showToast === 'function') showToast(err?.message, 'error'); } });
++ root.querySelector('#arena-session-hype')?.addEventListener('click', async (e) => {
++ e.target.disabled = true;
++ try { const r = await api(`/arena/talk/session/${encodeURIComponent(v.user.username)}/hype`, { method: 'POST' }); e.target.innerHTML = r.added ? '<i class="fa-solid fa-fire"></i> Hyped!' : '<i class="fa-solid fa-check"></i> Already hyped'; refresh(); }
++ catch (err) { e.target.disabled = false; if (typeof showToast === 'function') showToast(err?.message || 'Hype failed', 'error'); }
++ });
++ };
++ const refresh = async () => {
++ if (currentPage !== 'arena') return _aTalkStop();
++ try { const nv = await api(`/arena/talk/session/${encodeURIComponent(username)}`); if (nv.session) { v = nv; render(nv.session); } } catch { /* keep */ }
++ };
++ render(v.session);
++ if (v.session.status === 'live') _arenaTalkTimers.push(setInterval(refresh, 4000));
++}
++
++function _aLevelUp(level) {
++ const el = document.createElement('div');
++ el.className = 'arena-levelup';
++ el.innerHTML = `<i class="fa-solid fa-arrow-up"></i> TRASH LEVEL ${level}`;
++ document.body.appendChild(el);
++ setTimeout(() => el.remove(), 2300);
++}
++
window.loadArenaPage = loadArenaPage;
ddiff --git a/server/arena/arena-chat.js b/server/arena/arena-chat.js
iindex c5e607fb..1ae23d61 100644
---- a/server/arena/arena-chat.js
++++ b/server/arena/arena-chat.js
@@@ -49,6 +49,16 @@ function handle(chat, ws, client, cmd, parts) {
try {
if (cmd === '!hype') {
if (!streamer) return reply('!hype works inside a streamer\'s chat.');
++ // A live session (they are talking trash on stream right now) takes priority.
++ try {
++ const s = require('./talk-session').hypeSession(streamer.id, key);
++ if (s) {
++ if (!s.added) return reply(`You already hyped this topic. ${s.hypers} hyping · level ${s.level} · ${s.progress}% to the next topic.`);
++ reply(`🔥 Hyped the live session! ${s.hypers} hyping · level ${s.level} (${s.xp} XP) · topic ${s.progress}% cleared → ${base()}/arena/talk/${encodeURIComponent(streamer.username)}`);
++ if (s.hypers === 1 || s.hypers % 5 === 0) chat.broadcastToStream(client.streamId, { type: 'system', message: `🎤 ${streamer.display_name || streamer.username} is talking trash live — ${s.hypers} hyping. Type !hype to push the topic. ${base()}/arena/talk/${encodeURIComponent(streamer.username)}` });
++ return;
++ }
++ } catch (e) { return reply(`Arena: ${e.message}`); }
const entry = talk.latestEntryFor(streamer.id);
if (!entry) return reply(`${streamer.display_name || streamer.username} hasn't entered the current Trash Talk topic yet — ${base()}/arena/talk`);
const r = talk.hype(entry.id, key);
ddiff --git a/server/arena/arena-job.js b/server/arena/arena-job.js
iindex 19d656f7..93a4ebee 100644
---- a/server/arena/arena-job.js
++++ b/server/arena/arena-job.js
@@@ -49,7 +49,9 @@ function start() {
_timer = setInterval(() => tick().catch(e => console.warn('[Arena] job:', e.message)), INTERVAL_MS);
if (_timer.unref) _timer.unref();
setTimeout(() => tick().catch(() => {}), 90_000).unref?.();
-- console.log('[Arena] job started (every 20 min)');
++ // Live trash-talk sessions read the transcript every 15 s (no-op when none are live).
++ try { require('./talk-session').start(); } catch (e) { console.warn('[Arena] session ticker not started:', e.message); }
++ console.log('[Arena] job started (every 20 min; session ticker every 15 s)');
}
function stop() { if (_timer) { clearInterval(_timer); _timer = null; } }
ddiff --git a/server/arena/arena-service.js b/server/arena/arena-service.js
iindex 50d51ed0..f75beb23 100644
---- a/server/arena/arena-service.js
++++ b/server/arena/arena-service.js
@@@ -1140,6 +1140,7 @@ module.exports = {
STAT_WEIGHTS,
ROUNDS,
ARENA_DIR,
++ getFighterImageUrl: (userId) => imageUrlFor(profileRow(userId)),
// pure helpers (tests)
_computeRatings: computeRatings,
_simulateRounds: simulateRounds,
ddiff --git a/server/arena/routes.js b/server/arena/routes.js
iindex 0c0d9b47..d45737f3 100644
---- a/server/arena/routes.js
++++ b/server/arena/routes.js
@@@ -81,6 +81,44 @@ router.post('/talk/submit', requireAuth, async (req, res) => {
}
});
++// ── Live trash-talk sessions (server/arena/talk-session.js) ──
++const sessions = require('./talk-session');
++
++router.get('/talk/sessions', (req, res) => {
++ try { res.set('Cache-Control', 'no-store'); res.json({ sessions: sessions.liveSessionSummaries() }); }
++ catch (err) { res.status(500).json({ error: err.message }); }
++});
++router.get('/talk/session/:user', (req, res) => {
++ try {
++ const v = sessions.viewFor(req.params.user);
++ if (!v) return res.status(404).json({ error: 'No such user' });
++ res.set('Cache-Control', 'no-store');
++ res.json(v);
++ } catch (err) { res.status(500).json({ error: err.message }); }
++});
++router.post('/talk/session/start', requireAuth, async (req, res) => {
++ try { const s = await sessions.startSession(req.user.id); res.json({ ok: true, session: sessions.sessionView(s) }); }
++ catch (err) { res.status(400).json({ error: err.message }); }
++});
++router.post('/talk/session/stop', requireAuth, (req, res) => {
++ try { const s = sessions.stopSession(req.user.id); res.json({ ok: true, session: sessions.sessionView(s) }); }
++ catch (err) { res.status(400).json({ error: err.message }); }
++});
++router.post('/talk/session/skip', requireAuth, async (req, res) => {
++ try { const t = await sessions.skipTopic(req.user.id); res.json({ ok: true, topic: t ? { topic: t.topic, hint: t.hint, idx: t.idx } : null }); }
++ catch (err) { res.status(400).json({ error: err.message }); }
++});
++router.post('/talk/session/:user/hype', optionalAuth, (req, res) => {
++ try {
++ const db = require('../db/database');
++ const user = /^\d+$/.test(req.params.user) ? db.getUserById(Number(req.params.user)) : db.getUserByUsername(req.params.user);
++ if (!user) return res.status(404).json({ error: 'No such user' });
++ const r = sessions.hypeSession(user.id, arena.voterKeyFor(req));
++ if (!r) return res.status(404).json({ error: 'No live session' });
++ res.json(r);
++ } catch (err) { res.status(400).json({ error: err.message }); }
++});
++
router.post('/talk/:id/hype', optionalAuth, (req, res) => {
try { res.json(talk.hype(Number(req.params.id), arena.voterKeyFor(req))); }
catch (err) { res.status(400).json({ error: err.message }); }
ddiff --git a/server/arena/talk-session.js b/server/arena/talk-session.js
nnew file mode 100644
iindex 00000000..12ea4ab6
---- /dev/null
++++ b/server/arena/talk-session.js
@@@ -0,0 +1,434 @@
++/**
++ * OpenVibe.Live — Arena Trash Talk SESSIONS (live, continuous, transcription-driven)
++ *
++ * A streamer who is live with the timeline transcription running starts a session. From
++ * then on, every TICK_MS the session reads the new speech lines off the transcript,
++ * buffers them against the CURRENT TOPIC, and — once enough new words have arrived —
++ * asks the judge whether that chunk was trash talk on the topic and how good it was.
++ *
++ * progress → each judged chunk adds `progress_gain` (0–60) to the topic; at 100 the
++ * topic is CLEARED: it becomes a regular Trash Talk entry (so it counts for
++ * the POWER bonus and the Hall of Trash) and the NEXT topic is generated,
++ * shaped by what they have already cleared. Talk enough → the topic changes.
++ * xp/level → every judged chunk adds XP (quality × trash-talkiness); levels every
++ * XP_PER_LEVEL. Viewers add XP + progress with `!hype` (one per person per
++ * topic).
++ * about → each chunk also yields a ≤ 10-word "what they talked about" tag, so the
++ * page shows what kind of trash was talked during the session.
++ *
++ * Everything is visible live at /arena/talk/<username> (public) and polled every few
++ * seconds; the streamer sees the same console with start / skip / stop controls.
++ * Sessions end when the stream ends, after IDLE_END_MIN minutes without speech, or at
++ * MAX_SESSION_HOURS. AI use is bounded: one judge call per ≥ JUDGE_MIN_INTERVAL_MS and
++ * ≥ JUDGE_MIN_WORDS new words, a few hundred per session at most.
++ */
++'use strict';
++
++const db = require('../db/database');
++const llm = require('../ai/llm');
++
++const TICK_MS = 15 * 1000;
++const JUDGE_MIN_INTERVAL_MS = 30 * 1000;
++const JUDGE_MIN_WORDS = 25;
++const JUDGE_MAX_CALLS = 300;
++const IDLE_END_MIN = 20;
++const MAX_SESSION_HOURS = 3;
++const XP_PER_LEVEL = 40;
++const HYPE_XP = 2;
++const HYPE_PROGRESS = 4;
++const CHUNK_MAX_CHARS = 1400;
++
++let _ready = false;
++function ensureTables() {
++ if (_ready) return;
++ db.run(`CREATE TABLE IF NOT EXISTS arena_talk_sessions (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ user_id INTEGER NOT NULL,
++ stream_id INTEGER NOT NULL,
++ status TEXT NOT NULL DEFAULT 'live',
++ level INTEGER DEFAULT 1,
++ xp INTEGER DEFAULT 0,
++ topics_cleared INTEGER DEFAULT 0,
++ lines_seen INTEGER DEFAULT 0,
++ words_seen INTEGER DEFAULT 0,
++ judge_calls INTEGER DEFAULT 0,
++ last_offset_sec REAL DEFAULT 0,
++ offset_start_sec REAL DEFAULT 0,
++ last_judge_at DATETIME,
++ last_speech_at DATETIME,
++ about_json TEXT,
++ started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ ended_at DATETIME,
++ end_reason TEXT
++ )`);
++ db.run(`CREATE TABLE IF NOT EXISTS arena_talk_session_topics (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ session_id INTEGER NOT NULL,
++ idx INTEGER NOT NULL,
++ topic TEXT NOT NULL,
++ hint TEXT,
++ tone TEXT,
++ status TEXT NOT NULL DEFAULT 'active',
++ progress REAL DEFAULT 0,
++ score REAL DEFAULT 0,
++ chunks INTEGER DEFAULT 0,
++ best_line TEXT,
++ best_line_score REAL DEFAULT 0,
++ best_line_sec INTEGER,
++ best_vod_id INTEGER,
++ pending_json TEXT,
++ judged_json TEXT,
++ entry_id INTEGER,
++ started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ cleared_at DATETIME
++ )`);
++ db.run(`CREATE TABLE IF NOT EXISTS arena_talk_session_hype (
++ topic_id INTEGER NOT NULL,
++ voter_key TEXT NOT NULL,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ UNIQUE(topic_id, voter_key)
++ )`);
++ db.run('CREATE INDEX IF NOT EXISTS idx_arena_talk_sessions_live ON arena_talk_sessions (status, user_id)');
++ _ready = true;
++}
++
++function aiOn() { try { return llm.isEnabled() && llm.withinBudget(); } catch { return false; } }
++function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }
++function arena() { return require('./arena-service'); }
++function talk() { return require('./trash-talk'); }
++function levelFor(xp) { return 1 + Math.floor((Number(xp) || 0) / XP_PER_LEVEL); }
++function words(t) { return String(t || '').split(/\s+/).filter(Boolean).length; }
++
++// ── Lookups ──────────────────────────────────────────────────
++
++function liveSessionFor(userId) {
++ ensureTables();
++ return db.get(`SELECT * FROM arena_talk_sessions WHERE user_id = ? AND status = 'live' ORDER BY id DESC LIMIT 1`, [userId]) || null;
++}
++function activeTopic(sessionId) {
++ return db.get(`SELECT * FROM arena_talk_session_topics WHERE session_id = ? AND status = 'active' ORDER BY idx DESC LIMIT 1`, [sessionId]) || null;
++}
++function liveSessions() {
++ ensureTables();
++ return db.all(`SELECT * FROM arena_talk_sessions WHERE status = 'live'`);
++}
++
++// ── Topics for a session ─────────────────────────────────────
++
++const TOPIC_SCHEMA = {
++ name: 'arena_session_topic',
++ schema: { type: 'object', additionalProperties: false, required: ['topic', 'hint', 'tone'], properties: { topic: { type: 'string' }, hint: { type: 'string' }, tone: { type: 'string', enum: ['brag', 'roast', 'spin', 'bit'] } } },
++};
++const TOPIC_SYSTEM = `You feed topics to a live-streamer who is trash-talking on stream in real time, one topic at a time. Each topic must be answerable in 30–60 seconds of talking, funny, PG-13, and about streaming life, their chat, the Arena ladder, their category, or the day's Main Event — never appearance, identity, health or money. Vary tone and angle from the topics they already cleared. Output only the JSON.`;
++
++async function nextTopic(session, idx) {
++ const cleared = db.all('SELECT topic FROM arena_talk_session_topics WHERE session_id = ? ORDER BY idx ASC', [session.id]).map(r => r.topic);
++ let t = null;
++ if (aiOn()) {
++ try {
++ const roster = arena().loadRoster();
++ const me = roster.byId[session.user_id];
++ const persona = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [session.user_id])?.persona_json);
++ const rivals = roster.order.filter(id => id !== session.user_id).slice(0, 4).map(id => (parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [id])?.persona_json) || {}).fighter_name || roster.byId[id].user.display_name);
++ const r = await llm.complete({
++ role: 'summary', kind: 'arena_session_topic', source: 'arena', ownerUserId: session.user_id,
++ system: TOPIC_SYSTEM,
++ user: JSON.stringify({ fighter: persona?.fighter_name || me?.user?.display_name, category: me?.raw?.category, rank: roster.order.indexOf(session.user_id) + 1, rivals, already_cleared: cleared, topic_number: idx + 1 }),
++ json: TOPIC_SCHEMA, maxTokens: 200, temperature: 1.05, timeoutMs: 20000,
++ });
++ if (r && r.json && r.json.topic) t = r.json;
++ } catch (e) { console.warn('[Arena] session topic:', e.message); }
++ }
++ if (!t) {
++ const pool = talk().FALLBACK_TOPICS || [];
++ const seed = (session.id * 7 + idx * 13) % Math.max(1, pool.length);
++ t = pool[seed] || { topic: 'Talk your talk. Anything goes, PG-13.', hint: 'Chat is listening.', tone: 'bit' };
++ }
++ db.run('INSERT INTO arena_talk_session_topics (session_id, idx, topic, hint, tone) VALUES (?, ?, ?, ?, ?)', [session.id, idx, String(t.topic).slice(0, 200), String(t.hint || '').slice(0, 140), t.tone || 'bit']);
++ return activeTopic(session.id);
++}
++
++// ── Lifecycle ────────────────────────────────────────────────
++
++async function startSession(userId) {
++ ensureTables();
++ const existing = liveSessionFor(userId);
++ if (existing) return existing;
++ const roster = arena().loadRoster();
++ if (!roster.byId[userId]) throw new Error('Only fighters on the roster can run a session — stream once and come back');
++ const mic = talk().micAvailable(userId);
++ if (!mic.available) throw new Error(mic.reason === 'not_live' ? 'Go live first — sessions run off your live transcription' : 'You are live, but no transcription has come through yet. Talk for a few seconds and try again.');
++ const stream = db.get('SELECT id, started_at FROM streams WHERE id = ?', [mic.stream_id]);
++ const startedMs = stream?.started_at ? Date.parse(String(stream.started_at).replace(' ', 'T') + 'Z') : Date.now();
++ const offset = Math.max(0, (Date.now() - startedMs) / 1000);
++ db.run(`INSERT INTO arena_talk_sessions (user_id, stream_id, last_offset_sec, offset_start_sec, last_speech_at, about_json) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, '[]')`, [userId, mic.stream_id, offset, offset]);
++ const session = liveSessionFor(userId);
++ await nextTopic(session, 0);
++ console.log(`[Arena] trash-talk session ${session.id} started for user ${userId} on stream ${mic.stream_id}`);
++ return session;
++}
++
++function endSession(session, reason = 'stopped') {
++ db.run(`UPDATE arena_talk_sessions SET status = 'ended', ended_at = CURRENT_TIMESTAMP, end_reason = ? WHERE id = ? AND status = 'live'`, [reason, session.id]);
++ db.run(`UPDATE arena_talk_session_topics SET status = 'skipped' WHERE session_id = ? AND status = 'active'`, [session.id]);
++ console.log(`[Arena] trash-talk session ${session.id} ended (${reason})`);
++}
++
++function stopSession(userId) {
++ const s = liveSessionFor(userId);
++ if (!s) throw new Error('No live session');
++ endSession(s, 'stopped');
++ return db.get('SELECT * FROM arena_talk_sessions WHERE id = ?', [s.id]);
++}
++
++async function skipTopic(userId) {
++ const s = liveSessionFor(userId);
++ if (!s) throw new Error('No live session');
++ const t = activeTopic(s.id);
++ if (t) db.run(`UPDATE arena_talk_session_topics SET status = 'skipped' WHERE id = ?`, [t.id]);
++ return nextTopic(s, (t ? t.idx : -1) + 1);
++}
++
++// ── Judge (chunk mode) ───────────────────────────────────────
++
++const CHUNK_SCHEMA = {
++ name: 'arena_session_chunk',
++ schema: {
++ type: 'object', additionalProperties: false,
++ required: ['is_trash_talk', 'spice', 'wit', 'on_topic', 'delivery', 'progress_gain', 'best_line', 'about', 'flagged'],
++ properties: {
++ is_trash_talk: { type: 'boolean', description: 'true if a meaningful part of this chunk is trash talk / bragging / roasting aimed at the topic' },
++ spice: { type: 'integer', minimum: 0, maximum: 10 },
++ wit: { type: 'integer', minimum: 0, maximum: 10 },
++ on_topic: { type: 'integer', minimum: 0, maximum: 10 },
++ delivery: { type: 'integer', minimum: 0, maximum: 10 },
++ progress_gain: { type: 'integer', minimum: 0, maximum: 60, description: 'how much of the topic this chunk covered: 0 = not about it, 60 = nailed it completely' },
++ best_line: { type: 'string', description: 'the single best line, copied VERBATIM from the chunk (empty if none)' },
++ about: { type: 'string', description: '≤ 10 words: what they were actually talking about in this chunk' },
++ flagged: { type: 'boolean', description: 'slurs / hate / threats / attacks on appearance, identity, health or money' },
++ },
++ },
++};
++const CHUNK_SYSTEM = `You judge a LIVE trash-talk session. You get the current topic and the newest ~60 seconds of a streamer's speech-to-text. Decide whether they were trash-talking (bragging, roasting, hyping themselves, calling people out — in good fun) ON that topic, score it, say how much of the topic they covered, pick the best verbatim line, and summarise what they talked about in ≤ 10 words. Ordinary gameplay chatter, silence or off-topic talk → is_trash_talk false, low progress. Forgive transcription noise. Flag cruelty (appearance, identity, health, money, threats, hate). Output only the JSON.`;
++
++function heuristicChunk(text, topic) {
++ const h = talk()._heuristicJudge(text, topic.topic);
++ const trash = h.spice >= 5 || /\b(chat|champ|bet|weak|scared|better|best|nobody|clown|cook|ratio)\b/i.test(text);
++ const lines = String(text).split(/(?<=[.!?])\s+/).filter(l => l.length > 12);
++ const best = lines.sort((a, b) => (b.match(/[!?]/g) || []).length - (a.match(/[!?]/g) || []).length)[0] || '';
++ return { is_trash_talk: trash, spice: h.spice, wit: h.wit, on_topic: h.on_topic, delivery: h.delivery, progress_gain: trash ? Math.min(60, 10 + h.on_topic * 4) : Math.min(15, h.on_topic * 2), best_line: best.slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), flagged: false, _fallback: true };
++}
++
++async function judgeChunk(session, topic, text) {
++ if (arena()._isBannedText(text)) return { is_trash_talk: false, spice: 0, wit: 0, on_topic: 0, delivery: 0, progress_gain: 0, best_line: '', about: 'crossed the line — voided', flagged: true };
++ let s = null;
++ if (aiOn()) {
++ try {
++ const r = await llm.complete({
++ role: 'chat', kind: 'arena_session_judge', source: 'arena', ownerUserId: session.user_id,
++ system: CHUNK_SYSTEM,
++ user: JSON.stringify({ topic: topic.topic, hint: topic.hint, tone: topic.tone, progress_so_far: Math.round(topic.progress), speech: text }),
++ json: CHUNK_SCHEMA, maxTokens: 260, temperature: 0.5, timeoutMs: 25000,
++ });
++ if (r && r.json && typeof r.json.spice === 'number') s = r.json;
++ } catch (e) { console.warn('[Arena] session judge:', e.message); }
++ }
++ if (!s) s = heuristicChunk(text, topic);
++ const c = (v, max = 10) => Math.max(0, Math.min(max, Math.round(Number(v) || 0)));
++ return { is_trash_talk: !!s.is_trash_talk && !s.flagged, spice: c(s.spice), wit: c(s.wit), on_topic: c(s.on_topic), delivery: c(s.delivery), progress_gain: s.flagged ? 0 : c(s.progress_gain, 60), best_line: String(s.best_line || '').slice(0, 220), about: String(s.about || '').slice(0, 80), flagged: !!s.flagged, fallback: !!s._fallback };
++}
++
++// ── Tick ─────────────────────────────────────────────────────
++
++async function clearTopic(session, topic) {
++ const judged = parseJson(topic.judged_json, []);
++ const hits = judged.filter(j => j.is_trash_talk);
++ const avg = (k) => hits.length ? hits.reduce((a, j) => a + j[k], 0) / hits.length : 0;
++ const scores = { spice: Math.round(avg('spice')), wit: Math.round(avg('wit')), on_topic: Math.round(avg('on_topic')), delivery: Math.round(avg('delivery')), verdict: `Cleared live on stream — ${hits.length} chunk${hits.length === 1 ? '' : 's'} of trash talk`, note: `Best line: “${topic.best_line || '—'}”`, flagged: false };
++ const text = (hits.map(j => j.best_line).filter(Boolean).join(' … ') || topic.best_line || '').slice(0, 400) || 'Cleared on the live mic.';
++ let entryId = null;
++ try {
++ const t = talk();
++ const topicRow = await t.getTopic({ generate: false });
++ // Session clears live outside the 6-hour slot topic, so they are stored against their
++ // own topic text (in the note) but under the current slot id for the board.
++ const total = t._totalFor(scores, 0);
++ db.run(`INSERT INTO arena_talk (topic_id, user_id, stream_id, source, text, vod_id, start_sec, scores_json, total, flagged, verdict, note, stamp, judge_model, judged_at)
++ VALUES (?, ?, ?, 'session', ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, CURRENT_TIMESTAMP)
++ ON CONFLICT(topic_id, user_id) DO UPDATE SET text = CASE WHEN excluded.total > arena_talk.total THEN excluded.text ELSE arena_talk.text END,
++ scores_json = CASE WHEN excluded.total > arena_talk.total THEN excluded.scores_json ELSE arena_talk.scores_json END,
++ total = MAX(arena_talk.total, excluded.total), stamp = CASE WHEN excluded.total > arena_talk.total THEN excluded.stamp ELSE arena_talk.stamp END,
++ verdict = excluded.verdict, note = excluded.note, source = 'session', vod_id = COALESCE(excluded.vod_id, arena_talk.vod_id), start_sec = COALESCE(excluded.start_sec, arena_talk.start_sec)`,
++ [topicRow.id, session.user_id, session.stream_id, text, topic.best_vod_id, topic.best_line_sec, JSON.stringify(scores), total, scores.verdict, `${topic.topic} — ${scores.note}`, t._stampFor(total), hits[0]?.fallback ? null : 'session']);
++ entryId = db.get('SELECT id FROM arena_talk WHERE topic_id = ? AND user_id = ?', [topicRow.id, session.user_id])?.id || null;
++ } catch (e) { console.warn('[Arena] session clear → entry:', e.message); }
++ db.run(`UPDATE arena_talk_session_topics SET status = 'cleared', progress = 100, cleared_at = CURRENT_TIMESTAMP, entry_id = ? WHERE id = ?`, [entryId, topic.id]);
++ db.run(`UPDATE arena_talk_sessions SET topics_cleared = topics_cleared + 1 WHERE id = ?`, [session.id]);
++ console.log(`[Arena] session ${session.id}: topic ${topic.idx + 1} cleared (“${topic.topic.slice(0, 50)}…”)`);
++ try { arena().loadRoster(true); } catch { /* */ }
++ return nextTopic(session, topic.idx + 1);
++}
++
++async function tickSession(session) {
++ const stream = db.get('SELECT id, is_live, started_at FROM streams WHERE id = ?', [session.stream_id]);
++ if (!stream || !stream.is_live) return endSession(session, 'stream_ended');
++ if (Date.now() - Date.parse(session.started_at + 'Z') > MAX_SESSION_HOURS * 3600 * 1000) return endSession(session, 'max_duration');
++ const lastSpeech = session.last_speech_at ? Date.parse(session.last_speech_at + 'Z') : Date.parse(session.started_at + 'Z');
++ if (Date.now() - lastSpeech > IDLE_END_MIN * 60 * 1000) return endSession(session, 'idle');
++ if (session.judge_calls >= JUDGE_MAX_CALLS) return endSession(session, 'judge_cap');
++
++ let topic = activeTopic(session.id);
++ if (!topic) topic = await nextTopic(session, 0);
++
++ const rows = db.all(`SELECT text, start_sec, end_sec, vod_id FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND start_sec > ? ORDER BY start_sec ASC LIMIT 120`, [session.stream_id, session.last_offset_sec]);
++ if (rows.length) {
++ const pending = parseJson(topic.pending_json, []);
++ for (const r of rows) pending.push({ t: r.text, s: Math.floor(r.start_sec), v: r.vod_id || null });
++ const newWords = rows.reduce((n, r) => n + words(r.text), 0);
++ db.run(`UPDATE arena_talk_sessions SET last_offset_sec = ?, lines_seen = lines_seen + ?, words_seen = words_seen + ?, last_speech_at = CURRENT_TIMESTAMP WHERE id = ?`, [rows[rows.length - 1].start_sec, rows.length, newWords, session.id]);
++ db.run('UPDATE arena_talk_session_topics SET pending_json = ? WHERE id = ?', [JSON.stringify(pending.slice(-60)), topic.id]);
++ topic.pending_json = JSON.stringify(pending.slice(-60));
++ }
++
++ const pending = parseJson(topic.pending_json, []);
++ const pendingWords = pending.reduce((n, l) => n + words(l.t), 0);
++ const sinceJudge = session.last_judge_at ? Date.now() - Date.parse(session.last_judge_at + 'Z') : Infinity;
++ if (pendingWords < JUDGE_MIN_WORDS || sinceJudge < JUDGE_MIN_INTERVAL_MS) return;
++
++ const text = pending.map(l => l.t.replace(/^\s*(?:>>|--?)\s*/, '').trim()).filter(Boolean).join(' ').replace(/\s+/g, ' ').slice(-CHUNK_MAX_CHARS);
++ const j = await judgeChunk(session, topic, text);
++ const quality = (j.spice + j.wit + j.on_topic + j.delivery) / 4;
++ const xpGain = j.flagged ? 0 : Math.round(quality * (j.is_trash_talk ? 1 : 0.15));
++ const judged = parseJson(topic.judged_json, []);
++ judged.push({ at: new Date().toISOString(), ...j, words: pendingWords, quality: Number(quality.toFixed(1)), xp: xpGain });
++ // Locate the best line in the pending buffer for a VOD link.
++ let bestSec = topic.best_line_sec, bestVod = topic.best_vod_id, bestLine = topic.best_line, bestScore = topic.best_line_score || 0;
++ if (j.is_trash_talk && j.best_line && quality > bestScore) {
++ const needle = j.best_line.toLowerCase().slice(0, 30);
++ const hit = pending.find(l => l.t.toLowerCase().includes(needle)) || pending[0];
++ bestLine = j.best_line; bestScore = quality; bestSec = hit ? Math.max(0, hit.s - 2) : null; bestVod = hit ? hit.v : null;
++ }
++ const about = parseJson(session.about_json, []);
++ if (j.about && !j.flagged) { about.push({ text: j.about, topic_idx: topic.idx, hit: j.is_trash_talk, at: new Date().toISOString() }); }
++ const newProgress = Math.min(100, (topic.progress || 0) + (j.is_trash_talk ? j.progress_gain : Math.min(5, j.progress_gain)));
++ const newScore = (topic.score || 0) + (j.is_trash_talk ? quality : 0);
++ const newXp = (session.xp || 0) + xpGain;
++ const newLevel = levelFor(newXp);
++ if (newLevel > (session.level || 1)) console.log(`[Arena] session ${session.id}: level ${newLevel}`);
++ db.run(`UPDATE arena_talk_session_topics SET progress = ?, score = ?, chunks = chunks + 1, judged_json = ?, pending_json = '[]', best_line = ?, best_line_score = ?, best_line_sec = ?, best_vod_id = ? WHERE id = ?`,
++ [newProgress, newScore, JSON.stringify(judged.slice(-40)), bestLine, bestScore, bestSec, bestVod, topic.id]);
++ db.run(`UPDATE arena_talk_sessions SET xp = ?, level = ?, judge_calls = judge_calls + 1, last_judge_at = CURRENT_TIMESTAMP, about_json = ? WHERE id = ?`, [newXp, newLevel, JSON.stringify(about.slice(-60)), session.id]);
++ if (newProgress >= 100) {
++ const fresh = db.get('SELECT * FROM arena_talk_session_topics WHERE id = ?', [topic.id]);
++ await clearTopic({ ...session, xp: newXp }, fresh);
++ }
++}
++
++let _timer = null, _busy = false;
++async function tick() {
++ if (_busy) return;
++ _busy = true;
++ try {
++ for (const s of liveSessions()) {
++ try { await tickSession(s); } catch (e) { console.warn(`[Arena] session ${s.id} tick:`, e.message); }
++ }
++ } finally { _busy = false; }
++}
++function start() {
++ if (_timer) return;
++ ensureTables();
++ _timer = setInterval(() => tick().catch(() => {}), TICK_MS);
++ if (_timer.unref) _timer.unref();
++}
++function stop() { if (_timer) { clearInterval(_timer); _timer = null; } }
++
++// ── Hype (chat / button) ─────────────────────────────────────
++
++function hypeSession(userId, voterKey) {
++ const s = liveSessionFor(userId);
++ if (!s) return null;
++ const t = activeTopic(s.id);
++ if (!t) return null;
++ if (voterKey === `user:${userId}`) throw new Error("You can't hype yourself");
++ const ins = db.run('INSERT OR IGNORE INTO arena_talk_session_hype (topic_id, voter_key) VALUES (?, ?)', [t.id, voterKey]);
++ const uniques = db.get('SELECT COUNT(*) AS n FROM arena_talk_session_hype WHERE topic_id = ?', [t.id])?.n || 0;
++ if (ins.changes) {
++ const xp = (s.xp || 0) + HYPE_XP;
++ db.run('UPDATE arena_talk_sessions SET xp = ?, level = ? WHERE id = ?', [xp, levelFor(xp), s.id]);
++ db.run('UPDATE arena_talk_session_topics SET progress = MIN(100, progress + ?) WHERE id = ?', [HYPE_PROGRESS, t.id]);
++ }
++ return { added: !!ins.changes, hypers: uniques, session_id: s.id, level: levelFor((s.xp || 0) + (ins.changes ? HYPE_XP : 0)), xp: (s.xp || 0) + (ins.changes ? HYPE_XP : 0), progress: Math.min(100, (t.progress || 0) + (ins.changes ? HYPE_PROGRESS : 0)) };
++}
++
++// ── Views ────────────────────────────────────────────────────
++
++function formatTopic(t) {
++ if (!t) return null;
++ const judged = parseJson(t.judged_json, []);
++ const hypers = db.get('SELECT COUNT(*) AS n FROM arena_talk_session_hype WHERE topic_id = ?', [t.id])?.n || 0;
++ return {
++ id: t.id, idx: t.idx, topic: t.topic, hint: t.hint, tone: t.tone, status: t.status,
++ progress: Math.round(t.progress || 0), score: Number((t.score || 0).toFixed(1)), chunks: t.chunks || 0,
++ best_line: t.best_line || null, best_line_sec: t.best_line_sec, best_vod_id: t.best_vod_id,
++ hits: judged.filter(j => j.is_trash_talk).length, hypers,
++ last_judgement: judged.length ? { at: judged[judged.length - 1].at, is_trash_talk: judged[judged.length - 1].is_trash_talk, quality: judged[judged.length - 1].quality, about: judged[judged.length - 1].about, fallback: !!judged[judged.length - 1].fallback } : null,
++ entry_id: t.entry_id || null, started_at: t.started_at, cleared_at: t.cleared_at,
++ };
++}
++
++function sessionView(session, { includeLines = true } = {}) {
++ if (!session) return null;
++ const topics = db.all('SELECT * FROM arena_talk_session_topics WHERE session_id = ? ORDER BY idx ASC', [session.id]);
++ const active = topics.find(t => t.status === 'active') || null;
++ const lines = includeLines ? db.all(`SELECT text, start_sec, vod_id FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND start_sec >= ? ORDER BY start_sec DESC LIMIT 14`, [session.stream_id, session.offset_start_sec])
++ .reverse().filter(l => !arena()._isBannedText(l.text)).map(l => ({ text: l.text, at: Math.max(0, Math.round(l.start_sec - session.offset_start_sec)), sec: Math.floor(l.start_sec), vod_id: l.vod_id })) : [];
++ const about = parseJson(session.about_json, []);
++ return {
++ id: session.id, status: session.status, started_at: session.started_at, ended_at: session.ended_at, end_reason: session.end_reason,
++ level: session.level || 1, xp: session.xp || 0, next_level_xp: levelFor(session.xp || 0) * XP_PER_LEVEL, xp_per_level: XP_PER_LEVEL,
++ topics_cleared: session.topics_cleared || 0, lines_seen: session.lines_seen || 0, words_seen: session.words_seen || 0,
++ active_topic: formatTopic(active),
++ cleared_topics: topics.filter(t => t.status !== 'active').map(formatTopic),
++ talked_about: about.slice(-20).reverse(),
++ recent_lines: lines,
++ stream_id: session.stream_id,
++ judge_is_ai: aiOn(),
++ };
++}
++
++function viewFor(usernameOrId) {
++ ensureTables();
++ const user = /^\d+$/.test(String(usernameOrId)) ? db.getUserById(Number(usernameOrId)) : db.getUserByUsername(String(usernameOrId));
++ if (!user) return null;
++ const session = liveSessionFor(user.id) || db.get(`SELECT * FROM arena_talk_sessions WHERE user_id = ? ORDER BY id DESC LIMIT 1`, [user.id]);
++ const roster = arena().loadRoster();
++ const f = roster.byId[user.id];
++ const persona = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [user.id])?.persona_json);
++ return {
++ user: f ? f.user : { id: user.id, username: user.username, display_name: user.display_name || user.username, avatar_url: user.avatar_url, profile_color: user.profile_color },
++ fighter_name: persona?.fighter_name || user.display_name || user.username,
++ rank: f ? roster.order.indexOf(user.id) + 1 : null,
++ power: f ? f.ratings.power : null,
++ talk_bonus: f ? f.ratings.talk_bonus : 0,
++ image_url: (() => { try { return arena().getFighterImageUrl ? arena().getFighterImageUrl(user.id) : null; } catch { return null; } })(),
++ session: sessionView(session),
++ };
++}
++
++function liveSessionSummaries() {
++ ensureTables();
++ const roster = arena().loadRoster();
++ return liveSessions().map(s => {
++ const f = roster.byId[s.user_id];
++ const t = activeTopic(s.id);
++ const persona = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [s.user_id])?.persona_json);
++ return { user: f ? f.user : { id: s.user_id, username: `user${s.user_id}`, display_name: `user${s.user_id}` }, fighter_name: persona?.fighter_name || f?.user?.display_name, level: s.level, xp: s.xp, topics_cleared: s.topics_cleared, topic: t ? t.topic : null, progress: t ? Math.round(t.progress || 0) : 0, started_at: s.started_at };
++ });
++}
++
++module.exports = {
++ ensureTables, start, stop, tick, tickSession,
++ startSession, stopSession, skipTopic, hypeSession, liveSessionFor, viewFor, sessionView, liveSessionSummaries,
++ TICK_MS, XP_PER_LEVEL, JUDGE_MIN_WORDS, JUDGE_MIN_INTERVAL_MS, IDLE_END_MIN,
++ _judgeChunk: judgeChunk, _heuristicChunk: heuristicChunk, _levelFor: levelFor, _activeTopic: activeTopic, _nextTopic: nextTopic,
++};
ddiff --git a/server/arena/trash-talk.js b/server/arena/trash-talk.js
iindex bfe868dc..4903a78c 100644
---- a/server/arena/trash-talk.js
++++ b/server/arena/trash-talk.js
@@@ -393,6 +393,6 @@ function entriesFor(userId, limit = 6) {
module.exports = {
ensureTables, getTopic, currentSlot, startMic, micFeed, micAvailable, submit, hype, latestEntryFor, board, entriesFor, talkBonuses,
-- TALK_BONUS_MAX, TALK_BONUS_DAYS, TEXT_MAX, MIC_WINDOW_SEC,
++ TALK_BONUS_MAX, TALK_BONUS_DAYS, TEXT_MAX, MIC_WINDOW_SEC, FALLBACK_TOPICS,
_judge: judge, _heuristicJudge: heuristicJudge, _totalFor: totalFor, _crowdScore: crowdScore, _stampFor: stampFor, _sessions: sessions,
};
ddiff --git a/test/arena-session.test.js b/test/arena-session.test.js
nnew file mode 100644
iindex 00000000..ed0f5f7d
---- /dev/null
++++ b/test/arena-session.test.js
@@@ -0,0 +1,120 @@
++'use strict';
++
++// Live trash-talk sessions: start needs a live stream with transcription, the ticker
++// judges new transcript chunks against the current topic, progress clears topics and
++// generates the next one, XP levels up, hype adds XP/progress, and the session ends when
++// the stream ends. No AI configured → heuristic chunk judge, templated topics.
++
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-session-'));
++process.env.DB_PATH = path.join(tmp, 'live.db');
++process.env.ARENA_IMAGE_PATH = path.join(tmp, 'arena');
++const db = require('../server/db/database');
++db.initDb();
++const arena = require('../server/arena/arena-service');
++const sessions = require('../server/arena/talk-session');
++
++const mk = (u, key) => Number(db.createUser({ username: u, email: `${u}@x`, password_hash: 'x', display_name: u.toUpperCase(), stream_key: key }).lastInsertRowid);
++const u1 = mk('alpha', 'a'.repeat(32));
++db.ensureChannel(u1);
++const old = Number(db.createStream({ user_id: u1, title: 'old', category: 'irl', protocol: 'rtmp' }).lastInsertRowid);
++db.run(`UPDATE streams SET is_live = 0, started_at = datetime('now', '-2 days'), ended_at = datetime('now', '-2 days'), duration_seconds = 3600, peak_viewers = 20 WHERE id = ?`, [old]);
++
++assert.strictEqual(sessions._levelFor(0), 1);
++assert.strictEqual(sessions._levelFor(39), 1);
++assert.strictEqual(sessions._levelFor(40), 2);
++assert.strictEqual(sessions._levelFor(125), 4);
++const hc = sessions._heuristicChunk("Chat, your chat couldn't read a menu. I'm the smartest streamer on this whole site, bet!", { topic: 'Explain why your chat is the smartest chat on this site.', hint: '', tone: 'brag' });
++assert.strictEqual(hc.is_trash_talk, true);
++assert.ok(hc.progress_gain > 10 && hc.best_line.length > 5, JSON.stringify(hc));
++const quiet = sessions._heuristicChunk('okay let me open the inventory and craft the pickaxe then we go mining', { topic: 'Explain why your chat is the smartest chat on this site.', hint: '', tone: 'brag' });
++assert.ok(quiet.progress_gain <= 15, 'gameplay chatter barely moves the topic');
++console.log('✅ levels + heuristic chunk judge');
++
++(async () => {
++ await assert.rejects(() => sessions.startSession(u1), /Go live/, 'needs a live stream');
++ const live = Number(db.createStream({ user_id: u1, title: 'live', category: 'irl', protocol: 'rtmp' }).lastInsertRowid);
++ db.run(`UPDATE streams SET is_live = 1, started_at = datetime('now', '-10 minutes') WHERE id = ?`, [live]);
++ await assert.rejects(() => sessions.startSession(u1), /transcription/, 'needs transcript rows');
++ db.addTimelineEvents([{ stream_id: live, user_id: u1, vod_id: 77, kind: 'speech', start_sec: 500, end_sec: 505, text: 'welcome in chat, we are live', label: null, confidence: 0.9 }]);
++
++ const s = await sessions.startSession(u1);
++ assert.strictEqual(s.status, 'live');
++ const again = await sessions.startSession(u1);
++ assert.strictEqual(again.id, s.id, 'one live session per user');
++ let v = sessions.viewFor('alpha');
++ assert.ok(v.session.active_topic && v.session.active_topic.topic, 'a first topic is set');
++ assert.strictEqual(v.session.level, 1);
++ assert.strictEqual(v.session.active_topic.progress, 0);
++ console.log('✅ session start + first topic');
++
++ // Feed trash talk in the transcript AFTER the session offset and tick.
++ const off = 600 + 5; // session started ~600s into the stream
++ const trash = [
++ "Chat, your chat couldn't read a menu. I'm the smartest streamer on this whole site, bet!",
++ 'Nobody on this ladder is scared of nothing except me, champ. Come see who is better.',
++ "You call that a stream? Best believe my chat is smarter, faster, and prettier than yours!",
++ ];
++ db.addTimelineEvents(trash.map((t, i) => ({ stream_id: live, user_id: u1, vod_id: 77, kind: 'speech', start_sec: off + i * 10, end_sec: off + i * 10 + 8, text: t, label: null, confidence: 0.9 })));
++ await sessions.tick();
++ v = sessions.viewFor('alpha');
++ const t1 = v.session.active_topic;
++ assert.ok(t1.chunks >= 1, 'a chunk was judged');
++ assert.ok(t1.progress > 0 && t1.progress <= 100, `progress ${t1.progress}`);
++ assert.ok(v.session.xp > 0, 'xp earned');
++ assert.ok(v.session.recent_lines.length >= 3, 'live lines visible');
++ assert.ok(v.session.talked_about.length >= 1, 'talked-about tags');
++ assert.ok(t1.best_line && t1.best_vod_id === 77 && typeof t1.best_line_sec === 'number', 'best line links to the VOD');
++ console.log('✅ tick judges new speech against the topic');
++
++ // Hype from two viewers, self-hype blocked.
++ const h1 = sessions.hypeSession(u1, 'user:50');
++ assert.strictEqual(h1.added, true);
++ assert.strictEqual(sessions.hypeSession(u1, 'user:50').added, false);
++ sessions.hypeSession(u1, 'anon:abc');
++ assert.throws(() => sessions.hypeSession(u1, `user:${u1}`), /yourself/);
++ v = sessions.viewFor('alpha');
++ assert.strictEqual(v.session.active_topic.hypers, 2);
++ console.log('✅ hype adds XP + progress');
++
++ // Keep talking (bypass the 30 s judge interval by rewinding last_judge_at) until the topic clears.
++ let cleared = 0;
++ for (let round = 0; round < 8 && !cleared; round++) {
++ db.run(`UPDATE arena_talk_sessions SET last_judge_at = datetime('now', '-2 minutes') WHERE id = ?`, [s.id]);
++ db.addTimelineEvents(trash.map((t, i) => ({ stream_id: live, user_id: u1, vod_id: 77, kind: 'speech', start_sec: off + 100 + round * 40 + i * 10, end_sec: off + 100 + round * 40 + i * 10 + 8, text: t + ` (${round})`, label: null, confidence: 0.9 })));
++ await sessions.tick();
++ cleared = sessions.viewFor('alpha').session.topics_cleared;
++ }
++ v = sessions.viewFor('alpha');
++ assert.strictEqual(v.session.topics_cleared, 1, 'talking enough clears the topic');
++ assert.strictEqual(v.session.cleared_topics.length, 1);
++ assert.strictEqual(v.session.cleared_topics[0].status, 'cleared');
++ assert.ok(v.session.active_topic && v.session.active_topic.idx === 1, 'a new topic replaces it');
++ assert.notStrictEqual(v.session.active_topic.topic, v.session.cleared_topics[0].topic, 'the new topic differs');
++ const entry = db.get(`SELECT * FROM arena_talk WHERE user_id = ? AND source = 'session'`, [u1]);
++ assert.ok(entry && entry.total > 0, 'a cleared topic becomes a Trash Talk entry');
++ assert.ok(arena.loadRoster(true).byId[u1].ratings.talk_bonus >= 1, 'and feeds the POWER bonus');
++ console.log('✅ topic clears → next topic + entry + bonus');
++
++ const skipped = await sessions.skipTopic(u1);
++ assert.strictEqual(skipped.idx, 2);
++ assert.strictEqual(sessions.viewFor('alpha').session.cleared_topics.filter(t => t.status === 'skipped').length, 1);
++ assert.ok(sessions.liveSessionSummaries().length === 1 && sessions.liveSessionSummaries()[0].user.username === 'alpha');
++ console.log('✅ skip + live list');
++
++ db.run('UPDATE streams SET is_live = 0 WHERE id = ?', [live]);
++ await sessions.tick();
++ v = sessions.viewFor('alpha');
++ assert.strictEqual(v.session.status, 'ended');
++ assert.strictEqual(v.session.end_reason, 'stream_ended');
++ assert.strictEqual(sessions.liveSessionFor(u1), null);
++ assert.throws(() => sessions.stopSession(u1), /No live session/);
++ console.log('✅ session ends with the stream');
++
++ console.log('\n✅ All session tests passed');
++ process.exit(0);
++})().catch(err => { console.error(err); process.exit(1); });