openvibers/openvibe.live Commits
Arena: streamer-vs-streamer tab — ratings from analytics, AI personas + portraits, daily battles, crowd votes
Changes
@@@ -68,6 +68,7 @@ Every file below is also served on the site at `/docs/<name>` (rendered by `serv- [docs/architecture.md](docs/architecture.md) — System design, data flows, module map- [docs/broadcasting.md](docs/broadcasting.md) — Streaming protocols (WebRTC/RTMP/JSMPEG/WHIP)- [docs/whip.md](docs/whip.md) — WHIP ingest API reference (auth forms, CORS, browser-only publishing, error codes)++- [docs/arena.md](docs/arena.md) — Arena tab (streamer vs streamer): ratings, AI personas, gated portrait generation, battles, votes- [docs/chat-system.md](docs/chat-system.md) — Chat features and moderation- [docs/api-tokens.md](docs/api-tokens.md) — Bot/integration token system- [docs/vods-and-clips.md](docs/vods-and-clips.md) — VOD/clip pipeline (pre-split; storage/cutting now in OpenVibe.Media)@@@ -10,6 +10,7 @@- [Vibe Coding](vibe-coding.md) — Copilot companion publisher protocol, slot settings, and viewer feed- [VOD & Clips](vods-and-clips.md) — Recordings, DVR seeking, clip creation- [API Tokens](api-tokens.md) — Bot/integration authentication++- [Arena](arena.md) — Streamer-vs-streamer tab: ratings from analytics, AI personas/portraits, daily battles, crowd votes## Streamer Tools- [Dashboard](dashboard.md) — Streamer dashboard features: emotes, rewards, chat logs, media managementnnew file mode 100644@@@ -0,0 +1,54 @@++# Arena — streamer vs streamer++++The **Arena** tab (`/arena`) turns the analytics and AI data OpenVibe.Live already keeps about every streamer into a fighting-game roster: a ranked leaderboard, a character-select profile per streamer, live head-to-head matchups, and daily battles that the crowd finishes with a vote.++++Everything is opt-in on the AI side — with AI off the tab still works from stats alone (templated lore and commentary, avatar cards instead of portraits).++++## What each streamer gets++++| 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 |++| **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.++++Live streamers are paired by neighbouring Power on the leaderboard ("Live battles"), with their live thumbnails, refreshed every 30 s.++++## API++++| 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`) |++| 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" }` |++| GET | `/api/arena/live` | live matchups |++| GET | `/api/arena/status` | roster size, AI / image-generation state, counts |++++## Settings (site settings, admin → Settings)++++| Key | Default | Effect |++|---|---|---|++| `arena_enabled` | `true` | `false` hides the API (404) — the nav link should be removed too |++| `ai_image_enabled` | `false` | allow portrait generation (needs AI enabled + an OpenAI-shaped provider) |++| `ai_image_model` | `gpt-image-1` | image model; `dall-e-*` models are handled too |++| `ai_image_quality` | `low` | passed to `gpt-image-*` |++| `ai_image_cost_usd` | `0.011` | per-image cost recorded into `ai_usage` so the AI budget stays honest |++| `arena_vote_salt` | `JWT_SECRET` | salt for anonymous voter hashing |++++Portraits are written to `data/arena/` (override with `ARENA_IMAGE_PATH`) and served from `/data/arena/`.++++## 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.++- Portraits never use the streamer's face: only the persona and an identity-free scene description feed the image model.++- The background job (`server/arena/arena-job.js`) refreshes at most 6 personas and 2 portraits per 20-minute tick, top of the leaderboard first, and stops when the AI budget is exhausted.++- All AI text is rendered escaped; nothing from a model is inserted as HTML.++++Tests: `node test/arena.test.js` (ratings, seeded battles, votes, roster on a temp DB — no AI needed).@@@ -13831,3 +13831,173 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }.aiv-slot-grid label > span { font-weight:600; display:flex; justify-content:space-between; }.aiv-slot-grid label > span b { color:var(--accent); }.aiv-slot-grid input[type=range] { accent-color:var(--accent); }++++/* ═══════════════════════════════════════════════════════════════++ ARENA — streamer vs streamer (public/js/arena.js)++ ═══════════════════════════════════════════════════════════════ */++.arena-container { max-width: 1180px; }++.arena-loading { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 60px 0; color: var(--text-secondary); font-size: 1rem; }++.arena-loading i { font-size: 2rem; color: var(--accent); }++.arena-loading small { color: var(--text-muted); }++.arena-empty { text-align: center; padding: 60px 20px; color: var(--text-secondary); }++.arena-empty i { font-size: 2.4rem; color: var(--accent); margin-bottom: 12px; display: block; }++.arena-note { font-size: 0.8rem; color: var(--text-muted); }++.arena-back { margin: 8px 0 16px; display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }++.arena-back a { color: var(--text-secondary); text-decoration: none; }++.arena-back a:hover { color: var(--accent-light); }++++.arena-hero { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; flex-wrap: wrap; padding: 24px 0 16px; }++.arena-hero h1 { margin: 0 0 6px; font-size: 2rem; display: flex; align-items: center; gap: 10px; }++.arena-hero h1 i { color: var(--accent); }++.arena-hero-actions { display: flex; align-items: center; gap: 12px; }++++/* portraits */++.arena-portrait { position: relative; border-radius: 14px; overflow: hidden; background: radial-gradient(120% 120% at 30% 20%, color-mix(in srgb, var(--fc, var(--accent)) 45%, #000) 0%, #0a0a0e 70%); box-shadow: 0 0 0 2px color-mix(in srgb, var(--fc, var(--accent)) 60%, transparent), 0 10px 30px rgba(0, 0, 0, 0.45); flex-shrink: 0; }++.arena-portrait img { width: 100%; height: 100%; object-fit: cover; display: block; }++.arena-portrait-avatar img { filter: saturate(1.25) contrast(1.1); }++.arena-portrait-glow { position: absolute; inset: 0; background: linear-gradient(180deg, transparent 55%, color-mix(in srgb, var(--fc, var(--accent)) 55%, transparent)); pointer-events: none; }++.arena-portrait-initial { display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 900; text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6); }++.arena-portrait-xs { width: 40px; height: 40px; border-radius: 10px; font-size: 1.1rem; }++.arena-portrait-sm { width: 64px; height: 64px; font-size: 1.6rem; }++.arena-portrait-md { width: 100%; max-width: 260px; aspect-ratio: 1; margin: 0 auto; font-size: 4rem; }++.arena-portrait-lg { width: 100%; max-width: 320px; aspect-ratio: 1; font-size: 5rem; }++.arena-portrait-pending { margin-top: 8px; font-size: 0.8rem; color: var(--accent-light); text-align: center; }++++/* live matchups */++.arena-live { margin: 8px 0 28px; }++.arena-live h2 { display: flex; align-items: center; gap: 10px; font-size: 1.15rem; margin: 0 0 12px; }++.arena-live h2 small { color: var(--text-muted); font-weight: 400; font-size: 0.85rem; }++.arena-live-dot { width: 10px; height: 10px; border-radius: 50%; background: #e74c3c; box-shadow: 0 0 0 4px rgba(231, 76, 60, 0.25); animation: arena-pulse 1.6s infinite; }++@keyframes arena-pulse { 0%, 100% { box-shadow: 0 0 0 4px rgba(231, 76, 60, 0.25); } 50% { box-shadow: 0 0 0 8px rgba(231, 76, 60, 0.08); } }++.arena-live-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); gap: 14px; }++.arena-live-match { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 10px; padding: 12px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 14px; }++.arena-live-fighter { display: flex; align-items: center; gap: 10px; color: var(--text-primary); text-decoration: none; min-width: 0; }++.arena-live-b { flex-direction: row-reverse; text-align: right; }++.arena-live-thumb { width: 96px; height: 54px; object-fit: cover; border-radius: 8px; background: #000; flex-shrink: 0; }++.arena-live-meta { display: flex; flex-direction: column; min-width: 0; }++.arena-live-meta strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }++.arena-live-meta span { font-size: 0.78rem; color: var(--text-secondary); }++.arena-live-vs { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 54px; height: 54px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--accent-dark)); color: #fff; font-weight: 900; text-decoration: none; font-size: 0.95rem; line-height: 1; box-shadow: 0 6px 18px rgba(139, 92, 246, 0.35); }++.arena-live-vs small { font-size: 0.55rem; font-weight: 600; opacity: 0.85; text-transform: uppercase; }++.arena-live-vs:hover { transform: scale(1.06); }++.arena-live-vs-idle { background: var(--bg-tertiary); color: var(--text-muted); box-shadow: none; width: auto; height: auto; padding: 10px 12px; border-radius: 12px; }++.arena-live-vs-idle small { white-space: nowrap; }++.arena-live-pill { display: inline-block; font-size: 0.62rem; font-weight: 800; letter-spacing: 0.06em; background: #e74c3c; color: #fff; padding: 2px 6px; border-radius: 4px; vertical-align: middle; margin-left: 6px; }++++/* leaderboard */++.arena-board-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 12px; }++.arena-board-head h2 { margin: 0; font-size: 1.15rem; }++.arena-board-head input { padding: 8px 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text-primary); min-width: 220px; }++.arena-list { display: flex; flex-direction: column; gap: 8px; }++.arena-row { display: grid; grid-template-columns: 44px 64px 1fr auto; align-items: center; gap: 14px; padding: 10px 14px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 14px; color: var(--text-primary); text-decoration: none; transition: transform 0.12s, border-color 0.12s; }++.arena-row:hover { transform: translateY(-1px); border-color: var(--accent); }++.arena-row.is-live { border-color: rgba(231, 76, 60, 0.5); }++.arena-rank { font-weight: 900; font-size: 1.2rem; color: var(--text-secondary); text-align: center; }++.arena-rank-1 { color: #f5c542; } .arena-rank-2 { color: #cfd6dd; } .arena-rank-3 { color: #d09056; }++.arena-row-main { display: flex; flex-direction: column; min-width: 0; gap: 2px; }++.arena-row-main strong { font-size: 1.02rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }++.arena-row-sub { font-size: 0.8rem; color: var(--text-secondary); }++.arena-row-taunt { font-size: 0.8rem; color: var(--text-muted); font-style: italic; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }++.arena-row-stats { display: flex; align-items: center; gap: 16px; }++.arena-power { display: flex; flex-direction: column; align-items: center; line-height: 1; }++.arena-power b { font-size: 1.5rem; color: var(--accent-light); }++.arena-power small { font-size: 0.6rem; letter-spacing: 0.1em; color: var(--text-muted); }++.arena-power-lg b { font-size: 2.6rem; }++.arena-record { font-size: 0.85rem; color: var(--text-secondary); font-variant-numeric: tabular-nums; }++.arena-record-lg { font-size: 1rem; }++++/* profile */++.arena-profile { display: grid; grid-template-columns: 320px 1fr; gap: 28px; padding: 20px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 18px; background-image: radial-gradient(60% 40% at 0% 0%, color-mix(in srgb, var(--fc, var(--accent)) 18%, transparent), transparent); }++.arena-profile-portrait { display: flex; flex-direction: column; align-items: center; gap: 10px; }++.arena-profile-rank { font-weight: 800; font-size: 1.1rem; }++.arena-profile-rank small { color: var(--text-muted); font-weight: 400; }++.arena-profile-main { display: flex; flex-direction: column; gap: 18px; min-width: 0; }++.arena-profile-name h1 { margin: 0; font-size: 2rem; line-height: 1.15; }++.arena-title { margin: 4px 0 0; color: var(--accent-light); font-weight: 600; }++.arena-handle { margin: 4px 0 0; color: var(--text-secondary); font-size: 0.9rem; }++.arena-handle a { color: inherit; }++.arena-profile-power { display: flex; align-items: center; gap: 24px; }++.arena-profile-stats { display: grid; grid-template-columns: 200px 1fr; gap: 18px; align-items: center; }++.arena-radar { width: 100%; height: auto; max-width: 200px; }++.arena-radar-ring { fill: none; stroke: var(--border-light); stroke-width: 1; }++.arena-radar-fill { stroke-width: 2; }++.arena-radar text { fill: var(--text-secondary); font-size: 10px; font-weight: 600; }++.arena-quips { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px 14px; }++.arena-quip { display: flex; flex-direction: column; font-size: 0.85rem; }++.arena-quip b { color: var(--text-primary); }++.arena-quip span { color: var(--text-secondary); }++.arena-lore-text { font-size: 1rem; line-height: 1.55; margin: 0 0 12px; }++.arena-moves { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin-bottom: 12px; }++.arena-move { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px; border-radius: 10px; background: var(--bg-tertiary); border: 1px solid var(--border); font-size: 0.85rem; }++.arena-move-kind { font-size: 0.65rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent-light); }++.arena-move-weak .arena-move-kind { color: #e74c3c; }++.arena-flavor { display: flex; flex-wrap: wrap; gap: 8px 18px; font-size: 0.85rem; color: var(--text-secondary); }++.arena-flavor i { color: var(--accent); margin-right: 4px; }++.arena-numbers { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 8px; }++.arena-number { display: flex; flex-direction: column; padding: 8px 10px; border-radius: 10px; background: var(--bg-tertiary); }++.arena-number b { font-size: 1.1rem; }++.arena-number span { font-size: 0.72rem; color: var(--text-muted); }++.arena-challenge { margin-top: 24px; }++.arena-challenge h2 { font-size: 1.15rem; margin: 0 0 12px; }++.arena-opponents { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; }++.arena-opponent { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 12px; background: var(--bg-card); border: 1px solid var(--border); color: var(--text-primary); text-decoration: none; }++.arena-opponent:hover { border-color: var(--accent); }++.arena-opponent span { display: flex; flex-direction: column; min-width: 0; }++.arena-opponent strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }++.arena-opponent small { color: var(--text-muted); }++++/* battle */++.arena-battle { display: grid; grid-template-columns: 1fr 1.2fr 1fr; gap: 20px; align-items: start; }++.arena-side { padding: 18px; border-radius: 18px; background: var(--bg-card); border: 2px solid var(--border); text-align: center; transition: border-color 0.2s, box-shadow 0.2s, transform 0.1s; background-image: radial-gradient(70% 45% at 50% 0%, color-mix(in srgb, var(--fc, var(--accent)) 22%, transparent), transparent); }++.arena-side h2 { margin: 12px 0 0; font-size: 1.35rem; }++.arena-side .arena-power { margin: 10px auto; }++.arena-side.is-winner { border-color: var(--fc, var(--accent)); box-shadow: 0 0 0 4px color-mix(in srgb, var(--fc, var(--accent)) 25%, transparent), 0 14px 40px rgba(0, 0, 0, 0.4); }++.arena-side.is-hit { animation: arena-hit 0.5s; }++@keyframes arena-hit { 0% { transform: scale(1); } 30% { transform: scale(1.03) rotate(-0.6deg); } 100% { transform: scale(1); } }++.arena-side-taunt { font-style: italic; color: var(--text-secondary); font-size: 0.85rem; margin: 10px 0 0; }++.arena-bars { display: flex; flex-direction: column; gap: 5px; text-align: left; }++.arena-bar { display: grid; grid-template-columns: 58px 1fr 28px; align-items: center; gap: 8px; font-size: 0.75rem; }++.arena-bar-label { color: var(--text-secondary); }++.arena-bar-track { height: 7px; border-radius: 4px; background: var(--bg-tertiary); overflow: hidden; }++.arena-bar-fill { display: block; height: 100%; background: linear-gradient(90deg, var(--fc, var(--accent)), color-mix(in srgb, var(--fc, var(--accent)) 50%, #fff)); }++.arena-bar-val { text-align: right; font-variant-numeric: tabular-nums; }++.arena-center { display: flex; flex-direction: column; align-items: center; gap: 12px; }++.arena-vs { font-size: 3rem; font-weight: 900; letter-spacing: 0.05em; background: linear-gradient(135deg, var(--accent-light), #ec4899); -webkit-background-clip: text; background-clip: text; color: transparent; line-height: 1; }++.arena-scoreboard { font-size: 2.2rem; font-weight: 900; font-variant-numeric: tabular-nums; display: flex; gap: 12px; align-items: center; }++.arena-score-sep { color: var(--text-muted); }++.arena-commentary { width: 100%; min-height: 120px; max-height: 420px; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; padding: 12px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 14px; }++.arena-line { padding: 8px 10px; border-radius: 10px; background: var(--bg-tertiary); font-size: 0.9rem; line-height: 1.45; animation: arena-in 0.35s ease-out; }++@keyframes arena-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }++.arena-line i { color: var(--accent); margin-right: 6px; }++.arena-line-a { border-left: 3px solid var(--accent); }++.arena-line-b { border-left: 3px solid #ec4899; }++.arena-line-upset { background: rgba(245, 197, 66, 0.12); }++.arena-line-finisher { font-weight: 600; }++.arena-line-verdict { background: color-mix(in srgb, var(--accent) 18%, var(--bg-tertiary)); font-weight: 600; }++.arena-round-tag { display: inline-block; font-size: 0.65rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); margin-right: 6px; }++.arena-round-score { float: right; font-size: 0.75rem; color: var(--text-muted); font-variant-numeric: tabular-nums; }++.arena-vote { width: 100%; }++.arena-vote-head { text-align: center; font-weight: 700; margin-bottom: 8px; }++.arena-vote-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }++.arena-vote-btn { padding: 10px; border-radius: 10px; border: 2px solid var(--border); background: var(--bg-card); color: var(--text-primary); font-weight: 700; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }++.arena-vote-a:hover, .arena-vote-a.is-picked { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 18%, var(--bg-card)); }++.arena-vote-b:hover, .arena-vote-b.is-picked { border-color: #ec4899; background: color-mix(in srgb, #ec4899 18%, var(--bg-card)); }++.arena-vote-btn:disabled { opacity: 0.6; cursor: wait; }++.arena-vote-bar { height: 8px; border-radius: 4px; background: #ec4899; overflow: hidden; margin-top: 10px; }++.arena-vote-bar span { display: block; height: 100%; background: var(--accent); transition: width 0.4s; }++.arena-vote-tally { display: flex; justify-content: space-between; font-size: 0.75rem; color: var(--text-muted); margin-top: 4px; }++.arena-vote-result { text-align: center; font-size: 0.85rem; color: var(--text-secondary); margin-top: 8px; line-height: 1.5; }++.arena-battle-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }++.arena-loading-battle { padding: 90px 0; }++++@media (max-width: 900px) {++ .arena-profile { grid-template-columns: 1fr; }++ .arena-profile-portrait { flex-direction: row; align-items: flex-start; }++ .arena-portrait-lg { max-width: 160px; }++ .arena-profile-stats { grid-template-columns: 1fr; }++ .arena-battle { grid-template-columns: 1fr; }++ .arena-center { order: -1; }++ .arena-live-grid { grid-template-columns: 1fr; }++ .arena-row { grid-template-columns: 32px 48px 1fr auto; gap: 10px; }++ .arena-row-taunt { display: none; }++}@@@ -62,7 +62,7 @@</script><!-- Stylesheets -->-- <link rel="stylesheet" href="/css/style.css?v=140">++ <link rel="stylesheet" href="/css/style.css?v=141"><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,6 +131,9 @@<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>@@@ -3503,6 +3506,17 @@ WantedBy=multi-user.target</code></section><!-- Documentation Page -->++ <!-- ═══════════════════════════════════════════════════════════ -->++ <!-- ARENA — streamer vs streamer (public/js/arena.js) -->++ <!-- ═══════════════════════════════════════════════════════════ -->++ <section id="page-arena" class="page">++ <div class="content-container arena-container">++ <div id="arena-root">++ <div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>++ </div>++ </div>++ </section>++<section id="page-documentation" class="page"><div class="content-container" style="max-width:960px">@@@ -5093,6 +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/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>@@@ -58,7 +58,7 @@ function isStaffUser(user = currentUser) {}// Reserved paths (not usernames)--const RESERVED = new Set(['vods', 'clips', 'vod', 'clip', 'dashboard', 'settings', 'broadcast', 'admin', 'themes', 'game', 'canvas', 'chat', 'api', 'ws', 'media', 'pastes', 'p', 'updates', 'dmca', 'tos', 'terms']);++const RESERVED = new Set(['vods', 'clips', 'vod', 'clip', 'dashboard', 'settings', 'broadcast', 'admin', 'themes', 'game', 'canvas', 'chat', 'api', 'ws', 'media', 'pastes', 'p', 'updates', 'dmca', 'tos', 'terms', 'arena']);const CHANNEL_USERNAME_RE = /^[a-zA-Z0-9_]{3,24}$/;function normalizeChannelUsername(username) {@@@ -985,6 +985,9 @@ function routeFromURL() {});}).catch(() => {});}++ } else if (segments[0] === 'arena') {++ showPage('arena');++ if (typeof loadArenaPage === 'function') loadArenaPage(segments);} else if (segments[0] === 'updates') {showPage('updates');loadUpdatesPage();@@@ -1045,7 +1048,7 @@ function showPage(page) {}// Highlight nav link-- const pageToNav = { home: 'home', vods: 'vods', clips: 'clips', broadcast: 'broadcast', dashboard: 'dashboard', admin: 'admin', chat: 'chat', game: 'game', canvas: 'game', pastes: 'pastes', 'paste-viewer': 'pastes' };++ const pageToNav = { home: 'home', vods: 'vods', clips: 'clips', broadcast: 'broadcast', dashboard: 'dashboard', admin: 'admin', chat: 'chat', game: 'game', canvas: 'game', pastes: 'pastes', 'paste-viewer': 'pastes', arena: 'arena' };const navPage = pageToNav[page];if (navPage) {const link = document.querySelector(`.nav-link[data-page="${navPage}"]`);nnew file mode 100644@@@ -0,0 +1,386 @@++/**++ * 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++ *++ * 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.++ */++'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' };++let _arenaRoster = null; // cached leaderboard payload++let _arenaLiveTimer = null;++let _arenaImagePoll = null;++let _arenaBattleTimers = [];++++function _aEsc(s) { return typeof esc === 'function' ? esc(String(s ?? '')) : String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }++function _aNum(n) { n = Number(n) || 0; return n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n); }++function _aInitial(u) { return (u.display_name || u.username || '?').trim().charAt(0).toUpperCase(); }++++function _aStopTimers() {++ if (_arenaLiveTimer) { clearInterval(_arenaLiveTimer); _arenaLiveTimer = null; }++ if (_arenaImagePoll) { clearInterval(_arenaImagePoll); _arenaImagePoll = null; }++ for (const t of _arenaBattleTimers) clearTimeout(t);++ _arenaBattleTimers = [];++}++++/** Portrait: AI image when present, otherwise the avatar (or an initial) styled as a card. */++function _aPortrait(f, size = 'md') {++ const u = f.user;++ const color = u.profile_color || '#8b5cf6';++ if (f.image_url) {++ return `<div class="arena-portrait arena-portrait-${size}" style="--fc:${_aEsc(color)}"><img src="${_aEsc(f.image_url)}" alt="" loading="lazy"></div>`;++ }++ if (u.avatar_url) {++ return `<div class="arena-portrait arena-portrait-${size} arena-portrait-avatar" style="--fc:${_aEsc(color)}"><img src="${_aEsc(u.avatar_url)}" alt="" loading="lazy"><span class="arena-portrait-glow"></span></div>`;++ }++ return `<div class="arena-portrait arena-portrait-${size} arena-portrait-initial" style="--fc:${_aEsc(color)}"><span>${_aEsc(_aInitial(u))}</span></div>`;++}++++function _aRatingBars(r) {++ return `<div class="arena-bars">${ARENA_STATS.map(k => `++ <div class="arena-bar" 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>++ </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++ 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 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>`;++ }).join('');++ return `<svg class="arena-radar" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" aria-hidden="true">++ ${[33, 66, 99].map(v => `<polygon points="${ring(v)}" class="arena-radar-ring"></polygon>`).join('')}++ <polygon points="${poly}" class="arena-radar-fill" style="fill:${_aEsc(color)}33;stroke:${_aEsc(color)}"></polygon>++ ${labels}++ </svg>`;++}++++function _aChannelLink(u) { return `/${encodeURIComponent(u.username)}`; }++++// ── Page entry ───────────────────────────────────────────────++++async function loadArenaPage(segments = []) {++ _aStopTimers();++ const root = document.getElementById('arena-root');++ if (!root) return;++ const [, first, second, third] = segments;++ try {++ if (first === 'battle' && second && third) return await _aRenderBattle(root, second, third);++ if (first && first !== 'battle') return await _aRenderFighter(root, first);++ return await _aRenderHome(root);++ } catch (err) {++ root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-plug-circle-xmark"></i><p>${_aEsc(err?.message || 'The arena lights went out.')}</p></div>`;++ }++}++++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 ────────────────────────++++async function _aRenderHome(root) {++ root.innerHTML = _aSpinner('Loading the roster…');++ const [data, live] = await Promise.all([api('/arena/fighters'), api('/arena/live').catch(() => null)]);++ _arenaRoster = data;++ const fighters = data.fighters || [];++ if (!fighters.length) {++ root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-hand-fist"></i><p>No fighters yet — the roster fills with everyone who has streamed in the last 45 days.</p></div>`;++ return;++ }++ root.innerHTML = `++ <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>++ </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-live" class="arena-live"></section>++ <section class="arena-board">++ <div class="arena-board-head">++ <h2>Leaderboard</h2>++ <input id="arena-search" type="search" placeholder="Find a fighter…" autocomplete="off">++ </div>++ <div class="arena-list" id="arena-list"></div>++ </section>`;++ _aRenderLive(live);++ _aRenderList(fighters);++ 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);++ });++ 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)}`);++ });++ _arenaLiveTimer = setInterval(async () => {++ if (currentPage !== 'arena' || !document.getElementById('arena-live')) return _aStopTimers();++ try { _aRenderLive(await api('/arena/live')); } catch { /* keep last */ }++ }, 30000);++}++++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))}')">++ ${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>++ </div>++ </a>`;++ el.innerHTML = `++ <h2><span class="arena-live-dot"></span> Live battles <small>${live.live_count} fighter${live.live_count === 1 ? '' : 's'} live now</small></h2>++ <div class="arena-live-grid">++ ${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>++ ${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>` : ''}++ </div>`;++}++++function _aRenderList(fighters) {++ const el = document.getElementById('arena-list');++ 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))}')">++ <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>++ <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('');++}++++// ── Fighter profile ──────────────────────────────────────────++++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>`;++ return;++ }++ const p = f.persona, color = f.user.profile_color || '#8b5cf6';++ 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-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>++ </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>++ </div>++ <div class="arena-profile-power">++ <div class="arena-power arena-power-lg"><b>${f.ratings.power}</b><small>POWER</small></div>++ <div class="arena-record arena-record-lg">${f.record.wins}W – ${f.record.losses}L</div>++ </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>++ <div class="arena-lore">++ <p class="arena-lore-text">${_aEsc(p.lore)}</p>++ <div class="arena-moves">++ <div class="arena-move"><span class="arena-move-kind">Signature</span><b>${_aEsc(p.signature_move?.name)}</b><span>${_aEsc(p.signature_move?.description)}</span></div>++ <div class="arena-move"><span class="arena-move-kind">Special</span><b>${_aEsc(p.special?.name)}</b><span>${_aEsc(p.special?.description)}</span></div>++ <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-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>++ <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>++ <section class="arena-challenge">++ <h2>Pick an opponent</h2>++ <div class="arena-opponents" id="arena-opponents">${_aSpinner('Scouting…')}</div>++ </section>`;++ // 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>';++ } 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;++ _arenaImagePoll = setInterval(async () => {++ if (++tries > 20 || currentPage !== 'arena') return _aStopTimers();++ try {++ 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>`;++ clearInterval(_arenaImagePoll); _arenaImagePoll = null;++ }++ } catch { /* */ }++ }, 6000);++ }++}++++// ── Battle ───────────────────────────────────────────────────++++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 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>++ <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>++ </div>`;++ 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-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-commentary" id="arena-commentary"></div>++ <div class="arena-vote" id="arena-vote"></div>++ <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>++ <button class="btn btn-ghost" id="arena-rematch"><i class="fa-solid fa-dice"></i> Random rematch</button>++ </div>++ </div>++ ${side(B, 'b')}++ </div>`;++ _aPlayBattle(battle);++ document.getElementById('arena-share').addEventListener('click', async () => {++ const url = `${location.origin}/arena/battle/${encodeURIComponent(A.user.username)}/${encodeURIComponent(B.user.username)}`;++ 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-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)}`);++ });++}++++/** Reveal rounds one by one, then the finisher, verdict and the crowd vote. */++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');++ 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 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');++ battle.rounds.forEach((r, i) => {++ t += 1400;++ at(t, () => {++ 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' : ''}`);++ document.querySelector(`.arena-side-${r.winner}`)?.classList.add('is-hit');++ setTimeout(() => document.querySelector(`.arena-side-${r.winner}`)?.classList.remove('is-hit'), 600);++ });++ });++ t += 1400;++ 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');++ _aRenderVote(battle);++ });++}++++function _aRenderVote(battle) {++ const el = document.getElementById('arena-vote');++ if (!el) return;++ const A = battle.a, B = battle.b;++ const render = (votes, outcome, yourVote) => {++ const total = votes.a + votes.b;++ const pa = total ? Math.round((votes.a / total) * 100) : 50;++ const crowdLine = outcome.crowd ? `Crowd round → <b>${_aEsc((outcome.crowd === 'a' ? A : B).persona.fighter_name)}</b>` : 'Crowd round is still open — no votes yet';++ const finalLine = outcome.winner++ ? `<b>${_aEsc((outcome.winner === 'a' ? A : B).persona.fighter_name)}</b> wins ${outcome.a}–${outcome.b}${outcome.tiebreak ? ' (tiebreak: Power)' : ''}`++ : 'Dead even — your vote decides it';++ el.innerHTML = `++ <div class="arena-vote-head">Who are you backing?</div>++ <div class="arena-vote-buttons">++ <button class="arena-vote-btn arena-vote-a ${yourVote === 'a' ? 'is-picked' : ''}" data-side="a">${_aEsc(A.persona.fighter_name)}</button>++ <button class="arena-vote-btn arena-vote-b ${yourVote === 'b' ? 'is-picked' : ''}" data-side="b">${_aEsc(B.persona.fighter_name)}</button>++ </div>++ <div class="arena-vote-bar"><span style="width:${pa}%"></span></div>++ <div class="arena-vote-tally"><span>${votes.a} vote${votes.a === 1 ? '' : 's'}</span><span>${votes.b} vote${votes.b === 1 ? '' : 's'}</span></div>++ <div class="arena-vote-result">${crowdLine}<br>${finalLine}</div>`;++ document.getElementById('arena-score-a').textContent = outcome.a;++ document.getElementById('arena-score-b').textContent = outcome.b;++ document.querySelectorAll('.arena-side').forEach(s => s.classList.remove('is-winner'));++ if (outcome.winner) document.querySelector(`.arena-side-${outcome.winner}`)?.classList.add('is-winner');++ el.querySelectorAll('.arena-vote-btn').forEach(btn => btn.addEventListener('click', async () => {++ btn.disabled = true;++ try {++ const r = await api(`/arena/battle/${encodeURIComponent(A.user.username)}/${encodeURIComponent(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(battle.votes, battle.outcome, battle.your_vote || null);++}++++window.loadArenaPage = loadArenaPage;nnew file mode 100644@@@ -0,0 +1,57 @@++/**++ * arena-job.js — keeps Arena personas (and, when enabled, portraits) warm for the roster++ * so the tab loads instantly instead of generating on first view.++ *++ * Every 20 min: refresh up to BATCH stale personas (24 h TTL) for the highest-ranked++ * fighters first, and up to IMAGE_BATCH stale portraits (7 d TTL). Bounded per tick so a++ * big roster cannot burn the AI budget in one go; no-ops entirely while AI is off or over++ * budget (the shared gate in server/ai/llm.js).++ */++'use strict';++++const arena = require('./arena-service');++++const INTERVAL_MS = 20 * 60 * 1000;++const BATCH = 6;++const IMAGE_BATCH = 2;++let _timer = null;++let _busy = false;++++async function tick() {++ if (_busy || !arena.arenaEnabled() || !arena.aiOn()) return;++ _busy = true;++ try {++ const db = require('../db/database');++ const roster = arena.loadRoster();++ let personas = 0, images = 0;++ for (const userId of roster.order) {++ if (personas >= BATCH && (images >= IMAGE_BATCH || !arena.imageGenAvailable())) break;++ const row = db.get('SELECT persona_generated_at, image_generated_at, image_path FROM arena_profiles WHERE user_id = ?', [userId]) || {};++ const personaStale = !row.persona_generated_at || Date.now() - Date.parse(row.persona_generated_at + 'Z') > 24 * 60 * 60 * 1000;++ if (personaStale && personas < BATCH) {++ try { await arena.generatePersona(userId); personas++; } catch (e) { console.warn('[Arena] job persona:', e.message); }++ }++ const imageStale = !row.image_path || !row.image_generated_at || Date.now() - Date.parse(row.image_generated_at + 'Z') > 7 * 24 * 60 * 60 * 1000;++ if (imageStale && images < IMAGE_BATCH && arena.imageGenAvailable()) {++ try { await arena.generateImage(userId); images++; } catch (e) { console.warn('[Arena] job image:', e.message); }++ }++ if (!arena.aiOn()) break; // budget ran out mid-tick++ }++ if (personas || images) console.log(`[Arena] job: ${personas} persona(s), ${images} portrait(s) refreshed`);++ } finally {++ _busy = false;++ }++}++++function start() {++ if (_timer) return;++ arena.ensureTables();++ _timer = setInterval(() => tick().catch(e => console.warn('[Arena] job:', e.message)), INTERVAL_MS);++ if (_timer.unref) _timer.unref();++ setTimeout(() => tick().catch(() => {}), 90_000).unref?.();++ console.log('[Arena] job started (every 20 min)');++}++++function stop() { if (_timer) { clearInterval(_timer); _timer = null; } }++++module.exports = { start, stop, tick };nnew file mode 100644@@@ -0,0 +1,823 @@++/**++ * OpenVibe.Live — Arena (streamer vs streamer)++ *++ * 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.++ * 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.++ * 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++ * identity), and draws a stylised fighting-game character. Gated by the++ * `ai_image_enabled` setting, the shared AI budget, and a 7-day cache.++ * battles → a seeded simulation per matchup per day (same pair, same day → same++ * rounds) with AI hype-caster commentary, plus a crowd vote that counts++ * as the final round.++ *++ * Nothing here touches the AI unless AI is enabled and within budget (server/ai/llm.js);++ * without it the tab still works with stats, templated commentary and avatar cards.++ */++'use strict';++++const fs = require('fs');++const path = require('path');++const crypto = require('crypto');++const db = require('../db/database');++const llm = require('../ai/llm');++++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 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 STAT_META = {++ hype: { label: 'Hype', desc: 'peak concurrent viewers' },++ grind: { label: 'Grind', desc: 'hours live' },++ chat: { label: 'Chat', desc: 'chat messages per hour live' },++ loyalty: { label: 'Loyalty', desc: 'followers + returning chatters' },++ clutch: { label: 'Clutch', desc: 'clips + tips per hour' },++ vibe: { label: 'Vibe', desc: 'average viewers' },++};++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' },++];++++// ── Tables ───────────────────────────────────────────────────++++let _tablesReady = false;++function ensureTables() {++ if (_tablesReady) return;++ db.run(`CREATE TABLE IF NOT EXISTS arena_profiles (++ user_id INTEGER PRIMARY KEY,++ stats_json TEXT,++ persona_json TEXT,++ persona_model TEXT,++ persona_generated_at DATETIME,++ image_path TEXT,++ image_prompt TEXT,++ image_model TEXT,++ image_generated_at DATETIME,++ image_error TEXT,++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP++ )`);++ db.run(`CREATE TABLE IF NOT EXISTS arena_battles (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ a_user_id INTEGER NOT NULL,++ b_user_id INTEGER NOT NULL,++ day TEXT NOT NULL,++ result_json TEXT NOT NULL,++ commentary_json TEXT,++ winner_user_id INTEGER,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ UNIQUE(a_user_id, b_user_id, day)++ )`);++ db.run(`CREATE TABLE IF NOT EXISTS arena_votes (++ battle_id INTEGER NOT NULL,++ voter_key TEXT NOT NULL,++ side TEXT NOT NULL CHECK (side IN ('a', 'b')),++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ UNIQUE(battle_id, voter_key)++ )`);++ db.run('CREATE INDEX IF NOT EXISTS idx_arena_battles_users ON arena_battles (a_user_id, b_user_id)');++ try { fs.mkdirSync(ARENA_DIR, { recursive: true }); } catch { /* */ }++ _tablesReady = true;++}++++// ── Settings ─────────────────────────────────────────────────++++function setting(key, fallback = '') {++ try { const v = db.getSetting(key); return v === undefined || v === null || v === '' ? fallback : v; } catch { return fallback; }++}++function boolSetting(key, fallback = false) {++ const v = setting(key, null);++ if (v === null) return fallback;++ return v === true || v === 'true' || v === 1 || v === '1';++}++function arenaEnabled() { return boolSetting('arena_enabled', true); }++function aiOn() { try { return llm.isEnabled() && llm.withinBudget(); } catch { return false; } }++function imageGenAvailable() {++ if (!aiOn() || !boolSetting('ai_image_enabled', false)) return false;++ try { return llm.resolveProvider('vision').kind === 'openai'; } catch { return false; }++}++++// ── Raw stats ────────────────────────────────────────────────++++function activeStreamerIds() {++ return db.all(`++ SELECT DISTINCT s.user_id++ FROM streams s JOIN users u ON u.id = s.user_id++ WHERE s.duration_seconds > 0++ AND s.started_at >= datetime('now', ?)++ AND COALESCE(u.is_banned, 0) = 0++ `, [`-${ACTIVE_DAYS} days`]).map(r => r.user_id);++}++++function rawStatsFor(userId) {++ const win = `-${STATS_WINDOW_DAYS} days`;++ const agg = db.get(`++ SELECT COUNT(*) AS streams,++ COALESCE(SUM(s.duration_seconds), 0) / 3600.0 AS hours,++ COALESCE(MAX(s.peak_viewers), 0) AS peak_viewers,++ COALESCE(AVG(sa.avg_viewers), 0) AS avg_viewers,++ COALESCE(SUM(sa.total_messages), 0) AS messages,++ COALESCE(SUM(sa.unique_chatters), 0) AS unique_chatters,++ COALESCE(SUM(sa.total_watch_minutes), 0) AS watch_minutes,++ COALESCE(SUM(sa.clips_created), 0) AS clips,++ MAX(s.ended_at) AS last_live_at++ 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', ?)++ `, [userId, win]) || {};++ const allTime = db.get(`++ SELECT COUNT(*) AS streams, COALESCE(SUM(duration_seconds), 0) / 3600.0 AS hours, COALESCE(MAX(peak_viewers), 0) AS peak_viewers++ FROM streams WHERE user_id = ? AND duration_seconds > 0++ `, [userId]) || {};++ const followers = db.get('SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ?', [userId])?.n || 0;++ const tips = db.get(`SELECT COALESCE(SUM(amount), 0) AS n FROM transactions WHERE to_user_id = ? AND type = 'donation' AND created_at >= datetime('now', ?)`, [userId, win])?.n || 0;++ const category = db.get(`++ SELECT category, COUNT(*) AS n FROM streams++ WHERE user_id = ? AND duration_seconds > 0 AND category IS NOT NULL AND category != ''++ GROUP BY category ORDER BY n DESC LIMIT 1++ `, [userId])?.category || null;++ const hours = Math.max(Number(agg.hours) || 0, 0.1);++ return {++ window_days: STATS_WINDOW_DAYS,++ streams: agg.streams || 0,++ hours: Number((agg.hours || 0).toFixed(1)),++ peak_viewers: agg.peak_viewers || 0,++ avg_viewers: Number((agg.avg_viewers || 0).toFixed(1)),++ messages: agg.messages || 0,++ messages_per_hour: Number((agg.messages / hours).toFixed(1)),++ unique_chatters: agg.unique_chatters || 0,++ 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,++ followers,++ loyalty_score: followers + (agg.unique_chatters || 0) / 4,++ all_time_hours: Number((allTime.hours || 0).toFixed(1)),++ all_time_peak: allTime.peak_viewers || 0,++ all_time_streams: allTime.streams || 0,++ last_live_at: agg.last_live_at || null,++ category,++ };++}++++const METRIC_FOR_STAT = {++ hype: (r) => r.peak_viewers,++ grind: (r) => r.hours,++ chat: (r) => r.messages_per_hour,++ loyalty: (r) => r.loyalty_score,++ clutch: (r) => r.clutch_per_hour,++ vibe: (r) => r.avg_viewers,++};++++/**++ * Percentile ratings across a roster: rating = 40 + 59 × percentile. Ties share a rank,++ * a roster of one is a flat 70 across the board (no population to compare against).++ */++function computeRatings(rosterRaw) {++ const ids = Object.keys(rosterRaw);++ const out = {};++ for (const id of ids) out[id] = {};++ for (const stat of STAT_KEYS) {++ const values = ids.map(id => METRIC_FOR_STAT[stat](rosterRaw[id]) || 0);++ const sorted = [...values].sort((a, b) => a - b);++ ids.forEach((id, i) => {++ const v = values[i];++ let pct;++ if (ids.length < 2) pct = 0.5;++ else {++ const below = sorted.filter(x => x < v).length;++ const equal = sorted.filter(x => x === v).length;++ pct = (below + (equal - 1) / 2) / (ids.length - 1);++ }++ 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));++ }++ return out;++}++++// ── Roster cache (stats are cheap but not free — recompute every few minutes) ──++++let _roster = null; // { at, byId: { [userId]: { user, raw, ratings } }, order: [userId…] }++const ROSTER_TTL_MS = 3 * 60 * 1000;++++function loadRoster(force = false) {++ if (!force && _roster && Date.now() - _roster.at < ROSTER_TTL_MS) return _roster;++ ensureTables();++ const ids = activeStreamerIds();++ const rawById = {};++ for (const id of ids) rawById[id] = rawStatsFor(id);++ const ratings = computeRatings(rawById);++ const byId = {};++ for (const id of ids) {++ const user = db.getUserById(id);++ if (!user) continue;++ 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);++ _roster = { at: Date.now(), byId, order };++ return _roster;++}++++function publicUser(u) {++ return {++ id: u.id,++ username: u.username,++ display_name: u.display_name || u.username,++ avatar_url: u.avatar_url || null,++ profile_color: u.profile_color || null,++ bio: u.bio || '',++ };++}++++// ── Records (W/L from stored battles) ────────────────────────++++function recordFor(userId) {++ const r = db.get(`++ SELECT++ 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 = ?++ `, [userId, userId, userId, userId]) || {};++ return { wins: r.wins || 0, losses: r.losses || 0 };++}++++// ── Persona (AI) ─────────────────────────────────────────────++++function profileRow(userId) {++ ensureTables();++ return db.get('SELECT * FROM arena_profiles WHERE user_id = ?', [userId]) || null;++}++++function parseJson(text, fallback = null) {++ if (!text) return fallback;++ 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;++}++++/** Everything the site knows about a streamer that a writer could riff on. */++function gatherContext(userId) {++ const ctx = {};++ try { ctx.overview = db.getStreamerOverview(userId)?.overview || null; } catch { /* */ }++ try {++ const mems = db.all('SELECT description, tags FROM stream_memories WHERE user_id = ? ORDER BY captured_at DESC LIMIT 4', [userId]);++ ctx.memories = mems.map(m => m.description).filter(Boolean);++ } catch { ctx.memories = []; }++ try {++ const chatAi = db.getChatAiSummary('user', userId, 'rolling');++ 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++ 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 }));++ } catch {++ 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 = []; }++ return ctx;++}++++const PERSONA_SCHEMA = {++ name: 'arena_persona',++ schema: {++ type: 'object',++ additionalProperties: false,++ required: ['fighter_name', 'title', 'class', 'element', 'signature_move', 'special', 'weakness', 'taunt', 'lore', 'catchphrase', 'entrance_music', 'stat_quips'],++ properties: {++ fighter_name: { type: 'string', description: 'Arena ring name, 2–5 words, based on the streamer' },++ title: { type: 'string', description: 'Epithet like "The Midnight Menace of Cozy Corner"' },++ class: { type: 'string', description: 'Fighting-game archetype, e.g. Grappler, Zoner, Rushdown, Summoner, Bard, Tank' },++ element: { type: 'string', description: 'Single word element/vibe, e.g. Caffeine, Static, Lo-fi, Chaos' },++ signature_move: { type: 'object', additionalProperties: false, required: ['name', 'description'], properties: { name: { type: 'string' }, description: { type: 'string' } } },++ special: { type: 'object', additionalProperties: false, required: ['name', 'description'], properties: { name: { type: 'string' }, description: { type: 'string' } } },++ weakness: { type: 'string' },++ taunt: { type: 'string', description: 'One-line trash talk they would say to an opponent' },++ lore: { type: 'string', description: '2–3 sentence character-select bio' },++ catchphrase: { type: 'string' },++ entrance_music: { type: 'string', description: 'Invented track name + fake artist' },++ stat_quips: {++ type: 'object', additionalProperties: false, required: STAT_KEYS,++ properties: Object.fromEntries(STAT_KEYS.map(k => [k, { type: 'string', description: `≤ 8 words explaining their ${k} rating in character` }])),++ },++ },++ },++};++++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.`;++++async function generatePersona(userId, { force = false } = {}) {++ ensureTables();++ const row = profileRow(userId);++ if (!force && personaIsFresh(row)) return parseJson(row.persona_json);++ if (!aiOn()) return row ? parseJson(row.persona_json) : null;++++ const roster = loadRoster();++ const entry = roster.byId[userId] || { user: publicUser(db.getUserById(userId) || { id: userId, username: `user${userId}` }), raw: rawStatsFor(userId), ratings: null };++ const ctx = gatherContext(userId);++ const stats = entry.ratings || Object.fromEntries(STAT_KEYS.map(k => [k, 70]).concat([['power', 70]]));++ const facts = {++ name: entry.user.display_name,++ handle: entry.user.username,++ category: entry.raw.category,++ ratings: stats,++ numbers: {++ 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,++ },++ 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,++ };++ const r = await llm.complete({++ 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,++ });++ const persona = r && r.json && r.json.fighter_name ? r.json : null;++ if (!persona) {++ console.warn(`[Arena] persona generation failed for user ${userId}`);++ return row ? parseJson(row.persona_json) : null;++ }++ db.run(`INSERT INTO arena_profiles (user_id, persona_json, persona_model, persona_generated_at, updated_at)++ VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)++ ON CONFLICT(user_id) DO UPDATE SET persona_json = excluded.persona_json, persona_model = excluded.persona_model,++ persona_generated_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP`,++ [userId, JSON.stringify(persona), r.model || null]);++ console.log(`[Arena] persona for ${entry.user.username}: "${persona.fighter_name}" (${persona.class})`);++ return persona;++}++++/** Non-AI stand-in so the tab is never empty. */++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];++ return {++ fighter_name: entry.user.display_name,++ title: `The ${STAT_META[best].label} Specialist`,++ class: cls,++ element: entry.raw.category ? entry.raw.category.replace(/[-_]/g, ' ') : 'Static',++ signature_move: { name: `${STAT_META[best].label} Surge`, description: `Turns ${STAT_META[best].desc} into raw damage.` },++ special: { name: 'Go Live', description: 'Hits the button. The arena fills up.' },++ weakness: 'Sleep schedules.',++ taunt: 'Chat, are you seeing this?',++ lore: `${entry.user.display_name} shows up, streams, and leaves the leaderboard slightly different than they found it.`,++ catchphrase: 'Let him cook.',++ entrance_music: 'Untitled Loop (feat. Notification Sound)',++ stat_quips: Object.fromEntries(STAT_KEYS.map(k => [k, STAT_META[k].desc])),++ _fallback: true,++ };++}++++// ── Image (AI, optional) ─────────────────────────────────────++++function imageIsFresh(row) {++ if (!row || !row.image_path || !row.image_generated_at) return false;++ if (!fs.existsSync(path.join(ARENA_DIR, path.basename(row.image_path)))) return false;++ return Date.now() - Date.parse(row.image_generated_at + 'Z') < IMAGE_TTL_MS;++}++++function imageUrlFor(row) {++ if (!row || !row.image_path) return null;++ const base = path.basename(row.image_path);++ if (!fs.existsSync(path.join(ARENA_DIR, base))) return null;++ return `/data/arena/${base}`;++}++++/** Latest visual of the streamer's stream: live thumb if live, else newest VOD thumbnail. */++function latestThumbnailFor(userId) {++ try {++ const live = db.get('SELECT id FROM streams WHERE user_id = ? AND is_live = 1 ORDER BY started_at DESC LIMIT 1', [userId]);++ if (live) {++ const thumbs = require('../media-proxy/live-thumbs');++ const url = thumbs.getCurrentLiveThumbnailUrl(live.id);++ if (url) {++ const local = path.resolve('./data/live-thumbs', path.basename(url));++ if (fs.existsSync(local)) return local;++ return url.startsWith('http') ? url : null;++ }++ }++ } catch { /* */ }++ try {++ const v = db.get('SELECT thumbnail_url FROM vods WHERE user_id = ? AND thumbnail_url IS NOT NULL AND is_public = 1 ORDER BY created_at DESC LIMIT 1', [userId]);++ if (v && /^https?:\/\//i.test(v.thumbnail_url)) return v.thumbnail_url;++ } catch { /* */ }++ return null;++}++++const SCENE_SYSTEM = 'Describe this stream thumbnail as a SCENE for an illustrator in ≤ 60 words: setting, objects, lighting, colours, mood, what activity is happening. Do NOT describe any person\'s face, body, skin, hair, age, gender or identity — refer to a person only as "the host" if at all. Plain text only.';++++const _imageInFlight = new Map(); // userId → Promise++++async function generateImage(userId, { force = false } = {}) {++ ensureTables();++ const row = profileRow(userId);++ if (!force && imageIsFresh(row)) return imageUrlFor(row);++ if (!imageGenAvailable()) return imageUrlFor(row);++ if (_imageInFlight.has(userId)) return _imageInFlight.get(userId);++++ const task = (async () => {++ const entry = loadRoster().byId[userId];++ const persona = parseJson(row?.persona_json) || (entry ? fallbackPersona(entry) : null);++ if (!persona) return null;++ let scene = '';++ const thumb = latestThumbnailFor(userId);++ if (thumb) {++ try {++ const d = await llm.complete({ role: 'vision', kind: 'arena_scene', source: 'arena', ownerUserId: userId, system: SCENE_SYSTEM, user: 'Describe the scene.', image: thumb, maxTokens: 120, temperature: 0.4, timeoutMs: 30000 });++ scene = (d && d.text || '').trim();++ } catch { scene = ''; }++ }++ const color = entry?.user?.profile_color || '#8b5cf6';++ const prompt = [++ `Fighting-game character-select portrait of an original stylised hero called "${persona.fighter_name}" — ${persona.title}.`,++ `Class: ${persona.class}. Element: ${persona.element}. Signature move: ${persona.signature_move?.name} (${persona.signature_move?.description}).`,++ entry?.raw?.category ? `Costume and props inspired by ${String(entry.raw.category).replace(/[-_]/g, ' ')} streaming.` : '',++ scene ? `Background inspired by this scene: ${scene}` : 'Background: dark neon arena.',++ `Colour palette led by ${color}. Bold comic-book line art, dramatic rim light, dynamic pose, three-quarter view, waist up.`,++ 'Fictional character, not a real person. No text, no letters, no logos, no watermark.',++ ].filter(Boolean).join(' ');++++ const p = llm.resolveProvider('vision');++ const model = String(setting('ai_image_model', 'gpt-image-1'));++ const body = { model, prompt, n: 1, size: '1024x1024' };++ if (/^dall-e/i.test(model)) body.response_format = 'b64_json';++ else body.quality = String(setting('ai_image_quality', 'low'));++ const started = Date.now();++ let b64 = null;++ try {++ const res = await fetch(`${p.baseUrl}/images/generations`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', ...(p.apiKey ? { Authorization: `Bearer ${p.apiKey}` } : {}) },++ body: JSON.stringify(body),++ signal: AbortSignal.timeout(120000),++ });++ const j = await res.json().catch(() => ({}));++ if (!res.ok) throw new Error((j.error && (j.error.message || j.error)) || `HTTP ${res.status}`);++ const item = j.data && j.data[0];++ if (item?.b64_json) b64 = item.b64_json;++ else if (item?.url) {++ const r2 = await fetch(item.url, { signal: AbortSignal.timeout(60000) });++ b64 = Buffer.from(await r2.arrayBuffer()).toString('base64');++ }++ if (!b64) throw new Error('no image in response');++ } catch (err) {++ console.warn(`[Arena] image generation failed for user ${userId}:`, err.message);++ db.run('INSERT INTO arena_profiles (user_id, image_error, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(user_id) DO UPDATE SET image_error = excluded.image_error, updated_at = CURRENT_TIMESTAMP', [userId, err.message.slice(0, 300)]);++ return imageUrlFor(row);++ }++ const file = `u${userId}-${Date.now().toString(36)}.png`;++ fs.writeFileSync(path.join(ARENA_DIR, file), Buffer.from(b64, 'base64'));++ if (row?.image_path) { try { fs.unlinkSync(path.join(ARENA_DIR, path.basename(row.image_path))); } catch { /* */ } }++ db.run(`INSERT INTO arena_profiles (user_id, image_path, image_prompt, image_model, image_generated_at, image_error, updated_at)++ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, NULL, CURRENT_TIMESTAMP)++ ON CONFLICT(user_id) DO UPDATE SET image_path = excluded.image_path, image_prompt = excluded.image_prompt, image_model = excluded.image_model,++ image_generated_at = CURRENT_TIMESTAMP, image_error = NULL, updated_at = CURRENT_TIMESTAMP`,++ [userId, file, prompt, model]);++ try {++ db.recordAiUsage({ kind: 'arena_image', model, cost_usd: Number(setting('ai_image_cost_usd', 0.011)) || 0, owner_user_id: userId, source: 'arena', role: 'image', provider: 'shared', latency_ms: Date.now() - started });++ } catch { /* */ }++ console.log(`[Arena] portrait generated for user ${userId} (${model}, ${Date.now() - started} ms)`);++ return `/data/arena/${file}`;++ })().finally(() => _imageInFlight.delete(userId));++ _imageInFlight.set(userId, task);++ return task;++}++++// ── Fighter cards ────────────────────────────────────────────++++function resolveUser(usernameOrId) {++ if (/^\d+$/.test(String(usernameOrId))) return db.getUserById(Number(usernameOrId));++ return db.getUserByUsername(String(usernameOrId));++}++++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 } = {}) {++ 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 {++ user: entry.user,++ rank,++ roster_size: roster.order.length,++ ratings: entry.ratings,++ stat_meta: STAT_META,++ raw: includeRaw ? entry.raw : undefined,++ persona,++ persona_is_fallback: !!persona._fallback,++ persona_generated_at: row?.persona_generated_at || null,++ image_url: imageUrlFor(row),++ image_pending: false,++ record: recordFor(userId),++ live: isLive(userId),++ };++}++++async function getFighter(usernameOrId, { generate = true } = {}) {++ const user = resolveUser(usernameOrId);++ 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()) {++ 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()) {++ // Fire and forget — the client polls until image_url is present.++ generateImage(user.id).catch(() => {});++ }++ }++ const card = cardFor(user.id, roster);++ card.image_pending = !card.image_url && _imageInFlight.has(user.id);++ card.image_generation = imageGenAvailable() ? 'ai' : 'off';++ return card;++}++++function listFighters() {++ const roster = loadRoster();++ return roster.order.map(id => {++ 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_is_fallback: c.persona_is_fallback,++ category: roster.byId[id].raw.category,++ last_live_at: roster.byId[id].raw.last_live_at,++ };++ });++}++++// ── Battles ──────────────────────────────────────────────────++++function today() { return new Date().toISOString().slice(0, 10); }++++function seededRandom(seedText) {++ let h = parseInt(crypto.createHash('sha256').update(seedText).digest('hex').slice(0, 8), 16) >>> 0;++ return () => {++ h += 0x6D2B79F5;++ let t = h;++ t = Math.imul(t ^ (t >>> 15), t | 1);++ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);++ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;++ };++}++++/** Same pair on the same day → identical rounds. Order-independent. */++function simulateRounds(aRatings, bRatings, seedText) {++ const rand = seededRandom(seedText);++ const rounds = 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) {++ const r = db.get(`SELECT SUM(CASE WHEN side = 'a' THEN 1 ELSE 0 END) AS a, SUM(CASE WHEN side = 'b' THEN 1 ELSE 0 END) AS b FROM arena_votes WHERE battle_id = ?`, [battleId]) || {};++ return { a: r.a || 0, b: r.b || 0 };++}++++function scoreBattle(rounds, votes, aPower, bPower) {++ let a = rounds.filter(r => r.winner === 'a').length;++ let b = rounds.filter(r => r.winner === 'b').length;++ let crowd = null;++ if (votes.a !== votes.b) { crowd = votes.a > votes.b ? 'a' : 'b'; if (crowd === 'a') a++; else b++; }++ let winner = a === b ? (aPower === bPower ? null : (aPower > bPower ? 'a' : 'b')) : (a > b ? 'a' : 'b');++ return { a, b, crowd, winner, tiebreak: a === b && winner ? 'power' : null };++}++++const COMMENTARY_SCHEMA = {++ name: 'arena_commentary',++ schema: {++ type: 'object', additionalProperties: false,++ 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' } },++ 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.`;++++function templateCommentary(A, B, rounds, outcome) {++ const name = (s) => (s === 'a' ? A.persona.fighter_name : B.persona.fighter_name);++ return {++ intro: `${A.persona.fighter_name} versus ${B.persona.fighter_name}. The arena is loud, the chat is louder.`,++ rounds: rounds.map(r => `${r.label}: ${name(r.winner)} takes it${r.upset ? ' — an upset!' : ''} (${r.a}–${r.b}).`),++ finisher: outcome.winner ? `${name(outcome.winner)} closes it out with ${(outcome.winner === 'a' ? A : B).persona.signature_move.name}.` : 'Nobody lands the finisher. The crowd decides.',++ verdict: outcome.winner ? `${name(outcome.winner)} wins ${outcome.a}–${outcome.b}${outcome.tiebreak ? ' on Power' : ''}.` : 'Dead even — vote to break the tie.',++ _fallback: true,++ };++}++++async function getBattle(aName, bName, { generate = true } = {}) {++ const ua = resolveUser(aName), ub = resolveUser(bName);++ if (!ua || !ub || ua.id === ub.id) return null;++ const roster = loadRoster();++ if (!roster.byId[ua.id] || !roster.byId[ub.id]) return { error: 'both fighters must be on the roster' };++ // Canonical order so a-vs-b and b-vs-a are the same battle.++ const [ida, idb] = ua.id < ub.id ? [ua.id, ub.id] : [ub.id, ua.id];++ const day = today();++ 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()) {++ for (const id of [ida, idb]) {++ if (!personaIsFresh(profileRow(id))) { try { await generatePersona(id); } catch { /* */ } }++ }++ }++ const A = cardFor(ida, roster, { includeRaw: false });++ const B = cardFor(idb, roster, { includeRaw: false });++++ if (!row) {++ const rounds = simulateRounds(A.ratings, B.ratings, `${ida}:${idb}:${day}`);++ db.run('INSERT OR IGNORE INTO arena_battles (a_user_id, b_user_id, day, result_json) VALUES (?, ?, ?, ?)', [ida, idb, day, JSON.stringify({ rounds })]);++ row = db.get('SELECT * FROM arena_battles WHERE a_user_id = ? AND b_user_id = ? AND day = ?', [ida, idb, day]);++ }++ const rounds = parseJson(row.result_json, { rounds: [] }).rounds;++ const votes = tallyVotes(row.id);++ const outcome = scoreBattle(rounds, votes, A.ratings.power, B.ratings.power);++ const winnerId = outcome.winner === 'a' ? ida : outcome.winner === 'b' ? idb : null;++ if (row.winner_user_id !== winnerId) db.run('UPDATE arena_battles SET winner_user_id = ? WHERE id = ?', [winnerId, row.id]);++++ let commentary = parseJson(row.commentary_json);++ if (!commentary && generate && aiOn()) {++ try {++ 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 },++ 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,++ });++ if (r && r.json && Array.isArray(r.json.rounds)) {++ commentary = r.json;++ db.run('UPDATE arena_battles SET commentary_json = ? WHERE id = ?', [JSON.stringify(commentary), row.id]);++ }++ } catch (e) { console.warn('[Arena] commentary:', e.message); }++ }++ if (!commentary) commentary = templateCommentary(A, B, rounds, outcome);++++ return {++ id: row.id, day, a: A, b: B, rounds, votes, outcome,++ winner: winnerId ? (winnerId === ida ? A.user : B.user) : null,++ commentary,++ commentary_is_fallback: !!commentary._fallback,++ };++}++++function castVote(battleId, voterKey, side) {++ if (!['a', 'b'].includes(side)) throw new Error('side must be a or b');++ ensureTables();++ const battle = db.get('SELECT * FROM arena_battles WHERE id = ?', [battleId]);++ if (!battle) throw new Error('battle not found');++ if (battle.day !== today()) throw new Error('voting closed for this battle');++ db.run('INSERT INTO arena_votes (battle_id, voter_key, side) VALUES (?, ?, ?) ON CONFLICT(battle_id, voter_key) DO UPDATE SET side = excluded.side, created_at = CURRENT_TIMESTAMP', [battleId, voterKey, side]);++ const votes = tallyVotes(battleId);++ const rounds = parseJson(battle.result_json, { rounds: [] }).rounds;++ const roster = loadRoster();++ const pa = roster.byId[battle.a_user_id]?.ratings.power ?? 0, pb = roster.byId[battle.b_user_id]?.ratings.power ?? 0;++ const outcome = scoreBattle(rounds, votes, pa, pb);++ const winnerId = outcome.winner === 'a' ? battle.a_user_id : outcome.winner === 'b' ? battle.b_user_id : null;++ db.run('UPDATE arena_battles SET winner_user_id = ? WHERE id = ?', [winnerId, battleId]);++ return { votes, outcome, your_side: side };++}++++function voterKeyFor(req) {++ if (req.user && req.user.id) return `user:${req.user.id}`;++ const ip = String(req.ip || req.headers['x-forwarded-for'] || req.socket?.remoteAddress || '');++ const salt = String(setting('arena_vote_salt', '') || process.env.JWT_SECRET || 'arena');++ return `anon:${crypto.createHash('sha256').update(ip + '|' + salt).digest('hex').slice(0, 24)}`;++}++++// ── Live matchups ────────────────────────────────────────────++++function getLiveMatchups() {++ const roster = loadRoster();++ let live = [];++ try { live = db.getLiveStreams() || []; } catch { live = []; }++ const byUser = new Map();++ for (const s of live) {++ if (!roster.byId[s.user_id]) continue;++ const cur = byUser.get(s.user_id);++ if (!cur || (s.viewer_count || 0) > (cur.viewer_count || 0)) byUser.set(s.user_id, s);++ }++ 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 });++ 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,++ };++ }).sort((x, y) => y.ratings.power - x.ratings.power);++ // Pair neighbours on the power ladder so live fights are close ones.++ const matchups = [];++ for (let i = 0; i + 1 < fighters.length; i += 2) matchups.push({ a: fighters[i], b: fighters[i + 1] });++ const odd = fighters.length % 2 ? fighters[fighters.length - 1] : null;++ return { live_count: fighters.length, matchups, waiting: odd };++}++++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 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;++ return {++ enabled: arenaEnabled(),++ ai: aiOn(),++ image_generation: imageGenAvailable(),++ image_model: imageGenAvailable() ? String(setting('ai_image_model', 'gpt-image-1')) : null,++ roster: roster.order.length,++ personas: counts.personas || 0,++ images: counts.images || 0,++ battles, votes,++ active_days: ACTIVE_DAYS,++ };++}++++module.exports = {++ ensureTables,++ arenaEnabled,++ aiOn,++ imageGenAvailable,++ loadRoster,++ listFighters,++ getFighter,++ generatePersona,++ generateImage,++ getBattle,++ castVote,++ voterKeyFor,++ getLiveMatchups,++ status,++ STAT_KEYS,++ STAT_META,++ ROUNDS,++ ARENA_DIR,++ // pure helpers (tests)++ _computeRatings: computeRatings,++ _simulateRounds: simulateRounds,++ _scoreBattle: scoreBattle,++ _seededRandom: seededRandom,++ _fallbackPersona: fallbackPersona,++};nnew file mode 100644@@@ -0,0 +1,110 @@++/**++ * OpenVibe.Live — Arena API (mounted at /api/arena)++ *++ * GET /fighters leaderboard (roster ranked by POWER)++ * GET /fighters/:user fighter card (ratings, persona, portrait, record)++ * POST /fighters/:user/refresh admin: regenerate persona (+ portrait)++ * GET /battle/:a/:b today's battle for the pair (creates it on first view)++ * POST /battle/:a/:b/vote {side} crowd vote — one per user (or per IP when anonymous)++ * GET /live currently-live fighters paired up++ * GET /status feature status (AI on? image gen? roster size)++ */++'use strict';++++const express = require('express');++const { requireAuth, optionalAuth } = require('../auth/auth');++const permissions = require('../auth/permissions');++const arena = require('./arena-service');++++const router = express.Router();++++function gate(req, res, next) {++ if (!arena.arenaEnabled()) return res.status(404).json({ error: 'Arena is disabled' });++ next();++}++router.use(gate);++++router.get('/status', (req, res) => {++ try { res.json(arena.status()); }++ catch (err) { console.error('[Arena] status:', err.message); res.status(500).json({ error: 'Arena unavailable' }); }++});++++router.get('/fighters', (req, res) => {++ try {++ res.set('Cache-Control', 'public, max-age=60');++ res.json({ fighters: arena.listFighters(), ai: arena.aiOn(), image_generation: arena.imageGenAvailable() });++ } catch (err) {++ console.error('[Arena] fighters:', err.message);++ res.status(500).json({ error: 'Failed to load the roster' });++ }++});++++router.get('/live', (req, res) => {++ try {++ res.set('Cache-Control', 'no-store');++ res.json(arena.getLiveMatchups());++ } catch (err) {++ console.error('[Arena] live:', err.message);++ res.status(500).json({ error: 'Failed to load live matchups' });++ }++});++++router.get('/fighters/:user', async (req, res) => {++ try {++ const generate = req.query.generate !== '0';++ const card = await arena.getFighter(req.params.user, { generate });++ if (!card) return res.status(404).json({ error: 'No such fighter' });++ res.set('Cache-Control', 'no-store');++ res.json(card);++ } catch (err) {++ console.error('[Arena] fighter:', err.message);++ res.status(500).json({ error: 'Failed to load fighter' });++ }++});++++router.post('/fighters/:user/refresh', requireAuth, permissions.requireAdmin, async (req, res) => {++ try {++ const card = await arena.getFighter(req.params.user, { generate: false });++ if (!card || card.not_on_roster) return res.status(404).json({ error: 'No such fighter on the roster' });++ const persona = await arena.generatePersona(card.user.id, { force: true });++ let image = null;++ if (req.body?.image !== false && arena.imageGenAvailable()) image = await arena.generateImage(card.user.id, { force: true });++ res.json({ ok: true, persona, image_url: image });++ } catch (err) {++ console.error('[Arena] refresh:', err.message);++ res.status(500).json({ error: 'Refresh failed' });++ }++});++++router.get('/battle/:a/:b', optionalAuth, async (req, res) => {++ try {++ const battle = await arena.getBattle(req.params.a, req.params.b, { generate: req.query.generate !== '0' });++ if (!battle) return res.status(404).json({ error: 'Pick two different fighters' });++ if (battle.error) return res.status(400).json({ error: battle.error });++ let yourVote = null;++ try {++ const key = arena.voterKeyFor(req);++ const row = require('../db/database').get('SELECT side FROM arena_votes WHERE battle_id = ? AND voter_key = ?', [battle.id, key]);++ yourVote = row ? row.side : null;++ } catch { /* */ }++ res.set('Cache-Control', 'no-store');++ res.json({ ...battle, your_vote: yourVote });++ } catch (err) {++ console.error('[Arena] battle:', err.message);++ res.status(500).json({ error: 'The arena lights went out. Try again.' });++ }++});++++router.post('/battle/:a/:b/vote', optionalAuth, async (req, res) => {++ try {++ const battle = await arena.getBattle(req.params.a, req.params.b, { generate: false });++ if (!battle || battle.error) return res.status(404).json({ error: 'No such battle' });++ const side = String(req.body?.side || '');++ const result = arena.castVote(battle.id, arena.voterKeyFor(req), side);++ res.json(result);++ } catch (err) {++ res.status(400).json({ error: err.message });++ }++});++++module.exports = router;@@@ -462,6 +462,12 @@ app.use(express.static(path.join(__dirname, '../public'), { setHeaders: (res, fi// Serve locally-cached song-request media files (media player page)app.use('/media', express.static(path.resolve('./data/media')));++// Arena portraits (AI-generated fighting-game character art, see server/arena)++app.use('/data/arena', express.static(path.resolve(process.env.ARENA_IMAGE_PATH || './data/arena'), {++ maxAge: '7d', immutable: true, index: false, dotfiles: 'deny',++ setHeaders: (res) => res.setHeader('Content-Type', 'image/png'),++}));++// OpenVibe.Media → Live webhook (vod.ready / clip.ready …). Mounted BEFORE the// /internal router because it authenticates with an HMAC signature, not X-Internal-Key.app.post('/internal/media-webhook', require('./media-proxy/webhook'));@@@ -527,6 +533,7 @@ app.use('/api/vods', vodRoutes);app.use('/api/clips', clipRoutes);app.use('/api/chat-ai', require('./ai/chat-ai-routes'));app.use('/api/easter-egg', require('./ai/easter-egg-routes'));++app.use('/api/arena', require('./arena/routes')); // streamer vs streamer (docs/arena.md)app.use('/api/comments', commentRoutes);app.use('/api/controls', controlRoutes);app.use('/api/onvif', onvifRoutes);@@@ -1020,6 +1027,7 @@ async function start() {try { require('./ai/ai-moments-job').start(); } catch (e) { console.warn('[AI] moments job not started:', e.message); }try { require('./ai/auto-clip-job').start(); } catch (e) { console.warn('[AI] auto-clip job not started:', e.message); }try { require('./ai/easter-egg-job').start(); } catch (e) { console.warn('[AI] easter-egg job not started:', e.message); }++ try { require('./arena/arena-job').start(); } catch (e) { console.warn('[Arena] job not started:', e.message); }// Continuous audio → stream_timeline_events. Gated behind ai_timeline_enabled// (default off), so starting it is a no-op until switched on in admin.try { require('./ai/timeline-job').start(); } catch (e) { console.warn('[AI] timeline job not started:', e.message); }@@@ -448,7 +448,7 @@ function _urlTag(loc, lastmod, changefreq, priority) {async function buildSitemap() {const urls = [];const seenChannels = new Set();-- const statics = [['/', 'daily', '1.0'], ['/vods', 'hourly', '0.8'], ['/clips', 'hourly', '0.8'], ['/pastes', 'hourly', '0.7'], ['/chat', 'daily', '0.5']];++ const statics = [['/', 'daily', '1.0'], ['/vods', 'hourly', '0.8'], ['/clips', 'hourly', '0.8'], ['/pastes', 'hourly', '0.7'], ['/chat', 'daily', '0.5'], ['/arena', 'hourly', '0.6']];// Rendered docs (server/docs/routes.js): /docs is the index (README.md), the rest by file name.try {for (const f of require('fs').readdirSync(require('path').join(__dirname, '../../docs'))) {nnew file mode 100644@@@ -0,0 +1,123 @@++'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++// end-to-end on a temp DB without any AI configured (personas fall back, no LLM calls).++++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-arena-'));++process.env.DB_PATH = path.join(tmp, 'live.db');++process.env.ARENA_IMAGE_PATH = path.join(tmp, 'arena');++++const db = require('../server/db/database');++db.initDb();++const arena = require('../server/arena/arena-service');++++// ── Pure helpers ──++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 },++});++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.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.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');++++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 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);++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');++console.log('✅ crowd vote is the last round');++++// ── End-to-end on a temp DB (no AI configured → fallback personas, 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);++const stream = (uid, peak, hours, daysAgo) => {++ const id = Number(db.createStream({ user_id: uid, title: `${uid} stream`, category: 'irl', protocol: 'rtmp' }).lastInsertRowid);++ db.run(`UPDATE streams SET is_live = 0, started_at = datetime('now', ?), ended_at = datetime('now', ?), duration_seconds = ?, peak_viewers = ? WHERE id = ?`,++ [`-${daysAgo} days`, `-${daysAgo} days`, Math.round(hours * 3600), peak, id]);++ db.run(`INSERT INTO stream_analytics (stream_id, avg_viewers, peak_viewers, unique_chatters, total_messages, total_watch_minutes, new_followers, clips_created, coins_earned)++ 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);++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]);++++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.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');++++(async () => {++ const card = await arena.getFighter('alpha');++ assert.strictEqual(card.user.username, 'alpha');++ assert.strictEqual(card.rank, 1);++ assert.ok(card.raw.hours >= 9.9 && card.raw.hours <= 10.1, `hours aggregated: ${card.raw.hours}`);++ assert.strictEqual(card.raw.followers, 2);++ assert.strictEqual(card.image_url, null);++ assert.strictEqual(card.image_generation, 'off', 'no AI → no image generation');++ const off = await arena.getFighter('idle');++ assert.strictEqual(off.not_on_roster, true);++ console.log('✅ fighter card');++++ 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.commentary_is_fallback, true);++ assert.strictEqual(b1.votes.a + b1.votes.b, 0);++++ const v1 = arena.castVote(b1.id, 'user:99', 'b');++ assert.deepStrictEqual(v1.votes, { a: 0, b: 1 });++ const v2 = arena.castVote(b1.id, 'user:99', 'a');++ assert.deepStrictEqual(v2.votes, { a: 1, b: 0 }, 'a voter can change sides, never double-vote');++ arena.castVote(b1.id, 'anon:abc', 'a');++ const after = await arena.getBattle('alpha', 'bravo', { generate: false });++ assert.deepStrictEqual(after.votes, { a: 2, b: 0 });++ 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');++++ 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 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.votes, 2);++ console.log('✅ records, live matchups, status');++++ console.log('\n✅ All Arena tests passed');++ process.exit(0);++})().catch(err => { console.error(err); process.exit(1); });
@@ -68,6 +68,7 @@ Every file below is also served on the site at `/docs/<name>` (rendered by `serv
@@ -68,6 +68,7 @@ Every file below is also served on the site at `/docs/<name>` (rendered by `serv
- [docs/architecture.md](docs/architecture.md) — System design, data flows, module map
- [docs/architecture.md](docs/architecture.md) — System design, data flows, module map
- [docs/broadcasting.md](docs/broadcasting.md) — Streaming protocols (WebRTC/RTMP/JSMPEG/WHIP)
- [docs/broadcasting.md](docs/broadcasting.md) — Streaming protocols (WebRTC/RTMP/JSMPEG/WHIP)
- [docs/whip.md](docs/whip.md) — WHIP ingest API reference (auth forms, CORS, browser-only publishing, error codes)
- [docs/whip.md](docs/whip.md) — WHIP ingest API reference (auth forms, CORS, browser-only publishing, error codes)
+- [docs/arena.md](docs/arena.md) — Arena tab (streamer vs streamer): ratings, AI personas, gated portrait generation, battles, votes
- [docs/chat-system.md](docs/chat-system.md) — Chat features and moderation
- [docs/chat-system.md](docs/chat-system.md) — Chat features and moderation
- [docs/api-tokens.md](docs/api-tokens.md) — Bot/integration token system
- [docs/api-tokens.md](docs/api-tokens.md) — Bot/integration token system
- [docs/vods-and-clips.md](docs/vods-and-clips.md) — VOD/clip pipeline (pre-split; storage/cutting now in OpenVibe.Media)
- [docs/vods-and-clips.md](docs/vods-and-clips.md) — VOD/clip pipeline (pre-split; storage/cutting now in OpenVibe.Media)
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
- [Vibe Coding](vibe-coding.md) — Copilot companion publisher protocol, slot settings, and viewer feed
- [Vibe Coding](vibe-coding.md) — Copilot companion publisher protocol, slot settings, and viewer feed
- [VOD & Clips](vods-and-clips.md) — Recordings, DVR seeking, clip creation
- [VOD & Clips](vods-and-clips.md) — Recordings, DVR seeking, clip creation
- [API Tokens](api-tokens.md) — Bot/integration authentication
- [API Tokens](api-tokens.md) — Bot/integration authentication
+- [Arena](arena.md) — Streamer-vs-streamer tab: ratings from analytics, AI personas/portraits, daily battles, crowd votes
## Streamer Tools
## Streamer Tools
- [Dashboard](dashboard.md) — Streamer dashboard features: emotes, rewards, chat logs, media management
- [Dashboard](dashboard.md) — Streamer dashboard features: emotes, rewards, chat logs, media management
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+# Arena — streamer vs streamer
+
+The **Arena** tab (`/arena`) turns the analytics and AI data OpenVibe.Live already keeps about every streamer into a fighting-game roster: a ranked leaderboard, a character-select profile per streamer, live head-to-head matchups, and daily battles that the crowd finishes with a vote.
+
+Everything is opt-in on the AI side — with AI off the tab still works from stats alone (templated lore and commentary, avatar cards instead of portraits).
+
+## What each streamer gets
+
+| 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 |
+| **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.
+
+Live streamers are paired by neighbouring Power on the leaderboard ("Live battles"), with their live thumbnails, refreshed every 30 s.
+
+## API
+
+| 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`) |
+| 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" }` |
+| GET | `/api/arena/live` | live matchups |
+| GET | `/api/arena/status` | roster size, AI / image-generation state, counts |
+
+## Settings (site settings, admin → Settings)
+
+| Key | Default | Effect |
+|---|---|---|
+| `arena_enabled` | `true` | `false` hides the API (404) — the nav link should be removed too |
+| `ai_image_enabled` | `false` | allow portrait generation (needs AI enabled + an OpenAI-shaped provider) |
+| `ai_image_model` | `gpt-image-1` | image model; `dall-e-*` models are handled too |
+| `ai_image_quality` | `low` | passed to `gpt-image-*` |
+| `ai_image_cost_usd` | `0.011` | per-image cost recorded into `ai_usage` so the AI budget stays honest |
+| `arena_vote_salt` | `JWT_SECRET` | salt for anonymous voter hashing |
+
+Portraits are written to `data/arena/` (override with `ARENA_IMAGE_PATH`) and served from `/data/arena/`.
+
+## 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.
+- Portraits never use the streamer's face: only the persona and an identity-free scene description feed the image model.
+- The background job (`server/arena/arena-job.js`) refreshes at most 6 personas and 2 portraits per 20-minute tick, top of the leaderboard first, and stops when the AI budget is exhausted.
+- All AI text is rendered escaped; nothing from a model is inserted as HTML.
+
+Tests: `node test/arena.test.js` (ratings, seeded battles, votes, roster on a temp DB — no AI needed).
@@ -13831,3 +13831,173 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
@@ -13831,3 +13831,173 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
.aiv-slot-grid label > span { font-weight:600; display:flex; justify-content:space-between; }
.aiv-slot-grid label > span { font-weight:600; display:flex; justify-content:space-between; }
.aiv-slot-grid label > span b { color:var(--accent); }
.aiv-slot-grid label > span b { color:var(--accent); }
.aiv-slot-grid input[type=range] { accent-color:var(--accent); }
.aiv-slot-grid input[type=range] { accent-color:var(--accent); }
+
+/* ═══════════════════════════════════════════════════════════════
+ ARENA — streamer vs streamer (public/js/arena.js)
+ ═══════════════════════════════════════════════════════════════ */
+.arena-container { max-width: 1180px; }
+.arena-loading { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 60px 0; color: var(--text-secondary); font-size: 1rem; }
+.arena-loading i { font-size: 2rem; color: var(--accent); }
+.arena-loading small { color: var(--text-muted); }
+.arena-empty { text-align: center; padding: 60px 20px; color: var(--text-secondary); }
+.arena-empty i { font-size: 2.4rem; color: var(--accent); margin-bottom: 12px; display: block; }
+.arena-note { font-size: 0.8rem; color: var(--text-muted); }
+.arena-back { margin: 8px 0 16px; display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }
+.arena-back a { color: var(--text-secondary); text-decoration: none; }
+.arena-back a:hover { color: var(--accent-light); }
+
+.arena-hero { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; flex-wrap: wrap; padding: 24px 0 16px; }
+.arena-hero h1 { margin: 0 0 6px; font-size: 2rem; display: flex; align-items: center; gap: 10px; }
+.arena-hero h1 i { color: var(--accent); }
+.arena-hero-actions { display: flex; align-items: center; gap: 12px; }
+
+/* portraits */
+.arena-portrait { position: relative; border-radius: 14px; overflow: hidden; background: radial-gradient(120% 120% at 30% 20%, color-mix(in srgb, var(--fc, var(--accent)) 45%, #000) 0%, #0a0a0e 70%); box-shadow: 0 0 0 2px color-mix(in srgb, var(--fc, var(--accent)) 60%, transparent), 0 10px 30px rgba(0, 0, 0, 0.45); flex-shrink: 0; }
+.arena-portrait img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.arena-portrait-avatar img { filter: saturate(1.25) contrast(1.1); }
+.arena-portrait-glow { position: absolute; inset: 0; background: linear-gradient(180deg, transparent 55%, color-mix(in srgb, var(--fc, var(--accent)) 55%, transparent)); pointer-events: none; }
+.arena-portrait-initial { display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 900; text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6); }
+.arena-portrait-xs { width: 40px; height: 40px; border-radius: 10px; font-size: 1.1rem; }
+.arena-portrait-sm { width: 64px; height: 64px; font-size: 1.6rem; }
+.arena-portrait-md { width: 100%; max-width: 260px; aspect-ratio: 1; margin: 0 auto; font-size: 4rem; }
+.arena-portrait-lg { width: 100%; max-width: 320px; aspect-ratio: 1; font-size: 5rem; }
+.arena-portrait-pending { margin-top: 8px; font-size: 0.8rem; color: var(--accent-light); text-align: center; }
+
+/* live matchups */
+.arena-live { margin: 8px 0 28px; }
+.arena-live h2 { display: flex; align-items: center; gap: 10px; font-size: 1.15rem; margin: 0 0 12px; }
+.arena-live h2 small { color: var(--text-muted); font-weight: 400; font-size: 0.85rem; }
+.arena-live-dot { width: 10px; height: 10px; border-radius: 50%; background: #e74c3c; box-shadow: 0 0 0 4px rgba(231, 76, 60, 0.25); animation: arena-pulse 1.6s infinite; }
+@keyframes arena-pulse { 0%, 100% { box-shadow: 0 0 0 4px rgba(231, 76, 60, 0.25); } 50% { box-shadow: 0 0 0 8px rgba(231, 76, 60, 0.08); } }
+.arena-live-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); gap: 14px; }
+.arena-live-match { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 10px; padding: 12px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 14px; }
+.arena-live-fighter { display: flex; align-items: center; gap: 10px; color: var(--text-primary); text-decoration: none; min-width: 0; }
+.arena-live-b { flex-direction: row-reverse; text-align: right; }
+.arena-live-thumb { width: 96px; height: 54px; object-fit: cover; border-radius: 8px; background: #000; flex-shrink: 0; }
+.arena-live-meta { display: flex; flex-direction: column; min-width: 0; }
+.arena-live-meta strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.arena-live-meta span { font-size: 0.78rem; color: var(--text-secondary); }
+.arena-live-vs { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 54px; height: 54px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--accent-dark)); color: #fff; font-weight: 900; text-decoration: none; font-size: 0.95rem; line-height: 1; box-shadow: 0 6px 18px rgba(139, 92, 246, 0.35); }
+.arena-live-vs small { font-size: 0.55rem; font-weight: 600; opacity: 0.85; text-transform: uppercase; }
+.arena-live-vs:hover { transform: scale(1.06); }
+.arena-live-vs-idle { background: var(--bg-tertiary); color: var(--text-muted); box-shadow: none; width: auto; height: auto; padding: 10px 12px; border-radius: 12px; }
+.arena-live-vs-idle small { white-space: nowrap; }
+.arena-live-pill { display: inline-block; font-size: 0.62rem; font-weight: 800; letter-spacing: 0.06em; background: #e74c3c; color: #fff; padding: 2px 6px; border-radius: 4px; vertical-align: middle; margin-left: 6px; }
+
+/* leaderboard */
+.arena-board-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 12px; }
+.arena-board-head h2 { margin: 0; font-size: 1.15rem; }
+.arena-board-head input { padding: 8px 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text-primary); min-width: 220px; }
+.arena-list { display: flex; flex-direction: column; gap: 8px; }
+.arena-row { display: grid; grid-template-columns: 44px 64px 1fr auto; align-items: center; gap: 14px; padding: 10px 14px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 14px; color: var(--text-primary); text-decoration: none; transition: transform 0.12s, border-color 0.12s; }
+.arena-row:hover { transform: translateY(-1px); border-color: var(--accent); }
+.arena-row.is-live { border-color: rgba(231, 76, 60, 0.5); }
+.arena-rank { font-weight: 900; font-size: 1.2rem; color: var(--text-secondary); text-align: center; }
+.arena-rank-1 { color: #f5c542; } .arena-rank-2 { color: #cfd6dd; } .arena-rank-3 { color: #d09056; }
+.arena-row-main { display: flex; flex-direction: column; min-width: 0; gap: 2px; }
+.arena-row-main strong { font-size: 1.02rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.arena-row-sub { font-size: 0.8rem; color: var(--text-secondary); }
+.arena-row-taunt { font-size: 0.8rem; color: var(--text-muted); font-style: italic; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.arena-row-stats { display: flex; align-items: center; gap: 16px; }
+.arena-power { display: flex; flex-direction: column; align-items: center; line-height: 1; }
+.arena-power b { font-size: 1.5rem; color: var(--accent-light); }
+.arena-power small { font-size: 0.6rem; letter-spacing: 0.1em; color: var(--text-muted); }
+.arena-power-lg b { font-size: 2.6rem; }
+.arena-record { font-size: 0.85rem; color: var(--text-secondary); font-variant-numeric: tabular-nums; }
+.arena-record-lg { font-size: 1rem; }
+
+/* profile */
+.arena-profile { display: grid; grid-template-columns: 320px 1fr; gap: 28px; padding: 20px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 18px; background-image: radial-gradient(60% 40% at 0% 0%, color-mix(in srgb, var(--fc, var(--accent)) 18%, transparent), transparent); }
+.arena-profile-portrait { display: flex; flex-direction: column; align-items: center; gap: 10px; }
+.arena-profile-rank { font-weight: 800; font-size: 1.1rem; }
+.arena-profile-rank small { color: var(--text-muted); font-weight: 400; }
+.arena-profile-main { display: flex; flex-direction: column; gap: 18px; min-width: 0; }
+.arena-profile-name h1 { margin: 0; font-size: 2rem; line-height: 1.15; }
+.arena-title { margin: 4px 0 0; color: var(--accent-light); font-weight: 600; }
+.arena-handle { margin: 4px 0 0; color: var(--text-secondary); font-size: 0.9rem; }
+.arena-handle a { color: inherit; }
+.arena-profile-power { display: flex; align-items: center; gap: 24px; }
+.arena-profile-stats { display: grid; grid-template-columns: 200px 1fr; gap: 18px; align-items: center; }
+.arena-radar { width: 100%; height: auto; max-width: 200px; }
+.arena-radar-ring { fill: none; stroke: var(--border-light); stroke-width: 1; }
+.arena-radar-fill { stroke-width: 2; }
+.arena-radar text { fill: var(--text-secondary); font-size: 10px; font-weight: 600; }
+.arena-quips { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px 14px; }
+.arena-quip { display: flex; flex-direction: column; font-size: 0.85rem; }
+.arena-quip b { color: var(--text-primary); }
+.arena-quip span { color: var(--text-secondary); }
+.arena-lore-text { font-size: 1rem; line-height: 1.55; margin: 0 0 12px; }
+.arena-moves { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin-bottom: 12px; }
+.arena-move { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px; border-radius: 10px; background: var(--bg-tertiary); border: 1px solid var(--border); font-size: 0.85rem; }
+.arena-move-kind { font-size: 0.65rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent-light); }
+.arena-move-weak .arena-move-kind { color: #e74c3c; }
+.arena-flavor { display: flex; flex-wrap: wrap; gap: 8px 18px; font-size: 0.85rem; color: var(--text-secondary); }
+.arena-flavor i { color: var(--accent); margin-right: 4px; }
+.arena-numbers { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 8px; }
+.arena-number { display: flex; flex-direction: column; padding: 8px 10px; border-radius: 10px; background: var(--bg-tertiary); }
+.arena-number b { font-size: 1.1rem; }
+.arena-number span { font-size: 0.72rem; color: var(--text-muted); }
+.arena-challenge { margin-top: 24px; }
+.arena-challenge h2 { font-size: 1.15rem; margin: 0 0 12px; }
+.arena-opponents { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; }
+.arena-opponent { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 12px; background: var(--bg-card); border: 1px solid var(--border); color: var(--text-primary); text-decoration: none; }
+.arena-opponent:hover { border-color: var(--accent); }
+.arena-opponent span { display: flex; flex-direction: column; min-width: 0; }
+.arena-opponent strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.arena-opponent small { color: var(--text-muted); }
+
+/* battle */
+.arena-battle { display: grid; grid-template-columns: 1fr 1.2fr 1fr; gap: 20px; align-items: start; }
+.arena-side { padding: 18px; border-radius: 18px; background: var(--bg-card); border: 2px solid var(--border); text-align: center; transition: border-color 0.2s, box-shadow 0.2s, transform 0.1s; background-image: radial-gradient(70% 45% at 50% 0%, color-mix(in srgb, var(--fc, var(--accent)) 22%, transparent), transparent); }
+.arena-side h2 { margin: 12px 0 0; font-size: 1.35rem; }
+.arena-side .arena-power { margin: 10px auto; }
+.arena-side.is-winner { border-color: var(--fc, var(--accent)); box-shadow: 0 0 0 4px color-mix(in srgb, var(--fc, var(--accent)) 25%, transparent), 0 14px 40px rgba(0, 0, 0, 0.4); }
+.arena-side.is-hit { animation: arena-hit 0.5s; }
+@keyframes arena-hit { 0% { transform: scale(1); } 30% { transform: scale(1.03) rotate(-0.6deg); } 100% { transform: scale(1); } }
+.arena-side-taunt { font-style: italic; color: var(--text-secondary); font-size: 0.85rem; margin: 10px 0 0; }
+.arena-bars { display: flex; flex-direction: column; gap: 5px; text-align: left; }
+.arena-bar { display: grid; grid-template-columns: 58px 1fr 28px; align-items: center; gap: 8px; font-size: 0.75rem; }
+.arena-bar-label { color: var(--text-secondary); }
+.arena-bar-track { height: 7px; border-radius: 4px; background: var(--bg-tertiary); overflow: hidden; }
+.arena-bar-fill { display: block; height: 100%; background: linear-gradient(90deg, var(--fc, var(--accent)), color-mix(in srgb, var(--fc, var(--accent)) 50%, #fff)); }
+.arena-bar-val { text-align: right; font-variant-numeric: tabular-nums; }
+.arena-center { display: flex; flex-direction: column; align-items: center; gap: 12px; }
+.arena-vs { font-size: 3rem; font-weight: 900; letter-spacing: 0.05em; background: linear-gradient(135deg, var(--accent-light), #ec4899); -webkit-background-clip: text; background-clip: text; color: transparent; line-height: 1; }
+.arena-scoreboard { font-size: 2.2rem; font-weight: 900; font-variant-numeric: tabular-nums; display: flex; gap: 12px; align-items: center; }
+.arena-score-sep { color: var(--text-muted); }
+.arena-commentary { width: 100%; min-height: 120px; max-height: 420px; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; padding: 12px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 14px; }
+.arena-line { padding: 8px 10px; border-radius: 10px; background: var(--bg-tertiary); font-size: 0.9rem; line-height: 1.45; animation: arena-in 0.35s ease-out; }
+@keyframes arena-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
+.arena-line i { color: var(--accent); margin-right: 6px; }
+.arena-line-a { border-left: 3px solid var(--accent); }
+.arena-line-b { border-left: 3px solid #ec4899; }
+.arena-line-upset { background: rgba(245, 197, 66, 0.12); }
+.arena-line-finisher { font-weight: 600; }
+.arena-line-verdict { background: color-mix(in srgb, var(--accent) 18%, var(--bg-tertiary)); font-weight: 600; }
+.arena-round-tag { display: inline-block; font-size: 0.65rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); margin-right: 6px; }
+.arena-round-score { float: right; font-size: 0.75rem; color: var(--text-muted); font-variant-numeric: tabular-nums; }
+.arena-vote { width: 100%; }
+.arena-vote-head { text-align: center; font-weight: 700; margin-bottom: 8px; }
+.arena-vote-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
+.arena-vote-btn { padding: 10px; border-radius: 10px; border: 2px solid var(--border); background: var(--bg-card); color: var(--text-primary); font-weight: 700; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.arena-vote-a:hover, .arena-vote-a.is-picked { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 18%, var(--bg-card)); }
+.arena-vote-b:hover, .arena-vote-b.is-picked { border-color: #ec4899; background: color-mix(in srgb, #ec4899 18%, var(--bg-card)); }
+.arena-vote-btn:disabled { opacity: 0.6; cursor: wait; }
+.arena-vote-bar { height: 8px; border-radius: 4px; background: #ec4899; overflow: hidden; margin-top: 10px; }
+.arena-vote-bar span { display: block; height: 100%; background: var(--accent); transition: width 0.4s; }
+.arena-vote-tally { display: flex; justify-content: space-between; font-size: 0.75rem; color: var(--text-muted); margin-top: 4px; }
+.arena-vote-result { text-align: center; font-size: 0.85rem; color: var(--text-secondary); margin-top: 8px; line-height: 1.5; }
+.arena-battle-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
+.arena-loading-battle { padding: 90px 0; }
+
+@media (max-width: 900px) {
+ .arena-profile { grid-template-columns: 1fr; }
+ .arena-profile-portrait { flex-direction: row; align-items: flex-start; }
+ .arena-portrait-lg { max-width: 160px; }
+ .arena-profile-stats { grid-template-columns: 1fr; }
+ .arena-battle { grid-template-columns: 1fr; }
+ .arena-center { order: -1; }
+ .arena-live-grid { grid-template-columns: 1fr; }
+ .arena-row { grid-template-columns: 32px 48px 1fr auto; gap: 10px; }
+ .arena-row-taunt { display: none; }
+}
@@ -62,7 +62,7 @@
@@ -62,7 +62,7 @@
</script>
</script>
<!-- Stylesheets -->
<!-- Stylesheets -->
- <link rel="stylesheet" href="/css/style.css?v=140">
+ <link rel="stylesheet" href="/css/style.css?v=141">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
@@ -131,6 +131,9 @@
@@ -131,6 +131,9 @@
<a href="/pastes" onclick="return handleLinkClick(event, '/pastes')" class="nav-link" data-page="pastes" data-tooltip="Pastes">
<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>
<i class="fa-solid fa-paste"></i> <span>Pastes</span>
</a>
</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">
<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>
<i class="fa-solid fa-comments"></i> <span>Chat</span>
</a>
</a>
@@ -3503,6 +3506,17 @@ WantedBy=multi-user.target</code>
@@ -3503,6 +3506,17 @@ WantedBy=multi-user.target</code>
</section>
</section>
<!-- Documentation Page -->
<!-- Documentation Page -->
+ <!-- ═══════════════════════════════════════════════════════════ -->
+ <!-- ARENA — streamer vs streamer (public/js/arena.js) -->
+ <!-- ═══════════════════════════════════════════════════════════ -->
+ <section id="page-arena" class="page">
+ <div class="content-container arena-container">
+ <div id="arena-root">
+ <div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
+ </div>
+ </div>
+ </section>
+
<section id="page-documentation" class="page">
<section id="page-documentation" class="page">
<div class="content-container" style="max-width:960px">
<div class="content-container" style="max-width:960px">
@@ -5093,6 +5107,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5093,6 +5107,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/staff-console.js?v=1" defer></script>
<script src="/js/staff-console.js?v=1" defer></script>
<script src="/js/dashboard-moderation.js?v=5" defer></script>
<script src="/js/dashboard-moderation.js?v=5" defer></script>
<script src="/js/ai-viewers.js?v=2" defer></script>
<script src="/js/ai-viewers.js?v=2" defer></script>
+<script src="/js/arena.js?v=1" defer></script>
<script src="/js/powerchat.js?v=10" defer></script>
<script src="/js/powerchat.js?v=10" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
@@ -58,7 +58,7 @@ function isStaffUser(user = currentUser) {
@@ -58,7 +58,7 @@ function isStaffUser(user = currentUser) {
}
}
// Reserved paths (not usernames)
// Reserved paths (not usernames)
-const RESERVED = new Set(['vods', 'clips', 'vod', 'clip', 'dashboard', 'settings', 'broadcast', 'admin', 'themes', 'game', 'canvas', 'chat', 'api', 'ws', 'media', 'pastes', 'p', 'updates', 'dmca', 'tos', 'terms']);
+const RESERVED = new Set(['vods', 'clips', 'vod', 'clip', 'dashboard', 'settings', 'broadcast', 'admin', 'themes', 'game', 'canvas', 'chat', 'api', 'ws', 'media', 'pastes', 'p', 'updates', 'dmca', 'tos', 'terms', 'arena']);
const CHANNEL_USERNAME_RE = /^[a-zA-Z0-9_]{3,24}$/;
const CHANNEL_USERNAME_RE = /^[a-zA-Z0-9_]{3,24}$/;
function normalizeChannelUsername(username) {
function normalizeChannelUsername(username) {
@@ -985,6 +985,9 @@ function routeFromURL() {
@@ -985,6 +985,9 @@ function routeFromURL() {
});
});
}).catch(() => {});
}).catch(() => {});
}
}
+ } else if (segments[0] === 'arena') {
+ showPage('arena');
+ if (typeof loadArenaPage === 'function') loadArenaPage(segments);
} else if (segments[0] === 'updates') {
} else if (segments[0] === 'updates') {
showPage('updates');
showPage('updates');
loadUpdatesPage();
loadUpdatesPage();
@@ -1045,7 +1048,7 @@ function showPage(page) {
@@ -1045,7 +1048,7 @@ function showPage(page) {
}
}
// Highlight nav link
// Highlight nav link
- const pageToNav = { home: 'home', vods: 'vods', clips: 'clips', broadcast: 'broadcast', dashboard: 'dashboard', admin: 'admin', chat: 'chat', game: 'game', canvas: 'game', pastes: 'pastes', 'paste-viewer': 'pastes' };
+ const pageToNav = { home: 'home', vods: 'vods', clips: 'clips', broadcast: 'broadcast', dashboard: 'dashboard', admin: 'admin', chat: 'chat', game: 'game', canvas: 'game', pastes: 'pastes', 'paste-viewer': 'pastes', arena: 'arena' };
const navPage = pageToNav[page];
const navPage = pageToNav[page];
if (navPage) {
if (navPage) {
const link = document.querySelector(`.nav-link[data-page="${navPage}"]`);
const link = document.querySelector(`.nav-link[data-page="${navPage}"]`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,386 @@
@@ -0,0 +1,386 @@
+/**
+ * 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
+ *
+ * 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.
+ */
+'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' };
+let _arenaRoster = null; // cached leaderboard payload
+let _arenaLiveTimer = null;
+let _arenaImagePoll = null;
+let _arenaBattleTimers = [];
+
+function _aEsc(s) { return typeof esc === 'function' ? esc(String(s ?? '')) : String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
+function _aNum(n) { n = Number(n) || 0; return n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n); }
+function _aInitial(u) { return (u.display_name || u.username || '?').trim().charAt(0).toUpperCase(); }
+
+function _aStopTimers() {
+ if (_arenaLiveTimer) { clearInterval(_arenaLiveTimer); _arenaLiveTimer = null; }
+ if (_arenaImagePoll) { clearInterval(_arenaImagePoll); _arenaImagePoll = null; }
+ for (const t of _arenaBattleTimers) clearTimeout(t);
+ _arenaBattleTimers = [];
+}
+
+/** Portrait: AI image when present, otherwise the avatar (or an initial) styled as a card. */
+function _aPortrait(f, size = 'md') {
+ const u = f.user;
+ const color = u.profile_color || '#8b5cf6';
+ if (f.image_url) {
+ return `<div class="arena-portrait arena-portrait-${size}" style="--fc:${_aEsc(color)}"><img src="${_aEsc(f.image_url)}" alt="" loading="lazy"></div>`;
+ }
+ if (u.avatar_url) {
+ return `<div class="arena-portrait arena-portrait-${size} arena-portrait-avatar" style="--fc:${_aEsc(color)}"><img src="${_aEsc(u.avatar_url)}" alt="" loading="lazy"><span class="arena-portrait-glow"></span></div>`;
+ }
+ return `<div class="arena-portrait arena-portrait-${size} arena-portrait-initial" style="--fc:${_aEsc(color)}"><span>${_aEsc(_aInitial(u))}</span></div>`;
+}
+
+function _aRatingBars(r) {
+ return `<div class="arena-bars">${ARENA_STATS.map(k => `
+ <div class="arena-bar" 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>
+ </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
+ 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 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>`;
+ }).join('');
+ return `<svg class="arena-radar" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" aria-hidden="true">
+ ${[33, 66, 99].map(v => `<polygon points="${ring(v)}" class="arena-radar-ring"></polygon>`).join('')}
+ <polygon points="${poly}" class="arena-radar-fill" style="fill:${_aEsc(color)}33;stroke:${_aEsc(color)}"></polygon>
+ ${labels}
+ </svg>`;
+}
+
+function _aChannelLink(u) { return `/${encodeURIComponent(u.username)}`; }
+
+// ── Page entry ───────────────────────────────────────────────
+
+async function loadArenaPage(segments = []) {
+ _aStopTimers();
+ const root = document.getElementById('arena-root');
+ if (!root) return;
+ const [, first, second, third] = segments;
+ try {
+ if (first === 'battle' && second && third) return await _aRenderBattle(root, second, third);
+ if (first && first !== 'battle') return await _aRenderFighter(root, first);
+ return await _aRenderHome(root);
+ } catch (err) {
+ root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-plug-circle-xmark"></i><p>${_aEsc(err?.message || 'The arena lights went out.')}</p></div>`;
+ }
+}
+
+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 ────────────────────────
+
+async function _aRenderHome(root) {
+ root.innerHTML = _aSpinner('Loading the roster…');
+ const [data, live] = await Promise.all([api('/arena/fighters'), api('/arena/live').catch(() => null)]);
+ _arenaRoster = data;
+ const fighters = data.fighters || [];
+ if (!fighters.length) {
+ root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-hand-fist"></i><p>No fighters yet — the roster fills with everyone who has streamed in the last 45 days.</p></div>`;
+ return;
+ }
+ root.innerHTML = `
+ <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>
+ </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-live" class="arena-live"></section>
+ <section class="arena-board">
+ <div class="arena-board-head">
+ <h2>Leaderboard</h2>
+ <input id="arena-search" type="search" placeholder="Find a fighter…" autocomplete="off">
+ </div>
+ <div class="arena-list" id="arena-list"></div>
+ </section>`;
+ _aRenderLive(live);
+ _aRenderList(fighters);
+ 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);
+ });
+ 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)}`);
+ });
+ _arenaLiveTimer = setInterval(async () => {
+ if (currentPage !== 'arena' || !document.getElementById('arena-live')) return _aStopTimers();
+ try { _aRenderLive(await api('/arena/live')); } catch { /* keep last */ }
+ }, 30000);
+}
+
+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))}')">
+ ${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>
+ </div>
+ </a>`;
+ el.innerHTML = `
+ <h2><span class="arena-live-dot"></span> Live battles <small>${live.live_count} fighter${live.live_count === 1 ? '' : 's'} live now</small></h2>
+ <div class="arena-live-grid">
+ ${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>
+ ${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>` : ''}
+ </div>`;
+}
+
+function _aRenderList(fighters) {
+ const el = document.getElementById('arena-list');
+ 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))}')">
+ <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>
+ <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('');
+}
+
+// ── Fighter profile ──────────────────────────────────────────
+
+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>`;
+ return;
+ }
+ const p = f.persona, color = f.user.profile_color || '#8b5cf6';
+ 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-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>
+ </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>
+ </div>
+ <div class="arena-profile-power">
+ <div class="arena-power arena-power-lg"><b>${f.ratings.power}</b><small>POWER</small></div>
+ <div class="arena-record arena-record-lg">${f.record.wins}W – ${f.record.losses}L</div>
+ </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>
+ <div class="arena-lore">
+ <p class="arena-lore-text">${_aEsc(p.lore)}</p>
+ <div class="arena-moves">
+ <div class="arena-move"><span class="arena-move-kind">Signature</span><b>${_aEsc(p.signature_move?.name)}</b><span>${_aEsc(p.signature_move?.description)}</span></div>
+ <div class="arena-move"><span class="arena-move-kind">Special</span><b>${_aEsc(p.special?.name)}</b><span>${_aEsc(p.special?.description)}</span></div>
+ <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-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>
+ <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>
+ <section class="arena-challenge">
+ <h2>Pick an opponent</h2>
+ <div class="arena-opponents" id="arena-opponents">${_aSpinner('Scouting…')}</div>
+ </section>`;
+ // 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>';
+ } 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;
+ _arenaImagePoll = setInterval(async () => {
+ if (++tries > 20 || currentPage !== 'arena') return _aStopTimers();
+ try {
+ 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>`;
+ clearInterval(_arenaImagePoll); _arenaImagePoll = null;
+ }
+ } catch { /* */ }
+ }, 6000);
+ }
+}
+
+// ── Battle ───────────────────────────────────────────────────
+
+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 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>
+ <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>
+ </div>`;
+ 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-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-commentary" id="arena-commentary"></div>
+ <div class="arena-vote" id="arena-vote"></div>
+ <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>
+ <button class="btn btn-ghost" id="arena-rematch"><i class="fa-solid fa-dice"></i> Random rematch</button>
+ </div>
+ </div>
+ ${side(B, 'b')}
+ </div>`;
+ _aPlayBattle(battle);
+ document.getElementById('arena-share').addEventListener('click', async () => {
+ const url = `${location.origin}/arena/battle/${encodeURIComponent(A.user.username)}/${encodeURIComponent(B.user.username)}`;
+ 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-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)}`);
+ });
+}
+
+/** Reveal rounds one by one, then the finisher, verdict and the crowd vote. */
+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');
+ 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 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');
+ battle.rounds.forEach((r, i) => {
+ t += 1400;
+ at(t, () => {
+ 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' : ''}`);
+ document.querySelector(`.arena-side-${r.winner}`)?.classList.add('is-hit');
+ setTimeout(() => document.querySelector(`.arena-side-${r.winner}`)?.classList.remove('is-hit'), 600);
+ });
+ });
+ t += 1400;
+ 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');
+ _aRenderVote(battle);
+ });
+}
+
+function _aRenderVote(battle) {
+ const el = document.getElementById('arena-vote');
+ if (!el) return;
+ const A = battle.a, B = battle.b;
+ const render = (votes, outcome, yourVote) => {
+ const total = votes.a + votes.b;
+ const pa = total ? Math.round((votes.a / total) * 100) : 50;
+ const crowdLine = outcome.crowd ? `Crowd round → <b>${_aEsc((outcome.crowd === 'a' ? A : B).persona.fighter_name)}</b>` : 'Crowd round is still open — no votes yet';
+ const finalLine = outcome.winner
+ ? `<b>${_aEsc((outcome.winner === 'a' ? A : B).persona.fighter_name)}</b> wins ${outcome.a}–${outcome.b}${outcome.tiebreak ? ' (tiebreak: Power)' : ''}`
+ : 'Dead even — your vote decides it';
+ el.innerHTML = `
+ <div class="arena-vote-head">Who are you backing?</div>
+ <div class="arena-vote-buttons">
+ <button class="arena-vote-btn arena-vote-a ${yourVote === 'a' ? 'is-picked' : ''}" data-side="a">${_aEsc(A.persona.fighter_name)}</button>
+ <button class="arena-vote-btn arena-vote-b ${yourVote === 'b' ? 'is-picked' : ''}" data-side="b">${_aEsc(B.persona.fighter_name)}</button>
+ </div>
+ <div class="arena-vote-bar"><span style="width:${pa}%"></span></div>
+ <div class="arena-vote-tally"><span>${votes.a} vote${votes.a === 1 ? '' : 's'}</span><span>${votes.b} vote${votes.b === 1 ? '' : 's'}</span></div>
+ <div class="arena-vote-result">${crowdLine}<br>${finalLine}</div>`;
+ document.getElementById('arena-score-a').textContent = outcome.a;
+ document.getElementById('arena-score-b').textContent = outcome.b;
+ document.querySelectorAll('.arena-side').forEach(s => s.classList.remove('is-winner'));
+ if (outcome.winner) document.querySelector(`.arena-side-${outcome.winner}`)?.classList.add('is-winner');
+ el.querySelectorAll('.arena-vote-btn').forEach(btn => btn.addEventListener('click', async () => {
+ btn.disabled = true;
+ try {
+ const r = await api(`/arena/battle/${encodeURIComponent(A.user.username)}/${encodeURIComponent(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(battle.votes, battle.outcome, battle.your_vote || null);
+}
+
+window.loadArenaPage = loadArenaPage;
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+/**
+ * arena-job.js — keeps Arena personas (and, when enabled, portraits) warm for the roster
+ * so the tab loads instantly instead of generating on first view.
+ *
+ * Every 20 min: refresh up to BATCH stale personas (24 h TTL) for the highest-ranked
+ * fighters first, and up to IMAGE_BATCH stale portraits (7 d TTL). Bounded per tick so a
+ * big roster cannot burn the AI budget in one go; no-ops entirely while AI is off or over
+ * budget (the shared gate in server/ai/llm.js).
+ */
+'use strict';
+
+const arena = require('./arena-service');
+
+const INTERVAL_MS = 20 * 60 * 1000;
+const BATCH = 6;
+const IMAGE_BATCH = 2;
+let _timer = null;
+let _busy = false;
+
+async function tick() {
+ if (_busy || !arena.arenaEnabled() || !arena.aiOn()) return;
+ _busy = true;
+ try {
+ const db = require('../db/database');
+ const roster = arena.loadRoster();
+ let personas = 0, images = 0;
+ for (const userId of roster.order) {
+ if (personas >= BATCH && (images >= IMAGE_BATCH || !arena.imageGenAvailable())) break;
+ const row = db.get('SELECT persona_generated_at, image_generated_at, image_path FROM arena_profiles WHERE user_id = ?', [userId]) || {};
+ const personaStale = !row.persona_generated_at || Date.now() - Date.parse(row.persona_generated_at + 'Z') > 24 * 60 * 60 * 1000;
+ if (personaStale && personas < BATCH) {
+ try { await arena.generatePersona(userId); personas++; } catch (e) { console.warn('[Arena] job persona:', e.message); }
+ }
+ const imageStale = !row.image_path || !row.image_generated_at || Date.now() - Date.parse(row.image_generated_at + 'Z') > 7 * 24 * 60 * 60 * 1000;
+ if (imageStale && images < IMAGE_BATCH && arena.imageGenAvailable()) {
+ try { await arena.generateImage(userId); images++; } catch (e) { console.warn('[Arena] job image:', e.message); }
+ }
+ if (!arena.aiOn()) break; // budget ran out mid-tick
+ }
+ if (personas || images) console.log(`[Arena] job: ${personas} persona(s), ${images} portrait(s) refreshed`);
+ } finally {
+ _busy = false;
+ }
+}
+
+function start() {
+ if (_timer) return;
+ arena.ensureTables();
+ _timer = setInterval(() => tick().catch(e => console.warn('[Arena] job:', e.message)), INTERVAL_MS);
+ if (_timer.unref) _timer.unref();
+ setTimeout(() => tick().catch(() => {}), 90_000).unref?.();
+ console.log('[Arena] job started (every 20 min)');
+}
+
+function stop() { if (_timer) { clearInterval(_timer); _timer = null; } }
+
+module.exports = { start, stop, tick };
new file mode 100644
new file mode 100644
@@ -0,0 +1,823 @@
@@ -0,0 +1,823 @@
+/**
+ * OpenVibe.Live — Arena (streamer vs streamer)
+ *
+ * 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.
+ * 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.
+ * 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
+ * identity), and draws a stylised fighting-game character. Gated by the
+ * `ai_image_enabled` setting, the shared AI budget, and a 7-day cache.
+ * battles → a seeded simulation per matchup per day (same pair, same day → same
+ * rounds) with AI hype-caster commentary, plus a crowd vote that counts
+ * as the final round.
+ *
+ * Nothing here touches the AI unless AI is enabled and within budget (server/ai/llm.js);
+ * without it the tab still works with stats, templated commentary and avatar cards.
+ */
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const db = require('../db/database');
+const llm = require('../ai/llm');
+
+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 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 STAT_META = {
+ hype: { label: 'Hype', desc: 'peak concurrent viewers' },
+ grind: { label: 'Grind', desc: 'hours live' },
+ chat: { label: 'Chat', desc: 'chat messages per hour live' },
+ loyalty: { label: 'Loyalty', desc: 'followers + returning chatters' },
+ clutch: { label: 'Clutch', desc: 'clips + tips per hour' },
+ vibe: { label: 'Vibe', desc: 'average viewers' },
+};
+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' },
+];
+
+// ── Tables ───────────────────────────────────────────────────
+
+let _tablesReady = false;
+function ensureTables() {
+ if (_tablesReady) return;
+ db.run(`CREATE TABLE IF NOT EXISTS arena_profiles (
+ user_id INTEGER PRIMARY KEY,
+ stats_json TEXT,
+ persona_json TEXT,
+ persona_model TEXT,
+ persona_generated_at DATETIME,
+ image_path TEXT,
+ image_prompt TEXT,
+ image_model TEXT,
+ image_generated_at DATETIME,
+ image_error TEXT,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS arena_battles (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ a_user_id INTEGER NOT NULL,
+ b_user_id INTEGER NOT NULL,
+ day TEXT NOT NULL,
+ result_json TEXT NOT NULL,
+ commentary_json TEXT,
+ winner_user_id INTEGER,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(a_user_id, b_user_id, day)
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS arena_votes (
+ battle_id INTEGER NOT NULL,
+ voter_key TEXT NOT NULL,
+ side TEXT NOT NULL CHECK (side IN ('a', 'b')),
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(battle_id, voter_key)
+ )`);
+ db.run('CREATE INDEX IF NOT EXISTS idx_arena_battles_users ON arena_battles (a_user_id, b_user_id)');
+ try { fs.mkdirSync(ARENA_DIR, { recursive: true }); } catch { /* */ }
+ _tablesReady = true;
+}
+
+// ── Settings ─────────────────────────────────────────────────
+
+function setting(key, fallback = '') {
+ try { const v = db.getSetting(key); return v === undefined || v === null || v === '' ? fallback : v; } catch { return fallback; }
+}
+function boolSetting(key, fallback = false) {
+ const v = setting(key, null);
+ if (v === null) return fallback;
+ return v === true || v === 'true' || v === 1 || v === '1';
+}
+function arenaEnabled() { return boolSetting('arena_enabled', true); }
+function aiOn() { try { return llm.isEnabled() && llm.withinBudget(); } catch { return false; } }
+function imageGenAvailable() {
+ if (!aiOn() || !boolSetting('ai_image_enabled', false)) return false;
+ try { return llm.resolveProvider('vision').kind === 'openai'; } catch { return false; }
+}
+
+// ── Raw stats ────────────────────────────────────────────────
+
+function activeStreamerIds() {
+ return db.all(`
+ SELECT DISTINCT s.user_id
+ FROM streams s JOIN users u ON u.id = s.user_id
+ WHERE s.duration_seconds > 0
+ AND s.started_at >= datetime('now', ?)
+ AND COALESCE(u.is_banned, 0) = 0
+ `, [`-${ACTIVE_DAYS} days`]).map(r => r.user_id);
+}
+
+function rawStatsFor(userId) {
+ const win = `-${STATS_WINDOW_DAYS} days`;
+ const agg = db.get(`
+ SELECT COUNT(*) AS streams,
+ COALESCE(SUM(s.duration_seconds), 0) / 3600.0 AS hours,
+ COALESCE(MAX(s.peak_viewers), 0) AS peak_viewers,
+ COALESCE(AVG(sa.avg_viewers), 0) AS avg_viewers,
+ COALESCE(SUM(sa.total_messages), 0) AS messages,
+ COALESCE(SUM(sa.unique_chatters), 0) AS unique_chatters,
+ COALESCE(SUM(sa.total_watch_minutes), 0) AS watch_minutes,
+ COALESCE(SUM(sa.clips_created), 0) AS clips,
+ MAX(s.ended_at) AS last_live_at
+ 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', ?)
+ `, [userId, win]) || {};
+ const allTime = db.get(`
+ SELECT COUNT(*) AS streams, COALESCE(SUM(duration_seconds), 0) / 3600.0 AS hours, COALESCE(MAX(peak_viewers), 0) AS peak_viewers
+ FROM streams WHERE user_id = ? AND duration_seconds > 0
+ `, [userId]) || {};
+ const followers = db.get('SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ?', [userId])?.n || 0;
+ const tips = db.get(`SELECT COALESCE(SUM(amount), 0) AS n FROM transactions WHERE to_user_id = ? AND type = 'donation' AND created_at >= datetime('now', ?)`, [userId, win])?.n || 0;
+ const category = db.get(`
+ SELECT category, COUNT(*) AS n FROM streams
+ WHERE user_id = ? AND duration_seconds > 0 AND category IS NOT NULL AND category != ''
+ GROUP BY category ORDER BY n DESC LIMIT 1
+ `, [userId])?.category || null;
+ const hours = Math.max(Number(agg.hours) || 0, 0.1);
+ return {
+ window_days: STATS_WINDOW_DAYS,
+ streams: agg.streams || 0,
+ hours: Number((agg.hours || 0).toFixed(1)),
+ peak_viewers: agg.peak_viewers || 0,
+ avg_viewers: Number((agg.avg_viewers || 0).toFixed(1)),
+ messages: agg.messages || 0,
+ messages_per_hour: Number((agg.messages / hours).toFixed(1)),
+ unique_chatters: agg.unique_chatters || 0,
+ 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,
+ followers,
+ loyalty_score: followers + (agg.unique_chatters || 0) / 4,
+ all_time_hours: Number((allTime.hours || 0).toFixed(1)),
+ all_time_peak: allTime.peak_viewers || 0,
+ all_time_streams: allTime.streams || 0,
+ last_live_at: agg.last_live_at || null,
+ category,
+ };
+}
+
+const METRIC_FOR_STAT = {
+ hype: (r) => r.peak_viewers,
+ grind: (r) => r.hours,
+ chat: (r) => r.messages_per_hour,
+ loyalty: (r) => r.loyalty_score,
+ clutch: (r) => r.clutch_per_hour,
+ vibe: (r) => r.avg_viewers,
+};
+
+/**
+ * Percentile ratings across a roster: rating = 40 + 59 × percentile. Ties share a rank,
+ * a roster of one is a flat 70 across the board (no population to compare against).
+ */
+function computeRatings(rosterRaw) {
+ const ids = Object.keys(rosterRaw);
+ const out = {};
+ for (const id of ids) out[id] = {};
+ for (const stat of STAT_KEYS) {
+ const values = ids.map(id => METRIC_FOR_STAT[stat](rosterRaw[id]) || 0);
+ const sorted = [...values].sort((a, b) => a - b);
+ ids.forEach((id, i) => {
+ const v = values[i];
+ let pct;
+ if (ids.length < 2) pct = 0.5;
+ else {
+ const below = sorted.filter(x => x < v).length;
+ const equal = sorted.filter(x => x === v).length;
+ pct = (below + (equal - 1) / 2) / (ids.length - 1);
+ }
+ 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));
+ }
+ return out;
+}
+
+// ── Roster cache (stats are cheap but not free — recompute every few minutes) ──
+
+let _roster = null; // { at, byId: { [userId]: { user, raw, ratings } }, order: [userId…] }
+const ROSTER_TTL_MS = 3 * 60 * 1000;
+
+function loadRoster(force = false) {
+ if (!force && _roster && Date.now() - _roster.at < ROSTER_TTL_MS) return _roster;
+ ensureTables();
+ const ids = activeStreamerIds();
+ const rawById = {};
+ for (const id of ids) rawById[id] = rawStatsFor(id);
+ const ratings = computeRatings(rawById);
+ const byId = {};
+ for (const id of ids) {
+ const user = db.getUserById(id);
+ if (!user) continue;
+ 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);
+ _roster = { at: Date.now(), byId, order };
+ return _roster;
+}
+
+function publicUser(u) {
+ return {
+ id: u.id,
+ username: u.username,
+ display_name: u.display_name || u.username,
+ avatar_url: u.avatar_url || null,
+ profile_color: u.profile_color || null,
+ bio: u.bio || '',
+ };
+}
+
+// ── Records (W/L from stored battles) ────────────────────────
+
+function recordFor(userId) {
+ const r = db.get(`
+ SELECT
+ 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 = ?
+ `, [userId, userId, userId, userId]) || {};
+ return { wins: r.wins || 0, losses: r.losses || 0 };
+}
+
+// ── Persona (AI) ─────────────────────────────────────────────
+
+function profileRow(userId) {
+ ensureTables();
+ return db.get('SELECT * FROM arena_profiles WHERE user_id = ?', [userId]) || null;
+}
+
+function parseJson(text, fallback = null) {
+ if (!text) return fallback;
+ 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;
+}
+
+/** Everything the site knows about a streamer that a writer could riff on. */
+function gatherContext(userId) {
+ const ctx = {};
+ try { ctx.overview = db.getStreamerOverview(userId)?.overview || null; } catch { /* */ }
+ try {
+ const mems = db.all('SELECT description, tags FROM stream_memories WHERE user_id = ? ORDER BY captured_at DESC LIMIT 4', [userId]);
+ ctx.memories = mems.map(m => m.description).filter(Boolean);
+ } catch { ctx.memories = []; }
+ try {
+ const chatAi = db.getChatAiSummary('user', userId, 'rolling');
+ 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
+ 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 }));
+ } catch {
+ 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 = []; }
+ return ctx;
+}
+
+const PERSONA_SCHEMA = {
+ name: 'arena_persona',
+ schema: {
+ type: 'object',
+ additionalProperties: false,
+ required: ['fighter_name', 'title', 'class', 'element', 'signature_move', 'special', 'weakness', 'taunt', 'lore', 'catchphrase', 'entrance_music', 'stat_quips'],
+ properties: {
+ fighter_name: { type: 'string', description: 'Arena ring name, 2–5 words, based on the streamer' },
+ title: { type: 'string', description: 'Epithet like "The Midnight Menace of Cozy Corner"' },
+ class: { type: 'string', description: 'Fighting-game archetype, e.g. Grappler, Zoner, Rushdown, Summoner, Bard, Tank' },
+ element: { type: 'string', description: 'Single word element/vibe, e.g. Caffeine, Static, Lo-fi, Chaos' },
+ signature_move: { type: 'object', additionalProperties: false, required: ['name', 'description'], properties: { name: { type: 'string' }, description: { type: 'string' } } },
+ special: { type: 'object', additionalProperties: false, required: ['name', 'description'], properties: { name: { type: 'string' }, description: { type: 'string' } } },
+ weakness: { type: 'string' },
+ taunt: { type: 'string', description: 'One-line trash talk they would say to an opponent' },
+ lore: { type: 'string', description: '2–3 sentence character-select bio' },
+ catchphrase: { type: 'string' },
+ entrance_music: { type: 'string', description: 'Invented track name + fake artist' },
+ stat_quips: {
+ type: 'object', additionalProperties: false, required: STAT_KEYS,
+ properties: Object.fromEntries(STAT_KEYS.map(k => [k, { type: 'string', description: `≤ 8 words explaining their ${k} rating in character` }])),
+ },
+ },
+ },
+};
+
+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.`;
+
+async function generatePersona(userId, { force = false } = {}) {
+ ensureTables();
+ const row = profileRow(userId);
+ if (!force && personaIsFresh(row)) return parseJson(row.persona_json);
+ if (!aiOn()) return row ? parseJson(row.persona_json) : null;
+
+ const roster = loadRoster();
+ const entry = roster.byId[userId] || { user: publicUser(db.getUserById(userId) || { id: userId, username: `user${userId}` }), raw: rawStatsFor(userId), ratings: null };
+ const ctx = gatherContext(userId);
+ const stats = entry.ratings || Object.fromEntries(STAT_KEYS.map(k => [k, 70]).concat([['power', 70]]));
+ const facts = {
+ name: entry.user.display_name,
+ handle: entry.user.username,
+ category: entry.raw.category,
+ ratings: stats,
+ numbers: {
+ 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,
+ },
+ 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,
+ };
+ const r = await llm.complete({
+ 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,
+ });
+ const persona = r && r.json && r.json.fighter_name ? r.json : null;
+ if (!persona) {
+ console.warn(`[Arena] persona generation failed for user ${userId}`);
+ return row ? parseJson(row.persona_json) : null;
+ }
+ db.run(`INSERT INTO arena_profiles (user_id, persona_json, persona_model, persona_generated_at, updated_at)
+ VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+ ON CONFLICT(user_id) DO UPDATE SET persona_json = excluded.persona_json, persona_model = excluded.persona_model,
+ persona_generated_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP`,
+ [userId, JSON.stringify(persona), r.model || null]);
+ console.log(`[Arena] persona for ${entry.user.username}: "${persona.fighter_name}" (${persona.class})`);
+ return persona;
+}
+
+/** Non-AI stand-in so the tab is never empty. */
+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];
+ return {
+ fighter_name: entry.user.display_name,
+ title: `The ${STAT_META[best].label} Specialist`,
+ class: cls,
+ element: entry.raw.category ? entry.raw.category.replace(/[-_]/g, ' ') : 'Static',
+ signature_move: { name: `${STAT_META[best].label} Surge`, description: `Turns ${STAT_META[best].desc} into raw damage.` },
+ special: { name: 'Go Live', description: 'Hits the button. The arena fills up.' },
+ weakness: 'Sleep schedules.',
+ taunt: 'Chat, are you seeing this?',
+ lore: `${entry.user.display_name} shows up, streams, and leaves the leaderboard slightly different than they found it.`,
+ catchphrase: 'Let him cook.',
+ entrance_music: 'Untitled Loop (feat. Notification Sound)',
+ stat_quips: Object.fromEntries(STAT_KEYS.map(k => [k, STAT_META[k].desc])),
+ _fallback: true,
+ };
+}
+
+// ── Image (AI, optional) ─────────────────────────────────────
+
+function imageIsFresh(row) {
+ if (!row || !row.image_path || !row.image_generated_at) return false;
+ if (!fs.existsSync(path.join(ARENA_DIR, path.basename(row.image_path)))) return false;
+ return Date.now() - Date.parse(row.image_generated_at + 'Z') < IMAGE_TTL_MS;
+}
+
+function imageUrlFor(row) {
+ if (!row || !row.image_path) return null;
+ const base = path.basename(row.image_path);
+ if (!fs.existsSync(path.join(ARENA_DIR, base))) return null;
+ return `/data/arena/${base}`;
+}
+
+/** Latest visual of the streamer's stream: live thumb if live, else newest VOD thumbnail. */
+function latestThumbnailFor(userId) {
+ try {
+ const live = db.get('SELECT id FROM streams WHERE user_id = ? AND is_live = 1 ORDER BY started_at DESC LIMIT 1', [userId]);
+ if (live) {
+ const thumbs = require('../media-proxy/live-thumbs');
+ const url = thumbs.getCurrentLiveThumbnailUrl(live.id);
+ if (url) {
+ const local = path.resolve('./data/live-thumbs', path.basename(url));
+ if (fs.existsSync(local)) return local;
+ return url.startsWith('http') ? url : null;
+ }
+ }
+ } catch { /* */ }
+ try {
+ const v = db.get('SELECT thumbnail_url FROM vods WHERE user_id = ? AND thumbnail_url IS NOT NULL AND is_public = 1 ORDER BY created_at DESC LIMIT 1', [userId]);
+ if (v && /^https?:\/\//i.test(v.thumbnail_url)) return v.thumbnail_url;
+ } catch { /* */ }
+ return null;
+}
+
+const SCENE_SYSTEM = 'Describe this stream thumbnail as a SCENE for an illustrator in ≤ 60 words: setting, objects, lighting, colours, mood, what activity is happening. Do NOT describe any person\'s face, body, skin, hair, age, gender or identity — refer to a person only as "the host" if at all. Plain text only.';
+
+const _imageInFlight = new Map(); // userId → Promise
+
+async function generateImage(userId, { force = false } = {}) {
+ ensureTables();
+ const row = profileRow(userId);
+ if (!force && imageIsFresh(row)) return imageUrlFor(row);
+ if (!imageGenAvailable()) return imageUrlFor(row);
+ if (_imageInFlight.has(userId)) return _imageInFlight.get(userId);
+
+ const task = (async () => {
+ const entry = loadRoster().byId[userId];
+ const persona = parseJson(row?.persona_json) || (entry ? fallbackPersona(entry) : null);
+ if (!persona) return null;
+ let scene = '';
+ const thumb = latestThumbnailFor(userId);
+ if (thumb) {
+ try {
+ const d = await llm.complete({ role: 'vision', kind: 'arena_scene', source: 'arena', ownerUserId: userId, system: SCENE_SYSTEM, user: 'Describe the scene.', image: thumb, maxTokens: 120, temperature: 0.4, timeoutMs: 30000 });
+ scene = (d && d.text || '').trim();
+ } catch { scene = ''; }
+ }
+ const color = entry?.user?.profile_color || '#8b5cf6';
+ const prompt = [
+ `Fighting-game character-select portrait of an original stylised hero called "${persona.fighter_name}" — ${persona.title}.`,
+ `Class: ${persona.class}. Element: ${persona.element}. Signature move: ${persona.signature_move?.name} (${persona.signature_move?.description}).`,
+ entry?.raw?.category ? `Costume and props inspired by ${String(entry.raw.category).replace(/[-_]/g, ' ')} streaming.` : '',
+ scene ? `Background inspired by this scene: ${scene}` : 'Background: dark neon arena.',
+ `Colour palette led by ${color}. Bold comic-book line art, dramatic rim light, dynamic pose, three-quarter view, waist up.`,
+ 'Fictional character, not a real person. No text, no letters, no logos, no watermark.',
+ ].filter(Boolean).join(' ');
+
+ const p = llm.resolveProvider('vision');
+ const model = String(setting('ai_image_model', 'gpt-image-1'));
+ const body = { model, prompt, n: 1, size: '1024x1024' };
+ if (/^dall-e/i.test(model)) body.response_format = 'b64_json';
+ else body.quality = String(setting('ai_image_quality', 'low'));
+ const started = Date.now();
+ let b64 = null;
+ try {
+ const res = await fetch(`${p.baseUrl}/images/generations`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...(p.apiKey ? { Authorization: `Bearer ${p.apiKey}` } : {}) },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(120000),
+ });
+ const j = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error((j.error && (j.error.message || j.error)) || `HTTP ${res.status}`);
+ const item = j.data && j.data[0];
+ if (item?.b64_json) b64 = item.b64_json;
+ else if (item?.url) {
+ const r2 = await fetch(item.url, { signal: AbortSignal.timeout(60000) });
+ b64 = Buffer.from(await r2.arrayBuffer()).toString('base64');
+ }
+ if (!b64) throw new Error('no image in response');
+ } catch (err) {
+ console.warn(`[Arena] image generation failed for user ${userId}:`, err.message);
+ db.run('INSERT INTO arena_profiles (user_id, image_error, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(user_id) DO UPDATE SET image_error = excluded.image_error, updated_at = CURRENT_TIMESTAMP', [userId, err.message.slice(0, 300)]);
+ return imageUrlFor(row);
+ }
+ const file = `u${userId}-${Date.now().toString(36)}.png`;
+ fs.writeFileSync(path.join(ARENA_DIR, file), Buffer.from(b64, 'base64'));
+ if (row?.image_path) { try { fs.unlinkSync(path.join(ARENA_DIR, path.basename(row.image_path))); } catch { /* */ } }
+ db.run(`INSERT INTO arena_profiles (user_id, image_path, image_prompt, image_model, image_generated_at, image_error, updated_at)
+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, NULL, CURRENT_TIMESTAMP)
+ ON CONFLICT(user_id) DO UPDATE SET image_path = excluded.image_path, image_prompt = excluded.image_prompt, image_model = excluded.image_model,
+ image_generated_at = CURRENT_TIMESTAMP, image_error = NULL, updated_at = CURRENT_TIMESTAMP`,
+ [userId, file, prompt, model]);
+ try {
+ db.recordAiUsage({ kind: 'arena_image', model, cost_usd: Number(setting('ai_image_cost_usd', 0.011)) || 0, owner_user_id: userId, source: 'arena', role: 'image', provider: 'shared', latency_ms: Date.now() - started });
+ } catch { /* */ }
+ console.log(`[Arena] portrait generated for user ${userId} (${model}, ${Date.now() - started} ms)`);
+ return `/data/arena/${file}`;
+ })().finally(() => _imageInFlight.delete(userId));
+ _imageInFlight.set(userId, task);
+ return task;
+}
+
+// ── Fighter cards ────────────────────────────────────────────
+
+function resolveUser(usernameOrId) {
+ if (/^\d+$/.test(String(usernameOrId))) return db.getUserById(Number(usernameOrId));
+ return db.getUserByUsername(String(usernameOrId));
+}
+
+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 } = {}) {
+ 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 {
+ user: entry.user,
+ rank,
+ roster_size: roster.order.length,
+ ratings: entry.ratings,
+ stat_meta: STAT_META,
+ raw: includeRaw ? entry.raw : undefined,
+ persona,
+ persona_is_fallback: !!persona._fallback,
+ persona_generated_at: row?.persona_generated_at || null,
+ image_url: imageUrlFor(row),
+ image_pending: false,
+ record: recordFor(userId),
+ live: isLive(userId),
+ };
+}
+
+async function getFighter(usernameOrId, { generate = true } = {}) {
+ const user = resolveUser(usernameOrId);
+ 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()) {
+ 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()) {
+ // Fire and forget — the client polls until image_url is present.
+ generateImage(user.id).catch(() => {});
+ }
+ }
+ const card = cardFor(user.id, roster);
+ card.image_pending = !card.image_url && _imageInFlight.has(user.id);
+ card.image_generation = imageGenAvailable() ? 'ai' : 'off';
+ return card;
+}
+
+function listFighters() {
+ const roster = loadRoster();
+ return roster.order.map(id => {
+ 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_is_fallback: c.persona_is_fallback,
+ category: roster.byId[id].raw.category,
+ last_live_at: roster.byId[id].raw.last_live_at,
+ };
+ });
+}
+
+// ── Battles ──────────────────────────────────────────────────
+
+function today() { return new Date().toISOString().slice(0, 10); }
+
+function seededRandom(seedText) {
+ let h = parseInt(crypto.createHash('sha256').update(seedText).digest('hex').slice(0, 8), 16) >>> 0;
+ return () => {
+ h += 0x6D2B79F5;
+ let t = h;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+/** Same pair on the same day → identical rounds. Order-independent. */
+function simulateRounds(aRatings, bRatings, seedText) {
+ const rand = seededRandom(seedText);
+ const rounds = 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) {
+ const r = db.get(`SELECT SUM(CASE WHEN side = 'a' THEN 1 ELSE 0 END) AS a, SUM(CASE WHEN side = 'b' THEN 1 ELSE 0 END) AS b FROM arena_votes WHERE battle_id = ?`, [battleId]) || {};
+ return { a: r.a || 0, b: r.b || 0 };
+}
+
+function scoreBattle(rounds, votes, aPower, bPower) {
+ let a = rounds.filter(r => r.winner === 'a').length;
+ let b = rounds.filter(r => r.winner === 'b').length;
+ let crowd = null;
+ if (votes.a !== votes.b) { crowd = votes.a > votes.b ? 'a' : 'b'; if (crowd === 'a') a++; else b++; }
+ let winner = a === b ? (aPower === bPower ? null : (aPower > bPower ? 'a' : 'b')) : (a > b ? 'a' : 'b');
+ return { a, b, crowd, winner, tiebreak: a === b && winner ? 'power' : null };
+}
+
+const COMMENTARY_SCHEMA = {
+ name: 'arena_commentary',
+ schema: {
+ type: 'object', additionalProperties: false,
+ 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' } },
+ 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.`;
+
+function templateCommentary(A, B, rounds, outcome) {
+ const name = (s) => (s === 'a' ? A.persona.fighter_name : B.persona.fighter_name);
+ return {
+ intro: `${A.persona.fighter_name} versus ${B.persona.fighter_name}. The arena is loud, the chat is louder.`,
+ rounds: rounds.map(r => `${r.label}: ${name(r.winner)} takes it${r.upset ? ' — an upset!' : ''} (${r.a}–${r.b}).`),
+ finisher: outcome.winner ? `${name(outcome.winner)} closes it out with ${(outcome.winner === 'a' ? A : B).persona.signature_move.name}.` : 'Nobody lands the finisher. The crowd decides.',
+ verdict: outcome.winner ? `${name(outcome.winner)} wins ${outcome.a}–${outcome.b}${outcome.tiebreak ? ' on Power' : ''}.` : 'Dead even — vote to break the tie.',
+ _fallback: true,
+ };
+}
+
+async function getBattle(aName, bName, { generate = true } = {}) {
+ const ua = resolveUser(aName), ub = resolveUser(bName);
+ if (!ua || !ub || ua.id === ub.id) return null;
+ const roster = loadRoster();
+ if (!roster.byId[ua.id] || !roster.byId[ub.id]) return { error: 'both fighters must be on the roster' };
+ // Canonical order so a-vs-b and b-vs-a are the same battle.
+ const [ida, idb] = ua.id < ub.id ? [ua.id, ub.id] : [ub.id, ua.id];
+ const day = today();
+ 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()) {
+ for (const id of [ida, idb]) {
+ if (!personaIsFresh(profileRow(id))) { try { await generatePersona(id); } catch { /* */ } }
+ }
+ }
+ const A = cardFor(ida, roster, { includeRaw: false });
+ const B = cardFor(idb, roster, { includeRaw: false });
+
+ if (!row) {
+ const rounds = simulateRounds(A.ratings, B.ratings, `${ida}:${idb}:${day}`);
+ db.run('INSERT OR IGNORE INTO arena_battles (a_user_id, b_user_id, day, result_json) VALUES (?, ?, ?, ?)', [ida, idb, day, JSON.stringify({ rounds })]);
+ row = db.get('SELECT * FROM arena_battles WHERE a_user_id = ? AND b_user_id = ? AND day = ?', [ida, idb, day]);
+ }
+ const rounds = parseJson(row.result_json, { rounds: [] }).rounds;
+ const votes = tallyVotes(row.id);
+ const outcome = scoreBattle(rounds, votes, A.ratings.power, B.ratings.power);
+ const winnerId = outcome.winner === 'a' ? ida : outcome.winner === 'b' ? idb : null;
+ if (row.winner_user_id !== winnerId) db.run('UPDATE arena_battles SET winner_user_id = ? WHERE id = ?', [winnerId, row.id]);
+
+ let commentary = parseJson(row.commentary_json);
+ if (!commentary && generate && aiOn()) {
+ try {
+ 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 },
+ 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,
+ });
+ if (r && r.json && Array.isArray(r.json.rounds)) {
+ commentary = r.json;
+ db.run('UPDATE arena_battles SET commentary_json = ? WHERE id = ?', [JSON.stringify(commentary), row.id]);
+ }
+ } catch (e) { console.warn('[Arena] commentary:', e.message); }
+ }
+ if (!commentary) commentary = templateCommentary(A, B, rounds, outcome);
+
+ return {
+ id: row.id, day, a: A, b: B, rounds, votes, outcome,
+ winner: winnerId ? (winnerId === ida ? A.user : B.user) : null,
+ commentary,
+ commentary_is_fallback: !!commentary._fallback,
+ };
+}
+
+function castVote(battleId, voterKey, side) {
+ if (!['a', 'b'].includes(side)) throw new Error('side must be a or b');
+ ensureTables();
+ const battle = db.get('SELECT * FROM arena_battles WHERE id = ?', [battleId]);
+ if (!battle) throw new Error('battle not found');
+ if (battle.day !== today()) throw new Error('voting closed for this battle');
+ db.run('INSERT INTO arena_votes (battle_id, voter_key, side) VALUES (?, ?, ?) ON CONFLICT(battle_id, voter_key) DO UPDATE SET side = excluded.side, created_at = CURRENT_TIMESTAMP', [battleId, voterKey, side]);
+ const votes = tallyVotes(battleId);
+ const rounds = parseJson(battle.result_json, { rounds: [] }).rounds;
+ const roster = loadRoster();
+ const pa = roster.byId[battle.a_user_id]?.ratings.power ?? 0, pb = roster.byId[battle.b_user_id]?.ratings.power ?? 0;
+ const outcome = scoreBattle(rounds, votes, pa, pb);
+ const winnerId = outcome.winner === 'a' ? battle.a_user_id : outcome.winner === 'b' ? battle.b_user_id : null;
+ db.run('UPDATE arena_battles SET winner_user_id = ? WHERE id = ?', [winnerId, battleId]);
+ return { votes, outcome, your_side: side };
+}
+
+function voterKeyFor(req) {
+ if (req.user && req.user.id) return `user:${req.user.id}`;
+ const ip = String(req.ip || req.headers['x-forwarded-for'] || req.socket?.remoteAddress || '');
+ const salt = String(setting('arena_vote_salt', '') || process.env.JWT_SECRET || 'arena');
+ return `anon:${crypto.createHash('sha256').update(ip + '|' + salt).digest('hex').slice(0, 24)}`;
+}
+
+// ── Live matchups ────────────────────────────────────────────
+
+function getLiveMatchups() {
+ const roster = loadRoster();
+ let live = [];
+ try { live = db.getLiveStreams() || []; } catch { live = []; }
+ const byUser = new Map();
+ for (const s of live) {
+ if (!roster.byId[s.user_id]) continue;
+ const cur = byUser.get(s.user_id);
+ if (!cur || (s.viewer_count || 0) > (cur.viewer_count || 0)) byUser.set(s.user_id, s);
+ }
+ 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 });
+ 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,
+ };
+ }).sort((x, y) => y.ratings.power - x.ratings.power);
+ // Pair neighbours on the power ladder so live fights are close ones.
+ const matchups = [];
+ for (let i = 0; i + 1 < fighters.length; i += 2) matchups.push({ a: fighters[i], b: fighters[i + 1] });
+ const odd = fighters.length % 2 ? fighters[fighters.length - 1] : null;
+ return { live_count: fighters.length, matchups, waiting: odd };
+}
+
+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 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;
+ return {
+ enabled: arenaEnabled(),
+ ai: aiOn(),
+ image_generation: imageGenAvailable(),
+ image_model: imageGenAvailable() ? String(setting('ai_image_model', 'gpt-image-1')) : null,
+ roster: roster.order.length,
+ personas: counts.personas || 0,
+ images: counts.images || 0,
+ battles, votes,
+ active_days: ACTIVE_DAYS,
+ };
+}
+
+module.exports = {
+ ensureTables,
+ arenaEnabled,
+ aiOn,
+ imageGenAvailable,
+ loadRoster,
+ listFighters,
+ getFighter,
+ generatePersona,
+ generateImage,
+ getBattle,
+ castVote,
+ voterKeyFor,
+ getLiveMatchups,
+ status,
+ STAT_KEYS,
+ STAT_META,
+ ROUNDS,
+ ARENA_DIR,
+ // pure helpers (tests)
+ _computeRatings: computeRatings,
+ _simulateRounds: simulateRounds,
+ _scoreBattle: scoreBattle,
+ _seededRandom: seededRandom,
+ _fallbackPersona: fallbackPersona,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -0,0 +1,110 @@
+/**
+ * OpenVibe.Live — Arena API (mounted at /api/arena)
+ *
+ * GET /fighters leaderboard (roster ranked by POWER)
+ * GET /fighters/:user fighter card (ratings, persona, portrait, record)
+ * POST /fighters/:user/refresh admin: regenerate persona (+ portrait)
+ * GET /battle/:a/:b today's battle for the pair (creates it on first view)
+ * POST /battle/:a/:b/vote {side} crowd vote — one per user (or per IP when anonymous)
+ * GET /live currently-live fighters paired up
+ * GET /status feature status (AI on? image gen? roster size)
+ */
+'use strict';
+
+const express = require('express');
+const { requireAuth, optionalAuth } = require('../auth/auth');
+const permissions = require('../auth/permissions');
+const arena = require('./arena-service');
+
+const router = express.Router();
+
+function gate(req, res, next) {
+ if (!arena.arenaEnabled()) return res.status(404).json({ error: 'Arena is disabled' });
+ next();
+}
+router.use(gate);
+
+router.get('/status', (req, res) => {
+ try { res.json(arena.status()); }
+ catch (err) { console.error('[Arena] status:', err.message); res.status(500).json({ error: 'Arena unavailable' }); }
+});
+
+router.get('/fighters', (req, res) => {
+ try {
+ res.set('Cache-Control', 'public, max-age=60');
+ res.json({ fighters: arena.listFighters(), ai: arena.aiOn(), image_generation: arena.imageGenAvailable() });
+ } catch (err) {
+ console.error('[Arena] fighters:', err.message);
+ res.status(500).json({ error: 'Failed to load the roster' });
+ }
+});
+
+router.get('/live', (req, res) => {
+ try {
+ res.set('Cache-Control', 'no-store');
+ res.json(arena.getLiveMatchups());
+ } catch (err) {
+ console.error('[Arena] live:', err.message);
+ res.status(500).json({ error: 'Failed to load live matchups' });
+ }
+});
+
+router.get('/fighters/:user', async (req, res) => {
+ try {
+ const generate = req.query.generate !== '0';
+ const card = await arena.getFighter(req.params.user, { generate });
+ if (!card) return res.status(404).json({ error: 'No such fighter' });
+ res.set('Cache-Control', 'no-store');
+ res.json(card);
+ } catch (err) {
+ console.error('[Arena] fighter:', err.message);
+ res.status(500).json({ error: 'Failed to load fighter' });
+ }
+});
+
+router.post('/fighters/:user/refresh', requireAuth, permissions.requireAdmin, async (req, res) => {
+ try {
+ const card = await arena.getFighter(req.params.user, { generate: false });
+ if (!card || card.not_on_roster) return res.status(404).json({ error: 'No such fighter on the roster' });
+ const persona = await arena.generatePersona(card.user.id, { force: true });
+ let image = null;
+ if (req.body?.image !== false && arena.imageGenAvailable()) image = await arena.generateImage(card.user.id, { force: true });
+ res.json({ ok: true, persona, image_url: image });
+ } catch (err) {
+ console.error('[Arena] refresh:', err.message);
+ res.status(500).json({ error: 'Refresh failed' });
+ }
+});
+
+router.get('/battle/:a/:b', optionalAuth, async (req, res) => {
+ try {
+ const battle = await arena.getBattle(req.params.a, req.params.b, { generate: req.query.generate !== '0' });
+ if (!battle) return res.status(404).json({ error: 'Pick two different fighters' });
+ if (battle.error) return res.status(400).json({ error: battle.error });
+ let yourVote = null;
+ try {
+ const key = arena.voterKeyFor(req);
+ const row = require('../db/database').get('SELECT side FROM arena_votes WHERE battle_id = ? AND voter_key = ?', [battle.id, key]);
+ yourVote = row ? row.side : null;
+ } catch { /* */ }
+ res.set('Cache-Control', 'no-store');
+ res.json({ ...battle, your_vote: yourVote });
+ } catch (err) {
+ console.error('[Arena] battle:', err.message);
+ res.status(500).json({ error: 'The arena lights went out. Try again.' });
+ }
+});
+
+router.post('/battle/:a/:b/vote', optionalAuth, async (req, res) => {
+ try {
+ const battle = await arena.getBattle(req.params.a, req.params.b, { generate: false });
+ if (!battle || battle.error) return res.status(404).json({ error: 'No such battle' });
+ const side = String(req.body?.side || '');
+ const result = arena.castVote(battle.id, arena.voterKeyFor(req), side);
+ res.json(result);
+ } catch (err) {
+ res.status(400).json({ error: err.message });
+ }
+});
+
+module.exports = router;
@@ -462,6 +462,12 @@ app.use(express.static(path.join(__dirname, '../public'), { setHeaders: (res, fi
@@ -462,6 +462,12 @@ app.use(express.static(path.join(__dirname, '../public'), { setHeaders: (res, fi
// Serve locally-cached song-request media files (media player page)
// Serve locally-cached song-request media files (media player page)
app.use('/media', express.static(path.resolve('./data/media')));
app.use('/media', express.static(path.resolve('./data/media')));
+// Arena portraits (AI-generated fighting-game character art, see server/arena)
+app.use('/data/arena', express.static(path.resolve(process.env.ARENA_IMAGE_PATH || './data/arena'), {
+ maxAge: '7d', immutable: true, index: false, dotfiles: 'deny',
+ setHeaders: (res) => res.setHeader('Content-Type', 'image/png'),
+}));
+
// OpenVibe.Media → Live webhook (vod.ready / clip.ready …). Mounted BEFORE the
// OpenVibe.Media → Live webhook (vod.ready / clip.ready …). Mounted BEFORE the
// /internal router because it authenticates with an HMAC signature, not X-Internal-Key.
// /internal router because it authenticates with an HMAC signature, not X-Internal-Key.
app.post('/internal/media-webhook', require('./media-proxy/webhook'));
app.post('/internal/media-webhook', require('./media-proxy/webhook'));
@@ -527,6 +533,7 @@ app.use('/api/vods', vodRoutes);
@@ -527,6 +533,7 @@ app.use('/api/vods', vodRoutes);
app.use('/api/clips', clipRoutes);
app.use('/api/clips', clipRoutes);
app.use('/api/chat-ai', require('./ai/chat-ai-routes'));
app.use('/api/chat-ai', require('./ai/chat-ai-routes'));
app.use('/api/easter-egg', require('./ai/easter-egg-routes'));
app.use('/api/easter-egg', require('./ai/easter-egg-routes'));
+app.use('/api/arena', require('./arena/routes')); // streamer vs streamer (docs/arena.md)
app.use('/api/comments', commentRoutes);
app.use('/api/comments', commentRoutes);
app.use('/api/controls', controlRoutes);
app.use('/api/controls', controlRoutes);
app.use('/api/onvif', onvifRoutes);
app.use('/api/onvif', onvifRoutes);
@@ -1020,6 +1027,7 @@ async function start() {
@@ -1020,6 +1027,7 @@ async function start() {
try { require('./ai/ai-moments-job').start(); } catch (e) { console.warn('[AI] moments job not started:', e.message); }
try { require('./ai/ai-moments-job').start(); } catch (e) { console.warn('[AI] moments job not started:', e.message); }
try { require('./ai/auto-clip-job').start(); } catch (e) { console.warn('[AI] auto-clip job not started:', e.message); }
try { require('./ai/auto-clip-job').start(); } catch (e) { console.warn('[AI] auto-clip job not started:', e.message); }
try { require('./ai/easter-egg-job').start(); } catch (e) { console.warn('[AI] easter-egg job not started:', e.message); }
try { require('./ai/easter-egg-job').start(); } catch (e) { console.warn('[AI] easter-egg job not started:', e.message); }
+ try { require('./arena/arena-job').start(); } catch (e) { console.warn('[Arena] job not started:', e.message); }
// Continuous audio → stream_timeline_events. Gated behind ai_timeline_enabled
// Continuous audio → stream_timeline_events. Gated behind ai_timeline_enabled
// (default off), so starting it is a no-op until switched on in admin.
// (default off), so starting it is a no-op until switched on in admin.
try { require('./ai/timeline-job').start(); } catch (e) { console.warn('[AI] timeline job not started:', e.message); }
try { require('./ai/timeline-job').start(); } catch (e) { console.warn('[AI] timeline job not started:', e.message); }
@@ -448,7 +448,7 @@ function _urlTag(loc, lastmod, changefreq, priority) {
@@ -448,7 +448,7 @@ function _urlTag(loc, lastmod, changefreq, priority) {
async function buildSitemap() {
async function buildSitemap() {
const urls = [];
const urls = [];
const seenChannels = new Set();
const seenChannels = new Set();
- const statics = [['/', 'daily', '1.0'], ['/vods', 'hourly', '0.8'], ['/clips', 'hourly', '0.8'], ['/pastes', 'hourly', '0.7'], ['/chat', 'daily', '0.5']];
+ const statics = [['/', 'daily', '1.0'], ['/vods', 'hourly', '0.8'], ['/clips', 'hourly', '0.8'], ['/pastes', 'hourly', '0.7'], ['/chat', 'daily', '0.5'], ['/arena', 'hourly', '0.6']];
// Rendered docs (server/docs/routes.js): /docs is the index (README.md), the rest by file name.
// Rendered docs (server/docs/routes.js): /docs is the index (README.md), the rest by file name.
try {
try {
for (const f of require('fs').readdirSync(require('path').join(__dirname, '../../docs'))) {
for (const f of require('fs').readdirSync(require('path').join(__dirname, '../../docs'))) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,123 @@
@@ -0,0 +1,123 @@
+'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
+// end-to-end on a temp DB without any AI configured (personas fall back, no LLM calls).
+
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-arena-'));
+process.env.DB_PATH = path.join(tmp, 'live.db');
+process.env.ARENA_IMAGE_PATH = path.join(tmp, 'arena');
+
+const db = require('../server/db/database');
+db.initDb();
+const arena = require('../server/arena/arena-service');
+
+// ── Pure helpers ──
+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 },
+});
+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.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.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');
+
+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 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);
+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');
+console.log('✅ crowd vote is the last round');
+
+// ── End-to-end on a temp DB (no AI configured → fallback personas, 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);
+const stream = (uid, peak, hours, daysAgo) => {
+ const id = Number(db.createStream({ user_id: uid, title: `${uid} stream`, category: 'irl', protocol: 'rtmp' }).lastInsertRowid);
+ db.run(`UPDATE streams SET is_live = 0, started_at = datetime('now', ?), ended_at = datetime('now', ?), duration_seconds = ?, peak_viewers = ? WHERE id = ?`,
+ [`-${daysAgo} days`, `-${daysAgo} days`, Math.round(hours * 3600), peak, id]);
+ db.run(`INSERT INTO stream_analytics (stream_id, avg_viewers, peak_viewers, unique_chatters, total_messages, total_watch_minutes, new_followers, clips_created, coins_earned)
+ 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);
+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]);
+
+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.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');
+
+(async () => {
+ const card = await arena.getFighter('alpha');
+ assert.strictEqual(card.user.username, 'alpha');
+ assert.strictEqual(card.rank, 1);
+ assert.ok(card.raw.hours >= 9.9 && card.raw.hours <= 10.1, `hours aggregated: ${card.raw.hours}`);
+ assert.strictEqual(card.raw.followers, 2);
+ assert.strictEqual(card.image_url, null);
+ assert.strictEqual(card.image_generation, 'off', 'no AI → no image generation');
+ const off = await arena.getFighter('idle');
+ assert.strictEqual(off.not_on_roster, true);
+ console.log('✅ fighter card');
+
+ 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.commentary_is_fallback, true);
+ assert.strictEqual(b1.votes.a + b1.votes.b, 0);
+
+ const v1 = arena.castVote(b1.id, 'user:99', 'b');
+ assert.deepStrictEqual(v1.votes, { a: 0, b: 1 });
+ const v2 = arena.castVote(b1.id, 'user:99', 'a');
+ assert.deepStrictEqual(v2.votes, { a: 1, b: 0 }, 'a voter can change sides, never double-vote');
+ arena.castVote(b1.id, 'anon:abc', 'a');
+ const after = await arena.getBattle('alpha', 'bravo', { generate: false });
+ assert.deepStrictEqual(after.votes, { a: 2, b: 0 });
+ 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');
+
+ 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 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.votes, 2);
+ console.log('✅ records, live matchups, status');
+
+ console.log('\n✅ All Arena tests passed');
+ process.exit(0);
+})().catch(err => { console.error(err); process.exit(1); });