openvibers/openvibe.live Commits

Arena: MIC rating + quotes from the transcripts, Mic Drop round, main event, expandable views; Chat before Arena in the nav

OP OpenVibers committed 29 Aug 2026 · e1a5cd1a07 · 7 files, +858 −133

- MIC (7th rating, 10% of POWER) from stream_timeline_events: share of stream time
  talking, words/min, hype words/hour, laughs/hour (sound events). Streamers with no
  transcript data tie at the floor. Fifth battle round "Mic Drop" uses it.
- Quotes: transcript lines pre-scored for hype/punctuation, AI picks the most quotable
  harmless ones (heuristic without AI), each linked to the VOD at that second (▶ hear
  it) plus a walkout line, mic style and voice verdict; the announcer gets the walkout
  lines. Cached 24 h. Voice card on the profile with talk %, wpm, hype/h, laughs/h,
  soundscape chips. Live matchups show the "hot mic" (last transcribed line).
- Main event: one seeded featured battle per day from the top 8, pinned on /arena
  with vote buttons. Head-to-head history, recent fights and rivalries per fighter.
- Expandable everything: leaderboard rows open in place (radar, quips, signature,
  fight/profile), every stat opens a sparkline drill-down (last 14 streams, rank,
  ladder top 3), rounds expand to the ratings/rolls behind them, tale of the tape,
  portrait lightbox with "how this was painted", replay button, 🔊 speech synthesis
  for taunts/quotes/the announcer's call.
- Navbar: Chat now sits before Arena. Docs + tests updated.

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 f3f673b2..a764be5b 100644
---- a/docs/arena.md
++++ b/docs/arena.md
@@@ -8,14 +8,17 @@ Everything is opt-in on the AI side — with AI off the tab still works from sta
| Piece | Source | Cost |
|---|---|---|
--| **Ratings** — HYPE, GRIND, CHAT, LOYALTY, CLUTCH, VIBE (40–99) and overall **POWER** | `streams` + `stream_analytics` (last 90 days), `follows`, `transactions` (tips). Each rating is the streamer's *percentile* on that metric across the active roster (streamed in the last 45 days), so a 99 means "best on the site", never an absolute number. | none |
++| **Ratings** — HYPE, GRIND, CHAT, LOYALTY, CLUTCH, VIBE, **MIC** (40–99) and overall **POWER** | `streams` + `stream_analytics` (last 90 days), `follows`, `transactions` (tips). **MIC** comes from the continuous audio transcription (`stream_timeline_events`): share of stream time spent talking, words per minute, hype words per hour, laughs per hour (sound events). Each rating is the streamer's *percentile* on that metric across the active roster (streamed in the last 45 days), so a 99 means "best on the site", never an absolute number. Streamers without transcript data tie at the MIC floor. | none |
++| **Quotes** — "things they actually said", each with a ▶ that jumps to the VOD at that second, plus a walkout line, mic style and a one-line voice verdict | Transcript lines (25–220 chars, last 90 days) pre-scored for hype/punctuation, then the AI picks the most quotable and harmless ones. Heuristic pick when AI is off. Cached 24 h. Needs ≥ 20 lines. | ~1 LLM call / streamer / day |
| **Persona** — fighter name, title, class, element, signature move, special, weakness, taunt, lore, catchphrase, entrance music, a quip per stat | AI (`summary` role) from the streamer's AI overview, stream memories, chat-AI notes, recent VOD titles/overviews and the numbers. Cached 24 h. | ~1 LLM call / streamer / day |
| **Portrait** — stylised character-select art | AI image model (OpenAI-compatible `/images/generations`). The prompt is built from the persona, category, profile colour and a *scene* description of the latest thumbnail (setting, objects, mood — the vision model is told never to describe faces or identity). It is deliberately **not a likeness** of the person. Cached 7 days. | 1 image / streamer / week, only when enabled |
| **Record** — W–L | `arena_battles` | none |
## Battles
--`/arena/battle/<a>/<b>` — the same pair on the same day always gets the same four rounds (Hype Check, Chat War, Endurance, Clutch Time), simulated from the ratings with a seeded random so the underdog sometimes lands an upset. The **crowd vote** is the fifth round: one vote per user (per IP when anonymous), changeable until midnight UTC, and it breaks 2–2 ties (Power breaks a tie with no votes). The announcer commentary is AI (`chat` role, cached per battle) with a templated fallback.
++`/arena/battle/<a>/<b>` — the same pair on the same day always gets the same five rounds (Hype Check, Chat War, Endurance, Clutch Time, **Mic Drop**), simulated from the ratings with a seeded random so the underdog sometimes lands an upset. The **crowd vote** is the fifth round: one vote per user (per IP when anonymous), changeable until midnight UTC, and it breaks 2–2 ties (Power breaks a tie with no votes). The announcer commentary is AI (`chat` role, cached per battle; it gets both walkout lines) with a templated fallback. Every round line expands to show the ratings and rolls behind it; **Tale of the tape** compares the raw numbers; the all-time head-to-head record is shown above the commentary.
++
++**Main event** — one featured battle per day, seeded from the top 8 of the ladder, pinned to the top of `/arena` with its own vote buttons.
Live streamers are paired by neighbouring Power on the leaderboard ("Live battles"), with their live thumbnails, refreshed every 30 s.
@@@ -24,7 +27,9 @@ Live streamers are paired by neighbouring Power on the leaderboard ("Live battle
| Method | Path | Notes |
|---|---|---|
| GET | `/api/arena/fighters` | leaderboard |
--| GET | `/api/arena/fighters/:username` | full card; generates a stale persona synchronously and kicks off a portrait in the background (`image_pending`) |
++| GET | `/api/arena/fighters/:username` | full card incl. `voice`, `quotes`, `recent_battles`, `rivalry`; generates a stale persona/quotes synchronously and kicks off a portrait in the background (`image_pending`) |
++| GET | `/api/arena/fighters/:username/stat/:stat` | drill-down: per-stream series (last 14 streams), rank on that stat, top 3 of the ladder |
++| GET | `/api/arena/main-event` | today's featured battle |
| POST | `/api/arena/fighters/:username/refresh` | admin — regenerate persona (+ portrait unless `{ "image": false }`) |
| GET | `/api/arena/battle/:a/:b` | today's battle (created on first view); `?generate=0` skips AI |
| POST | `/api/arena/battle/:a/:b/vote` | `{ "side": "a" \| "b" }` |
@@@ -44,6 +49,12 @@ Live streamers are paired by neighbouring Power on the leaderboard ("Live battle
Portraits are written to `data/arena/` (override with `ARENA_IMAGE_PATH`) and served from `/data/arena/`.
++## Interaction
++
++- Leaderboard rows expand in place (radar, quips, signature move, fight/profile buttons); every stat on a profile opens a sparkline of the last 14 streams with the roster's top 3; the portrait opens a lightbox that explains how it was painted (and shows the prompt).
++- 🔊 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.
++
## 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 d4fb80a6..a597b069 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -14001,3 +14001,106 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
.arena-row { grid-template-columns: 32px 48px 1fr auto; gap: 10px; }
.arena-row-taunt { display: none; }
}
++
++/* ── Arena: expandable rows, stat drill-down, voice, main event, tape ── */
++.arena-row { cursor: pointer; }
++.arena-row .arena-chevron { color: var(--text-muted); transition: transform 0.15s; margin-left: 6px; }
++.arena-row.is-open .arena-chevron { transform: rotate(90deg); }
++.arena-row-expand { display: none; grid-column: 1 / -1; padding: 12px 6px 4px; border-top: 1px dashed var(--border); margin-top: 8px; }
++.arena-row.is-open .arena-row-expand { display: grid; grid-template-columns: 180px 1fr auto; gap: 16px; align-items: start; }
++.arena-row-expand .arena-quips { grid-template-columns: repeat(2, 1fr); font-size: 0.8rem; }
++.arena-row-expand-actions { display: flex; flex-direction: column; gap: 8px; }
++.arena-row-lore { font-size: 0.88rem; color: var(--text-secondary); margin: 0 0 8px; }
++.arena-mini-record { font-size: 0.78rem; color: var(--text-muted); margin-top: 6px; }
++.arena-quip.is-clickable, .arena-bar.is-clickable { cursor: pointer; border-radius: 8px; padding: 4px 6px; margin: -4px -6px; }
++.arena-quip.is-clickable:hover, .arena-bar.is-clickable:hover { background: var(--bg-tertiary); }
++.arena-stat-detail { grid-column: 1 / -1; margin-top: 8px; padding: 14px; border-radius: 12px; background: var(--bg-tertiary); border: 1px solid var(--border); animation: arena-in 0.25s ease-out; }
++.arena-stat-detail-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
++.arena-stat-detail-head h3 { margin: 0; font-size: 1rem; }
++.arena-stat-detail-head small { color: var(--text-muted); }
++.arena-stat-detail-close { background: none; border: 0; color: var(--text-muted); cursor: pointer; font-size: 1rem; }
++.arena-spark { width: 100%; height: 70px; display: block; }
++.arena-spark polyline { fill: none; stroke: var(--fc, var(--accent)); stroke-width: 2; }
++.arena-spark .arena-spark-area { fill: color-mix(in srgb, var(--fc, var(--accent)) 25%, transparent); stroke: none; }
++.arena-spark circle { fill: var(--fc, var(--accent)); }
++.arena-spark-caption { display: flex; justify-content: space-between; font-size: 0.72rem; color: var(--text-muted); }
++.arena-stat-top { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
++.arena-stat-top a { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border-radius: 8px; background: var(--bg-card); border: 1px solid var(--border); color: var(--text-primary); text-decoration: none; font-size: 0.8rem; }
++.arena-stat-top a b { color: var(--accent-light); }
++.arena-stat-detail .arena-weight { font-size: 0.75rem; color: var(--text-muted); }
++
++/* voice */
++.arena-voice { padding: 14px 16px; border-radius: 14px; background: var(--bg-tertiary); border: 1px solid var(--border); }
++.arena-voice-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }
++.arena-voice-head h3 { margin: 0; font-size: 1rem; display: flex; align-items: center; gap: 8px; }
++.arena-voice-head h3 i { color: var(--accent); }
++.arena-voice-style { font-size: 0.8rem; color: var(--accent-light); font-weight: 600; }
++.arena-voice-verdict { font-size: 0.9rem; color: var(--text-secondary); font-style: italic; margin: 0 0 10px; }
++.arena-voice-meters { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; margin-bottom: 10px; }
++.arena-voice-meter { display: flex; flex-direction: column; padding: 8px 10px; border-radius: 10px; background: var(--bg-card); }
++.arena-voice-meter b { font-size: 1.05rem; }
++.arena-voice-meter span { font-size: 0.7rem; color: var(--text-muted); }
++.arena-sounds { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
++.arena-sounds span { font-size: 0.72rem; padding: 3px 8px; border-radius: 999px; background: var(--bg-card); border: 1px solid var(--border); color: var(--text-secondary); }
++.arena-quotes { display: flex; flex-direction: column; gap: 6px; }
++.arena-quote { display: grid; grid-template-columns: 1fr auto; gap: 10px; align-items: center; padding: 8px 10px; border-radius: 10px; background: var(--bg-card); border-left: 3px solid var(--fc, var(--accent)); }
++.arena-quote q { font-size: 0.92rem; quotes: "“" "”"; }
++.arena-quote small { display: block; color: var(--text-muted); font-size: 0.72rem; margin-top: 2px; }
++.arena-quote-actions { display: flex; gap: 4px; }
++.arena-quote-actions a, .arena-quote-actions button { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-secondary); text-decoration: none; cursor: pointer; font-size: 0.85rem; }
++.arena-quote-actions a:hover, .arena-quote-actions button:hover { color: var(--accent-light); border-color: var(--accent); }
++.arena-quote-actions button.is-speaking { color: var(--accent-light); border-color: var(--accent); }
++.arena-speak { background: none; border: 0; color: var(--text-muted); cursor: pointer; padding: 0 4px; }
++.arena-speak:hover, .arena-speak.is-speaking { color: var(--accent-light); }
++.arena-voice-empty { font-size: 0.85rem; color: var(--text-muted); }
++
++/* portrait lightbox */
++.arena-portrait-lg { cursor: zoom-in; }
++.arena-lightbox { position: fixed; inset: 0; z-index: 3000; background: rgba(0, 0, 0, 0.82); display: flex; align-items: center; justify-content: center; padding: 24px; animation: arena-in 0.2s; }
++.arena-lightbox-inner { max-width: 900px; width: 100%; display: grid; grid-template-columns: minmax(0, 520px) 1fr; gap: 20px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 18px; padding: 18px; }
++.arena-lightbox-inner img { width: 100%; border-radius: 12px; display: block; }
++.arena-lightbox-text h3 { margin: 0 0 8px; }
++.arena-lightbox-text p { font-size: 0.88rem; color: var(--text-secondary); }
++.arena-lightbox-prompt { font-size: 0.78rem; color: var(--text-muted); max-height: 220px; overflow: auto; background: var(--bg-tertiary); padding: 10px; border-radius: 8px; }
++.arena-lightbox-close { position: absolute; top: 16px; right: 20px; background: none; border: 0; color: #fff; font-size: 1.8rem; cursor: pointer; }
++@media (max-width: 720px) { .arena-lightbox-inner { grid-template-columns: 1fr; } }
++
++/* main event */
++.arena-main-event { margin: 4px 0 24px; padding: 16px 18px; border-radius: 16px; border: 1px solid var(--border); background: linear-gradient(120deg, color-mix(in srgb, var(--accent) 16%, var(--bg-card)), var(--bg-card) 60%, color-mix(in srgb, #ec4899 14%, var(--bg-card))); }
++.arena-main-event-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
++.arena-main-event-head h2 { margin: 0; font-size: 1.1rem; display: flex; align-items: center; gap: 8px; }
++.arena-main-event-head h2 i { color: #f5c542; }
++.arena-main-event-body { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 14px; }
++.arena-me-side { display: flex; align-items: center; gap: 12px; min-width: 0; text-decoration: none; color: var(--text-primary); }
++.arena-me-side.is-b { flex-direction: row-reverse; text-align: right; }
++.arena-me-side strong { display: block; font-size: 1.05rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
++.arena-me-side span { font-size: 0.78rem; color: var(--text-secondary); }
++.arena-me-center { display: flex; flex-direction: column; align-items: center; gap: 6px; }
++.arena-me-score { font-size: 1.6rem; font-weight: 900; font-variant-numeric: tabular-nums; }
++.arena-me-vote { display: flex; gap: 6px; }
++.arena-me-vote button { padding: 6px 10px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text-primary); font-size: 0.8rem; cursor: pointer; }
++.arena-me-vote button.is-picked { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 20%, var(--bg-card)); }
++.arena-me-tally { font-size: 0.72rem; color: var(--text-muted); }
++.arena-me-intro { font-size: 0.88rem; color: var(--text-secondary); margin: 10px 0 0; font-style: italic; }
++
++/* battle extras */
++.arena-history { font-size: 0.8rem; color: var(--text-muted); }
++.arena-walkout { margin: 8px 0 0; font-size: 0.82rem; color: var(--text-secondary); display: flex; align-items: center; gap: 6px; justify-content: center; flex-wrap: wrap; }
++.arena-walkout q { font-style: italic; }
++.arena-line.is-expandable { cursor: pointer; }
++.arena-line-detail { display: none; margin-top: 6px; font-size: 0.78rem; color: var(--text-muted); }
++.arena-line.is-open .arena-line-detail { display: block; }
++.arena-tape { width: 100%; }
++.arena-tape summary { cursor: pointer; font-weight: 600; font-size: 0.9rem; padding: 6px 0; color: var(--text-secondary); }
++.arena-tape table { width: 100%; border-collapse: collapse; font-size: 0.82rem; font-variant-numeric: tabular-nums; }
++.arena-tape td, .arena-tape th { padding: 5px 6px; border-bottom: 1px solid var(--border); text-align: center; }
++.arena-tape th { color: var(--text-muted); font-weight: 500; font-size: 0.72rem; }
++.arena-tape td.is-better { color: var(--accent-light); font-weight: 700; }
++.arena-tape td.arena-tape-label { text-align: center; color: var(--text-muted); }
++@media (max-width: 900px) {
++ .arena-row.is-open .arena-row-expand { grid-template-columns: 1fr; }
++ .arena-main-event-body { grid-template-columns: 1fr; }
++ .arena-me-side.is-b { flex-direction: row; text-align: left; }
++}
++#arena-stat-detail { grid-column: 1 / -1; }
++#arena-stat-detail:empty { display: none; }
ddiff --git a/public/index.html b/public/index.html
iindex ea78eb58..92ff7477 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -62,7 +62,7 @@
</script>
<!-- Stylesheets -->
-- <link rel="stylesheet" href="/css/style.css?v=141">
++ <link rel="stylesheet" href="/css/style.css?v=142">
<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">
@@@ -131,12 +131,12 @@
<a href="/pastes" onclick="return handleLinkClick(event, '/pastes')" class="nav-link" data-page="pastes" data-tooltip="Pastes">
<i class="fa-solid fa-paste"></i> <span>Pastes</span>
</a>
-- <a href="/arena" onclick="return handleLinkClick(event, '/arena')" class="nav-link" data-page="arena" data-tooltip="Arena">
-- <i class="fa-solid fa-hand-fist"></i> <span>Arena</span>
-- </a>
<a href="/chat" onclick="return handleLinkClick(event, '/chat')" class="nav-link" data-page="chat" data-tooltip="Chat">
<i class="fa-solid fa-comments"></i> <span>Chat</span>
</a>
++ <a href="/arena" onclick="return handleLinkClick(event, '/arena')" class="nav-link" data-page="arena" data-tooltip="Arena">
++ <i class="fa-solid fa-hand-fist"></i> <span>Arena</span>
++ </a>
<a href="https://openvibe.games" class="nav-link" data-page="game" data-tooltip="Game" target="_blank" rel="noopener">
<i class="fa-solid fa-gamepad"></i> <span>Game</span>
</a>
@@@ -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=1" defer></script>
++<script src="/js/arena.js?v=2" 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 f1324c39..7a7cb464 100644
---- a/public/js/arena.js
++++ b/public/js/arena.js
@@@ -2,31 +2,60 @@
* OpenVibe.Live — Arena tab (streamer vs streamer)
*
* Routes (handled from app.js):
-- * /arena leaderboard + live matchups
-- * /arena/<username> fighter profile
-- * /arena/battle/<a>/<b> head-to-head battle
++ * /arena main event + live matchups + leaderboard (rows expand)
++ * /arena/<username> fighter profile (stats drill down, voice + quotes, portrait lightbox)
++ * /arena/battle/<a>/<b> head-to-head battle (rounds, tale of the tape, walkouts, crowd vote)
*
-- * Talks to /api/arena/* (server/arena). Everything renders with DOM building or
-- * escaped template strings — persona text is AI-written and must never be trusted as HTML.
++ * Talks to /api/arena/* (server/arena). Everything renders with escaped template strings —
++ * persona/quote text is AI-written or transcribed and must never be trusted as HTML.
*/
'use strict';
--const ARENA_STATS = ['hype', 'grind', 'chat', 'loyalty', 'clutch', 'vibe'];
--const ARENA_STAT_LABEL = { hype: 'Hype', grind: 'Grind', chat: 'Chat', loyalty: 'Loyalty', clutch: 'Clutch', vibe: 'Vibe' };
++const ARENA_STATS = ['hype', 'grind', 'chat', 'loyalty', 'clutch', 'vibe', 'mic'];
++const ARENA_STAT_LABEL = { hype: 'Hype', grind: 'Grind', chat: 'Chat', loyalty: 'Loyalty', clutch: 'Clutch', vibe: 'Vibe', mic: 'Mic' };
let _arenaRoster = null; // cached leaderboard payload
let _arenaLiveTimer = null;
let _arenaImagePoll = null;
let _arenaBattleTimers = [];
++let _arenaUtterance = null;
function _aEsc(s) { return typeof esc === 'function' ? esc(String(s ?? '')) : String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
--function _aNum(n) { n = Number(n) || 0; return n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n); }
++function _aNum(n) { n = Number(n) || 0; return n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(Number.isInteger(n) ? n : n.toFixed(1)); }
function _aInitial(u) { return (u.display_name || u.username || '?').trim().charAt(0).toUpperCase(); }
++function _aStamp(sec) { sec = Math.max(0, Math.floor(sec || 0)); const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60; return (h ? `${h}:` : '') + `${h ? String(m).padStart(2, '0') : m}:${String(s).padStart(2, '0')}`; }
++function _aDate(d) { try { return new Date(String(d).replace(' ', 'T') + (String(d).endsWith('Z') ? '' : 'Z')).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } catch { return String(d || ''); } }
function _aStopTimers() {
if (_arenaLiveTimer) { clearInterval(_arenaLiveTimer); _arenaLiveTimer = null; }
if (_arenaImagePoll) { clearInterval(_arenaImagePoll); _arenaImagePoll = null; }
for (const t of _arenaBattleTimers) clearTimeout(t);
_arenaBattleTimers = [];
++ _aStopSpeaking();
++}
++
++// ── Speech: hear the taunt / quotes read out (browser TTS, no server cost) ──
++function _aStopSpeaking() {
++ try { if (window.speechSynthesis) speechSynthesis.cancel(); } catch { /* */ }
++ document.querySelectorAll('.is-speaking').forEach(el => el.classList.remove('is-speaking'));
++ _arenaUtterance = null;
++}
++function _aSpeak(text, btn) {
++ if (!window.speechSynthesis || !text) return;
++ if (btn && btn.classList.contains('is-speaking')) { _aStopSpeaking(); return; }
++ _aStopSpeaking();
++ const u = new SpeechSynthesisUtterance(text);
++ u.rate = 1.05; u.pitch = 0.9;
++ const voices = speechSynthesis.getVoices();
++ const pick = voices.find(v => /en/i.test(v.lang) && /Google|Daniel|Samantha|Alex/i.test(v.name)) || voices.find(v => /en/i.test(v.lang));
++ if (pick) u.voice = pick;
++ u.onend = u.onerror = () => { if (btn) btn.classList.remove('is-speaking'); _arenaUtterance = null; };
++ _arenaUtterance = u;
++ if (btn) btn.classList.add('is-speaking');
++ speechSynthesis.speak(u);
++}
++function _aSpeakBtn(text, cls = 'arena-speak') { return `<button type="button" class="${cls}" data-speak="${_aEsc(text)}" title="Hear it (browser voice)"><i class="fa-solid fa-volume-high"></i></button>`; }
++function _aBindSpeak(root) {
++ root.querySelectorAll('[data-speak]').forEach(btn => btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); _aSpeak(btn.dataset.speak, btn); }));
}
/** Portrait: AI image when present, otherwise the avatar (or an initial) styled as a card. */
@@@ -42,22 +71,22 @@ function _aPortrait(f, size = 'md') {
return `<div class="arena-portrait arena-portrait-${size} arena-portrait-initial" style="--fc:${_aEsc(color)}"><span>${_aEsc(_aInitial(u))}</span></div>`;
}
--function _aRatingBars(r) {
++function _aRatingBars(r, { clickable = false } = {}) {
return `<div class="arena-bars">${ARENA_STATS.map(k => `
-- <div class="arena-bar" title="${_aEsc(ARENA_STAT_LABEL[k])}">
++ <div class="arena-bar ${clickable ? 'is-clickable' : ''}" data-stat="${k}" title="${_aEsc(ARENA_STAT_LABEL[k])}">
<span class="arena-bar-label">${_aEsc(ARENA_STAT_LABEL[k])}</span>
-- <span class="arena-bar-track"><span class="arena-bar-fill" style="width:${Math.max(0, Math.min(100, r[k]))}%"></span></span>
-- <span class="arena-bar-val">${r[k]}</span>
++ <span class="arena-bar-track"><span class="arena-bar-fill" style="width:${Math.max(0, Math.min(100, r[k] || 0))}%"></span></span>
++ <span class="arena-bar-val">${r[k] ?? '–'}</span>
</div>`).join('')}</div>`;
}
--/** Hexagon radar chart as inline SVG (no library). */
--function _aRadar(r, color, size = 180) {
-- const c = size / 2, R = size / 2 - 28; // leave room for the labels outside the outer ring
++/** Heptagon radar chart as inline SVG (no library). */
++function _aRadar(r, color, size = 200) {
++ const c = size / 2, R = size / 2 - 28;
const angle = (i) => -Math.PI / 2 + (i * 2 * Math.PI) / ARENA_STATS.length;
const pt = (i, v) => { const rr = R * (v / 99); return [c + rr * Math.cos(angle(i)), c + rr * Math.sin(angle(i))]; };
const ring = (v) => ARENA_STATS.map((_, i) => pt(i, v).map(x => x.toFixed(1)).join(',')).join(' ');
-- const poly = ARENA_STATS.map((k, i) => pt(i, r[k]).map(x => x.toFixed(1)).join(',')).join(' ');
++ const poly = ARENA_STATS.map((k, i) => pt(i, r[k] || 0).map(x => x.toFixed(1)).join(',')).join(' ');
const labels = ARENA_STATS.map((k, i) => {
const rr = R + 16, x = c + rr * Math.cos(angle(i)), y = c + rr * Math.sin(angle(i));
return `<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" text-anchor="middle" dominant-baseline="middle">${_aEsc(ARENA_STAT_LABEL[k])}</text>`;
@@@ -69,7 +98,28 @@ function _aRadar(r, color, size = 180) {
</svg>`;
}
++/** Sparkline of a per-stream series. */
++function _aSpark(series, color) {
++ const w = 320, h = 70, pad = 6;
++ const vals = series.map(p => Number(p.value) || 0);
++ if (!vals.length) return '<p class="arena-voice-empty">No stream history in the window.</p>';
++ const max = Math.max(...vals, 1), min = 0;
++ const x = (i) => vals.length === 1 ? w / 2 : pad + (i * (w - pad * 2)) / (vals.length - 1);
++ const y = (v) => h - pad - ((v - min) / (max - min || 1)) * (h - pad * 2);
++ const pts = vals.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ');
++ const area = `${x(0).toFixed(1)},${h - pad} ${pts} ${x(vals.length - 1).toFixed(1)},${h - pad}`;
++ return `<svg class="arena-spark" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" style="--fc:${_aEsc(color)}">
++ <polygon class="arena-spark-area" points="${area}"></polygon>
++ <polyline points="${pts}"></polyline>
++ ${vals.map((v, i) => `<circle cx="${x(i).toFixed(1)}" cy="${y(v).toFixed(1)}" r="2.5"><title>${_aEsc(series[i].title || '')} · ${_aEsc(_aDate(series[i].date))}: ${_aEsc(_aNum(v))}</title></circle>`).join('')}
++ </svg>
++ <div class="arena-spark-caption"><span>${_aEsc(_aDate(series[0].date))}</span><span>peak ${_aEsc(_aNum(max))}</span><span>${_aEsc(_aDate(series[series.length - 1].date))}</span></div>`;
++}
++
function _aChannelLink(u) { return `/${encodeURIComponent(u.username)}`; }
++function _aFighterLink(u) { return `/arena/${encodeURIComponent(u.username)}`; }
++function _aBattleLink(a, b) { return `/arena/battle/${encodeURIComponent(a.username)}/${encodeURIComponent(b.username)}`; }
++function _aA(href, inner, cls = '') { return `<a class="${cls}" href="${_aEsc(href)}" onclick="return handleLinkClick(event, '${_aEsc(href)}')">${inner}</a>`; }
// ── Page entry ───────────────────────────────────────────────
@@@ -89,7 +139,7 @@ async function loadArenaPage(segments = []) {
function _aSpinner(text) { return `<div class="arena-loading"><i class="fa-solid fa-circle-notch fa-spin"></i><span>${_aEsc(text)}</span></div>`; }
--// ── Home: live matchups + leaderboard ────────────────────────
++// ── Home: main event + live matchups + leaderboard ───────────
async function _aRenderHome(root) {
root.innerHTML = _aSpinner('Loading the roster…');
@@@ -104,13 +154,14 @@ async function _aRenderHome(root) {
<div class="arena-hero">
<div>
<h1><i class="fa-solid fa-hand-fist"></i> The Arena</h1>
-- <p class="page-subtitle">Every streamer, ranked by the numbers and roasted by the AI. Pick two, watch them fight, and let chat decide the last round.</p>
++ <p class="page-subtitle">Every streamer, ranked by the numbers and roasted by the AI. Tap a fighter to expand, pick two to fight, and let chat decide the last round.</p>
</div>
<div class="arena-hero-actions">
<button class="btn btn-primary" id="arena-random"><i class="fa-solid fa-dice"></i> Random battle</button>
${data.ai ? '' : '<span class="arena-note" title="AI is off — stats and templated commentary only">AI commentary off</span>'}
</div>
</div>
++ <section id="arena-main-event" class="arena-main-event" style="display:none"></section>
<section id="arena-live" class="arena-live"></section>
<section class="arena-board">
<div class="arena-board-head">
@@@ -121,15 +172,16 @@ async function _aRenderHome(root) {
</section>`;
_aRenderLive(live);
_aRenderList(fighters);
++ api('/arena/main-event').then(me => _aRenderMainEvent(me)).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].some(s => String(s || '').toLowerCase().includes(q))) : fighters);
++ _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);
});
document.getElementById('arena-random').addEventListener('click', () => {
if (fighters.length < 2) return;
const a = fighters[Math.floor(Math.random() * fighters.length)];
let b = a; while (b === a) b = fighters[Math.floor(Math.random() * fighters.length)];
-- navigate(`/arena/battle/${encodeURIComponent(a.user.username)}/${encodeURIComponent(b.user.username)}`);
++ navigate(_aBattleLink(a.user, b.user));
});
_arenaLiveTimer = setInterval(async () => {
if (currentPage !== 'arena' || !document.getElementById('arena-live')) return _aStopTimers();
@@@ -137,16 +189,50 @@ async function _aRenderHome(root) {
}, 30000);
}
++function _aRenderMainEvent(me) {
++ const el = document.getElementById('arena-main-event');
++ if (!el || !me || !me.a) return;
++ const side = (f, s) => _aA(_aBattleLink(me.a.user, me.b.user), `${_aPortrait(f, 'sm')}<div><strong>${_aEsc(f.persona.fighter_name)}</strong><span>${_aEsc(f.user.display_name)} · #${f.rank} · PWR ${f.ratings.power}</span></div>`, `arena-me-side is-${s}`);
++ const render = (votes, outcome, yourVote) => {
++ el.style.display = '';
++ el.innerHTML = `
++ <div class="arena-main-event-head">
++ <h2><i class="fa-solid fa-star"></i> Main event · ${_aEsc(me.day)}</h2>
++ ${_aA(_aBattleLink(me.a.user, me.b.user), '<i class="fa-solid fa-play"></i> Watch the fight', 'btn btn-ghost')}
++ </div>
++ <div class="arena-main-event-body">
++ ${side(me.a, 'a')}
++ <div class="arena-me-center">
++ <div class="arena-me-score">${outcome.a} – ${outcome.b}</div>
++ <div class="arena-me-vote">
++ <button data-side="a" class="${yourVote === 'a' ? 'is-picked' : ''}">Back ${_aEsc(me.a.persona.fighter_name.split(' ')[0])}</button>
++ <button data-side="b" class="${yourVote === 'b' ? 'is-picked' : ''}">Back ${_aEsc(me.b.persona.fighter_name.split(' ')[0])}</button>
++ </div>
++ <div class="arena-me-tally">${votes.a + votes.b} vote${votes.a + votes.b === 1 ? '' : 's'} · ${outcome.winner ? `${_aEsc((outcome.winner === 'a' ? me.a : me.b).persona.fighter_name)} leads` : 'dead even'}</div>
++ </div>
++ ${side(me.b, 'b')}
++ </div>
++ <p class="arena-me-intro"><i class="fa-solid fa-microphone-lines"></i> ${_aEsc(me.commentary?.intro || '')}</p>`;
++ el.querySelectorAll('.arena-me-vote button').forEach(btn => btn.addEventListener('click', async () => {
++ btn.disabled = true;
++ try { const r = await api(`/arena/battle/${encodeURIComponent(me.a.user.username)}/${encodeURIComponent(me.b.user.username)}/vote`, { method: 'POST', body: { side: btn.dataset.side } }); render(r.votes, r.outcome, r.your_side); }
++ catch (err) { btn.disabled = false; if (typeof showToast === 'function') showToast(err?.message || 'Vote failed', 'error'); }
++ }));
++ };
++ render(me.votes, me.outcome, me.your_vote || null);
++}
++
function _aRenderLive(live) {
const el = document.getElementById('arena-live');
if (!el) return;
if (!live || !live.live_count) { el.innerHTML = ''; return; }
const card = (f, side) => `
-- <a class="arena-live-fighter arena-live-${side}" href="${_aChannelLink(f.user)}" onclick="return handleLinkClick(event, '${_aEsc(_aChannelLink(f.user))}')">
++ <a class="arena-live-fighter arena-live-${side}" href="${_aEsc(_aChannelLink(f.user))}" onclick="return handleLinkClick(event, '${_aEsc(_aChannelLink(f.user))}')">
${f.thumbnail_url ? `<img class="arena-live-thumb" src="${_aEsc(f.thumbnail_url)}?t=${Date.now()}" alt="">` : _aPortrait(f, 'sm')}
<div class="arena-live-meta">
<strong>${_aEsc(f.persona.fighter_name)}</strong>
<span>${_aEsc(f.user.display_name)} · <i class="fa-solid fa-eye"></i> ${_aNum(f.stream.viewer_count)} · PWR ${f.ratings.power}</span>
++ ${f.hot_mic ? `<span title="Hot mic — last thing the transcript heard"><i class="fa-solid fa-microphone"></i> “${_aEsc(f.hot_mic.text.length > 70 ? f.hot_mic.text.slice(0, 70) + '…' : f.hot_mic.text)}”</span>` : ''}
</div>
</a>`;
el.innerHTML = `
@@@ -155,7 +241,7 @@ function _aRenderLive(live) {
${live.matchups.map(m => `
<div class="arena-live-match">
${card(m.a, 'a')}
-- <a class="arena-live-vs" href="/arena/battle/${encodeURIComponent(m.a.user.username)}/${encodeURIComponent(m.b.user.username)}" onclick="return handleLinkClick(event, '/arena/battle/${_aEsc(encodeURIComponent(m.a.user.username))}/${_aEsc(encodeURIComponent(m.b.user.username))}')">VS<small>fight</small></a>
++ ${_aA(_aBattleLink(m.a.user, m.b.user), 'VS<small>fight</small>', 'arena-live-vs')}
${card(m.b, 'b')}
</div>`).join('')}
${live.waiting ? `<div class="arena-live-match arena-live-waiting">${card(live.waiting, 'a')}<div class="arena-live-vs arena-live-vs-idle">?<small>waiting for a challenger</small></div></div>` : ''}
@@@ -167,44 +253,105 @@ function _aRenderList(fighters) {
if (!el) return;
if (!fighters.length) { el.innerHTML = '<div class="arena-empty"><p>No fighter matches that.</p></div>'; return; }
el.innerHTML = fighters.map(f => `
-- <a class="arena-row ${f.live ? 'is-live' : ''}" href="/arena/${encodeURIComponent(f.user.username)}" onclick="return handleLinkClick(event, '/arena/${_aEsc(encodeURIComponent(f.user.username))}')">
++ <div class="arena-row ${f.live ? 'is-live' : ''}" data-user="${_aEsc(f.user.username)}" style="--fc:${_aEsc(f.user.profile_color || '#8b5cf6')}">
<span class="arena-rank ${f.rank <= 3 ? `arena-rank-${f.rank}` : ''}">${f.rank}</span>
${_aPortrait(f, 'sm')}
<span class="arena-row-main">
-- <strong>${_aEsc(f.persona.fighter_name)} ${f.live ? '<span class="arena-live-pill">LIVE</span>' : ''}</strong>
-- <span class="arena-row-sub">${_aEsc(f.user.display_name)} · ${_aEsc(f.persona.class)} · ${_aEsc(f.persona.element)}</span>
++ <strong>${_aEsc(f.persona.fighter_name)} ${f.live ? '<span class="arena-live-pill">LIVE</span>' : ''}<i class="fa-solid fa-chevron-right arena-chevron"></i></strong>
++ <span class="arena-row-sub">${_aEsc(f.user.display_name)} · ${_aEsc(f.persona.class)} · ${_aEsc(f.persona.element)}${f.voice?.has_data ? ` · <i class="fa-solid fa-microphone" title="has transcript data"></i> Mic ${f.ratings.mic}` : ''}</span>
<em class="arena-row-taunt">“${_aEsc(f.persona.taunt)}”</em>
</span>
<span class="arena-row-stats">
<span class="arena-power"><b>${f.ratings.power}</b><small>PWR</small></span>
<span class="arena-record">${f.record.wins}W–${f.record.losses}L</span>
</span>
-- </a>`).join('');
++ <div class="arena-row-expand">
++ <div>${_aRadar(f.ratings, f.user.profile_color || '#8b5cf6', 180)}<div class="arena-mini-record">${f.category ? _aEsc(f.category) + ' · ' : ''}last live ${_aEsc(f.last_live_at ? _aDate(f.last_live_at) : '—')}</div></div>
++ <div>
++ <p class="arena-row-lore">${_aEsc(f.persona.lore)}</p>
++ <div class="arena-quips">${ARENA_STATS.map(k => `<div class="arena-quip"><b>${_aEsc(ARENA_STAT_LABEL[k])} ${f.ratings[k]}</b><span>${_aEsc((f.persona.stat_quips || {})[k] || '')}</span></div>`).join('')}</div>
++ ${f.persona.signature_move ? `<div class="arena-mini-record"><b>Signature:</b> ${_aEsc(f.persona.signature_move.name)} — ${_aEsc(f.persona.signature_move.description)}</div>` : ''}
++ </div>
++ <div class="arena-row-expand-actions">
++ ${_aA(_aFighterLink(f.user), '<i class="fa-solid fa-id-card"></i> Full profile', 'btn btn-primary')}
++ <button class="btn btn-ghost arena-row-fight" data-user="${_aEsc(f.user.username)}"><i class="fa-solid fa-hand-fist"></i> Fight someone</button>
++ ${_aSpeakBtn(f.persona.taunt, 'btn btn-ghost')}
++ </div>
++ </div>
++ </div>`).join('');
++ el.querySelectorAll('.arena-row').forEach(row => row.addEventListener('click', (e) => {
++ if (e.target.closest('a, button')) return;
++ row.classList.toggle('is-open');
++ }));
++ el.querySelectorAll('.arena-row-fight').forEach(btn => btn.addEventListener('click', () => {
++ const me = fighters.find(f => f.user.username === btn.dataset.user);
++ const pool = fighters.filter(f => f !== me);
++ if (!me || !pool.length) return;
++ navigate(_aBattleLink(me.user, pool[Math.floor(Math.random() * pool.length)].user));
++ }));
++ _aBindSpeak(el);
}
// ── Fighter profile ──────────────────────────────────────────
++function _aVoiceCard(f) {
++ const v = f.voice || {};
++ const q = f.quotes;
++ const color = f.user.profile_color || '#8b5cf6';
++ if (!v.has_data) {
++ return `<div class="arena-voice" style="--fc:${_aEsc(color)}"><div class="arena-voice-head"><h3><i class="fa-solid fa-microphone-slash"></i> On the mic</h3></div><p class="arena-voice-empty">No transcript data yet — the audio transcription picks this up on their next streams. Until then, MIC is rated at the floor.</p></div>`;
++ }
++ const quotes = (q && q.picks && q.picks.length) ? q.picks : [];
++ return `<div class="arena-voice" style="--fc:${_aEsc(color)}">
++ <div class="arena-voice-head">
++ <h3><i class="fa-solid fa-microphone"></i> On the mic <span class="arena-power" style="margin-left:6px"><b style="font-size:1.1rem">${f.ratings.mic}</b><small>MIC</small></span></h3>
++ ${q?.mic_style ? `<span class="arena-voice-style">${_aEsc(q.mic_style)}</span>` : ''}
++ </div>
++ ${q?.voice_verdict ? `<p class="arena-voice-verdict">${_aEsc(q.voice_verdict)}</p>` : ''}
++ <div class="arena-voice-meters">
++ <div class="arena-voice-meter"><b>${_aEsc(v.talk_ratio_pct)}%</b><span>of stream time talking</span></div>
++ <div class="arena-voice-meter"><b>${_aEsc(_aNum(v.speech_minutes))} min</b><span>of speech heard (90d)</span></div>
++ <div class="arena-voice-meter"><b>${_aEsc(v.wpm)}</b><span>words per minute</span></div>
++ <div class="arena-voice-meter"><b>${_aEsc(v.hype_per_hour)}</b><span>hype words / hour</span></div>
++ <div class="arena-voice-meter"><b>${_aEsc(v.laughs_per_hour)}</b><span>laughs / hour</span></div>
++ <div class="arena-voice-meter"><b>${_aEsc(v.streams_heard)}</b><span>streams transcribed</span></div>
++ </div>
++ ${v.top_sounds && v.top_sounds.length ? `<div class="arena-sounds"><span title="what the stream sounds like, from the audio-event detector"><i class="fa-solid fa-wave-square"></i> soundscape</span>${v.top_sounds.map(s => `<span>${_aEsc(s.label)} ×${s.n}</span>`).join('')}</div>` : ''}
++ ${quotes.length ? `<div class="arena-quotes">${quotes.map(p => `
++ <div class="arena-quote">
++ <div><q>${_aEsc(p.text)}</q><small>${_aEsc(p.why || '')}${p.vod_id ? ` · at ${_aStamp(p.start_sec)}` : ''}</small></div>
++ <div class="arena-quote-actions">
++ ${p.vod_id ? `<a href="/vod/${p.vod_id}?t=${p.start_sec}" onclick="return handleLinkClick(event, '/vod/${p.vod_id}?t=${p.start_sec}')" title="Hear them say it (jumps to the VOD)"><i class="fa-solid fa-play"></i></a>` : ''}
++ ${_aSpeakBtn(p.text, '')}
++ </div>
++ </div>`).join('')}</div>` : '<p class="arena-voice-empty">Quotes appear once enough lines have been transcribed.</p>'}
++ ${q?._fallback ? '<p class="arena-note">Quotes picked by heuristic — the AI curates these once enabled.</p>' : ''}
++ </div>`;
++}
++
async function _aRenderFighter(root, username) {
root.innerHTML = _aSpinner('Pulling the fighter file…');
const f = await api(`/arena/fighters/${encodeURIComponent(username)}`);
if (f.not_on_roster) {
-- root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-user-slash"></i><p><strong>${_aEsc(f.user.display_name)}</strong> is not on the roster yet — ${_aEsc(f.reason)}.</p><a class="btn" href="/arena" onclick="return handleLinkClick(event, '/arena')">Back to the Arena</a></div>`;
++ root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-user-slash"></i><p><strong>${_aEsc(f.user.display_name)}</strong> is not on the roster yet — ${_aEsc(f.reason)}.</p>${_aA('/arena', 'Back to the Arena', 'btn')}</div>`;
return;
}
const p = f.persona, color = f.user.profile_color || '#8b5cf6';
++ const recent = f.recent_battles || [];
root.innerHTML = `
-- <div class="arena-back"><a href="/arena" onclick="return handleLinkClick(event, '/arena')"><i class="fa-solid fa-arrow-left"></i> Arena</a></div>
++ <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')}</div>
<div class="arena-profile" style="--fc:${_aEsc(color)}">
<div class="arena-profile-portrait" id="arena-profile-portrait">
${_aPortrait(f, 'lg')}
${f.image_pending ? '<div class="arena-portrait-pending"><i class="fa-solid fa-wand-magic-sparkles fa-fade"></i> painting portrait…</div>' : ''}
<div class="arena-profile-rank">#${f.rank} <small>of ${f.roster_size}</small></div>
++ ${f.rivalry ? `<div class="arena-mini-record"><i class="fa-solid fa-fire"></i> Rivalry: ${_aA(_aBattleLink(f.user, f.rivalry.opponent), _aEsc(f.rivalry.fighter_name))} (${f.rivalry.wins}–${f.rivalry.losses} in ${f.rivalry.fights} fights)</div>` : ''}
</div>
<div class="arena-profile-main">
<div class="arena-profile-name">
<h1>${_aEsc(p.fighter_name)} ${f.live ? '<span class="arena-live-pill">LIVE</span>' : ''}</h1>
<p class="arena-title">${_aEsc(p.title)}</p>
-- <p class="arena-handle"><a href="${_aChannelLink(f.user)}" onclick="return handleLinkClick(event, '${_aEsc(_aChannelLink(f.user))}')">${_aEsc(f.user.display_name)} · @${_aEsc(f.user.username)}</a> · ${_aEsc(p.class)} · ${_aEsc(p.element)}</p>
++ <p class="arena-handle">${_aA(_aChannelLink(f.user), `${_aEsc(f.user.display_name)} · @${_aEsc(f.user.username)}`)} · ${_aEsc(p.class)} · ${_aEsc(p.element)}</p>
</div>
<div class="arena-profile-power">
<div class="arena-power arena-power-lg"><b>${f.ratings.power}</b><small>POWER</small></div>
@@@ -212,7 +359,8 @@ async function _aRenderFighter(root, username) {
</div>
<div class="arena-profile-stats">
${_aRadar(f.ratings, color)}
-- <div class="arena-quips">${ARENA_STATS.map(k => `<div class="arena-quip"><b>${_aEsc(ARENA_STAT_LABEL[k])} ${f.ratings[k]}</b><span>${_aEsc((p.stat_quips || {})[k] || '')}</span></div>`).join('')}</div>
++ <div class="arena-quips">${ARENA_STATS.map(k => `<div class="arena-quip is-clickable" data-stat="${k}" title="Tap for the breakdown"><b>${_aEsc(ARENA_STAT_LABEL[k])} ${f.ratings[k]} <i class="fa-solid fa-magnifying-glass-chart" style="font-size:0.7em;opacity:0.6"></i></b><span>${_aEsc((p.stat_quips || {})[k] || '')}</span></div>`).join('')}</div>
++ <div id="arena-stat-detail"></div>
</div>
<div class="arena-lore">
<p class="arena-lore-text">${_aEsc(p.lore)}</p>
@@@ -222,34 +370,62 @@ async function _aRenderFighter(root, username) {
<div class="arena-move arena-move-weak"><span class="arena-move-kind">Weakness</span><b>${_aEsc(p.weakness)}</b></div>
</div>
<div class="arena-flavor">
-- <span><i class="fa-solid fa-comment"></i> “${_aEsc(p.taunt)}”</span>
++ <span><i class="fa-solid fa-comment"></i> “${_aEsc(p.taunt)}” ${_aSpeakBtn(p.taunt)}</span>
<span><i class="fa-solid fa-music"></i> ${_aEsc(p.entrance_music)}</span>
<span><i class="fa-solid fa-quote-left"></i> ${_aEsc(p.catchphrase)}</span>
</div>
${f.persona_is_fallback ? '<p class="arena-note">Stats-only profile — AI lore appears once the AI is enabled.</p>' : ''}
</div>
++ ${_aVoiceCard(f)}
<div class="arena-numbers">
${[['Hours live (90d)', f.raw.hours], ['Peak viewers', f.raw.peak_viewers], ['Avg viewers', f.raw.avg_viewers], ['Chat msgs / hr', f.raw.messages_per_hour], ['Followers', f.raw.followers], ['Clips', f.raw.clips], ['All-time hours', f.raw.all_time_hours], ['All-time peak', f.raw.all_time_peak]]
.map(([l, v]) => `<div class="arena-number"><b>${_aEsc(_aNum(v))}</b><span>${_aEsc(l)}</span></div>`).join('')}
</div>
</div>
</div>
++ ${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>
<div class="arena-opponents" id="arena-opponents">${_aSpinner('Scouting…')}</div>
</section>`;
++ _aBindSpeak(root);
++
++ // Stat drill-down
++ root.querySelectorAll('.arena-quip.is-clickable').forEach(el => el.addEventListener('click', async () => {
++ const stat = el.dataset.stat;
++ const box = document.getElementById('arena-stat-detail');
++ if (box.dataset.stat === stat) { box.innerHTML = ''; box.dataset.stat = ''; return; }
++ box.dataset.stat = stat;
++ box.innerHTML = `<div class="arena-stat-detail">${_aSpinner('Crunching…')}</div>`;
++ try {
++ const d = await api(`/arena/fighters/${encodeURIComponent(username)}/stat/${stat}`);
++ if (box.dataset.stat !== stat) return;
++ box.innerHTML = `<div class="arena-stat-detail" style="--fc:${_aEsc(color)}">
++ <div class="arena-stat-detail-head">
++ <h3>${_aEsc(d.label)} ${d.rating} <small>· #${d.position} of ${d.roster_size} · ${_aEsc(_aNum(d.value))} ${_aEsc(d.unit)}</small></h3>
++ <button class="arena-stat-detail-close" title="Close">&times;</button>
++ </div>
++ <p class="arena-weight">${_aEsc(d.desc)} · ${Math.round(d.weight * 100)}% of POWER · rating = your percentile across the roster</p>
++ ${_aSpark(d.series, color)}
++ ${d.top.length ? `<div class="arena-stat-top"><span class="arena-weight">Top of the ladder:</span>${d.top.map((t, i) => _aA(_aFighterLink(t.user), `#${i + 1} ${_aEsc(t.fighter_name)} <b>${_aEsc(_aNum(t.value))}</b>`)).join('')}</div>` : ''}
++ </div>`;
++ box.querySelector('.arena-stat-detail-close').addEventListener('click', () => { box.innerHTML = ''; box.dataset.stat = ''; });
++ } catch (err) { box.innerHTML = `<div class="arena-stat-detail">${_aEsc(err?.message || 'Failed')}</div>`; }
++ }));
++
++ // Portrait lightbox ("how this was painted")
++ const portrait = root.querySelector('.arena-portrait-lg');
++ if (portrait && f.image_url) portrait.addEventListener('click', () => _aLightbox(f));
++
// Opponent picker
try {
const data = _arenaRoster || await api('/arena/fighters');
_arenaRoster = data;
const others = (data.fighters || []).filter(o => o.user.id !== f.user.id);
const el = document.getElementById('arena-opponents');
-- el.innerHTML = others.map(o => `
-- <a class="arena-opponent" href="/arena/battle/${encodeURIComponent(f.user.username)}/${encodeURIComponent(o.user.username)}" onclick="return handleLinkClick(event, '/arena/battle/${_aEsc(encodeURIComponent(f.user.username))}/${_aEsc(encodeURIComponent(o.user.username))}')">
-- ${_aPortrait(o, 'xs')}
-- <span><strong>${_aEsc(o.persona.fighter_name)}</strong><small>#${o.rank} · PWR ${o.ratings.power}${o.live ? ' · LIVE' : ''}</small></span>
-- </a>`).join('') || '<p class="arena-note">Nobody else on the roster yet.</p>';
++ el.innerHTML = others.map(o => _aA(_aBattleLink(f.user, o.user), `${_aPortrait(o, 'xs')}<span><strong>${_aEsc(o.persona.fighter_name)}</strong><small>#${o.rank} · PWR ${o.ratings.power}${o.live ? ' · LIVE' : ''}</small></span>`, 'arena-opponent')).join('') || '<p class="arena-note">Nobody else on the roster yet.</p>';
} catch { /* */ }
++
// Poll for the AI portrait while it is being painted.
if (f.image_pending || (!f.image_url && f.image_generation === 'ai')) {
let tries = 0;
@@@ -259,7 +435,11 @@ async function _aRenderFighter(root, username) {
const fresh = await api(`/arena/fighters/${encodeURIComponent(username)}?generate=0`);
if (fresh.image_url) {
const holder = document.getElementById('arena-profile-portrait');
-- if (holder) holder.innerHTML = `${_aPortrait(fresh, 'lg')}<div class="arena-profile-rank">#${fresh.rank} <small>of ${fresh.roster_size}</small></div>`;
++ if (holder) {
++ holder.querySelector('.arena-portrait')?.outerHTML && (holder.querySelector('.arena-portrait').outerHTML = _aPortrait(fresh, 'lg'));
++ holder.querySelector('.arena-portrait-pending')?.remove();
++ holder.querySelector('.arena-portrait-lg')?.addEventListener('click', () => _aLightbox(fresh));
++ }
clearInterval(_arenaImagePoll); _arenaImagePoll = null;
}
} catch { /* */ }
@@@ -267,51 +447,99 @@ async function _aRenderFighter(root, username) {
}
}
++function _aLightbox(f) {
++ const box = document.createElement('div');
++ box.className = 'arena-lightbox';
++ box.innerHTML = `
++ <button class="arena-lightbox-close" aria-label="Close">&times;</button>
++ <div class="arena-lightbox-inner">
++ <img src="${_aEsc(f.image_url)}" alt="">
++ <div class="arena-lightbox-text">
++ <h3>${_aEsc(f.persona.fighter_name)}</h3>
++ <p>${_aEsc(f.persona.title)}</p>
++ <p>How this was painted: the AI wrote the persona from the stream's own data, described the <em>scene</em> of the latest thumbnail (never the person), and an image model drew a fictional character from that.${f.image_model ? ` Model: <code>${_aEsc(f.image_model)}</code>.` : ''}</p>
++ ${f.image_prompt ? `<div class="arena-lightbox-prompt">${_aEsc(f.image_prompt)}</div>` : ''}
++ </div>
++ </div>`;
++ const close = () => box.remove();
++ box.addEventListener('click', (e) => { if (e.target === box || e.target.closest('.arena-lightbox-close')) close(); });
++ document.addEventListener('keydown', function onKey(e) { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); } });
++ document.body.appendChild(box);
++}
++
// ── Battle ───────────────────────────────────────────────────
++function _aTape(A, B) {
++ const rows = [
++ ['Rank', `#${A.rank}`, `#${B.rank}`, A.rank < B.rank ? 'a' : B.rank < A.rank ? 'b' : null],
++ ['Power', A.ratings.power, B.ratings.power],
++ ['Hours live (90d)', A.raw.hours, B.raw.hours],
++ ['Peak viewers', A.raw.peak_viewers, B.raw.peak_viewers],
++ ['Avg viewers', A.raw.avg_viewers, B.raw.avg_viewers],
++ ['Chat msgs / hr', A.raw.messages_per_hour, B.raw.messages_per_hour],
++ ['Followers', A.raw.followers, B.raw.followers],
++ ['Clips', A.raw.clips, B.raw.clips],
++ ['Talk time %', A.raw.voice?.talk_ratio_pct ?? 0, B.raw.voice?.talk_ratio_pct ?? 0],
++ ['Hype words / hr', A.raw.voice?.hype_per_hour ?? 0, B.raw.voice?.hype_per_hour ?? 0],
++ ['Record', `${A.record.wins}–${A.record.losses}`, `${B.record.wins}–${B.record.losses}`, A.record.wins > B.record.wins ? 'a' : B.record.wins > A.record.wins ? 'b' : null],
++ ];
++ return `<details class="arena-tape"><summary><i class="fa-solid fa-scale-balanced"></i> Tale of the tape</summary><table>
++ <thead><tr><th>${_aEsc(A.persona.fighter_name)}</th><th></th><th>${_aEsc(B.persona.fighter_name)}</th></tr></thead>
++ <tbody>${rows.map(([label, a, b, better]) => {
++ const win = better !== undefined ? better : (Number(a) > Number(b) ? 'a' : Number(b) > Number(a) ? 'b' : null);
++ return `<tr><td class="${win === 'a' ? 'is-better' : ''}">${_aEsc(_aNum(a) === 'NaN' ? a : (typeof a === 'number' ? _aNum(a) : a))}</td><td class="arena-tape-label">${_aEsc(label)}</td><td class="${win === 'b' ? 'is-better' : ''}">${_aEsc(typeof b === 'number' ? _aNum(b) : b)}</td></tr>`;
++ }).join('')}</tbody></table></details>`;
++}
++
async function _aRenderBattle(root, a, b) {
root.innerHTML = `<div class="arena-loading arena-loading-battle"><i class="fa-solid fa-bolt fa-beat"></i><span>Fighters are entering the arena…</span><small>the announcer is warming up</small></div>`;
const battle = await api(`/arena/battle/${encodeURIComponent(a)}/${encodeURIComponent(b)}`);
-- const A = battle.a, B = battle.b, c = battle.commentary;
++ const A = battle.a, B = battle.b;
const side = (f, s) => `
<div class="arena-side arena-side-${s} ${battle.outcome.winner === s ? 'is-winner' : ''}" style="--fc:${_aEsc(f.user.profile_color || (s === 'a' ? '#8b5cf6' : '#ec4899'))}">
-- <a href="/arena/${encodeURIComponent(f.user.username)}" onclick="return handleLinkClick(event, '/arena/${_aEsc(encodeURIComponent(f.user.username))}')">${_aPortrait(f, 'md')}</a>
++ ${_aA(_aFighterLink(f.user), _aPortrait(f, 'md'))}
<h2>${_aEsc(f.persona.fighter_name)}</h2>
<p class="arena-title">${_aEsc(f.persona.title)}</p>
<p class="arena-handle">${_aEsc(f.user.display_name)} · ${_aEsc(f.persona.class)} · #${f.rank}</p>
<div class="arena-power"><b>${f.ratings.power}</b><small>PWR</small></div>
${_aRatingBars(f.ratings)}
-- <p class="arena-side-taunt">“${_aEsc(f.persona.taunt)}”</p>
++ <p class="arena-side-taunt">“${_aEsc(f.persona.taunt)}” ${_aSpeakBtn(f.persona.taunt)}</p>
++ ${f.walkout ? `<p class="arena-walkout"><i class="fa-solid fa-microphone"></i> Walkout line: <q>${_aEsc(f.walkout.text)}</q> ${f.walkout.vod_id ? `<a href="/vod/${f.walkout.vod_id}?t=${f.walkout.start_sec}" onclick="return handleLinkClick(event, '/vod/${f.walkout.vod_id}?t=${f.walkout.start_sec}')" title="Hear them say it"><i class="fa-solid fa-play"></i> hear it</a>` : ''}</p>` : ''}
</div>`;
++ const h = battle.history || { fights: 0 };
root.innerHTML = `
-- <div class="arena-back"><a href="/arena" onclick="return handleLinkClick(event, '/arena')"><i class="fa-solid fa-arrow-left"></i> Arena</a> <span class="arena-note">Battle of ${_aEsc(battle.day)} — same matchup, same result all day; the crowd vote is the last round.</span></div>
++ <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')} <span class="arena-note">Battle of ${_aEsc(battle.day)} — same matchup, same rounds all day; the crowd vote is the last round.</span></div>
<div class="arena-battle">
${side(A, 'a')}
<div class="arena-center">
<div class="arena-vs">VS</div>
<div class="arena-scoreboard" id="arena-scoreboard"><span id="arena-score-a">0</span><span class="arena-score-sep">–</span><span id="arena-score-b">0</span></div>
++ <div class="arena-history">${h.fights ? `All-time: ${_aEsc(A.persona.fighter_name)} ${h.a_wins} – ${h.b_wins} ${_aEsc(B.persona.fighter_name)} (${h.fights} fight${h.fights === 1 ? '' : 's'})` : 'First meeting'}</div>
<div class="arena-commentary" id="arena-commentary"></div>
<div class="arena-vote" id="arena-vote"></div>
++ ${_aTape(A, B)}
<div class="arena-battle-actions">
<button class="btn btn-ghost" id="arena-share"><i class="fa-solid fa-link"></i> Share</button>
-- <a class="btn btn-ghost" href="/arena/battle/${encodeURIComponent(b)}/${encodeURIComponent(a)}" onclick="return handleLinkClick(event, '/arena/battle/${_aEsc(encodeURIComponent(b))}/${_aEsc(encodeURIComponent(a))}')"><i class="fa-solid fa-right-left"></i> Swap corners</a>
++ ${_aA(_aBattleLink(B.user, A.user), '<i class="fa-solid fa-right-left"></i> Swap corners', 'btn btn-ghost')}
<button class="btn btn-ghost" id="arena-rematch"><i class="fa-solid fa-dice"></i> Random rematch</button>
++ <button class="btn btn-ghost" id="arena-replay"><i class="fa-solid fa-rotate-left"></i> Replay</button>
</div>
</div>
${side(B, 'b')}
</div>`;
++ _aBindSpeak(root);
_aPlayBattle(battle);
document.getElementById('arena-share').addEventListener('click', async () => {
-- const url = `${location.origin}/arena/battle/${encodeURIComponent(A.user.username)}/${encodeURIComponent(B.user.username)}`;
++ const url = `${location.origin}${_aBattleLink(A.user, B.user)}`;
try { await navigator.clipboard.writeText(url); document.getElementById('arena-share').innerHTML = '<i class="fa-solid fa-check"></i> Copied'; } catch { prompt('Battle link', url); }
});
++ document.getElementById('arena-replay').addEventListener('click', () => { for (const t of _arenaBattleTimers) clearTimeout(t); _arenaBattleTimers = []; _aPlayBattle(battle); });
document.getElementById('arena-rematch').addEventListener('click', async () => {
const data = _arenaRoster || await api('/arena/fighters');
_arenaRoster = data;
const pool = (data.fighters || []).filter(f => f.user.id !== A.user.id);
if (!pool.length) return;
-- const o = pool[Math.floor(Math.random() * pool.length)];
-- navigate(`/arena/battle/${encodeURIComponent(A.user.username)}/${encodeURIComponent(o.user.username)}`);
++ navigate(_aBattleLink(A.user, pool[Math.floor(Math.random() * pool.length)].user));
});
}
@@@ -320,8 +548,16 @@ function _aPlayBattle(battle) {
const A = battle.a, B = battle.b, c = battle.commentary;
const box = document.getElementById('arena-commentary');
const sa = document.getElementById('arena-score-a'), sb = document.getElementById('arena-score-b');
++ box.innerHTML = ''; sa.textContent = '0'; sb.textContent = '0';
++ document.getElementById('arena-vote').innerHTML = '';
let a = 0, b = 0;
-- const line = (html, cls = '') => { const d = document.createElement('div'); d.className = `arena-line ${cls}`; d.innerHTML = html; box.appendChild(d); box.scrollTop = box.scrollHeight; };
++ const line = (html, cls = '', detail = null) => {
++ const d = document.createElement('div');
++ d.className = `arena-line ${cls}${detail ? ' is-expandable' : ''}`;
++ d.innerHTML = html + (detail ? `<div class="arena-line-detail">${detail}</div>` : '');
++ if (detail) d.addEventListener('click', () => d.classList.toggle('is-open'));
++ box.appendChild(d); box.scrollTop = box.scrollHeight;
++ };
const at = (ms, fn) => _arenaBattleTimers.push(setTimeout(fn, ms));
let t = 0;
line(`<i class="fa-solid fa-microphone-lines"></i> ${_aEsc(c.intro)}`, 'arena-line-intro');
@@@ -331,7 +567,9 @@ function _aPlayBattle(battle) {
const winner = r.winner === 'a' ? A : B;
if (r.winner === 'a') a++; else b++;
sa.textContent = a; sb.textContent = b;
-- line(`<span class="arena-round-tag">R${i + 1} · ${_aEsc(r.label)}</span> ${_aEsc(c.rounds[i] || `${winner.persona.fighter_name} takes it.`)} <span class="arena-round-score">${r.a}–${r.b}${r.upset ? ' · UPSET' : ''}</span>`, `arena-line-${r.winner}${r.upset ? ' arena-line-upset' : ''}`);
++ const meta = battle.stat_meta?.[r.stat];
++ const detail = `${_aEsc(ARENA_STAT_LABEL[r.stat])} rating ${A.ratings[r.stat]} vs ${B.ratings[r.stat]} (${_aEsc(meta?.desc || '')}) · roll ${r.a} vs ${r.b}, margin ${r.margin}${r.upset ? ' · the lower rating won this one' : ''} · tap to collapse`;
++ line(`<span class="arena-round-tag">R${i + 1} · ${_aEsc(r.label)}</span> ${_aEsc(c.rounds[i] || `${winner.persona.fighter_name} takes it.`)} <span class="arena-round-score">${r.a}–${r.b}${r.upset ? ' · UPSET' : ''}</span>`, `arena-line-${r.winner}${r.upset ? ' arena-line-upset' : ''}`, detail);
document.querySelector(`.arena-side-${r.winner}`)?.classList.add('is-hit');
setTimeout(() => document.querySelector(`.arena-side-${r.winner}`)?.classList.remove('is-hit'), 600);
});
@@@ -340,7 +578,8 @@ function _aPlayBattle(battle) {
at(t, () => line(`<i class="fa-solid fa-burst"></i> ${_aEsc(c.finisher)}`, 'arena-line-finisher'));
t += 1200;
at(t, () => {
-- line(`<i class="fa-solid fa-gavel"></i> ${_aEsc(c.verdict)}`, 'arena-line-verdict');
++ line(`<i class="fa-solid fa-gavel"></i> ${_aEsc(c.verdict)} ${_aSpeakBtn(`${c.intro} ${c.rounds.join(' ')} ${c.finisher} ${c.verdict}`)}`, 'arena-line-verdict');
++ _aBindSpeak(box);
_aRenderVote(battle);
});
}
ddiff --git a/server/arena/arena-service.js b/server/arena/arena-service.js
iindex 29d1e281..cf721858 100644
---- a/server/arena/arena-service.js
++++ b/server/arena/arena-service.js
@@@ -4,12 +4,16 @@
* Turns the analytics + AI data the site already collects about each streamer into a
* tongue-in-cheek fighting-game roster:
*
-- * stats → six 40–99 ratings (HYPE, GRIND, CHAT, LOYALTY, CLUTCH, VIBE) computed
-- * as percentiles across the active roster, plus an overall POWER score.
-- * Deterministic and free — no AI involved.
++ * stats → seven 40–99 ratings (HYPE, GRIND, CHAT, LOYALTY, CLUTCH, VIBE, MIC)
++ * computed as percentiles across the active roster, plus an overall POWER.
++ * Deterministic and free — no AI involved. MIC comes from the continuous
++ * audio transcription (stream_timeline_events): how much they talk, how
++ * hyped they sound, how often the room laughs.
* persona → AI "character select" bio (fighter name, class, signature move, taunt,
* lore …) written from the streamer's AI overview, stream memories, chat-AI
* notes, VOD overviews and the numbers. Cached 24 h per streamer.
++ * quotes → AI-picked "things they actually said" from the transcripts, each linked
++ * to the VOD at that second so people can go hear it. Cached 24 h.
* image → optional AI character portrait. Deliberately NOT a likeness: the image
* model gets a description of the persona, category, colours and the
* *scene* of the latest thumbnail (setting/objects/mood — never faces or
@@@ -34,9 +38,11 @@ const ARENA_DIR = path.resolve(process.env.ARENA_IMAGE_PATH || './data/arena');
const ACTIVE_DAYS = 45; // streamed within this window → on the roster
const STATS_WINDOW_DAYS = 90; // ratings use the last 90 days of streams
const PERSONA_TTL_MS = 24 * 60 * 60 * 1000;
++const QUOTES_TTL_MS = 24 * 60 * 60 * 1000;
const IMAGE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const RATING_MIN = 40; // nobody on the roster is a 5/99
--const STAT_KEYS = ['hype', 'grind', 'chat', 'loyalty', 'clutch', 'vibe'];
++const MIN_QUOTE_LINES = 20; // need this many transcript lines before picking quotes
++const STAT_KEYS = ['hype', 'grind', 'chat', 'loyalty', 'clutch', 'vibe', 'mic'];
const STAT_META = {
hype: { label: 'Hype', desc: 'peak concurrent viewers' },
grind: { label: 'Grind', desc: 'hours live' },
@@@ -44,13 +50,18 @@ const STAT_META = {
loyalty: { label: 'Loyalty', desc: 'followers + returning chatters' },
clutch: { label: 'Clutch', desc: 'clips + tips per hour' },
vibe: { label: 'Vibe', desc: 'average viewers' },
++ mic: { label: 'Mic', desc: 'how much (and how loud) they talk — from the transcripts' },
};
++const STAT_WEIGHTS = { hype: 0.22, vibe: 0.18, chat: 0.14, loyalty: 0.13, grind: 0.13, clutch: 0.1, mic: 0.1 };
const ROUNDS = [
{ key: 'hype', label: 'Hype Check', stat: 'hype' },
{ key: 'chat', label: 'Chat War', stat: 'chat' },
{ key: 'grind', label: 'Endurance', stat: 'grind' },
{ key: 'clutch', label: 'Clutch Time', stat: 'clutch' },
++ { key: 'mic', label: 'Mic Drop', stat: 'mic' },
];
++// Words that count as "hyped" when they show up in the transcript.
++const HYPE_PATTERNS = ["let's go", 'lets go', 'no way', 'oh my god', 'insane', 'clutch', 'holy', 'gg', 'unreal', 'what the', 'bro', 'chat,', 'chat ', 'yo ', 'welcome', 'lfg', 'poggers', 'pog'];
// ── Tables ───────────────────────────────────────────────────
@@@ -70,6 +81,10 @@ function ensureTables() {
image_error TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
++ // Added later — inline ALTERs wrapped in try/catch, like the rest of the codebase.
++ for (const col of ['quotes_json TEXT', 'quotes_generated_at DATETIME']) {
++ try { db.run(`ALTER TABLE arena_profiles ADD COLUMN ${col}`); } catch { /* exists */ }
++ }
db.run(`CREATE TABLE IF NOT EXISTS arena_battles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
a_user_id INTEGER NOT NULL,
@@@ -122,6 +137,61 @@ function activeStreamerIds() {
`, [`-${ACTIVE_DAYS} days`]).map(r => r.user_id);
}
++/** Voice metrics from the continuous transcription (stream_timeline_events). */
++function voiceStatsFor(userId, win) {
++ const hypeSql = HYPE_PATTERNS.map(() => "(LOWER(text) LIKE ?)").join(' + ');
++ const hypeParams = HYPE_PATTERNS.map(p => `%${p}%`);
++ let speech = {};
++ try {
++ speech = db.get(`
++ SELECT COUNT(*) AS lines,
++ COALESCE(SUM(COALESCE(end_sec, start_sec + 3) - start_sec), 0) AS speech_sec,
++ COALESCE(SUM(LENGTH(text) - LENGTH(REPLACE(text, ' ', '')) + 1), 0) AS words,
++ COALESCE(SUM(text LIKE '%!%'), 0) AS exclaims,
++ COALESCE(SUM(text LIKE '%?%'), 0) AS questions,
++ COALESCE(SUM(${hypeSql}), 0) AS hype_hits,
++ COUNT(DISTINCT stream_id) AS streams_heard
++ FROM stream_timeline_events
++ WHERE user_id = ? AND kind = 'speech' AND created_at >= datetime('now', ?)
++ `, [...hypeParams, userId, win]) || {};
++ } catch { speech = {}; }
++ let covered = 0, laughs = 0, topSounds = [];
++ try {
++ covered = db.get(`
++ SELECT COALESCE(SUM(duration_seconds), 0) AS sec FROM streams
++ WHERE user_id = ? AND duration_seconds > 0
++ AND id IN (SELECT DISTINCT stream_id FROM stream_timeline_events WHERE user_id = ? AND created_at >= datetime('now', ?))
++ `, [userId, userId, win])?.sec || 0;
++ laughs = db.get(`SELECT COUNT(*) AS n FROM stream_timeline_events WHERE user_id = ? AND kind = 'sound' AND (LOWER(label) LIKE '%laugh%' OR LOWER(label) LIKE '%giggle%' OR LOWER(label) LIKE '%chuckle%') AND created_at >= datetime('now', ?)`, [userId, win])?.n || 0;
++ topSounds = db.all(`SELECT label, COUNT(*) AS n FROM stream_timeline_events WHERE user_id = ? AND kind = 'sound' AND label IS NOT NULL AND created_at >= datetime('now', ?) GROUP BY label ORDER BY n DESC LIMIT 5`, [userId, win]);
++ } catch { /* */ }
++ const speechSec = Number(speech.speech_sec) || 0;
++ const coveredHours = Math.max(covered / 3600, 0.05);
++ const speechMin = Math.max(speechSec / 60, 0.1);
++ const talkRatio = covered > 0 ? Math.min(1, speechSec / covered) : 0;
++ const wpm = speech.words ? speech.words / speechMin : 0;
++ const hypePerHour = (speech.hype_hits || 0) / coveredHours;
++ const laughsPerHour = laughs / coveredHours;
++ const hasData = (speech.lines || 0) >= 5;
++ return {
++ has_data: hasData,
++ lines: speech.lines || 0,
++ streams_heard: speech.streams_heard || 0,
++ speech_minutes: Number((speechSec / 60).toFixed(1)),
++ talk_ratio_pct: Number((talkRatio * 100).toFixed(1)),
++ wpm: Number(wpm.toFixed(0)),
++ words: speech.words || 0,
++ exclaims: speech.exclaims || 0,
++ questions: speech.questions || 0,
++ hype_hits: speech.hype_hits || 0,
++ hype_per_hour: Number(hypePerHour.toFixed(1)),
++ laughs, laughs_per_hour: Number(laughsPerHour.toFixed(1)),
++ top_sounds: topSounds.map(s => ({ label: s.label, n: s.n })),
++ // The single number MIC is ranked on: talk share carries it, hype + laughter spice it.
++ voice_score: hasData ? Number((talkRatio * 100 + Math.min(hypePerHour, 60) * 0.5 + Math.min(laughsPerHour, 30) + Math.min(wpm, 200) / 20).toFixed(2)) : 0,
++ };
++}
++
function rawStatsFor(userId) {
const win = `-${STATS_WINDOW_DAYS} days`;
const agg = db.get(`
@@@ -149,6 +219,7 @@ function rawStatsFor(userId) {
GROUP BY category ORDER BY n DESC LIMIT 1
`, [userId])?.category || null;
const hours = Math.max(Number(agg.hours) || 0, 0.1);
++ const voice = voiceStatsFor(userId, win);
return {
window_days: STATS_WINDOW_DAYS,
streams: agg.streams || 0,
@@@ -161,7 +232,7 @@ function rawStatsFor(userId) {
watch_hours: Number(((agg.watch_minutes || 0) / 60).toFixed(1)),
clips: agg.clips || 0,
tips,
-- clutch_per_hour: Number(((agg.clips || 0) * 3 + tips / 100) / hours).toFixed ? Number((((agg.clips || 0) * 3 + tips / 100) / hours).toFixed(2)) : 0,
++ clutch_per_hour: Number((((agg.clips || 0) * 3 + tips / 100) / hours).toFixed(2)),
followers,
loyalty_score: followers + (agg.unique_chatters || 0) / 4,
all_time_hours: Number((allTime.hours || 0).toFixed(1)),
@@@ -169,6 +240,7 @@ function rawStatsFor(userId) {
all_time_streams: allTime.streams || 0,
last_live_at: agg.last_live_at || null,
category,
++ voice,
};
}
@@@ -179,6 +251,7 @@ const METRIC_FOR_STAT = {
loyalty: (r) => r.loyalty_score,
clutch: (r) => r.clutch_per_hour,
vibe: (r) => r.avg_viewers,
++ mic: (r) => (r.voice ? r.voice.voice_score : 0),
};
/**
@@@ -204,9 +277,8 @@ function computeRatings(rosterRaw) {
out[id][stat] = Math.round(RATING_MIN + (99 - RATING_MIN) * Math.max(0, Math.min(1, pct)));
});
}
-- const weights = { hype: 0.25, vibe: 0.2, chat: 0.15, loyalty: 0.15, grind: 0.15, clutch: 0.1 };
for (const id of ids) {
-- out[id].power = Math.round(STAT_KEYS.reduce((sum, k) => sum + out[id][k] * weights[k], 0));
++ out[id].power = Math.round(STAT_KEYS.reduce((sum, k) => sum + out[id][k] * STAT_WEIGHTS[k], 0));
}
return out;
}
@@@ -227,11 +299,7 @@ function loadRoster(force = false) {
for (const id of ids) {
const user = db.getUserById(id);
if (!user) continue;
-- byId[id] = {
-- user: publicUser(user),
-- raw: rawById[id],
-- ratings: ratings[id],
-- };
++ 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 { /* */ }
}
const order = Object.keys(byId).map(Number).sort((a, b) => byId[b].ratings.power - byId[a].ratings.power || a - b);
@@@ -250,7 +318,7 @@ function publicUser(u) {
};
}
--// ── Records (W/L from stored battles) ────────────────────────
++// ── Records, history, rivalries ──────────────────────────────
function recordFor(userId) {
const r = db.get(`
@@@ -262,6 +330,52 @@ function recordFor(userId) {
return { wins: r.wins || 0, losses: r.losses || 0 };
}
++function recentBattles(userId, roster, limit = 8) {
++ const rows = db.all(`
++ SELECT id, a_user_id, b_user_id, day, winner_user_id, result_json FROM arena_battles
++ WHERE a_user_id = ? OR b_user_id = ? ORDER BY day DESC, id DESC LIMIT ?
++ `, [userId, userId, limit]);
++ return rows.map(b => {
++ const oppId = b.a_user_id === userId ? b.b_user_id : b.a_user_id;
++ const opp = roster.byId[oppId];
++ const oppUser = opp ? opp.user : publicUser(db.getUserById(oppId) || { id: oppId, username: `user${oppId}` });
++ const rounds = parseJson(b.result_json, { rounds: [] }).rounds || [];
++ const mine = b.a_user_id === userId ? 'a' : 'b';
++ return {
++ id: b.id, day: b.day,
++ opponent: oppUser,
++ opponent_fighter_name: opp ? (parseJson(profileRow(oppId)?.persona_json) || fallbackPersona(opp)).fighter_name : oppUser.display_name,
++ result: b.winner_user_id == null ? 'draw' : (b.winner_user_id === userId ? 'win' : 'loss'),
++ rounds_won: rounds.filter(r => r.winner === mine).length,
++ rounds_total: rounds.length,
++ };
++ });
++}
++
++function rivalryFor(userId, roster) {
++ const row = db.get(`
++ SELECT CASE WHEN a_user_id = ? THEN b_user_id ELSE a_user_id END AS opp, COUNT(*) AS fights,
++ SUM(CASE WHEN winner_user_id = ? THEN 1 ELSE 0 END) AS wins,
++ SUM(CASE WHEN winner_user_id IS NOT NULL AND winner_user_id != ? THEN 1 ELSE 0 END) AS losses
++ FROM arena_battles WHERE a_user_id = ? OR b_user_id = ?
++ GROUP BY opp ORDER BY fights DESC, wins ASC LIMIT 1
++ `, [userId, userId, userId, userId, userId]);
++ if (!row || row.fights < 2) return null;
++ const opp = roster.byId[row.opp];
++ if (!opp) return null;
++ return { opponent: opp.user, fighter_name: (parseJson(profileRow(row.opp)?.persona_json) || fallbackPersona(opp)).fighter_name, fights: row.fights, wins: row.wins || 0, losses: row.losses || 0 };
++}
++
++function headToHead(ida, idb) {
++ const r = db.get(`
++ SELECT COUNT(*) AS fights,
++ SUM(CASE WHEN winner_user_id = ? THEN 1 ELSE 0 END) AS a_wins,
++ SUM(CASE WHEN winner_user_id = ? THEN 1 ELSE 0 END) AS b_wins
++ FROM arena_battles WHERE a_user_id = ? AND b_user_id = ?
++ `, [ida, idb, ida, idb]) || {};
++ return { fights: r.fights || 0, a_wins: r.a_wins || 0, b_wins: r.b_wins || 0 };
++}
++
// ── Persona (AI) ─────────────────────────────────────────────
function profileRow(userId) {
@@@ -274,10 +388,9 @@ function parseJson(text, fallback = null) {
try { return JSON.parse(text); } catch { return fallback; }
}
--function personaIsFresh(row) {
-- if (!row || !row.persona_json || !row.persona_generated_at) return false;
-- return Date.now() - Date.parse(row.persona_generated_at + 'Z') < PERSONA_TTL_MS;
--}
++function freshWithin(ts, ttl) { return !!ts && Date.now() - Date.parse(ts + 'Z') < ttl; }
++function personaIsFresh(row) { return !!(row && row.persona_json) && freshWithin(row.persona_generated_at, PERSONA_TTL_MS); }
++function quotesAreFresh(row) { return !!(row && row.quotes_json) && freshWithin(row.quotes_generated_at, QUOTES_TTL_MS); }
/** Everything the site knows about a streamer that a writer could riff on. */
function gatherContext(userId) {
@@@ -292,12 +405,11 @@ function gatherContext(userId) {
if (chatAi) {
const ov = parseJson(chatAi.overview, null);
ctx.chat_notes = ov ? [ov.alltime, ov.today].filter(Boolean).join(' ') : String(chatAi.overview || '');
-- ctx.chat_memory = String(chatAi.memory_json || '').slice(0, 800);
}
} catch { /* */ }
try {
ctx.vods = db.all(`
-- SELECT v.title, va.overview_short AS overview FROM vods v
++ SELECT v.title, va.ai_overview_short AS overview FROM vods v
LEFT JOIN vod_ai_state va ON va.vod_id = v.id
WHERE v.user_id = ? AND v.is_public = 1 ORDER BY v.created_at DESC LIMIT 5
`, [userId]).map(v => ({ title: v.title, overview: v.overview || null }));
@@@ -305,6 +417,10 @@ function gatherContext(userId) {
try { ctx.vods = db.all('SELECT title FROM vods WHERE user_id = ? ORDER BY created_at DESC LIMIT 5', [userId]).map(v => ({ title: v.title })); } catch { ctx.vods = []; }
}
try { ctx.titles = db.all('SELECT DISTINCT title FROM streams WHERE user_id = ? AND duration_seconds > 0 ORDER BY started_at DESC LIMIT 8', [userId]).map(r => r.title).filter(Boolean); } catch { ctx.titles = []; }
++ try {
++ // A taste of how they actually talk — short, recent, non-empty lines.
++ ctx.said = db.all(`SELECT text FROM stream_timeline_events WHERE user_id = ? AND kind = 'speech' AND LENGTH(text) BETWEEN 30 AND 140 ORDER BY created_at DESC LIMIT 12`, [userId]).map(r => r.text);
++ } catch { ctx.said = []; }
return ctx;
}
@@@ -335,8 +451,8 @@ const PERSONA_SCHEMA = {
};
const PERSONA_SYSTEM = `You write fighting-game "character select" bios for a live-streaming site's tongue-in-cheek Arena tab, where streamers are ranked and pitted against each other for laughs.
--Voice: hype-caster energy, absurd, affectionate roast. Riff on what they actually stream, their running jokes, chat culture, schedule and the numbers you are given.
--Hard rules: never mock appearance, body, voice, disability, age, ethnicity, gender, religion, health, money troubles or anything a streamer cannot laugh at. Never invent real-life facts; everything is Arena lore. Keep it PG-13. Do not use the word "streamer" more than once. Output only the JSON.`;
++Voice: hype-caster energy, absurd, affectionate roast. Riff on what they actually stream, how they talk (transcript lines are provided), their running jokes, chat culture, schedule and the numbers you are given.
++Hard rules: never mock appearance, body, voice quality, accent, disability, age, ethnicity, gender, religion, health, money troubles or anything a streamer cannot laugh at. Never invent real-life facts; everything is Arena lore. Keep it PG-13. Do not use the word "streamer" more than once. Output only the JSON.`;
async function generatePersona(userId, { force = false } = {}) {
ensureTables();
@@@ -357,24 +473,20 @@ async function generatePersona(userId, { force = false } = {}) {
hours_live_90d: entry.raw.hours, peak_viewers_90d: entry.raw.peak_viewers, avg_viewers: entry.raw.avg_viewers,
chat_messages_per_hour: entry.raw.messages_per_hour, followers: entry.raw.followers, clips: entry.raw.clips,
all_time_hours: entry.raw.all_time_hours, all_time_peak: entry.raw.all_time_peak,
++ on_mic: entry.raw.voice.has_data ? { talk_share_pct: entry.raw.voice.talk_ratio_pct, words_per_minute: entry.raw.voice.wpm, hype_words_per_hour: entry.raw.voice.hype_per_hour, laughs_per_hour: entry.raw.voice.laughs_per_hour, stream_sounds: entry.raw.voice.top_sounds.map(s => s.label) } : 'no transcript data yet',
},
ai_overview: ctx.overview,
recent_stream_titles: ctx.titles,
what_the_camera_saw_recently: ctx.memories,
chat_ai_notes: ctx.chat_notes,
recent_vods: ctx.vods,
++ things_they_said_on_stream: ctx.said,
};
const r = await llm.complete({
-- role: 'summary',
-- kind: 'arena_persona',
-- source: 'arena',
-- ownerUserId: userId,
++ role: 'summary', kind: 'arena_persona', source: 'arena', ownerUserId: userId,
system: PERSONA_SYSTEM,
user: `Write the Arena persona for this fighter. Facts (JSON):\n${JSON.stringify(facts)}`,
-- json: PERSONA_SCHEMA,
-- maxTokens: 900,
-- temperature: 0.95,
-- timeoutMs: 30000,
++ json: PERSONA_SCHEMA, maxTokens: 950, temperature: 0.95, timeoutMs: 30000,
});
const persona = r && r.json && r.json.fighter_name ? r.json : null;
if (!persona) {
@@@ -394,7 +506,7 @@ async function generatePersona(userId, { force = false } = {}) {
function fallbackPersona(entry) {
const r = entry.ratings || {};
const best = STAT_KEYS.reduce((a, b) => ((r[b] || 0) > (r[a] || 0) ? b : a), STAT_KEYS[0]);
-- const cls = { hype: 'Rushdown', grind: 'Tank', chat: 'Bard', loyalty: 'Summoner', clutch: 'Assassin', vibe: 'Zoner' }[best];
++ const cls = { hype: 'Rushdown', grind: 'Tank', chat: 'Bard', loyalty: 'Summoner', clutch: 'Assassin', vibe: 'Zoner', mic: 'Caster' }[best];
return {
fighter_name: entry.user.display_name,
title: `The ${STAT_META[best].label} Specialist`,
@@@ -412,6 +524,89 @@ function fallbackPersona(entry) {
};
}
++// ── Quotes (from the transcripts; AI picks the good ones) ────
++
++function quoteCandidates(userId, limit = 90) {
++ let rows = [];
++ try {
++ rows = db.all(`
++ SELECT id, stream_id, vod_id, start_sec, text FROM stream_timeline_events
++ WHERE user_id = ? AND kind = 'speech' AND created_at >= datetime('now', ?)
++ AND LENGTH(text) BETWEEN 25 AND 220
++ ORDER BY created_at DESC LIMIT 1500
++ `, [userId, `-${STATS_WINDOW_DAYS} days`]);
++ } catch { rows = []; }
++ const score = (t) => {
++ const s = t.toLowerCase();
++ let n = (t.match(/!/g) || []).length * 2 + (t.match(/\?/g) || []).length;
++ for (const p of HYPE_PATTERNS) if (s.includes(p)) n += 3;
++ if (/\b(i|we|you|chat)\b/.test(s)) n += 1;
++ if (/\b(um+|uh+|like like)\b/.test(s)) n -= 1;
++ return n;
++ };
++ // Keep the spiciest, then fill with a spread across time so one stream cannot dominate.
++ const scored = rows.map(r => ({ ...r, score: score(r.text) })).sort((x, y) => y.score - x.score);
++ const picked = scored.slice(0, Math.ceil(limit / 2));
++ const rest = scored.slice(Math.ceil(limit / 2));
++ const step = Math.max(1, Math.floor(rest.length / Math.max(1, limit - picked.length)));
++ for (let i = 0; i < rest.length && picked.length < limit; i += step) picked.push(rest[i]);
++ return picked;
++}
++
++const QUOTES_SCHEMA = {
++ name: 'arena_quotes',
++ schema: {
++ type: 'object', additionalProperties: false,
++ required: ['picks', 'walkout', 'voice_verdict', 'mic_style'],
++ properties: {
++ picks: { type: 'array', minItems: 1, maxItems: 6, items: { type: 'object', additionalProperties: false, required: ['index', 'why'], properties: { index: { type: 'integer' }, why: { type: 'string', description: '≤ 10 words: why this line is a banger' } } } },
++ walkout: { type: 'integer', description: 'index of the line that should play as their walkout' },
++ voice_verdict: { type: 'string', description: 'One sentence on how they sound on the mic, announcer voice' },
++ mic_style: { type: 'string', description: '2–3 words, e.g. "Rapid-fire tech sermon"' },
++ },
++ },
++};
++
++const QUOTES_SYSTEM = `You pick the most quotable lines a live-streamer actually said, from raw speech-to-text (expect transcription noise). Choose lines that are funny, hype, weirdly profound, or perfect trash talk out of context. Skip anything that would embarrass them or anyone else: no insults about people, nothing about health/money/family, nothing offensive. Prefer complete sentences. Return indexes into the list you are given. Output only the JSON.`;
++
++function fallbackQuotes(candidates) {
++ const picks = candidates.slice(0, 5).map(c => ({ ...c, why: 'straight from the transcript' }));
++ return { picks, walkout: picks[0] || null, voice_verdict: null, mic_style: null, _fallback: true };
++}
++
++function materializeQuotes(candidates, sel) {
++ const pick = (i, why) => { const c = candidates[i]; return c ? { text: c.text, stream_id: c.stream_id, vod_id: c.vod_id, start_sec: Math.max(0, Math.floor(Number(c.start_sec) || 0) - 2), why } : null; };
++ const picks = (sel.picks || []).map(p => pick(p.index, p.why)).filter(Boolean);
++ const walkout = pick(sel.walkout, 'walkout line') || picks[0] || null;
++ return { picks, walkout, voice_verdict: sel.voice_verdict || null, mic_style: sel.mic_style || null };
++}
++
++async function generateQuotes(userId, { force = false } = {}) {
++ ensureTables();
++ const row = profileRow(userId);
++ if (!force && quotesAreFresh(row)) return parseJson(row.quotes_json);
++ const candidates = quoteCandidates(userId);
++ if (candidates.length < MIN_QUOTE_LINES) return null;
++ let result = null;
++ if (aiOn()) {
++ try {
++ const r = await llm.complete({
++ role: 'summary', kind: 'arena_quotes', source: 'arena', ownerUserId: userId,
++ system: QUOTES_SYSTEM,
++ user: `Lines (index: text):\n${candidates.map((c, i) => `${i}: ${c.text}`).join('\n')}`,
++ json: QUOTES_SCHEMA, maxTokens: 500, temperature: 0.7, timeoutMs: 30000,
++ });
++ if (r && r.json && Array.isArray(r.json.picks)) result = materializeQuotes(candidates, r.json);
++ } catch (e) { console.warn('[Arena] quotes:', e.message); }
++ }
++ if (!result || !result.picks.length) result = materializeQuotes(candidates, { picks: fallbackQuotes(candidates).picks.map((p, i) => ({ index: i, why: p.why })), walkout: 0 });
++ if (!aiOn()) result._fallback = true;
++ db.run(`INSERT INTO arena_profiles (user_id, quotes_json, quotes_generated_at, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
++ ON CONFLICT(user_id) DO UPDATE SET quotes_json = excluded.quotes_json, quotes_generated_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP`,
++ [userId, JSON.stringify(result)]);
++ return result;
++}
++
// ── Image (AI, optional) ─────────────────────────────────────
function imageIsFresh(row) {
@@@ -538,27 +733,32 @@ function isLive(userId) {
return !!db.get('SELECT 1 FROM streams WHERE user_id = ? AND is_live = 1 LIMIT 1', [userId]);
}
--function cardFor(userId, roster, { includeRaw = true } = {}) {
++function cardFor(userId, roster, { includeRaw = true, includeQuotes = false } = {}) {
const entry = roster.byId[userId];
if (!entry) return null;
const row = profileRow(userId);
const persona = parseJson(row?.persona_json) || fallbackPersona(entry);
const rank = roster.order.indexOf(userId) + 1;
-- return {
++ const card = {
user: entry.user,
rank,
roster_size: roster.order.length,
ratings: entry.ratings,
stat_meta: STAT_META,
raw: includeRaw ? entry.raw : undefined,
++ voice: entry.raw.voice,
persona,
persona_is_fallback: !!persona._fallback,
persona_generated_at: row?.persona_generated_at || null,
image_url: imageUrlFor(row),
++ image_prompt: row?.image_prompt || null,
++ image_model: row?.image_model || null,
image_pending: false,
record: recordFor(userId),
live: isLive(userId),
};
++ if (includeQuotes) card.quotes = parseJson(row?.quotes_json) || null;
++ return card;
}
async function getFighter(usernameOrId, { generate = true } = {}) {
@@@ -566,17 +766,20 @@ async function getFighter(usernameOrId, { generate = true } = {}) {
if (!user) return null;
const roster = loadRoster();
if (!roster.byId[user.id]) return { user: publicUser(user), not_on_roster: true, reason: `No streams in the last ${ACTIVE_DAYS} days` };
-- if (generate && aiOn()) {
++ if (generate) {
const row = profileRow(user.id);
-- if (!personaIsFresh(row)) { try { await generatePersona(user.id); } catch (e) { console.warn('[Arena] persona:', e.message); } }
-- if (!imageIsFresh(profileRow(user.id)) && imageGenAvailable()) {
++ if (aiOn() && !personaIsFresh(row)) { try { await generatePersona(user.id); } catch (e) { console.warn('[Arena] persona:', e.message); } }
++ if (!quotesAreFresh(profileRow(user.id))) { try { await generateQuotes(user.id); } catch (e) { console.warn('[Arena] quotes:', e.message); } }
++ if (aiOn() && !imageIsFresh(profileRow(user.id)) && imageGenAvailable()) {
// Fire and forget — the client polls until image_url is present.
generateImage(user.id).catch(() => {});
}
}
-- const card = cardFor(user.id, roster);
++ const card = cardFor(user.id, roster, { includeQuotes: true });
card.image_pending = !card.image_url && _imageInFlight.has(user.id);
card.image_generation = imageGenAvailable() ? 'ai' : 'off';
++ card.recent_battles = recentBattles(user.id, roster);
++ card.rivalry = rivalryFor(user.id, roster);
return card;
}
@@@ -586,14 +789,59 @@ function listFighters() {
const c = cardFor(id, roster, { includeRaw: false });
return {
user: c.user, rank: c.rank, ratings: c.ratings, record: c.record, live: c.live, image_url: c.image_url,
-- persona: { fighter_name: c.persona.fighter_name, title: c.persona.title, class: c.persona.class, element: c.persona.element, taunt: c.persona.taunt },
++ persona: { fighter_name: c.persona.fighter_name, title: c.persona.title, class: c.persona.class, element: c.persona.element, taunt: c.persona.taunt, lore: c.persona.lore, signature_move: c.persona.signature_move, stat_quips: c.persona.stat_quips },
persona_is_fallback: c.persona_is_fallback,
category: roster.byId[id].raw.category,
last_live_at: roster.byId[id].raw.last_live_at,
++ voice: { has_data: c.voice.has_data, talk_ratio_pct: c.voice.talk_ratio_pct, speech_minutes: c.voice.speech_minutes },
};
});
}
++/** Per-stream series for one stat (for the expandable stat view) + where they rank on it. */
++function getStatDetail(userId, stat) {
++ if (!STAT_KEYS.includes(stat)) return null;
++ const roster = loadRoster();
++ const entry = roster.byId[userId];
++ if (!entry) return null;
++ const win = `-${STATS_WINDOW_DAYS} days`;
++ let series = [];
++ try {
++ const rows = db.all(`
++ SELECT s.id, s.title, s.started_at, s.duration_seconds, s.peak_viewers,
++ sa.avg_viewers, sa.total_messages, sa.unique_chatters, sa.new_followers, sa.clips_created,
++ (SELECT COALESCE(SUM(COALESCE(e.end_sec, e.start_sec + 3) - e.start_sec), 0) FROM stream_timeline_events e WHERE e.stream_id = s.id AND e.kind = 'speech') AS speech_sec
++ FROM streams s LEFT JOIN stream_analytics sa ON sa.stream_id = s.id
++ WHERE s.user_id = ? AND s.duration_seconds > 0 AND s.started_at >= datetime('now', ?)
++ ORDER BY s.started_at DESC LIMIT 14
++ `, [userId, win]).reverse();
++ const per = {
++ hype: r => r.peak_viewers || 0,
++ grind: r => Number(((r.duration_seconds || 0) / 3600).toFixed(2)),
++ chat: r => Number(((r.total_messages || 0) / Math.max((r.duration_seconds || 0) / 3600, 0.1)).toFixed(1)),
++ loyalty: r => (r.new_followers || 0) + (r.unique_chatters || 0) / 4,
++ clutch: r => Number((((r.clips_created || 0) * 3) / Math.max((r.duration_seconds || 0) / 3600, 0.1)).toFixed(2)),
++ vibe: r => Number((r.avg_viewers || 0).toFixed(1)),
++ mic: r => (r.duration_seconds ? Number(((r.speech_sec || 0) / r.duration_seconds * 100).toFixed(1)) : 0),
++ };
++ series = rows.map(r => ({ stream_id: r.id, title: r.title, date: r.started_at, value: per[stat](r) }));
++ } catch { series = []; }
++ const unit = { hype: 'peak viewers', grind: 'hours', chat: 'msgs / hour', loyalty: 'loyalty points', clutch: 'clutch / hour', vibe: 'avg viewers', mic: '% of stream talking' }[stat];
++ // MIC is ranked on a composite voice score, but people are shown the talk-time share
++ // (the same number the voice card uses) so the drill-down never contradicts the card.
++ const shown = (raw) => (stat === 'mic' ? (raw.voice?.talk_ratio_pct || 0) : (METRIC_FOR_STAT[stat](raw) || 0));
++ const ranked = roster.order.map(id => ({ id, value: METRIC_FOR_STAT[stat](roster.byId[id].raw) || 0, shown: shown(roster.byId[id].raw), rating: roster.byId[id].ratings[stat] })).sort((x, y) => y.value - x.value);
++ const position = ranked.findIndex(r => r.id === userId) + 1;
++ const top = ranked.slice(0, 3).map(r => ({ user: roster.byId[r.id].user, fighter_name: (parseJson(profileRow(r.id)?.persona_json) || fallbackPersona(roster.byId[r.id])).fighter_name, value: Number(Number(r.shown).toFixed(1)), rating: r.rating }));
++ return {
++ stat, label: STAT_META[stat].label, desc: STAT_META[stat].desc, unit,
++ rating: entry.ratings[stat], value: Number(Number(shown(entry.raw)).toFixed(1)),
++ position, roster_size: roster.order.length, weight: STAT_WEIGHTS[stat],
++ series, top,
++ voice: stat === 'mic' ? entry.raw.voice : undefined,
++ };
++}
++
// ── Battles ──────────────────────────────────────────────────
function today() { return new Date().toISOString().slice(0, 10); }
@@@ -612,14 +860,13 @@ function seededRandom(seedText) {
/** Same pair on the same day → identical rounds. Order-independent. */
function simulateRounds(aRatings, bRatings, seedText) {
const rand = seededRandom(seedText);
-- const rounds = ROUNDS.map(r => {
++ return ROUNDS.map(r => {
const a = aRatings[r.stat] + rand() * 30;
const b = bRatings[r.stat] + rand() * 30;
const winner = a === b ? (aRatings.power >= bRatings.power ? 'a' : 'b') : (a > b ? 'a' : 'b');
const margin = Math.abs(a - b);
return { key: r.key, label: r.label, stat: r.stat, a: Math.round(a), b: Math.round(b), winner, margin: Math.round(margin), upset: (winner === 'a' ? aRatings[r.stat] < bRatings[r.stat] : bRatings[r.stat] < aRatings[r.stat]) };
});
-- return rounds;
}
function tallyVotes(battleId) {
@@@ -643,14 +890,14 @@ const COMMENTARY_SCHEMA = {
required: ['intro', 'rounds', 'finisher', 'verdict'],
properties: {
intro: { type: 'string', description: 'Announcer intro, ≤ 2 sentences, name both fighters' },
-- rounds: { type: 'array', minItems: 4, maxItems: 4, items: { type: 'string', description: 'One vivid play-by-play sentence per round, in order' } },
++ rounds: { type: 'array', minItems: ROUNDS.length, maxItems: ROUNDS.length, items: { type: 'string', description: 'One vivid play-by-play sentence per round, in order' } },
finisher: { type: 'string', description: 'The finishing move that decided it, 1 sentence' },
verdict: { type: 'string', description: 'Announcer verdict + a wink at the crowd vote, ≤ 2 sentences' },
},
},
};
--const COMMENTARY_SYSTEM = `You are the over-caffeinated ring announcer of a streaming site's Arena, where streamers battle as fighting-game characters. Call the fight from the round results you are given: name the signature moves, riff on their lore and chat culture, celebrate upsets. Affectionate roast only — never cruel, never about appearance/body/health/identity. PG-13. Output only the JSON.`;
++const COMMENTARY_SYSTEM = `You are the over-caffeinated ring announcer of a streaming site's Arena, where streamers battle as fighting-game characters. Call the fight from the round results you are given: name the signature moves, riff on their lore, quote their walkout lines if given, celebrate upsets. The "Mic Drop" round is about who talks more and louder on stream. Affectionate roast only — never cruel, never about appearance/body/voice quality/accent/health/identity. PG-13. Output only the JSON.`;
function templateCommentary(A, B, rounds, outcome) {
const name = (s) => (s === 'a' ? A.persona.fighter_name : B.persona.fighter_name);
@@@ -674,13 +921,14 @@ async function getBattle(aName, bName, { generate = true } = {}) {
ensureTables();
let row = db.get('SELECT * FROM arena_battles WHERE a_user_id = ? AND b_user_id = ? AND day = ?', [ida, idb, day]);
-- if (generate && aiOn()) {
++ if (generate) {
for (const id of [ida, idb]) {
-- if (!personaIsFresh(profileRow(id))) { try { await generatePersona(id); } catch { /* */ } }
++ if (aiOn() && !personaIsFresh(profileRow(id))) { try { await generatePersona(id); } catch { /* */ } }
++ if (!quotesAreFresh(profileRow(id))) { try { await generateQuotes(id); } catch { /* */ } }
}
}
-- const A = cardFor(ida, roster, { includeRaw: false });
-- const B = cardFor(idb, roster, { includeRaw: false });
++ const A = cardFor(ida, roster, { includeRaw: true, includeQuotes: true });
++ const B = cardFor(idb, roster, { includeRaw: true, includeQuotes: true });
if (!row) {
const rounds = simulateRounds(A.ratings, B.ratings, `${ida}:${idb}:${day}`);
@@@ -696,16 +944,16 @@ async function getBattle(aName, bName, { generate = true } = {}) {
let commentary = parseJson(row.commentary_json);
if (!commentary && generate && aiOn()) {
try {
++ const brief = (X) => ({ name: X.persona.fighter_name, title: X.persona.title, class: X.persona.class, signature_move: X.persona.signature_move, special: X.persona.special, weakness: X.persona.weakness, lore: X.persona.lore, ratings: X.ratings, walkout_line: X.quotes?.walkout?.text || null, mic_style: X.quotes?.mic_style || null });
const r = await llm.complete({
role: 'chat', kind: 'arena_commentary', source: 'arena',
system: COMMENTARY_SYSTEM,
user: JSON.stringify({
-- a: { name: A.persona.fighter_name, title: A.persona.title, class: A.persona.class, signature_move: A.persona.signature_move, special: A.persona.special, weakness: A.persona.weakness, lore: A.persona.lore, ratings: A.ratings },
-- b: { name: B.persona.fighter_name, title: B.persona.title, class: B.persona.class, signature_move: B.persona.signature_move, special: B.persona.special, weakness: B.persona.weakness, lore: B.persona.lore, ratings: B.ratings },
++ a: brief(A), b: brief(B),
rounds: rounds.map(r => ({ round: r.label, stat: r.stat, a_score: r.a, b_score: r.b, winner: r.winner === 'a' ? A.persona.fighter_name : B.persona.fighter_name, upset: r.upset })),
result_before_crowd_vote: { a_rounds: rounds.filter(r => r.winner === 'a').length, b_rounds: rounds.filter(r => r.winner === 'b').length },
}),
-- json: COMMENTARY_SCHEMA, maxTokens: 600, temperature: 0.95, timeoutMs: 25000,
++ json: COMMENTARY_SCHEMA, maxTokens: 700, temperature: 0.95, timeoutMs: 25000,
});
if (r && r.json && Array.isArray(r.json.rounds)) {
commentary = r.json;
@@@ -715,14 +963,29 @@ async function getBattle(aName, bName, { generate = true } = {}) {
}
if (!commentary) commentary = templateCommentary(A, B, rounds, outcome);
++ const strip = (X) => ({ ...X, quotes: undefined, walkout: X.quotes?.walkout || null, mic_style: X.quotes?.mic_style || null, voice_verdict: X.quotes?.voice_verdict || null });
return {
-- id: row.id, day, a: A, b: B, rounds, votes, outcome,
++ id: row.id, day, a: strip(A), b: strip(B), rounds, votes, outcome,
winner: winnerId ? (winnerId === ida ? A.user : B.user) : null,
commentary,
commentary_is_fallback: !!commentary._fallback,
++ history: headToHead(ida, idb),
++ stat_meta: STAT_META,
};
}
++/** Today's featured fight: a seeded pick of two fighters from the top of the ladder. */
++async function getMainEvent({ generate = true } = {}) {
++ const roster = loadRoster();
++ if (roster.order.length < 2) return null;
++ const pool = roster.order.slice(0, Math.min(8, roster.order.length));
++ const rand = seededRandom(`main-event:${today()}`);
++ const i = Math.floor(rand() * pool.length);
++ let j = Math.floor(rand() * (pool.length - 1)); if (j >= i) j++;
++ const battle = await getBattle(String(pool[i]), String(pool[j]), { generate });
++ return battle && !battle.error ? battle : null;
++}
++
function castVote(battleId, voterKey, side) {
if (!['a', 'b'].includes(side)) throw new Error('side must be a or b');
ensureTables();
@@@ -762,11 +1025,18 @@ function getLiveMatchups() {
let thumbs = null; try { thumbs = require('../media-proxy/live-thumbs'); } catch { /* */ }
const fighters = [...byUser.values()].map(s => {
const c = cardFor(s.user_id, roster, { includeRaw: false });
++ // What they said most recently on this live stream — the arena "hot mic".
++ let lastLine = null;
++ try {
++ const r = db.get(`SELECT text, start_sec FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND LENGTH(text) > 15 ORDER BY start_sec DESC LIMIT 1`, [s.id]);
++ if (r) lastLine = { text: r.text, start_sec: Math.floor(r.start_sec) };
++ } catch { /* */ }
return {
user: c.user, rank: c.rank, ratings: c.ratings, record: c.record, image_url: c.image_url,
persona: { fighter_name: c.persona.fighter_name, title: c.persona.title, class: c.persona.class, taunt: c.persona.taunt },
stream: { id: s.id, title: s.title, category: s.category, viewer_count: s.viewer_count || 0, slug: s.slug || null, managed_stream_id: s.managed_stream_id || null, started_at: s.started_at },
thumbnail_url: thumbs ? (thumbs.getCurrentLiveThumbnailUrl(s.id) || null) : null,
++ hot_mic: lastLine,
};
}).sort((x, y) => y.ratings.power - x.ratings.power);
// Pair neighbours on the power ladder so live fights are close ones.
@@@ -779,19 +1049,23 @@ function getLiveMatchups() {
function status() {
ensureTables();
const roster = loadRoster();
-- const counts = db.get(`SELECT SUM(persona_json IS NOT NULL) AS personas, SUM(image_path IS NOT NULL) AS images FROM arena_profiles`) || {};
++ const counts = db.get(`SELECT SUM(persona_json IS NOT NULL) AS personas, SUM(image_path IS NOT NULL) AS images, SUM(quotes_json IS NOT NULL) AS quotes FROM arena_profiles`) || {};
const battles = db.get('SELECT COUNT(*) AS n FROM arena_battles')?.n || 0;
const votes = db.get('SELECT COUNT(*) AS n FROM arena_votes')?.n || 0;
++ const withVoice = roster.order.filter(id => roster.byId[id].raw.voice.has_data).length;
return {
enabled: arenaEnabled(),
ai: aiOn(),
image_generation: imageGenAvailable(),
image_model: imageGenAvailable() ? String(setting('ai_image_model', 'gpt-image-1')) : null,
roster: roster.order.length,
++ with_voice_data: withVoice,
personas: counts.personas || 0,
++ quotes: counts.quotes || 0,
images: counts.images || 0,
battles, votes,
active_days: ACTIVE_DAYS,
++ rounds: ROUNDS.map(r => r.label),
};
}
@@@ -803,15 +1077,19 @@ module.exports = {
loadRoster,
listFighters,
getFighter,
++ getStatDetail,
generatePersona,
++ generateQuotes,
generateImage,
getBattle,
++ getMainEvent,
castVote,
voterKeyFor,
getLiveMatchups,
status,
STAT_KEYS,
STAT_META,
++ STAT_WEIGHTS,
ROUNDS,
ARENA_DIR,
// pure helpers (tests)
@@@ -820,4 +1098,6 @@ module.exports = {
_scoreBattle: scoreBattle,
_seededRandom: seededRandom,
_fallbackPersona: fallbackPersona,
++ _voiceStatsFor: voiceStatsFor,
++ _quoteCandidates: quoteCandidates,
};
ddiff --git a/server/arena/routes.js b/server/arena/routes.js
iindex 14d58fbf..e83a9227 100644
---- a/server/arena/routes.js
++++ b/server/arena/routes.js
@@@ -49,6 +49,39 @@ router.get('/live', (req, res) => {
}
});
++router.get('/main-event', optionalAuth, async (req, res) => {
++ try {
++ const battle = await arena.getMainEvent({ generate: req.query.generate !== '0' });
++ if (!battle) return res.status(404).json({ error: 'No main event yet — need at least two fighters' });
++ let yourVote = null;
++ try {
++ const row = require('../db/database').get('SELECT side FROM arena_votes WHERE battle_id = ? AND voter_key = ?', [battle.id, arena.voterKeyFor(req)]);
++ yourVote = row ? row.side : null;
++ } catch { /* */ }
++ res.set('Cache-Control', 'no-store');
++ res.json({ ...battle, your_vote: yourVote });
++ } catch (err) {
++ console.error('[Arena] main event:', err.message);
++ res.status(500).json({ error: 'Failed to load the main event' });
++ }
++});
++
++router.get('/fighters/:user/stat/:stat', (req, res) => {
++ try {
++ const card = arena.loadRoster();
++ 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 || !card.byId[user.id]) return res.status(404).json({ error: 'No such fighter on the roster' });
++ const detail = arena.getStatDetail(user.id, String(req.params.stat));
++ if (!detail) return res.status(404).json({ error: 'Unknown stat' });
++ res.set('Cache-Control', 'public, max-age=120');
++ res.json(detail);
++ } catch (err) {
++ console.error('[Arena] stat detail:', err.message);
++ res.status(500).json({ error: 'Failed to load stat detail' });
++ }
++});
++
router.get('/fighters/:user', async (req, res) => {
try {
const generate = req.query.generate !== '0';
ddiff --git a/test/arena.test.js b/test/arena.test.js
iindex f2e43bf7..4885981e 100644
---- a/test/arena.test.js
++++ b/test/arena.test.js
@@@ -1,7 +1,8 @@
'use strict';
--// Arena regression tests: ratings are fair percentiles, battles are deterministic per
--// day and order-independent, the crowd vote is the last round, and the roster/API works
++// Arena regression tests: ratings are fair percentiles (incl. MIC from the transcripts),
++// battles are deterministic per day and order-independent, the crowd vote is the last
++// round, quotes come from real transcript lines with VOD links, and the roster/API works
// end-to-end on a temp DB without any AI configured (personas fall back, no LLM calls).
const assert = require('assert');
@@@ -18,39 +19,45 @@ db.initDb();
const arena = require('../server/arena/arena-service');
// ── Pure helpers ──
++const base = { peak_viewers: 10, hours: 5, messages_per_hour: 20, loyalty_score: 30, clutch_per_hour: 0.2, avg_viewers: 4, voice: { voice_score: 5 } };
const ratings = arena._computeRatings({
-- 1: { peak_viewers: 100, hours: 50, messages_per_hour: 200, loyalty_score: 300, clutch_per_hour: 2, avg_viewers: 40 },
-- 2: { peak_viewers: 10, hours: 5, messages_per_hour: 20, loyalty_score: 30, clutch_per_hour: 0.2, avg_viewers: 4 },
-- 3: { peak_viewers: 50, hours: 25, messages_per_hour: 100, loyalty_score: 150, clutch_per_hour: 1, avg_viewers: 20 },
++ 1: { ...base, peak_viewers: 100, hours: 50, messages_per_hour: 200, loyalty_score: 300, clutch_per_hour: 2, avg_viewers: 40, voice: { voice_score: 60 } },
++ 2: { ...base },
++ 3: { ...base, peak_viewers: 50, hours: 25, messages_per_hour: 100, loyalty_score: 150, clutch_per_hour: 1, avg_viewers: 20, voice: { voice_score: 30 } },
});
assert.strictEqual(ratings[1].hype, 99, 'top of every metric is a 99');
assert.strictEqual(ratings[2].hype, 40, 'bottom is the floor, never 0');
++assert.strictEqual(ratings[1].mic, 99, 'MIC ranks the voice score');
++assert.strictEqual(ratings[2].mic, 40);
assert.ok(ratings[3].hype > 40 && ratings[3].hype < 99, 'middle sits in between');
assert.ok(ratings[1].power > ratings[3].power && ratings[3].power > ratings[2].power, 'power orders the roster');
--const solo = arena._computeRatings({ 7: { peak_viewers: 1, hours: 1, messages_per_hour: 1, loyalty_score: 1, clutch_per_hour: 0, avg_viewers: 1 } });
++assert.ok(Math.abs(Object.values(arena.STAT_WEIGHTS).reduce((a, b) => a + b, 0) - 1) < 1e-9, 'stat weights sum to 1');
++const solo = arena._computeRatings({ 7: { ...base } });
assert.strictEqual(solo[7].hype, 70, 'a roster of one is a flat 70 — no population to compare to');
--console.log('✅ ratings are percentile-based with a floor');
++console.log('✅ ratings are percentile-based with a floor (7 stats incl. MIC)');
--const A = { hype: 90, grind: 60, chat: 70, loyalty: 80, clutch: 50, vibe: 85, power: 80 };
--const B = { hype: 60, grind: 90, chat: 65, loyalty: 70, clutch: 95, vibe: 60, power: 70 };
++const A = { hype: 90, grind: 60, chat: 70, loyalty: 80, clutch: 50, vibe: 85, mic: 75, power: 80 };
++const B = { hype: 60, grind: 90, chat: 65, loyalty: 70, clutch: 95, vibe: 60, mic: 50, power: 70 };
const r1 = arena._simulateRounds(A, B, '1:2:2026-08-29');
const r2 = arena._simulateRounds(A, B, '1:2:2026-08-29');
const r3 = arena._simulateRounds(A, B, '1:2:2026-08-30');
assert.deepStrictEqual(r1, r2, 'same pair + same day → identical rounds');
assert.notDeepStrictEqual(r1.map(r => r.a), r3.map(r => r.a), 'a new day rolls new rounds');
--assert.strictEqual(r1.length, 4);
++assert.strictEqual(r1.length, 5, 'five rounds incl. Mic Drop');
++assert.strictEqual(r1[4].stat, 'mic');
for (const r of r1) assert.ok(['a', 'b'].includes(r.winner) && r.a >= 40 && r.b >= 40);
console.log('✅ battles are seeded per day');
--const roundsAll = [{ winner: 'a' }, { winner: 'a' }, { winner: 'b' }, { winner: 'a' }];
--assert.deepStrictEqual(arena._scoreBattle(roundsAll, { a: 0, b: 0 }, 80, 70), { a: 3, b: 1, crowd: null, winner: 'a', tiebreak: null });
--const even = [{ winner: 'a' }, { winner: 'a' }, { winner: 'b' }, { winner: 'b' }];
--assert.strictEqual(arena._scoreBattle(even, { a: 0, b: 0 }, 80, 70).tiebreak, 'power', '2–2 with no votes → Power tiebreak');
--assert.strictEqual(arena._scoreBattle(even, { a: 3, b: 1 }, 70, 80).winner, 'a', 'the crowd round breaks a 2–2');
--assert.strictEqual(arena._scoreBattle(even, { a: 1, b: 3 }, 80, 70).winner, 'b', 'even against the higher Power');
++const w = (s) => ({ winner: s });
++assert.deepStrictEqual(arena._scoreBattle([w('a'), w('a'), w('b'), w('a'), w('b')], { a: 0, b: 0 }, 80, 70), { a: 3, b: 2, crowd: null, winner: 'a', tiebreak: null });
++const even = [w('a'), w('a'), w('b'), w('b'), w('a')];
++assert.strictEqual(arena._scoreBattle(even, { a: 0, b: 5 }, 80, 70).tiebreak, 'power');
++assert.strictEqual(arena._scoreBattle(even, { a: 0, b: 5 }, 80, 70).winner, 'a', 'power tiebreak goes to the higher Power');
++assert.strictEqual(arena._scoreBattle([w('a'), w('a'), w('b'), w('b'), w('b')], { a: 9, b: 1 }, 70, 80).winner, 'b', 'crowd adds one round: 2+1 vs 3 → 3–3 → Power (b) wins');
++assert.strictEqual(arena._scoreBattle(even, { a: 0, b: 5 }, 80, 80).winner, null, '3–3 after the crowd round with equal Power stays a draw');
console.log('✅ crowd vote is the last round');
--// ── End-to-end on a temp DB (no AI configured → fallback personas, no LLM calls) ──
++// ── End-to-end on a temp DB (no AI configured → fallback personas/quotes, no LLM calls) ──
const mk = (username, key) => Number(db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username.toUpperCase(), stream_key: key }).lastInsertRowid);
const u1 = mk('alpha', 'a'.repeat(32)), u2 = mk('bravo', 'b'.repeat(32)), u3 = mk('charlie', 'c'.repeat(32)), idle = mk('idle', 'd'.repeat(32));
for (const uid of [u1, u2, u3, idle]) db.ensureChannel(uid);
@@@ -62,19 +69,43 @@ const stream = (uid, peak, hours, daysAgo) => {
VALUES (?, ?, ?, ?, ?, ?, 0, ?, 0)`, [id, peak / 2, peak, peak, peak * 20, peak * 60, Math.round(hours)]);
return id;
};
--stream(u1, 120, 6, 2); stream(u1, 80, 4, 5);
++const s1 = stream(u1, 120, 6, 2); stream(u1, 80, 4, 5);
stream(u2, 30, 10, 1);
stream(u3, 10, 1, 9);
stream(idle, 500, 50, 120); // too old → not on the roster
db.run('INSERT INTO follows (follower_id, streamer_id) VALUES (?, ?), (?, ?), (?, ?)', [u2, u1, u3, u1, u1, u2]);
++// Transcript for alpha: 40 lines over the 6 h stream, some hype, all linked to a VOD.
++db.run(`INSERT INTO vods (id, user_id, stream_id, title, file_path, is_public, created_at) VALUES (901, ?, ?, 'alpha vod', '/x/alpha.webm', 1, datetime('now', '-2 days'))`, [u1, s1]);
++const lines = [];
++for (let i = 0; i < 40; i++) {
++ const t = i % 4 === 0 ? `Let's go chat, that was insane, no way we just did that number ${i}!` : `Okay so here is the current situation with the setup number ${i}, we are rebuilding it live.`;
++ lines.push({ stream_id: s1, user_id: u1, vod_id: 901, kind: 'speech', start_sec: i * 60, end_sec: i * 60 + 20, text: t, label: null, confidence: 0.9 });
++}
++db.addTimelineEvents(lines);
++db.addTimelineEvents([{ stream_id: s1, user_id: u1, vod_id: 901, kind: 'sound', start_sec: 30, end_sec: 33, text: null, label: 'Laughter', confidence: 0.8 }, { stream_id: s1, user_id: u1, vod_id: 901, kind: 'sound', start_sec: 90, end_sec: 93, text: null, label: 'Rock music', confidence: 0.8 }]);
++
++const voice = arena._voiceStatsFor(u1, '-90 days');
++assert.strictEqual(voice.has_data, true);
++assert.strictEqual(voice.lines, 40);
++assert.ok(voice.talk_ratio_pct > 0 && voice.talk_ratio_pct <= 100, `talk ratio: ${voice.talk_ratio_pct}`);
++assert.ok(voice.hype_hits >= 10, `hype hits counted: ${voice.hype_hits}`);
++assert.strictEqual(voice.laughs, 1);
++assert.deepStrictEqual(voice.top_sounds.map(s => s.label).sort(), ['Laughter', 'Rock music']);
++assert.ok(voice.voice_score > 0);
++assert.strictEqual(arena._voiceStatsFor(u2, '-90 days').has_data, false, 'no transcript → no data');
++console.log('✅ MIC voice stats from the transcription timeline');
++
const fighters = arena.listFighters();
assert.deepStrictEqual(fighters.map(f => f.user.username), ['alpha', 'bravo', 'charlie'], `roster ordered by power, idle excluded: ${fighters.map(f => f.user.username)}`);
assert.strictEqual(fighters[0].rank, 1);
--assert.ok(fighters[0].ratings.power > fighters[2].ratings.power);
++assert.strictEqual(fighters[0].ratings.mic, 99, 'the only one with transcripts tops MIC');
++assert.strictEqual(fighters[1].ratings.mic, fighters[2].ratings.mic, 'no-transcript fighters tie on MIC (shared rank)');
++assert.ok(fighters[1].ratings.mic < fighters[0].ratings.mic);
assert.ok(fighters[0].persona.fighter_name, 'fallback persona has a name even with AI off');
assert.strictEqual(fighters[0].persona_is_fallback, true);
--console.log('✅ roster from real stream/analytics rows, inactive streamers excluded');
++assert.strictEqual(fighters[0].voice.has_data, true);
++console.log('✅ roster from real stream/analytics/transcript rows, inactive streamers excluded');
(async () => {
const card = await arena.getFighter('alpha');
@@@ -84,17 +115,36 @@ console.log('✅ roster from real stream/analytics rows, inactive streamers excl
assert.strictEqual(card.raw.followers, 2);
assert.strictEqual(card.image_url, null);
assert.strictEqual(card.image_generation, 'off', 'no AI → no image generation');
++ assert.ok(card.quotes && card.quotes.picks.length >= 3, 'heuristic quotes without AI');
++ assert.ok(card.quotes.picks[0].vod_id === 901 && typeof card.quotes.picks[0].start_sec === 'number', 'quotes link to the VOD second');
++ assert.ok(card.quotes.picks[0].text.includes("Let's go") || card.quotes.picks[0].text.includes('insane'), 'the spiciest line ranks first without AI');
++ assert.strictEqual(card.quotes._fallback, true);
++ assert.deepStrictEqual(card.recent_battles, []);
++ assert.strictEqual(card.rivalry, null);
const off = await arena.getFighter('idle');
assert.strictEqual(off.not_on_roster, true);
-- console.log('✅ fighter card');
++ console.log('✅ fighter card + transcript quotes');
++
++ const detail = arena.getStatDetail(u1, 'hype');
++ assert.strictEqual(detail.position, 1);
++ assert.strictEqual(detail.series.length, 2, 'one point per stream in the window');
++ assert.strictEqual(detail.series[1].value, 120, 'latest stream last');
++ assert.strictEqual(detail.top[0].user.username, 'alpha');
++ const micDetail = arena.getStatDetail(u1, 'mic');
++ assert.ok(micDetail.voice && micDetail.series.some(p => p.value > 0), 'mic series = % of stream talking');
++ assert.strictEqual(arena.getStatDetail(u1, 'nope'), null);
++ console.log('✅ stat drill-down');
const b1 = await arena.getBattle('alpha', 'bravo');
const b2 = await arena.getBattle('bravo', 'alpha');
assert.strictEqual(b1.id, b2.id, 'a-vs-b and b-vs-a are the same battle');
-- assert.strictEqual(b1.rounds.length, 4);
-- assert.ok(b1.commentary.intro && b1.commentary.rounds.length === 4, 'templated commentary without AI');
++ assert.strictEqual(b1.rounds.length, 5);
++ assert.ok(b1.commentary.intro && b1.commentary.rounds.length === 5, 'templated commentary without AI');
assert.strictEqual(b1.commentary_is_fallback, true);
assert.strictEqual(b1.votes.a + b1.votes.b, 0);
++ assert.ok(b1.a.walkout && b1.a.walkout.vod_id === 901, 'walkout line comes from the transcript');
++ assert.strictEqual(b1.a.quotes, undefined, 'full quote list is not shipped with the battle');
++ assert.deepStrictEqual(b1.history, { fights: 1, a_wins: b1.outcome.winner === 'a' ? 1 : 0, b_wins: b1.outcome.winner === 'b' ? 1 : 0 });
const v1 = arena.castVote(b1.id, 'user:99', 'b');
assert.deepStrictEqual(v1.votes, { a: 0, b: 1 });
@@@ -106,17 +156,26 @@ console.log('✅ roster from real stream/analytics rows, inactive streamers excl
assert.strictEqual(after.outcome.crowd, 'a');
assert.throws(() => arena.castVote(b1.id, 'user:1', 'c'), /side/);
assert.throws(() => arena.castVote(999999, 'user:1', 'a'), /not found/);
-- console.log('✅ battle + votes');
++ console.log('✅ battle + votes + head-to-head history');
-- const rec = arena.listFighters().find(f => f.user.username === (after.winner.username));
++ const rec = arena.listFighters().find(f => f.user.username === after.winner.username);
assert.strictEqual(rec.record.wins, 1, 'winner gets the W on the leaderboard');
++ const profile = await arena.getFighter('alpha', { generate: false });
++ assert.strictEqual(profile.recent_battles.length, 1);
++ assert.strictEqual(profile.recent_battles[0].opponent.username, 'bravo');
++ const me = await arena.getMainEvent({ generate: false });
++ assert.ok(me && me.a && me.b && me.a.user.id !== me.b.user.id, 'main event pairs two different top fighters');
++ const me2 = await arena.getMainEvent({ generate: false });
++ assert.strictEqual(me.id, me2.id, 'main event is the same all day');
const live = arena.getLiveMatchups();
assert.strictEqual(live.live_count, 0);
const st = arena.status();
assert.strictEqual(st.roster, 3);
-- assert.strictEqual(st.battles, 1);
++ assert.strictEqual(st.with_voice_data, 1);
++ assert.ok(st.battles >= 1);
assert.strictEqual(st.votes, 2);
-- console.log('✅ records, live matchups, status');
++ assert.strictEqual(st.rounds.length, 5);
++ console.log('✅ records, recent fights, main event, live matchups, status');
console.log('\n✅ All Arena tests passed');
process.exit(0);