openvibers/openvibe.live Commits

Arena: streamer-vs-streamer tab — ratings from analytics, AI personas + portraits, daily battles, crowd votes

OP OpenVibers committed 29 Aug 2026 · bfea01cd86 · 13 files, +1755 −4

New /arena tab (nav → Arena) that turns the analytics and AI data the site already
keeps about every streamer into a fighting-game roster:

- Ratings: HYPE / GRIND / CHAT / LOYALTY / CLUTCH / VIBE (40–99) as percentiles across
  everyone who streamed in the last 45 days (90-day window of streams,
  stream_analytics, follows, tips), plus an overall POWER. Deterministic, no AI.
- Personas: AI character-select bios (fighter name, title, class, element, signature
  move, special, weakness, taunt, lore, catchphrase, entrance music, a quip per
  stat) written from the streamer's AI overview, stream memories, chat-AI notes,
  VOD overviews and the numbers. Cached 24 h; stats-only fallback when AI is off.
- Portraits: optional AI character art (OpenAI-compatible /images/generations,
  gated by ai_image_enabled + the shared AI budget, cached 7 days, cost metered
  into ai_usage). Deliberately not a likeness — only the persona and an
  identity-free scene description of the latest thumbnail feed the model.
- Battles: /arena/battle/<a>/<b> — four seeded rounds per pair per day (upsets
  possible), AI ring-announcer commentary (templated fallback), and a crowd vote
  (one per user / per IP) that is the fifth round and breaks ties. W–L records
  on the leaderboard. Live streamers are paired by Power under "Live battles"
  with their live thumbnails.
- server/arena/{arena-service,routes,arena-job}.js, public/js/arena.js, styles,
  routing, sitemap, docs/arena.md, test/arena.test.js (ratings, seeded battles,
  votes, roster on a temp DB — no AI needed).

Guard rails: prompts forbid mocking appearance/body/identity/health/money and keep
everything PG-13 Arena lore; all AI text is rendered escaped; the background job
refreshes at most 6 personas + 2 portraits per 20 min, top of the ladder first.

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

Changes

ddiff --git a/AGENTS.md b/AGENTS.md
iindex 2a75db4c..c39f8861 100644
---- a/AGENTS.md
++++ b/AGENTS.md
@@@ -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)
ddiff --git a/docs/README.md b/docs/README.md
iindex bd588360..cade57c0 100644
---- a/docs/README.md
++++ b/docs/README.md
@@@ -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 management
ddiff --git a/docs/arena.md b/docs/arena.md
nnew file mode 100644
iindex 00000000..f3f673b2
---- /dev/null
++++ b/docs/arena.md
@@@ -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).
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 4ff63a63..d4fb80a6 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -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; }
++}
ddiff --git a/public/index.html b/public/index.html
iindex 1cc6333d..ea78eb58 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -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>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex b0760f0a..302c8a25 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -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}"]`);
ddiff --git a/public/js/arena.js b/public/js/arena.js
nnew file mode 100644
iindex 00000000..f1324c39
---- /dev/null
++++ b/public/js/arena.js
@@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
++function _aNum(n) { n = Number(n) || 0; return n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n); }
++function _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;
ddiff --git a/server/arena/arena-job.js b/server/arena/arena-job.js
nnew file mode 100644
iindex 00000000..19d656f7
---- /dev/null
++++ b/server/arena/arena-job.js
@@@ -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 };
ddiff --git a/server/arena/arena-service.js b/server/arena/arena-service.js
nnew file mode 100644
iindex 00000000..29d1e281
---- /dev/null
++++ b/server/arena/arena-service.js
@@@ -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,
++};
ddiff --git a/server/arena/routes.js b/server/arena/routes.js
nnew file mode 100644
iindex 00000000..14d58fbf
---- /dev/null
++++ b/server/arena/routes.js
@@@ -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;
ddiff --git a/server/index.js b/server/index.js
iindex 353c65c2..98194b68 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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); }
ddiff --git a/server/seo/seo.js b/server/seo/seo.js
iindex 31d1b4ef..e1443245 100644
---- a/server/seo/seo.js
++++ b/server/seo/seo.js
@@@ -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'))) {
ddiff --git a/test/arena.test.js b/test/arena.test.js
nnew file mode 100644
iindex 00000000..f2e43bf7
---- /dev/null
++++ b/test/arena.test.js
@@@ -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); });