openvibers/openvibe.live Commits
Arena: live trash-talk sessions (transcription-driven power leveling) + phone layout pass
Changes
@@@ -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`.@@@ -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; } }@@@ -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>@@@ -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 <user></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;@@@ -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);@@@ -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; } }@@@ -1140,6 +1140,7 @@ module.exports = {STAT_WEIGHTS,ROUNDS,ARENA_DIR,++ getFighterImageUrl: (userId) => imageUrlFor(profileRow(userId)),// pure helpers (tests)_computeRatings: computeRatings,_simulateRounds: simulateRounds,@@@ -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 }); }nnew file mode 100644@@@ -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,++};@@@ -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,};nnew file mode 100644@@@ -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); });
@@ -67,6 +67,19 @@ The part streamers and viewers play themselves.
@@ -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.
- **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.
- **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.
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`.
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`.
@@ -14254,3 +14254,112 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
@@ -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-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; }
.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; } }
@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; } }
@@ -62,7 +62,7 @@
@@ -62,7 +62,7 @@
</script>
</script>
<!-- Stylesheets -->
<!-- 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/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=3" defer></script>
+<script src="/js/arena.js?v=4" 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>
@@ -131,7 +131,7 @@ async function loadArenaPage(segments = []) {
@@ -131,7 +131,7 @@ async function loadArenaPage(segments = []) {
const [, first, second, third] = segments;
const [, first, second, third] = segments;
try {
try {
if (first === 'battle' && second && third) return await _aRenderBattle(root, second, third);
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);
if (first && first !== 'battle') return await _aRenderFighter(root, first);
return await _aRenderHome(root);
return await _aRenderHome(root);
} catch (err) {
} catch (err) {
@@ -709,6 +709,8 @@ async function _aRenderTalk(root) {
@@ -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 <user></code></p>
<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 <user></code></p>
</div>
</div>
</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-talk-enter" id="arena-talk-enter"></section>
<section class="arena-board">
<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>
<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) {
@@ -718,6 +720,8 @@ async function _aRenderTalk(root) {
_aBindSpeak(root);
_aBindSpeak(root);
_aBindHype(root);
_aBindHype(root);
_aRenderTalkEnter(t, me);
_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));
_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) {
@@ -823,4 +827,119 @@ function _aRenderTalkEnter(t, me) {
renderMode(mic.available ? 'mic' : 'text');
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;
window.loadArenaPage = loadArenaPage;
@@ -49,6 +49,16 @@ function handle(chat, ws, client, cmd, parts) {
@@ -49,6 +49,16 @@ function handle(chat, ws, client, cmd, parts) {
try {
try {
if (cmd === '!hype') {
if (cmd === '!hype') {
if (!streamer) return reply('!hype works inside a streamer\'s chat.');
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);
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`);
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);
const r = talk.hype(entry.id, key);
@@ -49,7 +49,9 @@ function start() {
@@ -49,7 +49,9 @@ function start() {
_timer = setInterval(() => tick().catch(e => console.warn('[Arena] job:', e.message)), INTERVAL_MS);
_timer = setInterval(() => tick().catch(e => console.warn('[Arena] job:', e.message)), INTERVAL_MS);
if (_timer.unref) _timer.unref();
if (_timer.unref) _timer.unref();
setTimeout(() => tick().catch(() => {}), 90_000).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; } }
function stop() { if (_timer) { clearInterval(_timer); _timer = null; } }
@@ -1140,6 +1140,7 @@ module.exports = {
@@ -1140,6 +1140,7 @@ module.exports = {
STAT_WEIGHTS,
STAT_WEIGHTS,
ROUNDS,
ROUNDS,
ARENA_DIR,
ARENA_DIR,
+ getFighterImageUrl: (userId) => imageUrlFor(profileRow(userId)),
// pure helpers (tests)
// pure helpers (tests)
_computeRatings: computeRatings,
_computeRatings: computeRatings,
_simulateRounds: simulateRounds,
_simulateRounds: simulateRounds,
@@ -81,6 +81,44 @@ router.post('/talk/submit', requireAuth, async (req, res) => {
@@ -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) => {
router.post('/talk/:id/hype', optionalAuth, (req, res) => {
try { res.json(talk.hype(Number(req.params.id), arena.voterKeyFor(req))); }
try { res.json(talk.hype(Number(req.params.id), arena.voterKeyFor(req))); }
catch (err) { res.status(400).json({ error: err.message }); }
catch (err) { res.status(400).json({ error: err.message }); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,434 @@
@@ -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,
+};
@@ -393,6 +393,6 @@ function entriesFor(userId, limit = 6) {
@@ -393,6 +393,6 @@ function entriesFor(userId, limit = 6) {
module.exports = {
module.exports = {
ensureTables, getTopic, currentSlot, startMic, micFeed, micAvailable, submit, hype, latestEntryFor, board, entriesFor, talkBonuses,
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,
_judge: judge, _heuristicJudge: heuristicJudge, _totalFor: totalFor, _crowdScore: crowdScore, _stampFor: stampFor, _sessions: sessions,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -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); });