openvibers/openvibe.live Commits

Arena: Trash Talk — streamers enter by live mic or text, AI judge scores, chat hypes, POWER bonus; chat commands

OP OpenVibers committed 29 Aug 2026 · 5e9ba92e37 · 10 files, +972 −2

The part of the Arena streamers and viewers play themselves (server/arena/trash-talk.js,
/arena/talk):

- Topic every 6 h, AI-written from the main event / top of the ladder (templated when
  AI is off), seeded per slot.
- A streamer on the roster enters once per topic: LIVE MIC (start → say it on stream →
  drop the mic; the entry is what the transcription heard in that window, linked to
  the VOD second, with a hot-mic feed while it runs) or typed (≤ 280 chars).
- AI judge: Spice / Wit / On-topic / Delivery (0–10) + verdict, judge's note, stamp
  (COOKED / SOLID / MID / FLOP); the hard slur filter runs before the model and the
  model can flag cruelty — flagged entries score 0 and are hidden from others.
  Heuristic scoring when AI is off. Score reveal animates on the page.
- Crowd is the fifth score: `!hype` in the streamer's chat or the Hype button, one per
  person, min(10, hypers / 3), open for 24 h.
- Total /50 → Trash Talk bonus on POWER (max +12, linear decay over 7 days), shown on
  the profile and used in the leaderboard order. Hall of Trash: best lines, 30 days.
- Chat commands hooked into ChatServer.handleBangCommand via server/arena/arena-chat.js:
  !hype, !talk, !arena [user], !vote a|b (main event, signed-in), !fight <user>;
  rate-limited per person; hype milestones and call-outs announced to the room.
- Topic card on the Arena home; trash-talk record on profiles; docs; tests
  (test/arena-talk.test.js incl. a fake ChatServer).

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

Changes

ddiff --git a/docs/arena.md b/docs/arena.md
iindex a764be5b..bd052ef8 100644
---- a/docs/arena.md
++++ b/docs/arena.md
@@@ -55,6 +55,22 @@ Portraits are written to `data/arena/` (override with `ARENA_IMAGE_PATH`) and se
- 🔊 buttons read taunts, quotes and the announcer's call aloud with the browser's own speech synthesis (no server cost); ▶ on a quote jumps to the VOD at that moment so you hear the real thing.
- Live matchups show the **hot mic** — the last line the transcription heard on each live stream.
++
++## Trash Talk (`/arena/talk`)
++
++The part streamers and viewers play themselves.
++
++- **Topic** — every 6 h the Arena posts a topic (AI-written from the main event / top of the ladder; templated when AI is off), e.g. *"Explain why your chat is the smartest chat on this site."*
++- **Entering** — a streamer on the roster enters once per topic, either from the **live mic** (press *I'm about to cook*, say it on stream, *Drop the mic* — the entry is what the transcription heard in that window, linked to the VOD second) or by **typing** (≤ 280 chars). Live mic needs the stream to be live with the timeline transcription running.
++- **Judge** — the AI scores **Spice, Wit, On-topic, Delivery** (0–10 each) with a verdict, a judge's note and a stamp (COOKED ≥ 42 · SOLID ≥ 30 · MID ≥ 18 · FLOP). Slurs/hate/threats are caught by the hard filter before the model, and the model can flag cruelty: a flagged entry scores 0 and its text is hidden from everyone but the author. Heuristic scoring when AI is off.
++- **Crowd** — viewers add the fifth score: `!hype` in the streamer's chat or the Hype button, one per person, `Crowd = min(10, hypers / 3)`, open for 24 h.
++- **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.
++
++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`.
++
## Guard rails
- Persona and commentary prompts forbid mocking appearance, body, voice, disability, age, ethnicity, gender, religion, health or money; everything is framed as Arena lore, PG-13.
ddiff --git a/public/css/style.css b/public/css/style.css
iindex ef6854ab..67e786da 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -14183,3 +14183,74 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
.hero-bg-layer.active { animation: none !important; }
}
@media (prefers-reduced-motion: reduce) { .hero-chart-svg .bar { animation: none; } .hero-chart, .hero-chart-overlay { animation: none; } }
++
++/* ── Arena: Trash Talk ── */
++.arena-talk-card { margin: 4px 0 24px; padding: 16px 18px; border-radius: 16px; border: 1px solid var(--border); background: linear-gradient(120deg, color-mix(in srgb, #f97316 14%, var(--bg-card)), var(--bg-card) 65%); }
++.arena-talk-card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
++.arena-talk-card-head h2 { margin: 0; font-size: 1.1rem; display: flex; align-items: center; gap: 8px; }
++.arena-talk-card-head h2 i { color: #f97316; }
++.arena-talk-card-head h2 small { color: var(--text-muted); font-weight: 400; font-size: 0.8rem; }
++.arena-talk-topic { font-size: 1.15rem; font-weight: 600; margin: 10px 0 8px; line-height: 1.35; }
++.arena-talk-card-foot { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; font-size: 0.85rem; color: var(--text-secondary); }
++.arena-talk-card-foot code, .arena-talk-how code, .arena-talk-gate code, .arena-talk-hype code { background: var(--bg-tertiary); padding: 1px 5px; border-radius: 4px; font-size: 0.85em; }
++.arena-stamp { display: inline-block; padding: 2px 8px; border-radius: 6px; font-size: 0.7rem; font-weight: 900; letter-spacing: 0.08em; border: 1.5px solid currentColor; transform: rotate(-3deg); }
++.arena-stamp--cooked { color: #f97316; } .arena-stamp--solid { color: #22c55e; } .arena-stamp--mid { color: #eab308; } .arena-stamp--flop { color: #94a3b8; }
++.arena-stamp--big { font-size: 1.3rem; padding: 6px 14px; animation: arena-stamp-in 0.5s cubic-bezier(0.2, 1.4, 0.4, 1) both; animation-delay: 1.3s; }
++@keyframes arena-stamp-in { from { transform: scale(2.2) rotate(-12deg); opacity: 0; } to { transform: scale(1) rotate(-3deg); opacity: 1; } }
++.arena-talk-hero { display: grid; grid-template-columns: 1.3fr 1fr; gap: 20px; margin-bottom: 20px; }
++.arena-talk-hero-main { padding: 22px; border-radius: 18px; border: 1px solid var(--border); background: radial-gradient(80% 60% at 0% 0%, color-mix(in srgb, #f97316 20%, transparent), transparent), var(--bg-card); }
++.arena-talk-kicker { font-size: 0.75rem; letter-spacing: 0.08em; text-transform: uppercase; color: #f97316; font-weight: 700; margin-bottom: 8px; }
++.arena-talk-kicker span { color: var(--text-muted); text-transform: none; letter-spacing: 0; }
++.arena-talk-topic-big { margin: 0; font-size: clamp(1.4rem, 3vw, 2rem); line-height: 1.25; }
++.arena-talk-hint { color: var(--text-secondary); margin: 12px 0 0; }
++.arena-talk-hint i { color: #eab308; }
++.arena-talk-how { padding: 16px 18px; border-radius: 18px; border: 1px solid var(--border); background: var(--bg-card); font-size: 0.88rem; }
++.arena-talk-how h3 { margin: 0 0 8px; font-size: 1rem; }
++.arena-talk-how ol { margin: 0 0 8px; padding-left: 20px; display: flex; flex-direction: column; gap: 6px; color: var(--text-secondary); }
++.arena-talk-how b { color: var(--text-primary); }
++.arena-talk-panel { margin-bottom: 24px; padding: 18px; border-radius: 18px; border: 2px solid color-mix(in srgb, #f97316 45%, var(--border)); background: var(--bg-card); }
++.arena-talk-panel-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 12px; }
++.arena-talk-panel-head h2 { margin: 0; font-size: 1.1rem; display: flex; align-items: center; gap: 8px; } .arena-talk-panel-head h2 i { color: #f97316; }
++.arena-talk-modes { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
++.arena-talk-mode { padding: 8px 14px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-secondary); cursor: pointer; font-weight: 600; }
++.arena-talk-mode.active { border-color: #f97316; color: var(--text-primary); background: color-mix(in srgb, #f97316 14%, var(--bg-tertiary)); }
++.arena-talk-mode:disabled { opacity: 0.5; cursor: not-allowed; }
++.arena-talk-mode small { font-weight: 400; color: var(--text-muted); }
++.arena-talk-panel textarea { width: 100%; min-height: 90px; padding: 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text-primary); font: inherit; resize: vertical; }
++.arena-talk-actions { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-top: 10px; flex-wrap: wrap; }
++.arena-mic-status { display: flex; align-items: center; gap: 8px; font-weight: 600; color: var(--text-secondary); }
++.arena-mic-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--text-muted); }
++.arena-mic-status.is-live { color: #ef4444; } .arena-mic-status.is-live .arena-mic-dot { background: #ef4444; box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.25); animation: arena-pulse 1.2s infinite; }
++.arena-mic-feed { margin-top: 10px; min-height: 90px; max-height: 220px; overflow-y: auto; padding: 10px 12px; border-radius: 12px; background: var(--bg-secondary); border: 1px dashed var(--border); font-size: 0.9rem; }
++.arena-mic-line { display: flex; gap: 10px; padding: 3px 0; animation: arena-in 0.3s; } .arena-mic-line span { color: var(--text-muted); font-variant-numeric: tabular-nums; font-size: 0.78rem; min-width: 38px; }
++.arena-talk-gate { padding: 14px 16px; border-radius: 12px; background: var(--bg-tertiary); border: 1px solid var(--border); margin-bottom: 20px; color: var(--text-secondary); }
++.arena-talk-gate.is-done { border-color: #22c55e; } .arena-talk-gate.is-error { border-color: var(--danger); color: var(--danger); }
++.arena-talk-list { display: flex; flex-direction: column; gap: 10px; }
++.arena-talk-entry { padding: 14px 16px; border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); transition: transform 0.1s; }
++.arena-talk-entry.is-mine { border-color: color-mix(in srgb, #f97316 50%, var(--border)); }
++.arena-talk-entry.is-flagged { opacity: 0.7; }
++.arena-talk-entry.is-hit { animation: arena-hit 0.5s; }
++.arena-talk-entry-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; }
++.arena-talk-who { display: flex; align-items: center; gap: 10px; color: var(--text-primary); text-decoration: none; min-width: 0; }
++.arena-talk-who span { display: flex; flex-direction: column; min-width: 0; } .arena-talk-who small { color: var(--text-muted); font-size: 0.75rem; } .arena-talk-who em { font-style: italic; }
++.arena-talk-total { display: flex; align-items: center; gap: 8px; } .arena-talk-total b { font-size: 1.5rem; color: var(--accent-light); } .arena-talk-total small { color: var(--text-muted); }
++.arena-talk-text { margin: 10px 0; padding: 10px 14px; border-left: 3px solid #f97316; background: var(--bg-tertiary); border-radius: 0 10px 10px 0; font-size: 1rem; line-height: 1.5; }
++.arena-talk-hear { margin-left: 8px; font-size: 0.8rem; color: var(--accent-light); text-decoration: none; }
++.arena-talk-void { color: var(--text-muted); font-style: italic; }
++.arena-talk-verdict { margin: 6px 0; font-weight: 600; } .arena-talk-verdict i { color: var(--accent); margin-right: 6px; }
++.arena-talk-note { margin: 4px 0; font-size: 0.85rem; color: var(--text-secondary); font-style: italic; }
++.arena-talk-scores { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 6px 16px; margin: 8px 0; }
++.arena-talk-score { display: grid; grid-template-columns: 86px 1fr 24px; align-items: center; gap: 8px; font-size: 0.8rem; }
++.arena-talk-score-label { color: var(--text-secondary); } .arena-talk-score-label i { width: 14px; color: #f97316; }
++.arena-talk-score-track { height: 8px; border-radius: 4px; background: var(--bg-tertiary); overflow: hidden; }
++.arena-talk-score-fill { display: block; height: 100%; background: linear-gradient(90deg, #f97316, #fbbf24); transform-origin: left; }
++.arena-talk-scores.is-animated .arena-talk-score-fill { animation: arena-fill 0.7s cubic-bezier(0.2, 0.8, 0.3, 1) both; animation-delay: calc(0.15s + var(--i, 0) * 0.22s); }
++@keyframes arena-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
++.arena-talk-score b { text-align: right; font-variant-numeric: tabular-nums; }
++.arena-talk-hype { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 8px; font-size: 0.82rem; color: var(--text-secondary); }
++.arena-talk-reveal { padding: 16px; border-radius: 14px; border: 1px solid #f97316; background: radial-gradient(70% 50% at 50% 0%, color-mix(in srgb, #f97316 18%, transparent), transparent), var(--bg-card); animation: arena-in 0.3s; }
++.arena-talk-reveal.is-flagged { border-color: var(--danger); }
++.arena-talk-reveal-head { display: flex; align-items: center; justify-content: center; gap: 18px; margin-bottom: 8px; }
++.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; } }
ddiff --git a/public/index.html b/public/index.html
iindex 166a1164..c92b52e6 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -62,7 +62,7 @@
</script>
<!-- Stylesheets -->
-- <link rel="stylesheet" href="/css/style.css?v=143">
++ <link rel="stylesheet" href="/css/style.css?v=144">
<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=2" defer></script>
++<script src="/js/arena.js?v=3" defer></script>
<script src="/js/powerchat.js?v=10" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
ddiff --git a/public/js/arena.js b/public/js/arena.js
iindex 7a7cb464..f634863a 100644
---- a/public/js/arena.js
++++ b/public/js/arena.js
@@@ -31,6 +31,7 @@ function _aStopTimers() {
for (const t of _arenaBattleTimers) clearTimeout(t);
_arenaBattleTimers = [];
_aStopSpeaking();
++ if (typeof _aTalkStop === 'function') _aTalkStop();
}
// ── Speech: hear the taunt / quotes read out (browser TTS, no server cost) ──
@@@ -130,6 +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 && first !== 'battle') return await _aRenderFighter(root, first);
return await _aRenderHome(root);
} catch (err) {
@@@ -162,6 +164,7 @@ async function _aRenderHome(root) {
</div>
</div>
<section id="arena-main-event" class="arena-main-event" style="display:none"></section>
++ <section id="arena-talk-card" class="arena-talk-card" style="display:none"></section>
<section id="arena-live" class="arena-live"></section>
<section class="arena-board">
<div class="arena-board-head">
@@@ -173,6 +176,7 @@ async function _aRenderHome(root) {
_aRenderLive(live);
_aRenderList(fighters);
api('/arena/main-event').then(me => _aRenderMainEvent(me)).catch(() => {});
++ api('/arena/talk?generate=0').then(t => _aRenderTalkCard(t)).catch(() => {});
document.getElementById('arena-search').addEventListener('input', (e) => {
const q = e.target.value.trim().toLowerCase();
_aRenderList(q ? fighters.filter(f => [f.user.username, f.user.display_name, f.persona.fighter_name, f.persona.class, f.persona.element].some(s => String(s || '').toLowerCase().includes(q))) : fighters);
@@@ -355,6 +359,7 @@ async function _aRenderFighter(root, username) {
</div>
<div class="arena-profile-power">
<div class="arena-power arena-power-lg"><b>${f.ratings.power}</b><small>POWER</small></div>
++ ${f.ratings.talk_bonus ? `<div class="arena-talk-bonus" title="Trash Talk bonus — earned on /arena/talk, decays over a week"><i class="fa-solid fa-microphone-lines"></i> +${f.ratings.talk_bonus} trash talk</div>` : ''}
<div class="arena-record arena-record-lg">${f.record.wins}W – ${f.record.losses}L</div>
</div>
<div class="arena-profile-stats">
@@@ -383,6 +388,7 @@ async function _aRenderFighter(root, username) {
</div>
</div>
</div>
++ ${(f.trash_talk || []).length ? `<section class="arena-challenge"><h2><i class="fa-solid fa-microphone-lines"></i> Trash talk record</h2><div class="arena-talk-list">${f.trash_talk.map(e => _aTalkEntry(e, { compact: true })).join('')}</div></section>` : ''}
${recent.length ? `<section class="arena-challenge"><h2>Recent fights</h2><div class="arena-opponents">${recent.map(b => _aA(_aBattleLink(f.user, b.opponent), `${_aPortrait({ user: b.opponent }, 'xs')}<span><strong>${b.result === 'win' ? '<span style="color:var(--success)">W</span>' : b.result === 'loss' ? '<span style="color:var(--danger)">L</span>' : 'D'} vs ${_aEsc(b.opponent_fighter_name)}</strong><small>${_aEsc(b.day)} · rounds ${b.rounds_won}/${b.rounds_total}</small></span>`, 'arena-opponent')).join('')}</div></section>` : ''}
<section class="arena-challenge">
<h2>Pick an opponent</h2>
@@@ -622,4 +628,199 @@ function _aRenderVote(battle) {
render(battle.votes, battle.outcome, battle.your_vote || null);
}
++// ── Trash Talk ───────────────────────────────────────────────
++
++const ARENA_TALK_SCORES = [['spice', 'Spice', 'fa-pepper-hot'], ['wit', 'Wit', 'fa-brain'], ['on_topic', 'On topic', 'fa-bullseye'], ['delivery', 'Delivery', 'fa-microphone'], ['crowd', 'Crowd', 'fa-people-group']];
++let _arenaTalkTimers = [];
++function _aTalkStop() { for (const t of _arenaTalkTimers) clearInterval(t); _arenaTalkTimers = []; }
++
++function _aStampClass(stamp) { return `arena-stamp arena-stamp--${String(stamp || '').toLowerCase()}`; }
++function _aCountdown(iso) {
++ const ms = Date.parse(iso) - Date.now();
++ if (ms <= 0) return 'new topic any moment';
++ const h = Math.floor(ms / 3600000), m = Math.floor((ms % 3600000) / 60000);
++ return h ? `${h}h ${m}m left` : `${m}m left`;
++}
++
++/** Topic card on the Arena home. */
++function _aRenderTalkCard(t) {
++ const el = document.getElementById('arena-talk-card');
++ if (!el || !t || !t.topic) return;
++ const top = t.entries && t.entries[0];
++ el.style.display = '';
++ el.innerHTML = `
++ <div class="arena-talk-card-head"><h2><i class="fa-solid fa-microphone-lines"></i> Trash Talk <small>${_aEsc(_aCountdown(t.topic.ends_at))}</small></h2>${_aA('/arena/talk', `<i class="fa-solid fa-fire"></i> ${t.can_enter ? 'Enter now' : 'Open'}`, 'btn btn-primary')}</div>
++ <p class="arena-talk-topic">“${_aEsc(t.topic.topic)}”</p>
++ <div class="arena-talk-card-foot">
++ <span>${t.entries?.length ? `${t.entries.length} entr${t.entries.length === 1 ? 'y' : 'ies'}` : 'No entries yet — be first'}${top ? ` · leading: <b>${_aEsc(top.fighter_name)}</b> ${top.total}/50 <span class="${_aStampClass(top.stamp)}">${_aEsc(top.stamp)}</span>` : ''}</span>
++ <span class="arena-note">Streamers talk on the mic or type it · the AI judges · chat types <code>!hype</code> · up to +${t.rules?.bonus_max || 12} POWER</span>
++ </div>`;
++}
++
++function _aScoreBars(scores, { animate = false } = {}) {
++ if (!scores) return '';
++ return `<div class="arena-talk-scores ${animate ? 'is-animated' : ''}">${ARENA_TALK_SCORES.map(([k, label, icon], i) => `
++ <div class="arena-talk-score" style="--i:${i}">
++ <span class="arena-talk-score-label"><i class="fa-solid ${icon}"></i> ${_aEsc(label)}</span>
++ <span class="arena-talk-score-track"><span class="arena-talk-score-fill" style="width:${Math.min(100, (Number(scores[k]) || 0) * 10)}%"></span></span>
++ <b>${Number(scores[k]) || 0}</b>
++ </div>`).join('')}</div>`;
++}
++
++function _aTalkEntry(e, { compact = false, mine = false, hypeable = true } = {}) {
++ const flagged = e.flagged;
++ const jump = e.vod_id && e.start_sec != null ? `<a class="arena-talk-hear" href="/vod/${e.vod_id}?t=${e.start_sec}" onclick="return handleLinkClick(event, '/vod/${e.vod_id}?t=${e.start_sec}')" title="Hear them say it"><i class="fa-solid fa-play"></i> hear it</a>` : '';
++ return `<div class="arena-talk-entry ${flagged ? 'is-flagged' : ''} ${mine ? 'is-mine' : ''}" data-id="${e.id}">
++ <div class="arena-talk-entry-head">
++ ${e.user ? _aA(_aFighterLink(e.user), `${_aPortrait({ user: e.user, image_url: e.image_url }, 'xs')}<span><strong>${_aEsc(e.fighter_name)}</strong><small>${_aEsc(e.user.display_name)}${e.rank ? ` · #${e.rank}` : ''} · ${e.source === 'mic' ? '<i class="fa-solid fa-microphone"></i> live mic' : '<i class="fa-solid fa-keyboard"></i> typed'}${e.topic ? ` · <em>${_aEsc(e.topic)}</em>` : ''}</small></span>`, 'arena-talk-who') : ''}
++ <div class="arena-talk-total"><b>${e.total}</b><small>/50</small> <span class="${_aStampClass(e.stamp)}">${_aEsc(e.stamp || '')}</span></div>
++ </div>
++ ${e.text ? `<blockquote class="arena-talk-text">${_aEsc(e.text)} ${_aSpeakBtn(e.text)} ${jump}</blockquote>` : '<p class="arena-talk-void"><i class="fa-solid fa-ban"></i> Voided by the judge — crossed the line.</p>'}
++ ${e.verdict ? `<p class="arena-talk-verdict"><i class="fa-solid fa-gavel"></i> ${_aEsc(e.verdict)}</p>` : ''}
++ ${!compact ? _aScoreBars(e.scores) : ''}
++ ${!compact && e.note && (mine || !flagged) ? `<p class="arena-talk-note">Judge's note: ${_aEsc(e.note)}</p>` : ''}
++ ${!compact && !flagged ? `<div class="arena-talk-hype"><button class="btn btn-ghost btn-sm arena-hype-btn" data-id="${e.id}" ${!hypeable || !e.hype_open ? 'disabled' : ''}><i class="fa-solid fa-fire"></i> Hype</button><span class="arena-talk-hype-count">${e.crowd_uniques} hyped · Crowd ${e.scores?.crowd ?? 0}/10</span><span class="arena-note">or type <code>!hype</code> in their chat</span></div>` : ''}
++ </div>`;
++}
++
++async function _aRenderTalk(root) {
++ _aTalkStop();
++ root.innerHTML = _aSpinner('Setting up the ring…');
++ const t = await api('/arena/talk');
++ const topic = t.topic;
++ const me = (typeof currentUser !== 'undefined' && currentUser) ? currentUser : null;
++ const rules = t.rules || {};
++ root.innerHTML = `
++ <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')}</div>
++ <div class="arena-talk-hero">
++ <div class="arena-talk-hero-main">
++ <div class="arena-talk-kicker"><i class="fa-solid fa-microphone-lines"></i> Trash Talk · topic changes every ${rules.topic_slot_hours || 6} h · <span id="arena-talk-countdown">${_aEsc(_aCountdown(topic.ends_at))}</span></div>
++ <h1 class="arena-talk-topic-big">“${_aEsc(topic.topic)}”</h1>
++ ${topic.hint ? `<p class="arena-talk-hint"><i class="fa-solid fa-lightbulb"></i> ${_aEsc(topic.hint)}</p>` : ''}
++ </div>
++ <div class="arena-talk-how">
++ <h3>How it works</h3>
++ <ol>
++ <li><b>Streamers</b> on the roster answer the topic — on the <b>live mic</b> (we transcribe what you say on stream) or by <b>typing</b> it.</li>
++ <li>The <b>AI judge</b> scores Spice, Wit, On-topic and Delivery (0–10 each). Slurs or cruelty void the entry.</li>
++ <li><b>Viewers</b> add the fifth score: type <code>!hype</code> in that streamer's chat (or tap Hype here). 3 people = 1 point, up to 10.</li>
++ <li>Your total (/50) becomes a <b>Trash Talk bonus</b> on POWER — up to +${rules.bonus_max || 12}, fading over ${rules.bonus_days || 7} days.</li>
++ </ol>
++ <p class="arena-note">Chat commands: <code>!talk</code> topic · <code>!hype</code> · <code>!arena</code> card · <code>!vote a|b</code> main event · <code>!fight &lt;user&gt;</code></p>
++ </div>
++ </div>
++ <section class="arena-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>
++ <div class="arena-talk-list" id="arena-talk-entries">${t.entries.length ? t.entries.map(e => _aTalkEntry(e, { mine: me && e.user.id === me.id })).join('') : '<div class="arena-empty"><i class="fa-solid fa-microphone-slash"></i><p>Nobody has talked yet. The mic is right there.</p></div>'}</div>
++ </section>
++ ${t.hall_of_trash?.length ? `<section class="arena-board"><div class="arena-board-head"><h2><i class="fa-solid fa-trophy"></i> Hall of Trash <small class="arena-note">best lines, last 30 days</small></h2></div><div class="arena-talk-list">${t.hall_of_trash.map(e => _aTalkEntry(e, { compact: true })).join('')}</div></section>` : ''}`;
++ _aBindSpeak(root);
++ _aBindHype(root);
++ _aRenderTalkEnter(t, me);
++ _arenaTalkTimers.push(setInterval(() => { const el = document.getElementById('arena-talk-countdown'); if (el) el.textContent = _aCountdown(topic.ends_at); }, 30000));
++}
++
++function _aBindHype(root) {
++ root.querySelectorAll('.arena-hype-btn').forEach(btn => btn.addEventListener('click', async () => {
++ btn.disabled = true;
++ try {
++ const r = await api(`/arena/talk/${btn.dataset.id}/hype`, { method: 'POST' });
++ const entry = btn.closest('.arena-talk-entry');
++ entry.querySelector('.arena-talk-hype-count').textContent = `${r.crowd_uniques} hyped · Crowd ${r.crowd}/10`;
++ entry.querySelector('.arena-talk-total').innerHTML = `<b>${r.total}</b><small>/50</small> <span class="${_aStampClass(r.stamp)}">${_aEsc(r.stamp)}</span>`;
++ const crowdFill = entry.querySelectorAll('.arena-talk-score-fill')[4]; if (crowdFill) crowdFill.style.width = `${Math.min(100, r.crowd * 10)}%`;
++ const crowdNum = entry.querySelectorAll('.arena-talk-score b')[4]; if (crowdNum) crowdNum.textContent = r.crowd;
++ btn.innerHTML = r.added ? '<i class="fa-solid fa-fire"></i> Hyped!' : '<i class="fa-solid fa-check"></i> Already hyped';
++ entry.classList.add('is-hit'); setTimeout(() => entry.classList.remove('is-hit'), 600);
++ } catch (err) { btn.disabled = false; if (typeof showToast === 'function') showToast(err?.message || 'Hype failed', 'error'); }
++ }));
++}
++
++/** The entry panel: live mic (countdown + hot-mic feed) or typed, then the score reveal. */
++function _aRenderTalkEnter(t, me) {
++ const el = document.getElementById('arena-talk-enter');
++ if (!el) return;
++ if (!me) { el.innerHTML = `<div class="arena-talk-gate"><i class="fa-solid fa-right-to-bracket"></i> <b>Streamers:</b> sign in to enter. <b>Viewers:</b> hype an entry below, or type <code>!hype</code> in the streamer's chat.</div>`; return; }
++ if (!t.on_roster) { el.innerHTML = `<div class="arena-talk-gate"><i class="fa-solid fa-hand-fist"></i> You're not on the roster yet — stream once and you're in. Until then: hype away.</div>`; return; }
++ if (t.my_entry) { el.innerHTML = `<div class="arena-talk-gate is-done"><i class="fa-solid fa-circle-check"></i> You're in this round — <b>${t.my_entry.total}/50 · ${_aEsc(t.my_entry.stamp)}</b>. Next topic in ${_aEsc(_aCountdown(t.topic.ends_at))}. Tell chat to <code>!hype</code>.</div>`; return; }
++ const mic = t.mic || {};
++ el.innerHTML = `
++ <div class="arena-talk-panel">
++ <div class="arena-talk-panel-head"><h2><i class="fa-solid fa-fire"></i> Your turn</h2><span class="arena-note">one entry per topic</span></div>
++ <div class="arena-talk-modes">
++ <button class="arena-talk-mode ${mic.available ? 'active' : ''}" data-mode="mic" ${mic.available ? '' : 'disabled'} title="${mic.available ? 'Say it on stream — we transcribe it' : mic.reason === 'not_live' ? 'Go live to use the live mic' : 'Live, but no transcription yet — talk for a few seconds and reload, or type it'}"><i class="fa-solid fa-microphone"></i> Live mic${mic.available ? '' : mic.reason === 'not_live' ? ' <small>(go live)</small>' : ' <small>(no transcript yet)</small>'}</button>
++ <button class="arena-talk-mode ${mic.available ? '' : 'active'}" data-mode="text"><i class="fa-solid fa-keyboard"></i> Type it</button>
++ </div>
++ <div class="arena-talk-mode-body" id="arena-talk-mode-body"></div>
++ <div class="arena-talk-result" id="arena-talk-result"></div>
++ </div>`;
++ const body = document.getElementById('arena-talk-mode-body');
++ const renderMode = (mode) => {
++ el.querySelectorAll('.arena-talk-mode').forEach(b => b.classList.toggle('active', b.dataset.mode === mode));
++ if (mode === 'text') {
++ body.innerHTML = `<textarea id="arena-talk-text" maxlength="${t.rules.text_max}" rows="3" placeholder="Talk your talk… (${t.rules.text_max} characters, PG-13, petty not cruel)"></textarea>
++ <div class="arena-talk-actions"><span class="arena-note" id="arena-talk-count">0 / ${t.rules.text_max}</span><button class="btn btn-primary" id="arena-talk-submit"><i class="fa-solid fa-gavel"></i> Send it to the judge</button></div>`;
++ const ta = document.getElementById('arena-talk-text');
++ ta.addEventListener('input', () => { document.getElementById('arena-talk-count').textContent = `${ta.value.length} / ${t.rules.text_max}`; });
++ document.getElementById('arena-talk-submit').addEventListener('click', () => submit('text', ta.value));
++ } else {
++ body.innerHTML = `<div class="arena-mic">
++ <div class="arena-mic-status" id="arena-mic-status"><span class="arena-mic-dot"></span> Ready. Press start, then say it on stream — you have ${t.rules.mic_window_sec}s.</div>
++ <div class="arena-mic-feed" id="arena-mic-feed"><span class="arena-note">Hot mic — what the transcription hears shows up here (a few seconds behind).</span></div>
++ <div class="arena-talk-actions"><button class="btn btn-primary" id="arena-mic-start"><i class="fa-solid fa-microphone"></i> I'm about to cook</button><button class="btn btn-ghost" id="arena-mic-drop" disabled><i class="fa-solid fa-hand-point-down"></i> Drop the mic</button></div>
++ </div>`;
++ let poll = null;
++ document.getElementById('arena-mic-start').addEventListener('click', async () => {
++ try {
++ await api('/arena/talk/mic/start', { method: 'POST' });
++ document.getElementById('arena-mic-start').disabled = true; document.getElementById('arena-mic-drop').disabled = false;
++ const status = document.getElementById('arena-mic-status'), feed = document.getElementById('arena-mic-feed');
++ status.classList.add('is-live');
++ poll = setInterval(async () => {
++ try {
++ const f = await api('/arena/talk/mic/feed');
++ if (!f.active) return;
++ status.innerHTML = `<span class="arena-mic-dot"></span> LIVE MIC · ${f.remaining_sec}s left · ${f.lines.length} line${f.lines.length === 1 ? '' : 's'} heard`;
++ if (f.lines.length) feed.innerHTML = f.lines.map(l => `<div class="arena-mic-line"><span>${_aStamp(l.at)}</span>${_aEsc(l.text)}</div>`).join('');
++ if (f.remaining_sec <= 0) { clearInterval(poll); poll = null; submit('mic'); }
++ } catch { /* */ }
++ }, 2500);
++ _arenaTalkTimers.push(poll);
++ } catch (err) { if (typeof showToast === 'function') showToast(err?.message || 'Could not start', 'error'); }
++ });
++ document.getElementById('arena-mic-drop').addEventListener('click', () => { if (poll) { clearInterval(poll); poll = null; } submit('mic'); });
++ }
++ };
++ const submit = async (mode, text) => {
++ const res = document.getElementById('arena-talk-result');
++ el.querySelectorAll('button').forEach(b => b.disabled = true);
++ res.innerHTML = `<div class="arena-loading"><i class="fa-solid fa-gavel fa-shake"></i><span>The judge is reading…</span></div>`;
++ try {
++ const r = await api('/arena/talk/submit', { method: 'POST', body: { mode, text } });
++ const e = r.entry;
++ body.innerHTML = '';
++ res.innerHTML = `<div class="arena-talk-reveal ${e.flagged ? 'is-flagged' : ''}">
++ <div class="arena-talk-reveal-head"><span class="arena-talk-reveal-total"><b id="arena-reveal-total">0</b><small>/50</small></span><span class="${_aStampClass(e.stamp)} arena-stamp--big" id="arena-reveal-stamp">${_aEsc(e.stamp)}</span></div>
++ <blockquote class="arena-talk-text">${_aEsc(e.text || '')}</blockquote>
++ ${_aScoreBars(e.scores || { spice: 0, wit: 0, on_topic: 0, delivery: 0, crowd: 0 }, { animate: true })}
++ <p class="arena-talk-verdict"><i class="fa-solid fa-gavel"></i> ${_aEsc(e.verdict || '')}</p>
++ ${e.note ? `<p class="arena-talk-note">Judge's note: ${_aEsc(e.note)}</p>` : ''}
++ <p class="arena-note">${e.flagged ? 'This entry was voided and is hidden from others.' : `Now tell chat to type <code>!hype</code> — every 3 people add a Crowd point. Bonus lands on your POWER within a minute.`}</p>
++ </div>`;
++ const totalEl = document.getElementById('arena-reveal-total');
++ const start = performance.now(), dur = 1400;
++ const tick = (now) => { const p = Math.min(1, (now - start) / dur); totalEl.textContent = (e.total * (1 - Math.pow(1 - p, 3))).toFixed(p < 1 ? 1 : 0); if (p < 1) requestAnimationFrame(tick); else totalEl.textContent = e.total; };
++ requestAnimationFrame(tick);
++ const list = document.getElementById('arena-talk-entries');
++ if (list) { const empty = list.querySelector('.arena-empty'); if (empty) empty.remove(); list.insertAdjacentHTML('afterbegin', _aTalkEntry(e, { mine: true, hypeable: false })); _aBindSpeak(list); _aBindHype(list); }
++ } catch (err) {
++ res.innerHTML = `<div class="arena-talk-gate is-error"><i class="fa-solid fa-triangle-exclamation"></i> ${_aEsc(err?.message || 'The judge walked out.')}</div>`;
++ el.querySelectorAll('button').forEach(b => b.disabled = false);
++ }
++ };
++ el.querySelectorAll('.arena-talk-mode').forEach(b => b.addEventListener('click', () => renderMode(b.dataset.mode)));
++ renderMode(mic.available ? 'mic' : 'text');
++}
++
window.loadArenaPage = loadArenaPage;
ddiff --git a/server/arena/arena-chat.js b/server/arena/arena-chat.js
nnew file mode 100644
iindex 00000000..c5e607fb
---- /dev/null
++++ b/server/arena/arena-chat.js
@@@ -0,0 +1,100 @@
++/**
++ * OpenVibe.Live — Arena chat commands (viewers play along from chat)
++ *
++ * !hype hype the streamer's latest trash-talk entry (one per person; Crowd score)
++ * !talk show the current trash-talk topic + how to enter
++ * !arena [user] fighter card summary + link (defaults to the streamer whose chat this is)
++ * !vote a|b vote in today's Main Event (signed-in only)
++ * !fight <user> post a battle link: this streamer vs <user>
++ *
++ * Called from ChatServer.handleBangCommand; returns true when the command was handled.
++ * Replies go to the sender as a system line; hype milestones are announced to the room.
++ */
++'use strict';
++
++const db = require('../db/database');
++
++const RATE_MS = 4000;
++const _last = new Map(); // voterKey → ms
++
++function voterKey(client) {
++ if (client.user?.id) return `user:${client.user.id}`;
++ if (client.anonId) return `anon:${client.anonId}`;
++ return `ip:${String(client.ip || '')}`;
++}
++
++function rateLimited(key) {
++ const now = Date.now();
++ if (now - (_last.get(key) || 0) < RATE_MS) return true;
++ _last.set(key, now);
++ return false;
++}
++
++function base() {
++ try { const c = require('../config'); return String(c.baseUrl || '').replace(/\/$/, ''); } catch { return ''; }
++}
++
++function handle(chat, ws, client, cmd, parts) {
++ if (!['!hype', '!talk', '!arena', '!vote', '!fight'].includes(cmd)) return false;
++ const reply = (message) => chat.sendTo(ws, { type: 'system', message });
++ let arena, talk;
++ try { arena = require('./arena-service'); talk = require('./trash-talk'); } catch { reply('The Arena is closed right now.'); return true; }
++ if (!arena.arenaEnabled()) { reply('The Arena is closed right now.'); return true; }
++ const key = voterKey(client);
++ if (rateLimited(key)) { reply('Easy — one Arena command every few seconds.'); return true; }
++ const stream = client.streamId ? db.getStreamById(client.streamId) : null;
++ const streamer = stream ? db.getUserById(stream.user_id) : null;
++
++ (async () => {
++ try {
++ if (cmd === '!hype') {
++ if (!streamer) return reply('!hype works inside a streamer\'s chat.');
++ 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);
++ if (!r.added) return reply(`You already hyped this one. Crowd score ${r.crowd}/10 (${r.crowd_uniques} hyped).`);
++ reply(`🔥 Hyped! Crowd score ${r.crowd}/10 (${r.crowd_uniques} hyped) · total ${r.total}/50 · ${r.stamp}`);
++ if (r.crowd_uniques === 1 || r.crowd_uniques % 5 === 0) {
++ chat.broadcastToStream(client.streamId, { type: 'system', message: `🎤 Chat is hyping ${streamer.display_name || streamer.username}'s trash talk — ${r.crowd_uniques} so far. Type !hype to add yours.` });
++ }
++ return;
++ }
++ if (cmd === '!talk') {
++ const t = await talk.getTopic({ generate: false });
++ return reply(`🎤 Trash Talk topic: "${t.topic}" — ${t.hint || ''} Streamers enter at ${base()}/arena/talk; viewers type !hype in their chat to boost the Crowd score.`);
++ }
++ if (cmd === '!arena') {
++ const target = parts[1] ? db.getUserByUsername(String(parts[1]).replace(/^@/, '')) : streamer;
++ if (!target) return reply('Usage: !arena <username>');
++ const card = await arena.getFighter(target.id, { generate: false });
++ if (!card || card.not_on_roster) return reply(`${target.display_name || target.username} isn't on the Arena roster yet.`);
++ const r = card.ratings;
++ return reply(`🥊 ${card.persona.fighter_name} — ${card.persona.title} · #${card.rank} of ${card.roster_size} · PWR ${r.power}${r.talk_bonus ? ` (+${r.talk_bonus} trash talk)` : ''} · ${card.record.wins}W–${card.record.losses}L · "${card.persona.taunt}" → ${base()}/arena/${encodeURIComponent(target.username)}`);
++ }
++ if (cmd === '!vote') {
++ if (!client.user?.id) return reply('Sign in to vote in the Main Event.');
++ const side = String(parts[1] || '').toLowerCase();
++ const me = await arena.getMainEvent({ generate: false });
++ if (!me) return reply('No Main Event today.');
++ const pick = side === 'a' || side === me.a.user.username.toLowerCase() ? 'a' : side === 'b' || side === me.b.user.username.toLowerCase() ? 'b' : null;
++ if (!pick) return reply(`Usage: !vote a|b — today: (a) ${me.a.persona.fighter_name} vs (b) ${me.b.persona.fighter_name}`);
++ const r = arena.castVote(me.id, `user:${client.user.id}`, pick);
++ return reply(`🗳️ Voted ${(pick === 'a' ? me.a : me.b).persona.fighter_name}. Crowd: ${r.votes.a}–${r.votes.b} · score ${r.outcome.a}–${r.outcome.b}${r.outcome.winner ? ` · ${(r.outcome.winner === 'a' ? me.a : me.b).persona.fighter_name} leads` : ''} → ${base()}/arena/battle/${encodeURIComponent(me.a.user.username)}/${encodeURIComponent(me.b.user.username)}`);
++ }
++ if (cmd === '!fight') {
++ if (!streamer) return reply('!fight works inside a streamer\'s chat: !fight <username>');
++ const opp = parts[1] ? db.getUserByUsername(String(parts[1]).replace(/^@/, '')) : null;
++ if (!opp) return reply('Usage: !fight <username>');
++ if (opp.id === streamer.id) return reply('They can\'t fight themselves. Yet.');
++ const url = `${base()}/arena/battle/${encodeURIComponent(streamer.username)}/${encodeURIComponent(opp.username)}`;
++ chat.broadcastToStream(client.streamId, { type: 'system', message: `🥊 ${client.user?.display_name || client.user?.username || 'Chat'} called out ${opp.display_name || opp.username}: ${streamer.display_name || streamer.username} vs ${opp.display_name || opp.username} → ${url}` });
++ return;
++ }
++ } catch (err) {
++ reply(`Arena: ${err.message}`);
++ }
++ })();
++ return true;
++}
++
++module.exports = { handle, _voterKey: voterKey };
ddiff --git a/server/arena/arena-service.js b/server/arena/arena-service.js
iindex 6262ba20..50d51ed0 100644
---- a/server/arena/arena-service.js
++++ b/server/arena/arena-service.js
@@@ -330,10 +330,18 @@ function loadRoster(force = false) {
const rawById = {};
for (const id of ids) rawById[id] = rawStatsFor(id);
const ratings = computeRatings(rawById);
++ // Trash Talk bonus (server/arena/trash-talk.js): recent judged entries add up to
++ // TALK_BONUS_MAX POWER, decaying over a week — the one lever streamers pull themselves.
++ let bonuses = {};
++ try { bonuses = require('./trash-talk').talkBonuses(); } catch { bonuses = {}; }
const byId = {};
for (const id of ids) {
const user = db.getUserById(id);
if (!user) continue;
++ const talkBonus = bonuses[id] || 0;
++ ratings[id].base_power = ratings[id].power;
++ ratings[id].talk_bonus = talkBonus;
++ ratings[id].power = Math.min(99 + require('./trash-talk').TALK_BONUS_MAX, ratings[id].power + talkBonus);
byId[id] = { user: publicUser(user), raw: rawById[id], ratings: ratings[id] };
try { db.run('INSERT INTO arena_profiles (user_id, stats_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(user_id) DO UPDATE SET stats_json = excluded.stats_json, updated_at = CURRENT_TIMESTAMP', [id, JSON.stringify({ raw: rawById[id], ratings: ratings[id] })]); } catch { /* */ }
}
@@@ -818,6 +826,7 @@ async function getFighter(usernameOrId, { generate = true } = {}) {
card.image_generation = imageGenAvailable() ? 'ai' : 'off';
card.recent_battles = recentBattles(user.id, roster);
card.rivalry = rivalryFor(user.id, roster);
++ try { card.trash_talk = require('./trash-talk').entriesFor(user.id, 5); } catch { card.trash_talk = []; }
return card;
}
ddiff --git a/server/arena/routes.js b/server/arena/routes.js
iindex e83a9227..0c0d9b47 100644
---- a/server/arena/routes.js
++++ b/server/arena/routes.js
@@@ -49,6 +49,43 @@ router.get('/live', (req, res) => {
}
});
++// ── Trash Talk (server/arena/trash-talk.js) ──
++const talk = require('./trash-talk');
++
++router.get('/talk', optionalAuth, async (req, res) => {
++ try {
++ res.set('Cache-Control', 'no-store');
++ res.json(await talk.board({ userId: req.user?.id || null, generate: req.query.generate !== '0' }));
++ } catch (err) {
++ console.error('[Arena] talk board:', err.message);
++ res.status(500).json({ error: 'Failed to load Trash Talk' });
++ }
++});
++
++router.post('/talk/mic/start', requireAuth, (req, res) => {
++ try { res.json(talk.startMic(req.user.id)); }
++ catch (err) { res.status(400).json({ error: err.message }); }
++});
++
++router.get('/talk/mic/feed', requireAuth, (req, res) => {
++ try { res.set('Cache-Control', 'no-store'); res.json(talk.micFeed(req.user.id)); }
++ catch (err) { res.status(400).json({ error: err.message }); }
++});
++
++router.post('/talk/submit', requireAuth, async (req, res) => {
++ try {
++ const mode = req.body?.mode === 'mic' ? 'mic' : 'text';
++ res.json({ ok: true, entry: await talk.submit(req.user.id, { mode, text: req.body?.text }) });
++ } 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 }); }
++});
++
router.get('/main-event', optionalAuth, async (req, res) => {
try {
const battle = await arena.getMainEvent({ generate: req.query.generate !== '0' });
ddiff --git a/server/arena/trash-talk.js b/server/arena/trash-talk.js
nnew file mode 100644
iindex 00000000..bfe868dc
---- /dev/null
++++ b/server/arena/trash-talk.js
@@@ -0,0 +1,398 @@
++/**
++ * OpenVibe.Live — Arena Trash Talk
++ *
++ * The part of the Arena streamers and viewers play themselves:
++ *
++ * topic → every TOPIC_SLOT_HOURS the Arena posts a topic ("tonight's beef"), AI-written
++ * from what is going on (main event, top of the ladder), with a templated
++ * fallback. Seeded per slot so every server/restart agrees on it.
++ * entry → a streamer on the roster enters once per topic, either from the LIVE MIC
++ * (we read what they said on stream from the transcription timeline between
++ * "start" and "drop the mic") or by typing it.
++ * judge → the AI scores Spice / Wit / On-topic / Delivery (0–10 each) with a verdict,
++ * a note and a stamp; slurs / harassment are zeroed and hidden (hard filter
++ * before the model, plus the model's own `flagged`). Heuristic when AI is off.
++ * crowd → viewers add the fifth score: `!hype` in that streamer's chat (or the Hype
++ * button) — one per person, Crowd = min(10, uniques / 3).
++ * power → an entry's total (/50) becomes a Trash Talk bonus on POWER (up to
++ * TALK_BONUS_MAX), decaying linearly over TALK_BONUS_DAYS.
++ */
++'use strict';
++
++const crypto = require('crypto');
++const db = require('../db/database');
++const llm = require('../ai/llm');
++
++const TOPIC_SLOT_HOURS = 6;
++const MIC_WINDOW_SEC = 60; // how long a live-mic session may run
++const MIC_GRACE_SEC = 8; // transcript lag — lines a few seconds after "drop" still count
++const TEXT_MAX = 280;
++const TALK_BONUS_MAX = 12;
++const TALK_BONUS_DAYS = 7;
++const HYPE_WINDOW_MS = 24 * 60 * 60 * 1000; // an entry can be hyped for a day
++const STAMPS = [[42, 'COOKED'], [30, 'SOLID'], [18, 'MID'], [0, 'FLOP']];
++
++const FALLBACK_TOPICS = [
++ { topic: 'Explain why your chat is the smartest chat on this site. Lie if you must.', hint: 'Bring receipts, or confidence. Confidence is cheaper.', tone: 'brag' },
++ { topic: 'Your stream schedule is a lifestyle. Defend it against the calendar.', hint: 'Sleep is for people with fewer viewers.', tone: 'brag' },
++ { topic: 'The Main Event has two fighters in it. Tell us why neither is the real champion.', hint: 'Name names. Lovingly.', tone: 'roast' },
++ { topic: 'Describe the last thing that went wrong on your stream as if it was a strategic decision.', hint: 'The audio cutting out was a bit. Obviously.', tone: 'spin' },
++ { topic: 'Your signature move vs. the #1 fighter\'s. Who taps first?', hint: 'Fighting-game energy. Frame data optional.', tone: 'roast' },
++ { topic: 'Sell your stream category to someone who has never heard of it, in one breath.', hint: 'You have the mic. They have their doubts.', tone: 'brag' },
++ { topic: 'What does your chat say about you when you step away? Set the record straight.', hint: 'They say worse things. Get ahead of it.', tone: 'spin' },
++ { topic: 'Rank the top three fighters and explain why you\'re about to move up.', hint: 'Bonus points for a plan that could not possibly work.', tone: 'roast' },
++ { topic: 'Your setup cost more than their setup. Or it cost less and it\'s better. Pick one.', hint: 'Cables count. Cable management counts double.', tone: 'brag' },
++ { topic: 'A new streamer just joined the site. Give them the worst possible advice with total confidence.', hint: 'Every word must sound like wisdom.', tone: 'bit' },
++ { topic: 'Your entrance music just played. Narrate your walk to the ring.', hint: 'Slow-motion. Pyro. A confused sound guy.', tone: 'bit' },
++ { topic: 'Defend the most controversial opinion you have ever said on stream.', hint: 'Keep it PG-13, keep it petty.', tone: 'spin' },
++];
++
++// ── Tables ───────────────────────────────────────────────────
++
++let _ready = false;
++function ensureTables() {
++ if (_ready) return;
++ db.run(`CREATE TABLE IF NOT EXISTS arena_talk_topics (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ slot TEXT NOT NULL UNIQUE,
++ topic TEXT NOT NULL,
++ hint TEXT,
++ tone TEXT,
++ source TEXT,
++ generated_at DATETIME DEFAULT CURRENT_TIMESTAMP
++ )`);
++ db.run(`CREATE TABLE IF NOT EXISTS arena_talk (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ topic_id INTEGER NOT NULL,
++ user_id INTEGER NOT NULL,
++ stream_id INTEGER,
++ source TEXT NOT NULL,
++ text TEXT NOT NULL,
++ vod_id INTEGER,
++ start_sec INTEGER,
++ scores_json TEXT,
++ total REAL DEFAULT 0,
++ crowd_uniques INTEGER DEFAULT 0,
++ flagged INTEGER DEFAULT 0,
++ verdict TEXT,
++ note TEXT,
++ stamp TEXT,
++ judge_model TEXT,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ judged_at DATETIME,
++ UNIQUE(topic_id, user_id)
++ )`);
++ db.run(`CREATE TABLE IF NOT EXISTS arena_talk_hype (
++ talk_id INTEGER NOT NULL,
++ voter_key TEXT NOT NULL,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ UNIQUE(talk_id, voter_key)
++ )`);
++ db.run('CREATE INDEX IF NOT EXISTS idx_arena_talk_user ON arena_talk (user_id, created_at)');
++ _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'); }
++
++// ── Topic ────────────────────────────────────────────────────
++
++function currentSlot(now = Date.now()) {
++ const d = new Date(now);
++ const slotIndex = Math.floor(d.getUTCHours() / TOPIC_SLOT_HOURS);
++ return `${d.toISOString().slice(0, 10)}/${slotIndex}`;
++}
++function slotEndsAt(slot) {
++ const [day, idx] = slot.split('/');
++ return new Date(`${day}T00:00:00Z`).getTime() + (Number(idx) + 1) * TOPIC_SLOT_HOURS * 3600 * 1000;
++}
++
++function seededIndex(seedText, n) {
++ const h = parseInt(crypto.createHash('sha256').update(seedText).digest('hex').slice(0, 8), 16);
++ return h % n;
++}
++
++const TOPIC_SCHEMA = {
++ name: 'arena_talk_topic',
++ schema: {
++ type: 'object', additionalProperties: false, required: ['topic', 'hint', 'tone'],
++ properties: {
++ topic: { type: 'string', description: 'One-sentence prompt a streamer answers on the mic, ≤ 140 chars' },
++ hint: { type: 'string', description: 'A cheeky one-line tip, ≤ 90 chars' },
++ tone: { type: 'string', enum: ['brag', 'roast', 'spin', 'bit'] },
++ },
++ },
++};
++const TOPIC_SYSTEM = `You write prompts for a live-streaming site's Trash Talk contest: a streamer answers the prompt out loud on stream, an AI judge scores spice, wit, relevance and delivery, and chat hypes them. Prompts must be answerable in 30 seconds, funny, PG-13, and about streaming life, chat, the fighters on the Arena ladder, or the day's Main Event — never about appearance, identity, health or money. Output only the JSON.`;
++
++async function getTopic({ generate = true } = {}) {
++ ensureTables();
++ const slot = currentSlot();
++ let row = db.get('SELECT * FROM arena_talk_topics WHERE slot = ?', [slot]);
++ if (row) return { ...row, ends_at: new Date(slotEndsAt(slot)).toISOString() };
++ let topic = null;
++ if (generate && aiOn()) {
++ try {
++ const roster = arena().loadRoster();
++ const names = roster.order.slice(0, 5).map(id => {
++ const p = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [id])?.persona_json);
++ return p?.fighter_name || roster.byId[id].user.display_name;
++ });
++ let mainEvent = null;
++ try { const me = await arena().getMainEvent({ generate: false }); if (me) mainEvent = `${me.a.persona.fighter_name} vs ${me.b.persona.fighter_name}`; } catch { /* */ }
++ const r = await llm.complete({
++ role: 'summary', kind: 'arena_talk_topic', source: 'arena',
++ system: TOPIC_SYSTEM,
++ user: JSON.stringify({ slot, top_fighters: names, main_event: mainEvent, previous_topics: db.all('SELECT topic FROM arena_talk_topics ORDER BY id DESC LIMIT 8').map(r => r.topic) }),
++ json: TOPIC_SCHEMA, maxTokens: 200, temperature: 1.0, timeoutMs: 20000,
++ });
++ if (r && r.json && r.json.topic) topic = { ...r.json, source: 'ai' };
++ } catch (e) { console.warn('[Arena] talk topic:', e.message); }
++ }
++ if (!topic) topic = { ...FALLBACK_TOPICS[seededIndex(slot, FALLBACK_TOPICS.length)], source: 'template' };
++ try {
++ db.run('INSERT OR IGNORE INTO arena_talk_topics (slot, topic, hint, tone, source) VALUES (?, ?, ?, ?, ?)', [slot, topic.topic.slice(0, 200), (topic.hint || '').slice(0, 140), topic.tone || 'brag', topic.source]);
++ } catch { /* */ }
++ row = db.get('SELECT * FROM arena_talk_topics WHERE slot = ?', [slot]);
++ return { ...row, ends_at: new Date(slotEndsAt(slot)).toISOString() };
++}
++
++// ── Live mic sessions (in memory — a session is a minute long) ──
++
++const sessions = new Map(); // userId → { streamId, startedAt, streamStartedMs }
++
++function liveStreamFor(userId) {
++ return db.get('SELECT id, started_at FROM streams WHERE user_id = ? AND is_live = 1 ORDER BY started_at DESC LIMIT 1', [userId]) || null;
++}
++function streamOffsetNow(stream) {
++ const startedMs = stream?.started_at ? Date.parse(String(stream.started_at).replace(' ', 'T') + 'Z') : Date.now();
++ return Math.max(0, (Date.now() - startedMs) / 1000);
++}
++function micAvailable(userId) {
++ const live = liveStreamFor(userId);
++ if (!live) return { available: false, reason: 'not_live' };
++ const recent = db.get(`SELECT COUNT(*) AS n FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND created_at >= datetime('now', '-15 minutes')`, [live.id])?.n || 0;
++ if (!recent) return { available: false, reason: 'no_transcription', stream_id: live.id };
++ return { available: true, stream_id: live.id };
++}
++
++function startMic(userId) {
++ const live = liveStreamFor(userId);
++ if (!live) throw new Error('You need to be live for the live mic — type your entry instead');
++ const s = { streamId: live.id, startedAt: Date.now(), offsetStart: streamOffsetNow(live) };
++ sessions.set(userId, s);
++ return { stream_id: live.id, started_at: new Date(s.startedAt).toISOString(), window_sec: MIC_WINDOW_SEC };
++}
++
++function micFeed(userId) {
++ const s = sessions.get(userId);
++ if (!s) return { active: false, lines: [] };
++ const elapsed = (Date.now() - s.startedAt) / 1000;
++ const rows = db.all(`SELECT text, start_sec FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND start_sec >= ? ORDER BY start_sec ASC LIMIT 60`, [s.streamId, Math.floor(s.offsetStart) - 2]);
++ return {
++ active: true,
++ elapsed_sec: Math.round(elapsed),
++ remaining_sec: Math.max(0, Math.round(MIC_WINDOW_SEC - elapsed)),
++ lines: rows.map(r => ({ text: r.text, at: Math.max(0, Math.round(r.start_sec - s.offsetStart)) })),
++ };
++}
++
++function collectMic(userId) {
++ const s = sessions.get(userId);
++ if (!s) throw new Error('No live-mic session — press "I\'m about to cook" first');
++ sessions.delete(userId);
++ const elapsed = Math.min(MIC_WINDOW_SEC, (Date.now() - s.startedAt) / 1000) + MIC_GRACE_SEC;
++ const rows = db.all(`SELECT text, start_sec, vod_id FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND start_sec >= ? AND start_sec <= ? ORDER BY start_sec ASC LIMIT 80`,
++ [s.streamId, Math.floor(s.offsetStart) - 2, Math.ceil(s.offsetStart + elapsed)]);
++ const text = rows.map(r => String(r.text || '').replace(/^\s*(?:>>|--?)\s*/, '').trim()).filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
++ return { text: text.slice(0, 1200), stream_id: s.streamId, vod_id: rows[0]?.vod_id || null, start_sec: rows.length ? Math.max(0, Math.floor(rows[0].start_sec) - 2) : Math.floor(s.offsetStart), lines: rows.length };
++}
++
++// ── Judge ────────────────────────────────────────────────────
++
++const JUDGE_SCHEMA = {
++ name: 'arena_talk_judgement',
++ schema: {
++ type: 'object', additionalProperties: false,
++ required: ['spice', 'wit', 'on_topic', 'delivery', 'verdict', 'note', 'flagged'],
++ properties: {
++ spice: { type: 'integer', minimum: 0, maximum: 10, description: 'attitude and bite' },
++ wit: { type: 'integer', minimum: 0, maximum: 10, description: 'cleverness, jokes, wordplay' },
++ on_topic: { type: 'integer', minimum: 0, maximum: 10, description: 'actually answers the topic' },
++ delivery: { type: 'integer', minimum: 0, maximum: 10, description: 'rhythm, quotability, confidence (read as a transcript — forgive filler words)' },
++ verdict: { type: 'string', description: 'One-line ring-announcer verdict, ≤ 120 chars' },
++ note: { type: 'string', description: "Judge's note: what landed and what to do better, ≤ 160 chars" },
++ flagged: { type: 'boolean', description: 'true if it attacks appearance/identity/health/money, threatens, or is hateful — then the entry is void' },
++ },
++ },
++};
++const JUDGE_SYSTEM = `You judge a wholesome trash-talk contest between live streamers. Score the entry against the topic. Reward attitude, jokes, specificity about streaming life and the Arena, and lines chat would clip. Punish generic filler and anything cruel. Entries arrive as speech-to-text: forgive "um", repeats and odd punctuation — judge the content. Flag (and it becomes void) anything targeting appearance, body, disability, ethnicity, gender, religion, health or money, or containing threats or hate. Output only the JSON.`;
++
++function heuristicJudge(text, topic) {
++ const t = String(text || '');
++ const words = t.split(/\s+/).filter(Boolean).length;
++ const excl = (t.match(/!/g) || []).length;
++ // Stem-ish match so "smartest" in the topic counts "smarter" in the entry.
++ const stems = [...new Set((String(topic || '').toLowerCase().match(/[a-z]{4,}/g) || []).map(w => w.slice(0, 5)))].filter(w => !['your', 'this', 'that', 'with', 'what', 'from', 'they', 'them', 'have', 'must', 'about', 'which', 'their'].includes(w));
++ const hits = stems.filter(w => t.toLowerCase().includes(w)).length;
++ const spice = Math.min(10, 3 + excl + (/\b(you|your|chat|arena|champ|weak|scared|bet)\b/i.test(t) ? 2 : 0));
++ const wit = Math.min(10, Math.round(Math.min(words, 60) / 8) + (/\b(like|than|because|but)\b/i.test(t) ? 2 : 0));
++ const on_topic = Math.min(10, 2 + hits * 2);
++ const delivery = Math.min(10, words < 8 ? 2 : words > 120 ? 5 : 7);
++ return { spice, wit, on_topic, delivery, verdict: 'The judge is out — scored on instinct.', note: 'AI judging is off; this is a rough heuristic score.', flagged: false, _fallback: true };
++}
++
++function stampFor(total) { return STAMPS.find(([min]) => total >= min)[1]; }
++function crowdScore(uniques) { return Math.min(10, Math.round((Number(uniques) || 0) / 3 * 10) / 10); }
++
++async function judge(text, topic, context) {
++ const banned = arena()._isBannedText(text);
++ let s = null;
++ if (!banned && aiOn()) {
++ try {
++ const r = await llm.complete({
++ role: 'chat', kind: 'arena_talk_judge', source: 'arena', ownerUserId: context.userId,
++ system: JUDGE_SYSTEM,
++ user: JSON.stringify({ topic: topic.topic, tone: topic.tone, fighter: context.fighter, entry: text, source: context.source }),
++ json: JUDGE_SCHEMA, maxTokens: 300, temperature: 0.6, timeoutMs: 25000,
++ });
++ if (r && r.json && typeof r.json.spice === 'number') s = { ...r.json, _model: r.model };
++ } catch (e) { console.warn('[Arena] talk judge:', e.message); }
++ }
++ if (!s) s = heuristicJudge(text, topic.topic);
++ if (banned) s = { ...s, spice: 0, wit: 0, on_topic: 0, delivery: 0, flagged: true, verdict: 'Void — that crossed the line.', note: 'Slurs, hate or threats void an entry. Keep it petty, not cruel.' };
++ const clamp = (v) => Math.max(0, Math.min(10, Math.round(Number(v) || 0)));
++ return { spice: clamp(s.spice), wit: clamp(s.wit), on_topic: clamp(s.on_topic), delivery: clamp(s.delivery), verdict: String(s.verdict || '').slice(0, 200), note: String(s.note || '').slice(0, 240), flagged: !!s.flagged, model: s._model || null, fallback: !!s._fallback };
++}
++
++// ── Entries ──────────────────────────────────────────────────
++
++function totalFor(scores, crowdUniques) {
++ if (!scores || scores.flagged) return 0;
++ return Number((scores.spice + scores.wit + scores.on_topic + scores.delivery + crowdScore(crowdUniques)).toFixed(1));
++}
++
++async function submit(userId, { mode, text }) {
++ ensureTables();
++ const roster = arena().loadRoster();
++ const entry = roster.byId[userId];
++ if (!entry) throw new Error('Only fighters on the roster can enter — stream once and come back');
++ const topic = await getTopic();
++ if (db.get('SELECT id FROM arena_talk WHERE topic_id = ? AND user_id = ?', [topic.id, userId])) throw new Error('You already entered this topic — wait for the next one');
++ let source = 'text', streamId = null, vodId = null, startSec = null, body = String(text || '').trim();
++ if (mode === 'mic') {
++ const c = collectMic(userId);
++ if (!c.text || c.lines < 1) throw new Error("The mic didn't catch anything — the transcription needs a few seconds after you speak. Try again, or type it.");
++ source = 'mic'; body = c.text; streamId = c.stream_id; vodId = c.vod_id; startSec = c.start_sec;
++ } else {
++ if (body.length < 12) throw new Error('Say a little more than that');
++ body = body.slice(0, TEXT_MAX);
++ const live = liveStreamFor(userId); if (live) streamId = live.id;
++ }
++ const fighter = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [userId])?.persona_json)?.fighter_name || entry.user.display_name;
++ const scores = await judge(body, topic, { userId, fighter, source });
++ const total = 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
++ [topic.id, userId, streamId, source, body, vodId, startSec, JSON.stringify(scores), total, scores.flagged ? 1 : 0, scores.verdict, scores.note, stampFor(total), scores.model]);
++ try { arena().loadRoster(true); } catch { /* */ }
++ const row = db.get('SELECT * FROM arena_talk WHERE topic_id = ? AND user_id = ?', [topic.id, userId]);
++ console.log(`[Arena] trash talk by user ${userId} (${source}): ${total}/50 ${stampFor(total)}${scores.flagged ? ' FLAGGED' : ''}`);
++ return formatEntry(row, roster, { own: true });
++}
++
++function hype(talkId, voterKey) {
++ ensureTables();
++ const row = db.get('SELECT * FROM arena_talk WHERE id = ?', [talkId]);
++ if (!row) throw new Error('No such entry');
++ if (row.flagged) throw new Error('That entry is void');
++ if (Date.now() - Date.parse(row.created_at + 'Z') > HYPE_WINDOW_MS) throw new Error('Hype window closed');
++ if (voterKey === `user:${row.user_id}`) throw new Error("You can't hype yourself");
++ const ins = db.run('INSERT OR IGNORE INTO arena_talk_hype (talk_id, voter_key) VALUES (?, ?)', [talkId, voterKey]);
++ const uniques = db.get('SELECT COUNT(*) AS n FROM arena_talk_hype WHERE talk_id = ?', [talkId])?.n || 0;
++ const scores = parseJson(row.scores_json);
++ const total = totalFor(scores, uniques);
++ db.run('UPDATE arena_talk SET crowd_uniques = ?, total = ?, stamp = ? WHERE id = ?', [uniques, total, stampFor(total), talkId]);
++ if (ins.changes) { try { arena().loadRoster(true); } catch { /* */ } }
++ return { added: !!ins.changes, crowd_uniques: uniques, crowd: crowdScore(uniques), total, stamp: stampFor(total) };
++}
++
++/** Latest hypeable entry by a streamer (for `!hype` in their chat). */
++function latestEntryFor(userId) {
++ ensureTables();
++ return db.get(`SELECT * FROM arena_talk WHERE user_id = ? AND flagged = 0 AND created_at >= datetime('now', '-1 day') ORDER BY id DESC LIMIT 1`, [userId]) || null;
++}
++
++function formatEntry(row, roster, { own = false } = {}) {
++ const f = roster.byId[row.user_id];
++ const persona = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [row.user_id])?.persona_json);
++ const scores = parseJson(row.scores_json) || {};
++ const flagged = !!row.flagged;
++ return {
++ id: row.id,
++ user: f ? f.user : { id: row.user_id, username: `user${row.user_id}`, display_name: `user${row.user_id}` },
++ fighter_name: persona?.fighter_name || f?.user?.display_name || `user${row.user_id}`,
++ rank: f ? roster.order.indexOf(row.user_id) + 1 : null,
++ source: row.source,
++ text: flagged && !own ? null : row.text,
++ vod_id: row.vod_id, start_sec: row.start_sec,
++ scores: flagged ? null : { spice: scores.spice, wit: scores.wit, on_topic: scores.on_topic, delivery: scores.delivery, crowd: crowdScore(row.crowd_uniques) },
++ crowd_uniques: row.crowd_uniques || 0,
++ total: row.total || 0,
++ stamp: row.stamp,
++ verdict: row.verdict, note: own || !flagged ? row.note : null,
++ flagged,
++ judged_by_ai: !!row.judge_model,
++ created_at: row.created_at,
++ hype_open: Date.now() - Date.parse(row.created_at + 'Z') <= HYPE_WINDOW_MS,
++ };
++}
++
++/** POWER bonus per user from entries in the last TALK_BONUS_DAYS, decaying linearly. */
++function talkBonuses() {
++ ensureTables();
++ const rows = db.all(`SELECT user_id, total, created_at FROM arena_talk WHERE flagged = 0 AND created_at >= datetime('now', ?)`, [`-${TALK_BONUS_DAYS} days`]);
++ const out = {};
++ for (const r of rows) {
++ const ageDays = (Date.now() - Date.parse(r.created_at + 'Z')) / 86400000;
++ const decay = Math.max(0, 1 - ageDays / TALK_BONUS_DAYS);
++ out[r.user_id] = (out[r.user_id] || 0) + (r.total / 50) * TALK_BONUS_MAX * decay;
++ }
++ for (const id of Object.keys(out)) out[id] = Math.min(TALK_BONUS_MAX, Math.round(out[id]));
++ return out;
++}
++
++async function board({ userId = null, generate = true } = {}) {
++ ensureTables();
++ const roster = arena().loadRoster();
++ const topic = await getTopic({ generate });
++ const entries = db.all('SELECT * FROM arena_talk WHERE topic_id = ? ORDER BY total DESC, id ASC', [topic.id]).map(r => formatEntry(r, roster, { own: r.user_id === userId }));
++ const hall = db.all(`SELECT t.*, tp.topic AS topic_text FROM arena_talk t JOIN arena_talk_topics tp ON tp.id = t.topic_id WHERE t.flagged = 0 AND t.created_at >= datetime('now', '-30 days') ORDER BY t.total DESC, t.id ASC LIMIT 10`)
++ .map(r => ({ ...formatEntry(r, roster), topic: r.topic_text }));
++ const my = userId ? entries.find(e => e.user.id === userId) || null : null;
++ const onRoster = !!(userId && roster.byId[userId]);
++ return {
++ topic, entries, hall_of_trash: hall,
++ my_entry: my,
++ can_enter: onRoster && !my,
++ on_roster: onRoster,
++ mic: userId ? micAvailable(userId) : { available: false, reason: 'anon' },
++ mic_session: userId && sessions.has(userId) ? micFeed(userId) : null,
++ rules: { text_max: TEXT_MAX, mic_window_sec: MIC_WINDOW_SEC, bonus_max: TALK_BONUS_MAX, bonus_days: TALK_BONUS_DAYS, topic_slot_hours: TOPIC_SLOT_HOURS, crowd_per_point: 3 },
++ ai: aiOn(),
++ };
++}
++
++function entriesFor(userId, limit = 6) {
++ ensureTables();
++ const roster = arena().loadRoster();
++ return db.all(`SELECT t.*, tp.topic AS topic_text FROM arena_talk t JOIN arena_talk_topics tp ON tp.id = t.topic_id WHERE t.user_id = ? ORDER BY t.id DESC LIMIT ?`, [userId, limit])
++ .map(r => ({ ...formatEntry(r, roster, { own: true }), topic: r.topic_text }));
++}
++
++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,
++ _judge: judge, _heuristicJudge: heuristicJudge, _totalFor: totalFor, _crowdScore: crowdScore, _stampFor: stampFor, _sessions: sessions,
++};
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex ea1e0756..611e8d19 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -953,6 +953,9 @@ class ChatServer {
const parts = text.trim().split(/\s+/);
const cmd = parts[0].toLowerCase();
++ // Arena: !hype / !talk / !arena / !vote / !fight (server/arena/arena-chat.js)
++ try { if (require('../arena/arena-chat').handle(this, ws, client, cmd, parts)) return; } catch (e) { console.warn('[Arena] chat command:', e.message); }
++
if (cmd === '!gotti') {
const username = client.user ? client.user.display_name : client.anonId;
const coreUsername = client.user ? client.user.username : null;
ddiff --git a/test/arena-talk.test.js b/test/arena-talk.test.js
nnew file mode 100644
iindex 00000000..5663c059
---- /dev/null
++++ b/test/arena-talk.test.js
@@@ -0,0 +1,135 @@
++'use strict';
++
++// Trash Talk: topics are per-slot and deterministic without AI, entries are judged
++// (heuristic without AI, slurs void), the crowd score comes from unique hypes, the
++// POWER bonus decays, and chat commands reply through the ChatServer API.
++
++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-talk-'));
++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 talk = require('../server/arena/trash-talk');
++const chat = require('../server/arena/arena-chat');
++
++// ── pure bits ──
++assert.strictEqual(talk.currentSlot(Date.UTC(2026, 7, 29, 13, 5)), '2026-08-29/2', '13:05 UTC is slot 2 of 6-hour slots');
++assert.strictEqual(talk.currentSlot(Date.UTC(2026, 7, 29, 23, 59)), '2026-08-29/3');
++assert.strictEqual(talk._crowdScore(0), 0);
++assert.strictEqual(talk._crowdScore(3), 1);
++assert.strictEqual(talk._crowdScore(90), 10, 'crowd caps at 10');
++assert.strictEqual(talk._stampFor(45), 'COOKED');
++assert.strictEqual(talk._stampFor(31), 'SOLID');
++assert.strictEqual(talk._stampFor(5), 'FLOP');
++assert.strictEqual(talk._totalFor({ spice: 8, wit: 7, on_topic: 9, delivery: 6 }, 9), 33, 'total = 4 judge scores + crowd');
++assert.strictEqual(talk._totalFor({ spice: 8, wit: 7, on_topic: 9, delivery: 6, flagged: true }, 30), 0, 'flagged entries score 0');
++const h = talk._heuristicJudge('Chat, your chat is smarter than this whole arena, bet!', 'Explain why your chat is the smartest chat on this site.');
++assert.ok(h.spice >= 3 && h.on_topic >= 4 && !h.flagged, JSON.stringify(h));
++console.log('✅ slots, crowd score, stamps, totals, heuristic judge');
++
++// ── roster ──
++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)), u2 = mk('bravo', 'b'.repeat(32)), viewer = mk('fan', 'c'.repeat(32));
++for (const uid of [u1, u2, viewer]) db.ensureChannel(uid);
++const stream = (uid, peak, hours, daysAgo) => {
++ const id = Number(db.createStream({ user_id: uid, title: 't', category: 'irl', protocol: 'rtmp' }).lastInsertRowid);
++ db.run(`UPDATE streams SET is_live = 0, started_at = datetime('now', ?), ended_at = datetime('now', ?), duration_seconds = ?, peak_viewers = ? WHERE id = ?`, [`-${daysAgo} days`, `-${daysAgo} days`, Math.round(hours * 3600), peak, id]);
++ return id;
++};
++stream(u1, 50, 4, 1); stream(u2, 40, 4, 2);
++
++(async () => {
++ const t1 = await talk.getTopic({ generate: false });
++ const t2 = await talk.getTopic({ generate: false });
++ assert.strictEqual(t1.id, t2.id, 'one topic per slot');
++ assert.ok(t1.topic.length > 10 && t1.ends_at, 'templated topic without AI');
++ console.log('✅ topic per slot');
++
++ const b0 = await talk.board({ userId: viewer, generate: false });
++ assert.strictEqual(b0.on_roster, false, 'a viewer is not on the roster');
++ assert.strictEqual(b0.can_enter, false);
++ await assert.rejects(() => talk.submit(viewer, { mode: 'text', text: 'I am the best and everyone knows it, chat.' }), /roster/);
++ await assert.rejects(() => talk.submit(u1, { mode: 'text', text: 'short' }), /more than that/);
++ await assert.rejects(() => talk.submit(u1, { mode: 'mic', text: '' }), /live-mic session/);
++ console.log('✅ entry gating');
++
++ const e1 = await talk.submit(u1, { mode: 'text', text: 'My chat solves my bugs before I finish typing them. Your chat is still loading, champ!' });
++ assert.strictEqual(e1.source, 'text');
++ assert.ok(e1.total > 0 && e1.scores && e1.stamp, JSON.stringify(e1));
++ assert.strictEqual(e1.flagged, false);
++ await assert.rejects(() => talk.submit(u1, { mode: 'text', text: 'Trying to enter the same topic twice should fail.' }), /already entered/);
++ const bad = await talk.submit(u2, { mode: 'text', text: 'Your chat is full of retards and you know it, loser.' });
++ assert.strictEqual(bad.flagged, true, 'slur → void');
++ assert.strictEqual(bad.total, 0);
++ console.log('✅ judged entries; slurs void');
++
++ const before = talk.hype(e1.id, 'user:999');
++ assert.strictEqual(before.added, true);
++ assert.strictEqual(talk.hype(e1.id, 'user:999').added, false, 'one hype per person');
++ for (let i = 0; i < 5; i++) talk.hype(e1.id, `anon:${i}`);
++ const after = talk.hype(e1.id, 'user:1000');
++ assert.strictEqual(after.crowd_uniques, 7);
++ assert.ok(Math.abs(after.crowd - 2.3) < 0.01, `crowd 7/3 → ${after.crowd}`);
++ assert.strictEqual(after.total, e1.total + after.crowd);
++ assert.throws(() => talk.hype(e1.id, `user:${u1}`), /yourself/);
++ assert.throws(() => talk.hype(bad.id, 'user:5'), /void/);
++ console.log('✅ crowd hype');
++
++ const board = await talk.board({ userId: u1, generate: false });
++ assert.strictEqual(board.entries.length, 2);
++ assert.strictEqual(board.entries[0].user.username, 'alpha', 'ranked by total');
++ assert.strictEqual(board.entries[1].text, null, 'a flagged entry hides its text from others');
++ assert.strictEqual(board.my_entry.id, e1.id);
++ assert.strictEqual(board.can_enter, false, 'already entered');
++ assert.ok(board.hall_of_trash.length === 1 && board.hall_of_trash[0].topic, 'hall lists non-flagged entries with their topic');
++ const ownBad = await talk.board({ userId: u2, generate: false });
++ assert.ok(ownBad.my_entry.text && ownBad.my_entry.note, 'the author still sees their voided text + note');
++ console.log('✅ board');
++
++ const bonuses = talk.talkBonuses();
++ assert.ok(bonuses[u1] >= 1 && bonuses[u1] <= talk.TALK_BONUS_MAX, `bonus ${bonuses[u1]}`);
++ assert.strictEqual(bonuses[u2], undefined, 'flagged entry gives no bonus');
++ const roster = arena.loadRoster(true);
++ assert.strictEqual(roster.byId[u1].ratings.talk_bonus, bonuses[u1]);
++ assert.strictEqual(roster.byId[u1].ratings.power, roster.byId[u1].ratings.base_power + bonuses[u1], 'bonus is added to POWER');
++ assert.strictEqual(roster.byId[u2].ratings.talk_bonus, 0);
++ const card = await arena.getFighter('alpha', { generate: false });
++ assert.strictEqual(card.trash_talk.length, 1);
++ console.log('✅ POWER bonus');
++
++ // ── chat commands (fake ChatServer) ──
++ const sent = [], broadcast = [];
++ const fakeChat = { sendTo: (ws, m) => sent.push(m.message), broadcastToStream: (sid, m) => broadcast.push(m.message) };
++ const live = Number(db.createStream({ user_id: u1, title: 'live', category: 'irl', protocol: 'rtmp' }).lastInsertRowid);
++ db.run('UPDATE streams SET is_live = 1 WHERE id = ?', [live]);
++ const client = (user, anonId = null) => ({ user, anonId, streamId: live, ip: '1.2.3.4' });
++ assert.strictEqual(chat.handle(fakeChat, {}, client(null, 'anonA'), '!sr', ['!sr']), false, 'unrelated commands fall through');
++ assert.strictEqual(chat.handle(fakeChat, {}, client(null, 'anonA'), '!hype', ['!hype']), true);
++ await new Promise(r => setTimeout(r, 50));
++ assert.ok(/Hyped!|already hyped/.test(sent[sent.length - 1]), sent[sent.length - 1]);
++ chat.handle(fakeChat, {}, client(null, 'anonB'), '!talk', ['!talk']);
++ await new Promise(r => setTimeout(r, 50));
++ assert.ok(/Trash Talk topic/.test(sent[sent.length - 1]));
++ chat.handle(fakeChat, {}, client(db.getUserById(viewer)), '!arena', ['!arena']);
++ await new Promise(r => setTimeout(r, 100));
++ assert.ok(/PWR/.test(sent[sent.length - 1]), sent[sent.length - 1]);
++ chat.handle(fakeChat, {}, client(null, 'anonC'), '!vote', ['!vote', 'a']);
++ await new Promise(r => setTimeout(r, 50));
++ assert.ok(/Sign in/.test(sent[sent.length - 1]));
++ chat.handle(fakeChat, {}, client(null, 'anonD'), '!fight', ['!fight', 'bravo']);
++ await new Promise(r => setTimeout(r, 50));
++ assert.ok(/called out/.test(broadcast[broadcast.length - 1]), 'fight is announced to the room');
++ chat.handle(fakeChat, {}, client(null, 'anonD'), '!talk', ['!talk']);
++ await new Promise(r => setTimeout(r, 50));
++ assert.ok(/Easy/.test(sent[sent.length - 1]), 'rate limited per person');
++ console.log('✅ chat commands');
++
++ console.log('\n✅ All Trash Talk tests passed');
++ process.exit(0);
++})().catch(err => { console.error(err); process.exit(1); });